diff --git a/.eslintrc.js b/.eslintrc.js index 5ed62980e..91993d504 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,14 +1,22 @@ module.exports = { plugins: [ "matrix-org", + "import", ], extends: [ "plugin:matrix-org/babel", + "plugin:import/typescript", ], env: { browser: true, node: true, }, + settings: { + "import/resolver": { + typescript: true, + node: true, + }, + }, // NOTE: These rules are frozen and new rules should not be added here. // New changes belong in https://github.com/matrix-org/eslint-plugin-matrix-org/ rules: { @@ -35,7 +43,19 @@ module.exports = { "no-console": "error", // restrict EventEmitters to force callers to use TypedEventEmitter - "no-restricted-imports": ["error", "events"], + "no-restricted-imports": ["error", { + name: "events", + message: "Please use TypedEventEmitter instead" + }], + + "import/no-restricted-paths": ["error", { + "zones": [{ + "target": "./src/", + "from": "./src/index.ts", + "message": "The package index is dynamic between src and lib depending on " + + "whether release or development, target the specific module or matrix.ts instead", + }], + }], }, overrides: [{ files: [ diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 21ebc70c2..10bda8e20 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -25,6 +25,6 @@ jobs: steps: - uses: tibdex/backport@v2 with: - labels_template: "<%= JSON.stringify(labels) %>" + labels_template: "<%= JSON.stringify([...labels, 'X-Release-Blocker']) %>" # We can't use GITHUB_TOKEN here or CI won't run on the new PR github_token: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml new file mode 100644 index 000000000..84d88ea5e --- /dev/null +++ b/.github/workflows/release-npm.yml @@ -0,0 +1,41 @@ +# Must only be called from `release#published` triggers +name: Publish to npm +on: + workflow_call: + secrets: + NPM_TOKEN: + required: true +jobs: + npm: + name: Publish to npm + runs-on: ubuntu-latest + steps: + - name: 🧮 Checkout code + uses: actions/checkout@v3 + + - name: 🔧 Yarn cache + uses: actions/setup-node@v3 + with: + cache: "yarn" + registry-url: 'https://registry.npmjs.org' + + - name: 🔨 Install dependencies + run: "yarn install --pure-lockfile" + + - name: 🚀 Publish to npm + id: npm-publish + uses: JS-DevTools/npm-publish@v1 + with: + token: ${{ secrets.NPM_TOKEN }} + access: public + tag: next + + - name: 🎖️ Add `latest` dist-tag to final releases + if: github.event.release.prerelease == false + run: | + package=$(cat package.json | jq -er .name) + npm dist-tag add "$package@$release" latest + env: + # JS-DevTools/npm-publish overrides `NODE_AUTH_TOKEN` with `INPUT_TOKEN` in .npmrc + INPUT_TOKEN: ${{ secrets.NPM_TOKEN }} + release: ${{ steps.npm-publish.outputs.version }} diff --git a/.github/workflows/jsdoc.yml b/.github/workflows/release.yml similarity index 90% rename from .github/workflows/jsdoc.yml rename to .github/workflows/release.yml index 3763c7841..5b7621c49 100644 --- a/.github/workflows/jsdoc.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,6 @@ jobs: - name: 📋 Copy to temp run: | - ls -lah tag="${{ github.ref_name }}" version="${tag#v}" echo "VERSION=$version" >> $GITHUB_ENV @@ -51,3 +50,9 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} keep_files: true publish_dir: . + + npm: + name: Publish + uses: matrix-org/matrix-js-sdk/.github/workflows/release-npm.yml@develop + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index e9d965f02..c1a7423ab 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -10,7 +10,18 @@ jobs: runs-on: ubuntu-latest if: github.event.workflow_run.conclusion == 'success' steps: + # We create the status here and then update it to success/failure in the `report` stage + # This provides an easy link to this workflow_run from the PR before Cypress is done. + - uses: Sibz/github-status-action@v1 + with: + authToken: ${{ secrets.GITHUB_TOKEN }} + state: pending + context: ${{ github.workflow }} / SonarCloud (${{ github.event.workflow_run.event }} => ${{ github.event_name }}) + sha: ${{ github.event.workflow_run.head_sha }} + target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + - name: "🩻 SonarCloud Scan" + id: sonarcloud uses: matrix-org/sonarcloud-workflow-action@v2.2 with: repository: ${{ github.event.workflow_run.head_repository.full_name }} @@ -22,3 +33,13 @@ jobs: coverage_run_id: ${{ github.event.workflow_run.id }} coverage_workflow_name: tests.yml coverage_extract_path: coverage + + + - uses: Sibz/github-status-action@v1 + if: always() + with: + authToken: ${{ secrets.GITHUB_TOKEN }} + state: ${{ steps.sonarcloud.outcome == 'success' && 'success' || 'failure' }} + context: ${{ github.workflow }} / SonarCloud (${{ github.event.workflow_run.event }} => ${{ github.event_name }}) + sha: ${{ github.event.workflow_run.head_sha }} + target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 33be6c806..44ccbf495 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -23,6 +23,16 @@ jobs: - name: Typecheck run: "yarn run lint:types" + - name: Switch js-sdk to release mode + run: | + scripts/switch_package_to_release.js + yarn install + yarn run build:compile + yarn run build:types + + - name: Typecheck (release mode) + run: "yarn run lint:types" + js_lint: name: "ESLint" runs-on: ubuntu-latest @@ -73,7 +83,7 @@ jobs: - name: Detecting files changed id: files - uses: futuratrepadeira/changed-files@v3.2.1 + uses: futuratrepadeira/changed-files@v4.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} pattern: '^.*\.tsx?$' @@ -81,7 +91,7 @@ jobs: - uses: t3chguy/typescript-check-action@main with: repo-token: ${{ secrets.GITHUB_TOKEN }} - use-check: true + use-check: false check-fail-mode: added output-behaviour: annotate ts-extra-args: '--strict' diff --git a/CHANGELOG.md b/CHANGELOG.md index 00691cac0..ce41de1aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,89 @@ +Changes in [20.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.1.0) (2022-10-11) +============================================================================================================ + +## ✨ Features + * Add local notification settings capability ([\#2700](https://github.com/matrix-org/matrix-js-sdk/pull/2700)). + * Implementation of MSC3882 login token request ([\#2687](https://github.com/matrix-org/matrix-js-sdk/pull/2687)). Contributed by @hughns. + * Typings for MSC2965 OIDC provider discovery ([\#2424](https://github.com/matrix-org/matrix-js-sdk/pull/2424)). Contributed by @hughns. + * Support to remotely toggle push notifications ([\#2686](https://github.com/matrix-org/matrix-js-sdk/pull/2686)). + * Read receipts for threads ([\#2635](https://github.com/matrix-org/matrix-js-sdk/pull/2635)). + +## 🐛 Bug Fixes + * Use the correct sender key when checking shared secret ([\#2730](https://github.com/matrix-org/matrix-js-sdk/pull/2730)). Fixes vector-im/element-web#23374. + * Unexpected ignored self key request when it's not shared history ([\#2724](https://github.com/matrix-org/matrix-js-sdk/pull/2724)). Contributed by @mcalinghee. + * Fix IDB initial migration handling causing spurious lazy loading upgrade loops ([\#2718](https://github.com/matrix-org/matrix-js-sdk/pull/2718)). Fixes vector-im/element-web#23377. + * Fix backpagination at end logic being spec non-conforming ([\#2680](https://github.com/matrix-org/matrix-js-sdk/pull/2680)). Fixes vector-im/element-web#22784. + +Changes in [20.0.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.0.2) (2022-09-30) +================================================================================================== + +## 🐛 Bug Fixes + * Fix issue in sync when crypto is not supported by client ([\#2715](https://github.com/matrix-org/matrix-js-sdk/pull/2715)). Contributed by @stas-demydiuk. + +Changes in [20.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.0.1) (2022-09-28) +================================================================================================== + +## 🐛 Bug Fixes + * Fix missing return when receiving an invitation without shared history ([\#2710](https://github.com/matrix-org/matrix-js-sdk/pull/2710)). + +Changes in [20.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.0.0) (2022-09-28) +================================================================================================== + +## 🚨 BREAKING CHANGES + * Bump IDB crypto store version ([\#2705](https://github.com/matrix-org/matrix-js-sdk/pull/2705)). + +Changes in [19.7.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.7.0) (2022-09-28) +================================================================================================== + +## 🔒 Security +* Fix for [CVE-2022-39249](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39249) +* Fix for [CVE-2022-39250](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39250) +* Fix for [CVE-2022-39251](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39251) +* Fix for [CVE-2022-39236](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39236) + +Changes in [19.6.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.6.0) (2022-09-27) +================================================================================================== + +## ✨ Features + * Add a property aggregating all names of a NamespacedValue ([\#2656](https://github.com/matrix-org/matrix-js-sdk/pull/2656)). + * Implementation of MSC3824 to add action= param on SSO login ([\#2398](https://github.com/matrix-org/matrix-js-sdk/pull/2398)). Contributed by @hughns. + * Add invited_count and joined_count to sliding sync room responses. ([\#2628](https://github.com/matrix-org/matrix-js-sdk/pull/2628)). + * Base support for MSC3847: Ignore invites with policy rooms ([\#2626](https://github.com/matrix-org/matrix-js-sdk/pull/2626)). Contributed by @Yoric. + +## 🐛 Bug Fixes + * Fix handling of remote echoes doubling up ([\#2639](https://github.com/matrix-org/matrix-js-sdk/pull/2639)). Fixes #2618. + +Changes in [19.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.5.0) (2022-09-13) +================================================================================================== + +## 🐛 Bug Fixes + * Fix bug in deepCompare which would incorrectly return objects with disjoint keys as equal ([\#2586](https://github.com/matrix-org/matrix-js-sdk/pull/2586)). Contributed by @3nprob. + * Refactor Sync and fix `initialSyncLimit` ([\#2587](https://github.com/matrix-org/matrix-js-sdk/pull/2587)). + * Use deep equality comparisons when searching for outgoing key requests by target ([\#2623](https://github.com/matrix-org/matrix-js-sdk/pull/2623)). Contributed by @duxovni. + * Fix room membership race with PREPARED event ([\#2613](https://github.com/matrix-org/matrix-js-sdk/pull/2613)). Contributed by @jotto. + +Changes in [19.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.4.0) (2022-08-31) +================================================================================================== + +## 🔒 Security +* Fix for [CVE-2022-36059](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D36059) + +Find more details at https://matrix.org/blog/2022/08/31/security-releases-matrix-js-sdk-19-4-0-and-matrix-react-sdk-3-53-0 + +## ✨ Features + * Re-emit room state events on rooms ([\#2607](https://github.com/matrix-org/matrix-js-sdk/pull/2607)). + * Add ability to override built in room name generator for an i18n'able one ([\#2609](https://github.com/matrix-org/matrix-js-sdk/pull/2609)). + * Add txn_id support to sliding sync ([\#2567](https://github.com/matrix-org/matrix-js-sdk/pull/2567)). + +## 🐛 Bug Fixes + * Refactor Sync and fix `initialSyncLimit` ([\#2587](https://github.com/matrix-org/matrix-js-sdk/pull/2587)). + * Use deep equality comparisons when searching for outgoing key requests by target ([\#2623](https://github.com/matrix-org/matrix-js-sdk/pull/2623)). Contributed by @duxovni. + * Fix room membership race with PREPARED event ([\#2613](https://github.com/matrix-org/matrix-js-sdk/pull/2613)). Contributed by @jotto. + * fixed a sliding sync bug which could cause the `roomIndexToRoomId` map to be incorrect when a new room is added in the middle of the list or when an existing room is deleted from the middle of the list. ([\#2610](https://github.com/matrix-org/matrix-js-sdk/pull/2610)). + * Fix: Handle parsing of a beacon info event without asset ([\#2591](https://github.com/matrix-org/matrix-js-sdk/pull/2591)). Fixes vector-im/element-web#23078. Contributed by @kerryarchibald. + * Fix finding event read up to if stable private read receipts is missing ([\#2585](https://github.com/matrix-org/matrix-js-sdk/pull/2585)). Fixes vector-im/element-web#23027. + * fixed a sliding sync issue where history could be interpreted as live events. ([\#2583](https://github.com/matrix-org/matrix-js-sdk/pull/2583)). + Changes in [19.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.3.0) (2022-08-16) ================================================================================================== diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7df3845e3..7405ed23f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,284 +1,5 @@ Contributing code to matrix-js-sdk ================================== -Everyone is welcome to contribute code to matrix-js-sdk, provided that they are -willing to license their contributions under the same license as the project -itself. We follow a simple 'inbound=outbound' model for contributions: the act -of submitting an 'inbound' contribution means that the contributor agrees to -license the code under the same terms as the project's overall 'outbound' -license - in this case, Apache Software License v2 (see -[LICENSE](LICENSE)). +matrix-js-sdk follows the same pattern as https://github.com/vector-im/element-web/blob/develop/CONTRIBUTING.md -How to contribute ------------------ - -The preferred and easiest way to contribute changes to the project is to fork -it on github, and then create a pull request to ask us to pull your changes -into our repo (https://help.github.com/articles/using-pull-requests/) - -We use GitHub's pull request workflow to review the contribution, and either -ask you to make any refinements needed or merge it and make them ourselves. - -Things that should go into your PR description: - * A changelog entry in the `Notes` section (see below) - * References to any bugs fixed by the change (in GitHub's `Fixes` notation) - * Describe the why and what is changing in the PR description so it's easy for - onlookers and reviewers to onboard and context switch. This information is - also helpful when we come back to look at this in 6 months and ask "why did - we do it like that?" we have a chance of finding out. - * Why didn't it work before? Why does it work now? What use cases does it - unlock? - * If you find yourself adding information on how the code works or why you - chose to do it the way you did, make sure this information is instead - written as comments in the code itself. - * Sometimes a PR can change considerably as it is developed. In this case, - the description should be updated to reflect the most recent state of - the PR. (It can be helpful to retain the old content under a suitable - heading, for additional context.) - * Include both **before** and **after** screenshots to easily compare and discuss - what's changing. - * Include a step-by-step testing strategy so that a reviewer can check out the - code locally and easily get to the point of testing your change. - * Add comments to the diff for the reviewer that might help them to understand - why the change is necessary or how they might better understand and review it. - -We rely on information in pull request to populate the information that goes -into the changelogs our users see, both for the JS SDK itself and also for some -projects based on it. This is picked up from both labels on the pull request and -the `Notes:` annotation in the description. By default, the PR title will be -used for the changelog entry, but you can specify more options, as follows. - -To add a longer, more detailed description of the change for the changelog: - - -*Fix llama herding bug* - -``` -Notes: Fix a bug (https://github.com/matrix-org/notaproject/issues/123) where the 'Herd' button would not herd more than 8 Llamas if the moon was in the waxing gibbous phase -``` - -For some PRs, it's not useful to have an entry in the user-facing changelog (this is -the default for PRs labelled with `T-Task`): - -*Remove outdated comment from `Ungulates.ts`* -``` -Notes: none -``` - -Sometimes, you're fixing a bug in a downstream project, in which case you want -an entry in that project's changelog. You can do that too: - -*Fix another herding bug* -``` -Notes: Fix a bug where the `herd()` function would only work on Tuesdays -element-web notes: Fix a bug where the 'Herd' button only worked on Tuesdays -``` - -This example is for Element Web. You can specify: - * matrix-react-sdk - * element-web - * element-desktop - -If your PR introduces a breaking change, use the `Notes` section in the same -way, additionally adding the `X-Breaking-Change` label (see below). There's no need -to specify in the notes that it's a breaking change - this will be added -automatically based on the label - but remember to tell the developer how to -migrate: - -*Remove legacy class* - -``` -Notes: Remove legacy `Camelopard` class. `Giraffe` should be used instead. -``` - -Other metadata can be added using labels. - * `X-Breaking-Change`: A breaking change - adding this label will mean the change causes a *major* version bump. - * `T-Enhancement`: A new feature - adding this label will mean the change causes a *minor* version bump. - * `T-Defect`: A bug fix (in either code or docs). - * `T-Task`: No user-facing changes, eg. code comments, CI fixes, refactors or tests. Won't have a changelog entry unless you specify one. - -If you don't have permission to add labels, your PR reviewer(s) can work with you -to add them: ask in the PR description or comments. - -We use continuous integration, and all pull requests get automatically tested: -if your change breaks the build, then the PR will show that there are failed -checks, so please check back after a few minutes. - -Tests ------ -Your PR should include tests. - -For new user facing features in `matrix-react-sdk` or `element-web`, you -must include: - -1. Comprehensive unit tests written in Jest. These are located in `/test`. -2. "happy path" end-to-end tests. - These are located in `/test/end-to-end-tests` in `matrix-react-sdk`, and - are run using `element-web`. Ideally, you would also include tests for edge - and error cases. - -Unit tests are expected even when the feature is in labs. It's good practice -to write tests alongside the code as it ensures the code is testable from -the start, and gives you a fast feedback loop while you're developing the -functionality. End-to-end tests should be added prior to the feature -leaving labs, but don't have to be present from the start (although it might -be beneficial to have some running early, so you can test things faster). - -For bugs in those repos, your change must include at least one unit test or -end-to-end test; which is best depends on what sort of test most concisely -exercises the area. - -Changes to `matrix-js-sdk` must be accompanied by unit tests written in Jest. -These are located in `/spec/`. - -When writing unit tests, please aim for a high level of test coverage -for new code - 80% or greater. If you cannot achieve that, please document -why it's not possible in your PR. - -Some sections of code are not sensible to add coverage for, such as those -which explicitly inhibit noisy logging for tests. Which can be hidden using -an istanbul magic comment as [documented here][1]. See example: -```javascript -/* istanbul ignore if */ -if (process.env.NODE_ENV !== "test") { - logger.error("Log line that is noisy enough in tests to want to skip"); -} -``` - -Tests validate that your change works as intended and also document -concisely what is being changed. Ideally, your new tests fail -prior to your change, and succeed once it has been applied. You may -find this simpler to achieve if you write the tests first. - -If you're spiking some code that's experimental and not being used to support -production features, exceptions can be made to requirements for tests. -Note that tests will still be required in order to ship the feature, and it's -strongly encouraged to think about tests early in the process, as adding -tests later will become progressively more difficult. - -If you're not sure how to approach writing tests for your change, ask for help -in [#element-dev](https://matrix.to/#/#element-dev:matrix.org). - -Code style ----------- -The js-sdk aims to target TypeScript/ES6. All new files should be written in -TypeScript and existing files should use ES6 principles where possible. - -Members should not be exported as a default export in general - it causes problems -with the architecture of the SDK (index file becomes less clear) and could -introduce naming problems (as default exports get aliased upon import). In -general, avoid using `export default`. - -The remaining code-style for matrix-js-sdk is not formally documented, but -contributors are encouraged to read the -[code style document for matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md) -and follow the principles set out there. - -Please ensure your changes match the cosmetic style of the existing project, -and ***never*** mix cosmetic and functional changes in the same commit, as it -makes it horribly hard to review otherwise. - -Attribution ------------ -Everyone who contributes anything to Matrix is welcome to be listed in the -AUTHORS.rst file for the project in question. Please feel free to include a -change to AUTHORS.rst in your pull request to list yourself and a short -description of the area(s) you've worked on. Also, we sometimes have swag to -give away to contributors - if you feel that Matrix-branded apparel is missing -from your life, please mail us your shipping address to matrix at matrix.org -and we'll try to fix it :) - -Sign off --------- -In order to have a concrete record that your contribution is intentional -and you agree to license it under the same terms as the project's license, we've -adopted the same lightweight approach that the Linux Kernel -(https://www.kernel.org/doc/Documentation/SubmittingPatches), Docker -(https://github.com/docker/docker/blob/master/CONTRIBUTING.md), and many other -projects use: the DCO (Developer Certificate of Origin: -http://developercertificate.org/). This is a simple declaration that you wrote -the contribution or otherwise have the right to contribute it to Matrix: - -``` -Developer Certificate of Origin -Version 1.1 - -Copyright (C) 2004, 2006 The Linux Foundation and its contributors. -660 York Street, Suite 102, -San Francisco, CA 94110 USA - -Everyone is permitted to copy and distribute verbatim copies of this -license document, but changing it is not allowed. - -Developer's Certificate of Origin 1.1 - -By making a contribution to this project, I certify that: - -(a) The contribution was created in whole or in part by me and I - have the right to submit it under the open source license - indicated in the file; or - -(b) The contribution is based upon previous work that, to the best - of my knowledge, is covered under an appropriate open source - license and I have the right under that license to submit that - work with modifications, whether created in whole or in part - by me, under the same open source license (unless I am - permitted to submit under a different license), as indicated - in the file; or - -(c) The contribution was provided directly to me by some other - person who certified (a), (b) or (c) and I have not modified - it. - -(d) I understand and agree that this project and the contribution - are public and that a record of the contribution (including all - personal information I submit with it, including my sign-off) is - maintained indefinitely and may be redistributed consistent with - this project or the open source license(s) involved. -``` - -If you agree to this for your contribution, then all that's needed is to -include the line in your commit or pull request comment: - -``` -Signed-off-by: Your Name -``` - -We accept contributions under a legally identifiable name, such as your name on -government documentation or common-law names (names claimed by legitimate usage -or repute). Unfortunately, we cannot accept anonymous contributions at this -time. - -Git allows you to add this signoff automatically when using the `-s` flag to -`git commit`, which uses the name and email set in your `user.name` and -`user.email` git configs. - -If you forgot to sign off your commits before making your pull request and are -on Git 2.17+ you can mass signoff using rebase: - -``` -git rebase --signoff origin/develop -``` - -Review expectations -=================== - -See https://github.com/vector-im/element-meta/wiki/Review-process - - -Merge Strategy -============== - -The preferred method for merging pull requests is squash merging to keep the -commit history trim, but it is up to the discretion of the team member merging -the change. We do not support rebase merges due to `allchange` being unable to -handle them. When merging make sure to leave the default commit title, or -at least leave the PR number at the end in brackets like by default. -When stacking pull requests, you may wish to do the following: - -1. Branch from develop to your branch (branch1), push commits onto it and open a pull request -2. Branch from your base branch (branch1) to your work branch (branch2), push commits and open a pull request configuring the base to be branch1, saying in the description that it is based on your other PR. -3. Merge the first PR using a merge commit otherwise your stacked PR will need a rebase. Github will automatically adjust the base branch of your other PR to be develop. - - -[1]: https://github.com/gotwarlost/istanbul/blob/master/ignoring-code-for-coverage.md diff --git a/package.json b/package.json index b991e763c..fda529c91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "19.3.0", + "version": "20.1.0", "description": "Matrix Client-Server SDK for Javascript", "engines": { "node": ">=12.9.0" @@ -78,28 +78,30 @@ "@babel/preset-env": "^7.12.11", "@babel/preset-typescript": "^7.12.7", "@babel/register": "^7.12.10", - "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz", + "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.13.tgz", "@types/bs58": "^4.0.1", "@types/content-type": "^1.1.5", - "@types/jest": "^28.0.0", + "@types/jest": "^29.0.0", "@types/node": "16", "@types/request": "^2.48.5", "@typescript-eslint/eslint-plugin": "^5.6.0", "@typescript-eslint/parser": "^5.6.0", "allchange": "^1.0.6", - "babel-jest": "^28.0.0", + "babel-jest": "^29.0.0", "babelify": "^10.0.0", "better-docs": "^2.4.0-beta.9", "browserify": "^17.0.0", "docdash": "^1.2.0", - "eslint": "8.22.0", + "eslint": "8.24.0", "eslint-config-google": "^0.14.0", - "eslint-plugin-import": "^2.25.4", + "eslint-import-resolver-typescript": "^3.5.1", + "eslint-plugin-import": "^2.26.0", "eslint-plugin-matrix-org": "^0.6.0", "exorcist": "^2.0.0", "fake-indexeddb": "^4.0.0", - "jest": "^28.0.0", + "jest": "^29.0.0", "jest-localstorage-mock": "^2.4.6", + "jest-mock": "^27.5.1", "jest-sonar-reporter": "^2.0.0", "jsdoc": "^3.6.6", "matrix-mock-request": "^2.1.2", diff --git a/post-release.sh b/post-release.sh new file mode 100755 index 000000000..4e93f668a --- /dev/null +++ b/post-release.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# +# Script to perform a post-release steps of matrix-js-sdk. +# +# Requires: +# jq; install from your distribution's package manager (https://stedolan.github.io/jq/) + +set -e + +jq --version > /dev/null || (echo "jq is required: please install it"; kill $$) + +if [ "$(git branch -lr | grep origin/develop -c)" -ge 1 ]; then + # When merging to develop, we need revert the `main` and `typings` fields if we adjusted them previously. + for i in main typings + do + # If a `lib` prefixed value is present, it means we adjusted the field + # earlier at publish time, so we should revert it now. + if [ "$(jq -r ".matrix_lib_$i" package.json)" != "null" ]; then + # If there's a `src` prefixed value, use that, otherwise delete. + # This is used to delete the `typings` field and reset `main` back + # to the TypeScript source. + src_value=$(jq -r ".matrix_src_$i" package.json) + if [ "$src_value" != "null" ]; then + jq ".$i = .matrix_src_$i" package.json > package.json.new && mv package.json.new package.json + else + jq "del(.$i)" package.json > package.json.new && mv package.json.new package.json + fi + fi + done + + if [ -n "$(git ls-files --modified package.json)" ]; then + echo "Committing develop package.json" + git commit package.json -m "Resetting package fields for development" + fi + + git push origin develop +fi diff --git a/release.sh b/release.sh index 63f0765c1..92a593e18 100755 --- a/release.sh +++ b/release.sh @@ -3,19 +3,16 @@ # Script to perform a release of matrix-js-sdk and downstream projects. # # Requires: -# github-changelog-generator; install via: -# pip install git+https://github.com/matrix-org/github-changelog-generator.git # jq; install from your distribution's package manager (https://stedolan.github.io/jq/) # hub; install via brew (macOS) or source/pre-compiled binaries (debian) (https://github.com/github/hub) - Tested on v2.2.9 -# npm; typically installed by Node.js # yarn; install via brew (macOS) or similar (https://yarnpkg.com/docs/install/) # -# Note: this script is also used to release matrix-react-sdk and element-web. +# Note: this script is also used to release matrix-react-sdk, element-web, and element-desktop. set -e jq --version > /dev/null || (echo "jq is required: please install it"; kill $$) -if [[ `command -v hub` ]] && [[ `hub --version` =~ hub[[:space:]]version[[:space:]]([0-9]*).([0-9]*) ]]; then +if [[ $(command -v hub) ]] && [[ $(hub --version) =~ hub[[:space:]]version[[:space:]]([0-9]*).([0-9]*) ]]; then HUB_VERSION_MAJOR=${BASH_REMATCH[1]} HUB_VERSION_MINOR=${BASH_REMATCH[2]} if [[ $HUB_VERSION_MAJOR -lt 2 ]] || [[ $HUB_VERSION_MAJOR -eq 2 && $HUB_VERSION_MINOR -lt 5 ]]; then @@ -26,7 +23,6 @@ else echo "hub is required: please install it" exit fi -npm --version > /dev/null || (echo "npm is required: please install it"; kill $$) yarn --version > /dev/null || (echo "yarn is required: please install it"; kill $$) USAGE="$0 [-x] [-c changelog_file] vX.Y.Z" @@ -37,17 +33,9 @@ $USAGE -c changelog_file: specify name of file containing changelog -x: skip updating the changelog - -n: skip publish to NPM EOF } -ret=0 -cat package.json | jq '.dependencies[]' | grep -q '#develop' || ret=$? -if [ "$ret" -eq 0 ]; then - echo "package.json contains develop dependencies. Refusing to release." - exit -fi - if ! git diff-index --quiet --cached HEAD; then echo "this git checkout has staged (uncommitted) changes. Refusing to release." exit @@ -59,10 +47,8 @@ if ! git diff-files --quiet; then fi skip_changelog= -skip_npm= changelog_file="CHANGELOG.md" -expected_npm_user="matrixdotorg" -while getopts hc:u:xzn f; do +while getopts hc:x f; do case $f in h) help @@ -74,21 +60,70 @@ while getopts hc:u:xzn f; do x) skip_changelog=1 ;; - n) - skip_npm=1 - ;; - u) - expected_npm_user="$OPTARG" - ;; esac done -shift `expr $OPTIND - 1` +shift $(expr $OPTIND - 1) if [ $# -ne 1 ]; then echo "Usage: $USAGE" >&2 exit 1 fi +function check_dependency { + local depver=$(cat package.json | jq -r .dependencies[\"$1\"]) + if [ "$depver" == "null" ]; then return 0; fi + + echo "Checking version of $1..." + local latestver=$(yarn info -s "$1" dist-tags.next) + if [ "$depver" != "$latestver" ] + then + echo "The latest version of $1 is $latestver but package.json depends on $depver." + echo -n "Type 'u' to auto-upgrade, 'c' to continue anyway, or 'a' to abort:" + read resp + if [ "$resp" != "u" ] && [ "$resp" != "c" ] + then + echo "Aborting." + exit 1 + fi + if [ "$resp" == "u" ] + then + echo "Upgrading $1 to $latestver..." + yarn add -E "$1@$latestver" + git add -u + git commit -m "Upgrade $1 to $latestver" + fi + fi +} + +function reset_dependency { + local depver=$(cat package.json | jq -r .dependencies[\"$1\"]) + if [ "$depver" == "null" ]; then return 0; fi + + echo "Resetting $1 to develop branch..." + yarn add "github:matrix-org/$1#develop" + git add -u + git commit -m "Reset $1 back to develop branch" +} + +has_subprojects=0 +if [ -f release_config.yaml ]; then + subprojects=$(cat release_config.yaml | python -c "import yaml; import sys; print(' '.join(list(yaml.load(sys.stdin)['subprojects'].keys())))" 2> /dev/null) + if [ "$?" -eq 0 ]; then + has_subprojects=1 + echo "Checking subprojects for upgrades" + for proj in $subprojects; do + check_dependency "$proj" + done + fi +fi + +ret=0 +cat package.json | jq '.dependencies[]' | grep -q '#develop' || ret=$? +if [ "$ret" -eq 0 ]; then + echo "package.json contains develop dependencies. Refusing to release." + exit +fi + # We use Git branch / commit dependencies for some packages, and Yarn seems # to have a hard time getting that right. See also # https://github.com/yarnpkg/yarn/issues/4734. As a workaround, we clean the @@ -97,20 +132,9 @@ yarn cache clean # Ensure all dependencies are updated yarn install --ignore-scripts --pure-lockfile -# Login and publish continues to use `npm`, as it seems to have more clearly -# defined options and semantics than `yarn` for writing to the registry. -if [ -z "$skip_npm" ]; then - actual_npm_user=`npm whoami`; - if [ $expected_npm_user != $actual_npm_user ]; then - echo "you need to be logged into npm as $expected_npm_user, but you are logged in as $actual_npm_user" >&2 - exit 1 - fi -fi - # ignore leading v on release release="${1#v}" tag="v${release}" -rel_branch="release-$tag" prerelease=0 # We check if this build is a prerelease by looking to @@ -125,18 +149,7 @@ else read -p "Making a FINAL RELEASE, press enter to continue " REPLY fi -# We might already be on the release branch, in which case, yay -# If we're on any branch starting with 'release', or the staging branch -# we don't create a separate release branch (this allows us to use the same -# release branch for releases and release candidates). -curbranch=$(git symbolic-ref --short HEAD) -if [[ "$curbranch" != release* && "$curbranch" != "staging" ]]; then - echo "Creating release branch" - git checkout -b "$rel_branch" -else - echo "Using current branch ($curbranch) for release" - rel_branch=$curbranch -fi +rel_branch=$(git symbolic-ref --short HEAD) if [ -z "$skip_changelog" ]; then echo "Generating changelog" @@ -148,8 +161,8 @@ if [ -z "$skip_changelog" ]; then git commit "$changelog_file" -m "Prepare changelog for $tag" fi fi -latest_changes=`mktemp` -cat "${changelog_file}" | `dirname $0`/scripts/changelog_head.py > "${latest_changes}" +latest_changes=$(mktemp) +cat "${changelog_file}" | "$(dirname "$0")/scripts/changelog_head.py" > "${latest_changes}" set -x @@ -176,7 +189,7 @@ do done # commit yarn.lock if it exists, is versioned, and is modified -if [[ -f yarn.lock && `git status --porcelain yarn.lock | grep '^ M'` ]]; +if [[ -f yarn.lock && $(git status --porcelain yarn.lock | grep '^ M') ]]; then pkglock='yarn.lock' else @@ -188,7 +201,7 @@ git commit package.json $pkglock -m "$tag" # figure out if we should be signing this release signing_id= if [ -f release_config.yaml ]; then - result=`cat release_config.yaml | python -c "import yaml; import sys; print yaml.load(sys.stdin)['signing_id']" 2> /dev/null || true` + result=$(cat release_config.yaml | python -c "import yaml; import sys; print(yaml.load(sys.stdin)['signing_id'])" 2> /dev/null || true) if [ "$?" -eq 0 ]; then signing_id=$result fi @@ -206,8 +219,8 @@ assets='' dodist=0 jq -e .scripts.dist package.json 2> /dev/null || dodist=$? if [ $dodist -eq 0 ]; then - projdir=`pwd` - builddir=`mktemp -d 2>/dev/null || mktemp -d -t 'mytmpdir'` + projdir=$(pwd) + builddir=$(mktemp -d 2>/dev/null || mktemp -d -t 'mytmpdir') echo "Building distribution copy in $builddir" pushd "$builddir" git clone "$projdir" . @@ -232,7 +245,7 @@ fi if [ -n "$signing_id" ]; then # make a signed tag # gnupg seems to fail to get the right tty device unless we set it here - GIT_COMMITTER_EMAIL="$signing_id" GPG_TTY=`tty` git tag -u "$signing_id" -F "${latest_changes}" "$tag" + GIT_COMMITTER_EMAIL="$signing_id" GPG_TTY=$(tty) git tag -u "$signing_id" -F "${latest_changes}" "$tag" else git tag -a -F "${latest_changes}" "$tag" fi @@ -298,7 +311,7 @@ if [ $prerelease -eq 1 ]; then hubflags='-p' fi -release_text=`mktemp` +release_text=$(mktemp) echo "$tag" > "${release_text}" echo >> "${release_text}" cat "${latest_changes}" >> "${release_text}" @@ -310,19 +323,6 @@ fi rm "${release_text}" rm "${latest_changes}" -# Login and publish continues to use `npm`, as it seems to have more clearly -# defined options and semantics than `yarn` for writing to the registry. -# Tag both releases and prereleases as `next` so the last stable release remains -# the default. -if [ -z "$skip_npm" ]; then - npm publish --tag next - if [ $prerelease -eq 0 ]; then - # For a release, also add the default `latest` tag. - package=$(cat package.json | jq -er .name) - npm dist-tag add "$package@$release" latest - fi -fi - # if it is a pre-release, leave it on the release branch for now. if [ $prerelease -eq 1 ]; then git checkout "$rel_branch" @@ -339,34 +339,19 @@ git merge "$rel_branch" --no-edit git push origin master # finally, merge master back onto develop (if it exists) -if [ $(git branch -lr | grep origin/develop -c) -ge 1 ]; then +if [ "$(git branch -lr | grep origin/develop -c)" -ge 1 ]; then git checkout develop git pull git merge master --no-edit - - # When merging to develop, we need revert the `main` and `typings` fields if - # we adjusted them previously. - for i in main typings - do - # If a `lib` prefixed value is present, it means we adjusted the field - # earlier at publish time, so we should revert it now. - if [ "$(jq -r ".matrix_lib_$i" package.json)" != "null" ]; then - # If there's a `src` prefixed value, use that, otherwise delete. - # This is used to delete the `typings` field and reset `main` back - # to the TypeScript source. - src_value=$(jq -r ".matrix_src_$i" package.json) - if [ "$src_value" != "null" ]; then - jq ".$i = .matrix_src_$i" package.json > package.json.new && mv package.json.new package.json - else - jq "del(.$i)" package.json > package.json.new && mv package.json.new package.json - fi - fi - done - - if [ -n "$(git ls-files --modified package.json)" ]; then - echo "Committing develop package.json" - git commit package.json -m "Resetting package fields for development" - fi - + git push origin develop +fi + +[ -x ./post-release.sh ] && ./post-release.sh + +if [ $has_subprojects -eq 1 ] && [ $prerelease -eq 0 ]; then + echo "Resetting subprojects to develop" + for proj in $subprojects; do + reset_dependency "$proj" + done git push origin develop fi diff --git a/scripts/switch_package_to_release.js b/scripts/switch_package_to_release.js new file mode 100755 index 000000000..830c92dc4 --- /dev/null +++ b/scripts/switch_package_to_release.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +const fsProm = require('fs/promises'); + +const PKGJSON = 'package.json'; + +async function main() { + const pkgJson = JSON.parse(await fsProm.readFile(PKGJSON, 'utf8')); + for (const field of ['main', 'typings']) { + if (pkgJson["matrix_lib_"+field] !== undefined) { + pkgJson[field] = pkgJson["matrix_lib_"+field]; + } + } + await fsProm.writeFile(PKGJSON, JSON.stringify(pkgJson, null, 2)); +} + +main(); diff --git a/spec/MockStorageApi.js b/spec/MockStorageApi.ts similarity index 65% rename from spec/MockStorageApi.js rename to spec/MockStorageApi.ts index d985fe0cf..da002ed66 100644 --- a/spec/MockStorageApi.js +++ b/spec/MockStorageApi.ts @@ -1,6 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,31 +17,32 @@ limitations under the License. /** * A mock implementation of the webstorage api - * @constructor */ -export function MockStorageApi() { - this.data = {}; - this.keys = []; - this.length = 0; -} +export class MockStorageApi { + public data: Record = {}; + public keys: string[] = []; + public length = 0; -MockStorageApi.prototype = { - setItem: function(k, v) { + public setItem(k: string, v: string): void { this.data[k] = v; - this._recalc(); - }, - getItem: function(k) { + this.recalc(); + } + + public getItem(k: string): string | null { return this.data[k] || null; - }, - removeItem: function(k) { + } + + public removeItem(k: string): void { delete this.data[k]; - this._recalc(); - }, - key: function(index) { + this.recalc(); + } + + public key(index: number): string { return this.keys[index]; - }, - _recalc: function() { - const keys = []; + } + + private recalc(): void { + const keys: string[] = []; for (const k in this.data) { if (!this.data.hasOwnProperty(k)) { continue; @@ -50,6 +51,5 @@ MockStorageApi.prototype = { } this.keys = keys; this.length = keys.length; - }, -}; - + } +} diff --git a/spec/TestClient.ts b/spec/TestClient.ts index a1e5b9111..0a6c4e0ee 100644 --- a/spec/TestClient.ts +++ b/spec/TestClient.ts @@ -50,7 +50,7 @@ export class TestClient { options?: Partial, ) { if (sessionStoreBackend === undefined) { - sessionStoreBackend = new MockStorageApi(); + sessionStoreBackend = new MockStorageApi() as unknown as Storage; } this.httpBackend = new MockHttpBackend(); diff --git a/spec/browserify/setupTests.js b/spec/browserify/setupTests.ts similarity index 95% rename from spec/browserify/setupTests.js rename to spec/browserify/setupTests.ts index 16120f78a..833d8591c 100644 --- a/spec/browserify/setupTests.js +++ b/spec/browserify/setupTests.ts @@ -15,9 +15,11 @@ limitations under the License. */ // stub for browser-matrix browserify tests +// @ts-ignore global.XMLHttpRequest = jest.fn(); afterAll(() => { // clean up XMLHttpRequest mock + // @ts-ignore global.XMLHttpRequest = undefined; }); diff --git a/spec/browserify/sync-browserify.spec.js b/spec/browserify/sync-browserify.spec.ts similarity index 100% rename from spec/browserify/sync-browserify.spec.js rename to spec/browserify/sync-browserify.spec.ts diff --git a/spec/integ/devicelist-integ.spec.js b/spec/integ/devicelist-integ.spec.ts similarity index 95% rename from spec/integ/devicelist-integ.spec.js rename to spec/integ/devicelist-integ.spec.ts index 8be2ca59a..acd8f9c80 100644 --- a/spec/integ/devicelist-integ.spec.js +++ b/spec/integ/devicelist-integ.spec.ts @@ -122,7 +122,7 @@ describe("DeviceList management:", function() { aliceTestClient.httpBackend.when( 'PUT', '/send/', ).respond(200, { - event_id: '$event_id', + event_id: '$event_id', }); return Promise.all([ @@ -290,8 +290,9 @@ describe("DeviceList management:", function() { aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; + // Alice should be tracking bob's device list expect(bobStat).toBeGreaterThan( - 0, "Alice should be tracking bob's device list", + 0, ); }); }); @@ -326,8 +327,9 @@ describe("DeviceList management:", function() { aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; + // Alice should have marked bob's device list as untracked expect(bobStat).toEqual( - 0, "Alice should have marked bob's device list as untracked", + 0, ); }); }); @@ -362,8 +364,9 @@ describe("DeviceList management:", function() { aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; + // Alice should have marked bob's device list as untracked expect(bobStat).toEqual( - 0, "Alice should have marked bob's device list as untracked", + 0, ); }); }); @@ -378,13 +381,15 @@ describe("DeviceList management:", function() { anotherTestClient.httpBackend.when('GET', '/sync').respond( 200, getSyncResponse([])); await anotherTestClient.flushSync(); - await anotherTestClient.client.crypto.deviceList.saveIfDirty(); + await anotherTestClient.client?.crypto?.deviceList?.saveIfDirty(); + // @ts-ignore accessing private property anotherTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { - const bobStat = data.trackingStatus['@bob:xyz']; + const bobStat = data!.trackingStatus['@bob:xyz']; + // Alice should have marked bob's device list as untracked expect(bobStat).toEqual( - 0, "Alice should have marked bob's device list as untracked", + 0, ); }); } finally { diff --git a/spec/integ/matrix-client-crypto.spec.ts b/spec/integ/matrix-client-crypto.spec.ts index fc0e30dd9..f86394979 100644 --- a/spec/integ/matrix-client-crypto.spec.ts +++ b/spec/integ/matrix-client-crypto.spec.ts @@ -31,8 +31,9 @@ import '../olm-loader'; import { logger } from '../../src/logger'; import * as testUtils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; -import { CRYPTO_ENABLED } from "../../src/client"; +import { CRYPTO_ENABLED, IUploadKeysRequest } from "../../src/client"; import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../src/matrix"; +import { DeviceInfo } from '../../src/crypto/deviceinfo'; let aliTestClient: TestClient; const roomId = "!room:localhost"; @@ -71,12 +72,12 @@ function expectQueryKeys(querier: TestClient, uploader: TestClient): Promise { const keys = await bobTestClient.awaitOneTimeKeyUpload(); aliTestClient.httpBackend.when( "POST", "/keys/claim", - ).respond(200, function(_path, content) { - const claimType = content.one_time_keys[bobUserId][bobDeviceId]; + ).respond(200, function(_path, content: IUploadKeysRequest) { + const claimType = content.one_time_keys![bobUserId][bobDeviceId]; expect(claimType).toEqual("signed_curve25519"); - let keyId = null; + let keyId = ''; for (keyId in keys) { if (bobTestClient.oneTimeKeys.hasOwnProperty(keyId)) { if (keyId.indexOf(claimType + ":") === 0) { @@ -132,13 +133,13 @@ async function aliDownloadsKeys(): Promise { // check that the localStorage is updated as we expect (not sure this is // an integration test, but meh) await Promise.all([p1(), p2()]); - await aliTestClient.client.crypto.deviceList.saveIfDirty(); + await aliTestClient.client.crypto!.deviceList.saveIfDirty(); // @ts-ignore - protected aliTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { - const devices = data.devices[bobUserId]; + const devices = data!.devices[bobUserId]!; expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys.keys); expect(devices[bobDeviceId].verified). - toBe(0); // DeviceVerification.UNVERIFIED + toBe(DeviceInfo.DeviceVerification.UNVERIFIED); }); } @@ -237,7 +238,7 @@ function sendMessage(client: MatrixClient): Promise { async function expectSendMessageRequest(httpBackend: TestClient["httpBackend"]): Promise { const path = "/send/m.room.encrypted/"; - const prom = new Promise((resolve) => { + const prom = new Promise((resolve) => { httpBackend.when("PUT", path).respond(200, function(_path, content) { resolve(content); return { @@ -252,14 +253,14 @@ async function expectSendMessageRequest(httpBackend: TestClient["httpBackend"]): } function aliRecvMessage(): Promise { - const message = bobMessages.shift(); + const message = bobMessages.shift()!; return recvMessage( aliTestClient.httpBackend, aliTestClient.client, bobUserId, message, ); } function bobRecvMessage(): Promise { - const message = aliMessages.shift(); + const message = aliMessages.shift()!; return recvMessage( bobTestClient.httpBackend, bobTestClient.client, aliUserId, message, ); @@ -494,6 +495,7 @@ describe("MatrixClient crypto", () => { aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} }); await aliTestClient.start(); await bobTestClient.start(); + bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); await firstSync(aliTestClient); await aliEnablesEncryption(); await aliSendsFirstMessage(); @@ -504,10 +506,11 @@ describe("MatrixClient crypto", () => { aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} }); await aliTestClient.start(); await bobTestClient.start(); + bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); await firstSync(aliTestClient); await aliEnablesEncryption(); await aliSendsFirstMessage(); - const message = aliMessages.shift(); + const message = aliMessages.shift()!; const syncData = { next_batch: "x", rooms: { @@ -567,6 +570,7 @@ describe("MatrixClient crypto", () => { aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} }); await aliTestClient.start(); await bobTestClient.start(); + bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); await firstSync(aliTestClient); await aliEnablesEncryption(); await aliSendsFirstMessage(); @@ -584,6 +588,9 @@ describe("MatrixClient crypto", () => { await firstSync(bobTestClient); await aliEnablesEncryption(); await aliSendsFirstMessage(); + bobTestClient.httpBackend.when('POST', '/keys/query').respond( + 200, {}, + ); await bobRecvMessage(); await bobEnablesEncryption(); const ciphertext = await bobSendsReplyMessage(); @@ -658,11 +665,10 @@ describe("MatrixClient crypto", () => { ]); logger.log(aliTestClient + ': started'); httpBackend.when("POST", "/keys/upload") - .respond(200, (_path, content) => { + .respond(200, (_path, content: IUploadKeysRequest) => { expect(content.one_time_keys).toBeTruthy(); expect(content.one_time_keys).not.toEqual({}); - expect(Object.keys(content.one_time_keys).length).toBeGreaterThanOrEqual(1); - logger.log('received %i one-time keys', Object.keys(content.one_time_keys).length); + expect(Object.keys(content.one_time_keys!).length).toBeGreaterThanOrEqual(1); // cancel futher calls by telling the client // we have more than we need return { diff --git a/spec/integ/matrix-client-event-emitter.spec.js b/spec/integ/matrix-client-event-emitter.spec.ts similarity index 51% rename from spec/integ/matrix-client-event-emitter.spec.js rename to spec/integ/matrix-client-event-emitter.spec.ts index bb3c873b3..1ad244b54 100644 --- a/spec/integ/matrix-client-event-emitter.spec.js +++ b/spec/integ/matrix-client-event-emitter.spec.ts @@ -1,25 +1,59 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import HttpBackend from "matrix-mock-request"; + +import { + ClientEvent, + HttpApiEvent, + IEvent, + MatrixClient, + RoomEvent, + RoomMemberEvent, + RoomStateEvent, + UserEvent, +} from "../../src"; import * as utils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; describe("MatrixClient events", function() { - let client; - let httpBackend; const selfUserId = "@alice:localhost"; const selfAccessToken = "aseukfgwef"; + let client: MatrixClient | undefined; + let httpBackend: HttpBackend | undefined; + + const setupTests = (): [MatrixClient, HttpBackend] => { + const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken); + const client = testClient.client; + const httpBackend = testClient.httpBackend; + httpBackend!.when("GET", "/versions").respond(200, {}); + httpBackend!.when("GET", "/pushrules").respond(200, {}); + httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + + return [client!, httpBackend]; + }; beforeEach(function() { - const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken); - client = testClient.client; - httpBackend = testClient.httpBackend; - httpBackend.when("GET", "/versions").respond(200, {}); - httpBackend.when("GET", "/pushrules").respond(200, {}); - httpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + [client!, httpBackend] = setupTests(); }); afterEach(function() { - httpBackend.verifyNoOutstandingExpectation(); - client.stopClient(); - return httpBackend.stop(); + httpBackend?.verifyNoOutstandingExpectation(); + client?.stopClient(); + return httpBackend?.stop(); }); describe("emissions", function() { @@ -92,53 +126,49 @@ describe("MatrixClient events", function() { }; it("should emit events from both the first and subsequent /sync calls", - function() { - httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); - httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + function() { + httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); - let expectedEvents = []; - expectedEvents = expectedEvents.concat( - SYNC_DATA.presence.events, - SYNC_DATA.rooms.join["!erufh:bar"].timeline.events, - SYNC_DATA.rooms.join["!erufh:bar"].state.events, - NEXT_SYNC_DATA.rooms.join["!erufh:bar"].timeline.events, - NEXT_SYNC_DATA.rooms.join["!erufh:bar"].ephemeral.events, - ); + let expectedEvents: Partial[] = []; + expectedEvents = expectedEvents.concat( + SYNC_DATA.presence.events, + SYNC_DATA.rooms.join["!erufh:bar"].timeline.events, + SYNC_DATA.rooms.join["!erufh:bar"].state.events, + NEXT_SYNC_DATA.rooms.join["!erufh:bar"].timeline.events, + NEXT_SYNC_DATA.rooms.join["!erufh:bar"].ephemeral.events, + ); - client.on("event", function(event) { - let found = false; - for (let i = 0; i < expectedEvents.length; i++) { - if (expectedEvents[i].event_id === event.getId()) { - expectedEvents.splice(i, 1); - found = true; - break; + client!.on(ClientEvent.Event, function(event) { + let found = false; + for (let i = 0; i < expectedEvents.length; i++) { + if (expectedEvents[i].event_id === event.getId()) { + expectedEvents.splice(i, 1); + found = true; + break; + } } - } - expect(found).toBe( - true, "Unexpected 'event' emitted: " + event.getType(), - ); - }); + expect(found).toBe(true); + }); - client.startClient(); + client!.startClient(); - return Promise.all([ + return Promise.all([ // wait for two SYNCING events - utils.syncPromise(client).then(() => { - return utils.syncPromise(client); - }), - httpBackend.flushAllExpected(), - ]).then(() => { - expect(expectedEvents.length).toEqual( - 0, "Failed to see all events from /sync calls", - ); + utils.syncPromise(client!).then(() => { + return utils.syncPromise(client!); + }), + httpBackend!.flushAllExpected(), + ]).then(() => { + expect(expectedEvents.length).toEqual(0); + }); }); - }); it("should emit User events", function(done) { - httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); - httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); let fired = false; - client.on("User.presence", function(event, user) { + client!.on(UserEvent.Presence, function(event, user) { fired = true; expect(user).toBeTruthy(); expect(event).toBeTruthy(); @@ -146,58 +176,52 @@ describe("MatrixClient events", function() { return; } - expect(event.event).toMatch(SYNC_DATA.presence.events[0]); + expect(event.event).toEqual(SYNC_DATA.presence.events[0]); expect(user.presence).toEqual( - SYNC_DATA.presence.events[0].content.presence, + SYNC_DATA.presence.events[0]?.content?.presence, ); }); - client.startClient(); + client!.startClient(); - httpBackend.flushAllExpected().then(function() { - expect(fired).toBe(true, "User.presence didn't fire."); + httpBackend!.flushAllExpected().then(function() { + expect(fired).toBe(true); done(); }); }); it("should emit Room events", function() { - httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); - httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); let roomInvokeCount = 0; let roomNameInvokeCount = 0; let timelineFireCount = 0; - client.on("Room", function(room) { + client!.on(ClientEvent.Room, function(room) { roomInvokeCount++; expect(room.roomId).toEqual("!erufh:bar"); }); - client.on("Room.timeline", function(event, room) { + client!.on(RoomEvent.Timeline, function(event, room) { timelineFireCount++; expect(room.roomId).toEqual("!erufh:bar"); }); - client.on("Room.name", function(room) { + client!.on(RoomEvent.Name, function(room) { roomNameInvokeCount++; }); - client.startClient(); + client!.startClient(); return Promise.all([ - httpBackend.flushAllExpected(), - utils.syncPromise(client, 2), + httpBackend!.flushAllExpected(), + utils.syncPromise(client!, 2), ]).then(function() { - expect(roomInvokeCount).toEqual( - 1, "Room fired wrong number of times.", - ); - expect(roomNameInvokeCount).toEqual( - 1, "Room.name fired wrong number of times.", - ); - expect(timelineFireCount).toEqual( - 3, "Room.timeline fired the wrong number of times", - ); + expect(roomInvokeCount).toEqual(1); + expect(roomNameInvokeCount).toEqual(1); + expect(timelineFireCount).toEqual(3); }); }); it("should emit RoomState events", function() { - httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); - httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); const roomStateEventTypes = [ "m.room.member", "m.room.create", @@ -205,126 +229,106 @@ describe("MatrixClient events", function() { let eventsInvokeCount = 0; let membersInvokeCount = 0; let newMemberInvokeCount = 0; - client.on("RoomState.events", function(event, state) { + client!.on(RoomStateEvent.Events, function(event, state) { eventsInvokeCount++; const index = roomStateEventTypes.indexOf(event.getType()); - expect(index).not.toEqual( - -1, "Unexpected room state event type: " + event.getType(), - ); + expect(index).not.toEqual(-1); if (index >= 0) { roomStateEventTypes.splice(index, 1); } }); - client.on("RoomState.members", function(event, state, member) { + client!.on(RoomStateEvent.Members, function(event, state, member) { membersInvokeCount++; expect(member.roomId).toEqual("!erufh:bar"); expect(member.userId).toEqual("@foo:bar"); expect(member.membership).toEqual("join"); }); - client.on("RoomState.newMember", function(event, state, member) { + client!.on(RoomStateEvent.NewMember, function(event, state, member) { newMemberInvokeCount++; expect(member.roomId).toEqual("!erufh:bar"); expect(member.userId).toEqual("@foo:bar"); expect(member.membership).toBeFalsy(); }); - client.startClient(); + client!.startClient(); return Promise.all([ - httpBackend.flushAllExpected(), - utils.syncPromise(client, 2), + httpBackend!.flushAllExpected(), + utils.syncPromise(client!, 2), ]).then(function() { - expect(membersInvokeCount).toEqual( - 1, "RoomState.members fired wrong number of times", - ); - expect(newMemberInvokeCount).toEqual( - 1, "RoomState.newMember fired wrong number of times", - ); - expect(eventsInvokeCount).toEqual( - 2, "RoomState.events fired wrong number of times", - ); + expect(membersInvokeCount).toEqual(1); + expect(newMemberInvokeCount).toEqual(1); + expect(eventsInvokeCount).toEqual(2); }); }); it("should emit RoomMember events", function() { - httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); - httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); let typingInvokeCount = 0; let powerLevelInvokeCount = 0; let nameInvokeCount = 0; let membershipInvokeCount = 0; - client.on("RoomMember.name", function(event, member) { + client!.on(RoomMemberEvent.Name, function(event, member) { nameInvokeCount++; }); - client.on("RoomMember.typing", function(event, member) { + client!.on(RoomMemberEvent.Typing, function(event, member) { typingInvokeCount++; expect(member.typing).toBe(true); }); - client.on("RoomMember.powerLevel", function(event, member) { + client!.on(RoomMemberEvent.PowerLevel, function(event, member) { powerLevelInvokeCount++; }); - client.on("RoomMember.membership", function(event, member) { + client!.on(RoomMemberEvent.Membership, function(event, member) { membershipInvokeCount++; expect(member.membership).toEqual("join"); }); - client.startClient(); + client!.startClient(); return Promise.all([ - httpBackend.flushAllExpected(), - utils.syncPromise(client, 2), + httpBackend!.flushAllExpected(), + utils.syncPromise(client!, 2), ]).then(function() { - expect(typingInvokeCount).toEqual( - 1, "RoomMember.typing fired wrong number of times", - ); - expect(powerLevelInvokeCount).toEqual( - 0, "RoomMember.powerLevel fired wrong number of times", - ); - expect(nameInvokeCount).toEqual( - 0, "RoomMember.name fired wrong number of times", - ); - expect(membershipInvokeCount).toEqual( - 1, "RoomMember.membership fired wrong number of times", - ); + expect(typingInvokeCount).toEqual(1); + expect(powerLevelInvokeCount).toEqual(0); + expect(nameInvokeCount).toEqual(0); + expect(membershipInvokeCount).toEqual(1); }); }); it("should emit Session.logged_out on M_UNKNOWN_TOKEN", function() { const error = { errcode: 'M_UNKNOWN_TOKEN' }; - httpBackend.when("GET", "/sync").respond(401, error); + httpBackend!.when("GET", "/sync").respond(401, error); let sessionLoggedOutCount = 0; - client.on("Session.logged_out", function(errObj) { + client!.on(HttpApiEvent.SessionLoggedOut, function(errObj) { sessionLoggedOutCount++; expect(errObj.data).toEqual(error); }); - client.startClient(); + client!.startClient(); - return httpBackend.flushAllExpected().then(function() { - expect(sessionLoggedOutCount).toEqual( - 1, "Session.logged_out fired wrong number of times", - ); + return httpBackend!.flushAllExpected().then(function() { + expect(sessionLoggedOutCount).toEqual(1); }); }); it("should emit Session.logged_out on M_UNKNOWN_TOKEN (soft logout)", function() { const error = { errcode: 'M_UNKNOWN_TOKEN', soft_logout: true }; - httpBackend.when("GET", "/sync").respond(401, error); + httpBackend!.when("GET", "/sync").respond(401, error); let sessionLoggedOutCount = 0; - client.on("Session.logged_out", function(errObj) { + client!.on(HttpApiEvent.SessionLoggedOut, function(errObj) { sessionLoggedOutCount++; expect(errObj.data).toEqual(error); }); - client.startClient(); + client!.startClient(); - return httpBackend.flushAllExpected().then(function() { - expect(sessionLoggedOutCount).toEqual( - 1, "Session.logged_out fired wrong number of times", - ); + return httpBackend!.flushAllExpected().then(function() { + expect(sessionLoggedOutCount).toEqual(1); }); }); }); diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index 3bde9dd6d..f2bfa5f6a 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -15,10 +15,21 @@ limitations under the License. */ import * as utils from "../test-utils/test-utils"; -import { ClientEvent, EventTimeline, Filter, IEvent, MatrixClient, MatrixEvent, Room } from "../../src/matrix"; +import { + ClientEvent, + Direction, + EventTimeline, + EventTimelineSet, + Filter, + IEvent, + MatrixClient, + MatrixEvent, + Room, +} from "../../src/matrix"; import { logger } from "../../src/logger"; +import { encodeUri } from "../../src/utils"; import { TestClient } from "../TestClient"; -import { Thread, THREAD_RELATION_TYPE } from "../../src/models/thread"; +import { FeatureSupport, Thread, THREAD_RELATION_TYPE } from "../../src/models/thread"; const userId = "@alice:localhost"; const userName = "Alice"; @@ -131,6 +142,7 @@ const THREAD_REPLY = utils.mkEvent({ event: false, }); +// @ts-ignore we know this is a defined path for THREAD ROOT THREAD_ROOT.unsigned["m.relations"]["io.element.thread"].latest_event = THREAD_REPLY; const SYNC_THREAD_ROOT = withoutRoomId(THREAD_ROOT); @@ -145,8 +157,11 @@ SYNC_THREAD_ROOT.unsigned = { }, }; +type HttpBackend = TestClient["httpBackend"]; +type ExpectedHttpRequest = ReturnType; + // start the client, and wait for it to initialise -function startClient(httpBackend: TestClient["httpBackend"], client: MatrixClient) { +function startClient(httpBackend: HttpBackend, client: MatrixClient) { httpBackend.when("GET", "/versions").respond(200, {}); httpBackend.when("GET", "/pushrules").respond(200, {}); httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); @@ -172,7 +187,7 @@ function startClient(httpBackend: TestClient["httpBackend"], client: MatrixClien } describe("getEventTimeline support", function() { - let httpBackend: TestClient["httpBackend"]; + let httpBackend: HttpBackend; let client: MatrixClient; beforeEach(function() { @@ -189,9 +204,19 @@ describe("getEventTimeline support", function() { }); it("timeline support must be enabled to work", function() { + const testClient = new TestClient( + userId, + "DEVICE", + accessToken, + undefined, + { timelineSupport: false }, + ); + client = testClient.client; + httpBackend = testClient.httpBackend; + return startClient(httpBackend, client).then(function() { - const room = client.getRoom(roomId); - const timelineSet = room.getTimelineSets()[0]; + const room = client.getRoom(roomId)!; + const timelineSet = room!.getTimelineSets()[0]; expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy(); }); }); @@ -208,18 +233,35 @@ describe("getEventTimeline support", function() { httpBackend = testClient.httpBackend; return startClient(httpBackend, client).then(() => { - const room = client.getRoom(roomId); - const timelineSet = room.getTimelineSets()[0]; + const room = client.getRoom(roomId)!; + const timelineSet = room!.getTimelineSets()[0]; expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeFalsy(); }); }); + it("only works with room timelines", function() { + const testClient = new TestClient( + userId, + "DEVICE", + accessToken, + undefined, + { timelineSupport: true }, + ); + client = testClient.client; + httpBackend = testClient.httpBackend; + + return startClient(httpBackend, client).then(function() { + const timelineSet = new EventTimelineSet(undefined); + expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy(); + }); + }); + it("scrollback should be able to scroll back to before a gappy /sync", function() { // need a client with timelineSupport disabled to make this work - let room: Room; + let room: Room | undefined; return startClient(httpBackend, client).then(function() { - room = client.getRoom(roomId); + room = client.getRoom(roomId)!; httpBackend.when("GET", "/sync").respond(200, { next_batch: "s_5_4", @@ -259,8 +301,8 @@ describe("getEventTimeline support", function() { utils.syncPromise(client, 2), ]); }).then(function() { - expect(room.timeline.length).toEqual(1); - expect(room.timeline[0].event).toEqual(EVENTS[1]); + expect(room!.timeline.length).toEqual(1); + expect(room!.timeline[0].event).toEqual(EVENTS[1]); httpBackend.when("GET", "/messages").respond(200, { chunk: [EVENTS[0]], @@ -268,19 +310,19 @@ describe("getEventTimeline support", function() { end: "pagin_end", }); httpBackend.flush("/messages", 1); - return client.scrollback(room); + return client.scrollback(room!); }).then(function() { - expect(room.timeline.length).toEqual(2); - expect(room.timeline[0].event).toEqual(EVENTS[0]); - expect(room.timeline[1].event).toEqual(EVENTS[1]); - expect(room.oldState.paginationToken).toEqual("pagin_end"); + expect(room!.timeline.length).toEqual(2); + expect(room!.timeline[0].event).toEqual(EVENTS[0]); + expect(room!.timeline[1].event).toEqual(EVENTS[1]); + expect(room!.oldState.paginationToken).toEqual("pagin_end"); }); }); }); describe("MatrixClient event timelines", function() { let client: MatrixClient; - let httpBackend: TestClient["httpBackend"]; + let httpBackend: HttpBackend; beforeEach(function() { const testClient = new TestClient( @@ -299,12 +341,12 @@ describe("MatrixClient event timelines", function() { afterEach(function() { httpBackend.verifyNoOutstandingExpectation(); client.stopClient(); - Thread.setServerSideSupport(false, false); + Thread.setServerSideSupport(FeatureSupport.None); }); describe("getEventTimeline", function() { it("should create a new timeline for new events", function() { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/rooms/!foo%3Abar/context/event1%3Abar") .respond(200, function() { @@ -323,14 +365,14 @@ describe("MatrixClient event timelines", function() { return Promise.all([ client.getEventTimeline(timelineSet, "event1:bar").then(function(tl) { - expect(tl.getEvents().length).toEqual(4); + expect(tl!.getEvents().length).toEqual(4); for (let i = 0; i < 4; i++) { - expect(tl.getEvents()[i].event).toEqual(EVENTS[i]); - expect(tl.getEvents()[i].sender.name).toEqual(userName); + expect(tl!.getEvents()[i].event).toEqual(EVENTS[i]); + expect(tl!.getEvents()[i]?.sender.name).toEqual(userName); } - expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)) .toEqual("start_token"); - expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)) .toEqual("end_token"); }), httpBackend.flushAllExpected(), @@ -338,7 +380,7 @@ describe("MatrixClient event timelines", function() { }); it("should return existing timeline for known events", function() { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/sync").respond(200, { next_batch: "s_5_4", @@ -360,12 +402,12 @@ describe("MatrixClient event timelines", function() { httpBackend.flush("/sync"), utils.syncPromise(client), ]).then(function() { - return client.getEventTimeline(timelineSet, EVENTS[0].event_id); + return client.getEventTimeline(timelineSet, EVENTS[0].event_id!); }).then(function(tl) { - expect(tl.getEvents().length).toEqual(2); - expect(tl.getEvents()[1].event).toEqual(EVENTS[0]); - expect(tl.getEvents()[1].sender.name).toEqual(userName); - expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + expect(tl!.getEvents().length).toEqual(2); + expect(tl!.getEvents()[1].event).toEqual(EVENTS[0]); + expect(tl!.getEvents()[1]?.sender.name).toEqual(userName); + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)) .toEqual("f_1_1"); // expect(tl.getPaginationToken(EventTimeline.FORWARDS)) // .toEqual("s_5_4"); @@ -373,7 +415,7 @@ describe("MatrixClient event timelines", function() { }); it("should update timelines where they overlap a previous /sync", function() { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/sync").respond(200, { next_batch: "s_5_4", @@ -392,7 +434,7 @@ describe("MatrixClient event timelines", function() { }); httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[2].event_id)) + encodeURIComponent(EVENTS[2].event_id!)) .respond(200, function() { return { start: "start_token", @@ -406,13 +448,13 @@ describe("MatrixClient event timelines", function() { const prom = new Promise((resolve, reject) => { client.on(ClientEvent.Sync, function() { - client.getEventTimeline(timelineSet, EVENTS[2].event_id, + client.getEventTimeline(timelineSet, EVENTS[2].event_id!, ).then(function(tl) { - expect(tl.getEvents().length).toEqual(4); - expect(tl.getEvents()[0].event).toEqual(EVENTS[1]); - expect(tl.getEvents()[1].event).toEqual(EVENTS[2]); - expect(tl.getEvents()[3].event).toEqual(EVENTS[3]); - expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + expect(tl!.getEvents().length).toEqual(4); + expect(tl!.getEvents()[0].event).toEqual(EVENTS[1]); + expect(tl!.getEvents()[1].event).toEqual(EVENTS[2]); + expect(tl!.getEvents()[3].event).toEqual(EVENTS[3]); + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)) .toEqual("start_token"); // expect(tl.getPaginationToken(EventTimeline.FORWARDS)) // .toEqual("s_5_4"); @@ -427,13 +469,13 @@ describe("MatrixClient event timelines", function() { }); it("should join timelines where they overlap a previous /context", function() { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; // we fetch event 0, then 2, then 3, and finally 1. 1 is returned // with context which joins them all up. httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[0].event_id)) + encodeURIComponent(EVENTS[0].event_id!)) .respond(200, function() { return { start: "start_token0", @@ -446,7 +488,7 @@ describe("MatrixClient event timelines", function() { }); httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[2].event_id)) + encodeURIComponent(EVENTS[2].event_id!)) .respond(200, function() { return { start: "start_token2", @@ -459,7 +501,7 @@ describe("MatrixClient event timelines", function() { }); httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[3].event_id)) + encodeURIComponent(EVENTS[3].event_id!)) .respond(200, function() { return { start: "start_token3", @@ -472,7 +514,7 @@ describe("MatrixClient event timelines", function() { }); httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[1].event_id)) + encodeURIComponent(EVENTS[1].event_id!)) .respond(200, function() { return { start: "start_token4", @@ -487,26 +529,26 @@ describe("MatrixClient event timelines", function() { let tl0; let tl3; return Promise.all([ - client.getEventTimeline(timelineSet, EVENTS[0].event_id, + client.getEventTimeline(timelineSet, EVENTS[0].event_id!, ).then(function(tl) { - expect(tl.getEvents().length).toEqual(1); + expect(tl!.getEvents().length).toEqual(1); tl0 = tl; - return client.getEventTimeline(timelineSet, EVENTS[2].event_id); + return client.getEventTimeline(timelineSet, EVENTS[2].event_id!); }).then(function(tl) { - expect(tl.getEvents().length).toEqual(1); - return client.getEventTimeline(timelineSet, EVENTS[3].event_id); + expect(tl!.getEvents().length).toEqual(1); + return client.getEventTimeline(timelineSet, EVENTS[3].event_id!); }).then(function(tl) { - expect(tl.getEvents().length).toEqual(1); + expect(tl!.getEvents().length).toEqual(1); tl3 = tl; - return client.getEventTimeline(timelineSet, EVENTS[1].event_id); + return client.getEventTimeline(timelineSet, EVENTS[1].event_id!); }).then(function(tl) { // we expect it to get merged in with event 2 - expect(tl.getEvents().length).toEqual(2); - expect(tl.getEvents()[0].event).toEqual(EVENTS[1]); - expect(tl.getEvents()[1].event).toEqual(EVENTS[2]); - expect(tl.getNeighbouringTimeline(EventTimeline.BACKWARDS)) + expect(tl!.getEvents().length).toEqual(2); + expect(tl!.getEvents()[0].event).toEqual(EVENTS[1]); + expect(tl!.getEvents()[1].event).toEqual(EVENTS[2]); + expect(tl!.getNeighbouringTimeline(EventTimeline.BACKWARDS)) .toBe(tl0); - expect(tl.getNeighbouringTimeline(EventTimeline.FORWARDS)) + expect(tl!.getNeighbouringTimeline(EventTimeline.FORWARDS)) .toBe(tl3); expect(tl0.getPaginationToken(EventTimeline.BACKWARDS)) .toEqual("start_token0"); @@ -522,7 +564,7 @@ describe("MatrixClient event timelines", function() { }); it("should fail gracefully if there is no event field", function() { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; // we fetch event 0, then 2, then 3, and finally 1. 1 is returned // with context which joins them all up. @@ -552,13 +594,13 @@ describe("MatrixClient event timelines", function() { it("should handle thread replies with server support by fetching a contiguous thread timeline", async () => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; - Thread.setServerSideSupport(true, false); + Thread.setServerSideSupport(FeatureSupport.Experimental); client.stopClient(); // we don't need the client to be syncing at this time - const room = client.getRoom(roomId); - const thread = room.createThread(THREAD_ROOT.event_id, undefined, [], false); + const room = client.getRoom(roomId)!; + const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false); const timelineSet = thread.timelineSet; - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id)) + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id!)) .respond(200, function() { return { start: "start_token0", @@ -570,13 +612,13 @@ describe("MatrixClient event timelines", function() { }; }); - httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id)) + httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!)) .respond(200, function() { return THREAD_ROOT; }); httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + - encodeURIComponent(THREAD_ROOT.event_id) + "/" + + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20") .respond(200, function() { return { @@ -586,26 +628,26 @@ describe("MatrixClient event timelines", function() { }; }); - const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id); + const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!); await httpBackend.flushAllExpected(); const timeline = await timelinePromise; - expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)).toBeTruthy(); - expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.event_id)).toBeTruthy(); + expect(timeline!.getEvents().find(e => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy(); + expect(timeline!.getEvents().find(e => e.getId() === THREAD_REPLY.event_id!)).toBeTruthy(); }); it("should return relevant timeline from non-thread timelineSet when asking for the thread root", async () => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; - Thread.setServerSideSupport(true, false); + Thread.setServerSideSupport(FeatureSupport.Experimental); client.stopClient(); // we don't need the client to be syncing at this time - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const threadRoot = new MatrixEvent(THREAD_ROOT); - const thread = room.createThread(THREAD_ROOT.event_id, threadRoot, [threadRoot], false); - const timelineSet = room.getTimelineSets()[0]; + const thread = room.createThread(THREAD_ROOT.event_id!, threadRoot, [threadRoot], false)!; + const timelineSet = room.getTimelineSets()[0]!; - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id)) + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!)) .respond(200, function() { return { start: "start_token0", @@ -618,26 +660,26 @@ describe("MatrixClient event timelines", function() { }); const [timeline] = await Promise.all([ - client.getEventTimeline(timelineSet, THREAD_ROOT.event_id), + client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!), httpBackend.flushAllExpected(), ]); - expect(timeline).not.toBe(thread.liveTimeline); + expect(timeline!).not.toBe(thread.liveTimeline); expect(timelineSet.getTimelines()).toContain(timeline); - expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)).toBeTruthy(); + expect(timeline!.getEvents().find(e => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy(); }); it("should return undefined when event is not in the thread that the given timelineSet is representing", () => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; - Thread.setServerSideSupport(true, false); + Thread.setServerSideSupport(FeatureSupport.Experimental); client.stopClient(); // we don't need the client to be syncing at this time - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const threadRoot = new MatrixEvent(THREAD_ROOT); - const thread = room.createThread(THREAD_ROOT.event_id, threadRoot, [threadRoot], false); + const thread = room.createThread(THREAD_ROOT.event_id!, threadRoot, [threadRoot], false); const timelineSet = thread.timelineSet; - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id)) + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!)) .respond(200, function() { return { start: "start_token0", @@ -650,7 +692,7 @@ describe("MatrixClient event timelines", function() { }); return Promise.all([ - expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id)).resolves.toBeUndefined(), + expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id!)).resolves.toBeUndefined(), httpBackend.flushAllExpected(), ]); }); @@ -658,12 +700,12 @@ describe("MatrixClient event timelines", function() { it("should return undefined when event is within a thread but timelineSet is not", () => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; - Thread.setServerSideSupport(true, false); + Thread.setServerSideSupport(FeatureSupport.Experimental); client.stopClient(); // we don't need the client to be syncing at this time - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id)) + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id!)) .respond(200, function() { return { start: "start_token0", @@ -676,7 +718,7 @@ describe("MatrixClient event timelines", function() { }); return Promise.all([ - expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id)).resolves.toBeUndefined(), + expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!)).resolves.toBeUndefined(), httpBackend.flushAllExpected(), ]); }); @@ -685,10 +727,10 @@ describe("MatrixClient event timelines", function() { // @ts-ignore client.clientOpts.lazyLoadMembers = true; client.stopClient(); // we don't need the client to be syncing at this time - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; - const req = httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id)); + const req = httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!)); req.respond(200, function() { return { start: "start_token0", @@ -700,20 +742,77 @@ describe("MatrixClient event timelines", function() { }; }); req.check((request) => { - expect(request.queryParams.filter).toEqual(JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER)); + expect(request.queryParams?.filter).toEqual(JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER)); }); await Promise.all([ - client.getEventTimeline(timelineSet, EVENTS[0].event_id), + client.getEventTimeline(timelineSet, EVENTS[0].event_id!), httpBackend.flushAllExpected(), ]); }); }); describe("getLatestTimeline", function() { - it("should create a new timeline for new events", function() { + it("timeline support must be enabled to work", async function() { + await client.stopClient(); + + const testClient = new TestClient( + userId, + "DEVICE", + accessToken, + undefined, + { timelineSupport: false }, + ); + client = testClient.client; + httpBackend = testClient.httpBackend; + await startClient(httpBackend, client); + const room = client.getRoom(roomId); const timelineSet = room.getTimelineSets()[0]; + await expect(client.getLatestTimeline(timelineSet)).rejects.toBeTruthy(); + }); + + it("timeline support works when enabled", async function() { + await client.stopClient(); + + const testClient = new TestClient( + userId, + "DEVICE", + accessToken, + undefined, + { timelineSupport: true }, + ); + client = testClient.client; + httpBackend = testClient.httpBackend; + + return startClient(httpBackend, client).then(() => { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + expect(client.getLatestTimeline(timelineSet)).rejects.toBeFalsy(); + }); + }); + + it("only works with room timelines", async function() { + await client.stopClient(); + + const testClient = new TestClient( + userId, + "DEVICE", + accessToken, + undefined, + { timelineSupport: true }, + ); + client = testClient.client; + httpBackend = testClient.httpBackend; + await startClient(httpBackend, client); + + const timelineSet = new EventTimelineSet(undefined); + await expect(client.getLatestTimeline(timelineSet)).rejects.toBeTruthy(); + }); + + it("should create a new timeline for new events", function() { + const room = client.getRoom(roomId)!; + const timelineSet = room.getTimelineSets()[0]; const latestMessageId = 'event1:bar'; @@ -747,14 +846,14 @@ describe("MatrixClient event timelines", function() { // for `getEventTimeline` and make sure it's called with the // correct parameters. This doesn't feel too bad to make sure // `getLatestTimeline` is doing the right thing though. - expect(tl.getEvents().length).toEqual(4); + expect(tl!.getEvents().length).toEqual(4); for (let i = 0; i < 4; i++) { - expect(tl.getEvents()[i].event).toEqual(EVENTS[i]); - expect(tl.getEvents()[i].sender.name).toEqual(userName); + expect(tl!.getEvents()[i].event).toEqual(EVENTS[i]); + expect(tl!.getEvents()[i]?.sender.name).toEqual(userName); } - expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)) .toEqual("start_token"); - expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)) .toEqual("end_token"); }), httpBackend.flushAllExpected(), @@ -762,7 +861,7 @@ describe("MatrixClient event timelines", function() { }); it("should throw error when /messages does not return a message", () => { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/rooms/!foo%3Abar/messages") @@ -783,11 +882,11 @@ describe("MatrixClient event timelines", function() { describe("paginateEventTimeline", function() { it("should allow you to paginate backwards", function() { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[0].event_id)) + encodeURIComponent(EVENTS[0].event_id!)) .respond(200, function() { return { start: "start_token0", @@ -801,7 +900,7 @@ describe("MatrixClient event timelines", function() { httpBackend.when("GET", "/rooms/!foo%3Abar/messages") .check(function(req) { - const params = req.queryParams; + const params = req.queryParams!; expect(params.dir).toEqual("b"); expect(params.from).toEqual("start_token0"); expect(params.limit).toEqual("30"); @@ -814,31 +913,70 @@ describe("MatrixClient event timelines", function() { let tl; return Promise.all([ - client.getEventTimeline(timelineSet, EVENTS[0].event_id, + client.getEventTimeline(timelineSet, EVENTS[0].event_id!, ).then(function(tl0) { tl = tl0; return client.paginateEventTimeline(tl, { backwards: true }); }).then(function(success) { expect(success).toBeTruthy(); - expect(tl.getEvents().length).toEqual(3); - expect(tl.getEvents()[0].event).toEqual(EVENTS[2]); - expect(tl.getEvents()[1].event).toEqual(EVENTS[1]); - expect(tl.getEvents()[2].event).toEqual(EVENTS[0]); - expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + expect(tl!.getEvents().length).toEqual(3); + expect(tl!.getEvents()[0].event).toEqual(EVENTS[2]); + expect(tl!.getEvents()[1].event).toEqual(EVENTS[1]); + expect(tl!.getEvents()[2].event).toEqual(EVENTS[0]); + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)) .toEqual("start_token1"); - expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)) .toEqual("end_token0"); }), httpBackend.flushAllExpected(), ]); }); - it("should allow you to paginate forwards", function() { - const room = client.getRoom(roomId); + it("should stop paginating when it encounters no `end` token", () => { + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[0].event_id)) + encodeURIComponent(EVENTS[0].event_id!)) + .respond(200, () => ({ + start: "start_token0", + events_before: [], + event: EVENTS[0], + events_after: [], + end: "end_token0", + state: [], + })); + + httpBackend.when("GET", "/rooms/!foo%3Abar/messages") + .check(function(req) { + const params = req.queryParams!; + expect(params.dir).toEqual("b"); + expect(params.from).toEqual("start_token0"); + expect(params.limit).toEqual("30"); + }).respond(200, () => ({ + start: "start_token0", + chunk: [], + })); + + return Promise.all([ + (async () => { + const tl = await client.getEventTimeline(timelineSet, EVENTS[0].event_id!); + const success = await client.paginateEventTimeline(tl!, { backwards: true }); + expect(success).toBeFalsy(); + expect(tl!.getEvents().length).toEqual(1); + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)).toEqual(null); + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token0"); + })(), + httpBackend.flushAllExpected(), + ]); + }); + + it("should allow you to paginate forwards", function() { + const room = client.getRoom(roomId)!; + const timelineSet = room.getTimelineSets()[0]; + + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + + encodeURIComponent(EVENTS[0].event_id!)) .respond(200, function() { return { start: "start_token0", @@ -852,7 +990,7 @@ describe("MatrixClient event timelines", function() { httpBackend.when("GET", "/rooms/!foo%3Abar/messages") .check(function(req) { - const params = req.queryParams; + const params = req.queryParams!; expect(params.dir).toEqual("f"); expect(params.from).toEqual("end_token0"); expect(params.limit).toEqual("20"); @@ -865,20 +1003,20 @@ describe("MatrixClient event timelines", function() { let tl; return Promise.all([ - client.getEventTimeline(timelineSet, EVENTS[0].event_id, + client.getEventTimeline(timelineSet, EVENTS[0].event_id!, ).then(function(tl0) { tl = tl0; return client.paginateEventTimeline( tl, { backwards: false, limit: 20 }); }).then(function(success) { expect(success).toBeTruthy(); - expect(tl.getEvents().length).toEqual(3); - expect(tl.getEvents()[0].event).toEqual(EVENTS[0]); - expect(tl.getEvents()[1].event).toEqual(EVENTS[1]); - expect(tl.getEvents()[2].event).toEqual(EVENTS[2]); - expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + expect(tl!.getEvents().length).toEqual(3); + expect(tl!.getEvents()[0].event).toEqual(EVENTS[0]); + expect(tl!.getEvents()[1].event).toEqual(EVENTS[1]); + expect(tl!.getEvents()[2].event).toEqual(EVENTS[2]); + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)) .toEqual("start_token0"); - expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)) .toEqual("end_token1"); }), httpBackend.flushAllExpected(), @@ -886,6 +1024,236 @@ describe("MatrixClient event timelines", function() { }); }); + describe("paginateEventTimeline for thread list timeline", function() { + async function flushHttp(promise: Promise): Promise { + return Promise.all([promise, httpBackend.flushAllExpected()]).then(([result]) => result); + } + + const RANDOM_TOKEN = "7280349c7bee430f91defe2a38a0a08c"; + + function respondToFilter(): ExpectedHttpRequest { + const request = httpBackend.when("POST", "/filter"); + request.respond(200, { filter_id: "fid" }); + return request; + } + + function respondToSync(): ExpectedHttpRequest { + const request = httpBackend.when("GET", "/sync"); + request.respond(200, INITIAL_SYNC_DATA); + return request; + } + + function respondToThreads( + response = { + chunk: [THREAD_ROOT], + state: [], + next_batch: RANDOM_TOKEN, + }, + ): ExpectedHttpRequest { + const request = httpBackend.when("GET", encodeUri("/_matrix/client/r0/rooms/$roomId/threads", { + $roomId: roomId, + })); + request.respond(200, response); + return request; + } + + function respondToContext(): ExpectedHttpRequest { + const request = httpBackend.when("GET", encodeUri("/_matrix/client/r0/rooms/$roomId/context/$eventId", { + $roomId: roomId, + $eventId: THREAD_ROOT.event_id!, + })); + request.respond(200, { + end: `${Direction.Forward}${RANDOM_TOKEN}1`, + start: `${Direction.Backward}${RANDOM_TOKEN}1`, + state: [], + events_before: [], + events_after: [], + event: THREAD_ROOT, + }); + return request; + } + function respondToMessagesRequest(): ExpectedHttpRequest { + const request = httpBackend.when("GET", encodeUri("/_matrix/client/r0/rooms/$roomId/messages", { + $roomId: roomId, + })); + request.respond(200, { + chunk: [THREAD_ROOT], + state: [], + start: `${Direction.Forward}${RANDOM_TOKEN}2`, + end: `${Direction.Backward}${RANDOM_TOKEN}2`, + }); + return request; + } + + describe("with server compatibility", function() { + beforeEach(() => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Experimental); + Thread.setServerSideListSupport(FeatureSupport.Stable); + }); + + async function testPagination(timelineSet: EventTimelineSet, direction: Direction) { + respondToContext(); + await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!)); + respondToThreads(); + const timeline = await flushHttp(client.getLatestTimeline(timelineSet)); + expect(timeline).not.toBeNull(); + + respondToThreads(); + const success = await flushHttp(client.paginateEventTimeline(timeline!, { + backwards: direction === Direction.Backward, + })); + expect(success).toBeTruthy(); + expect(timeline!.getEvents().map(it => it.event)).toEqual([THREAD_ROOT]); + expect(timeline!.getPaginationToken(direction)).toEqual(RANDOM_TOKEN); + } + + it("should allow you to paginate all threads backwards", async function() { + const room = client.getRoom(roomId); + const timelineSets = await (room?.createThreadsTimelineSets()); + expect(timelineSets).not.toBeNull(); + const [allThreads, myThreads] = timelineSets!; + await testPagination(allThreads, Direction.Backward); + await testPagination(myThreads, Direction.Backward); + }); + + it("should allow you to paginate all threads forwards", async function() { + const room = client.getRoom(roomId); + const timelineSets = await (room?.createThreadsTimelineSets()); + expect(timelineSets).not.toBeNull(); + const [allThreads, myThreads] = timelineSets!; + + await testPagination(allThreads, Direction.Forward); + await testPagination(myThreads, Direction.Forward); + }); + + it("should allow fetching all threads", async function() { + const room = client.getRoom(roomId); + const timelineSets = await room?.createThreadsTimelineSets(); + expect(timelineSets).not.toBeNull(); + respondToThreads(); + respondToThreads(); + httpBackend.when("GET", "/sync").respond(200, INITIAL_SYNC_DATA); + await flushHttp(room.fetchRoomThreads()); + }); + }); + + describe("without server compatibility", function() { + beforeEach(() => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Experimental); + Thread.setServerSideListSupport(FeatureSupport.None); + }); + + async function testPagination(timelineSet: EventTimelineSet, direction: Direction) { + respondToContext(); + respondToSync(); + await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!)); + + respondToMessagesRequest(); + const timeline = await flushHttp(client.getLatestTimeline(timelineSet)); + expect(timeline).not.toBeNull(); + + respondToMessagesRequest(); + const success = await flushHttp(client.paginateEventTimeline(timeline!, { + backwards: direction === Direction.Backward, + })); + + expect(success).toBeTruthy(); + expect(timeline!.getEvents().map(it => it.event)).toEqual([THREAD_ROOT]); + expect(timeline!.getPaginationToken(direction)).toEqual(`${direction}${RANDOM_TOKEN}2`); + } + + it("should allow you to paginate all threads", async function() { + const room = client.getRoom(roomId); + + respondToFilter(); + respondToSync(); + respondToFilter(); + respondToSync(); + + const timelineSetsPromise = room?.createThreadsTimelineSets(); + expect(timelineSetsPromise).not.toBeNull(); + const timelineSets = await flushHttp(timelineSetsPromise!); + expect(timelineSets).not.toBeNull(); + const [allThreads, myThreads] = timelineSets!; + + await testPagination(allThreads, Direction.Backward); + await testPagination(myThreads, Direction.Backward); + }); + + it("should allow fetching all threads", async function() { + const room = client.getRoom(roomId); + + respondToFilter(); + respondToSync(); + respondToFilter(); + respondToSync(); + + const timelineSetsPromise = room?.createThreadsTimelineSets(); + expect(timelineSetsPromise).not.toBeNull(); + await flushHttp(timelineSetsPromise!); + respondToFilter(); + respondToSync(); + respondToSync(); + respondToSync(); + respondToMessagesRequest(); + await flushHttp(room.fetchRoomThreads()); + }); + }); + + it("should add lazy loading filter", async () => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Experimental); + Thread.setServerSideListSupport(FeatureSupport.Stable); + // @ts-ignore + client.clientOpts.lazyLoadMembers = true; + + const room = client.getRoom(roomId); + const timelineSets = await room?.createThreadsTimelineSets(); + expect(timelineSets).not.toBeNull(); + const [allThreads] = timelineSets!; + + respondToThreads().check((request) => { + expect(request.queryParams.filter).toEqual(JSON.stringify({ + "lazy_load_members": true, + })); + }); + + await flushHttp(client.paginateEventTimeline(allThreads.getLiveTimeline(), { + backwards: true, + })); + }); + + it("should correctly pass pagination token", async () => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Experimental); + Thread.setServerSideListSupport(FeatureSupport.Stable); + + const room = client.getRoom(roomId); + const timelineSets = await room?.createThreadsTimelineSets(); + expect(timelineSets).not.toBeNull(); + const [allThreads] = timelineSets!; + + respondToThreads({ + chunk: [THREAD_ROOT], + state: [], + next_batch: null, + }).check((request) => { + expect(request.queryParams.from).toEqual(RANDOM_TOKEN); + }); + + allThreads.getLiveTimeline().setPaginationToken(RANDOM_TOKEN, Direction.Backward); + await flushHttp(client.paginateEventTimeline(allThreads.getLiveTimeline(), { + backwards: true, + })); + }); + }); + describe("event timeline for sent events", function() { const TXN_ID = "txn1"; const event = utils.mkMessage({ @@ -918,17 +1286,17 @@ describe("MatrixClient event timelines", function() { }); it("should work when /send returns before /sync", function() { - const room = client.getRoom(roomId); - const timelineSet = room.getTimelineSets()[0]; + const room = client.getRoom(roomId)!; + const timelineSet = room.getTimelineSets()[0]!; return Promise.all([ client.sendTextMessage(roomId, "a body", TXN_ID).then(function(res) { - expect(res.event_id).toEqual(event.event_id); - return client.getEventTimeline(timelineSet, event.event_id); + expect(res.event_id).toEqual(event.event_id!); + return client.getEventTimeline(timelineSet, event.event_id!); }).then(function(tl) { // 2 because the initial sync contained an event - expect(tl.getEvents().length).toEqual(2); - expect(tl.getEvents()[1].getContent().body).toEqual("a body"); + expect(tl!.getEvents().length).toEqual(2); + expect(tl!.getEvents()[1].getContent().body).toEqual("a body"); // now let the sync complete, and check it again return Promise.all([ @@ -936,10 +1304,10 @@ describe("MatrixClient event timelines", function() { utils.syncPromise(client), ]); }).then(function() { - return client.getEventTimeline(timelineSet, event.event_id); + return client.getEventTimeline(timelineSet, event.event_id!); }).then(function(tl) { - expect(tl.getEvents().length).toEqual(2); - expect(tl.getEvents()[1].event).toEqual(event); + expect(tl!.getEvents().length).toEqual(2); + expect(tl!.getEvents()[1].event).toEqual(event); }), httpBackend.flush("/send/m.room.message/" + TXN_ID, 1), @@ -947,7 +1315,7 @@ describe("MatrixClient event timelines", function() { }); it("should work when /send returns after /sync", function() { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; return Promise.all([ @@ -955,23 +1323,23 @@ describe("MatrixClient event timelines", function() { // - but note that it won't complete until after the /sync does, below. client.sendTextMessage(roomId, "a body", TXN_ID).then(function(res) { logger.log("sendTextMessage completed"); - expect(res.event_id).toEqual(event.event_id); - return client.getEventTimeline(timelineSet, event.event_id); + expect(res.event_id).toEqual(event.event_id!); + return client.getEventTimeline(timelineSet, event.event_id!); }).then(function(tl) { logger.log("getEventTimeline completed (2)"); - expect(tl.getEvents().length).toEqual(2); - expect(tl.getEvents()[1].getContent().body).toEqual("a body"); + expect(tl!.getEvents().length).toEqual(2); + expect(tl!.getEvents()[1].getContent().body).toEqual("a body"); }), Promise.all([ httpBackend.flush("/sync", 1), utils.syncPromise(client), ]).then(function() { - return client.getEventTimeline(timelineSet, event.event_id); + return client.getEventTimeline(timelineSet, event.event_id!); }).then(function(tl) { logger.log("getEventTimeline completed (1)"); - expect(tl.getEvents().length).toEqual(2); - expect(tl.getEvents()[1].event).toEqual(event); + expect(tl!.getEvents().length).toEqual(2); + expect(tl!.getEvents()[1].event).toEqual(event); // now let the send complete. return httpBackend.flush("/send/m.room.message/" + TXN_ID, 1); @@ -1015,10 +1383,10 @@ describe("MatrixClient event timelines", function() { httpBackend.flushAllExpected(), utils.syncPromise(client), ]).then(function() { - const room = client.getRoom(roomId); - const tl = room.getLiveTimeline(); - expect(tl.getEvents().length).toEqual(3); - expect(tl.getEvents()[1].isRedacted()).toBe(true); + const room = client.getRoom(roomId)!; + const tl = room.getLiveTimeline()!; + expect(tl!.getEvents().length).toEqual(3); + expect(tl!.getEvents()[1].isRedacted()).toBe(true); const sync2 = { next_batch: "batch2", @@ -1044,8 +1412,8 @@ describe("MatrixClient event timelines", function() { utils.syncPromise(client), ]); }).then(function() { - const room = client.getRoom(roomId); - const tl = room.getLiveTimeline(); + const room = client.getRoom(roomId)!; + const tl = room.getLiveTimeline()!; expect(tl.getEvents().length).toEqual(1); }); }); @@ -1053,7 +1421,7 @@ describe("MatrixClient event timelines", function() { it("should re-insert room IDs for bundled thread relation events", async () => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; - Thread.setServerSideSupport(true, false); + Thread.setServerSideSupport(FeatureSupport.Experimental); httpBackend.when("GET", "/sync").respond(200, { next_batch: "s_5_4", @@ -1072,11 +1440,11 @@ describe("MatrixClient event timelines", function() { }); await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]); - const room = client.getRoom(roomId); - const thread = room.getThread(THREAD_ROOT.event_id); + const room = client.getRoom(roomId)!; + const thread = room.getThread(THREAD_ROOT.event_id!)!; const timelineSet = thread.timelineSet; - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id)) + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!)) .respond(200, { start: "start_token", events_before: [], @@ -1086,7 +1454,7 @@ describe("MatrixClient event timelines", function() { end: "end_token", }); httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + - encodeURIComponent(THREAD_ROOT.event_id) + "/" + + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20") .respond(200, function() { return { @@ -1096,7 +1464,7 @@ describe("MatrixClient event timelines", function() { }; }); await Promise.all([ - client.getEventTimeline(timelineSet, THREAD_ROOT.event_id), + client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!), httpBackend.flushAllExpected(), ]); diff --git a/spec/integ/matrix-client-methods.spec.js b/spec/integ/matrix-client-methods.spec.ts similarity index 76% rename from spec/integ/matrix-client-methods.spec.js rename to spec/integ/matrix-client-methods.spec.ts index 0dd33a02c..fdf32d9e9 100644 --- a/spec/integ/matrix-client-methods.spec.js +++ b/spec/integ/matrix-client-methods.spec.ts @@ -13,68 +13,84 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +import HttpBackend from "matrix-mock-request"; import * as utils from "../test-utils/test-utils"; -import { CRYPTO_ENABLED } from "../../src/client"; +import { CRYPTO_ENABLED, MatrixClient, IStoredClientOpts } from "../../src/client"; import { MatrixEvent } from "../../src/models/event"; import { Filter, MemoryStore, Room } from "../../src/matrix"; import { TestClient } from "../TestClient"; import { THREAD_RELATION_TYPE } from "../../src/models/thread"; +import { IFilterDefinition } from "../../src/filter"; +import { FileType } from "../../src/http-api"; +import { ISearchResults } from "../../src/@types/search"; +import { IStore } from "../../src/store"; describe("MatrixClient", function() { - let client = null; - let httpBackend = null; - let store = null; const userId = "@alice:localhost"; const accessToken = "aseukfgwef"; const idServerDomain = "identity.localhost"; // not a real server const identityAccessToken = "woop-i-am-a-secret"; + let client: MatrixClient | undefined; + let httpBackend: HttpBackend | undefined; + let store: MemoryStore | undefined; - beforeEach(function() { - store = new MemoryStore(); + const defaultClientOpts: IStoredClientOpts = { + canResetEntireTimeline: roomId => false, + experimentalThreadSupport: false, + crypto: {} as unknown as IStoredClientOpts['crypto'], + }; + const setupTests = (): [MatrixClient, HttpBackend, MemoryStore] => { + const store = new MemoryStore(); const testClient = new TestClient(userId, "aliceDevice", accessToken, undefined, { - store, + store: store as IStore, identityServer: { getAccessToken: () => Promise.resolve(identityAccessToken), }, idBaseUrl: `https://${idServerDomain}`, }); - httpBackend = testClient.httpBackend; - client = testClient.client; + + return [testClient.client, testClient.httpBackend, store]; + }; + + beforeEach(function() { + [client, httpBackend, store] = setupTests(); }); afterEach(function() { - httpBackend.verifyNoOutstandingExpectation(); - return httpBackend.stop(); + httpBackend!.verifyNoOutstandingExpectation(); + return httpBackend!.stop(); }); describe("uploadContent", function() { const buf = Buffer.from('hello world'); it("should upload the file", function() { - httpBackend.when( + httpBackend!.when( "POST", "/_matrix/media/r0/upload", ).check(function(req) { expect(req.rawData).toEqual(buf); - expect(req.queryParams.filename).toEqual("hi.txt"); - if (!(req.queryParams.access_token == accessToken || + expect(req.queryParams?.filename).toEqual("hi.txt"); + if (!(req.queryParams?.access_token == accessToken || req.headers["Authorization"] == "Bearer " + accessToken)) { expect(true).toBe(false); } expect(req.headers["Content-Type"]).toEqual("text/plain"); + // @ts-ignore private property expect(req.opts.json).toBeFalsy(); + // @ts-ignore private property expect(req.opts.timeout).toBe(undefined); }).respond(200, "content", true); - const prom = client.uploadContent({ + const prom = client!.uploadContent({ stream: buf, name: "hi.txt", type: "text/plain", - }); + } as unknown as FileType); expect(prom).toBeTruthy(); - const uploads = client.getCurrentUploads(); + const uploads = client!.getCurrentUploads(); expect(uploads.length).toEqual(1); expect(uploads[0].promise).toBe(prom); expect(uploads[0].loaded).toEqual(0); @@ -83,51 +99,53 @@ describe("MatrixClient", function() { // for backwards compatibility, we return the raw JSON expect(response).toEqual("content"); - const uploads = client.getCurrentUploads(); + const uploads = client!.getCurrentUploads(); expect(uploads.length).toEqual(0); }); - httpBackend.flush(); + httpBackend!.flush(''); return prom2; }); it("should parse the response if rawResponse=false", function() { - httpBackend.when( + httpBackend!.when( "POST", "/_matrix/media/r0/upload", ).check(function(req) { + // @ts-ignore private property expect(req.opts.json).toBeFalsy(); }).respond(200, { "content_uri": "uri" }); - const prom = client.uploadContent({ + const prom = client!.uploadContent({ stream: buf, name: "hi.txt", type: "text/plain", - }, { + } as unknown as FileType, { rawResponse: false, }).then(function(response) { expect(response.content_uri).toEqual("uri"); }); - httpBackend.flush(); + httpBackend!.flush(''); return prom; }); it("should parse errors into a MatrixError", function() { - httpBackend.when( + httpBackend!.when( "POST", "/_matrix/media/r0/upload", ).check(function(req) { expect(req.rawData).toEqual(buf); + // @ts-ignore private property expect(req.opts.json).toBeFalsy(); }).respond(400, { "errcode": "M_SNAFU", "error": "broken", }); - const prom = client.uploadContent({ + const prom = client!.uploadContent({ stream: buf, name: "hi.txt", type: "text/plain", - }).then(function(response) { + } as unknown as FileType).then(function(response) { throw Error("request not failed"); }, function(error) { expect(error.httpStatus).toEqual(400); @@ -135,18 +153,18 @@ describe("MatrixClient", function() { expect(error.message).toEqual("broken"); }); - httpBackend.flush(); + httpBackend!.flush(''); return prom; }); it("should return a promise which can be cancelled", function() { - const prom = client.uploadContent({ + const prom = client!.uploadContent({ stream: buf, name: "hi.txt", type: "text/plain", - }); + } as unknown as FileType); - const uploads = client.getCurrentUploads(); + const uploads = client!.getCurrentUploads(); expect(uploads.length).toEqual(1); expect(uploads[0].promise).toBe(prom); expect(uploads[0].loaded).toEqual(0); @@ -156,11 +174,11 @@ describe("MatrixClient", function() { }, function(error) { expect(error).toEqual("aborted"); - const uploads = client.getCurrentUploads(); + const uploads = client!.getCurrentUploads(); expect(uploads.length).toEqual(0); }); - const r = client.cancelUpload(prom); + const r = client!.cancelUpload(prom); expect(r).toBe(true); return prom2; }); @@ -169,17 +187,20 @@ describe("MatrixClient", function() { describe("joinRoom", function() { it("should no-op if you've already joined a room", function() { const roomId = "!foo:bar"; - const room = new Room(roomId, client, userId); - client.fetchRoomEvent = () => Promise.resolve({}); + const room = new Room(roomId, client!, userId); + client!.fetchRoomEvent = () => Promise.resolve({ + type: 'test', + content: {}, + }); room.addLiveEvents([ utils.mkMembership({ user: userId, room: roomId, mship: "join", event: true, }), ]); - httpBackend.verifyNoOutstandingRequests(); - store.storeRoom(room); - client.joinRoom(roomId); - httpBackend.verifyNoOutstandingRequests(); + httpBackend!.verifyNoOutstandingRequests(); + store!.storeRoom(room); + client!.joinRoom(roomId); + httpBackend!.verifyNoOutstandingRequests(); }); }); @@ -190,67 +211,67 @@ describe("MatrixClient", function() { const filter = Filter.fromJson(userId, filterId, { event_format: "client", }); - store.storeFilter(filter); - client.getFilter(userId, filterId, true).then(function(gotFilter) { + store!.storeFilter(filter); + client!.getFilter(userId, filterId, true).then(function(gotFilter) { expect(gotFilter).toEqual(filter); done(); }); - httpBackend.verifyNoOutstandingRequests(); + httpBackend!.verifyNoOutstandingRequests(); }); it("should do an HTTP request if !allowCached even if one exists", - function(done) { - const httpFilterDefinition = { - event_format: "federation", - }; + function(done) { + const httpFilterDefinition = { + event_format: "federation", + }; - httpBackend.when( - "GET", "/user/" + encodeURIComponent(userId) + "/filter/" + filterId, - ).respond(200, httpFilterDefinition); + httpBackend!.when( + "GET", "/user/" + encodeURIComponent(userId) + "/filter/" + filterId, + ).respond(200, httpFilterDefinition); - const storeFilter = Filter.fromJson(userId, filterId, { - event_format: "client", + const storeFilter = Filter.fromJson(userId, filterId, { + event_format: "client", + }); + store!.storeFilter(storeFilter); + client!.getFilter(userId, filterId, false).then(function(gotFilter) { + expect(gotFilter.getDefinition()).toEqual(httpFilterDefinition); + done(); + }); + + httpBackend!.flush(''); }); - store.storeFilter(storeFilter); - client.getFilter(userId, filterId, false).then(function(gotFilter) { - expect(gotFilter.getDefinition()).toEqual(httpFilterDefinition); - done(); - }); - - httpBackend.flush(); - }); it("should do an HTTP request if nothing is in the cache and then store it", - function(done) { - const httpFilterDefinition = { - event_format: "federation", - }; - expect(store.getFilter(userId, filterId)).toBe(null); + function(done) { + const httpFilterDefinition = { + event_format: "federation", + }; + expect(store!.getFilter(userId, filterId)).toBe(null); - httpBackend.when( - "GET", "/user/" + encodeURIComponent(userId) + "/filter/" + filterId, - ).respond(200, httpFilterDefinition); - client.getFilter(userId, filterId, true).then(function(gotFilter) { - expect(gotFilter.getDefinition()).toEqual(httpFilterDefinition); - expect(store.getFilter(userId, filterId)).toBeTruthy(); - done(); + httpBackend!.when( + "GET", "/user/" + encodeURIComponent(userId) + "/filter/" + filterId, + ).respond(200, httpFilterDefinition); + client!.getFilter(userId, filterId, true).then(function(gotFilter) { + expect(gotFilter.getDefinition()).toEqual(httpFilterDefinition); + expect(store!.getFilter(userId, filterId)).toBeTruthy(); + done(); + }); + + httpBackend!.flush(''); }); - - httpBackend.flush(); - }); }); describe("createFilter", function() { const filterId = "f1llllllerid"; it("should do an HTTP request and then store the filter", function(done) { - expect(store.getFilter(userId, filterId)).toBe(null); + expect(store!.getFilter(userId, filterId)).toBe(null); const filterDefinition = { - event_format: "client", + event_format: "client" as IFilterDefinition['event_format'], }; - httpBackend.when( + httpBackend!.when( "POST", "/user/" + encodeURIComponent(userId) + "/filter", ).check(function(req) { expect(req.data).toEqual(filterDefinition); @@ -258,13 +279,13 @@ describe("MatrixClient", function() { filter_id: filterId, }); - client.createFilter(filterDefinition).then(function(gotFilter) { + client!.createFilter(filterDefinition).then(function(gotFilter) { expect(gotFilter.getDefinition()).toEqual(filterDefinition); - expect(store.getFilter(userId, filterId)).toEqual(gotFilter); + expect(store!.getFilter(userId, filterId)).toEqual(gotFilter); done(); }); - httpBackend.flush(); + httpBackend!.flush(''); }); }); @@ -291,10 +312,10 @@ describe("MatrixClient", function() { }, }; - client.searchMessageText({ + client!.searchMessageText({ query: "monkeys", }); - httpBackend.when("POST", "/search").check(function(req) { + httpBackend!.when("POST", "/search").check(function(req) { expect(req.data).toEqual({ search_categories: { room_events: { @@ -304,7 +325,7 @@ describe("MatrixClient", function() { }); }).respond(200, response); - return httpBackend.flush(); + return httpBackend!.flush(''); }); describe("should filter out context from different timelines (threads)", () => { @@ -313,11 +334,14 @@ describe("MatrixClient", function() { search_categories: { room_events: { count: 24, + highlights: [], results: [{ rank: 0.1, result: { event_id: "$flibble:localhost", type: "m.room.message", + sender: '@test:locahost', + origin_server_ts: 123, user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", content: { @@ -326,9 +350,12 @@ describe("MatrixClient", function() { }, }, context: { + profile_info: {}, events_after: [{ event_id: "$ev-after:server", type: "m.room.message", + sender: '@test:locahost', + origin_server_ts: 123, user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", content: { @@ -343,6 +370,8 @@ describe("MatrixClient", function() { events_before: [{ event_id: "$ev-before:server", type: "m.room.message", + sender: '@test:locahost', + origin_server_ts: 123, user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", content: { @@ -356,15 +385,17 @@ describe("MatrixClient", function() { }, }; - const data = { + const data: ISearchResults = { results: [], highlights: [], }; - client.processRoomEventsSearch(data, response); + client!.processRoomEventsSearch(data, response); expect(data.results).toHaveLength(1); - expect(data.results[0].context.timeline).toHaveLength(2); - expect(data.results[0].context.timeline.find(e => e.getId() === "$ev-after:server")).toBeFalsy(); + expect(data.results[0].context.getTimeline()).toHaveLength(2); + expect( + data.results[0].context.getTimeline().find(e => e.getId() === "$ev-after:server"), + ).toBeFalsy(); }); it("filters out thread replies from threads other than the thread the result replied to", () => { @@ -372,11 +403,14 @@ describe("MatrixClient", function() { search_categories: { room_events: { count: 24, + highlights: [], results: [{ rank: 0.1, result: { event_id: "$flibble:localhost", type: "m.room.message", + sender: '@test:locahost', + origin_server_ts: 123, user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", content: { @@ -389,9 +423,12 @@ describe("MatrixClient", function() { }, }, context: { + profile_info: {}, events_after: [{ event_id: "$ev-after:server", type: "m.room.message", + sender: '@test:locahost', + origin_server_ts: 123, user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", content: { @@ -410,15 +447,17 @@ describe("MatrixClient", function() { }, }; - const data = { + const data: ISearchResults = { results: [], highlights: [], }; - client.processRoomEventsSearch(data, response); + client!.processRoomEventsSearch(data, response); expect(data.results).toHaveLength(1); - expect(data.results[0].context.timeline).toHaveLength(1); - expect(data.results[0].context.timeline.find(e => e.getId() === "$flibble:localhost")).toBeTruthy(); + expect(data.results[0].context.getTimeline()).toHaveLength(1); + expect( + data.results[0].context.getTimeline().find(e => e.getId() === "$flibble:localhost"), + ).toBeTruthy(); }); it("filters out main timeline events when result is a thread reply", () => { @@ -426,10 +465,13 @@ describe("MatrixClient", function() { search_categories: { room_events: { count: 24, + highlights: [], results: [{ rank: 0.1, result: { event_id: "$flibble:localhost", + sender: '@test:locahost', + origin_server_ts: 123, type: "m.room.message", user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", @@ -445,6 +487,8 @@ describe("MatrixClient", function() { context: { events_after: [{ event_id: "$ev-after:server", + sender: '@test:locahost', + origin_server_ts: 123, type: "m.room.message", user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", @@ -454,21 +498,24 @@ describe("MatrixClient", function() { }, }], events_before: [], + profile_info: {}, }, }], }, }, }; - const data = { + const data: ISearchResults = { results: [], highlights: [], }; - client.processRoomEventsSearch(data, response); + client!.processRoomEventsSearch(data, response); expect(data.results).toHaveLength(1); - expect(data.results[0].context.timeline).toHaveLength(1); - expect(data.results[0].context.timeline.find(e => e.getId() === "$flibble:localhost")).toBeTruthy(); + expect(data.results[0].context.getTimeline()).toHaveLength(1); + expect( + data.results[0].context.getTimeline().find(e => e.getId() === "$flibble:localhost"), + ).toBeTruthy(); }); }); }); @@ -479,16 +526,16 @@ describe("MatrixClient", function() { } beforeEach(function() { - return client.initCrypto(); + return client!.initCrypto(); }); afterEach(() => { - client.stopClient(); + client!.stopClient(); }); it("should do an HTTP request and then store the keys", function() { const ed25519key = "7wG2lzAqbjcyEkOP7O4gU7ItYcn+chKzh5sT/5r2l78"; - // ed25519key = client.getDeviceEd25519Key(); + // ed25519key = client!.getDeviceEd25519Key(); const borisKeys = { dev1: { algorithms: ["1"], @@ -512,7 +559,7 @@ describe("MatrixClient", function() { keys: { "ed25519:dev2": ed25519key }, signatures: { chaz: { - "ed25519:dev2": + "ed25519:dev2": "FwslH/Q7EYSb7swDJbNB5PSzcbEO1xRRBF1riuijqvL" + "EkrK9/XVN8jl4h7thGuRITQ01siBQnNmMK9t45QfcCQ", }, @@ -528,7 +575,7 @@ describe("MatrixClient", function() { var b = JSON.parse(JSON.stringify(o)); delete(b.signatures); delete(b.unsigned); - return client.crypto.olmDevice.sign(anotherjson.stringify(b)); + return client!.crypto.olmDevice.sign(anotherjson.stringify(b)); }; logger.log("Ed25519: " + ed25519key); @@ -536,7 +583,7 @@ describe("MatrixClient", function() { logger.log("chaz:", sign(chazKeys.dev2)); */ - httpBackend.when("POST", "/keys/query").check(function(req) { + httpBackend!.when("POST", "/keys/query").check(function(req) { expect(req.data).toEqual({ device_keys: { 'boris': [], 'chaz': [], @@ -548,7 +595,7 @@ describe("MatrixClient", function() { }, }); - const prom = client.downloadKeys(["boris", "chaz"]).then(function(res) { + const prom = client!.downloadKeys(["boris", "chaz"]).then(function(res) { assertObjectContains(res.boris.dev1, { verified: 0, // DeviceVerification.UNVERIFIED keys: { "ed25519:dev1": ed25519key }, @@ -564,23 +611,23 @@ describe("MatrixClient", function() { }); }); - httpBackend.flush(); + httpBackend!.flush(''); return prom; }); }); describe("deleteDevice", function() { - const auth = { a: 1 }; + const auth = { identifier: 1 }; it("should pass through an auth dict", function() { - httpBackend.when( + httpBackend!.when( "DELETE", "/_matrix/client/r0/devices/my_device", ).check(function(req) { expect(req.data).toEqual({ auth: auth }); }).respond(200); - const prom = client.deleteDevice("my_device", auth); + const prom = client!.deleteDevice("my_device", auth); - httpBackend.flush(); + httpBackend!.flush(''); return prom; }); }); @@ -588,7 +635,7 @@ describe("MatrixClient", function() { describe("partitionThreadedEvents", function() { let room; beforeEach(() => { - room = new Room("!STrMRsukXHtqQdSeHa:matrix.org", client, userId); + room = new Room("!STrMRsukXHtqQdSeHa:matrix.org", client!, userId); }); it("returns empty arrays when given an empty arrays", function() { @@ -599,7 +646,11 @@ describe("MatrixClient", function() { }); it("copies pre-thread in-timeline vote events onto both timelines", function() { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; const eventPollResponseReference = buildEventPollResponseReference(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); @@ -611,6 +662,7 @@ describe("MatrixClient", function() { eventPollResponseReference, ]; // Vote has no threadId yet + // @ts-ignore private property expect(eventPollResponseReference.threadId).toBeFalsy(); const [timeline, threaded] = room.partitionThreadedEvents(events); @@ -634,7 +686,11 @@ describe("MatrixClient", function() { }); it("copies pre-thread in-timeline reactions onto both timelines", function() { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); @@ -661,7 +717,11 @@ describe("MatrixClient", function() { }); it("copies post-thread in-timeline vote events onto both timelines", function() { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; const eventPollResponseReference = buildEventPollResponseReference(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); @@ -688,7 +748,11 @@ describe("MatrixClient", function() { }); it("copies post-thread in-timeline reactions onto both timelines", function() { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); @@ -715,7 +779,11 @@ describe("MatrixClient", function() { }); it("sends room state events to the main timeline only", function() { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; // This is based on recording the events in a real room: const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); @@ -768,7 +836,11 @@ describe("MatrixClient", function() { }); it("sends redactions of reactions to thread responses to thread timeline only", () => { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; const threadRootEvent = buildEventPollStartThreadRoot(); const eventMessageInThread = buildEventMessageInThread(threadRootEvent); @@ -797,7 +869,11 @@ describe("MatrixClient", function() { }); it("sends reply to reply to thread root outside of thread to main timeline only", () => { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; const threadRootEvent = buildEventPollStartThreadRoot(); const eventMessageInThread = buildEventMessageInThread(threadRootEvent); @@ -826,7 +902,11 @@ describe("MatrixClient", function() { }); it("sends reply to thread responses to main timeline only", () => { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; const threadRootEvent = buildEventPollStartThreadRoot(); const eventMessageInThread = buildEventMessageInThread(threadRootEvent); @@ -860,9 +940,9 @@ describe("MatrixClient", function() { fields: {}, }]; - const prom = client.getThirdpartyUser("irc", {}); - httpBackend.when("GET", "/thirdparty/user/irc").respond(200, response); - await httpBackend.flush(); + const prom = client!.getThirdpartyUser("irc", {}); + httpBackend!.when("GET", "/thirdparty/user/irc").respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -875,9 +955,9 @@ describe("MatrixClient", function() { fields: {}, }]; - const prom = client.getThirdpartyLocation("irc", {}); - httpBackend.when("GET", "/thirdparty/location/irc").respond(200, response); - await httpBackend.flush(); + const prom = client!.getThirdpartyLocation("irc", {}); + httpBackend!.when("GET", "/thirdparty/location/irc").respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -888,9 +968,10 @@ describe("MatrixClient", function() { pushers: [], }; - const prom = client.getPushers(); - httpBackend.when("GET", "/pushers").respond(200, response); - await httpBackend.flush(); + const prom = client!.getPushers(); + httpBackend!.when("GET", "/_matrix/client/versions").respond(200, {}); + httpBackend!.when("GET", "/pushers").respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -902,12 +983,12 @@ describe("MatrixClient", function() { left: [], }; - const prom = client.getKeyChanges("old", "new"); - httpBackend.when("GET", "/keys/changes").check((req) => { - expect(req.queryParams.from).toEqual("old"); - expect(req.queryParams.to).toEqual("new"); + const prom = client!.getKeyChanges("old", "new"); + httpBackend!.when("GET", "/keys/changes").check((req) => { + expect(req.queryParams?.from).toEqual("old"); + expect(req.queryParams?.to).toEqual("new"); }).respond(200, response); - await httpBackend.flush(); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -918,9 +999,9 @@ describe("MatrixClient", function() { devices: [], }; - const prom = client.getDevices(); - httpBackend.when("GET", "/devices").respond(200, response); - await httpBackend.flush(); + const prom = client!.getDevices(); + httpBackend!.when("GET", "/devices").respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -934,9 +1015,9 @@ describe("MatrixClient", function() { last_seen_ts: 1, }; - const prom = client.getDevice("DEADBEEF"); - httpBackend.when("GET", "/devices/DEADBEEF").respond(200, response); - await httpBackend.flush(); + const prom = client!.getDevice("DEADBEEF"); + httpBackend!.when("GET", "/devices/DEADBEEF").respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -947,9 +1028,9 @@ describe("MatrixClient", function() { threepids: [], }; - const prom = client.getThreePids(); - httpBackend.when("GET", "/account/3pid").respond(200, response); - await httpBackend.flush(); + const prom = client!.getThreePids(); + httpBackend!.when("GET", "/account/3pid").respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -957,9 +1038,9 @@ describe("MatrixClient", function() { describe("deleteAlias", () => { it("should hit the expected API endpoint", async () => { const response = {}; - const prom = client.deleteAlias("#foo:bar"); - httpBackend.when("DELETE", "/directory/room/" + encodeURIComponent("#foo:bar")).respond(200, response); - await httpBackend.flush(); + const prom = client!.deleteAlias("#foo:bar"); + httpBackend!.when("DELETE", "/directory/room/" + encodeURIComponent("#foo:bar")).respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -967,10 +1048,10 @@ describe("MatrixClient", function() { describe("deleteRoomTag", () => { it("should hit the expected API endpoint", async () => { const response = {}; - const prom = client.deleteRoomTag("!roomId:server", "u.tag"); + const prom = client!.deleteRoomTag("!roomId:server", "u.tag"); const url = `/user/${encodeURIComponent(userId)}/rooms/${encodeURIComponent("!roomId:server")}/tags/u.tag`; - httpBackend.when("DELETE", url).respond(200, response); - await httpBackend.flush(); + httpBackend!.when("DELETE", url).respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -985,10 +1066,10 @@ describe("MatrixClient", function() { }, }; - const prom = client.getRoomTags("!roomId:server"); + const prom = client!.getRoomTags("!roomId:server"); const url = `/user/${encodeURIComponent(userId)}/rooms/${encodeURIComponent("!roomId:server")}/tags`; - httpBackend.when("GET", url).respond(200, response); - await httpBackend.flush(); + httpBackend!.when("GET", url).respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -1000,19 +1081,19 @@ describe("MatrixClient", function() { submit_url: "https://foobar.matrix/_matrix/matrix", }; - httpBackend.when("GET", "/_matrix/client/versions").respond(200, { + httpBackend!.when("GET", "/_matrix/client/versions").respond(200, { versions: ["r0.6.0"], }); - const prom = client.requestRegisterEmailToken("bob@email", "secret", 1); - httpBackend.when("POST", "/register/email/requestToken").check(req => { + const prom = client!.requestRegisterEmailToken("bob@email", "secret", 1); + httpBackend!.when("POST", "/register/email/requestToken").check(req => { expect(req.data).toStrictEqual({ email: "bob@email", client_secret: "secret", send_attempt: 1, }); }).respond(200, response); - await httpBackend.flush(); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -1021,11 +1102,11 @@ describe("MatrixClient", function() { it("should supply an id_access_token", async () => { const targetEmail = "gerald@example.org"; - httpBackend.when("GET", "/_matrix/client/versions").respond(200, { + httpBackend!.when("GET", "/_matrix/client/versions").respond(200, { versions: ["r0.6.0"], }); - httpBackend.when("POST", "/invite").check(req => { + httpBackend!.when("POST", "/invite").check(req => { expect(req.data).toStrictEqual({ id_server: idServerDomain, id_access_token: identityAccessToken, @@ -1034,8 +1115,8 @@ describe("MatrixClient", function() { }); }).respond(200, {}); - const prom = client.inviteByThreePid("!room:example.org", "email", targetEmail); - await httpBackend.flush(); + const prom = client!.inviteByThreePid("!room:example.org", "email", targetEmail); + await httpBackend!.flush(''); await prom; // returns empty object, so no validation needed }); }); @@ -1055,11 +1136,11 @@ describe("MatrixClient", function() { }], }; - httpBackend.when("GET", "/_matrix/client/versions").respond(200, { + httpBackend!.when("GET", "/_matrix/client/versions").respond(200, { versions: ["r0.6.0"], }); - httpBackend.when("POST", "/createRoom").check(req => { + httpBackend!.when("POST", "/createRoom").check(req => { expect(req.data).toMatchObject({ invite_3pid: expect.arrayContaining([{ ...input.invite_3pid[0], @@ -1069,8 +1150,31 @@ describe("MatrixClient", function() { expect(req.data.invite_3pid.length).toBe(1); }).respond(200, response); - const prom = client.createRoom(input); - await httpBackend.flush(); + const prom = client!.createRoom(input); + await httpBackend!.flush(''); + expect(await prom).toStrictEqual(response); + }); + }); + + describe("requestLoginToken", () => { + it("should hit the expected API endpoint with UIA", async () => { + const response = {}; + const uiaData = {}; + const prom = client!.requestLoginToken(uiaData); + httpBackend! + .when("POST", "/unstable/org.matrix.msc3882/login/token", { auth: uiaData }) + .respond(200, response); + await httpBackend!.flush(''); + expect(await prom).toStrictEqual(response); + }); + + it("should hit the expected API endpoint without UIA", async () => { + const response = {}; + const prom = client!.requestLoginToken(); + httpBackend! + .when("POST", "/unstable/org.matrix.msc3882/login/token", {}) + .respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); diff --git a/spec/integ/matrix-client-opts.spec.js b/spec/integ/matrix-client-opts.spec.ts similarity index 93% rename from spec/integ/matrix-client-opts.spec.js rename to spec/integ/matrix-client-opts.spec.ts index 8e342b259..714030074 100644 --- a/spec/integ/matrix-client-opts.spec.js +++ b/spec/integ/matrix-client-opts.spec.ts @@ -5,10 +5,12 @@ import { MatrixClient } from "../../src/matrix"; import { MatrixScheduler } from "../../src/scheduler"; import { MemoryStore } from "../../src/store/memory"; import { MatrixError } from "../../src/http-api"; +import { ICreateClientOpts } from "../../src/client"; +import { IStore } from "../../src/store"; describe("MatrixClient opts", function() { const baseUrl = "http://localhost.or.something"; - let httpBackend = null; + let httpBackend = new HttpBackend(); const userId = "@alice:localhost"; const userB = "@bob:localhost"; const accessToken = "aseukfgwef"; @@ -67,7 +69,7 @@ describe("MatrixClient opts", function() { let client; beforeEach(function() { client = new MatrixClient({ - request: httpBackend.requestFn, + request: httpBackend.requestFn as unknown as ICreateClientOpts['request'], store: undefined, baseUrl: baseUrl, userId: userId, @@ -99,7 +101,7 @@ describe("MatrixClient opts", function() { ]; client.on("event", function(event) { expect(expectedEventTypes.indexOf(event.getType())).not.toEqual( - -1, "Recv unexpected event type: " + event.getType(), + -1, ); expectedEventTypes.splice( expectedEventTypes.indexOf(event.getType()), 1, @@ -118,7 +120,7 @@ describe("MatrixClient opts", function() { utils.syncPromise(client), ]); expect(expectedEventTypes.length).toEqual( - 0, "Expected to see event types: " + expectedEventTypes, + 0, ); }); }); @@ -127,8 +129,8 @@ describe("MatrixClient opts", function() { let client; beforeEach(function() { client = new MatrixClient({ - request: httpBackend.requestFn, - store: new MemoryStore(), + request: httpBackend.requestFn as unknown as ICreateClientOpts['request'], + store: new MemoryStore() as IStore, baseUrl: baseUrl, userId: userId, accessToken: accessToken, @@ -146,7 +148,7 @@ describe("MatrixClient opts", function() { error: "Ruh roh", })); client.sendTextMessage("!foo:bar", "a body", "txn1").then(function(res) { - expect(false).toBe(true, "sendTextMessage resolved but shouldn't"); + expect(false).toBe(true); }, function(err) { expect(err.errcode).toEqual("M_SOMETHING"); done(); diff --git a/spec/integ/matrix-client-relations.spec.ts b/spec/integ/matrix-client-relations.spec.ts new file mode 100644 index 000000000..3a8a99fbf --- /dev/null +++ b/spec/integ/matrix-client-relations.spec.ts @@ -0,0 +1,127 @@ +/* +Copyright 2022 Dominik Henneke +Copyright 2022 Nordeck IT + Consulting GmbH. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import HttpBackend from "matrix-mock-request"; + +import { Direction, MatrixClient, MatrixScheduler } from "../../src/matrix"; +import { TestClient } from "../TestClient"; + +describe("MatrixClient relations", () => { + const userId = "@alice:localhost"; + const accessToken = "aseukfgwef"; + const roomId = "!room:here"; + let client: MatrixClient | undefined; + let httpBackend: HttpBackend | undefined; + + const setupTests = (): [MatrixClient, HttpBackend] => { + const scheduler = new MatrixScheduler(); + const testClient = new TestClient( + userId, + "DEVICE", + accessToken, + undefined, + { scheduler }, + ); + const httpBackend = testClient.httpBackend; + const client = testClient.client; + + return [client, httpBackend]; + }; + + beforeEach(() => { + [client, httpBackend] = setupTests(); + }); + + afterEach(() => { + httpBackend!.verifyNoOutstandingExpectation(); + return httpBackend!.stop(); + }); + + it("should read related events with the default options", async () => { + const response = client!.relations(roomId, '$event-0', null, null); + + httpBackend! + .when("GET", "/rooms/!room%3Ahere/relations/%24event-0?dir=b") + .respond(200, { chunk: [], next_batch: 'NEXT' }); + + await httpBackend!.flushAllExpected(); + + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + }); + + it("should read related events with relation type", async () => { + const response = client!.relations(roomId, '$event-0', 'm.reference', null); + + httpBackend! + .when("GET", "/rooms/!room%3Ahere/relations/%24event-0/m.reference?dir=b") + .respond(200, { chunk: [], next_batch: 'NEXT' }); + + await httpBackend!.flushAllExpected(); + + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + }); + + it("should read related events with relation type and event type", async () => { + const response = client!.relations(roomId, '$event-0', 'm.reference', 'm.room.message'); + + httpBackend! + .when( + "GET", + "/rooms/!room%3Ahere/relations/%24event-0/m.reference/m.room.message?dir=b", + ) + .respond(200, { chunk: [], next_batch: 'NEXT' }); + + await httpBackend!.flushAllExpected(); + + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + }); + + it("should read related events with custom options", async () => { + const response = client!.relations(roomId, '$event-0', null, null, { + dir: Direction.Forward, + from: 'FROM', + limit: 10, + to: 'TO', + }); + + httpBackend! + .when( + "GET", + "/rooms/!room%3Ahere/relations/%24event-0?dir=f&from=FROM&limit=10&to=TO", + ) + .respond(200, { chunk: [], next_batch: 'NEXT' }); + + await httpBackend!.flushAllExpected(); + + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + }); + + it('should use default direction in the fetchRelations endpoint', async () => { + const response = client!.fetchRelations(roomId, '$event-0', null, null); + + httpBackend! + .when( + "GET", + "/rooms/!room%3Ahere/relations/%24event-0?dir=b", + ) + .respond(200, { chunk: [], next_batch: 'NEXT' }); + + await httpBackend!.flushAllExpected(); + + expect(await response).toEqual({ "chunk": [], "next_batch": "NEXT" }); + }); +}); diff --git a/spec/integ/matrix-client-retrying.spec.ts b/spec/integ/matrix-client-retrying.spec.ts index 31354b89a..877e80ac9 100644 --- a/spec/integ/matrix-client-retrying.spec.ts +++ b/spec/integ/matrix-client-retrying.spec.ts @@ -14,22 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventStatus, RoomEvent, MatrixClient } from "../../src/matrix"; -import { MatrixScheduler } from "../../src/scheduler"; +import HttpBackend from "matrix-mock-request"; + +import { EventStatus, RoomEvent, MatrixClient, MatrixScheduler } from "../../src/matrix"; import { Room } from "../../src/models/room"; import { TestClient } from "../TestClient"; describe("MatrixClient retrying", function() { - let client: MatrixClient = null; - let httpBackend: TestClient["httpBackend"] = null; - let scheduler; const userId = "@alice:localhost"; const accessToken = "aseukfgwef"; const roomId = "!room:here"; - let room: Room; + let client: MatrixClient | undefined; + let httpBackend: HttpBackend | undefined; + let room: Room | undefined; - beforeEach(function() { - scheduler = new MatrixScheduler(); + const setupTests = (): [MatrixClient, HttpBackend, Room] => { + const scheduler = new MatrixScheduler(); const testClient = new TestClient( userId, "DEVICE", @@ -37,15 +37,21 @@ describe("MatrixClient retrying", function() { undefined, { scheduler }, ); - httpBackend = testClient.httpBackend; - client = testClient.client; - room = new Room(roomId, client, userId); - client.store.storeRoom(room); + const httpBackend = testClient.httpBackend; + const client = testClient.client; + const room = new Room(roomId, client, userId); + client!.store.storeRoom(room); + + return [client, httpBackend, room]; + }; + + beforeEach(function() { + [client, httpBackend, room] = setupTests(); }); afterEach(function() { - httpBackend.verifyNoOutstandingExpectation(); - return httpBackend.stop(); + httpBackend!.verifyNoOutstandingExpectation(); + return httpBackend!.stop(); }); xit("should retry according to MatrixScheduler.retryFn", function() { @@ -66,7 +72,7 @@ describe("MatrixClient retrying", function() { it("should mark events as EventStatus.CANCELLED when cancelled", function() { // send a couple of events; the second will be queued - const p1 = client.sendMessage(roomId, { + const p1 = client!.sendMessage(roomId, { "msgtype": "m.text", "body": "m1", }).then(function() { @@ -79,13 +85,13 @@ describe("MatrixClient retrying", function() { // XXX: it turns out that the promise returned by this message // never gets resolved. // https://github.com/matrix-org/matrix-js-sdk/issues/496 - client.sendMessage(roomId, { + client!.sendMessage(roomId, { "msgtype": "m.text", "body": "m2", }); // both events should be in the timeline at this point - const tl = room.getLiveTimeline().getEvents(); + const tl = room!.getLiveTimeline().getEvents(); expect(tl.length).toEqual(2); const ev1 = tl[0]; const ev2 = tl[1]; @@ -94,24 +100,24 @@ describe("MatrixClient retrying", function() { expect(ev2.status).toEqual(EventStatus.SENDING); // the first message should get sent, and the second should get queued - httpBackend.when("PUT", "/send/m.room.message/").check(function() { + httpBackend!.when("PUT", "/send/m.room.message/").check(function() { // ev2 should now have been queued expect(ev2.status).toEqual(EventStatus.QUEUED); // now we can cancel the second and check everything looks sane - client.cancelPendingEvent(ev2); + client!.cancelPendingEvent(ev2); expect(ev2.status).toEqual(EventStatus.CANCELLED); expect(tl.length).toEqual(1); // shouldn't be able to cancel the first message yet expect(function() { - client.cancelPendingEvent(ev1); + client!.cancelPendingEvent(ev1); }).toThrow(); }).respond(400); // fail the first message // wait for the localecho of ev1 to be updated const p3 = new Promise((resolve, reject) => { - room.on(RoomEvent.LocalEchoUpdated, (ev0) => { + room!.on(RoomEvent.LocalEchoUpdated, (ev0) => { if (ev0 === ev1) { resolve(); } @@ -121,7 +127,7 @@ describe("MatrixClient retrying", function() { expect(tl.length).toEqual(1); // cancel the first message - client.cancelPendingEvent(ev1); + client!.cancelPendingEvent(ev1); expect(ev1.status).toEqual(EventStatus.CANCELLED); expect(tl.length).toEqual(0); }); @@ -129,7 +135,7 @@ describe("MatrixClient retrying", function() { return Promise.all([ p1, p3, - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), ]); }); diff --git a/spec/integ/matrix-client-room-timeline.spec.js b/spec/integ/matrix-client-room-timeline.spec.ts similarity index 76% rename from spec/integ/matrix-client-room-timeline.spec.js rename to spec/integ/matrix-client-room-timeline.spec.ts index acf751a8c..48ecee32b 100644 --- a/spec/integ/matrix-client-room-timeline.spec.js +++ b/spec/integ/matrix-client-room-timeline.spec.ts @@ -1,16 +1,35 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import HttpBackend from "matrix-mock-request"; + import * as utils from "../test-utils/test-utils"; import { EventStatus } from "../../src/models/event"; -import { RoomEvent } from "../../src"; +import { ClientEvent, IEvent, MatrixClient, RoomEvent } from "../../src"; import { TestClient } from "../TestClient"; describe("MatrixClient room timelines", function() { - let client = null; - let httpBackend = null; const userId = "@alice:localhost"; const userName = "Alice"; const accessToken = "aseukfgwef"; const roomId = "!foo:bar"; const otherUserId = "@bob:localhost"; + let client: MatrixClient | undefined; + let httpBackend: HttpBackend | undefined; + const USER_MEMBERSHIP_EVENT = utils.mkMembership({ room: roomId, mship: "join", user: userId, name: userName, }); @@ -55,8 +74,7 @@ describe("MatrixClient room timelines", function() { }, }; - function setNextSyncData(events) { - events = events || []; + function setNextSyncData(events: Partial[] = []) { NEXT_SYNC_DATA = { next_batch: "n", presence: { events: [] }, @@ -77,19 +95,9 @@ describe("MatrixClient room timelines", function() { throw new Error("setNextSyncData only works with one room id"); } if (e.state_key) { - if (e.__prev_event === undefined) { - throw new Error( - "setNextSyncData needs the prev state set to '__prev_event' " + - "for " + e.type, - ); - } - if (e.__prev_event !== null) { - // push the previous state for this event type - NEXT_SYNC_DATA.rooms.join[roomId].state.events.push(e.__prev_event); - } // push the current NEXT_SYNC_DATA.rooms.join[roomId].timeline.events.push(e); - } else if (["m.typing", "m.receipt"].indexOf(e.type) !== -1) { + } else if (["m.typing", "m.receipt"].indexOf(e.type!) !== -1) { NEXT_SYNC_DATA.rooms.join[roomId].ephemeral.events.push(e); } else { NEXT_SYNC_DATA.rooms.join[roomId].timeline.events.push(e); @@ -97,7 +105,7 @@ describe("MatrixClient room timelines", function() { }); } - beforeEach(async function() { + const setupTestClient = (): [MatrixClient, HttpBackend] => { // these tests should work with or without timelineSupport const testClient = new TestClient( userId, @@ -106,41 +114,46 @@ describe("MatrixClient room timelines", function() { undefined, { timelineSupport: true }, ); - httpBackend = testClient.httpBackend; - client = testClient.client; + const httpBackend = testClient.httpBackend; + const client = testClient.client; setNextSyncData(); - httpBackend.when("GET", "/versions").respond(200, {}); - httpBackend.when("GET", "/pushrules").respond(200, {}); - httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); - httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); - httpBackend.when("GET", "/sync").respond(200, function() { + httpBackend!.when("GET", "/versions").respond(200, {}); + httpBackend!.when("GET", "/pushrules").respond(200, {}); + httpBackend!.when("POST", "/filter").respond(200, { filter_id: "fid" }); + httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, function() { return NEXT_SYNC_DATA; }); - client.startClient(); + client!.startClient(); + return [client!, httpBackend]; + }; + + beforeEach(async function() { + [client!, httpBackend] = setupTestClient(); await httpBackend.flush("/versions"); await httpBackend.flush("/pushrules"); await httpBackend.flush("/filter"); }); afterEach(function() { - httpBackend.verifyNoOutstandingExpectation(); - client.stopClient(); - return httpBackend.stop(); + httpBackend!.verifyNoOutstandingExpectation(); + client!.stopClient(); + return httpBackend!.stop(); }); describe("local echo events", function() { it("should be added immediately after calling MatrixClient.sendEvent " + "with EventStatus.SENDING and the right event.sender", function(done) { - client.on("sync", function(state) { + client!.on(ClientEvent.Sync, function(state) { if (state !== "PREPARED") { return; } - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; expect(room.timeline.length).toEqual(1); - client.sendTextMessage(roomId, "I am a fish", "txn1"); + client!.sendTextMessage(roomId, "I am a fish", "txn1"); // check it was added expect(room.timeline.length).toEqual(2); // check status @@ -150,68 +163,68 @@ describe("MatrixClient room timelines", function() { expect(member.userId).toEqual(userId); expect(member.name).toEqual(userName); - httpBackend.flush("/sync", 1).then(function() { + httpBackend!.flush("/sync", 1).then(function() { done(); }); }); - httpBackend.flush("/sync", 1); + httpBackend!.flush("/sync", 1); }); it("should be updated correctly when the send request finishes " + "BEFORE the event comes down the event stream", function(done) { const eventId = "$foo:bar"; - httpBackend.when("PUT", "/txn1").respond(200, { + httpBackend!.when("PUT", "/txn1").respond(200, { event_id: eventId, }); const ev = utils.mkMessage({ - body: "I am a fish", user: userId, room: roomId, + msg: "I am a fish", user: userId, room: roomId, }); ev.event_id = eventId; ev.unsigned = { transaction_id: "txn1" }; setNextSyncData([ev]); - client.on("sync", function(state) { + client!.on(ClientEvent.Sync, function(state) { if (state !== "PREPARED") { return; } - const room = client.getRoom(roomId); - client.sendTextMessage(roomId, "I am a fish", "txn1").then( - function() { - expect(room.timeline[1].getId()).toEqual(eventId); - httpBackend.flush("/sync", 1).then(function() { + const room = client!.getRoom(roomId)!; + client!.sendTextMessage(roomId, "I am a fish", "txn1").then( + function() { expect(room.timeline[1].getId()).toEqual(eventId); - done(); + httpBackend!.flush("/sync", 1).then(function() { + expect(room.timeline[1].getId()).toEqual(eventId); + done(); + }); }); - }); - httpBackend.flush("/txn1", 1); + httpBackend!.flush("/txn1", 1); }); - httpBackend.flush("/sync", 1); + httpBackend!.flush("/sync", 1); }); it("should be updated correctly when the send request finishes " + "AFTER the event comes down the event stream", function(done) { const eventId = "$foo:bar"; - httpBackend.when("PUT", "/txn1").respond(200, { + httpBackend!.when("PUT", "/txn1").respond(200, { event_id: eventId, }); const ev = utils.mkMessage({ - body: "I am a fish", user: userId, room: roomId, + msg: "I am a fish", user: userId, room: roomId, }); ev.event_id = eventId; ev.unsigned = { transaction_id: "txn1" }; setNextSyncData([ev]); - client.on("sync", function(state) { + client!.on(ClientEvent.Sync, function(state) { if (state !== "PREPARED") { return; } - const room = client.getRoom(roomId); - const promise = client.sendTextMessage(roomId, "I am a fish", "txn1"); - httpBackend.flush("/sync", 1).then(function() { + const room = client!.getRoom(roomId)!; + const promise = client!.sendTextMessage(roomId, "I am a fish", "txn1"); + httpBackend!.flush("/sync", 1).then(function() { expect(room.timeline.length).toEqual(2); - httpBackend.flush("/txn1", 1); + httpBackend!.flush("/txn1", 1); promise.then(function() { expect(room.timeline.length).toEqual(2); expect(room.timeline[1].getId()).toEqual(eventId); @@ -219,7 +232,7 @@ describe("MatrixClient room timelines", function() { }); }); }); - httpBackend.flush("/sync", 1); + httpBackend!.flush("/sync", 1); }); }); @@ -229,7 +242,7 @@ describe("MatrixClient room timelines", function() { beforeEach(function() { sbEvents = []; - httpBackend.when("GET", "/messages").respond(200, function() { + httpBackend!.when("GET", "/messages").respond(200, function() { return { chunk: sbEvents, start: "pagin_start", @@ -240,26 +253,26 @@ describe("MatrixClient room timelines", function() { it("should set Room.oldState.paginationToken to null at the start" + " of the timeline.", function(done) { - client.on("sync", function(state) { + client!.on(ClientEvent.Sync, function(state) { if (state !== "PREPARED") { return; } - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; expect(room.timeline.length).toEqual(1); - client.scrollback(room).then(function() { + client!.scrollback(room).then(function() { expect(room.timeline.length).toEqual(1); expect(room.oldState.paginationToken).toBe(null); // still have a sync to flush - httpBackend.flush("/sync", 1).then(() => { + httpBackend!.flush("/sync", 1).then(() => { done(); }); }); - httpBackend.flush("/messages", 1); + httpBackend!.flush("/messages", 1); }); - httpBackend.flush("/sync", 1); + httpBackend!.flush("/sync", 1); }); it("should set the right event.sender values", function(done) { @@ -275,7 +288,7 @@ describe("MatrixClient room timelines", function() { // make an m.room.member event for alice's join const joinMshipEvent = utils.mkMembership({ mship: "join", user: userId, room: roomId, name: "Old Alice", - url: null, + url: undefined, }); // make an m.room.member event with prev_content for alice's nick @@ -286,7 +299,7 @@ describe("MatrixClient room timelines", function() { }); oldMshipEvent.prev_content = { displayname: "Old Alice", - avatar_url: null, + avatar_url: undefined, membership: "join", }; @@ -303,15 +316,15 @@ describe("MatrixClient room timelines", function() { joinMshipEvent, ]; - client.on("sync", function(state) { + client!.on(ClientEvent.Sync, function(state) { if (state !== "PREPARED") { return; } - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; // sync response expect(room.timeline.length).toEqual(1); - client.scrollback(room).then(function() { + client!.scrollback(room).then(function() { expect(room.timeline.length).toEqual(5); const joinMsg = room.timeline[0]; expect(joinMsg.sender.name).toEqual("Old Alice"); @@ -321,14 +334,14 @@ describe("MatrixClient room timelines", function() { expect(newMsg.sender.name).toEqual(userName); // still have a sync to flush - httpBackend.flush("/sync", 1).then(() => { + httpBackend!.flush("/sync", 1).then(() => { done(); }); }); - httpBackend.flush("/messages", 1); + httpBackend!.flush("/messages", 1); }); - httpBackend.flush("/sync", 1); + httpBackend!.flush("/sync", 1); }); it("should add it them to the right place in the timeline", function(done) { @@ -342,27 +355,27 @@ describe("MatrixClient room timelines", function() { }), ]; - client.on("sync", function(state) { + client!.on(ClientEvent.Sync, function(state) { if (state !== "PREPARED") { return; } - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; expect(room.timeline.length).toEqual(1); - client.scrollback(room).then(function() { + client!.scrollback(room).then(function() { expect(room.timeline.length).toEqual(3); expect(room.timeline[0].event).toEqual(sbEvents[1]); expect(room.timeline[1].event).toEqual(sbEvents[0]); // still have a sync to flush - httpBackend.flush("/sync", 1).then(() => { + httpBackend!.flush("/sync", 1).then(() => { done(); }); }); - httpBackend.flush("/messages", 1); + httpBackend!.flush("/messages", 1); }); - httpBackend.flush("/sync", 1); + httpBackend!.flush("/sync", 1); }); it("should use 'end' as the next pagination token", function(done) { @@ -373,25 +386,25 @@ describe("MatrixClient room timelines", function() { }), ]; - client.on("sync", function(state) { + client!.on(ClientEvent.Sync, function(state) { if (state !== "PREPARED") { return; } - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; expect(room.oldState.paginationToken).toBeTruthy(); - client.scrollback(room, 1).then(function() { + client!.scrollback(room, 1).then(function() { expect(room.oldState.paginationToken).toEqual(sbEndTok); }); - httpBackend.flush("/messages", 1).then(function() { + httpBackend!.flush("/messages", 1).then(function() { // still have a sync to flush - httpBackend.flush("/sync", 1).then(() => { + httpBackend!.flush("/sync", 1).then(() => { done(); }); }); }); - httpBackend.flush("/sync", 1); + httpBackend!.flush("/sync", 1); }); }); @@ -404,23 +417,23 @@ describe("MatrixClient room timelines", function() { setNextSyncData(eventData); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(() => { - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; let index = 0; - client.on("Room.timeline", function(event, rm, toStart) { + client!.on(RoomEvent.Timeline, function(event, rm, toStart) { expect(toStart).toBe(false); expect(rm).toEqual(room); expect(event.event).toEqual(eventData[index]); index += 1; }); - httpBackend.flush("/messages", 1); + httpBackend!.flush("/messages", 1); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(function() { expect(index).toEqual(2); expect(room.timeline.length).toEqual(3); @@ -442,17 +455,16 @@ describe("MatrixClient room timelines", function() { }), utils.mkMessage({ user: userId, room: roomId }), ]; - eventData[1].__prev_event = USER_MEMBERSHIP_EVENT; setNextSyncData(eventData); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(() => { - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(function() { const preNameEvent = room.timeline[room.timeline.length - 3]; const postNameEvent = room.timeline[room.timeline.length - 1]; @@ -468,22 +480,21 @@ describe("MatrixClient room timelines", function() { name: "Room 2", }, }); - secondRoomNameEvent.__prev_event = ROOM_NAME_EVENT; setNextSyncData([secondRoomNameEvent]); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(() => { - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; let nameEmitCount = 0; - client.on("Room.name", function(rm) { + client!.on(RoomEvent.Name, function(rm) { nameEmitCount += 1; }); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(function() { expect(nameEmitCount).toEqual(1); expect(room.name).toEqual("Room 2"); @@ -493,12 +504,11 @@ describe("MatrixClient room timelines", function() { name: "Room 3", }, }); - thirdRoomNameEvent.__prev_event = secondRoomNameEvent; setNextSyncData([thirdRoomNameEvent]); - httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]); }).then(function() { expect(nameEmitCount).toEqual(2); @@ -518,26 +528,24 @@ describe("MatrixClient room timelines", function() { user: userC, room: roomId, mship: "invite", skey: userD, }), ]; - eventData[0].__prev_event = null; - eventData[1].__prev_event = null; setNextSyncData(eventData); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(() => { - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(function() { expect(room.currentState.getMembers().length).toEqual(4); - expect(room.currentState.getMember(userC).name).toEqual("C"); - expect(room.currentState.getMember(userC).membership).toEqual( + expect(room.currentState.getMember(userC)!.name).toEqual("C"); + expect(room.currentState.getMember(userC)!.membership).toEqual( "join", ); - expect(room.currentState.getMember(userD).name).toEqual(userD); - expect(room.currentState.getMember(userD).membership).toEqual( + expect(room.currentState.getMember(userD)!.name).toEqual(userD); + expect(room.currentState.getMember(userD)!.membership).toEqual( "invite", ); }); @@ -554,26 +562,26 @@ describe("MatrixClient room timelines", function() { NEXT_SYNC_DATA.rooms.join[roomId].timeline.limited = true; return Promise.all([ - httpBackend.flush("/versions", 1), - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/versions", 1), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(() => { - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; - httpBackend.flush("/messages", 1); + httpBackend!.flush("/messages", 1); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(function() { expect(room.timeline.length).toEqual(1); expect(room.timeline[0].event).toEqual(eventData[0]); expect(room.currentState.getMembers().length).toEqual(2); - expect(room.currentState.getMember(userId).name).toEqual(userName); - expect(room.currentState.getMember(userId).membership).toEqual( + expect(room.currentState.getMember(userId)!.name).toEqual(userName); + expect(room.currentState.getMember(userId)!.membership).toEqual( "join", ); - expect(room.currentState.getMember(otherUserId).name).toEqual("Bob"); - expect(room.currentState.getMember(otherUserId).membership).toEqual( + expect(room.currentState.getMember(otherUserId)!.name).toEqual("Bob"); + expect(room.currentState.getMember(otherUserId)!.membership).toEqual( "join", ); }); @@ -588,21 +596,21 @@ describe("MatrixClient room timelines", function() { NEXT_SYNC_DATA.rooms.join[roomId].timeline.limited = true; return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(() => { - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; let emitCount = 0; - client.on("Room.timelineReset", function(emitRoom) { + client!.on(RoomEvent.TimelineReset, function(emitRoom) { expect(emitRoom).toEqual(room); emitCount++; }); - httpBackend.flush("/messages", 1); + httpBackend!.flush("/messages", 1); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(function() { expect(emitCount).toEqual(1); }); @@ -618,7 +626,7 @@ describe("MatrixClient room timelines", function() { ]; const contextUrl = `/rooms/${encodeURIComponent(roomId)}/context/` + - `${encodeURIComponent(initialSyncEventData[2].event_id)}`; + `${encodeURIComponent(initialSyncEventData[2].event_id!)}`; const contextResponse = { start: "start_token", events_before: [initialSyncEventData[1], initialSyncEventData[0]], @@ -636,19 +644,19 @@ describe("MatrixClient room timelines", function() { // Create a room from the sync await Promise.all([ - httpBackend.flushAllExpected(), - utils.syncPromise(client, 1), + httpBackend!.flushAllExpected(), + utils.syncPromise(client!, 1), ]); // Get the room after the first sync so the room is created - room = client.getRoom(roomId); + room = client!.getRoom(roomId)!; expect(room).toBeTruthy(); }); it('should clear and refresh messages in timeline', async () => { // `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()` // to construct a new timeline from. - httpBackend.when("GET", contextUrl) + httpBackend!.when("GET", contextUrl) .respond(200, function() { // The timeline should be cleared at this point in the refresh expect(room.timeline.length).toEqual(0); @@ -659,7 +667,7 @@ describe("MatrixClient room timelines", function() { // Refresh the timeline. await Promise.all([ room.refreshLiveTimeline(), - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), ]); // Make sure the message are visible @@ -681,7 +689,7 @@ describe("MatrixClient room timelines", function() { // middle of all of this refresh timeline logic. We want to make // sure the sync pagination still works as expected after messing // the refresh timline logic messes with the pagination tokens. - httpBackend.when("GET", contextUrl) + httpBackend!.when("GET", contextUrl) .respond(200, () => { // Now finally return and make the `/context` request respond return contextResponse; @@ -700,7 +708,7 @@ describe("MatrixClient room timelines", function() { const racingSyncEventData = [ utils.mkMessage({ user: userId, room: roomId }), ]; - const waitForRaceySyncAfterResetPromise = new Promise((resolve, reject) => { + const waitForRaceySyncAfterResetPromise = new Promise((resolve, reject) => { let eventFired = false; // Throw a more descriptive error if this part of the test times out. const failTimeout = setTimeout(() => { @@ -726,12 +734,12 @@ describe("MatrixClient room timelines", function() { // Then make a `/sync` happen by sending a message and seeing that it // shows up (simulate a /sync naturally racing with us). setNextSyncData(racingSyncEventData); - httpBackend.when("GET", "/sync").respond(200, function() { + httpBackend!.when("GET", "/sync").respond(200, function() { return NEXT_SYNC_DATA; }); await Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client, 1), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!, 1), ]); // Make sure the timeline has the racey sync data const afterRaceySyncTimelineEvents = room @@ -761,7 +769,7 @@ describe("MatrixClient room timelines", function() { await Promise.all([ refreshLiveTimelinePromise, // Then flush the remaining `/context` to left the refresh logic complete - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), ]); // Make sure sync pagination still works by seeing a new message show up @@ -770,12 +778,12 @@ describe("MatrixClient room timelines", function() { utils.mkMessage({ user: userId, room: roomId }), ]; setNextSyncData(afterRefreshEventData); - httpBackend.when("GET", "/sync").respond(200, function() { + httpBackend!.when("GET", "/sync").respond(200, function() { return NEXT_SYNC_DATA; }); await Promise.all([ - httpBackend.flushAllExpected(), - utils.syncPromise(client, 1), + httpBackend!.flushAllExpected(), + utils.syncPromise(client!, 1), ]); // Make sure the timeline includes the the events from the `/sync` @@ -794,7 +802,7 @@ describe("MatrixClient room timelines", function() { it('Timeline recovers after `/context` request to generate new timeline fails', async () => { // `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()` // to construct a new timeline from. - httpBackend.when("GET", contextUrl) + httpBackend!.when("GET", contextUrl) .respond(500, function() { // The timeline should be cleared at this point in the refresh expect(room.timeline.length).toEqual(0); @@ -809,7 +817,7 @@ describe("MatrixClient room timelines", function() { // Refresh the timeline and expect it to fail const settledFailedRefreshPromises = await Promise.allSettled([ room.refreshLiveTimeline(), - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), ]); // We only expect `TEST_FAKE_ERROR` here. Anything else is // unexpected and should fail the test. @@ -825,7 +833,7 @@ describe("MatrixClient room timelines", function() { // `/messages` request for `refreshLiveTimeline()` -> // `getLatestTimeline()` to construct a new timeline from. - httpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/messages`) + httpBackend!.when("GET", `/rooms/${encodeURIComponent(roomId)}/messages`) .respond(200, function() { return { chunk: [{ @@ -837,7 +845,7 @@ describe("MatrixClient room timelines", function() { // `/context` request for `refreshLiveTimeline()` -> // `getLatestTimeline()` -> `getEventTimeline()` to construct a new // timeline from. - httpBackend.when("GET", contextUrl) + httpBackend!.when("GET", contextUrl) .respond(200, function() { // The timeline should be cleared at this point in the refresh expect(room.timeline.length).toEqual(0); @@ -848,7 +856,7 @@ describe("MatrixClient room timelines", function() { // Refresh the timeline again but this time it should pass await Promise.all([ room.refreshLiveTimeline(), - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), ]); // Make sure sync pagination still works by seeing a new message show up @@ -857,12 +865,12 @@ describe("MatrixClient room timelines", function() { utils.mkMessage({ user: userId, room: roomId }), ]; setNextSyncData(afterRefreshEventData); - httpBackend.when("GET", "/sync").respond(200, function() { + httpBackend!.when("GET", "/sync").respond(200, function() { return NEXT_SYNC_DATA; }); await Promise.all([ - httpBackend.flushAllExpected(), - utils.syncPromise(client, 1), + httpBackend!.flushAllExpected(), + utils.syncPromise(client!, 1), ]); // Make sure the message are visible diff --git a/spec/integ/matrix-client-syncing.spec.js b/spec/integ/matrix-client-syncing.spec.ts similarity index 64% rename from spec/integ/matrix-client-syncing.spec.js rename to spec/integ/matrix-client-syncing.spec.ts index 0c571707a..78795051c 100644 --- a/spec/integ/matrix-client-syncing.spec.js +++ b/spec/integ/matrix-client-syncing.spec.ts @@ -14,14 +14,32 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventTimeline, MatrixEvent, RoomEvent, RoomStateEvent, RoomMemberEvent } from "../../src"; -import { UNSTABLE_MSC2716_MARKER } from "../../src/@types/event"; +import 'fake-indexeddb/auto'; + +import HttpBackend from "matrix-mock-request"; + +import { + EventTimeline, + MatrixEvent, + RoomEvent, + RoomStateEvent, + RoomMemberEvent, + UNSTABLE_MSC2716_MARKER, + MatrixClient, + ClientEvent, + IndexedDBCryptoStore, + ISyncResponse, + IRoomEvent, + IJoinedRoom, + IStateEvent, + IMinimalEvent, + NotificationCountType, +} from "../../src"; +import { UNREAD_THREAD_NOTIFICATIONS } from '../../src/@types/sync'; import * as utils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; -describe("MatrixClient syncing", function() { - let client = null; - let httpBackend = null; +describe("MatrixClient syncing", () => { const selfUserId = "@alice:localhost"; const selfAccessToken = "aseukfgwef"; const otherUserId = "@bob:localhost"; @@ -30,54 +48,62 @@ describe("MatrixClient syncing", function() { const userC = "@claire:bar"; const roomOne = "!foo:localhost"; const roomTwo = "!bar:localhost"; + let client: MatrixClient | undefined; + let httpBackend: HttpBackend | undefined; - beforeEach(function() { + const setupTestClient = (): [MatrixClient, HttpBackend] => { const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken); - httpBackend = testClient.httpBackend; - client = testClient.client; - httpBackend.when("GET", "/versions").respond(200, {}); - httpBackend.when("GET", "/pushrules").respond(200, {}); - httpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + const httpBackend = testClient.httpBackend; + const client = testClient.client; + httpBackend!.when("GET", "/versions").respond(200, {}); + httpBackend!.when("GET", "/pushrules").respond(200, {}); + httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + return [client, httpBackend]; + }; + + beforeEach(() => { + [client, httpBackend] = setupTestClient(); }); - afterEach(function() { - httpBackend.verifyNoOutstandingExpectation(); - client.stopClient(); - return httpBackend.stop(); + afterEach(() => { + httpBackend!.verifyNoOutstandingExpectation(); + client!.stopClient(); + return httpBackend!.stop(); }); - describe("startClient", function() { + describe("startClient", () => { const syncData = { next_batch: "batch_token", rooms: {}, presence: {}, }; - it("should /sync after /pushrules and /filter.", function(done) { - httpBackend.when("GET", "/sync").respond(200, syncData); + it("should /sync after /pushrules and /filter.", (done) => { + httpBackend!.when("GET", "/sync").respond(200, syncData); - client.startClient(); + client!.startClient(); - httpBackend.flushAllExpected().then(function() { + httpBackend!.flushAllExpected().then(() => { done(); }); }); - it("should pass the 'next_batch' token from /sync to the since= param " + - " of the next /sync", function(done) { - httpBackend.when("GET", "/sync").respond(200, syncData); - httpBackend.when("GET", "/sync").check(function(req) { - expect(req.queryParams.since).toEqual(syncData.next_batch); + it("should pass the 'next_batch' token from /sync to the since= param of the next /sync", (done) => { + httpBackend!.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").check((req) => { + expect(req.queryParams!.since).toEqual(syncData.next_batch); }).respond(200, syncData); - client.startClient(); + client!.startClient(); - httpBackend.flushAllExpected().then(function() { + httpBackend!.flushAllExpected().then(() => { done(); }); }); it("should emit RoomEvent.MyMembership for invite->leave->invite cycles", async () => { + await client!.initCrypto(); + const roomId = "!cycles:example.org"; // First sync: an invite @@ -96,14 +122,14 @@ describe("MatrixClient syncing", function() { }, }, }; - httpBackend.when("GET", "/sync").respond(200, { + httpBackend!.when("GET", "/sync").respond(200, { ...syncData, rooms: inviteSyncRoomSection, }); // Second sync: a leave (reject of some kind) - httpBackend.when("POST", "/leave").respond(200, {}); - httpBackend.when("GET", "/sync").respond(200, { + httpBackend!.when("POST", "/leave").respond(200, {}); + httpBackend!.when("GET", "/sync").respond(200, { ...syncData, rooms: { leave: { @@ -143,28 +169,28 @@ describe("MatrixClient syncing", function() { }); // Third sync: another invite - httpBackend.when("GET", "/sync").respond(200, { + httpBackend!.when("GET", "/sync").respond(200, { ...syncData, rooms: inviteSyncRoomSection, }); // First fire: an initial invite let fires = 0; - client.once(RoomEvent.MyMembership, (room, membership, oldMembership) => { // Room, string, string + client!.once(RoomEvent.MyMembership, (room, membership, oldMembership) => { // Room, string, string fires++; expect(room.roomId).toBe(roomId); expect(membership).toBe("invite"); expect(oldMembership).toBeFalsy(); // Second fire: a leave - client.once(RoomEvent.MyMembership, (room, membership, oldMembership) => { + client!.once(RoomEvent.MyMembership, (room, membership, oldMembership) => { fires++; expect(room.roomId).toBe(roomId); expect(membership).toBe("leave"); expect(oldMembership).toBe("invite"); // Third/final fire: a second invite - client.once(RoomEvent.MyMembership, (room, membership, oldMembership) => { + client!.once(RoomEvent.MyMembership, (room, membership, oldMembership) => { fires++; expect(room.roomId).toBe(roomId); expect(membership).toBe("invite"); @@ -173,38 +199,142 @@ describe("MatrixClient syncing", function() { }); // For maximum safety, "leave" the room after we register the handler - client.leave(roomId); + client!.leave(roomId); }); // noinspection ES6MissingAwait - client.startClient(); - await httpBackend.flushAllExpected(); + client!.startClient(); + await httpBackend!.flushAllExpected(); expect(fires).toBe(3); }); + + it("should honour lazyLoadMembers if user is not a guest", () => { + client!.doesServerSupportLazyLoading = jest.fn().mockResolvedValue(true); + + httpBackend!.when("GET", "/sync").check((req) => { + expect(JSON.parse(req.queryParams!.filter).room.state.lazy_load_members).toBeTruthy(); + }).respond(200, syncData); + + client!.setGuest(false); + client!.startClient({ lazyLoadMembers: true }); + + return httpBackend!.flushAllExpected(); + }); + + it("should not honour lazyLoadMembers if user is a guest", () => { + httpBackend!.expectedRequests = []; + httpBackend!.when("GET", "/versions").respond(200, {}); + client!.doesServerSupportLazyLoading = jest.fn().mockResolvedValue(true); + + httpBackend!.when("GET", "/sync").check((req) => { + expect(JSON.parse(req.queryParams!.filter).room?.state?.lazy_load_members).toBeFalsy(); + }).respond(200, syncData); + + client!.setGuest(true); + client!.startClient({ lazyLoadMembers: true }); + + return httpBackend!.flushAllExpected(); + }); + + it("should emit ClientEvent.Room when invited while crypto is disabled", async () => { + const roomId = "!invite:example.org"; + + // First sync: an invite + const inviteSyncRoomSection = { + invite: { + [roomId]: { + invite_state: { + events: [{ + type: "m.room.member", + state_key: selfUserId, + content: { + membership: "invite", + }, + }], + }, + }, + }, + }; + httpBackend!.when("GET", "/sync").respond(200, { + ...syncData, + rooms: inviteSyncRoomSection, + }); + + // First fire: an initial invite + let fires = 0; + client!.once(ClientEvent.Room, (room) => { + fires++; + expect(room.roomId).toBe(roomId); + }); + + // noinspection ES6MissingAwait + client!.startClient(); + await httpBackend!.flushAllExpected(); + + expect(fires).toBe(1); + }); }); - describe("resolving invites to profile info", function() { + describe("initial sync", () => { const syncData = { + next_batch: "batch_token", + rooms: {}, + presence: {}, + }; + + it("should only apply initialSyncLimit to the initial sync", () => { + // 1st request + httpBackend!.when("GET", "/sync").check((req) => { + expect(JSON.parse(req.queryParams!.filter).room.timeline.limit).toEqual(1); + }).respond(200, syncData); + // 2nd request + httpBackend!.when("GET", "/sync").check((req) => { + expect(req.queryParams!.filter).toEqual("a filter id"); + }).respond(200, syncData); + + client!.startClient({ initialSyncLimit: 1 }); + + httpBackend!.flushSync(undefined); + return httpBackend!.flushAllExpected(); + }); + + it("should not apply initialSyncLimit to a first sync if we have a stored token", () => { + httpBackend!.when("GET", "/sync").check((req) => { + expect(req.queryParams!.filter).toEqual("a filter id"); + }).respond(200, syncData); + + client!.store.getSavedSyncToken = jest.fn().mockResolvedValue("this-is-a-token"); + client!.startClient({ initialSyncLimit: 1 }); + + return httpBackend!.flushAllExpected(); + }); + }); + + describe("resolving invites to profile info", () => { + const syncData: ISyncResponse = { + account_data: { + events: [], + }, next_batch: "s_5_3", presence: { events: [], }, rooms: { - join: { - - }, + join: {}, + invite: {}, + leave: {}, }, }; - beforeEach(function() { - syncData.presence.events = []; + beforeEach(() => { + syncData.presence!.events = []; syncData.rooms.join[roomOne] = { timeline: { events: [ utils.mkMessage({ room: roomOne, user: otherUserId, msg: "hello", - }), + }) as IRoomEvent, ], }, state: { @@ -223,156 +353,161 @@ describe("MatrixClient syncing", function() { }), ], }, - }; + } as unknown as IJoinedRoom; }); - it("should resolve incoming invites from /sync", function() { + it("should resolve incoming invites from /sync", () => { syncData.rooms.join[roomOne].state.events.push( utils.mkMembership({ room: roomOne, mship: "invite", user: userC, - }), + }) as IStateEvent, ); - httpBackend.when("GET", "/sync").respond(200, syncData); - httpBackend.when("GET", "/profile/" + encodeURIComponent(userC)).respond( + httpBackend!.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/profile/" + encodeURIComponent(userC)).respond( 200, { avatar_url: "mxc://flibble/wibble", displayname: "The Boss", }, ); - client.startClient({ + client!.startClient({ resolveInvitesToProfiles: true, }); return Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), - ]).then(function() { - const member = client.getRoom(roomOne).getMember(userC); + ]).then(() => { + const member = client!.getRoom(roomOne)!.getMember(userC)!; expect(member.name).toEqual("The Boss"); expect( - member.getAvatarUrl("home.server.url", null, null, null, false), + member.getAvatarUrl("home.server.url", 1, 1, '', false, false), ).toBeTruthy(); }); }); - it("should use cached values from m.presence wherever possible", function() { - syncData.presence.events = [ + it("should use cached values from m.presence wherever possible", () => { + syncData.presence!.events = [ utils.mkPresence({ - user: userC, presence: "online", name: "The Ghost", - }), + user: userC, + presence: "online", + name: "The Ghost", + }) as IMinimalEvent, ]; syncData.rooms.join[roomOne].state.events.push( utils.mkMembership({ room: roomOne, mship: "invite", user: userC, - }), + }) as IStateEvent, ); - httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, syncData); - client.startClient({ + client!.startClient({ resolveInvitesToProfiles: true, }); return Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), - ]).then(function() { - const member = client.getRoom(roomOne).getMember(userC); + ]).then(() => { + const member = client!.getRoom(roomOne)!.getMember(userC)!; expect(member.name).toEqual("The Ghost"); }); }); - it("should result in events on the room member firing", function() { - syncData.presence.events = [ + it("should result in events on the room member firing", () => { + syncData.presence!.events = [ utils.mkPresence({ - user: userC, presence: "online", name: "The Ghost", - }), + user: userC, + presence: "online", + name: "The Ghost", + }) as IMinimalEvent, ]; syncData.rooms.join[roomOne].state.events.push( utils.mkMembership({ room: roomOne, mship: "invite", user: userC, - }), + }) as IStateEvent, ); - httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, syncData); - let latestFiredName = null; - client.on(RoomMemberEvent.Name, function(event, m) { + let latestFiredName: string; + client!.on(RoomMemberEvent.Name, (event, m) => { if (m.userId === userC && m.roomId === roomOne) { latestFiredName = m.name; } }); - client.startClient({ + client!.startClient({ resolveInvitesToProfiles: true, }); return Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), - ]).then(function() { + ]).then(() => { expect(latestFiredName).toEqual("The Ghost"); }); }); - it("should no-op if resolveInvitesToProfiles is not set", function() { + it("should no-op if resolveInvitesToProfiles is not set", () => { syncData.rooms.join[roomOne].state.events.push( utils.mkMembership({ room: roomOne, mship: "invite", user: userC, - }), + }) as IStateEvent, ); - httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, syncData); - client.startClient(); + client!.startClient(); return Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), - ]).then(function() { - const member = client.getRoom(roomOne).getMember(userC); + ]).then(() => { + const member = client!.getRoom(roomOne)!.getMember(userC)!; expect(member.name).toEqual(userC); expect( - member.getAvatarUrl("home.server.url", null, null, null, false), + member.getAvatarUrl("home.server.url", 1, 1, '', false, false), ).toBe(null); }); }); }); - describe("users", function() { + describe("users", () => { const syncData = { next_batch: "nb", presence: { events: [ utils.mkPresence({ - user: userA, presence: "online", + user: userA, + presence: "online", }), utils.mkPresence({ - user: userB, presence: "unavailable", + user: userB, + presence: "unavailable", }), ], }, }; - it("should create users for presence events from /sync", - function() { - httpBackend.when("GET", "/sync").respond(200, syncData); + it("should create users for presence events from /sync", () => { + httpBackend!.when("GET", "/sync").respond(200, syncData); - client.startClient(); + client!.startClient(); return Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), - ]).then(function() { - expect(client.getUser(userA).presence).toEqual("online"); - expect(client.getUser(userB).presence).toEqual("unavailable"); + ]).then(() => { + expect(client!.getUser(userA)!.presence).toEqual("online"); + expect(client!.getUser(userB)!.presence).toEqual("unavailable"); }); }); }); - describe("room state", function() { + describe("room state", () => { const msgText = "some text here"; const otherDisplayName = "Bob Smith"; @@ -478,17 +613,17 @@ describe("MatrixClient syncing", function() { }, }; - it("should continually recalculate the right room name.", function() { - httpBackend.when("GET", "/sync").respond(200, syncData); - httpBackend.when("GET", "/sync").respond(200, nextSyncData); + it("should continually recalculate the right room name.", () => { + httpBackend!.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, nextSyncData); - client.startClient(); + client!.startClient(); return Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(2), - ]).then(function() { - const room = client.getRoom(roomOne); + ]).then(() => { + const room = client!.getRoom(roomOne)!; // should have clobbered the name to the one from /events expect(room.name).toEqual( nextSyncData.rooms.join[roomOne].state.events[0].content.name, @@ -496,53 +631,53 @@ describe("MatrixClient syncing", function() { }); }); - it("should store the right events in the timeline.", function() { - httpBackend.when("GET", "/sync").respond(200, syncData); - httpBackend.when("GET", "/sync").respond(200, nextSyncData); + it("should store the right events in the timeline.", () => { + httpBackend!.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, nextSyncData); - client.startClient(); + client!.startClient(); return Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(2), - ]).then(function() { - const room = client.getRoom(roomTwo); + ]).then(() => { + const room = client!.getRoom(roomTwo)!; // should have added the message from /events expect(room.timeline.length).toEqual(2); expect(room.timeline[1].getContent().body).toEqual(msgText); }); }); - it("should set the right room name.", function() { - httpBackend.when("GET", "/sync").respond(200, syncData); - httpBackend.when("GET", "/sync").respond(200, nextSyncData); + it("should set the right room name.", () => { + httpBackend!.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, nextSyncData); - client.startClient(); + client!.startClient(); return Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(2), - ]).then(function() { - const room = client.getRoom(roomTwo); + ]).then(() => { + const room = client!.getRoom(roomTwo)!; // should use the display name of the other person. expect(room.name).toEqual(otherDisplayName); }); }); - it("should set the right user's typing flag.", function() { - httpBackend.when("GET", "/sync").respond(200, syncData); - httpBackend.when("GET", "/sync").respond(200, nextSyncData); + it("should set the right user's typing flag.", () => { + httpBackend!.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, nextSyncData); - client.startClient(); + client!.startClient(); return Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(2), - ]).then(function() { - const room = client.getRoom(roomTwo); - let member = room.getMember(otherUserId); + ]).then(() => { + const room = client!.getRoom(roomTwo)!; + let member = room.getMember(otherUserId)!; expect(member).toBeTruthy(); expect(member.typing).toEqual(true); - member = room.getMember(selfUserId); + member = room.getMember(selfUserId)!; expect(member).toBeTruthy(); expect(member.typing).toEqual(false); }); @@ -552,16 +687,16 @@ describe("MatrixClient syncing", function() { // events that arrive in the incremental sync as if they preceeded the // timeline events, however this breaks peeking, so it's disabled // (see sync.js) - xit("should correctly interpret state in incremental sync.", function() { - httpBackend.when("GET", "/sync").respond(200, syncData); - httpBackend.when("GET", "/sync").respond(200, nextSyncData); + xit("should correctly interpret state in incremental sync.", () => { + httpBackend!.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, nextSyncData); - client.startClient(); + client!.startClient(); return Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(2), - ]).then(function() { - const room = client.getRoom(roomOne); + ]).then(() => { + const room = client!.getRoom(roomOne)!; const stateAtStart = room.getLiveTimeline().getState( EventTimeline.BACKWARDS, ); @@ -576,11 +711,11 @@ describe("MatrixClient syncing", function() { }); }); - xit("should update power levels for users in a room", function() { + xit("should update power levels for users in a room", () => { }); - xit("should update the room topic", function() { + xit("should update the room topic", () => { }); @@ -650,16 +785,16 @@ describe("MatrixClient syncing", function() { expect(markerEvent.sender).toBeDefined(); expect(markerEvent.sender).not.toEqual(roomCreateEvent.sender); - httpBackend.when("GET", "/sync").respond(200, normalFirstSync); - httpBackend.when("GET", "/sync").respond(200, nextSyncData); + httpBackend!.when("GET", "/sync").respond(200, normalFirstSync); + httpBackend!.when("GET", "/sync").respond(200, nextSyncData); - client.startClient(); + client!.startClient(); await Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(2), ]); - const room = client.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; expect(room.getTimelineNeedsRefresh()).toEqual(false); }); @@ -721,15 +856,15 @@ describe("MatrixClient syncing", function() { }, }; - httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, syncData); - client.startClient(); + client!.startClient(); await Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), ]); - const room = client.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; expect(room.getTimelineNeedsRefresh()).toEqual(false); }); @@ -751,15 +886,15 @@ describe("MatrixClient syncing", function() { }, }; - httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, syncData); - client.startClient(); + client!.startClient(); await Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), ]); - const room = client.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; expect(room.getTimelineNeedsRefresh()).toEqual(false); }); @@ -784,15 +919,15 @@ describe("MatrixClient syncing", function() { }, }; - httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, syncData); - client.startClient(); + client!.startClient(); await Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), ]); - const room = client.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; expect(room.getTimelineNeedsRefresh()).toEqual(false); }); @@ -818,27 +953,27 @@ describe("MatrixClient syncing", function() { const markerEventId = nextSyncData.rooms.join[roomOne].timeline.events[0].event_id; // Only do the first sync - httpBackend.when("GET", "/sync").respond(200, normalFirstSync); - client.startClient(); + httpBackend!.when("GET", "/sync").respond(200, normalFirstSync); + client!.startClient(); await Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), ]); // Get the room after the first sync so the room is created - const room = client.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; let emitCount = 0; - room.on(RoomEvent.HistoryImportedWithinTimeline, function(markerEvent, room) { + room.on(RoomEvent.HistoryImportedWithinTimeline, (markerEvent, room) => { expect(markerEvent.getId()).toEqual(markerEventId); expect(room.roomId).toEqual(roomOne); emitCount += 1; }); // Now do a subsequent sync with the marker event - httpBackend.when("GET", "/sync").respond(200, nextSyncData); + httpBackend!.when("GET", "/sync").respond(200, nextSyncData); await Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), ]); @@ -873,16 +1008,16 @@ describe("MatrixClient syncing", function() { }, }; - httpBackend.when("GET", "/sync").respond(200, normalFirstSync); - httpBackend.when("GET", "/sync").respond(200, nextSyncData); + httpBackend!.when("GET", "/sync").respond(200, normalFirstSync); + httpBackend!.when("GET", "/sync").respond(200, nextSyncData); - client.startClient(); + client!.startClient(); await Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(2), ]); - const room = client.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; expect(room.getTimelineNeedsRefresh()).toEqual(true); }); }); @@ -929,19 +1064,19 @@ describe("MatrixClient syncing", function() { it("should be able to listen to state events even after " + "the timeline is reset during `limited` sync response", async () => { // Create a room from the sync - httpBackend.when("GET", "/sync").respond(200, syncData); - client.startClient(); + httpBackend!.when("GET", "/sync").respond(200, syncData); + client!.startClient(); await Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), ]); // Get the room after the first sync so the room is created - const room = client.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; expect(room).toBeTruthy(); let stateEventEmitCount = 0; - client.on(RoomStateEvent.Update, () => { + client!.on(RoomStateEvent.Update, () => { stateEventEmitCount += 1; }); @@ -969,10 +1104,10 @@ describe("MatrixClient syncing", function() { prev_batch: "newerTok", }, }; - httpBackend.when("GET", "/sync").respond(200, limitedSyncData); + httpBackend!.when("GET", "/sync").respond(200, limitedSyncData); await Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), ]); @@ -997,25 +1132,25 @@ describe("MatrixClient syncing", function() { { timelineSupport: true }, ); httpBackend = testClientWithTimelineSupport.httpBackend; - httpBackend.when("GET", "/versions").respond(200, {}); - httpBackend.when("GET", "/pushrules").respond(200, {}); - httpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + httpBackend!.when("GET", "/versions").respond(200, {}); + httpBackend!.when("GET", "/pushrules").respond(200, {}); + httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); client = testClientWithTimelineSupport.client; // Create a room from the sync - httpBackend.when("GET", "/sync").respond(200, syncData); - client.startClient(); + httpBackend!.when("GET", "/sync").respond(200, syncData); + client!.startClient(); await Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), ]); // Get the room after the first sync so the room is created - const room = client.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; expect(room).toBeTruthy(); let stateEventEmitCount = 0; - client.on(RoomStateEvent.Update, () => { + client!.on(RoomStateEvent.Update, () => { stateEventEmitCount += 1; }); @@ -1027,8 +1162,8 @@ describe("MatrixClient syncing", function() { const eventsInRoom = syncData.rooms.join[roomOne].timeline.events; const contextUrl = `/rooms/${encodeURIComponent(roomOne)}/context/` + `${encodeURIComponent(eventsInRoom[0].event_id)}`; - httpBackend.when("GET", contextUrl) - .respond(200, function() { + httpBackend!.when("GET", contextUrl) + .respond(200, () => { return { start: "start_token", events_before: [EVENTS[1], EVENTS[0]], @@ -1045,7 +1180,7 @@ describe("MatrixClient syncing", function() { // reference to change await Promise.all([ room.refreshLiveTimeline(), - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), ]); // Cause `RoomStateEvent.Update` to be fired @@ -1056,8 +1191,8 @@ describe("MatrixClient syncing", function() { }); }); - describe("timeline", function() { - beforeEach(function() { + describe("timeline", () => { + beforeEach(() => { const syncData = { next_batch: "batch_token", rooms: { @@ -1075,16 +1210,16 @@ describe("MatrixClient syncing", function() { }, }; - httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, syncData); - client.startClient(); + client!.startClient(); return Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), ]); }); - it("should set the back-pagination token on new rooms", function() { + it("should set the back-pagination token on new rooms", () => { const syncData = { next_batch: "batch_token", rooms: { @@ -1102,13 +1237,13 @@ describe("MatrixClient syncing", function() { }, }; - httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, syncData); return Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), - ]).then(function() { - const room = client.getRoom(roomTwo); + ]).then(() => { + const room = client!.getRoom(roomTwo)!; expect(room).toBeTruthy(); const tok = room.getLiveTimeline() .getPaginationToken(EventTimeline.BACKWARDS); @@ -1116,7 +1251,7 @@ describe("MatrixClient syncing", function() { }); }); - it("should set the back-pagination token on gappy syncs", function() { + it("should set the back-pagination token on gappy syncs", () => { const syncData = { next_batch: "batch_token", rooms: { @@ -1134,11 +1269,11 @@ describe("MatrixClient syncing", function() { prev_batch: "newerTok", }, }; - httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, syncData); let resetCallCount = 0; // the token should be set *before* timelineReset is emitted - client.on(RoomEvent.TimelineReset, function(room) { + client!.on(RoomEvent.TimelineReset, (room) => { resetCallCount++; const tl = room.getLiveTimeline(); @@ -1148,10 +1283,10 @@ describe("MatrixClient syncing", function() { }); return Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), - ]).then(function() { - const room = client.getRoom(roomOne); + ]).then(() => { + const room = client!.getRoom(roomOne)!; const tl = room.getLiveTimeline(); expect(tl.getEvents().length).toEqual(1); expect(resetCallCount).toEqual(1); @@ -1159,7 +1294,7 @@ describe("MatrixClient syncing", function() { }); }); - describe("receipts", function() { + describe("receipts", () => { const syncData = { rooms: { join: { @@ -1202,13 +1337,13 @@ describe("MatrixClient syncing", function() { }, }; - beforeEach(function() { + beforeEach(() => { syncData.rooms.join[roomOne].ephemeral = { events: [], }; }); - it("should sync receipts from /sync.", function() { + it("should sync receipts from /sync.", () => { const ackEvent = syncData.rooms.join[roomOne].timeline.events[0]; const receipt = {}; receipt[ackEvent.event_id] = { @@ -1222,15 +1357,15 @@ describe("MatrixClient syncing", function() { room_id: roomOne, type: "m.receipt", }]; - httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, syncData); - client.startClient(); + client!.startClient(); return Promise.all([ - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), awaitSyncEvent(), - ]).then(function() { - const room = client.getRoom(roomOne); + ]).then(() => { + const room = client!.getRoom(roomOne)!; expect(room.getReceiptsForEvent(new MatrixEvent(ackEvent))).toEqual([{ type: "m.read", userId: userC, @@ -1242,59 +1377,129 @@ describe("MatrixClient syncing", function() { }); }); - describe("of a room", function() { + describe("unread notifications", () => { + const THREAD_ID = "$ThisIsARandomEventId"; + + const syncData = { + rooms: { + join: { + [roomOne]: { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "hello", + }), + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "world", + }), + ], + }, + state: { + events: [ + utils.mkEvent({ + type: "m.room.name", room: roomOne, user: otherUserId, + content: { + name: "Room name", + }, + }), + utils.mkMembership({ + room: roomOne, mship: "join", user: otherUserId, + }), + utils.mkMembership({ + room: roomOne, mship: "join", user: selfUserId, + }), + utils.mkEvent({ + type: "m.room.create", room: roomOne, user: selfUserId, + content: { + creator: selfUserId, + }, + }), + ], + }, + }, + }, + }, + }; + it("should sync unread notifications.", () => { + syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = { + [THREAD_ID]: { + "highlight_count": 2, + "notification_count": 5, + }, + }; + + httpBackend!.when("GET", "/sync").respond(200, syncData); + + client!.startClient(); + + return Promise.all([ + httpBackend!.flushAllExpected(), + awaitSyncEvent(), + ]).then(() => { + const room = client!.getRoom(roomOne); + + expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(5); + expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(2); + }); + }); + }); + + describe("of a room", () => { xit("should sync when a join event (which changes state) for the user" + - " arrives down the event stream (e.g. join from another device)", function() { + " arrives down the event stream (e.g. join from another device)", () => { }); - xit("should sync when the user explicitly calls joinRoom", function() { + xit("should sync when the user explicitly calls joinRoom", () => { }); }); - describe("syncLeftRooms", function() { - beforeEach(function(done) { - client.startClient(); + describe("syncLeftRooms", () => { + beforeEach((done) => { + client!.startClient(); - httpBackend.flushAllExpected().then(function() { + httpBackend!.flushAllExpected().then(() => { // the /sync call from syncLeftRooms ends up in the request // queue behind the call from the running client; add a response // to flush the client's one out. - httpBackend.when("GET", "/sync").respond(200, {}); + httpBackend!.when("GET", "/sync").respond(200, {}); done(); }); }); - it("should create and use an appropriate filter", function() { - httpBackend.when("POST", "/filter").check(function(req) { + it("should create and use an appropriate filter", () => { + httpBackend!.when("POST", "/filter").check((req) => { expect(req.data).toEqual({ - room: { timeline: { limit: 1 }, - include_leave: true } }); + room: { + timeline: { limit: 1 }, + include_leave: true, + }, + }); }).respond(200, { filter_id: "another_id" }); - const prom = new Promise((resolve) => { - httpBackend.when("GET", "/sync").check(function(req) { - expect(req.queryParams.filter).toEqual("another_id"); + const prom = new Promise((resolve) => { + httpBackend!.when("GET", "/sync").check((req) => { + expect(req.queryParams!.filter).toEqual("another_id"); resolve(); }).respond(200, {}); }); - client.syncLeftRooms(); + client!.syncLeftRooms(); // first flush the filter request; this will make syncLeftRooms // make its /sync call return Promise.all([ - httpBackend.flush("/filter").then(function() { + httpBackend!.flush("/filter").then(() => { // flush the syncs - return httpBackend.flushAllExpected(); + return httpBackend!.flushAllExpected(); }), prom, ]); }); - it("should set the back-pagination token on left rooms", function() { + it("should set the back-pagination token on left rooms", () => { const syncData = { next_batch: "batch_token", rooms: { @@ -1313,15 +1518,15 @@ describe("MatrixClient syncing", function() { }, }; - httpBackend.when("POST", "/filter").respond(200, { + httpBackend!.when("POST", "/filter").respond(200, { filter_id: "another_id", }); - httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, syncData); return Promise.all([ - client.syncLeftRooms().then(function() { - const room = client.getRoom(roomTwo); + client!.syncLeftRooms().then(() => { + const room = client!.getRoom(roomTwo)!; const tok = room.getLiveTimeline().getPaginationToken( EventTimeline.BACKWARDS); @@ -1329,8 +1534,8 @@ describe("MatrixClient syncing", function() { }), // first flush the filter request; this will make syncLeftRooms make its /sync call - httpBackend.flush("/filter").then(function() { - return httpBackend.flushAllExpected(); + httpBackend!.flush("/filter").then(() => { + return httpBackend!.flushAllExpected(); }), ]); }); @@ -1342,7 +1547,74 @@ describe("MatrixClient syncing", function() { * @param {Number?} numSyncs number of syncs to wait for * @returns {Promise} promise which resolves after the sync events have happened */ - function awaitSyncEvent(numSyncs) { - return utils.syncPromise(client, numSyncs); + function awaitSyncEvent(numSyncs?: number) { + return utils.syncPromise(client!, numSyncs); } }); + +describe("MatrixClient syncing (IndexedDB version)", () => { + const selfUserId = "@alice:localhost"; + const selfAccessToken = "aseukfgwef"; + const syncData = { + next_batch: "batch_token", + rooms: {}, + presence: {}, + }; + + it("should emit ClientEvent.Room when invited while using indexeddb crypto store", async () => { + const idbTestClient = new TestClient( + selfUserId, + "DEVICE", + selfAccessToken, + undefined, + { cryptoStore: new IndexedDBCryptoStore(global.indexedDB, "tests") }, + ); + const idbHttpBackend = idbTestClient.httpBackend; + const idbClient = idbTestClient.client; + idbHttpBackend.when("GET", "/versions").respond(200, {}); + idbHttpBackend.when("GET", "/pushrules").respond(200, {}); + idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + + await idbClient.initCrypto(); + + const roomId = "!invite:example.org"; + + // First sync: an invite + const inviteSyncRoomSection = { + invite: { + [roomId]: { + invite_state: { + events: [{ + type: "m.room.member", + state_key: selfUserId, + content: { + membership: "invite", + }, + }], + }, + }, + }, + }; + idbHttpBackend.when("GET", "/sync").respond(200, { + ...syncData, + rooms: inviteSyncRoomSection, + }); + + // First fire: an initial invite + let fires = 0; + idbClient.once(ClientEvent.Room, (room) => { + fires++; + expect(room.roomId).toBe(roomId); + }); + + // noinspection ES6MissingAwait + idbClient.startClient(); + await idbHttpBackend.flushAllExpected(); + + expect(fires).toBe(1); + + idbHttpBackend.verifyNoOutstandingExpectation(); + idbClient.stopClient(); + idbHttpBackend.stop(); + }); +}); diff --git a/spec/integ/megolm-backup.spec.ts b/spec/integ/megolm-backup.spec.ts index 5fa675519..492e4f1dc 100644 --- a/spec/integ/megolm-backup.spec.ts +++ b/spec/integ/megolm-backup.spec.ts @@ -95,26 +95,31 @@ describe("megolm key backups", function() { return; } const Olm = global.Olm; - - let testOlmAccount: Account; + let testOlmAccount: Olm.Account; let aliceTestClient: TestClient; + const setupTestClient = (): [Account, TestClient] => { + const aliceTestClient = new TestClient( + "@alice:localhost", "xzcvb", "akjgkrgjs", + ); + const testOlmAccount = new Olm.Account(); + testOlmAccount!.create(); + + return [testOlmAccount, aliceTestClient]; + }; + beforeAll(function() { return Olm.init(); }); beforeEach(async function() { - aliceTestClient = new TestClient( - "@alice:localhost", "xzcvb", "akjgkrgjs", - ); - testOlmAccount = new Olm.Account(); - testOlmAccount.create(); - await aliceTestClient.client.initCrypto(); - aliceTestClient.client.crypto.backupManager.backupInfo = CURVE25519_BACKUP_INFO; + [testOlmAccount, aliceTestClient] = setupTestClient(); + await aliceTestClient!.client.initCrypto(); + aliceTestClient!.client.crypto!.backupManager.backupInfo = CURVE25519_BACKUP_INFO; }); afterEach(function() { - return aliceTestClient.stop(); + return aliceTestClient!.stop(); }); it("Alice checks key backups when receiving a message she can't decrypt", function() { @@ -130,22 +135,22 @@ describe("megolm key backups", function() { }, }; - return aliceTestClient.start().then(() => { + return aliceTestClient!.start().then(() => { return createOlmSession(testOlmAccount, aliceTestClient); }).then(() => { const privkey = decodeRecoveryKey(RECOVERY_KEY); - return aliceTestClient.client.crypto.storeSessionBackupPrivateKey(privkey); + return aliceTestClient!.client!.crypto!.storeSessionBackupPrivateKey(privkey); }).then(() => { - aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); - aliceTestClient.expectKeyBackupQuery( + aliceTestClient!.httpBackend.when("GET", "/sync").respond(200, syncResponse); + aliceTestClient!.expectKeyBackupQuery( ROOM_ID, SESSION_ID, 200, CURVE25519_KEY_BACKUP_DATA, ); - return aliceTestClient.httpBackend.flushAllExpected(); + return aliceTestClient!.httpBackend.flushAllExpected(); }).then(function(): Promise { - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient!.client.getRoom(ROOM_ID)!; const event = room.getLiveTimeline().getEvents()[0]; if (event.getContent()) { diff --git a/spec/integ/megolm-integ.spec.ts b/spec/integ/megolm-integ.spec.ts index f69d2ebdc..9454749a9 100644 --- a/spec/integ/megolm-integ.spec.ts +++ b/spec/integ/megolm-integ.spec.ts @@ -29,8 +29,11 @@ import { IDownloadKeyResult, MatrixEvent, MatrixEventEvent, + IndexedDBCryptoStore, + Room, } from "../../src/matrix"; import { IDeviceKeys } from "../../src/crypto/dehydration"; +import { DeviceInfo } from "../../src/crypto/deviceinfo"; const ROOM_ID = "!room:id"; @@ -204,9 +207,11 @@ describe("megolm", () => { } const Olm = global.Olm; - let testOlmAccount: Olm.Account; - let testSenderKey: string; - let aliceTestClient: TestClient; + let testOlmAccount = {} as unknown as Olm.Account; + let testSenderKey = ''; + let aliceTestClient = new TestClient( + "@alice:localhost", "device2", "access_token2", + ); /** * Get the device keys for testOlmAccount in a format suitable for a @@ -280,10 +285,13 @@ describe("megolm", () => { it("Alice receives a megolm message", async () => { await aliceTestClient.start(); + aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + // make the room_key event const roomKeyEncrypted = encryptGroupSessionKey({ senderKey: testSenderKey, @@ -316,7 +324,7 @@ describe("megolm", () => { aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); await aliceTestClient.flushSync(); - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; const event = room.getLiveTimeline().getEvents()[0]; expect(event.isEncrypted()).toBe(true); const decryptedEvent = await testUtils.awaitDecryption(event); @@ -326,10 +334,13 @@ describe("megolm", () => { it("Alice receives a megolm message before the session keys", async () => { // https://github.com/vector-im/element-web/issues/2273 await aliceTestClient.start(); + aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + // make the room_key event, but don't send it yet const roomKeyEncrypted = encryptGroupSessionKey({ senderKey: testSenderKey, @@ -353,7 +364,7 @@ describe("megolm", () => { }); await aliceTestClient.flushSync(); - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; expect(room.getLiveTimeline().getEvents()[0].getContent().msgtype).toEqual('m.bad.encrypted'); // now she gets the room_key event @@ -383,10 +394,13 @@ describe("megolm", () => { it("Alice gets a second room_key message", async () => { await aliceTestClient.start(); + aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + // make the room_key event const roomKeyEncrypted1 = encryptGroupSessionKey({ senderKey: testSenderKey, @@ -439,7 +453,7 @@ describe("megolm", () => { await aliceTestClient.flushSync(); await aliceTestClient.flushSync(); - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; await room.decryptCriticalEvents(); const event = room.getLiveTimeline().getEvents()[0]; expect(event.getContent().body).toEqual('42'); @@ -468,6 +482,9 @@ describe("megolm", () => { aliceTestClient.httpBackend.when('POST', '/keys/query').respond( 200, getTestKeysQueryResponse('@bob:xyz'), ); + aliceTestClient.httpBackend.when('POST', '/keys/query').respond( + 200, getTestKeysQueryResponse('@bob:xyz'), + ); await Promise.all([ aliceTestClient.client.sendTextMessage(ROOM_ID, 'test').then(() => { @@ -484,7 +501,7 @@ describe("megolm", () => { let inboundGroupSession: Olm.InboundGroupSession; aliceTestClient.httpBackend.when( 'PUT', '/sendToDevice/m.room.encrypted/', - ).respond(200, function(_path, content) { + ).respond(200, function(_path, content: any) { const m = content.messages['@bob:xyz'].DEVICE_ID; const ct = m.ciphertext[testSenderKey]; const decrypted = JSON.parse(p2pSession.decrypt(ct.type, ct.body)); @@ -510,7 +527,7 @@ describe("megolm", () => { return { event_id: '$event_id' }; }); - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; const pendingMsg = room.getPendingEvents()[0]; await Promise.all([ @@ -541,13 +558,16 @@ describe("megolm", () => { logger.log('Forcing alice to download our device keys'); + aliceTestClient.httpBackend.when('POST', '/keys/query').respond( + 200, getTestKeysQueryResponse('@bob:xyz'), + ); aliceTestClient.httpBackend.when('POST', '/keys/query').respond( 200, getTestKeysQueryResponse('@bob:xyz'), ); await Promise.all([ aliceTestClient.client.downloadKeys(['@bob:xyz']), - aliceTestClient.httpBackend.flush('/keys/query', 1), + aliceTestClient.httpBackend.flush('/keys/query', 2), ]); logger.log('Telling alice to block our device'); @@ -592,6 +612,9 @@ describe("megolm", () => { logger.log("Fetching bob's devices and marking known"); + aliceTestClient.httpBackend.when('POST', '/keys/query').respond( + 200, getTestKeysQueryResponse('@bob:xyz'), + ); aliceTestClient.httpBackend.when('POST', '/keys/query').respond( 200, getTestKeysQueryResponse('@bob:xyz'), ); @@ -607,7 +630,7 @@ describe("megolm", () => { let megolmSessionId: string; aliceTestClient.httpBackend.when( 'PUT', '/sendToDevice/m.room.encrypted/', - ).respond(200, function(_path, content) { + ).respond(200, function(_path, content: any) { logger.log('sendToDevice: ', content); const m = content.messages['@bob:xyz'].DEVICE_ID; const ct = m.ciphertext[testSenderKey]; @@ -685,7 +708,7 @@ describe("megolm", () => { // invalidate the device cache for all members in e2e rooms (ie, // herself), and do a key query. aliceTestClient.expectKeyQuery( - getTestKeysQueryResponse(aliceTestClient.userId), + getTestKeysQueryResponse(aliceTestClient.userId!), ); await aliceTestClient.httpBackend.flushAllExpected(); @@ -695,28 +718,30 @@ describe("megolm", () => { await aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'); throw new Error("sendTextMessage succeeded on an unknown device"); } catch (e) { - expect(e.name).toEqual("UnknownDeviceError"); - expect(Object.keys(e.devices)).toEqual([aliceTestClient.userId]); - expect(Object.keys(e.devices[aliceTestClient.userId])). + expect((e as any).name).toEqual("UnknownDeviceError"); + expect(Object.keys((e as any).devices)).toEqual([aliceTestClient.userId!]); + expect(Object.keys((e as any)?.devices[aliceTestClient.userId!])). toEqual(['DEVICE_ID']); } // mark the device as known, and resend. - aliceTestClient.client.setDeviceKnown(aliceTestClient.userId, 'DEVICE_ID'); + aliceTestClient.client.setDeviceKnown(aliceTestClient.userId!, 'DEVICE_ID'); aliceTestClient.httpBackend.when('POST', '/keys/claim').respond( - 200, function(_path, content) { - expect(content.one_time_keys[aliceTestClient.userId].DEVICE_ID) + 200, function(_path, content: IClaimOTKsResult) { + expect(content.one_time_keys[aliceTestClient.userId!].DEVICE_ID) .toEqual("signed_curve25519"); - return getTestKeysClaimResponse(aliceTestClient.userId); + return getTestKeysClaimResponse(aliceTestClient.userId!); }); let p2pSession: Olm.Session; let inboundGroupSession: Olm.InboundGroupSession; aliceTestClient.httpBackend.when( 'PUT', '/sendToDevice/m.room.encrypted/', - ).respond(200, function(_path, content) { + ).respond(200, function(_path, content: { + messages: { [userId: string]: { [deviceId: string]: Record }}; + }) { logger.log("sendToDevice: ", content); - const m = content.messages[aliceTestClient.userId].DEVICE_ID; + const m = content.messages[aliceTestClient.userId!].DEVICE_ID; const ct = m.ciphertext[testSenderKey]; expect(ct.type).toEqual(0); // pre-key message @@ -730,7 +755,7 @@ describe("megolm", () => { return {}; }); - let decrypted: IEvent; + let decrypted: Partial = {}; aliceTestClient.httpBackend.when( 'PUT', '/send/', ).respond(200, function(_path, content: IContent) { @@ -745,7 +770,7 @@ describe("megolm", () => { }); // Grab the event that we'll need to resend - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; const pendingEvents = room.getPendingEvents(); expect(pendingEvents.length).toEqual(1); const unsentEvent = pendingEvents[0]; @@ -760,7 +785,7 @@ describe("megolm", () => { ]); expect(decrypted.type).toEqual('m.room.message'); - expect(decrypted.content.body).toEqual('test'); + expect(decrypted.content?.body).toEqual('test'); }); it('Alice should wait for device list to complete when sending a megolm message', async () => { @@ -786,6 +811,10 @@ describe("megolm", () => { logger.log('Forcing alice to download our device keys'); const downloadPromise = aliceTestClient.client.downloadKeys(['@bob:xyz']); + aliceTestClient.httpBackend.when('POST', '/keys/query').respond( + 200, getTestKeysQueryResponse('@bob:xyz'), + ); + // so will this. const sendPromise = aliceTestClient.client.sendTextMessage(ROOM_ID, 'test') .then(() => { @@ -805,9 +834,12 @@ describe("megolm", () => { it("Alice exports megolm keys and imports them to a new device", async () => { aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} }, failures: {} }); await aliceTestClient.start(); + aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); // establish an olm session with alice const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); @@ -839,7 +871,7 @@ describe("megolm", () => { }); await aliceTestClient.flushSync(); - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; await room.decryptCriticalEvents(); expect(room.getLiveTimeline().getEvents()[0].getContent().body).toEqual('42'); @@ -855,6 +887,8 @@ describe("megolm", () => { await aliceTestClient.client.importRoomKeys(exported); await aliceTestClient.start(); + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + const syncResponse = { next_batch: 1, rooms: { @@ -897,7 +931,7 @@ describe("megolm", () => { ...rawEvent, room: ROOM_ID, }); - await event1.attemptDecryption(testClient.client.crypto, { isRetry: true }); + await event1.attemptDecryption(testClient.client.crypto!, { isRetry: true }); expect(event1.isKeySourceUntrusted()).toBeTruthy(); const event2 = testUtils.mkEvent({ @@ -913,24 +947,27 @@ describe("megolm", () => { // @ts-ignore - private event2.senderCurve25519Key = testSenderKey; // @ts-ignore - private - testClient.client.crypto.onRoomKeyEvent(event2); + testClient.client.crypto!.onRoomKeyEvent(event2); const event3 = testUtils.mkEvent({ event: true, ...rawEvent, room: ROOM_ID, }); - await event3.attemptDecryption(testClient.client.crypto, { isRetry: true }); + await event3.attemptDecryption(testClient.client.crypto!, { isRetry: true }); expect(event3.isKeySourceUntrusted()).toBeFalsy(); testClient.stop(); }); it("Alice can decrypt a message with falsey content", async () => { await aliceTestClient.start(); + aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + // make the room_key event const roomKeyEncrypted = encryptGroupSessionKey({ senderKey: testSenderKey, @@ -972,7 +1009,7 @@ describe("megolm", () => { aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); await aliceTestClient.flushSync(); - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; const event = room.getLiveTimeline().getEvents()[0]; expect(event.isEncrypted()).toBe(true); const decryptedEvent = await testUtils.awaitDecryption(event); @@ -985,10 +1022,13 @@ describe("megolm", () => { "should successfully decrypt bundled redaction events that don't include a room_id in their /sync data", async () => { await aliceTestClient.start(); + aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + // make the room_key event const roomKeyEncrypted = encryptGroupSessionKey({ senderKey: testSenderKey, @@ -1036,13 +1076,292 @@ describe("megolm", () => { aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); await aliceTestClient.flushSync(); - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; const event = room.getLiveTimeline().getEvents()[0]; expect(event.isEncrypted()).toBe(true); - await event.attemptDecryption(aliceTestClient.client.crypto); + await event.attemptDecryption(aliceTestClient.client.crypto!); expect(event.getContent()).toEqual({}); const redactionEvent: any = event.getRedactionEvent(); expect(redactionEvent.content.reason).toEqual("redaction test"); }, ); + + it("Alice receives shared history before being invited to a room by the sharer", async () => { + const beccaTestClient = new TestClient( + "@becca:localhost", "foobar", "bazquux", + ); + await beccaTestClient.client.initCrypto(); + + await aliceTestClient.start(); + aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); + await beccaTestClient.start(); + + const beccaRoom = new Room(ROOM_ID, beccaTestClient.client, "@becca:localhost", {}); + beccaTestClient.client.store.storeRoom(beccaRoom); + await beccaTestClient.client.setRoomEncryption(ROOM_ID, { "algorithm": "m.megolm.v1.aes-sha2" }); + + const event = new MatrixEvent({ + type: "m.room.message", + sender: "@becca:localhost", + room_id: ROOM_ID, + event_id: "$1", + content: { + msgtype: "m.text", + body: "test message", + }, + }); + + await beccaTestClient.client.crypto!.encryptEvent(event, beccaRoom); + // remove keys from the event + // @ts-ignore private properties + event.clearEvent = undefined; + // @ts-ignore private properties + event.senderCurve25519Key = null; + // @ts-ignore private properties + event.claimedEd25519Key = null; + + const device = new DeviceInfo(beccaTestClient.client.deviceId!); + aliceTestClient.client.crypto!.deviceList.getDeviceByIdentityKey = () => device; + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => beccaTestClient.client.getUserId()!; + + // Create an olm session for Becca and Alice's devices + const aliceOtks = await aliceTestClient.awaitOneTimeKeyUpload(); + const aliceOtkId = Object.keys(aliceOtks)[0]; + const aliceOtk = aliceOtks[aliceOtkId]; + const p2pSession = new global.Olm.Session(); + await beccaTestClient.client.crypto!.cryptoStore.doTxn( + 'readonly', + [IndexedDBCryptoStore.STORE_ACCOUNT], + (txn) => { + beccaTestClient.client.crypto!.cryptoStore.getAccount(txn, (pickledAccount: string) => { + const account = new global.Olm.Account(); + try { + account.unpickle(beccaTestClient.client.crypto!.olmDevice.pickleKey, pickledAccount); + p2pSession.create_outbound(account, aliceTestClient.getDeviceKey(), aliceOtk.key); + } finally { + account.free(); + } + }); + }, + ); + + const content = event.getWireContent(); + const groupSessionKey = await beccaTestClient.client.crypto!.olmDevice.getInboundGroupSessionKey( + ROOM_ID, + content.sender_key, + content.session_id, + ); + const encryptedForwardedKey = encryptOlmEvent({ + sender: "@becca:localhost", + senderKey: beccaTestClient.getDeviceKey(), + recipient: aliceTestClient, + p2pSession: p2pSession, + plaincontent: { + "algorithm": 'm.megolm.v1.aes-sha2', + "room_id": ROOM_ID, + "sender_key": content.sender_key, + "sender_claimed_ed25519_key": groupSessionKey.sender_claimed_ed25519_key, + "session_id": content.session_id, + "session_key": groupSessionKey.key, + "chain_index": groupSessionKey.chain_index, + "forwarding_curve25519_key_chain": groupSessionKey.forwarding_curve25519_key_chain, + "org.matrix.msc3061.shared_history": true, + }, + plaintype: 'm.forwarded_room_key', + }); + + // Alice receives shared history + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { + next_batch: 1, + to_device: { events: [encryptedForwardedKey] }, + }); + await aliceTestClient.flushSync(); + + // Alice is invited to the room by Becca + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { + next_batch: 2, + rooms: { invite: { [ROOM_ID]: { invite_state: { events: [ + { + sender: '@becca:localhost', + type: 'm.room.encryption', + state_key: '', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + }, + }, + { + sender: '@becca:localhost', + type: 'm.room.member', + state_key: '@alice:localhost', + content: { + membership: 'invite', + }, + }, + ] } } } }, + }); + await aliceTestClient.flushSync(); + + // Alice has joined the room + aliceTestClient.httpBackend.when("GET", "/sync").respond( + 200, getSyncResponse(["@alice:localhost", "@becca:localhost"]), + ); + await aliceTestClient.flushSync(); + + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { + next_batch: 4, + rooms: { + join: { + [ROOM_ID]: { timeline: { events: [event.event] } }, + }, + }, + }); + await aliceTestClient.flushSync(); + + const room = aliceTestClient.client.getRoom(ROOM_ID)!; + const roomEvent = room.getLiveTimeline().getEvents()[0]; + expect(roomEvent.isEncrypted()).toBe(true); + const decryptedEvent = await testUtils.awaitDecryption(roomEvent); + expect(decryptedEvent.getContent().body).toEqual('test message'); + + await beccaTestClient.stop(); + }); + + it("Alice receives shared history before being invited to a room by someone else", async () => { + const beccaTestClient = new TestClient( + "@becca:localhost", "foobar", "bazquux", + ); + await beccaTestClient.client.initCrypto(); + + await aliceTestClient.start(); + await beccaTestClient.start(); + + const beccaRoom = new Room(ROOM_ID, beccaTestClient.client, "@becca:localhost", {}); + beccaTestClient.client.store.storeRoom(beccaRoom); + await beccaTestClient.client.setRoomEncryption(ROOM_ID, { "algorithm": "m.megolm.v1.aes-sha2" }); + + const event = new MatrixEvent({ + type: "m.room.message", + sender: "@becca:localhost", + room_id: ROOM_ID, + event_id: "$1", + content: { + msgtype: "m.text", + body: "test message", + }, + }); + + await beccaTestClient.client.crypto!.encryptEvent(event, beccaRoom); + // remove keys from the event + // @ts-ignore private properties + event.clearEvent = undefined; + // @ts-ignore private properties + event.senderCurve25519Key = null; + // @ts-ignore private properties + event.claimedEd25519Key = null; + + const device = new DeviceInfo(beccaTestClient.client.deviceId!); + aliceTestClient.client.crypto!.deviceList.getDeviceByIdentityKey = () => device; + + // Create an olm session for Becca and Alice's devices + const aliceOtks = await aliceTestClient.awaitOneTimeKeyUpload(); + const aliceOtkId = Object.keys(aliceOtks)[0]; + const aliceOtk = aliceOtks[aliceOtkId]; + const p2pSession = new global.Olm.Session(); + await beccaTestClient.client.crypto!.cryptoStore.doTxn( + 'readonly', + [IndexedDBCryptoStore.STORE_ACCOUNT], + (txn) => { + beccaTestClient.client.crypto!.cryptoStore.getAccount(txn, (pickledAccount: string) => { + const account = new global.Olm.Account(); + try { + account.unpickle(beccaTestClient.client.crypto!.olmDevice.pickleKey, pickledAccount); + p2pSession.create_outbound(account, aliceTestClient.getDeviceKey(), aliceOtk.key); + } finally { + account.free(); + } + }); + }, + ); + + const content = event.getWireContent(); + const groupSessionKey = await beccaTestClient.client.crypto!.olmDevice.getInboundGroupSessionKey( + ROOM_ID, + content.sender_key, + content.session_id, + ); + const encryptedForwardedKey = encryptOlmEvent({ + sender: "@becca:localhost", + senderKey: beccaTestClient.getDeviceKey(), + recipient: aliceTestClient, + p2pSession: p2pSession, + plaincontent: { + "algorithm": 'm.megolm.v1.aes-sha2', + "room_id": ROOM_ID, + "sender_key": content.sender_key, + "sender_claimed_ed25519_key": groupSessionKey.sender_claimed_ed25519_key, + "session_id": content.session_id, + "session_key": groupSessionKey.key, + "chain_index": groupSessionKey.chain_index, + "forwarding_curve25519_key_chain": groupSessionKey.forwarding_curve25519_key_chain, + "org.matrix.msc3061.shared_history": true, + }, + plaintype: 'm.forwarded_room_key', + }); + + // Alice receives forwarded history from Becca + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { + next_batch: 1, + to_device: { events: [encryptedForwardedKey] }, + }); + await aliceTestClient.flushSync(); + + // Alice is invited to the room by Charlie + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { + next_batch: 2, + rooms: { invite: { [ROOM_ID]: { invite_state: { events: [ + { + sender: '@becca:localhost', + type: 'm.room.encryption', + state_key: '', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + }, + }, + { + sender: '@charlie:localhost', + type: 'm.room.member', + state_key: '@alice:localhost', + content: { + membership: 'invite', + }, + }, + ] } } } }, + }); + await aliceTestClient.flushSync(); + + // Alice has joined the room + aliceTestClient.httpBackend.when("GET", "/sync").respond( + 200, getSyncResponse(["@alice:localhost", "@becca:localhost", "@charlie:localhost"]), + ); + await aliceTestClient.flushSync(); + + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { + next_batch: 4, + rooms: { + join: { + [ROOM_ID]: { timeline: { events: [event.event] } }, + }, + }, + }); + await aliceTestClient.flushSync(); + + // Decryption should fail, because Alice hasn't received any keys she can trust + const room = aliceTestClient.client.getRoom(ROOM_ID)!; + const roomEvent = room.getLiveTimeline().getEvents()[0]; + expect(roomEvent.isEncrypted()).toBe(true); + const decryptedEvent = await testUtils.awaitDecryption(roomEvent); + expect(decryptedEvent.isDecryptionFailure()).toBe(true); + + await beccaTestClient.stop(); + }); }); diff --git a/spec/integ/sliding-sync-sdk.spec.ts b/spec/integ/sliding-sync-sdk.spec.ts index 34d84bdda..47e6ba8df 100644 --- a/spec/integ/sliding-sync-sdk.spec.ts +++ b/spec/integ/sliding-sync-sdk.spec.ts @@ -31,10 +31,10 @@ import { IStoredClientOpts } from "../../src/client"; import { logger } from "../../src/logger"; describe("SlidingSyncSdk", () => { - let client: MatrixClient = null; - let httpBackend: MockHttpBackend = null; - let sdk: SlidingSyncSdk = null; - let mockSlidingSync: SlidingSync = null; + let client: MatrixClient | undefined; + let httpBackend: MockHttpBackend | undefined; + let sdk: SlidingSyncSdk | undefined; + let mockSlidingSync: SlidingSync | undefined; const selfUserId = "@alice:localhost"; const selfAccessToken = "aseukfgwef"; @@ -66,7 +66,7 @@ describe("SlidingSyncSdk", () => { event_id: "$" + eventIdCounter, }; }; - const mkOwnStateEvent = (evType: string, content: object, stateKey?: string): IStateEvent => { + const mkOwnStateEvent = (evType: string, content: object, stateKey = ''): IStateEvent => { eventIdCounter++; return { type: evType, @@ -103,24 +103,24 @@ describe("SlidingSyncSdk", () => { client = testClient.client; mockSlidingSync = mockifySlidingSync(new SlidingSync("", [], {}, client, 0)); if (testOpts.withCrypto) { - httpBackend.when("GET", "/room_keys/version").respond(404, {}); - await client.initCrypto(); - testOpts.crypto = client.crypto; + httpBackend!.when("GET", "/room_keys/version").respond(404, {}); + await client!.initCrypto(); + testOpts.crypto = client!.crypto; } - httpBackend.when("GET", "/_matrix/client/r0/pushrules").respond(200, {}); + httpBackend!.when("GET", "/_matrix/client/r0/pushrules").respond(200, {}); sdk = new SlidingSyncSdk(mockSlidingSync, client, testOpts); }; // tear down client/httpBackend globals const teardownClient = () => { - client.stopClient(); - return httpBackend.stop(); + client!.stopClient(); + return httpBackend!.stop(); }; // find an extension on a SlidingSyncSdk instance const findExtension = (name: string): Extension => { - expect(mockSlidingSync.registerExtension).toHaveBeenCalled(); - const mockFn = mockSlidingSync.registerExtension as jest.Mock; + expect(mockSlidingSync!.registerExtension).toHaveBeenCalled(); + const mockFn = mockSlidingSync!.registerExtension as jest.Mock; // find the extension for (let i = 0; i < mockFn.mock.calls.length; i++) { const calledExtension = mockFn.mock.calls[i][0] as Extension; @@ -137,14 +137,14 @@ describe("SlidingSyncSdk", () => { }); afterAll(teardownClient); it("can sync()", async () => { - const hasSynced = sdk.sync(); - await httpBackend.flushAllExpected(); + const hasSynced = sdk!.sync(); + await httpBackend!.flushAllExpected(); await hasSynced; - expect(mockSlidingSync.start).toBeCalled(); + expect(mockSlidingSync!.start).toBeCalled(); }); it("can stop()", async () => { - sdk.stop(); - expect(mockSlidingSync.stop).toBeCalled(); + sdk!.stop(); + expect(mockSlidingSync!.stop).toBeCalled(); }); }); @@ -156,8 +156,8 @@ describe("SlidingSyncSdk", () => { describe("initial", () => { beforeAll(async () => { - const hasSynced = sdk.sync(); - await httpBackend.flushAllExpected(); + const hasSynced = sdk!.sync(); + await httpBackend!.flushAllExpected(); await hasSynced; }); // inject some rooms with different fields set. @@ -168,6 +168,7 @@ describe("SlidingSyncSdk", () => { const roomD = "!d_with_notif_count:localhost"; const roomE = "!e_with_invite:localhost"; const roomF = "!f_calc_room_name:localhost"; + const roomG = "!g_join_invite_counts:localhost"; const data: Record = { [roomA]: { name: "A", @@ -261,56 +262,83 @@ describe("SlidingSyncSdk", () => { ], initial: true, }, + [roomG]: { + name: "G", + required_state: [], + timeline: [ + mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""), + mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId), + mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""), + ], + joined_count: 5, + invited_count: 2, + initial: true, + }, }; it("can be created with required_state and timeline", () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]); - const gotRoom = client.getRoom(roomA); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]); + const gotRoom = client!.getRoom(roomA); expect(gotRoom).toBeDefined(); + if (gotRoom == null) { return; } expect(gotRoom.name).toEqual(data[roomA].name); expect(gotRoom.getMyMembership()).toEqual("join"); assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline); }); it("can be created with timeline only", () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]); - const gotRoom = client.getRoom(roomB); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]); + const gotRoom = client!.getRoom(roomB); expect(gotRoom).toBeDefined(); + if (gotRoom == null) { return; } expect(gotRoom.name).toEqual(data[roomB].name); expect(gotRoom.getMyMembership()).toEqual("join"); assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline); }); it("can be created with a highlight_count", () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]); - const gotRoom = client.getRoom(roomC); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]); + const gotRoom = client!.getRoom(roomC); expect(gotRoom).toBeDefined(); + if (gotRoom == null) { return; } expect( gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight), ).toEqual(data[roomC].highlight_count); }); it("can be created with a notification_count", () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]); - const gotRoom = client.getRoom(roomD); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]); + const gotRoom = client!.getRoom(roomD); expect(gotRoom).toBeDefined(); + if (gotRoom == null) { return; } expect( gotRoom.getUnreadNotificationCount(NotificationCountType.Total), ).toEqual(data[roomD].notification_count); }); - it("can be created with invite_state", () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]); - const gotRoom = client.getRoom(roomE); + it("can be created with an invited/joined_count", () => { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomG, data[roomG]); + const gotRoom = client!.getRoom(roomG); expect(gotRoom).toBeDefined(); + if (gotRoom == null) { return; } + expect(gotRoom.getInvitedMemberCount()).toEqual(data[roomG].invited_count); + expect(gotRoom.getJoinedMemberCount()).toEqual(data[roomG].joined_count); + }); + + it("can be created with invite_state", () => { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]); + const gotRoom = client!.getRoom(roomE); + expect(gotRoom).toBeDefined(); + if (gotRoom == null) { return; } expect(gotRoom.getMyMembership()).toEqual("invite"); expect(gotRoom.currentState.getJoinRule()).toEqual(JoinRule.Invite); }); it("uses the 'name' field to caluclate the room name", () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]); - const gotRoom = client.getRoom(roomF); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]); + const gotRoom = client!.getRoom(roomF); expect(gotRoom).toBeDefined(); + if (gotRoom == null) { return; } expect( gotRoom.name, ).toEqual(data[roomF].name); @@ -319,61 +347,80 @@ describe("SlidingSyncSdk", () => { describe("updating", () => { it("can update with a new timeline event", async () => { const newEvent = mkOwnEvent(EventType.RoomMessage, { body: "new event A" }); - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomA, { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, { timeline: [newEvent], required_state: [], name: data[roomA].name, }); - const gotRoom = client.getRoom(roomA); + const gotRoom = client!.getRoom(roomA); expect(gotRoom).toBeDefined(); + if (gotRoom == null) { return; } const newTimeline = data[roomA].timeline; newTimeline.push(newEvent); assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-3), newTimeline); }); it("can update with a new required_state event", async () => { - let gotRoom = client.getRoom(roomB); + let gotRoom = client!.getRoom(roomB); + expect(gotRoom).toBeDefined(); + if (gotRoom == null) { return; } expect(gotRoom.getJoinRule()).toEqual(JoinRule.Invite); // default - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomB, { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, { required_state: [ mkOwnStateEvent("m.room.join_rules", { join_rule: "restricted" }, ""), ], timeline: [], name: data[roomB].name, }); - gotRoom = client.getRoom(roomB); + gotRoom = client!.getRoom(roomB); expect(gotRoom).toBeDefined(); + if (gotRoom == null) { return; } expect(gotRoom.getJoinRule()).toEqual(JoinRule.Restricted); }); it("can update with a new highlight_count", async () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomC, { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomC, { name: data[roomC].name, required_state: [], timeline: [], highlight_count: 1, }); - const gotRoom = client.getRoom(roomC); + const gotRoom = client!.getRoom(roomC); expect(gotRoom).toBeDefined(); + if (gotRoom == null) { return; } expect( gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight), ).toEqual(1); }); it("can update with a new notification_count", async () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomD, { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomD, { name: data[roomD].name, required_state: [], timeline: [], notification_count: 1, }); - const gotRoom = client.getRoom(roomD); + const gotRoom = client!.getRoom(roomD); expect(gotRoom).toBeDefined(); + if (gotRoom == null) { return; } expect( gotRoom.getUnreadNotificationCount(NotificationCountType.Total), ).toEqual(1); }); + it("can update with a new joined_count", () => { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomG, { + name: data[roomD].name, + required_state: [], + timeline: [], + joined_count: 1, + }); + const gotRoom = client!.getRoom(roomG); + expect(gotRoom).toBeDefined(); + if (gotRoom == null) { return; } + expect(gotRoom.getJoinedMemberCount()).toEqual(1); + }); + // Regression test for a bug which caused the timeline entries to be out-of-order // when the same room appears twice with different timeline limits. E.g appears in // the list with timeline_limit:1 then appears again as a room subscription with @@ -386,14 +433,15 @@ describe("SlidingSyncSdk", () => { mkOwnEvent(EventType.RoomMessage, { body: "old event C" }), ...timeline, ]; - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomA, { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, { timeline: oldTimeline, required_state: [], name: data[roomA].name, initial: true, // e.g requested via room subscription }); - const gotRoom = client.getRoom(roomA); + const gotRoom = client!.getRoom(roomA); expect(gotRoom).toBeDefined(); + if (gotRoom == null) { return; } logger.log("want:", oldTimeline.map((e) => (e.type + " : " + (e.content || {}).body))); logger.log("got:", gotRoom.getLiveTimeline().getEvents().map( @@ -410,50 +458,50 @@ describe("SlidingSyncSdk", () => { describe("lifecycle", () => { beforeAll(async () => { await setupClient(); - const hasSynced = sdk.sync(); - await httpBackend.flushAllExpected(); + const hasSynced = sdk!.sync(); + await httpBackend!.flushAllExpected(); await hasSynced; }); const FAILED_SYNC_ERROR_THRESHOLD = 3; // would be nice to export the const in the actual class... it("emits SyncState.Reconnecting when < FAILED_SYNC_ERROR_THRESHOLD & SyncState.Error when over", async () => { - mockSlidingSync.emit( + mockSlidingSync!.emit( SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, { pos: "h", lists: [], rooms: {}, extensions: {} }, null, ); - expect(sdk.getSyncState()).toEqual(SyncState.Syncing); + expect(sdk!.getSyncState()).toEqual(SyncState.Syncing); - mockSlidingSync.emit( + mockSlidingSync!.emit( SlidingSyncEvent.Lifecycle, SlidingSyncState.RequestFinished, null, new Error("generic"), ); - expect(sdk.getSyncState()).toEqual(SyncState.Reconnecting); + expect(sdk!.getSyncState()).toEqual(SyncState.Reconnecting); for (let i = 0; i < FAILED_SYNC_ERROR_THRESHOLD; i++) { - mockSlidingSync.emit( + mockSlidingSync!.emit( SlidingSyncEvent.Lifecycle, SlidingSyncState.RequestFinished, null, new Error("generic"), ); } - expect(sdk.getSyncState()).toEqual(SyncState.Error); + expect(sdk!.getSyncState()).toEqual(SyncState.Error); }); it("emits SyncState.Syncing after a previous SyncState.Error", async () => { - mockSlidingSync.emit( + mockSlidingSync!.emit( SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, { pos: "i", lists: [], rooms: {}, extensions: {} }, null, ); - expect(sdk.getSyncState()).toEqual(SyncState.Syncing); + expect(sdk!.getSyncState()).toEqual(SyncState.Syncing); }); it("emits SyncState.Error immediately when receiving M_UNKNOWN_TOKEN and stops syncing", async () => { - expect(mockSlidingSync.stop).not.toBeCalled(); - mockSlidingSync.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.RequestFinished, null, new MatrixError({ + expect(mockSlidingSync!.stop).not.toBeCalled(); + mockSlidingSync!.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.RequestFinished, null, new MatrixError({ errcode: "M_UNKNOWN_TOKEN", message: "Oh no your access token is no longer valid", })); - expect(sdk.getSyncState()).toEqual(SyncState.Error); - expect(mockSlidingSync.stop).toBeCalled(); + expect(sdk!.getSyncState()).toEqual(SyncState.Error); + expect(mockSlidingSync!.stop).toBeCalled(); }); }); @@ -469,8 +517,8 @@ describe("SlidingSyncSdk", () => { avatar_url: "mxc://foobar", displayname: "The Invitee", }; - httpBackend.when("GET", "/profile").respond(200, inviteeProfile); - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomId, { + httpBackend!.when("GET", "/profile").respond(200, inviteeProfile); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomId, { initial: true, name: "Room with Invite", required_state: [], @@ -481,10 +529,10 @@ describe("SlidingSyncSdk", () => { mkOwnStateEvent(EventType.RoomMember, { membership: "invite" }, invitee), ], }); - await httpBackend.flush("/profile", 1, 1000); - const room = client.getRoom(roomId); + await httpBackend!.flush("/profile", 1, 1000); + const room = client!.getRoom(roomId)!; expect(room).toBeDefined(); - const inviteeMember = room.getMember(invitee); + const inviteeMember = room.getMember(invitee)!; expect(inviteeMember).toBeDefined(); expect(inviteeMember.getMxcAvatarUrl()).toEqual(inviteeProfile.avatar_url); expect(inviteeMember.name).toEqual(inviteeProfile.displayname); @@ -497,8 +545,8 @@ describe("SlidingSyncSdk", () => { await setupClient({ withCrypto: true, }); - const hasSynced = sdk.sync(); - await httpBackend.flushAllExpected(); + const hasSynced = sdk!.sync(); + await httpBackend!.flushAllExpected(); await hasSynced; ext = findExtension("e2ee"); }); @@ -506,7 +554,7 @@ describe("SlidingSyncSdk", () => { // needed else we do some async operations in the background which can cause Jest to whine: // "Cannot log after tests are done. Did you forget to wait for something async in your test?" // Attempted to log "Saving device tracking data null"." - client.crypto.stop(); + client!.crypto!.stop(); }); it("gets enabled on the initial request only", () => { expect(ext.onRequest(true)).toEqual({ @@ -524,38 +572,38 @@ describe("SlidingSyncSdk", () => { // TODO: more assertions? }); it("can update OTK counts", () => { - client.crypto.updateOneTimeKeyCount = jest.fn(); + client!.crypto!.updateOneTimeKeyCount = jest.fn(); ext.onResponse({ device_one_time_keys_count: { signed_curve25519: 42, }, }); - expect(client.crypto.updateOneTimeKeyCount).toHaveBeenCalledWith(42); + expect(client!.crypto!.updateOneTimeKeyCount).toHaveBeenCalledWith(42); ext.onResponse({ device_one_time_keys_count: { not_signed_curve25519: 42, // missing field -> default to 0 }, }); - expect(client.crypto.updateOneTimeKeyCount).toHaveBeenCalledWith(0); + expect(client!.crypto!.updateOneTimeKeyCount).toHaveBeenCalledWith(0); }); it("can update fallback keys", () => { ext.onResponse({ device_unused_fallback_key_types: ["signed_curve25519"], }); - expect(client.crypto.getNeedsNewFallback()).toEqual(false); + expect(client!.crypto!.getNeedsNewFallback()).toEqual(false); ext.onResponse({ device_unused_fallback_key_types: ["not_signed_curve25519"], }); - expect(client.crypto.getNeedsNewFallback()).toEqual(true); + expect(client!.crypto!.getNeedsNewFallback()).toEqual(true); }); }); describe("ExtensionAccountData", () => { let ext: Extension; beforeAll(async () => { await setupClient(); - const hasSynced = sdk.sync(); - await httpBackend.flushAllExpected(); + const hasSynced = sdk!.sync(); + await httpBackend!.flushAllExpected(); await hasSynced; ext = findExtension("account_data"); }); @@ -570,7 +618,7 @@ describe("SlidingSyncSdk", () => { const globalContent = { info: "here", }; - let globalData = client.getAccountData(globalType); + let globalData = client!.getAccountData(globalType); expect(globalData).toBeUndefined(); ext.onResponse({ global: [ @@ -580,13 +628,13 @@ describe("SlidingSyncSdk", () => { }, ], }); - globalData = client.getAccountData(globalType); + globalData = client!.getAccountData(globalType)!; expect(globalData).toBeDefined(); expect(globalData.getContent()).toEqual(globalContent); }); it("processes rooms account data", async () => { const roomId = "!room:id"; - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomId, { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomId, { name: "Room with account data", required_state: [], timeline: [ @@ -612,9 +660,9 @@ describe("SlidingSyncSdk", () => { ], }, }); - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; expect(room).toBeDefined(); - const event = room.getAccountData(roomType); + const event = room.getAccountData(roomType)!; expect(event).toBeDefined(); expect(event.getContent()).toEqual(roomContent); }); @@ -633,9 +681,9 @@ describe("SlidingSyncSdk", () => { ], }, }); - const room = client.getRoom(unknownRoomId); + const room = client!.getRoom(unknownRoomId); expect(room).toBeNull(); - expect(client.getAccountData(roomType)).toBeUndefined(); + expect(client!.getAccountData(roomType)).toBeUndefined(); }); it("can update push rules via account data", async () => { const roomId = "!foo:bar"; @@ -655,7 +703,7 @@ describe("SlidingSyncSdk", () => { }], }, }; - let pushRule = client.getRoomPushRule("global", roomId); + let pushRule = client!.getRoomPushRule("global", roomId); expect(pushRule).toBeUndefined(); ext.onResponse({ global: [ @@ -665,16 +713,16 @@ describe("SlidingSyncSdk", () => { }, ], }); - pushRule = client.getRoomPushRule("global", roomId); - expect(pushRule).toEqual(pushRulesContent.global[PushRuleKind.RoomSpecific][0]); + pushRule = client!.getRoomPushRule("global", roomId)!; + expect(pushRule).toEqual(pushRulesContent.global[PushRuleKind.RoomSpecific]![0]); }); }); describe("ExtensionToDevice", () => { let ext: Extension; beforeAll(async () => { await setupClient(); - const hasSynced = sdk.sync(); - await httpBackend.flushAllExpected(); + const hasSynced = sdk!.sync(); + await httpBackend!.flushAllExpected(); await hasSynced; ext = findExtension("to_device"); }); @@ -705,7 +753,7 @@ describe("SlidingSyncSdk", () => { foo: "bar", }; let called = false; - client.once(ClientEvent.ToDeviceEvent, (ev) => { + client!.once(ClientEvent.ToDeviceEvent, (ev) => { expect(ev.getContent()).toEqual(toDeviceContent); expect(ev.getType()).toEqual(toDeviceType); called = true; @@ -723,7 +771,7 @@ describe("SlidingSyncSdk", () => { }); it("can cancel key verification requests", async () => { const seen: Record = {}; - client.on(ClientEvent.ToDeviceEvent, (ev) => { + client!.on(ClientEvent.ToDeviceEvent, (ev) => { const evType = ev.getType(); expect(seen[evType]).toBeFalsy(); seen[evType] = true; diff --git a/spec/integ/sliding-sync.spec.ts b/spec/integ/sliding-sync.spec.ts index 4d09c2f8e..f7ec7c747 100644 --- a/spec/integ/sliding-sync.spec.ts +++ b/spec/integ/sliding-sync.spec.ts @@ -30,8 +30,8 @@ import { sleep } from "../../src/utils"; * Each test will call different functions on SlidingSync which may depend on state from previous tests. */ describe("SlidingSync", () => { - let client: MatrixClient = null; - let httpBackend: MockHttpBackend = null; + let client: MatrixClient | undefined; + let httpBackend: MockHttpBackend | undefined; const selfUserId = "@alice:localhost"; const selfAccessToken = "aseukfgwef"; const proxyBaseUrl = "http://localhost:8008"; @@ -46,9 +46,9 @@ describe("SlidingSync", () => { // tear down client/httpBackend globals const teardownClient = () => { - httpBackend.verifyNoOutstandingExpectation(); - client.stopClient(); - return httpBackend.stop(); + httpBackend!.verifyNoOutstandingExpectation(); + client!.stopClient(); + return httpBackend!.stop(); }; describe("start/stop", () => { @@ -57,14 +57,14 @@ describe("SlidingSync", () => { let slidingSync: SlidingSync; it("should start the sync loop upon calling start()", async () => { - slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client, 1); + slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client!, 1); const fakeResp = { pos: "a", lists: [], rooms: {}, extensions: {}, }; - httpBackend.when("POST", syncUrl).respond(200, fakeResp); + httpBackend!.when("POST", syncUrl).respond(200, fakeResp); const p = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state, resp, err) => { expect(state).toEqual(SlidingSyncState.RequestFinished); expect(resp).toEqual(fakeResp); @@ -72,13 +72,13 @@ describe("SlidingSync", () => { return true; }); slidingSync.start(); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; }); it("should stop the sync loop upon calling stop()", () => { slidingSync.stop(); - httpBackend.verifyNoOutstandingExpectation(); + httpBackend!.verifyNoOutstandingExpectation(); }); it("should reset the connection on HTTP 400 and send everything again", async () => { @@ -203,9 +203,9 @@ describe("SlidingSync", () => { it("should be able to subscribe to a room", async () => { // add the subscription - slidingSync = new SlidingSync(proxyBaseUrl, [], roomSubInfo, client, 1); + slidingSync = new SlidingSync(proxyBaseUrl, [], roomSubInfo, client!, 1); slidingSync.modifyRoomSubscriptions(new Set([roomId])); - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("room sub", body); expect(body.room_subscriptions).toBeTruthy(); @@ -225,7 +225,7 @@ describe("SlidingSync", () => { return true; }); slidingSync.start(); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; }); @@ -237,7 +237,7 @@ describe("SlidingSync", () => { ["m.room.member", "*"], ], }; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("adjusted sub", body); expect(body.room_subscriptions).toBeTruthy(); @@ -258,7 +258,7 @@ describe("SlidingSync", () => { }); slidingSync.modifyRoomSubscriptionInfo(newSubInfo); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; // need to set what the new subscription info is for subsequent tests roomSubInfo = newSubInfo; @@ -279,7 +279,7 @@ describe("SlidingSync", () => { required_state: [], timeline: [], }; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("new subs", body); expect(body.room_subscriptions).toBeTruthy(); @@ -304,12 +304,12 @@ describe("SlidingSync", () => { const subs = slidingSync.getRoomSubscriptions(); subs.add(anotherRoomID); slidingSync.modifyRoomSubscriptions(subs); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; }); it("should be able to unsubscribe from a room", async () => { - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("unsub request", body); expect(body.room_subscriptions).toBeFalsy(); @@ -326,7 +326,7 @@ describe("SlidingSync", () => { // remove the subscription for the first room slidingSync.modifyRoomSubscriptions(new Set([anotherRoomID])); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; slidingSync.stop(); @@ -373,8 +373,8 @@ describe("SlidingSync", () => { is_dm: true, }, }; - slidingSync = new SlidingSync(proxyBaseUrl, [listReq], {}, client, 1); - httpBackend.when("POST", syncUrl).check(function(req) { + slidingSync = new SlidingSync(proxyBaseUrl, [listReq], {}, client!, 1); + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("list", body); expect(body.lists).toBeTruthy(); @@ -401,7 +401,7 @@ describe("SlidingSync", () => { return state === SlidingSyncState.Complete; }); slidingSync.start(); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; expect(listenerData[roomA]).toEqual(rooms[roomA]); @@ -427,7 +427,7 @@ describe("SlidingSync", () => { it("should be possible to adjust list ranges", async () => { // modify the list ranges - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("next ranges", body.lists[0].ranges); expect(body.lists).toBeTruthy(); @@ -451,7 +451,7 @@ describe("SlidingSync", () => { return state === SlidingSyncState.RequestFinished; }); slidingSync.setListRanges(0, newRanges); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; }); @@ -464,7 +464,7 @@ describe("SlidingSync", () => { "is_dm": true, }, }; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("extra list", body); expect(body.lists).toBeTruthy(); @@ -503,13 +503,13 @@ describe("SlidingSync", () => { return state === SlidingSyncState.Complete; }); slidingSync.setList(1, extraListReq); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; }); it("should be possible to get list DELETE/INSERTs", async () => { // move C (2) to A (0) - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "e", lists: [{ count: 500, @@ -540,12 +540,12 @@ describe("SlidingSync", () => { let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; // move C (0) back to A (2) - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "f", lists: [{ count: 500, @@ -576,13 +576,13 @@ describe("SlidingSync", () => { responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; }); it("should ignore invalid list indexes", async () => { - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "e", lists: [{ count: 500, @@ -609,13 +609,13 @@ describe("SlidingSync", () => { const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; }); it("should be possible to update a list", async () => { - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "g", lists: [{ count: 42, @@ -655,7 +655,7 @@ describe("SlidingSync", () => { const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; }); @@ -667,7 +667,7 @@ describe("SlidingSync", () => { 1: roomC, }; expect(slidingSync.getListData(0).roomIndexToRoomId).toEqual(indexToRoomId); - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "f", // currently the list is [B,C] so we will insert D then immediately delete it lists: [{ @@ -698,7 +698,7 @@ describe("SlidingSync", () => { const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; }); @@ -708,7 +708,7 @@ describe("SlidingSync", () => { 0: roomB, 1: roomC, }); - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "g", lists: [{ count: 499, @@ -734,7 +734,7 @@ describe("SlidingSync", () => { const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; }); @@ -743,7 +743,7 @@ describe("SlidingSync", () => { expect(slidingSync.getListData(0).roomIndexToRoomId).toEqual({ 0: roomC, }); - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "h", lists: [{ count: 500, @@ -770,11 +770,11 @@ describe("SlidingSync", () => { let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "h", lists: [{ count: 501, @@ -802,7 +802,7 @@ describe("SlidingSync", () => { responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; slidingSync.stop(); @@ -825,11 +825,11 @@ describe("SlidingSync", () => { ], }; // add the subscription - slidingSync = new SlidingSync(proxyBaseUrl, [], roomSubInfo, client, 1); + slidingSync = new SlidingSync(proxyBaseUrl, [], roomSubInfo, client!, 1); // modification before SlidingSync.start() const subscribePromise = slidingSync.modifyRoomSubscriptions(new Set([roomId])); let txnId; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.debug("got ", body); expect(body.room_subscriptions).toBeTruthy(); @@ -852,7 +852,7 @@ describe("SlidingSync", () => { }; }); slidingSync.start(); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await subscribePromise; }); it("should resolve setList during a connection", async () => { @@ -861,7 +861,7 @@ describe("SlidingSync", () => { }; const promise = slidingSync.setList(0, newList); let txnId; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.debug("got ", body); expect(body.room_subscriptions).toBeFalsy(); @@ -876,14 +876,14 @@ describe("SlidingSync", () => { extensions: {}, }; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await promise; expect(txnId).toBeDefined(); }); it("should resolve setListRanges during a connection", async () => { const promise = slidingSync.setListRanges(0, [[20, 40]]); let txnId; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.debug("got ", body); expect(body.room_subscriptions).toBeFalsy(); @@ -900,7 +900,7 @@ describe("SlidingSync", () => { extensions: {}, }; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await promise; expect(txnId).toBeDefined(); }); @@ -909,7 +909,7 @@ describe("SlidingSync", () => { timeline_limit: 99, }); let txnId; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.debug("got ", body); expect(body.room_subscriptions).toBeTruthy(); @@ -925,22 +925,22 @@ describe("SlidingSync", () => { extensions: {}, }; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await promise; expect(txnId).toBeDefined(); }); it("should reject earlier pending promises if a later transaction is acknowledged", async () => { // i.e if we have [A,B,C] and see txn_id=C then A,B should be rejected. - const gotTxnIds = []; + const gotTxnIds: any[] = []; const pushTxn = function(req) { gotTxnIds.push(req.data.txn_id); }; const failPromise = slidingSync.setListRanges(0, [[20, 40]]); - httpBackend.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "e" }); // missing txn_id - await httpBackend.flushAllExpected(); + httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "e" }); // missing txn_id + await httpBackend!.flushAllExpected(); const failPromise2 = slidingSync.setListRanges(0, [[60, 70]]); - httpBackend.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "f" }); // missing txn_id - await httpBackend.flushAllExpected(); + httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "f" }); // missing txn_id + await httpBackend!.flushAllExpected(); // attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection // which is a fail. @@ -949,7 +949,7 @@ describe("SlidingSync", () => { const okPromise = slidingSync.setListRanges(0, [[0, 20]]); let txnId; - httpBackend.when("POST", syncUrl).check((req) => { + httpBackend!.when("POST", syncUrl).check((req) => { txnId = req.data.txn_id; }).respond(200, () => { // include the txn_id, earlier requests should now be reject()ed. @@ -958,23 +958,23 @@ describe("SlidingSync", () => { txn_id: txnId, }; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await okPromise; expect(txnId).toBeDefined(); }); it("should not reject later pending promises if an earlier transaction is acknowledged", async () => { // i.e if we have [A,B,C] and see txn_id=B then C should not be rejected but A should. - const gotTxnIds = []; + const gotTxnIds: any[] = []; const pushTxn = function(req) { - gotTxnIds.push(req.data.txn_id); + gotTxnIds.push(req.data?.txn_id); }; const A = slidingSync.setListRanges(0, [[20, 40]]); - httpBackend.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "A" }); - await httpBackend.flushAllExpected(); + httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "A" }); + await httpBackend!.flushAllExpected(); const B = slidingSync.setListRanges(0, [[60, 70]]); - httpBackend.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "B" }); // missing txn_id - await httpBackend.flushAllExpected(); + httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "B" }); // missing txn_id + await httpBackend!.flushAllExpected(); // attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection // which is a fail. @@ -985,14 +985,14 @@ describe("SlidingSync", () => { C.finally(() => { pendingC = false; }); - httpBackend.when("POST", syncUrl).check(pushTxn).respond(200, () => { + httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, () => { // include the txn_id for B, so C's promise is outstanding return { pos: "C", txn_id: gotTxnIds[1], }; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); // A is rejected, see above expect(B).resolves.toEqual(gotTxnIds[1]); // B is resolved expect(pendingC).toBe(true); // C is pending still @@ -1004,7 +1004,7 @@ describe("SlidingSync", () => { pending = false; }); let txnId; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.debug("got ", body); expect(body.room_subscriptions).toBeFalsy(); @@ -1021,7 +1021,7 @@ describe("SlidingSync", () => { extensions: {}, }; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); expect(txnId).toBeDefined(); expect(pending).toBe(true); slidingSync.stop(); @@ -1063,10 +1063,10 @@ describe("SlidingSync", () => { }; it("should be able to register an extension", async () => { - slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client, 1); + slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client!, 1); slidingSync.registerExtension(extPre); - const callbackOrder = []; + const callbackOrder: string[] = []; let extensionOnResponseCalled = false; onPreExtensionRequest = () => { return extReq; @@ -1077,7 +1077,7 @@ describe("SlidingSync", () => { expect(resp).toEqual(extResp); }; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("ext req", body); expect(body.extensions).toBeTruthy(); @@ -1098,7 +1098,7 @@ describe("SlidingSync", () => { } }); slidingSync.start(); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; expect(extensionOnResponseCalled).toBe(true); expect(callbackOrder).toEqual(["onPreExtensionResponse", "Lifecycle"]); @@ -1112,7 +1112,7 @@ describe("SlidingSync", () => { onPreExtensionResponse = (resp) => { responseCalled = true; }; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("ext req nothing", body); expect(body.extensions).toBeTruthy(); @@ -1130,7 +1130,7 @@ describe("SlidingSync", () => { const p = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state, resp, err) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; expect(responseCalled).toBe(false); }); @@ -1141,13 +1141,13 @@ describe("SlidingSync", () => { return extReq; }; let responseCalled = false; - const callbackOrder = []; + const callbackOrder: string[] = []; onPostExtensionResponse = (resp) => { expect(resp).toEqual(extResp); responseCalled = true; callbackOrder.push("onPostExtensionResponse"); }; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("ext req after start", body); expect(body.extensions).toBeTruthy(); @@ -1171,7 +1171,7 @@ describe("SlidingSync", () => { return true; } }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; expect(responseCalled).toBe(true); expect(callbackOrder).toEqual(["Lifecycle", "onPostExtensionResponse"]); @@ -1179,7 +1179,7 @@ describe("SlidingSync", () => { }); it("is not possible to register the same extension name twice", async () => { - slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client, 1); + slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client!, 1); slidingSync.registerExtension(extPre); expect(() => { slidingSync.registerExtension(extPre); }).toThrow(); }); @@ -1206,7 +1206,7 @@ function listenUntil( callback: (...args: any[]) => T, timeoutMs = 500, ): Promise { - const trace = new Error().stack.split(`\n`)[2]; + const trace = new Error().stack?.split(`\n`)[2]; return Promise.race([new Promise((resolve, reject) => { const wrapper = (...args) => { try { diff --git a/spec/olm-loader.js b/spec/olm-loader.ts similarity index 89% rename from spec/olm-loader.js rename to spec/olm-loader.ts index 505c08615..388220f0d 100644 --- a/spec/olm-loader.js +++ b/spec/olm-loader.ts @@ -20,6 +20,7 @@ import * as utils from "../src/utils"; // try to load the olm library. try { + // eslint-disable-next-line @typescript-eslint/no-var-requires global.Olm = require('@matrix-org/olm'); logger.log('loaded libolm'); } catch (e) { @@ -28,6 +29,7 @@ try { // also try to set node crypto try { + // eslint-disable-next-line @typescript-eslint/no-var-requires const crypto = require('crypto'); utils.setCrypto(crypto); } catch (err) { diff --git a/spec/test-utils/client.ts b/spec/test-utils/client.ts new file mode 100644 index 000000000..3cacd179d --- /dev/null +++ b/spec/test-utils/client.ts @@ -0,0 +1,94 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MethodKeysOf, mocked, MockedObject } from "jest-mock"; + +import { ClientEventHandlerMap, EmittedEvents, MatrixClient } from "../../src/client"; +import { TypedEventEmitter } from "../../src/models/typed-event-emitter"; +import { User } from "../../src/models/user"; + +/** + * Mock client with real event emitter + * useful for testing code that listens + * to MatrixClient events + */ +export class MockClientWithEventEmitter extends TypedEventEmitter { + constructor(mockProperties: Partial, unknown>> = {}) { + super(); + Object.assign(this, mockProperties); + } +} + +/** + * - make a mock client + * - cast the type to mocked(MatrixClient) + * - spy on MatrixClientPeg.get to return the mock + * eg + * ``` + * const mockClient = getMockClientWithEventEmitter({ + getUserId: jest.fn().mockReturnValue(aliceId), + }); + * ``` + */ +export const getMockClientWithEventEmitter = ( + mockProperties: Partial, unknown>>, +): MockedObject => { + const mock = mocked(new MockClientWithEventEmitter(mockProperties) as unknown as MatrixClient); + return mock; +}; + +/** + * Returns basic mocked client methods related to the current user + * ``` + * const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser('@mytestuser:domain'), + }); + * ``` + */ +export const mockClientMethodsUser = (userId = '@alice:domain') => ({ + getUserId: jest.fn().mockReturnValue(userId), + getUser: jest.fn().mockReturnValue(new User(userId)), + isGuest: jest.fn().mockReturnValue(false), + mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'), + credentials: { userId }, + getThreePids: jest.fn().mockResolvedValue({ threepids: [] }), + getAccessToken: jest.fn(), +}); + +/** + * Returns basic mocked client methods related to rendering events + * ``` + * const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser('@mytestuser:domain'), + }); + * ``` + */ +export const mockClientMethodsEvents = () => ({ + decryptEventIfNeeded: jest.fn(), + getPushActionsForEvent: jest.fn(), +}); + +/** + * Returns basic mocked client methods related to server support + */ +export const mockClientMethodsServer = (): Partial, unknown>> => ({ + doesServerSupportSeparateAddAndBind: jest.fn(), + getIdentityServerUrl: jest.fn(), + getHomeserverUrl: jest.fn(), + getCapabilities: jest.fn().mockReturnValue({}), + doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false), +}); + diff --git a/spec/test-utils/emitter.ts b/spec/test-utils/emitter.ts index 0e6971ada..b56ac2ebc 100644 --- a/spec/test-utils/emitter.ts +++ b/spec/test-utils/emitter.ts @@ -24,5 +24,5 @@ limitations under the License. * expect(beaconLivenessEmits.length).toBe(1); * ``` */ -export const filterEmitCallsByEventType = (eventType: string, spy: jest.SpyInstance) => +export const filterEmitCallsByEventType = (eventType: string, spy: jest.SpyInstance) => spy.mock.calls.filter((args) => args[0] === eventType); diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts index abb328e0c..288e0355b 100644 --- a/spec/test-utils/test-utils.ts +++ b/spec/test-utils/test-utils.ts @@ -6,7 +6,7 @@ import '../olm-loader'; import { logger } from '../../src/logger'; import { IContent, IEvent, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event"; -import { ClientEvent, EventType, MatrixClient, MsgType } from "../../src"; +import { ClientEvent, EventType, IPusher, MatrixClient, MsgType } from "../../src"; import { SyncState } from "../../src/sync"; import { eventMapperFor } from "../../src/event-mapper"; @@ -74,6 +74,7 @@ interface IEventOpts { sender?: string; skey?: string; content: IContent; + prev_content?: IContent; user?: string; unsigned?: IUnsigned; redacts?: string; @@ -103,6 +104,7 @@ export function mkEvent(opts: IEventOpts & { event?: boolean }, client?: MatrixC room_id: opts.room, sender: opts.sender || opts.user, // opts.user for backwards-compat content: opts.content, + prev_content: opts.prev_content, unsigned: opts.unsigned || {}, event_id: "$" + testEventIndex++ + "-" + Math.random() + "-" + Math.random(), txn_id: "~" + Math.random(), @@ -147,9 +149,9 @@ export function mkEventCustom(base: T): T & GeneratedMetadata { interface IPresenceOpts { user?: string; sender?: string; - url: string; - name: string; - ago: number; + url?: string; + name?: string; + ago?: number; presence?: string; event?: boolean; } @@ -371,3 +373,14 @@ export async function awaitDecryption(event: MatrixEvent): Promise } export const emitPromise = (e: EventEmitter, k: string): Promise => new Promise(r => e.once(k, r)); + +export const mkPusher = (extra: Partial = {}): IPusher => ({ + app_display_name: "app", + app_id: "123", + data: {}, + device_display_name: "name", + kind: "http", + lang: "en", + pushkey: "pushpush", + ...extra, +}); diff --git a/spec/unit/NamespacedValue.spec.ts b/spec/unit/NamespacedValue.spec.ts index 834acd0c9..d2864a134 100644 --- a/spec/unit/NamespacedValue.spec.ts +++ b/spec/unit/NamespacedValue.spec.ts @@ -21,18 +21,21 @@ describe("NamespacedValue", () => { const ns = new NamespacedValue("stable", "unstable"); expect(ns.name).toBe(ns.stable); expect(ns.altName).toBe(ns.unstable); + expect(ns.names).toEqual([ns.stable, ns.unstable]); }); it("should return unstable if there is no stable", () => { const ns = new NamespacedValue(null, "unstable"); expect(ns.name).toBe(ns.unstable); expect(ns.altName).toBeFalsy(); + expect(ns.names).toEqual([ns.unstable]); }); it("should have a falsey unstable if needed", () => { const ns = new NamespacedValue("stable", null); expect(ns.name).toBe(ns.stable); expect(ns.altName).toBeFalsy(); + expect(ns.names).toEqual([ns.stable]); }); it("should match against either stable or unstable", () => { @@ -58,12 +61,14 @@ describe("UnstableValue", () => { const ns = new UnstableValue("stable", "unstable"); expect(ns.name).toBe(ns.unstable); expect(ns.altName).toBe(ns.stable); + expect(ns.names).toEqual([ns.unstable, ns.stable]); }); it("should return unstable if there is no stable", () => { const ns = new UnstableValue(null, "unstable"); expect(ns.name).toBe(ns.unstable); expect(ns.altName).toBeFalsy(); + expect(ns.names).toEqual([ns.unstable]); }); it("should not permit falsey unstable values", () => { diff --git a/spec/unit/content-helpers.spec.ts b/spec/unit/content-helpers.spec.ts index ba431d9d5..359716007 100644 --- a/spec/unit/content-helpers.spec.ts +++ b/spec/unit/content-helpers.spec.ts @@ -22,6 +22,7 @@ import { makeBeaconContent, makeBeaconInfoContent, makeTopicContent, + parseBeaconContent, parseTopicContent, } from "../../src/content-helpers"; @@ -127,6 +128,66 @@ describe('Beacon content helpers', () => { }); }); }); + + describe("parseBeaconContent()", () => { + it("should not explode when parsing an invalid beacon", () => { + // deliberate cast to simulate wire content being invalid + const result = parseBeaconContent({} as any); + expect(result).toEqual({ + description: undefined, + uri: undefined, + timestamp: undefined, + }); + }); + + it("should parse unstable values", () => { + const uri = "urigoeshere"; + const description = "descriptiongoeshere"; + const timestamp = 1234; + const result = parseBeaconContent({ + "org.matrix.msc3488.location": { + uri, + description, + }, + "org.matrix.msc3488.ts": timestamp, + + // relationship not used - just here to satisfy types + "m.relates_to": { + rel_type: "m.reference", + event_id: "$unused", + }, + }); + expect(result).toEqual({ + description, + uri, + timestamp, + }); + }); + + it("should parse stable values", () => { + const uri = "urigoeshere"; + const description = "descriptiongoeshere"; + const timestamp = 1234; + const result = parseBeaconContent({ + "m.location": { + uri, + description, + }, + "m.ts": timestamp, + + // relationship not used - just here to satisfy types + "m.relates_to": { + rel_type: "m.reference", + event_id: "$unused", + }, + }); + expect(result).toEqual({ + description, + uri, + timestamp, + }); + }); + }); }); describe('Topic content helpers', () => { diff --git a/spec/unit/crypto.spec.ts b/spec/unit/crypto.spec.ts index 19217cdda..6e46b3aaa 100644 --- a/spec/unit/crypto.spec.ts +++ b/spec/unit/crypto.spec.ts @@ -15,6 +15,9 @@ import { CRYPTO_ENABLED } from "../../src/client"; import { DeviceInfo } from "../../src/crypto/deviceinfo"; import { logger } from '../../src/logger'; import { MemoryStore } from "../../src"; +import { RoomKeyRequestState } from '../../src/crypto/OutgoingRoomKeyRequestManager'; +import { RoomMember } from '../../src/models/room-member'; +import { IStore } from '../../src/store'; const Olm = global.Olm; @@ -39,20 +42,52 @@ async function keyshareEventForEvent(client, event, index): Promise type: "m.forwarded_room_key", sender: client.getUserId(), content: { - algorithm: olmlib.MEGOLM_ALGORITHM, - room_id: roomId, - sender_key: eventContent.sender_key, - sender_claimed_ed25519_key: key.sender_claimed_ed25519_key, - session_id: eventContent.session_id, - session_key: key.key, - chain_index: key.chain_index, - forwarding_curve25519_key_chain: - key.forwarding_curve_key_chain, + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": roomId, + "sender_key": eventContent.sender_key, + "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, + "session_id": eventContent.session_id, + "session_key": key.key, + "chain_index": key.chain_index, + "forwarding_curve25519_key_chain": key.forwarding_curve_key_chain, + "org.matrix.msc3061.shared_history": true, }, }); // make onRoomKeyEvent think this was an encrypted event // @ts-ignore private property ksEvent.senderCurve25519Key = "akey"; + ksEvent.getWireType = () => "m.room.encrypted"; + ksEvent.getWireContent = () => { + return { + algorithm: "m.olm.v1.curve25519-aes-sha2", + }; + }; + return ksEvent; +} + +function roomKeyEventForEvent(client: MatrixClient, event: MatrixEvent): MatrixEvent { + const roomId = event.getRoomId(); + const eventContent = event.getWireContent(); + const key = client.crypto.olmDevice.getOutboundGroupSessionKey(eventContent.session_id); + const ksEvent = new MatrixEvent({ + type: "m.room_key", + sender: client.getUserId(), + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": roomId, + "session_id": eventContent.session_id, + "session_key": key.key, + }, + }); + // make onRoomKeyEvent think this was an encrypted event + // @ts-ignore private property + ksEvent.senderCurve25519Key = event.getSenderKey(); + ksEvent.getWireType = () => "m.room.encrypted"; + ksEvent.getWireContent = () => { + return { + algorithm: "m.olm.v1.curve25519-aes-sha2", + }; + }; return ksEvent; } @@ -94,7 +129,7 @@ describe("Crypto", function() { event.getSenderKey = () => 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI'; event.getWireContent = () => {return { algorithm: olmlib.MEGOLM_ALGORITHM };}; event.getForwardingCurve25519KeyChain = () => ["not empty"]; - event.isKeySourceUntrusted = () => false; + event.isKeySourceUntrusted = () => true; event.getClaimedEd25519Key = () => 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; @@ -158,8 +193,8 @@ describe("Crypto", function() { let fakeEmitter; beforeEach(async function() { - const mockStorage = new MockStorageApi(); - const clientStore = new MemoryStore({ localStorage: mockStorage }); + const mockStorage = new MockStorageApi() as unknown as Storage; + const clientStore = new MemoryStore({ localStorage: mockStorage }) as unknown as IStore; const cryptoStore = new MemoryCryptoStore(); cryptoStore.storeEndToEndDeviceData({ @@ -232,6 +267,7 @@ describe("Crypto", function() { describe('Key requests', function() { let aliceClient: MatrixClient; let bobClient: MatrixClient; + let claraClient: MatrixClient; beforeEach(async function() { aliceClient = (new TestClient( @@ -240,22 +276,35 @@ describe("Crypto", function() { bobClient = (new TestClient( "@bob:example.com", "bobdevice", )).client; + claraClient = (new TestClient( + "@clara:example.com", "claradevice", + )).client; await aliceClient.initCrypto(); await bobClient.initCrypto(); + await claraClient.initCrypto(); }); afterEach(async function() { aliceClient.stopClient(); bobClient.stopClient(); + claraClient.stopClient(); }); - it("does not cancel keyshare requests if some messages are not decrypted", async function() { + it("does not cancel keyshare requests until all messages are decrypted with trusted keys", async function() { const encryptionCfg = { "algorithm": "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); + // Make Bob invited by Alice so Bob will accept Alice's forwarded keys + bobRoom.currentState.setStateEvents([new MatrixEvent({ + type: "m.room.member", + sender: "@alice:example.com", + room_id: roomId, + content: { membership: "invite" }, + state_key: "@bob:example.com", + })]); aliceClient.store.storeRoom(aliceRoom); bobClient.store.storeRoom(bobRoom); await aliceClient.setRoomEncryption(roomId, encryptionCfg); @@ -301,6 +350,9 @@ describe("Crypto", function() { } })); + const device = new DeviceInfo(aliceClient.deviceId); + bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device; + const bobDecryptor = bobClient.crypto.getRoomDecryptor( roomId, olmlib.MEGOLM_ALGORITHM, ); @@ -313,6 +365,8 @@ describe("Crypto", function() { // the first message can't be decrypted yet, but the second one // can let ksEvent = await keyshareEventForEvent(aliceClient, events[1], 1); + bobClient.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + bobClient.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com"; await bobDecryptor.onRoomKeyEvent(ksEvent); await decryptEventsPromise; expect(events[0].getContent().msgtype).toBe("m.bad.encrypted"); @@ -339,8 +393,24 @@ describe("Crypto", function() { await bobDecryptor.onRoomKeyEvent(ksEvent); await decryptEventPromise; expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); + expect(events[0].isKeySourceUntrusted()).toBeTruthy(); await sleep(1); - // the room key request should be gone since we've now decrypted everything + // the room key request should still be there, since we've + // decrypted everything with an untrusted key + expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined(); + + // Now share a trusted room key event so Bob will re-decrypt the messages. + // Bob will backfill trust when they receive a trusted session with a higher + // index that connects to an untrusted session with a lower index. + const roomKeyEvent = roomKeyEventForEvent(aliceClient, events[1]); + const trustedDecryptEventPromise = awaitEvent(events[0], "Event.decrypted"); + await bobDecryptor.onRoomKeyEvent(roomKeyEvent); + await trustedDecryptEventPromise; + expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); + expect(events[0].isKeySourceUntrusted()).toBeFalsy(); + await sleep(1); + // now the room key request should be gone, since there's + // no better key to wait for expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeFalsy(); }); @@ -382,6 +452,9 @@ describe("Crypto", function() { // decryption keys yet } + const device = new DeviceInfo(aliceClient.deviceId); + bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device; + const bobDecryptor = bobClient.crypto.getRoomDecryptor( roomId, olmlib.MEGOLM_ALGORITHM, ); @@ -461,6 +534,420 @@ describe("Crypto", function() { expect(aliceSendToDevice).toBeCalledTimes(3); expect(aliceSendToDevice.mock.calls[2][2]).not.toBe(txnId); }); + + it("should accept forwarded keys which it requested", async function() { + const encryptionCfg = { + "algorithm": "m.megolm.v1.aes-sha2", + }; + const roomId = "!someroom"; + const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); + const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); + aliceClient.store.storeRoom(aliceRoom); + bobClient.store.storeRoom(bobRoom); + await aliceClient.setRoomEncryption(roomId, encryptionCfg); + await bobClient.setRoomEncryption(roomId, encryptionCfg); + const events = [ + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$1", + content: { + msgtype: "m.text", + body: "1", + }, + }), + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$2", + content: { + msgtype: "m.text", + body: "2", + }, + }), + ]; + await Promise.all(events.map(async (event) => { + // alice encrypts each event, and then bob tries to decrypt + // them without any keys, so that they'll be in pending + await aliceClient.crypto.encryptEvent(event, aliceRoom); + // remove keys from the event + // @ts-ignore private properties + event.clearEvent = undefined; + // @ts-ignore private properties + event.senderCurve25519Key = null; + // @ts-ignore private properties + event.claimedEd25519Key = null; + try { + await bobClient.crypto.decryptEvent(event); + } catch (e) { + // we expect this to fail because we don't have the + // decryption keys yet + } + })); + + const device = new DeviceInfo(aliceClient.deviceId); + bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device; + bobClient.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com"; + + const cryptoStore = bobClient.crypto.cryptoStore; + const eventContent = events[0].getWireContent(); + const senderKey = eventContent.sender_key; + const sessionId = eventContent.session_id; + const roomKeyRequestBody = { + algorithm: olmlib.MEGOLM_ALGORITHM, + room_id: roomId, + sender_key: senderKey, + session_id: sessionId, + }; + const outgoingReq = await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody); + expect(outgoingReq).toBeDefined(); + await cryptoStore.updateOutgoingRoomKeyRequest( + outgoingReq.requestId, RoomKeyRequestState.Unsent, + { state: RoomKeyRequestState.Sent }, + ); + + const bobDecryptor = bobClient.crypto.getRoomDecryptor( + roomId, olmlib.MEGOLM_ALGORITHM, + ); + + const decryptEventsPromise = Promise.all(events.map((ev) => { + return awaitEvent(ev, "Event.decrypted"); + })); + const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); + await bobDecryptor.onRoomKeyEvent(ksEvent); + const key = await bobClient.crypto.olmDevice.getInboundGroupSessionKey( + roomId, + events[0].getWireContent().sender_key, + events[0].getWireContent().session_id, + ); + expect(key).not.toBeNull(); + await decryptEventsPromise; + expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); + expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted"); + }); + + it("should accept forwarded keys from the user who invited it to the room", async function() { + const encryptionCfg = { + "algorithm": "m.megolm.v1.aes-sha2", + }; + const roomId = "!someroom"; + const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); + const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); + const claraRoom = new Room(roomId, claraClient, "@clara:example.com", {}); + // Make Bob invited by Clara + bobRoom.currentState.setStateEvents([new MatrixEvent({ + type: "m.room.member", + sender: "@clara:example.com", + room_id: roomId, + content: { membership: "invite" }, + state_key: "@bob:example.com", + })]); + aliceClient.store.storeRoom(aliceRoom); + bobClient.store.storeRoom(bobRoom); + claraClient.store.storeRoom(claraRoom); + await aliceClient.setRoomEncryption(roomId, encryptionCfg); + await bobClient.setRoomEncryption(roomId, encryptionCfg); + await claraClient.setRoomEncryption(roomId, encryptionCfg); + const events = [ + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$1", + content: { + msgtype: "m.text", + body: "1", + }, + }), + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$2", + content: { + msgtype: "m.text", + body: "2", + }, + }), + ]; + await Promise.all(events.map(async (event) => { + // alice encrypts each event, and then bob tries to decrypt + // them without any keys, so that they'll be in pending + await aliceClient.crypto.encryptEvent(event, aliceRoom); + // remove keys from the event + // @ts-ignore private properties + event.clearEvent = undefined; + // @ts-ignore private properties + event.senderCurve25519Key = null; + // @ts-ignore private properties + event.claimedEd25519Key = null; + try { + await bobClient.crypto.decryptEvent(event); + } catch (e) { + // we expect this to fail because we don't have the + // decryption keys yet + } + })); + + const device = new DeviceInfo(claraClient.deviceId); + bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device; + bobClient.crypto.deviceList.getUserByIdentityKey = () => "@clara:example.com"; + + const bobDecryptor = bobClient.crypto.getRoomDecryptor( + roomId, olmlib.MEGOLM_ALGORITHM, + ); + + const decryptEventsPromise = Promise.all(events.map((ev) => { + return awaitEvent(ev, "Event.decrypted"); + })); + const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); + ksEvent.event.sender = claraClient.getUserId(), + ksEvent.sender = new RoomMember(roomId, claraClient.getUserId()); + await bobDecryptor.onRoomKeyEvent(ksEvent); + const key = await bobClient.crypto.olmDevice.getInboundGroupSessionKey( + roomId, + events[0].getWireContent().sender_key, + events[0].getWireContent().session_id, + ); + expect(key).not.toBeNull(); + await decryptEventsPromise; + expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); + expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted"); + }); + + it("should accept forwarded keys from one of its own user's other devices", async function() { + const encryptionCfg = { + "algorithm": "m.megolm.v1.aes-sha2", + }; + const roomId = "!someroom"; + const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); + const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); + aliceClient.store.storeRoom(aliceRoom); + bobClient.store.storeRoom(bobRoom); + await aliceClient.setRoomEncryption(roomId, encryptionCfg); + await bobClient.setRoomEncryption(roomId, encryptionCfg); + const events = [ + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$1", + content: { + msgtype: "m.text", + body: "1", + }, + }), + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$2", + content: { + msgtype: "m.text", + body: "2", + }, + }), + ]; + await Promise.all(events.map(async (event) => { + // alice encrypts each event, and then bob tries to decrypt + // them without any keys, so that they'll be in pending + await aliceClient.crypto.encryptEvent(event, aliceRoom); + // remove keys from the event + // @ts-ignore private properties + event.clearEvent = undefined; + // @ts-ignore private properties + event.senderCurve25519Key = null; + // @ts-ignore private properties + event.claimedEd25519Key = null; + try { + await bobClient.crypto.decryptEvent(event); + } catch (e) { + // we expect this to fail because we don't have the + // decryption keys yet + } + })); + + const device = new DeviceInfo(claraClient.deviceId); + device.verified = DeviceInfo.DeviceVerification.VERIFIED; + bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device; + bobClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:example.com"; + + const bobDecryptor = bobClient.crypto.getRoomDecryptor( + roomId, olmlib.MEGOLM_ALGORITHM, + ); + + const decryptEventsPromise = Promise.all(events.map((ev) => { + return awaitEvent(ev, "Event.decrypted"); + })); + const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); + ksEvent.event.sender = bobClient.getUserId(), + ksEvent.sender = new RoomMember(roomId, bobClient.getUserId()); + await bobDecryptor.onRoomKeyEvent(ksEvent); + const key = await bobClient.crypto.olmDevice.getInboundGroupSessionKey( + roomId, + events[0].getWireContent().sender_key, + events[0].getWireContent().session_id, + ); + expect(key).not.toBeNull(); + await decryptEventsPromise; + expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); + expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted"); + }); + + it("should not accept unexpected forwarded keys for a room it's in", async function() { + const encryptionCfg = { + "algorithm": "m.megolm.v1.aes-sha2", + }; + const roomId = "!someroom"; + const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); + const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); + const claraRoom = new Room(roomId, claraClient, "@clara:example.com", {}); + aliceClient.store.storeRoom(aliceRoom); + bobClient.store.storeRoom(bobRoom); + claraClient.store.storeRoom(claraRoom); + await aliceClient.setRoomEncryption(roomId, encryptionCfg); + await bobClient.setRoomEncryption(roomId, encryptionCfg); + await claraClient.setRoomEncryption(roomId, encryptionCfg); + const events = [ + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$1", + content: { + msgtype: "m.text", + body: "1", + }, + }), + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$2", + content: { + msgtype: "m.text", + body: "2", + }, + }), + ]; + await Promise.all(events.map(async (event) => { + // alice encrypts each event, and then bob tries to decrypt + // them without any keys, so that they'll be in pending + await aliceClient.crypto.encryptEvent(event, aliceRoom); + // remove keys from the event + // @ts-ignore private properties + event.clearEvent = undefined; + // @ts-ignore private properties + event.senderCurve25519Key = null; + // @ts-ignore private properties + event.claimedEd25519Key = null; + try { + await bobClient.crypto.decryptEvent(event); + } catch (e) { + // we expect this to fail because we don't have the + // decryption keys yet + } + })); + + const device = new DeviceInfo(claraClient.deviceId); + bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device; + bobClient.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com"; + + const bobDecryptor = bobClient.crypto.getRoomDecryptor( + roomId, olmlib.MEGOLM_ALGORITHM, + ); + + const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); + ksEvent.event.sender = claraClient.getUserId(), + ksEvent.sender = new RoomMember(roomId, claraClient.getUserId()); + await bobDecryptor.onRoomKeyEvent(ksEvent); + const key = await bobClient.crypto.olmDevice.getInboundGroupSessionKey( + roomId, + events[0].getWireContent().sender_key, + events[0].getWireContent().session_id, + ); + expect(key).toBeNull(); + }); + + it("should park forwarded keys for a room it's not in", async function() { + const encryptionCfg = { + "algorithm": "m.megolm.v1.aes-sha2", + }; + const roomId = "!someroom"; + const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); + aliceClient.store.storeRoom(aliceRoom); + await aliceClient.setRoomEncryption(roomId, encryptionCfg); + const events = [ + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$1", + content: { + msgtype: "m.text", + body: "1", + }, + }), + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$2", + content: { + msgtype: "m.text", + body: "2", + }, + }), + ]; + await Promise.all(events.map(async (event) => { + // alice encrypts each event, and then bob tries to decrypt + // them without any keys, so that they'll be in pending + await aliceClient.crypto.encryptEvent(event, aliceRoom); + // remove keys from the event + // @ts-ignore private properties + event.clearEvent = undefined; + // @ts-ignore private properties + event.senderCurve25519Key = null; + // @ts-ignore private properties + event.claimedEd25519Key = null; + })); + + const device = new DeviceInfo(aliceClient.deviceId); + bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device; + bobClient.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com"; + + const bobDecryptor = bobClient.crypto.getRoomDecryptor( + roomId, olmlib.MEGOLM_ALGORITHM, + ); + + const content = events[0].getWireContent(); + + const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); + await bobDecryptor.onRoomKeyEvent(ksEvent); + const bobKey = await bobClient.crypto.olmDevice.getInboundGroupSessionKey( + roomId, + content.sender_key, + content.session_id, + ); + expect(bobKey).toBeNull(); + + const aliceKey = await aliceClient.crypto.olmDevice.getInboundGroupSessionKey( + roomId, + content.sender_key, + content.session_id, + ); + const parked = await bobClient.crypto.cryptoStore.takeParkedSharedHistory(roomId); + expect(parked).toEqual([{ + senderId: aliceClient.getUserId(), + senderKey: content.sender_key, + sessionId: content.session_id, + sessionKey: aliceKey.key, + keysClaimed: { ed25519: aliceKey.sender_claimed_ed25519_key }, + forwardingCurve25519KeyChain: ["akey"], + }]); + }); }); describe('Secret storage', function() { @@ -469,12 +956,12 @@ describe("Crypto", function() { jest.setTimeout(10000); const client = (new TestClient("@a:example.com", "dev")).client; await client.initCrypto(); - client.crypto.getSecretStorageKey = async () => null; + client.crypto.getSecretStorageKey = jest.fn().mockResolvedValue(null); client.crypto.isCrossSigningReady = async () => false; client.crypto.baseApis.uploadDeviceSigningKeys = jest.fn().mockResolvedValue(null); - client.crypto.baseApis.setAccountData = () => null; - client.crypto.baseApis.uploadKeySignatures = () => null; - client.crypto.baseApis.http.authedRequest = () => null; + client.crypto.baseApis.setAccountData = jest.fn().mockResolvedValue(null); + client.crypto.baseApis.uploadKeySignatures = jest.fn(); + client.crypto.baseApis.http.authedRequest = jest.fn(); const createSecretStorageKey = async () => { return { keyInfo: undefined, // Returning undefined here used to cause a crash diff --git a/spec/unit/crypto/algorithms/megolm.spec.ts b/spec/unit/crypto/algorithms/megolm.spec.ts index 9aa3c5c78..f1bb62284 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.ts +++ b/spec/unit/crypto/algorithms/megolm.spec.ts @@ -32,8 +32,8 @@ import { ClientEvent, MatrixClient, RoomMember } from '../../../../src'; import { DeviceInfo, IDevice } from '../../../../src/crypto/deviceinfo'; import { DeviceTrustLevel } from '../../../../src/crypto/CrossSigning'; -const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; -const MegolmEncryption = algorithms.ENCRYPTION_CLASSES['m.megolm.v1.aes-sha2']; +const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get('m.megolm.v1.aes-sha2'); +const MegolmEncryption = algorithms.ENCRYPTION_CLASSES.get('m.megolm.v1.aes-sha2'); const ROOM_ID = '!ROOM:ID'; @@ -110,6 +110,12 @@ describe("MegolmDecryption", function() { senderCurve25519Key: "SENDER_CURVE25519", claimedEd25519Key: "SENDER_ED25519", }; + event.getWireType = () => "m.room.encrypted"; + event.getWireContent = () => { + return { + algorithm: "m.olm.v1.curve25519-aes-sha2", + }; + }; const mockCrypto = { decryptEvent: function() { diff --git a/spec/unit/crypto/backup.spec.ts b/spec/unit/crypto/backup.spec.ts index 6759fe161..acec47fce 100644 --- a/spec/unit/crypto/backup.spec.ts +++ b/spec/unit/crypto/backup.spec.ts @@ -34,7 +34,7 @@ import { IAbortablePromise, MatrixScheduler } from '../../../src'; const Olm = global.Olm; -const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; +const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get('m.megolm.v1.aes-sha2'); const ROOM_ID = '!ROOM:ID'; @@ -214,6 +214,12 @@ describe("MegolmBackup", function() { const event = new MatrixEvent({ type: 'm.room.encrypted', }); + event.getWireType = () => "m.room.encrypted"; + event.getWireContent = () => { + return { + algorithm: "m.olm.v1.curve25519-aes-sha2", + }; + }; const decryptedData = { clearEvent: { type: 'm.room_key', diff --git a/spec/unit/crypto/outgoing-room-key-requests.spec.ts b/spec/unit/crypto/outgoing-room-key-requests.spec.ts index c572a63eb..1b1a4f57a 100644 --- a/spec/unit/crypto/outgoing-room-key-requests.spec.ts +++ b/spec/unit/crypto/outgoing-room-key-requests.spec.ts @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { - IndexedDBCryptoStore, -} from '../../../src/crypto/store/indexeddb-crypto-store'; +import { CryptoStore } from '../../../src/crypto/store/base'; +import { IndexedDBCryptoStore } from '../../../src/crypto/store/indexeddb-crypto-store'; +import { LocalStorageCryptoStore } from '../../../src/crypto/store/localStorage-crypto-store'; import { MemoryCryptoStore } from '../../../src/crypto/store/memory-crypto-store'; import { RoomKeyRequestState } from '../../../src/crypto/OutgoingRoomKeyRequestManager'; @@ -26,36 +26,39 @@ import 'jest-localstorage-mock'; const requests = [ { requestId: "A", - requestBody: { session_id: "A", room_id: "A" }, + requestBody: { session_id: "A", room_id: "A", sender_key: "A", algorithm: "m.megolm.v1.aes-sha2" }, state: RoomKeyRequestState.Sent, + recipients: [ + { userId: "@alice:example.com", deviceId: "*" }, + { userId: "@becca:example.com", deviceId: "foobarbaz" }, + ], }, { requestId: "B", - requestBody: { session_id: "B", room_id: "B" }, + requestBody: { session_id: "B", room_id: "B", sender_key: "B", algorithm: "m.megolm.v1.aes-sha2" }, state: RoomKeyRequestState.Sent, + recipients: [ + { userId: "@alice:example.com", deviceId: "*" }, + { userId: "@carrie:example.com", deviceId: "barbazquux" }, + ], }, { requestId: "C", - requestBody: { session_id: "C", room_id: "C" }, + requestBody: { session_id: "C", room_id: "C", sender_key: "B", algorithm: "m.megolm.v1.aes-sha2" }, state: RoomKeyRequestState.Unsent, + recipients: [ + { userId: "@becca:example.com", deviceId: "foobarbaz" }, + ], }, ]; describe.each([ ["IndexedDBCryptoStore", () => new IndexedDBCryptoStore(global.indexedDB, "tests")], - ["LocalStorageCryptoStore", - () => new IndexedDBCryptoStore(undefined, "tests")], - ["MemoryCryptoStore", () => { - const store = new IndexedDBCryptoStore(undefined, "tests"); - // @ts-ignore set private properties - store.backend = new MemoryCryptoStore(); - // @ts-ignore - store.backendPromise = Promise.resolve(store.backend); - return store; - }], + ["LocalStorageCryptoStore", () => new LocalStorageCryptoStore(localStorage)], + ["MemoryCryptoStore", () => new MemoryCryptoStore()], ])("Outgoing room key requests [%s]", function(name, dbFactory) { - let store; + let store: CryptoStore; beforeAll(async () => { store = dbFactory(); @@ -75,6 +78,15 @@ describe.each([ }); }); + it("getOutgoingRoomKeyRequestsByTarget retrieves all entries with a given target", + async () => { + const r = await store.getOutgoingRoomKeyRequestsByTarget( + "@becca:example.com", "foobarbaz", [RoomKeyRequestState.Sent], + ); + expect(r).toHaveLength(1); + expect(r[0]).toEqual(requests[0]); + }); + test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state", async () => { const r = diff --git a/spec/unit/crypto/secrets.spec.ts b/spec/unit/crypto/secrets.spec.ts index 7939cdae2..d6ae8c3a3 100644 --- a/spec/unit/crypto/secrets.spec.ts +++ b/spec/unit/crypto/secrets.spec.ts @@ -26,6 +26,7 @@ import { logger } from '../../../src/logger'; import * as utils from "../../../src/utils"; import { ICreateClientOpts } from '../../../src/client'; import { ISecretStorageKeyInfo } from '../../../src/crypto/api'; +import { DeviceInfo } from '../../../src/crypto/deviceinfo'; try { // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -250,20 +251,20 @@ describe("Secrets", function() { osborne2.client.crypto.deviceList.storeDevicesForUser("@alice:example.com", { "VAX": { - user_id: "@alice:example.com", - device_id: "VAX", + known: false, algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], keys: { "ed25519:VAX": vaxDevice.deviceEd25519Key, "curve25519:VAX": vaxDevice.deviceCurve25519Key, }, + verified: DeviceInfo.DeviceVerification.VERIFIED, }, }); vax.client.crypto.deviceList.storeDevicesForUser("@alice:example.com", { "Osborne2": { - user_id: "@alice:example.com", - device_id: "Osborne2", algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], + verified: 0, + known: false, keys: { "ed25519:Osborne2": osborne2Device.deviceEd25519Key, "curve25519:Osborne2": osborne2Device.deviceCurve25519Key, @@ -280,10 +281,12 @@ describe("Secrets", function() { Object.values(otks)[0], ); - const request = await secretStorage.request("foo", ["VAX"]); - const secret = await request.promise; + osborne2.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + osborne2.client.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com"; + + const request = await secretStorage.request("foo", ["VAX"]); + await request.promise; // return value not used - expect(secret).toBe("bar"); osborne2.stop(); vax.stop(); clearTestClientTimeouts(); diff --git a/spec/unit/crypto/verification/InRoomChannel.spec.js b/spec/unit/crypto/verification/InRoomChannel.spec.ts similarity index 97% rename from spec/unit/crypto/verification/InRoomChannel.spec.js rename to spec/unit/crypto/verification/InRoomChannel.spec.ts index 90fd05b47..c6f9db339 100644 --- a/spec/unit/crypto/verification/InRoomChannel.spec.js +++ b/spec/unit/crypto/verification/InRoomChannel.spec.ts @@ -13,9 +13,9 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixClient } from "../../../../src/client"; import { InRoomChannel } from "../../../../src/crypto/verification/request/InRoomChannel"; import { MatrixEvent } from "../../../../src/models/event"; - "../../../../src/crypto/verification/request/ToDeviceChannel"; describe("InRoomChannel tests", function() { const ALICE = "@alice:hs.tld"; @@ -23,7 +23,7 @@ describe("InRoomChannel tests", function() { const MALORY = "@malory:hs.tld"; const client = { getUserId() { return ALICE; }, - }; + } as unknown as MatrixClient; it("getEventType only returns .request for a message with a msgtype", function() { const invalidEvent = new MatrixEvent({ diff --git a/spec/unit/crypto/verification/qr_code.spec.js b/spec/unit/crypto/verification/qr_code.spec.ts similarity index 100% rename from spec/unit/crypto/verification/qr_code.spec.js rename to spec/unit/crypto/verification/qr_code.spec.ts diff --git a/spec/unit/crypto/verification/request.spec.js b/spec/unit/crypto/verification/request.spec.ts similarity index 82% rename from spec/unit/crypto/verification/request.spec.js rename to spec/unit/crypto/verification/request.spec.ts index e530344e2..10b29bf9f 100644 --- a/spec/unit/crypto/verification/request.spec.js +++ b/spec/unit/crypto/verification/request.spec.ts @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ import "../../../olm-loader"; -import { verificationMethods } from "../../../../src/crypto"; +import { CryptoEvent, verificationMethods } from "../../../../src/crypto"; import { logger } from "../../../../src/logger"; import { SAS } from "../../../../src/crypto/verification/SAS"; import { makeTestClients, setupWebcrypto, teardownWebcrypto } from './util'; @@ -52,23 +52,22 @@ describe("verification request integration tests with crypto layer", function() alice.client.crypto.deviceList.getRawStoredDevicesForUser = function() { return { Dynabook: { + algorithms: [], + verified: 0, + known: false, keys: { "ed25519:Dynabook": "bob+base64+ed25519+key", }, }, }; }; - alice.client.downloadKeys = () => { - return Promise.resolve(); - }; - bob.client.downloadKeys = () => { - return Promise.resolve(); - }; - bob.client.on("crypto.verification.request", (request) => { + alice.client.downloadKeys = jest.fn().mockResolvedValue({}); + bob.client.downloadKeys = jest.fn().mockResolvedValue({}); + bob.client.on(CryptoEvent.VerificationRequest, (request) => { const bobVerifier = request.beginKeyVerification(verificationMethods.SAS); bobVerifier.verify(); - // XXX: Private function access (but it's a test, so we're okay) + // @ts-ignore Private function access (but it's a test, so we're okay) bobVerifier.endTimer(); }); const aliceRequest = await alice.client.requestVerification("@bob:example.com"); @@ -76,7 +75,7 @@ describe("verification request integration tests with crypto layer", function() const aliceVerifier = aliceRequest.verifier; expect(aliceVerifier).toBeInstanceOf(SAS); - // XXX: Private function access (but it's a test, so we're okay) + // @ts-ignore Private function access (but it's a test, so we're okay) aliceVerifier.endTimer(); alice.stop(); diff --git a/spec/unit/crypto/verification/sas.spec.js b/spec/unit/crypto/verification/sas.spec.ts similarity index 91% rename from spec/unit/crypto/verification/sas.spec.js rename to spec/unit/crypto/verification/sas.spec.ts index 7344c0379..32932b7e5 100644 --- a/spec/unit/crypto/verification/sas.spec.js +++ b/spec/unit/crypto/verification/sas.spec.ts @@ -19,10 +19,14 @@ import { makeTestClients, setupWebcrypto, teardownWebcrypto } from './util'; import { MatrixEvent } from "../../../../src/models/event"; import { SAS } from "../../../../src/crypto/verification/SAS"; import { DeviceInfo } from "../../../../src/crypto/deviceinfo"; -import { verificationMethods } from "../../../../src/crypto"; +import { CryptoEvent, verificationMethods } from "../../../../src/crypto"; import * as olmlib from "../../../../src/crypto/olmlib"; import { logger } from "../../../../src/logger"; import { resetCrossSigningKeys } from "../crypto-utils"; +import { VerificationBase } from "../../../../src/crypto/verification/Base"; +import { IVerificationChannel } from "../../../../src/crypto/verification/request/Channel"; +import { MatrixClient } from "../../../../src"; +import { VerificationRequest } from "../../../../src/crypto/verification/request/VerificationRequest"; const Olm = global.Olm; @@ -48,13 +52,15 @@ describe("SAS verification", function() { //channel, baseApis, userId, deviceId, startEvent, request const request = { onVerifierCancelled: function() {}, - }; + } as VerificationRequest; const channel = { send: function() { return Promise.resolve(); }, - }; - const sas = new SAS(channel, {}, "@alice:example.com", "ABCDEFG", null, request); + } as unknown as IVerificationChannel; + const mockClient = {} as unknown as MatrixClient; + const event = new MatrixEvent({ type: 'test' }); + const sas = new SAS(channel, mockClient, "@alice:example.com", "ABCDEFG", event, request); sas.handleEvent(new MatrixEvent({ sender: "@alice:example.com", type: "es.inquisition", @@ -65,7 +71,7 @@ describe("SAS verification", function() { expect(spy).toHaveBeenCalled(); // Cancel the SAS for cleanup (we started a verification, so abort) - sas.cancel(); + sas.cancel(new Error('error')); }); describe("verification", () => { @@ -403,16 +409,12 @@ describe("SAS verification", function() { }, ); alice.client.setDeviceVerified = jest.fn(); - alice.client.downloadKeys = () => { - return Promise.resolve(); - }; + alice.client.downloadKeys = jest.fn().mockResolvedValue({}); bob.client.setDeviceVerified = jest.fn(); - bob.client.downloadKeys = () => { - return Promise.resolve(); - }; + bob.client.downloadKeys = jest.fn().mockResolvedValue({}); - const bobPromise = new Promise((resolve, reject) => { - bob.client.on("crypto.verification.request", request => { + const bobPromise = new Promise>((resolve, reject) => { + bob.client.on(CryptoEvent.VerificationRequest, request => { request.verifier.on("show_sas", (e) => { e.mismatch(); }); @@ -421,7 +423,7 @@ describe("SAS verification", function() { }); const aliceVerifier = alice.client.beginKeyVerification( - verificationMethods.SAS, bob.client.getUserId(), bob.client.deviceId, + verificationMethods.SAS, bob.client.getUserId()!, bob.client.deviceId!, ); const aliceSpy = jest.fn(); @@ -462,7 +464,7 @@ describe("SAS verification", function() { }, ); - alice.client.setDeviceVerified = jest.fn(); + alice.client.crypto.setDeviceVerification = jest.fn(); alice.client.getDeviceEd25519Key = () => { return "alice+base64+ed25519+key"; }; @@ -480,7 +482,7 @@ describe("SAS verification", function() { return Promise.resolve(); }; - bob.client.setDeviceVerified = jest.fn(); + bob.client.crypto.setDeviceVerification = jest.fn(); bob.client.getStoredDevice = () => { return DeviceInfo.fromStorage( { @@ -501,7 +503,7 @@ describe("SAS verification", function() { aliceSasEvent = null; bobSasEvent = null; - bobPromise = new Promise((resolve, reject) => { + bobPromise = new Promise((resolve, reject) => { bob.client.on("crypto.verification.request", async (request) => { const verifier = request.beginKeyVerification(SAS.NAME); verifier.on("show_sas", (e) => { @@ -563,10 +565,24 @@ describe("SAS verification", function() { ]); // make sure Alice and Bob verified each other - expect(alice.client.setDeviceVerified) - .toHaveBeenCalledWith(bob.client.getUserId(), bob.client.deviceId); - expect(bob.client.setDeviceVerified) - .toHaveBeenCalledWith(alice.client.getUserId(), alice.client.deviceId); + expect(alice.client.crypto.setDeviceVerification) + .toHaveBeenCalledWith( + bob.client.getUserId(), + bob.client.deviceId, + true, + null, + null, + { "ed25519:Dynabook": "bob+base64+ed25519+key" }, + ); + expect(bob.client.crypto.setDeviceVerification) + .toHaveBeenCalledWith( + alice.client.getUserId(), + alice.client.deviceId, + true, + null, + null, + { "ed25519:Osborne2": "alice+base64+ed25519+key" }, + ); }); }); }); diff --git a/spec/unit/crypto/verification/secret_request.spec.js b/spec/unit/crypto/verification/secret_request.spec.ts similarity index 77% rename from spec/unit/crypto/verification/secret_request.spec.js rename to spec/unit/crypto/verification/secret_request.spec.ts index 398edc10a..1c0a7410a 100644 --- a/spec/unit/crypto/verification/secret_request.spec.js +++ b/spec/unit/crypto/verification/secret_request.spec.ts @@ -18,6 +18,9 @@ import { CrossSigningInfo } from '../../../../src/crypto/CrossSigning'; import { encodeBase64 } from "../../../../src/crypto/olmlib"; import { setupWebcrypto, teardownWebcrypto } from './util'; import { VerificationBase } from '../../../../src/crypto/verification/Base'; +import { MatrixClient, MatrixEvent } from '../../../../src'; +import { VerificationRequest } from '../../../../src/crypto/verification/request/VerificationRequest'; +import { IVerificationChannel } from '../../../../src/crypto/verification/request/Channel'; jest.useFakeTimers(); @@ -54,9 +57,21 @@ describe("self-verifications", () => { cacheCallbacks, ); crossSigningInfo.keys = { - master: { keys: { X: testKeyPub } }, - self_signing: { keys: { X: testKeyPub } }, - user_signing: { keys: { X: testKeyPub } }, + master: { + keys: { X: testKeyPub }, + usage: [], + user_id: 'user-id', + }, + self_signing: { + keys: { X: testKeyPub }, + usage: [], + user_id: 'user-id', + }, + user_signing: { + keys: { X: testKeyPub }, + usage: [], + user_id: 'user-id', + }, }; const secretStorage = { @@ -79,20 +94,22 @@ describe("self-verifications", () => { getUserId: () => userId, getKeyBackupVersion: () => Promise.resolve({}), restoreKeyBackupWithCache, - }; + } as unknown as MatrixClient; const request = { onVerifierFinished: () => undefined, - }; + } as unknown as VerificationRequest; const verification = new VerificationBase( - undefined, // channel + undefined as unknown as IVerificationChannel, // channel client, // baseApis userId, "ABC", // deviceId - undefined, // startEvent + undefined as unknown as MatrixEvent, // startEvent request, ); + + // @ts-ignore set private property verification.resolve = () => undefined; const result = await verification.done(); @@ -102,12 +119,12 @@ describe("self-verifications", () => { expect(secretStorage.request.mock.calls.length).toBe(4); expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[0][1]) - .toEqual(testKey); + .toEqual(testKey); expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[1][1]) - .toEqual(testKey); + .toEqual(testKey); expect(storeSessionBackupPrivateKey.mock.calls[0][0]) - .toEqual(testKey); + .toEqual(testKey); expect(restoreKeyBackupWithCache).toHaveBeenCalled(); diff --git a/spec/unit/crypto/verification/util.js b/spec/unit/crypto/verification/util.ts similarity index 66% rename from spec/unit/crypto/verification/util.js rename to spec/unit/crypto/verification/util.ts index 572a4b270..d7c519e74 100644 --- a/spec/unit/crypto/verification/util.js +++ b/spec/unit/crypto/verification/util.ts @@ -19,20 +19,23 @@ import nodeCrypto from "crypto"; import { TestClient } from '../../../TestClient'; import { MatrixEvent } from "../../../../src/models/event"; +import { IRoomTimelineData } from "../../../../src/models/event-timeline-set"; +import { Room, RoomEvent } from "../../../../src/models/room"; import { logger } from '../../../../src/logger'; +import { MatrixClient, ClientEvent } from '../../../../src/client'; -export async function makeTestClients(userInfos, options) { - const clients = []; - const timeouts = []; - const clientMap = {}; - const sendToDevice = function(type, map) { +export async function makeTestClients(userInfos, options): Promise<[TestClient[], () => void]> { + const clients: TestClient[] = []; + const timeouts: ReturnType[] = []; + const clientMap: Record> = {}; + const makeSendToDevice = (matrixClient: MatrixClient): MatrixClient['sendToDevice'] => async (type, map) => { // logger.log(this.getUserId(), "sends", type, map); for (const [userId, devMap] of Object.entries(map)) { if (userId in clientMap) { for (const [deviceId, msg] of Object.entries(devMap)) { if (deviceId in clientMap[userId]) { const event = new MatrixEvent({ - sender: this.getUserId(), // eslint-disable-line @babel/no-invalid-this + sender: matrixClient.getUserId()!, type: type, content: msg, }); @@ -42,18 +45,19 @@ export async function makeTestClients(userInfos, options) { Promise.resolve(); decryptionPromise.then( - () => client.emit("toDeviceEvent", event), + () => client.emit(ClientEvent.ToDeviceEvent, event), ); } } } } + return {}; }; - const sendEvent = function(room, type, content) { + const makeSendEvent = (matrixClient: MatrixClient) => (room, type, content) => { // make up a unique ID as the event ID - const eventId = "$" + this.makeTxnId(); // eslint-disable-line @babel/no-invalid-this + const eventId = "$" + matrixClient.makeTxnId(); const rawEvent = { - sender: this.getUserId(), // eslint-disable-line @babel/no-invalid-this + sender: matrixClient.getUserId()!, type: type, content: content, room_id: room, @@ -63,22 +67,24 @@ export async function makeTestClients(userInfos, options) { const event = new MatrixEvent(rawEvent); const remoteEcho = new MatrixEvent(Object.assign({}, rawEvent, { unsigned: { - transaction_id: this.makeTxnId(), // eslint-disable-line @babel/no-invalid-this + transaction_id: matrixClient.makeTxnId(), }, })); const timeout = setTimeout(() => { for (const tc of clients) { - if (tc.client === this) { // eslint-disable-line @babel/no-invalid-this + const room = new Room('test', tc.client, tc.client.getUserId()!); + const roomTimelineData = {} as unknown as IRoomTimelineData; + if (tc.client === matrixClient) { logger.log("sending remote echo!!"); - tc.client.emit("Room.timeline", remoteEcho); + tc.client.emit(RoomEvent.Timeline, remoteEcho, room, false, false, roomTimelineData); } else { - tc.client.emit("Room.timeline", event); + tc.client.emit(RoomEvent.Timeline, event, room, false, false, roomTimelineData); } } }); - timeouts.push(timeout); + timeouts.push(timeout as unknown as ReturnType); return Promise.resolve({ event_id: eventId }); }; @@ -99,8 +105,8 @@ export async function makeTestClients(userInfos, options) { clientMap[userInfo.userId] = {}; } clientMap[userInfo.userId][userInfo.deviceId] = testClient.client; - testClient.client.sendToDevice = sendToDevice; - testClient.client.sendEvent = sendEvent; + testClient.client.sendToDevice = makeSendToDevice(testClient.client); + testClient.client.sendEvent = makeSendEvent(testClient.client); clients.push(testClient); } @@ -116,11 +122,12 @@ export async function makeTestClients(userInfos, options) { export function setupWebcrypto() { global.crypto = { getRandomValues: (buf) => { - return nodeCrypto.randomFillSync(buf); + return nodeCrypto.randomFillSync(buf as any); }, - }; + } as unknown as Crypto; } export function teardownWebcrypto() { + // @ts-ignore undefined != Crypto global.crypto = undefined; } diff --git a/spec/unit/crypto/verification/verification_request.spec.js b/spec/unit/crypto/verification/verification_request.spec.ts similarity index 74% rename from spec/unit/crypto/verification/verification_request.spec.js rename to spec/unit/crypto/verification/verification_request.spec.ts index f8de29cee..0b759efb9 100644 --- a/spec/unit/crypto/verification/verification_request.spec.js +++ b/spec/unit/crypto/verification/verification_request.spec.ts @@ -19,11 +19,18 @@ import { InRoomChannel } from "../../../../src/crypto/verification/request/InRoo import { ToDeviceChannel } from "../../../../src/crypto/verification/request/ToDeviceChannel"; import { MatrixEvent } from "../../../../src/models/event"; +import { MatrixClient } from "../../../../src/client"; import { setupWebcrypto, teardownWebcrypto } from "./util"; +import { IVerificationChannel } from "../../../../src/crypto/verification/request/Channel"; +import { VerificationBase } from "../../../../src/crypto/verification/Base"; -function makeMockClient(userId, deviceId) { +type MockClient = MatrixClient & { + popEvents: () => MatrixEvent[]; + popDeviceEvents: (userId: string, deviceId: string) => MatrixEvent[]; +}; +function makeMockClient(userId: string, deviceId: string): MockClient { let counter = 1; - let events = []; + let events: MatrixEvent[] = []; const deviceEvents = {}; return { getUserId() { return userId; }, @@ -54,16 +61,18 @@ function makeMockClient(userId, deviceId) { deviceEvents[userId][deviceId].push(event); } } - return Promise.resolve(); + return Promise.resolve({}); }, - popEvents() { + // @ts-ignore special testing fn + popEvents(): MatrixEvent[] { const e = events; events = []; return e; }, - popDeviceEvents(userId, deviceId) { + // @ts-ignore special testing fn + popDeviceEvents(userId: string, deviceId: string): MatrixEvent[] { const forDevice = deviceEvents[userId]; const events = forDevice && forDevice[deviceId]; const result = events || []; @@ -72,12 +81,21 @@ function makeMockClient(userId, deviceId) { } return result; }, - }; + } as unknown as MockClient; } const MOCK_METHOD = "mock-verify"; -class MockVerifier { - constructor(channel, client, userId, deviceId, startEvent) { +class MockVerifier extends VerificationBase<'', any> { + public _channel; + public _startEvent; + constructor( + channel: IVerificationChannel, + client: MatrixClient, + userId: string, + deviceId: string, + startEvent: MatrixEvent, + ) { + super(channel, client, userId, deviceId, startEvent, {} as unknown as VerificationRequest); this._channel = channel; this._startEvent = startEvent; } @@ -115,7 +133,10 @@ function makeRemoteEcho(event) { async function distributeEvent(ownRequest, theirRequest, event) { await ownRequest.channel.handleEvent( - makeRemoteEcho(event), ownRequest, true); + makeRemoteEcho(event), + ownRequest, + true, + ); await theirRequest.channel.handleEvent(event, theirRequest, true); } @@ -133,12 +154,19 @@ describe("verification request unit tests", function() { it("transition from UNSENT to DONE through happy path", async function() { const alice = makeMockClient("@alice:matrix.tld", "device1"); const bob = makeMockClient("@bob:matrix.tld", "device1"); + const verificationMethods = new Map( + [[MOCK_METHOD, MockVerifier]], + ) as unknown as Map; const aliceRequest = new VerificationRequest( - new InRoomChannel(alice, "!room", bob.getUserId()), - new Map([[MOCK_METHOD, MockVerifier]]), alice); + new InRoomChannel(alice, "!room", bob.getUserId()!), + verificationMethods, + alice, + ); const bobRequest = new VerificationRequest( new InRoomChannel(bob, "!room"), - new Map([[MOCK_METHOD, MockVerifier]]), bob); + verificationMethods, + bob, + ); expect(aliceRequest.invalid).toBe(true); expect(bobRequest.invalid).toBe(true); @@ -157,7 +185,7 @@ describe("verification request unit tests", function() { expect(aliceRequest.ready).toBe(true); const verifier = aliceRequest.beginKeyVerification(MOCK_METHOD); - await verifier.start(); + await (verifier as MockVerifier).start(); const [startEvent] = alice.popEvents(); expect(startEvent.getType()).toBe(START_TYPE); await distributeEvent(aliceRequest, bobRequest, startEvent); @@ -165,8 +193,7 @@ describe("verification request unit tests", function() { expect(aliceRequest.verifier).toBeInstanceOf(MockVerifier); expect(bobRequest.started).toBe(true); expect(bobRequest.verifier).toBeInstanceOf(MockVerifier); - - await bobRequest.verifier.start(); + await (bobRequest.verifier as MockVerifier).start(); const [bobDoneEvent] = bob.popEvents(); expect(bobDoneEvent.getType()).toBe(DONE_TYPE); await distributeEvent(bobRequest, aliceRequest, bobDoneEvent); @@ -180,12 +207,20 @@ describe("verification request unit tests", function() { it("methods only contains common methods", async function() { const alice = makeMockClient("@alice:matrix.tld", "device1"); const bob = makeMockClient("@bob:matrix.tld", "device1"); + const aliceVerificationMethods = new Map( + [["c", function() {}], ["a", function() {}]], + ) as unknown as Map; + const bobVerificationMethods = new Map( + [["c", function() {}], ["b", function() {}]], + ) as unknown as Map; const aliceRequest = new VerificationRequest( - new InRoomChannel(alice, "!room", bob.getUserId()), - new Map([["c", function() {}], ["a", function() {}]]), alice); + new InRoomChannel(alice, "!room", bob.getUserId()!), + aliceVerificationMethods, alice); const bobRequest = new VerificationRequest( new InRoomChannel(bob, "!room"), - new Map([["c", function() {}], ["b", function() {}]]), bob); + bobVerificationMethods, + bob, + ); await aliceRequest.sendRequest(); const [requestEvent] = alice.popEvents(); await distributeEvent(aliceRequest, bobRequest, requestEvent); @@ -201,13 +236,22 @@ describe("verification request unit tests", function() { const bob1 = makeMockClient("@bob:matrix.tld", "device1"); const bob2 = makeMockClient("@bob:matrix.tld", "device2"); const aliceRequest = new VerificationRequest( - new InRoomChannel(alice, "!room", bob1.getUserId()), new Map(), alice); + new InRoomChannel(alice, "!room", bob1.getUserId()!), + new Map(), + alice, + ); await aliceRequest.sendRequest(); const [requestEvent] = alice.popEvents(); const bob1Request = new VerificationRequest( - new InRoomChannel(bob1, "!room"), new Map(), bob1); + new InRoomChannel(bob1, "!room"), + new Map(), + bob1, + ); const bob2Request = new VerificationRequest( - new InRoomChannel(bob2, "!room"), new Map(), bob2); + new InRoomChannel(bob2, "!room"), + new Map(), + bob2, + ); await bob1Request.channel.handleEvent(requestEvent, bob1Request, true); await bob2Request.channel.handleEvent(requestEvent, bob2Request, true); @@ -222,22 +266,34 @@ describe("verification request unit tests", function() { it("verify own device with to_device messages", async function() { const bob1 = makeMockClient("@bob:matrix.tld", "device1"); const bob2 = makeMockClient("@bob:matrix.tld", "device2"); + const verificationMethods = new Map( + [[MOCK_METHOD, MockVerifier]], + ) as unknown as Map; const bob1Request = new VerificationRequest( - new ToDeviceChannel(bob1, bob1.getUserId(), ["device1", "device2"], - ToDeviceChannel.makeTransactionId(), "device2"), - new Map([[MOCK_METHOD, MockVerifier]]), bob1); + new ToDeviceChannel( + bob1, + bob1.getUserId()!, + ["device1", "device2"], + ToDeviceChannel.makeTransactionId(), + "device2", + ), + verificationMethods, + bob1, + ); const to = { userId: "@bob:matrix.tld", deviceId: "device2" }; const verifier = bob1Request.beginKeyVerification(MOCK_METHOD, to); expect(verifier).toBeInstanceOf(MockVerifier); - await verifier.start(); + await (verifier as MockVerifier).start(); const [startEvent] = bob1.popDeviceEvents(to.userId, to.deviceId); expect(startEvent.getType()).toBe(START_TYPE); const bob2Request = new VerificationRequest( - new ToDeviceChannel(bob2, bob2.getUserId(), ["device1"]), - new Map([[MOCK_METHOD, MockVerifier]]), bob2); + new ToDeviceChannel(bob2, bob2.getUserId()!, ["device1"]), + verificationMethods, + bob2, + ); await bob2Request.channel.handleEvent(startEvent, bob2Request, true); - await bob2Request.verifier.start(); + await (bob2Request.verifier as MockVerifier).start(); const [doneEvent1] = bob2.popDeviceEvents("@bob:matrix.tld", "device1"); expect(doneEvent1.getType()).toBe(DONE_TYPE); await bob1Request.channel.handleEvent(doneEvent1, bob1Request, true); @@ -253,11 +309,13 @@ describe("verification request unit tests", function() { const alice = makeMockClient("@alice:matrix.tld", "device1"); const bob = makeMockClient("@bob:matrix.tld", "device1"); const aliceRequest = new VerificationRequest( - new InRoomChannel(alice, "!room", bob.getUserId()), new Map(), alice); + new InRoomChannel(alice, "!room", bob.getUserId()!), + new Map(), + alice, + ); await aliceRequest.sendRequest(); const [requestEvent] = alice.popEvents(); - await aliceRequest.channel.handleEvent(requestEvent, aliceRequest, true, - true, true); + await aliceRequest.channel.handleEvent(requestEvent, aliceRequest, true); expect(aliceRequest.cancelled).toBe(false); expect(aliceRequest._cancellingUserId).toBe(undefined); @@ -269,11 +327,17 @@ describe("verification request unit tests", function() { const alice = makeMockClient("@alice:matrix.tld", "device1"); const bob = makeMockClient("@bob:matrix.tld", "device1"); const aliceRequest = new VerificationRequest( - new InRoomChannel(alice, "!room", bob.getUserId()), new Map(), alice); + new InRoomChannel(alice, "!room", bob.getUserId()!), + new Map(), + alice, + ); await aliceRequest.sendRequest(); const [requestEvent] = alice.popEvents(); const bobRequest = new VerificationRequest( - new InRoomChannel(bob, "!room"), new Map(), bob); + new InRoomChannel(bob, "!room"), + new Map(), + bob, + ); await bobRequest.channel.handleEvent(requestEvent, bobRequest, true); diff --git a/spec/unit/event-timeline-set.spec.ts b/spec/unit/event-timeline-set.spec.ts index 42f4bca4d..e6c45fbd4 100644 --- a/spec/unit/event-timeline-set.spec.ts +++ b/spec/unit/event-timeline-set.spec.ts @@ -16,14 +16,15 @@ limitations under the License. import * as utils from "../test-utils/test-utils"; import { + DuplicateStrategy, EventTimeline, EventTimelineSet, EventType, + Filter, MatrixClient, MatrixEvent, MatrixEventEvent, Room, - DuplicateStrategy, } from '../../src'; import { Thread } from "../../src/models/thread"; import { ReEmitter } from "../../src/ReEmitter"; @@ -291,4 +292,34 @@ describe('EventTimelineSet', () => { expect(eventTimelineSet.canContain(event)).toBeTruthy(); }); }); + + describe("handleRemoteEcho", () => { + it("should add to liveTimeline only if the event matches the filter", () => { + const filter = new Filter(client.getUserId()!, "test_filter"); + filter.setDefinition({ + room: { + timeline: { + types: [EventType.RoomMessage], + }, + }, + }); + const eventTimelineSet = new EventTimelineSet(room, { filter }, client); + + const roomMessageEvent = new MatrixEvent({ + type: EventType.RoomMessage, + content: { body: "test" }, + event_id: "!test1:server", + }); + eventTimelineSet.handleRemoteEcho(roomMessageEvent, "~!local-event-id:server", roomMessageEvent.getId()); + expect(eventTimelineSet.getLiveTimeline().getEvents()).toContain(roomMessageEvent); + + const roomFilteredEvent = new MatrixEvent({ + type: "other_event_type", + content: { body: "test" }, + event_id: "!test2:server", + }); + eventTimelineSet.handleRemoteEcho(roomFilteredEvent, "~!local-event-id:server", roomFilteredEvent.getId()); + expect(eventTimelineSet.getLiveTimeline().getEvents()).not.toContain(roomFilteredEvent); + }); + }); }); diff --git a/spec/unit/feature.spec.ts b/spec/unit/feature.spec.ts new file mode 100644 index 000000000..97420947d --- /dev/null +++ b/spec/unit/feature.spec.ts @@ -0,0 +1,62 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { buildFeatureSupportMap, Feature, ServerSupport } from "../../src/feature"; + +describe("Feature detection", () => { + it("checks the matrix version", async () => { + const support = await buildFeatureSupportMap({ + versions: ["v1.3"], + unstable_features: {}, + }); + + expect(support.get(Feature.Thread)).toBe(ServerSupport.Stable); + expect(support.get(Feature.ThreadUnreadNotifications)).toBe(ServerSupport.Unsupported); + }); + + it("checks the matrix msc number", async () => { + const support = await buildFeatureSupportMap({ + versions: ["v1.2"], + unstable_features: { + "org.matrix.msc3771": true, + "org.matrix.msc3773": true, + }, + }); + expect(support.get(Feature.ThreadUnreadNotifications)).toBe(ServerSupport.Unstable); + }); + + it("requires two MSCs to pass", async () => { + const support = await buildFeatureSupportMap({ + versions: ["v1.2"], + unstable_features: { + "org.matrix.msc3771": false, + "org.matrix.msc3773": true, + }, + }); + expect(support.get(Feature.ThreadUnreadNotifications)).toBe(ServerSupport.Unsupported); + }); + + it("requires two MSCs OR matrix versions to pass", async () => { + const support = await buildFeatureSupportMap({ + versions: ["v1.4"], + unstable_features: { + "org.matrix.msc3771": false, + "org.matrix.msc3773": true, + }, + }); + expect(support.get(Feature.ThreadUnreadNotifications)).toBe(ServerSupport.Stable); + }); +}); diff --git a/spec/unit/filter.spec.ts b/spec/unit/filter.spec.ts index faa0f53ca..e246ec1a2 100644 --- a/spec/unit/filter.spec.ts +++ b/spec/unit/filter.spec.ts @@ -1,3 +1,4 @@ +import { UNREAD_THREAD_NOTIFICATIONS } from "../../src/@types/sync"; import { Filter, IFilterDefinition } from "../../src/filter"; describe("Filter", function() { @@ -43,4 +44,17 @@ describe("Filter", function() { expect(filter.getDefinition()).toEqual(definition); }); }); + + describe("setUnreadThreadNotifications", function() { + it("setUnreadThreadNotifications", function() { + filter.setUnreadThreadNotifications(true); + expect(filter.getDefinition()).toEqual({ + room: { + timeline: { + [UNREAD_THREAD_NOTIFICATIONS.name]: true, + }, + }, + }); + }); + }); }); diff --git a/spec/unit/local_notifications.spec.ts b/spec/unit/local_notifications.spec.ts new file mode 100644 index 000000000..4f6f0c32a --- /dev/null +++ b/spec/unit/local_notifications.spec.ts @@ -0,0 +1,43 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { LocalNotificationSettings } from "../../src/@types/local_notifications"; +import { LOCAL_NOTIFICATION_SETTINGS_PREFIX, MatrixClient } from "../../src/matrix"; +import { TestClient } from '../TestClient'; + +let client: MatrixClient; + +describe("Local notification settings", () => { + beforeEach(() => { + client = (new TestClient( + "@alice:matrix.org", "123", undefined, undefined, undefined, + )).client; + client.setAccountData = jest.fn(); + }); + + describe("Lets you set local notification settings", () => { + it("stores settings in account data", () => { + const deviceId = "device"; + const settings: LocalNotificationSettings = { is_silenced: true }; + client.setLocalNotificationSettings(deviceId, settings); + + expect(client.setAccountData).toHaveBeenCalledWith( + `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`, + settings, + ); + }); + }); +}); diff --git a/spec/unit/login.spec.ts b/spec/unit/login.spec.ts index f7cf6d307..6b3ae47fe 100644 --- a/spec/unit/login.spec.ts +++ b/spec/unit/login.spec.ts @@ -1,3 +1,4 @@ +import { SSOAction } from '../../src/@types/auth'; import { TestClient } from '../TestClient'; describe('Login request', function() { @@ -22,3 +23,37 @@ describe('Login request', function() { expect(client.client.getUserId()).toBe(response.user_id); }); }); + +describe('SSO login URL', function() { + let client: TestClient; + + beforeEach(function() { + client = new TestClient(); + }); + + afterEach(function() { + client.stop(); + }); + + describe('SSOAction', function() { + const redirectUri = "https://test.com/foo"; + + it('No action', function() { + const urlString = client.client.getSsoLoginUrl(redirectUri, undefined, undefined, undefined); + const url = new URL(urlString); + expect(url.searchParams.has('org.matrix.msc3824.action')).toBe(false); + }); + + it('register', function() { + const urlString = client.client.getSsoLoginUrl(redirectUri, undefined, undefined, SSOAction.REGISTER); + const url = new URL(urlString); + expect(url.searchParams.get('org.matrix.msc3824.action')).toEqual('register'); + }); + + it('login', function() { + const urlString = client.client.getSsoLoginUrl(redirectUri, undefined, undefined, SSOAction.LOGIN); + const url = new URL(urlString); + expect(url.searchParams.get('org.matrix.msc3824.action')).toEqual('login'); + }); + }); +}); diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 702c22c05..2b8faf506 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -36,9 +36,14 @@ import { ReceiptType } from "../../src/@types/read_receipts"; import * as testUtils from "../test-utils/test-utils"; import { makeBeaconInfoContent } from "../../src/content-helpers"; import { M_BEACON_INFO } from "../../src/@types/beacon"; -import { ContentHelpers, Room } from "../../src"; +import { ContentHelpers, EventTimeline, Room } from "../../src"; import { supportsMatrixCall } from "../../src/webrtc/call"; import { makeBeaconEvent } from "../test-utils/beacon"; +import { + IGNORE_INVITES_ACCOUNT_EVENT_KEY, + POLICIES_ACCOUNT_EVENT_TYPE, + PolicyScope, +} from "../../src/models/invites-ignorer"; jest.useFakeTimers(); @@ -427,7 +432,7 @@ describe("MatrixClient", function() { } }); }); - await client.startClient(); + await client.startClient({ filter }); await syncPromise; }); @@ -1412,4 +1417,301 @@ describe("MatrixClient", function() { expect(client.crypto.encryptAndSendToDevices).toHaveBeenLastCalledWith(deviceInfos, payload); }); }); + + describe("support for ignoring invites", () => { + beforeEach(() => { + // Mockup `getAccountData`/`setAccountData`. + const dataStore = new Map(); + client.setAccountData = function(eventType, content) { + dataStore.set(eventType, content); + return Promise.resolve(); + }; + client.getAccountData = function(eventType) { + const data = dataStore.get(eventType); + return new MatrixEvent({ + content: data, + }); + }; + + // Mockup `createRoom`/`getRoom`/`joinRoom`, including state. + const rooms = new Map(); + client.createRoom = function(options = {}) { + const roomId = options["_roomId"] || `!room-${rooms.size}:example.org`; + const state = new Map(); + const room = { + roomId, + _options: options, + _state: state, + getUnfilteredTimelineSet: function() { + return { + getLiveTimeline: function() { + return { + getState: function(direction) { + expect(direction).toBe(EventTimeline.FORWARDS); + return { + getStateEvents: function(type) { + const store = state.get(type) || {}; + return Object.keys(store).map(key => store[key]); + }, + }; + }, + }; + }, + }; + }, + }; + rooms.set(roomId, room); + return Promise.resolve({ room_id: roomId }); + }; + client.getRoom = function(roomId) { + return rooms.get(roomId); + }; + client.joinRoom = function(roomId) { + return this.getRoom(roomId) || this.createRoom({ _roomId: roomId }); + }; + + // Mockup state events + client.sendStateEvent = function(roomId, type, content) { + const room = this.getRoom(roomId); + const state: Map = room._state; + let store = state.get(type); + if (!store) { + store = {}; + state.set(type, store); + } + const eventId = `$event-${Math.random()}:example.org`; + store[eventId] = { + getId: function() { + return eventId; + }, + getRoomId: function() { + return roomId; + }, + getContent: function() { + return content; + }, + }; + return { event_id: eventId }; + }; + client.redactEvent = function(roomId, eventId) { + const room = this.getRoom(roomId); + const state: Map = room._state; + for (const store of state.values()) { + delete store[eventId]; + } + }; + }); + + it("should initialize and return the same `target` consistently", async () => { + const target1 = await client.ignoredInvites.getOrCreateTargetRoom(); + const target2 = await client.ignoredInvites.getOrCreateTargetRoom(); + expect(target1).toBeTruthy(); + expect(target1).toBe(target2); + }); + + it("should initialize and return the same `sources` consistently", async () => { + const sources1 = await client.ignoredInvites.getOrCreateSourceRooms(); + const sources2 = await client.ignoredInvites.getOrCreateSourceRooms(); + expect(sources1).toBeTruthy(); + expect(sources1).toHaveLength(1); + expect(sources1).toEqual(sources2); + }); + + it("should initially not reject any invite", async () => { + const rule = await client.ignoredInvites.getRuleForInvite({ + sender: "@foobar:example.org", + roomId: "!snafu:somewhere.org", + }); + expect(rule).toBeFalsy(); + }); + + it("should reject invites once we have added a matching rule in the target room (scope: user)", async () => { + await client.ignoredInvites.addRule(PolicyScope.User, "*:example.org", "just a test"); + + // We should reject this invite. + const ruleMatch = await client.ignoredInvites.getRuleForInvite({ + sender: "@foobar:example.org", + roomId: "!snafu:somewhere.org", + }); + expect(ruleMatch).toBeTruthy(); + expect(ruleMatch.getContent()).toMatchObject({ + recommendation: "m.ban", + reason: "just a test", + }); + + // We should let these invites go through. + const ruleWrongServer = await client.ignoredInvites.getRuleForInvite({ + sender: "@foobar:somewhere.org", + roomId: "!snafu:somewhere.org", + }); + expect(ruleWrongServer).toBeFalsy(); + + const ruleWrongServerRoom = await client.ignoredInvites.getRuleForInvite({ + sender: "@foobar:somewhere.org", + roomId: "!snafu:example.org", + }); + expect(ruleWrongServerRoom).toBeFalsy(); + }); + + it("should reject invites once we have added a matching rule in the target room (scope: server)", async () => { + const REASON = `Just a test ${Math.random()}`; + await client.ignoredInvites.addRule(PolicyScope.Server, "example.org", REASON); + + // We should reject these invites. + const ruleSenderMatch = await client.ignoredInvites.getRuleForInvite({ + sender: "@foobar:example.org", + roomId: "!snafu:somewhere.org", + }); + expect(ruleSenderMatch).toBeTruthy(); + expect(ruleSenderMatch.getContent()).toMatchObject({ + recommendation: "m.ban", + reason: REASON, + }); + + const ruleRoomMatch = await client.ignoredInvites.getRuleForInvite({ + sender: "@foobar:somewhere.org", + roomId: "!snafu:example.org", + }); + expect(ruleRoomMatch).toBeTruthy(); + expect(ruleRoomMatch.getContent()).toMatchObject({ + recommendation: "m.ban", + reason: REASON, + }); + + // We should let these invites go through. + const ruleWrongServer = await client.ignoredInvites.getRuleForInvite({ + sender: "@foobar:somewhere.org", + roomId: "!snafu:somewhere.org", + }); + expect(ruleWrongServer).toBeFalsy(); + }); + + it("should reject invites once we have added a matching rule in the target room (scope: room)", async () => { + const REASON = `Just a test ${Math.random()}`; + const BAD_ROOM_ID = "!bad:example.org"; + const GOOD_ROOM_ID = "!good:example.org"; + await client.ignoredInvites.addRule(PolicyScope.Room, BAD_ROOM_ID, REASON); + + // We should reject this invite. + const ruleSenderMatch = await client.ignoredInvites.getRuleForInvite({ + sender: "@foobar:example.org", + roomId: BAD_ROOM_ID, + }); + expect(ruleSenderMatch).toBeTruthy(); + expect(ruleSenderMatch.getContent()).toMatchObject({ + recommendation: "m.ban", + reason: REASON, + }); + + // We should let these invites go through. + const ruleWrongRoom = await client.ignoredInvites.getRuleForInvite({ + sender: BAD_ROOM_ID, + roomId: GOOD_ROOM_ID, + }); + expect(ruleWrongRoom).toBeFalsy(); + }); + + it("should reject invites once we have added a matching rule in a non-target source room", async () => { + const NEW_SOURCE_ROOM_ID = "!another-source:example.org"; + + // Make sure that everything is initialized. + await client.ignoredInvites.getOrCreateSourceRooms(); + await client.joinRoom(NEW_SOURCE_ROOM_ID); + await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID); + + // Add a rule in the new source room. + await client.sendStateEvent(NEW_SOURCE_ROOM_ID, PolicyScope.User, { + entity: "*:example.org", + reason: "just a test", + recommendation: "m.ban", + }); + + // We should reject this invite. + const ruleMatch = await client.ignoredInvites.getRuleForInvite({ + sender: "@foobar:example.org", + roomId: "!snafu:somewhere.org", + }); + expect(ruleMatch).toBeTruthy(); + expect(ruleMatch.getContent()).toMatchObject({ + recommendation: "m.ban", + reason: "just a test", + }); + + // We should let these invites go through. + const ruleWrongServer = await client.ignoredInvites.getRuleForInvite({ + sender: "@foobar:somewhere.org", + roomId: "!snafu:somewhere.org", + }); + expect(ruleWrongServer).toBeFalsy(); + + const ruleWrongServerRoom = await client.ignoredInvites.getRuleForInvite({ + sender: "@foobar:somewhere.org", + roomId: "!snafu:example.org", + }); + expect(ruleWrongServerRoom).toBeFalsy(); + }); + + it("should not reject invites anymore once we have removed a rule", async () => { + await client.ignoredInvites.addRule(PolicyScope.User, "*:example.org", "just a test"); + + // We should reject this invite. + const ruleMatch = await client.ignoredInvites.getRuleForInvite({ + sender: "@foobar:example.org", + roomId: "!snafu:somewhere.org", + }); + expect(ruleMatch).toBeTruthy(); + expect(ruleMatch.getContent()).toMatchObject({ + recommendation: "m.ban", + reason: "just a test", + }); + + // After removing the invite, we shouldn't reject it anymore. + await client.ignoredInvites.removeRule(ruleMatch); + const ruleMatch2 = await client.ignoredInvites.getRuleForInvite({ + sender: "@foobar:example.org", + roomId: "!snafu:somewhere.org", + }); + expect(ruleMatch2).toBeFalsy(); + }); + + it("should add new rules in the target room, rather than any other source room", async () => { + const NEW_SOURCE_ROOM_ID = "!another-source:example.org"; + + // Make sure that everything is initialized. + await client.ignoredInvites.getOrCreateSourceRooms(); + await client.joinRoom(NEW_SOURCE_ROOM_ID); + const newSourceRoom = client.getRoom(NEW_SOURCE_ROOM_ID); + + // Fetch the list of sources and check that we do not have the new room yet. + const policies = await client.getAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name).getContent(); + expect(policies).toBeTruthy(); + const ignoreInvites = policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name]; + expect(ignoreInvites).toBeTruthy(); + expect(ignoreInvites.sources).toBeTruthy(); + expect(ignoreInvites.sources).not.toContain(NEW_SOURCE_ROOM_ID); + + // Add a source. + const added = await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID); + expect(added).toBe(true); + const added2 = await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID); + expect(added2).toBe(false); + + // Fetch the list of sources and check that we have added the new room. + const policies2 = await client.getAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name).getContent(); + expect(policies2).toBeTruthy(); + const ignoreInvites2 = policies2[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name]; + expect(ignoreInvites2).toBeTruthy(); + expect(ignoreInvites2.sources).toBeTruthy(); + expect(ignoreInvites2.sources).toContain(NEW_SOURCE_ROOM_ID); + + // Add a rule. + const eventId = await client.ignoredInvites.addRule(PolicyScope.User, "*:example.org", "just a test"); + + // Check where it shows up. + const targetRoomId = ignoreInvites2.target; + const targetRoom = client.getRoom(targetRoomId); + expect(targetRoom._state.get(PolicyScope.User)[eventId]).toBeTruthy(); + expect(newSourceRoom._state.get(PolicyScope.User)?.[eventId]).toBeFalsy(); + }); + }); }); diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index 9c3a93e53..fdc8101eb 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -565,7 +565,7 @@ describe("MSC3089TreeSpace", () => { rooms = {}; rooms[tree.roomId] = parentRoom; (tree).room = parentRoom; // override readonly - client.getRoom = (r) => rooms[r]; + client.getRoom = (r) => rooms[r ?? ""]; clientSendStateFn = jest.fn() .mockImplementation((roomId: string, eventType: EventType, content: any, stateKey: string) => { diff --git a/spec/unit/models/beacon.spec.ts b/spec/unit/models/beacon.spec.ts index c0de3591b..3b2f75a06 100644 --- a/spec/unit/models/beacon.spec.ts +++ b/spec/unit/models/beacon.spec.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { REFERENCE_RELATION } from "matrix-events-sdk"; + import { MatrixEvent } from "../../../src"; import { M_BEACON_INFO } from "../../../src/@types/beacon"; import { @@ -431,6 +433,27 @@ describe('Beacon', () => { expect(emitSpy).not.toHaveBeenCalled(); }); + it("should ignore invalid beacon events", () => { + const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 })); + const emitSpy = jest.spyOn(beacon, 'emit'); + + const ev = new MatrixEvent({ + type: M_BEACON_INFO.name, + sender: userId, + room_id: roomId, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: beacon.beaconInfoId, + }, + }, + }); + beacon.addLocations([ev]); + + expect(beacon.latestLocationEvent).toBeFalsy(); + expect(emitSpy).not.toHaveBeenCalled(); + }); + describe('when beacon is live with a start timestamp is in the future', () => { it('ignores locations before the beacon start timestamp', () => { const startTimestamp = now + 60000; diff --git a/spec/unit/notifications.spec.ts b/spec/unit/notifications.spec.ts new file mode 100644 index 000000000..89601327b --- /dev/null +++ b/spec/unit/notifications.spec.ts @@ -0,0 +1,114 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { + EventType, + fixNotificationCountOnDecryption, + MatrixClient, + MatrixEvent, + MsgType, + NotificationCountType, + RelationType, + Room, +} from "../../src/matrix"; +import { IActionsObject } from "../../src/pushprocessor"; +import { ReEmitter } from "../../src/ReEmitter"; +import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../test-utils/client"; +import { mkEvent, mock } from "../test-utils/test-utils"; + +let mockClient: MatrixClient; +let room: Room; +let event: MatrixEvent; +let threadEvent: MatrixEvent; + +const ROOM_ID = "!roomId:example.org"; +let THREAD_ID; + +function mkPushAction(notify, highlight): IActionsObject { + return { + notify, + tweaks: { + highlight, + }, + }; +} + +describe("fixNotificationCountOnDecryption", () => { + beforeEach(() => { + mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(), + getPushActionsForEvent: jest.fn().mockReturnValue(mkPushAction(true, true)), + getRoom: jest.fn().mockImplementation(() => room), + decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0), + supportsExperimentalThreads: jest.fn().mockReturnValue(true), + }); + mockClient.reEmitter = mock(ReEmitter, 'ReEmitter'); + + room = new Room(ROOM_ID, mockClient, mockClient.getUserId()); + room.setUnreadNotificationCount(NotificationCountType.Total, 1); + room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); + + event = mkEvent({ + type: EventType.RoomMessage, + content: { + msgtype: MsgType.Text, + body: "Hello world!", + }, + event: true, + }, mockClient); + + THREAD_ID = event.getId(); + threadEvent = mkEvent({ + type: EventType.RoomMessage, + content: { + "m.relates_to": { + rel_type: RelationType.Thread, + event_id: THREAD_ID, + }, + "msgtype": MsgType.Text, + "body": "Thread reply", + }, + event: true, + }); + room.createThread(THREAD_ID, event, [threadEvent], false); + + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 1); + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0); + + event.getPushActions = jest.fn().mockReturnValue(mkPushAction(false, false)); + threadEvent.getPushActions = jest.fn().mockReturnValue(mkPushAction(false, false)); + }); + + it("changes the room count to highlight on decryption", () => { + expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(1); + expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toBe(0); + + fixNotificationCountOnDecryption(mockClient, event); + + expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(1); + expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toBe(1); + }); + + it("changes the thread count to highlight on decryption", () => { + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(1); + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(0); + + fixNotificationCountOnDecryption(mockClient, threadEvent); + + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(1); + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(1); + }); +}); diff --git a/spec/unit/pusher.spec.ts b/spec/unit/pusher.spec.ts new file mode 100644 index 000000000..4a27ef55b --- /dev/null +++ b/spec/unit/pusher.spec.ts @@ -0,0 +1,69 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import MockHttpBackend from 'matrix-mock-request'; + +import { IHttpOpts, MatrixClient, PUSHER_ENABLED } from "../../src/matrix"; +import { mkPusher } from '../test-utils/test-utils'; + +const realSetTimeout = setTimeout; +function flushPromises() { + return new Promise(r => { + realSetTimeout(r, 1); + }); +} + +let client: MatrixClient; +let httpBackend: MockHttpBackend; + +describe("Pushers", () => { + beforeEach(() => { + httpBackend = new MockHttpBackend(); + client = new MatrixClient({ + baseUrl: "https://my.home.server", + accessToken: "my.access.token", + request: httpBackend.requestFn as unknown as IHttpOpts["request"], + }); + }); + + describe("supports remotely toggling push notifications", () => { + it("migration support when connecting to a legacy homeserver", async () => { + httpBackend.when("GET", "/_matrix/client/versions").respond(200, { + unstable_features: { + "org.matrix.msc3881": false, + }, + }); + httpBackend.when("GET", "/pushers").respond(200, { + pushers: [ + mkPusher(), + mkPusher({ [PUSHER_ENABLED.name]: true }), + mkPusher({ [PUSHER_ENABLED.name]: false }), + ], + }); + + const promise = client.getPushers(); + + await httpBackend.flushAllExpected(); + await flushPromises(); + + const response = await promise; + + expect(response.pushers[0][PUSHER_ENABLED.name]).toBe(true); + expect(response.pushers[1][PUSHER_ENABLED.name]).toBe(true); + expect(response.pushers[2][PUSHER_ENABLED.name]).toBe(false); + }); + }); +}); diff --git a/spec/unit/pushprocessor.spec.ts b/spec/unit/pushprocessor.spec.ts index 3bbdd5233..db4d2a417 100644 --- a/spec/unit/pushprocessor.spec.ts +++ b/spec/unit/pushprocessor.spec.ts @@ -1,6 +1,6 @@ import * as utils from "../test-utils/test-utils"; -import { PushProcessor } from "../../src/pushprocessor"; -import { EventType, MatrixClient, MatrixEvent } from "../../src"; +import { IActionsObject, PushProcessor } from "../../src/pushprocessor"; +import { EventType, IContent, MatrixClient, MatrixEvent } from "../../src"; describe('NotificationService', function() { const testUserId = "@ali:matrix.org"; @@ -336,4 +336,102 @@ describe('NotificationService', function() { enabled: true, }, testEvent)).toBe(true); }); + + describe("performCustomEventHandling()", () => { + const getActionsForEvent = (prevContent: IContent, content: IContent): IActionsObject => { + testEvent = utils.mkEvent({ + type: "org.matrix.msc3401.call", + room: testRoomId, + user: "@alice:foo", + skey: "state_key", + event: true, + content: content, + prev_content: prevContent, + }); + + return pushProcessor.actionsForEvent(testEvent); + }; + + const assertDoesNotify = (actions: IActionsObject): void => { + expect(actions.notify).toBeTruthy(); + expect(actions.tweaks.sound).toBeTruthy(); + expect(actions.tweaks.highlight).toBeFalsy(); + }; + + const assertDoesNotNotify = (actions: IActionsObject): void => { + expect(actions.notify).toBeFalsy(); + expect(actions.tweaks.sound).toBeFalsy(); + expect(actions.tweaks.highlight).toBeFalsy(); + }; + + it.each( + ["m.ring", "m.prompt"], + )("should notify when new group call event appears with %s intent", (intent: string) => { + assertDoesNotify(getActionsForEvent({}, { + "m.intent": intent, + "m.type": "m.voice", + "m.name": "Call", + })); + }); + + it("should notify when a call is un-terminated", () => { + assertDoesNotify(getActionsForEvent({ + "m.intent": "m.ring", + "m.type": "m.voice", + "m.name": "Call", + "m.terminated": "All users left", + }, { + "m.intent": "m.ring", + "m.type": "m.voice", + "m.name": "Call", + })); + }); + + it("should not notify when call is terminated", () => { + assertDoesNotNotify(getActionsForEvent({ + "m.intent": "m.ring", + "m.type": "m.voice", + "m.name": "Call", + }, { + "m.intent": "m.ring", + "m.type": "m.voice", + "m.name": "Call", + "m.terminated": "All users left", + })); + }); + + it("should ignore with m.room intent", () => { + assertDoesNotNotify(getActionsForEvent({}, { + "m.intent": "m.room", + "m.type": "m.voice", + "m.name": "Call", + })); + }); + + describe("ignoring non-relevant state changes", () => { + it("should ignore intent changes", () => { + assertDoesNotNotify(getActionsForEvent({ + "m.intent": "m.ring", + "m.type": "m.voice", + "m.name": "Call", + }, { + "m.intent": "m.ring", + "m.type": "m.video", + "m.name": "Call", + })); + }); + + it("should ignore name changes", () => { + assertDoesNotNotify(getActionsForEvent({ + "m.intent": "m.ring", + "m.type": "m.voice", + "m.name": "Call", + }, { + "m.intent": "m.ring", + "m.type": "m.voice", + "m.name": "New call", + })); + }); + }); + }); }); diff --git a/spec/unit/read-receipt.spec.ts b/spec/unit/read-receipt.spec.ts new file mode 100644 index 000000000..2e906a3cc --- /dev/null +++ b/spec/unit/read-receipt.spec.ts @@ -0,0 +1,150 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import MockHttpBackend from 'matrix-mock-request'; + +import { ReceiptType } from '../../src/@types/read_receipts'; +import { MatrixClient } from "../../src/client"; +import { IHttpOpts } from '../../src/http-api'; +import { EventType } from '../../src/matrix'; +import { MAIN_ROOM_TIMELINE } from '../../src/models/read-receipt'; +import { encodeUri } from '../../src/utils'; +import * as utils from "../test-utils/test-utils"; + +// Jest now uses @sinonjs/fake-timers which exposes tickAsync() and a number of +// other async methods which break the event loop, letting scheduled promise +// callbacks run. Unfortunately, Jest doesn't expose these, so we have to do +// it manually (this is what sinon does under the hood). We do both in a loop +// until the thing we expect happens: hopefully this is the least flakey way +// and avoids assuming anything about the app's behaviour. +const realSetTimeout = setTimeout; +function flushPromises() { + return new Promise(r => { + realSetTimeout(r, 1); + }); +} + +let client: MatrixClient; +let httpBackend: MockHttpBackend; + +const THREAD_ID = "$thread_event_id"; +const ROOM_ID = "!123:matrix.org"; + +const threadEvent = utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: "@bob:matrix.org", + room: ROOM_ID, + content: { + "body": "Hello from a thread", + "m.relates_to": { + "event_id": THREAD_ID, + "m.in_reply_to": { + "event_id": THREAD_ID, + }, + "rel_type": "m.thread", + }, + }, +}); + +const roomEvent = utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: "@bob:matrix.org", + room: ROOM_ID, + content: { + "body": "Hello from a room", + }, +}); + +function mockServerSideSupport(client, hasServerSideSupport) { + const doesServerSupportUnstableFeature = client.doesServerSupportUnstableFeature; + client.doesServerSupportUnstableFeature = (unstableFeature) => { + if (unstableFeature === "org.matrix.msc3771") { + return Promise.resolve(hasServerSideSupport); + } else { + return doesServerSupportUnstableFeature(unstableFeature); + } + }; +} + +describe("Read receipt", () => { + beforeEach(() => { + httpBackend = new MockHttpBackend(); + client = new MatrixClient({ + baseUrl: "https://my.home.server", + accessToken: "my.access.token", + request: httpBackend.requestFn as unknown as IHttpOpts["request"], + }); + client.isGuest = () => false; + }); + + describe("sendReceipt", () => { + it("sends a thread read receipt", async () => { + httpBackend.when( + "POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", { + $roomId: ROOM_ID, + $receiptType: ReceiptType.Read, + $eventId: threadEvent.getId(), + }), + ).check((request) => { + expect(request.data.thread_id).toEqual(THREAD_ID); + }).respond(200, {}); + + mockServerSideSupport(client, true); + client.sendReceipt(threadEvent, ReceiptType.Read, {}); + + await httpBackend.flushAllExpected(); + await flushPromises(); + }); + + it("sends a room read receipt", async () => { + httpBackend.when( + "POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", { + $roomId: ROOM_ID, + $receiptType: ReceiptType.Read, + $eventId: roomEvent.getId(), + }), + ).check((request) => { + expect(request.data.thread_id).toEqual(MAIN_ROOM_TIMELINE); + }).respond(200, {}); + + mockServerSideSupport(client, true); + client.sendReceipt(roomEvent, ReceiptType.Read, {}); + + await httpBackend.flushAllExpected(); + await flushPromises(); + }); + + it("sends a room read receipt when there's no server support", async () => { + httpBackend.when( + "POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", { + $roomId: ROOM_ID, + $receiptType: ReceiptType.Read, + $eventId: threadEvent.getId(), + }), + ).check((request) => { + expect(request.data.thread_id).toBeUndefined(); + }).respond(200, {}); + + mockServerSideSupport(client, false); + client.sendReceipt(threadEvent, ReceiptType.Read, {}); + + await httpBackend.flushAllExpected(); + await flushPromises(); + }); + }); +}); diff --git a/spec/unit/room-member.spec.js b/spec/unit/room-member.spec.ts similarity index 50% rename from spec/unit/room-member.spec.js rename to spec/unit/room-member.spec.ts index 89e98692e..e435cca22 100644 --- a/spec/unit/room-member.spec.js +++ b/spec/unit/room-member.spec.ts @@ -1,12 +1,29 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + import * as utils from "../test-utils/test-utils"; -import { RoomMember } from "../../src/models/room-member"; +import { RoomMember, RoomMemberEvent } from "../../src/models/room-member"; +import { RoomState } from "../../src"; describe("RoomMember", function() { const roomId = "!foo:bar"; const userA = "@alice:bar"; const userB = "@bertha:bar"; const userC = "@clarissa:bar"; - let member; + let member = new RoomMember(roomId, userA); beforeEach(function() { member = new RoomMember(roomId, userA); @@ -27,17 +44,17 @@ describe("RoomMember", function() { avatar_url: "mxc://flibble/wibble", }, }); - const url = member.getAvatarUrl(hsUrl); + const url = member.getAvatarUrl(hsUrl, 1, 1, '', false, false); // we don't care about how the mxc->http conversion is done, other // than it contains the mxc body. - expect(url.indexOf("flibble/wibble")).not.toEqual(-1); + expect(url?.indexOf("flibble/wibble")).not.toEqual(-1); }); it("should return nothing if there is no m.room.member and allowDefault=false", - function() { - const url = member.getAvatarUrl(hsUrl, 64, 64, "crop", false); - expect(url).toEqual(null); - }); + function() { + const url = member.getAvatarUrl(hsUrl, 64, 64, "crop", false, false); + expect(url).toEqual(null); + }); }); describe("setPowerLevelEvent", function() { @@ -66,92 +83,92 @@ describe("RoomMember", function() { }); it("should emit 'RoomMember.powerLevel' if the power level changes.", - function() { - const event = utils.mkEvent({ - type: "m.room.power_levels", - room: roomId, - user: userA, - content: { - users_default: 20, - users: { - "@bertha:bar": 200, - "@invalid:user": 10, // shouldn't barf on this. + function() { + const event = utils.mkEvent({ + type: "m.room.power_levels", + room: roomId, + user: userA, + content: { + users_default: 20, + users: { + "@bertha:bar": 200, + "@invalid:user": 10, // shouldn't barf on this. + }, }, - }, - event: true, - }); - let emitCount = 0; + event: true, + }); + let emitCount = 0; - member.on("RoomMember.powerLevel", function(emitEvent, emitMember) { - emitCount += 1; - expect(emitMember).toEqual(member); - expect(emitEvent).toEqual(event); - }); + member.on(RoomMemberEvent.PowerLevel, function(emitEvent, emitMember) { + emitCount += 1; + expect(emitMember).toEqual(member); + expect(emitEvent).toEqual(event); + }); - member.setPowerLevelEvent(event); - expect(emitCount).toEqual(1); - member.setPowerLevelEvent(event); // no-op - expect(emitCount).toEqual(1); - }); + member.setPowerLevelEvent(event); + expect(emitCount).toEqual(1); + member.setPowerLevelEvent(event); // no-op + expect(emitCount).toEqual(1); + }); it("should honour power levels of zero.", - function() { - const event = utils.mkEvent({ - type: "m.room.power_levels", - room: roomId, - user: userA, - content: { - users_default: 20, - users: { - "@alice:bar": 0, + function() { + const event = utils.mkEvent({ + type: "m.room.power_levels", + room: roomId, + user: userA, + content: { + users_default: 20, + users: { + "@alice:bar": 0, + }, }, - }, - event: true, - }); - let emitCount = 0; + event: true, + }); + let emitCount = 0; - // set the power level to something other than zero or we - // won't get an event - member.powerLevel = 1; - member.on("RoomMember.powerLevel", function(emitEvent, emitMember) { - emitCount += 1; - expect(emitMember.userId).toEqual('@alice:bar'); - expect(emitMember.powerLevel).toEqual(0); - expect(emitEvent).toEqual(event); - }); + // set the power level to something other than zero or we + // won't get an event + member.powerLevel = 1; + member.on(RoomMemberEvent.PowerLevel, function(emitEvent, emitMember) { + emitCount += 1; + expect(emitMember.userId).toEqual('@alice:bar'); + expect(emitMember.powerLevel).toEqual(0); + expect(emitEvent).toEqual(event); + }); - member.setPowerLevelEvent(event); - expect(member.powerLevel).toEqual(0); - expect(emitCount).toEqual(1); - }); + member.setPowerLevelEvent(event); + expect(member.powerLevel).toEqual(0); + expect(emitCount).toEqual(1); + }); it("should not honor string power levels.", - function() { - const event = utils.mkEvent({ - type: "m.room.power_levels", - room: roomId, - user: userA, - content: { - users_default: 20, - users: { - "@alice:bar": "5", + function() { + const event = utils.mkEvent({ + type: "m.room.power_levels", + room: roomId, + user: userA, + content: { + users_default: 20, + users: { + "@alice:bar": "5", + }, }, - }, - event: true, - }); - let emitCount = 0; + event: true, + }); + let emitCount = 0; - member.on("RoomMember.powerLevel", function(emitEvent, emitMember) { - emitCount += 1; - expect(emitMember.userId).toEqual('@alice:bar'); - expect(emitMember.powerLevel).toEqual(20); - expect(emitEvent).toEqual(event); - }); + member.on(RoomMemberEvent.PowerLevel, function(emitEvent, emitMember) { + emitCount += 1; + expect(emitMember.userId).toEqual('@alice:bar'); + expect(emitMember.powerLevel).toEqual(20); + expect(emitEvent).toEqual(event); + }); - member.setPowerLevelEvent(event); - expect(member.powerLevel).toEqual(20); - expect(emitCount).toEqual(1); - }); + member.setPowerLevelEvent(event); + expect(member.powerLevel).toEqual(20); + expect(emitCount).toEqual(1); + }); }); describe("setTypingEvent", function() { @@ -183,34 +200,34 @@ describe("RoomMember", function() { }); it("should emit 'RoomMember.typing' if the typing state changes", - function() { - const event = utils.mkEvent({ - type: "m.typing", - room: roomId, - content: { - user_ids: [ - userA, userC, - ], - }, - event: true, + function() { + const event = utils.mkEvent({ + type: "m.typing", + room: roomId, + content: { + user_ids: [ + userA, userC, + ], + }, + event: true, + }); + let emitCount = 0; + member.on(RoomMemberEvent.Typing, function(ev, mem) { + expect(mem).toEqual(member); + expect(ev).toEqual(event); + emitCount += 1; + }); + member.typing = false; + member.setTypingEvent(event); + expect(emitCount).toEqual(1); + member.setTypingEvent(event); // no-op + expect(emitCount).toEqual(1); }); - let emitCount = 0; - member.on("RoomMember.typing", function(ev, mem) { - expect(mem).toEqual(member); - expect(ev).toEqual(event); - emitCount += 1; - }); - member.typing = false; - member.setTypingEvent(event); - expect(emitCount).toEqual(1); - member.setTypingEvent(event); // no-op - expect(emitCount).toEqual(1); - }); }); describe("isOutOfBand", function() { it("should be set by markOutOfBand", function() { - const member = new RoomMember(); + const member = new RoomMember(roomId, userA); expect(member.isOutOfBand()).toEqual(false); member.markOutOfBand(); expect(member.isOutOfBand()).toEqual(true); @@ -235,50 +252,50 @@ describe("RoomMember", function() { }); it("should set 'membership' and assign the event to 'events.member'.", - function() { - member.setMembershipEvent(inviteEvent); - expect(member.membership).toEqual("invite"); - expect(member.events.member).toEqual(inviteEvent); - member.setMembershipEvent(joinEvent); - expect(member.membership).toEqual("join"); - expect(member.events.member).toEqual(joinEvent); - }); + function() { + member.setMembershipEvent(inviteEvent); + expect(member.membership).toEqual("invite"); + expect(member.events.member).toEqual(inviteEvent); + member.setMembershipEvent(joinEvent); + expect(member.membership).toEqual("join"); + expect(member.events.member).toEqual(joinEvent); + }); it("should set 'name' based on user_id, displayname and room state", - function() { - const roomState = { - getStateEvents: function(type) { - if (type !== "m.room.member") { - return []; - } - return [ - utils.mkMembership({ - event: true, mship: "join", room: roomId, - user: userB, - }), - utils.mkMembership({ - event: true, mship: "join", room: roomId, - user: userC, name: "Alice", - }), - joinEvent, - ]; - }, - getUserIdsWithDisplayName: function(displayName) { - return [userA, userC]; - }, - }; - expect(member.name).toEqual(userA); // default = user_id - member.setMembershipEvent(joinEvent); - expect(member.name).toEqual("Alice"); // prefer displayname - member.setMembershipEvent(joinEvent, roomState); - expect(member.name).not.toEqual("Alice"); // it should disambig. - // user_id should be there somewhere - expect(member.name.indexOf(userA)).not.toEqual(-1); - }); + function() { + const roomState = { + getStateEvents: function(type) { + if (type !== "m.room.member") { + return []; + } + return [ + utils.mkMembership({ + event: true, mship: "join", room: roomId, + user: userB, + }), + utils.mkMembership({ + event: true, mship: "join", room: roomId, + user: userC, name: "Alice", + }), + joinEvent, + ]; + }, + getUserIdsWithDisplayName: function(displayName) { + return [userA, userC]; + }, + } as unknown as RoomState; + expect(member.name).toEqual(userA); // default = user_id + member.setMembershipEvent(joinEvent); + expect(member.name).toEqual("Alice"); // prefer displayname + member.setMembershipEvent(joinEvent, roomState); + expect(member.name).not.toEqual("Alice"); // it should disambig. + // user_id should be there somewhere + expect(member.name.indexOf(userA)).not.toEqual(-1); + }); it("should emit 'RoomMember.membership' if the membership changes", function() { let emitCount = 0; - member.on("RoomMember.membership", function(ev, mem) { + member.on(RoomMemberEvent.Membership, function(ev, mem) { emitCount += 1; expect(mem).toEqual(member); expect(ev).toEqual(inviteEvent); @@ -291,7 +308,7 @@ describe("RoomMember", function() { it("should emit 'RoomMember.name' if the name changes", function() { let emitCount = 0; - member.on("RoomMember.name", function(ev, mem) { + member.on(RoomMemberEvent.Name, function(ev, mem) { emitCount += 1; expect(mem).toEqual(member); expect(ev).toEqual(joinEvent); @@ -341,7 +358,7 @@ describe("RoomMember", function() { getUserIdsWithDisplayName: function(displayName) { return [userA, userC]; }, - }; + } as unknown as RoomState; expect(member.name).toEqual(userA); // default = user_id member.setMembershipEvent(joinEvent, roomState); expect(member.name).not.toEqual("Alíce"); // it should disambig. diff --git a/spec/unit/room-state.spec.js b/spec/unit/room-state.spec.ts similarity index 79% rename from spec/unit/room-state.spec.js rename to spec/unit/room-state.spec.ts index b54121431..858bed80c 100644 --- a/spec/unit/room-state.spec.js +++ b/spec/unit/room-state.spec.ts @@ -1,14 +1,37 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MockedObject } from 'jest-mock'; + import * as utils from "../test-utils/test-utils"; import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon"; import { filterEmitCallsByEventType } from "../test-utils/emitter"; import { RoomState, RoomStateEvent } from "../../src/models/room-state"; -import { BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon"; +import { + Beacon, + BeaconEvent, + getBeaconInfoIdentifier, +} from "../../src/models/beacon"; import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@types/event"; import { MatrixEvent, MatrixEventEvent, } from "../../src/models/event"; import { M_BEACON } from "../../src/@types/beacon"; +import { MatrixClient } from "../../src/client"; describe("RoomState", function() { const roomId = "!foo:bar"; @@ -17,7 +40,7 @@ describe("RoomState", function() { const userC = "@cleo:bar"; const userLazy = "@lazy:bar"; - let state; + let state = new RoomState(roomId); beforeEach(function() { state = new RoomState(roomId); @@ -67,8 +90,8 @@ describe("RoomState", function() { it("should return a member which changes as state changes", function() { const member = state.getMember(userB); - expect(member.membership).toEqual("join"); - expect(member.name).toEqual(userB); + expect(member?.membership).toEqual("join"); + expect(member?.name).toEqual(userB); state.setStateEvents([ utils.mkMembership({ @@ -77,40 +100,40 @@ describe("RoomState", function() { }), ]); - expect(member.membership).toEqual("leave"); - expect(member.name).toEqual("BobGone"); + expect(member?.membership).toEqual("leave"); + expect(member?.name).toEqual("BobGone"); }); }); describe("getSentinelMember", function() { it("should return a member with the user id as name", function() { - expect(state.getSentinelMember("@no-one:here").name).toEqual("@no-one:here"); + expect(state.getSentinelMember("@no-one:here")?.name).toEqual("@no-one:here"); }); it("should return a member which doesn't change when the state is updated", - function() { - const preLeaveUser = state.getSentinelMember(userA); - state.setStateEvents([ - utils.mkMembership({ - room: roomId, user: userA, mship: "leave", event: true, - name: "AliceIsGone", - }), - ]); - const postLeaveUser = state.getSentinelMember(userA); + function() { + const preLeaveUser = state.getSentinelMember(userA); + state.setStateEvents([ + utils.mkMembership({ + room: roomId, user: userA, mship: "leave", event: true, + name: "AliceIsGone", + }), + ]); + const postLeaveUser = state.getSentinelMember(userA); - expect(preLeaveUser.membership).toEqual("join"); - expect(preLeaveUser.name).toEqual(userA); + expect(preLeaveUser?.membership).toEqual("join"); + expect(preLeaveUser?.name).toEqual(userA); - expect(postLeaveUser.membership).toEqual("leave"); - expect(postLeaveUser.name).toEqual("AliceIsGone"); - }); + expect(postLeaveUser?.membership).toEqual("leave"); + expect(postLeaveUser?.name).toEqual("AliceIsGone"); + }); }); describe("getStateEvents", function() { it("should return null if a state_key was specified and there was no match", - function() { - expect(state.getStateEvents("foo.bar.baz", "keyname")).toEqual(null); - }); + function() { + expect(state.getStateEvents("foo.bar.baz", "keyname")).toEqual(null); + }); it("should return an empty list if a state_key was not specified and there" + " was no match", function() { @@ -118,21 +141,21 @@ describe("RoomState", function() { }); it("should return a list of matching events if no state_key was specified", - function() { - const events = state.getStateEvents("m.room.member"); - expect(events.length).toEqual(2); - // ordering unimportant - expect([userA, userB].indexOf(events[0].getStateKey())).not.toEqual(-1); - expect([userA, userB].indexOf(events[1].getStateKey())).not.toEqual(-1); - }); + function() { + const events = state.getStateEvents("m.room.member"); + expect(events.length).toEqual(2); + // ordering unimportant + expect([userA, userB].indexOf(events[0].getStateKey() as string)).not.toEqual(-1); + expect([userA, userB].indexOf(events[1].getStateKey() as string)).not.toEqual(-1); + }); it("should return a single MatrixEvent if a state_key was specified", - function() { - const event = state.getStateEvents("m.room.member", userA); - expect(event.getContent()).toMatchObject({ - membership: "join", + function() { + const event = state.getStateEvents("m.room.member", userA); + expect(event.getContent()).toMatchObject({ + membership: "join", + }); }); - }); }); describe("setStateEvents", function() { @@ -146,7 +169,7 @@ describe("RoomState", function() { }), ]; let emitCount = 0; - state.on("RoomState.members", function(ev, st, mem) { + state.on(RoomStateEvent.Members, function(ev, st, mem) { expect(ev).toEqual(memberEvents[emitCount]); expect(st).toEqual(state); expect(mem).toEqual(state.getMember(ev.getSender())); @@ -166,7 +189,7 @@ describe("RoomState", function() { }), ]; let emitCount = 0; - state.on("RoomState.newMember", function(ev, st, mem) { + state.on(RoomStateEvent.NewMember, function(ev, st, mem) { expect(state.getMember(mem.userId)).toEqual(mem); expect(mem.userId).toEqual(memberEvents[emitCount].getSender()); expect(mem.membership).toBeFalsy(); // not defined yet @@ -192,7 +215,7 @@ describe("RoomState", function() { }), ]; let emitCount = 0; - state.on("RoomState.events", function(ev, st) { + state.on(RoomStateEvent.Events, function(ev, st) { expect(ev).toEqual(events[emitCount]); expect(st).toEqual(state); emitCount += 1; @@ -272,7 +295,7 @@ describe("RoomState", function() { }), ]; let emitCount = 0; - state.on("RoomState.Marker", function(markerEvent, markerFoundOptions) { + state.on(RoomStateEvent.Marker, function(markerEvent, markerFoundOptions) { expect(markerEvent).toEqual(events[emitCount]); expect(markerFoundOptions).toEqual({ timelineWasEmpty: true }); emitCount += 1; @@ -296,7 +319,7 @@ describe("RoomState", function() { it('does not add redacted beacon info events to state', () => { const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId); - const redactionEvent = { event: { type: 'm.room.redaction' } }; + const redactionEvent = new MatrixEvent({ type: 'm.room.redaction' }); redactedBeaconEvent.makeRedacted(redactionEvent); const emitSpy = jest.spyOn(state, 'emit'); @@ -316,27 +339,27 @@ describe("RoomState", function() { state.setStateEvents([beaconEvent]); const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent)); - expect(beaconInstance.isLive).toEqual(true); + expect(beaconInstance?.isLive).toEqual(true); state.setStateEvents([updatedBeaconEvent]); // same Beacon expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(beaconInstance); // updated liveness - expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent)).isLive).toEqual(false); + expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))?.isLive).toEqual(false); }); it('destroys and removes redacted beacon events', () => { const beaconId = '$beacon1'; const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId); const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId); - const redactionEvent = { event: { type: 'm.room.redaction', redacts: beaconEvent.getId() } }; + const redactionEvent = new MatrixEvent({ type: 'm.room.redaction', redacts: beaconEvent.getId() }); redactedBeaconEvent.makeRedacted(redactionEvent); state.setStateEvents([beaconEvent]); const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent)); - const destroySpy = jest.spyOn(beaconInstance, 'destroy'); - expect(beaconInstance.isLive).toEqual(true); + const destroySpy = jest.spyOn(beaconInstance as Beacon, 'destroy'); + expect(beaconInstance?.isLive).toEqual(true); state.setStateEvents([redactedBeaconEvent]); @@ -357,7 +380,7 @@ describe("RoomState", function() { // live beacon is now not live const updatedLiveBeaconEvent = makeBeaconInfoEvent( - userA, roomId, { isLive: false }, liveBeaconEvent.getId(), '$beacon1', + userA, roomId, { isLive: false }, liveBeaconEvent.getId(), ); state.setStateEvents([updatedLiveBeaconEvent]); @@ -377,8 +400,8 @@ describe("RoomState", function() { state.markOutOfBandMembersStarted(); state.setOutOfBandMembers([oobMemberEvent]); const member = state.getMember(userLazy); - expect(member.userId).toEqual(userLazy); - expect(member.isOutOfBand()).toEqual(true); + expect(member?.userId).toEqual(userLazy); + expect(member?.isOutOfBand()).toEqual(true); }); it("should have no effect when not in correct status", function() { @@ -394,7 +417,7 @@ describe("RoomState", function() { user: userLazy, mship: "join", room: roomId, event: true, }); let eventReceived = false; - state.once('RoomState.newMember', (_, __, member) => { + state.once(RoomStateEvent.NewMember, (_event, _state, member) => { expect(member.userId).toEqual(userLazy); eventReceived = true; }); @@ -410,8 +433,8 @@ describe("RoomState", function() { state.markOutOfBandMembersStarted(); state.setOutOfBandMembers([oobMemberEvent]); const memberA = state.getMember(userA); - expect(memberA.events.member.getId()).not.toEqual(oobMemberEvent.getId()); - expect(memberA.isOutOfBand()).toEqual(false); + expect(memberA?.events?.member?.getId()).not.toEqual(oobMemberEvent.getId()); + expect(memberA?.isOutOfBand()).toEqual(false); }); it("should emit members when updating a member", function() { @@ -420,7 +443,7 @@ describe("RoomState", function() { user: doesntExistYetUserId, mship: "join", room: roomId, event: true, }); let eventReceived = false; - state.once('RoomState.members', (_, __, member) => { + state.once(RoomStateEvent.Members, (_event, _state, member) => { expect(member.userId).toEqual(doesntExistYetUserId); eventReceived = true; }); @@ -443,8 +466,8 @@ describe("RoomState", function() { [userA, userB, userLazy].forEach((userId) => { const member = state.getMember(userId); const memberCopy = copy.getMember(userId); - expect(member.name).toEqual(memberCopy.name); - expect(member.isOutOfBand()).toEqual(memberCopy.isOutOfBand()); + expect(member?.name).toEqual(memberCopy?.name); + expect(member?.isOutOfBand()).toEqual(memberCopy?.isOutOfBand()); }); // check member keys expect(Object.keys(state.members)).toEqual(Object.keys(copy.members)); @@ -496,78 +519,80 @@ describe("RoomState", function() { describe("maySendStateEvent", function() { it("should say any member may send state with no power level event", - function() { - expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); - }); + function() { + expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); + }); it("should say members with power >=50 may send state with power level event " + "but no state default", function() { - const powerLevelEvent = { - type: "m.room.power_levels", room: roomId, user: userA, event: true, + const powerLevelEvent = new MatrixEvent({ + type: "m.room.power_levels", room_id: roomId, sender: userA, + state_key: "", content: { users_default: 10, // state_default: 50, "intentionally left blank" events_default: 25, users: { + [userA]: 50, }, }, - }; - powerLevelEvent.content.users[userA] = 50; + }); - state.setStateEvents([utils.mkEvent(powerLevelEvent)]); + state.setStateEvents([powerLevelEvent]); expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); expect(state.maySendStateEvent('m.room.name', userB)).toEqual(false); }); it("should obey state_default", - function() { - const powerLevelEvent = { - type: "m.room.power_levels", room: roomId, user: userA, event: true, - content: { - users_default: 10, - state_default: 30, - events_default: 25, - users: { + function() { + const powerLevelEvent = new MatrixEvent({ + type: "m.room.power_levels", room_id: roomId, sender: userA, + state_key: "", + content: { + users_default: 10, + state_default: 30, + events_default: 25, + users: { + [userA]: 30, + [userB]: 29, + }, }, - }, - }; - powerLevelEvent.content.users[userA] = 30; - powerLevelEvent.content.users[userB] = 29; + }); - state.setStateEvents([utils.mkEvent(powerLevelEvent)]); + state.setStateEvents([powerLevelEvent]); - expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); - expect(state.maySendStateEvent('m.room.name', userB)).toEqual(false); - }); + expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); + expect(state.maySendStateEvent('m.room.name', userB)).toEqual(false); + }); it("should honour explicit event power levels in the power_levels event", - function() { - const powerLevelEvent = { - type: "m.room.power_levels", room: roomId, user: userA, event: true, - content: { - events: { - "m.room.other_thing": 76, + function() { + const powerLevelEvent = new MatrixEvent({ + type: "m.room.power_levels", room_id: roomId, sender: userA, + state_key: "", content: { + events: { + "m.room.other_thing": 76, + }, + users_default: 10, + state_default: 50, + events_default: 25, + users: { + [userA]: 80, + [userB]: 50, + }, }, - users_default: 10, - state_default: 50, - events_default: 25, - users: { - }, - }, - }; - powerLevelEvent.content.users[userA] = 80; - powerLevelEvent.content.users[userB] = 50; + }); - state.setStateEvents([utils.mkEvent(powerLevelEvent)]); + state.setStateEvents([powerLevelEvent]); - expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); - expect(state.maySendStateEvent('m.room.name', userB)).toEqual(true); + expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); + expect(state.maySendStateEvent('m.room.name', userB)).toEqual(true); - expect(state.maySendStateEvent('m.room.other_thing', userA)).toEqual(true); - expect(state.maySendStateEvent('m.room.other_thing', userB)).toEqual(false); - }); + expect(state.maySendStateEvent('m.room.other_thing', userA)).toEqual(true); + expect(state.maySendStateEvent('m.room.other_thing', userB)).toEqual(false); + }); }); describe("getJoinedMemberCount", function() { @@ -682,71 +707,73 @@ describe("RoomState", function() { describe("maySendEvent", function() { it("should say any member may send events with no power level event", - function() { - expect(state.maySendEvent('m.room.message', userA)).toEqual(true); - expect(state.maySendMessage(userA)).toEqual(true); - }); + function() { + expect(state.maySendEvent('m.room.message', userA)).toEqual(true); + expect(state.maySendMessage(userA)).toEqual(true); + }); it("should obey events_default", - function() { - const powerLevelEvent = { - type: "m.room.power_levels", room: roomId, user: userA, event: true, - content: { - users_default: 10, - state_default: 30, - events_default: 25, - users: { + function() { + const powerLevelEvent = new MatrixEvent({ + type: "m.room.power_levels", room_id: roomId, sender: userA, + state_key: "", + content: { + users_default: 10, + state_default: 30, + events_default: 25, + users: { + [userA]: 26, + [userB]: 24, + }, }, - }, - }; - powerLevelEvent.content.users[userA] = 26; - powerLevelEvent.content.users[userB] = 24; + }); - state.setStateEvents([utils.mkEvent(powerLevelEvent)]); + state.setStateEvents([powerLevelEvent]); - expect(state.maySendEvent('m.room.message', userA)).toEqual(true); - expect(state.maySendEvent('m.room.message', userB)).toEqual(false); + expect(state.maySendEvent('m.room.message', userA)).toEqual(true); + expect(state.maySendEvent('m.room.message', userB)).toEqual(false); - expect(state.maySendMessage(userA)).toEqual(true); - expect(state.maySendMessage(userB)).toEqual(false); - }); + expect(state.maySendMessage(userA)).toEqual(true); + expect(state.maySendMessage(userB)).toEqual(false); + }); it("should honour explicit event power levels in the power_levels event", - function() { - const powerLevelEvent = { - type: "m.room.power_levels", room: roomId, user: userA, event: true, - content: { - events: { - "m.room.other_thing": 33, + function() { + const powerLevelEvent = new MatrixEvent({ + type: "m.room.power_levels", room_id: roomId, sender: userA, + state_key: "", + content: { + events: { + "m.room.other_thing": 33, + }, + users_default: 10, + state_default: 50, + events_default: 25, + users: { + [userA]: 40, + [userB]: 30, + }, }, - users_default: 10, - state_default: 50, - events_default: 25, - users: { - }, - }, - }; - powerLevelEvent.content.users[userA] = 40; - powerLevelEvent.content.users[userB] = 30; + }); - state.setStateEvents([utils.mkEvent(powerLevelEvent)]); + state.setStateEvents([powerLevelEvent]); - expect(state.maySendEvent('m.room.message', userA)).toEqual(true); - expect(state.maySendEvent('m.room.message', userB)).toEqual(true); + expect(state.maySendEvent('m.room.message', userA)).toEqual(true); + expect(state.maySendEvent('m.room.message', userB)).toEqual(true); - expect(state.maySendMessage(userA)).toEqual(true); - expect(state.maySendMessage(userB)).toEqual(true); + expect(state.maySendMessage(userA)).toEqual(true); + expect(state.maySendMessage(userB)).toEqual(true); - expect(state.maySendEvent('m.room.other_thing', userA)).toEqual(true); - expect(state.maySendEvent('m.room.other_thing', userB)).toEqual(false); - }); + expect(state.maySendEvent('m.room.other_thing', userA)).toEqual(true); + expect(state.maySendEvent('m.room.other_thing', userB)).toEqual(false); + }); }); describe('processBeaconEvents', () => { - const beacon1 = makeBeaconInfoEvent(userA, roomId, {}, '$beacon1', '$beacon1'); - const beacon2 = makeBeaconInfoEvent(userB, roomId, {}, '$beacon2', '$beacon2'); + const beacon1 = makeBeaconInfoEvent(userA, roomId, {}, '$beacon1'); + const beacon2 = makeBeaconInfoEvent(userB, roomId, {}, '$beacon2'); - const mockClient = { decryptEventIfNeeded: jest.fn() }; + const mockClient = { decryptEventIfNeeded: jest.fn() } as unknown as MockedObject; beforeEach(() => { mockClient.decryptEventIfNeeded.mockClear(); @@ -816,11 +843,11 @@ describe("RoomState", function() { beaconInfoId: 'some-other-beacon', }); - state.setStateEvents([beacon1, beacon2], mockClient); + state.setStateEvents([beacon1, beacon2]); expect(state.beacons.size).toEqual(2); - const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beacon1)); + const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon; const addLocationsSpy = jest.spyOn(beaconInstance, 'addLocations'); state.processBeaconEvents([location1, location2, location3], mockClient); @@ -885,7 +912,7 @@ describe("RoomState", function() { }); state.setStateEvents([beacon1, beacon2]); - const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)); + const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon; const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear(); state.processBeaconEvents([location, otherRelatedEvent], mockClient); expect(addLocationsSpy).not.toHaveBeenCalled(); @@ -945,13 +972,13 @@ describe("RoomState", function() { }); jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true); state.setStateEvents([beacon1, beacon2]); - const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)); + const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon; const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear(); state.processBeaconEvents([decryptingRelatedEvent], mockClient); // this event is a message after decryption - decryptingRelatedEvent.type = EventType.RoomMessage; - decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted); + decryptingRelatedEvent.event.type = EventType.RoomMessage; + decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted, decryptingRelatedEvent); expect(addLocationsSpy).not.toHaveBeenCalled(); }); @@ -967,14 +994,14 @@ describe("RoomState", function() { }); jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true); state.setStateEvents([beacon1, beacon2]); - const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)); + const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon; const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear(); state.processBeaconEvents([decryptingRelatedEvent], mockClient); // update type after '''decryption''' decryptingRelatedEvent.event.type = M_BEACON.name; - decryptingRelatedEvent.event.content = locationEvent.content; - decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted); + decryptingRelatedEvent.event.content = locationEvent.event.content; + decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted, decryptingRelatedEvent); expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]); }); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 695bc1227..de9f0c5f9 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -32,13 +32,14 @@ import { RoomEvent, } from "../../src"; import { EventTimeline } from "../../src/models/event-timeline"; -import { IWrappedReceipt, Room } from "../../src/models/room"; +import { NotificationCountType, Room } from "../../src/models/room"; import { RoomState } from "../../src/models/room-state"; import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event"; import { TestClient } from "../TestClient"; import { emitPromise } from "../test-utils/test-utils"; import { ReceiptType } from "../../src/@types/read_receipts"; -import { Thread, ThreadEvent } from "../../src/models/thread"; +import { FeatureSupport, Thread, ThreadEvent } from "../../src/models/thread"; +import { WrappedReceipt } from "../../src/models/read-receipt"; describe("Room", function() { const roomId = "!foo:bar"; @@ -288,11 +289,11 @@ describe("Room", function() { room.addLiveEvents(events); expect(room.currentState.setStateEvents).toHaveBeenCalledWith( [events[0]], - { timelineWasEmpty: undefined }, + { timelineWasEmpty: false }, ); expect(room.currentState.setStateEvents).toHaveBeenCalledWith( [events[1]], - { timelineWasEmpty: undefined }, + { timelineWasEmpty: false }, ); expect(events[0].forwardLooking).toBe(true); expect(events[1].forwardLooking).toBe(true); @@ -426,6 +427,17 @@ describe("Room", function() { // but without the event ID matching we will still have the local event in pending events expect(room.getEventForTxnId(txnId)).toBeUndefined(); }); + + it("should correctly handle remote echoes from other devices", () => { + const remoteEvent = utils.mkMessage({ + room: roomId, user: userA, event: true, + }); + remoteEvent.event.unsigned = { transaction_id: "TXN_ID" }; + + // add the remoteEvent + room.addLiveEvents([remoteEvent]); + expect(room.timeline.length).toEqual(1); + }); }); describe('addEphemeralEvents', () => { @@ -1419,6 +1431,19 @@ describe("Room", function() { expect(room.getUsersReadUpTo(eventToAck)).toEqual([userB]); }); }); + + describe("hasUserReadUpTo", function() { + it("should acknowledge if an event has been read", function() { + const ts = 13787898424; + room.addReceipt(mkReceipt(roomId, [ + mkRecord(eventToAck.getId(), "m.read", userB, ts), + ])); + expect(room.hasUserReadEvent(userB, eventToAck.getId())).toEqual(true); + }); + it("return false for an unknown event", function() { + expect(room.hasUserReadEvent(userB, "unknown_event")).toEqual(false); + }); + }); }); describe("tags", function() { @@ -2383,7 +2408,7 @@ describe("Room", function() { }); it("should aggregate relations in thread event timeline set", () => { - Thread.setServerSideSupport(true, true); + Thread.setServerSideSupport(FeatureSupport.Stable); const threadRoot = mkMessage(); const rootReaction = mkReaction(threadRoot); const threadResponse = mkThreadResponse(threadRoot); @@ -2428,8 +2453,8 @@ describe("Room", function() { const room = new Room(roomId, client, userA); it("handles missing receipt type", () => { - room.getReadReceiptForUserId = (userId, ignore, receiptType) => { - return receiptType === ReceiptType.ReadPrivate ? { eventId: "eventId" } as IWrappedReceipt : null; + room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => { + return receiptType === ReceiptType.ReadPrivate ? { eventId: "eventId" } as WrappedReceipt : null; }; expect(room.getEventReadUpTo(userA)).toEqual("eventId"); @@ -2437,19 +2462,17 @@ describe("Room", function() { describe("prefers newer receipt", () => { it("should compare correctly using timelines", () => { - room.getReadReceiptForUserId = (userId, ignore, receiptType) => { + room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => { if (receiptType === ReceiptType.ReadPrivate) { - return { eventId: "eventId1" } as IWrappedReceipt; - } - if (receiptType === ReceiptType.UnstableReadPrivate) { - return { eventId: "eventId2" } as IWrappedReceipt; + return { eventId: "eventId1" } as WrappedReceipt; } if (receiptType === ReceiptType.Read) { - return { eventId: "eventId3" } as IWrappedReceipt; + return { eventId: "eventId2" } as WrappedReceipt; } + return null; }; - for (let i = 1; i <= 3; i++) { + for (let i = 1; i <= 2; i++) { room.getUnfilteredTimelineSet = () => ({ compareEventOrdering: (event1, event2) => { return (event1 === `eventId${i}`) ? 1 : -1; } } as EventTimelineSet); @@ -2460,20 +2483,18 @@ describe("Room", function() { describe("correctly compares by timestamp", () => { it("should correctly compare, if we have all receipts", () => { - for (let i = 1; i <= 3; i++) { + for (let i = 1; i <= 2; i++) { room.getUnfilteredTimelineSet = () => ({ compareEventOrdering: (_1, _2) => null, } as EventTimelineSet); - room.getReadReceiptForUserId = (userId, ignore, receiptType) => { + room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => { if (receiptType === ReceiptType.ReadPrivate) { - return { eventId: "eventId1", data: { ts: i === 1 ? 1 : 0 } } as IWrappedReceipt; - } - if (receiptType === ReceiptType.UnstableReadPrivate) { - return { eventId: "eventId2", data: { ts: i === 2 ? 1 : 0 } } as IWrappedReceipt; + return { eventId: "eventId1", data: { ts: i === 1 ? 2 : 1 } } as WrappedReceipt; } if (receiptType === ReceiptType.Read) { - return { eventId: "eventId3", data: { ts: i === 3 ? 1 : 0 } } as IWrappedReceipt; + return { eventId: "eventId2", data: { ts: i === 2 ? 2 : 1 } } as WrappedReceipt; } + return null; }; expect(room.getEventReadUpTo(userA)).toEqual(`eventId${i}`); @@ -2484,13 +2505,11 @@ describe("Room", function() { room.getUnfilteredTimelineSet = () => ({ compareEventOrdering: (_1, _2) => null, } as EventTimelineSet); - room.getReadReceiptForUserId = (userId, ignore, receiptType) => { - if (receiptType === ReceiptType.UnstableReadPrivate) { - return { eventId: "eventId1", data: { ts: 0 } } as IWrappedReceipt; - } + room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => { if (receiptType === ReceiptType.Read) { - return { eventId: "eventId2", data: { ts: 1 } } as IWrappedReceipt; + return { eventId: "eventId2", data: { ts: 1 } } as WrappedReceipt; } + return null; }; expect(room.getEventReadUpTo(userA)).toEqual(`eventId2`); @@ -2505,39 +2524,25 @@ describe("Room", function() { }); it("should give precedence to m.read.private", () => { - room.getReadReceiptForUserId = (userId, ignore, receiptType) => { + room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => { if (receiptType === ReceiptType.ReadPrivate) { - return { eventId: "eventId1" } as IWrappedReceipt; - } - if (receiptType === ReceiptType.UnstableReadPrivate) { - return { eventId: "eventId2" } as IWrappedReceipt; + return { eventId: "eventId1" } as WrappedReceipt; } if (receiptType === ReceiptType.Read) { - return { eventId: "eventId3" } as IWrappedReceipt; + return { eventId: "eventId2" } as WrappedReceipt; } + return null; }; expect(room.getEventReadUpTo(userA)).toEqual(`eventId1`); }); - it("should give precedence to org.matrix.msc2285.read.private", () => { - room.getReadReceiptForUserId = (userId, ignore, receiptType) => { - if (receiptType === ReceiptType.UnstableReadPrivate) { - return { eventId: "eventId2" } as IWrappedReceipt; - } - if (receiptType === ReceiptType.Read) { - return { eventId: "eventId2" } as IWrappedReceipt; - } - }; - - expect(room.getEventReadUpTo(userA)).toEqual(`eventId2`); - }); - it("should give precedence to m.read", () => { - room.getReadReceiptForUserId = (userId, ignore, receiptType) => { + room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => { if (receiptType === ReceiptType.Read) { - return { eventId: "eventId3" } as IWrappedReceipt; + return { eventId: "eventId3" } as WrappedReceipt; } + return null; }; expect(room.getEventReadUpTo(userA)).toEqual(`eventId3`); @@ -2557,4 +2562,40 @@ describe("Room", function() { expect(client.roomNameGenerator).toHaveBeenCalled(); }); }); + + describe("thread notifications", () => { + let room; + + beforeEach(() => { + const client = new TestClient(userA).client; + room = new Room(roomId, client, userA); + }); + + it("defaults to undefined", () => { + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBeUndefined(); + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBeUndefined(); + }); + + it("lets you set values", () => { + room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 1); + + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(1); + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBeUndefined(); + + room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 10); + + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(1); + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBe(10); + }); + + it("lets you reset threads notifications", () => { + room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 666); + room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 123); + + room.resetThreadUnreadNotificationCount(); + + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBeUndefined(); + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBeUndefined(); + }); + }); }); diff --git a/spec/unit/stores/indexeddb.spec.ts b/spec/unit/stores/indexeddb.spec.ts index 3fc7477cc..d0dd87243 100644 --- a/spec/unit/stores/indexeddb.spec.ts +++ b/spec/unit/stores/indexeddb.spec.ts @@ -20,6 +20,7 @@ import 'jest-localstorage-mock'; import { IndexedDBStore, IStateEventWithRoomId, MemoryStore } from "../../../src"; import { emitPromise } from "../../test-utils/test-utils"; import { LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend"; +import { defer } from "../../../src/utils"; describe("IndexedDBStore", () => { afterEach(() => { @@ -111,4 +112,57 @@ describe("IndexedDBStore", () => { await store.setPendingEvents(roomId, []); expect(localStorage.getItem("mx_pending_events_" + roomId)).toBeNull(); }); + + it("should resolve isNewlyCreated to true if no database existed initially", async () => { + const store = new IndexedDBStore({ + indexedDB, + dbName: "db1", + localStorage, + }); + await store.startup(); + + await expect(store.isNewlyCreated()).resolves.toBeTruthy(); + }); + + it("should resolve isNewlyCreated to false if database existed already", async () => { + let store = new IndexedDBStore({ + indexedDB, + dbName: "db2", + localStorage, + }); + await store.startup(); + + store = new IndexedDBStore({ + indexedDB, + dbName: "db2", + localStorage, + }); + await store.startup(); + + await expect(store.isNewlyCreated()).resolves.toBeFalsy(); + }); + + it("should resolve isNewlyCreated to false if database existed already but needs upgrade", async () => { + const deferred = defer(); + // seed db3 to Version 1 so it forces a migration + const req = indexedDB.open("matrix-js-sdk:db3", 1); + req.onupgradeneeded = () => { + const db = req.result; + db.createObjectStore("users", { keyPath: ["userId"] }); + db.createObjectStore("accountData", { keyPath: ["type"] }); + db.createObjectStore("sync", { keyPath: ["clobber"] }); + }; + req.onsuccess = deferred.resolve; + await deferred.promise; + req.result.close(); + + const store = new IndexedDBStore({ + indexedDB, + dbName: "db3", + localStorage, + }); + await store.startup(); + + await expect(store.isNewlyCreated()).resolves.toBeFalsy(); + }); }); diff --git a/spec/unit/sync-accumulator.spec.ts b/spec/unit/sync-accumulator.spec.ts index b5385330b..5618dbe22 100644 --- a/spec/unit/sync-accumulator.spec.ts +++ b/spec/unit/sync-accumulator.spec.ts @@ -30,6 +30,12 @@ const RES_WITH_AGE = { account_data: { events: [] }, ephemeral: { events: [] }, unread_notifications: {}, + unread_thread_notifications: { + "$143273582443PhrSn:example.org": { + highlight_count: 0, + notification_count: 1, + }, + }, timeline: { events: [ Object.freeze({ @@ -302,9 +308,6 @@ describe("SyncAccumulator", function() { [ReceiptType.ReadPrivate]: { "@dan:localhost": { ts: 4 }, }, - [ReceiptType.UnstableReadPrivate]: { - "@matthew:localhost": { ts: 5 }, - }, "some.other.receipt.type": { "@should_be_ignored:localhost": { key: "val" }, }, @@ -350,9 +353,6 @@ describe("SyncAccumulator", function() { [ReceiptType.ReadPrivate]: { "@dan:localhost": { ts: 4 }, }, - [ReceiptType.UnstableReadPrivate]: { - "@matthew:localhost": { ts: 5 }, - }, }, "$event2:localhost": { [ReceiptType.Read]: { @@ -445,6 +445,13 @@ describe("SyncAccumulator", function() { Object.keys(RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0]), ); }); + + it("should retrieve unread thread notifications", () => { + sa.accumulate(RES_WITH_AGE); + const output = sa.getJSON(); + expect(output.roomsData.join["!foo:bar"] + .unread_thread_notifications["$143273582443PhrSn:example.org"]).not.toBeUndefined(); + }); }); }); diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts index 36ad9e164..5d804022e 100644 --- a/spec/unit/utils.spec.ts +++ b/spec/unit/utils.spec.ts @@ -152,6 +152,9 @@ describe("utils", function() { assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 2 })); assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { b: 2, a: 1 })); assert.isFalse(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 3 })); + assert.isFalse(utils.deepCompare({ a: 1, b: 2 }, { a: 1 })); + assert.isFalse(utils.deepCompare({ a: 1 }, { a: 1, b: 2 })); + assert.isFalse(utils.deepCompare({ a: 1 }, { b: 1 })); assert.isTrue(utils.deepCompare({ 1: { name: "mhc", age: 28 }, @@ -525,38 +528,6 @@ describe("utils", function() { }); }); - describe('getPrivateReadReceiptField', () => { - it('should return m.read.private if server supports stable', async () => { - expect(await utils.getPrivateReadReceiptField({ - doesServerSupportUnstableFeature: jest.fn().mockImplementation((feature) => { - return feature === "org.matrix.msc2285.stable"; - }), - } as any)).toBe(ReceiptType.ReadPrivate); - }); - - it('should return m.read.private if server supports stable and unstable', async () => { - expect(await utils.getPrivateReadReceiptField({ - doesServerSupportUnstableFeature: jest.fn().mockImplementation((feature) => { - return ["org.matrix.msc2285.stable", "org.matrix.msc2285"].includes(feature); - }), - } as any)).toBe(ReceiptType.ReadPrivate); - }); - - it('should return org.matrix.msc2285.read.private if server supports unstable', async () => { - expect(await utils.getPrivateReadReceiptField({ - doesServerSupportUnstableFeature: jest.fn().mockImplementation((feature) => { - return feature === "org.matrix.msc2285"; - }), - } as any)).toBe(ReceiptType.UnstableReadPrivate); - }); - - it('should return none if server does not support either', async () => { - expect(await utils.getPrivateReadReceiptField({ - doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false), - } as any)).toBeFalsy(); - }); - }); - describe('isSupportedReceiptType', () => { it('should support m.read', () => { expect(utils.isSupportedReceiptType(ReceiptType.Read)).toBeTruthy(); @@ -566,10 +537,6 @@ describe("utils", function() { expect(utils.isSupportedReceiptType(ReceiptType.ReadPrivate)).toBeTruthy(); }); - it('should support org.matrix.msc2285.read.private', () => { - expect(utils.isSupportedReceiptType(ReceiptType.UnstableReadPrivate)).toBeTruthy(); - }); - it('should not support other receipt types', () => { expect(utils.isSupportedReceiptType("this is a receipt type")).toBeFalsy(); }); diff --git a/src/@types/PushRules.ts b/src/@types/PushRules.ts index 12d1b0d31..7af6d1481 100644 --- a/src/@types/PushRules.ts +++ b/src/@types/PushRules.ts @@ -156,9 +156,13 @@ export interface IPusher { lang: string; profile_tag?: string; pushkey: string; + enabled?: boolean | null | undefined; + "org.matrix.msc3881.enabled"?: boolean | null | undefined; + device_id?: string | null; + "org.matrix.msc3881.device_id"?: string | null; } -export interface IPusherRequest extends IPusher { +export interface IPusherRequest extends Omit { append?: boolean; } diff --git a/src/@types/auth.ts b/src/@types/auth.ts index 592974221..2b8f5d76f 100644 --- a/src/@types/auth.ts +++ b/src/@types/auth.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { UnstableValue } from "../NamespacedValue"; + // disable lint because these are wire responses /* eslint-disable camelcase */ @@ -27,3 +29,89 @@ export interface IRefreshTokenResponse { } /* eslint-enable camelcase */ + +/** + * Response to GET login flows as per https://spec.matrix.org/v1.3/client-server-api/#get_matrixclientv3login + */ +export interface ILoginFlowsResponse { + flows: LoginFlow[]; +} + +export type LoginFlow = ISSOFlow | IPasswordFlow | ILoginFlow; + +export interface ILoginFlow { + type: string; +} + +export interface IPasswordFlow extends ILoginFlow { + type: "m.login.password"; +} + +export const DELEGATED_OIDC_COMPATIBILITY = new UnstableValue( + "delegated_oidc_compatibility", + "org.matrix.msc3824.delegated_oidc_compatibility", +); + +/** + * Representation of SSO flow as per https://spec.matrix.org/v1.3/client-server-api/#client-login-via-sso + */ +export interface ISSOFlow extends ILoginFlow { + type: "m.login.sso" | "m.login.cas"; + // eslint-disable-next-line camelcase + identity_providers?: IIdentityProvider[]; + [DELEGATED_OIDC_COMPATIBILITY.name]?: boolean; + [DELEGATED_OIDC_COMPATIBILITY.altName]?: boolean; +} + +export enum IdentityProviderBrand { + Gitlab = "gitlab", + Github = "github", + Apple = "apple", + Google = "google", + Facebook = "facebook", + Twitter = "twitter", +} + +export interface IIdentityProvider { + id: string; + name: string; + icon?: string; + brand?: IdentityProviderBrand | string; +} + +/** + * Parameters to login request as per https://spec.matrix.org/v1.3/client-server-api/#login + */ +/* eslint-disable camelcase */ +export interface ILoginParams { + identifier?: object; + password?: string; + token?: string; + device_id?: string; + initial_device_display_name?: string; +} +/* eslint-enable camelcase */ + +export enum SSOAction { + /** The user intends to login to an existing account */ + LOGIN = "login", + + /** The user intends to register for a new account */ + REGISTER = "register", +} + +/** + * The result of a successful [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882) + * `m.login.token` issuance request. + * Note that this is UNSTABLE and subject to breaking changes without notice. + */ +export interface LoginTokenPostResponse { + /** + * The token to use with `m.login.token` to authenticate. + */ + login_token: string; + /** + * Expiration in seconds. + */ + expires_in: number; +} diff --git a/src/@types/crypto.ts b/src/@types/crypto.ts new file mode 100644 index 000000000..3c46d9939 --- /dev/null +++ b/src/@types/crypto.ts @@ -0,0 +1,20 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export type OlmGroupSessionExtraData = { + untrusted?: boolean; + sharedHistory?: boolean; +}; diff --git a/src/@types/event.ts b/src/@types/event.ts index dac2770ad..045f9af51 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -191,6 +191,33 @@ export const EVENT_VISIBILITY_CHANGE_TYPE = new UnstableValue( "m.visibility", "org.matrix.msc3531.visibility"); +/** + * https://github.com/matrix-org/matrix-doc/pull/3881 + * + * @experimental + */ +export const PUSHER_ENABLED = new UnstableValue( + "enabled", + "org.matrix.msc3881.enabled"); + +/** + * https://github.com/matrix-org/matrix-doc/pull/3881 + * + * @experimental + */ +export const PUSHER_DEVICE_ID = new UnstableValue( + "device_id", + "org.matrix.msc3881.device_id"); + +/** + * https://github.com/matrix-org/matrix-doc/pull/3890 + * + * @experimental + */ +export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue( + "m.local_notification_settings", + "org.matrix.msc3890.local_notification_settings"); + export interface IEncryptedFile { url: string; mimetype?: string; diff --git a/src/@types/local_notifications.ts b/src/@types/local_notifications.ts new file mode 100644 index 000000000..b92d986bd --- /dev/null +++ b/src/@types/local_notifications.ts @@ -0,0 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export interface LocalNotificationSettings { + is_silenced: boolean; +} diff --git a/src/@types/read_receipts.ts b/src/@types/read_receipts.ts index 16a67feb3..4e90ac2ea 100644 --- a/src/@types/read_receipts.ts +++ b/src/@types/read_receipts.ts @@ -18,8 +18,4 @@ export enum ReceiptType { Read = "m.read", FullyRead = "m.fully_read", ReadPrivate = "m.read.private", - /** - * @deprecated Please use the ReadPrivate type when possible. This value may be removed at any time without notice. - */ - UnstableReadPrivate = "org.matrix.msc2285.read.private", } diff --git a/src/@types/requests.ts b/src/@types/requests.ts index 9d0472cee..cb292bf2c 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -22,7 +22,7 @@ import { IRoomEventFilter } from "../filter"; import { Direction } from "../models/event-timeline"; import { PushRuleAction } from "./PushRules"; import { IRoomEvent } from "../sync-accumulator"; -import { RoomType } from "./event"; +import { EventType, RoomType } from "./event"; // allow camelcase as these are things that go onto the wire /* eslint-disable camelcase */ @@ -98,7 +98,18 @@ export interface ICreateRoomOpts { name?: string; topic?: string; preset?: Preset; - power_level_content_override?: object; + power_level_content_override?: { + ban?: number; + events?: Record; + events_default?: number; + invite?: number; + kick?: number; + notifications?: Record; + redact?: number; + state_default?: number; + users?: Record; + users_default?: number; + }; creation_content?: object; initial_state?: ICreateRoomStateEvent[]; invite?: string[]; @@ -149,7 +160,7 @@ export interface IRelationsRequestOpts { from?: string; to?: string; limit?: number; - direction?: Direction; + dir?: Direction; } export interface IRelationsResponse { diff --git a/src/@types/sync.ts b/src/@types/sync.ts new file mode 100644 index 000000000..036c542ba --- /dev/null +++ b/src/@types/sync.ts @@ -0,0 +1,26 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ServerControlledNamespacedValue } from "../NamespacedValue"; + +/** + * https://github.com/matrix-org/matrix-doc/pull/3773 + * + * @experimental + */ +export const UNREAD_THREAD_NOTIFICATIONS = new ServerControlledNamespacedValue( + "unread_thread_notifications", + "org.matrix.msc3773.unread_thread_notifications"); diff --git a/src/@types/uia.ts b/src/@types/uia.ts new file mode 100644 index 000000000..a976083a6 --- /dev/null +++ b/src/@types/uia.ts @@ -0,0 +1,29 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IAuthData } from "../interactive-auth"; + +/** + * Helper type to represent HTTP request body for a UIA enabled endpoint + */ +export type UIARequest = T & { + auth?: IAuthData; +}; + +/** + * Helper type to represent HTTP response body for a UIA enabled endpoint + */ +export type UIAResponse = T | IAuthData; diff --git a/src/NamespacedValue.ts b/src/NamespacedValue.ts index 59c2a1f83..9b8b3d408 100644 --- a/src/NamespacedValue.ts +++ b/src/NamespacedValue.ts @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { Optional } from "matrix-events-sdk/lib/types"; + /** * Represents a simple Matrix namespaced value. This will assume that if a stable prefix * is provided that the stable prefix should be used when representing the identifier. @@ -41,13 +43,20 @@ export class NamespacedValue { return this.unstable; } + public get names(): (U | S)[] { + const names = [this.name]; + const altName = this.altName; + if (altName) names.push(altName); + return names; + } + public matches(val: string): boolean { return this.name === val || this.altName === val; } // this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class // so we can instantiate `NamespacedValue` as a default type for that namespace. - public findIn(obj: any): T { + public findIn(obj: any): Optional { let val: T; if (this.name) { val = obj?.[this.name]; diff --git a/src/client.ts b/src/client.ts index acda99b70..a8857abbb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,7 +19,7 @@ limitations under the License. * @module client */ -import { EmoteEvent, IPartialEvent, MessageEvent, NoticeEvent } from "matrix-events-sdk"; +import { EmoteEvent, IPartialEvent, MessageEvent, NoticeEvent, Optional } from "matrix-events-sdk"; import { ISyncStateData, SyncApi, SyncState } from "./sync"; import { @@ -33,7 +33,7 @@ import { } from "./models/event"; import { StubStore } from "./store/stub"; import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call"; -import { Filter, IFilterDefinition } from "./filter"; +import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter"; import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler'; import * as utils from './utils'; import { sleep } from './utils'; @@ -158,7 +158,9 @@ import { } from "./@types/requests"; import { EventType, + LOCAL_NOTIFICATION_SETTINGS_PREFIX, MsgType, + PUSHER_ENABLED, RelationType, RoomCreateTypeField, RoomType, @@ -188,15 +190,22 @@ import { IPusher, IPusherRequest, IPushRules, PushRuleAction, PushRuleKind, Rule import { IThreepid } from "./@types/threepids"; import { CryptoStore } from "./crypto/store/base"; import { MediaHandler } from "./webrtc/mediaHandler"; -import { IRefreshTokenResponse } from "./@types/auth"; +import { LoginTokenPostResponse, ILoginFlowsResponse, IRefreshTokenResponse, SSOAction } from "./@types/auth"; import { TypedEventEmitter } from "./models/typed-event-emitter"; import { ReceiptType } from "./@types/read_receipts"; import { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, SlidingSync } from "./sliding-sync"; import { SlidingSyncSdk } from "./sliding-sync-sdk"; -import { Thread, THREAD_RELATION_TYPE } from "./models/thread"; +import { FeatureSupport, Thread, THREAD_RELATION_TYPE, determineFeatureSupport } from "./models/thread"; import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon"; +import { UnstableValue } from "./NamespacedValue"; import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue"; import { ToDeviceBatch } from "./models/ToDeviceMessage"; +import { MAIN_ROOM_TIMELINE } from "./models/read-receipt"; +import { IgnoredInvites } from "./models/invites-ignorer"; +import { UIARequest, UIAResponse } from "./@types/uia"; +import { LocalNotificationSettings } from "./@types/local_notifications"; +import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync"; +import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature"; export type Store = IStore; @@ -208,6 +217,11 @@ export const CRYPTO_ENABLED: boolean = isCryptoAvailable(); const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes +export const UNSTABLE_MSC3852_LAST_SEEN_UA = new UnstableValue( + "last_seen_user_agent", + "org.matrix.msc3852.last_seen_user_agent", +); + interface IExportedDevice { olmDevice: IExportedOlmDevice; userId: string; @@ -396,8 +410,7 @@ export interface IStartClientOpts { pollTimeout?: number; /** - * The filter to apply to /sync calls. This will override the opts.initialSyncLimit, which would - * normally result in a timeline limit filter. + * The filter to apply to /sync calls. */ filter?: Filter; @@ -517,15 +530,21 @@ export interface ITurnServer { credential: string; } -interface IServerVersions { +export interface IServerVersions { versions: string[]; unstable_features: Record; } +export const M_AUTHENTICATION = new UnstableValue( + "m.authentication", + "org.matrix.msc2965.authentication", +); + export interface IClientWellKnown { [key: string]: any; "m.homeserver"?: IWellKnownConfig; "m.identity_server"?: IWellKnownConfig; + [M_AUTHENTICATION.name]?: IDelegatedAuthConfig; // MSC2965 } export interface IWellKnownConfig { @@ -537,6 +556,13 @@ export interface IWellKnownConfig { base_url?: string | null; } +export interface IDelegatedAuthConfig { // MSC2965 + /** The OIDC Provider/issuer the client should use */ + issuer: string; + /** The optional URL of the web UI where the user can manage their account */ + account?: string; +} + interface IKeyBackupPath { path: string; queryData?: { @@ -572,6 +598,13 @@ interface IMessagesResponse { state: IStateEvent[]; } +interface IThreadedMessagesResponse { + prev_batch: string; + next_batch: string; + chunk: IRoomEvent[]; + state: IStateEvent[]; +} + export interface IRequestTokenResponse { sid: string; submit_url?: string; @@ -669,6 +702,8 @@ export interface IMyDevice { display_name?: string; last_seen_ip?: string; last_seen_ts?: number; + [UNSTABLE_MSC3852_LAST_SEEN_UA.stable]?: string; + [UNSTABLE_MSC3852_LAST_SEEN_UA.unstable]?: string; } export interface IDownloadKeyResult { @@ -846,7 +881,7 @@ type UserEvents = UserEvent.AvatarUrl | UserEvent.CurrentlyActive | UserEvent.LastPresenceTs; -type EmittedEvents = ClientEvent +export type EmittedEvents = ClientEvent | RoomEvents | RoomStateEvents | CryptoEvents @@ -881,6 +916,8 @@ export type ClientEventHandlerMap = { & HttpApiEventHandlerMap & BeaconEventHandlerMap; +const SSO_ACTION_PARAM = new UnstableValue("action", "org.matrix.msc3824.action"); + /** * Represents a Matrix Client. Only directly construct this if you want to use * custom modules. Normally, {@link createClient} should be used @@ -902,7 +939,7 @@ export class MatrixClient extends TypedEventEmitter } = {}; public identityServer: IIdentityServerProvider; public http: MatrixHttpApi; // XXX: Intended private, used in code. - public crypto: Crypto; // XXX: Intended private, used in code. + public crypto?: Crypto; // XXX: Intended private, used in code. public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code. public callEventHandler: CallEventHandler; // XXX: Intended private, used in code. public supportsCallTransfer = false; // XXX: Intended private, used in code. @@ -932,6 +969,8 @@ export class MatrixClient extends TypedEventEmitter; protected canResetTimelineCallback: ResetTimelineCallback; + public canSupport = new Map(); + // The pushprocessor caches useful things, so keep one and re-use it protected pushProcessor = new PushProcessor(this); @@ -955,6 +994,9 @@ export class MatrixClient extends TypedEventEmitter { - const oldActions = event.getPushActions(); - const actions = this.getPushActionsForEvent(event, true); - - const room = this.getRoom(event.getRoomId()); - if (!room) return; - - const currentCount = room.getUnreadNotificationCount(NotificationCountType.Highlight); - - // Ensure the unread counts are kept up to date if the event is encrypted - // We also want to make sure that the notification count goes up if we already - // have encrypted events to avoid other code from resetting 'highlight' to zero. - const oldHighlight = !!oldActions?.tweaks?.highlight; - const newHighlight = !!actions?.tweaks?.highlight; - if (oldHighlight !== newHighlight || currentCount > 0) { - // TODO: Handle mentions received while the client is offline - // See also https://github.com/vector-im/element-web/issues/9069 - if (!room.hasUserReadEvent(this.getUserId(), event.getId())) { - let newCount = currentCount; - if (newHighlight && !oldHighlight) newCount++; - if (!newHighlight && oldHighlight) newCount--; - room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount); - - // Fix 'Mentions Only' rooms from not having the right badge count - const totalCount = room.getUnreadNotificationCount(NotificationCountType.Total); - if (totalCount < newCount) { - room.setUnreadNotificationCount(NotificationCountType.Total, newCount); - } - } - } + fixNotificationCountOnDecryption(this, event); }); // Like above, we have to listen for read receipts from ourselves in order to @@ -1136,6 +1150,8 @@ export class MatrixClient extends TypedEventEmitter( undefined, Method.Get, "/room_keys/version", undefined, undefined, - { prefix: PREFIX_UNSTABLE }, + { prefix: PREFIX_V3 }, ); } catch (e) { if (e.errcode === 'M_NOT_FOUND') { @@ -2823,7 +2840,7 @@ export class MatrixClient extends TypedEventEmitter( undefined, Method.Post, "/room_keys/version", undefined, data, - { prefix: PREFIX_UNSTABLE }, + { prefix: PREFIX_V3 }, ); // We could assume everything's okay and enable directly, but this ensures @@ -2855,7 +2872,7 @@ export class MatrixClient extends TypedEventEmitter { + public async sendReceipt( + event: MatrixEvent, + receiptType: ReceiptType, + body: any, + callback?: Callback, + ): Promise<{}> { if (typeof (body) === 'function') { callback = body as any as Callback; // legacy body = {}; @@ -4597,10 +4622,19 @@ export class MatrixClient extends TypedEventEmitter { + public async sendReadReceipt( + event: MatrixEvent | null, + receiptType = ReceiptType.Read, + callback?: Callback, + ): Promise<{} | undefined> { + if (!event) return; const eventId = event.getId(); const room = this.getRoom(event.getRoomId()); if (room && room.hasPendingEvent(eventId)) { @@ -5279,6 +5318,7 @@ export class MatrixClient extends TypedEventEmitter { + public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise> { // don't allow any timeline support unless it's been enabled. if (!this.timelineSupport) { throw new Error("timeline support is disabled. Set the 'timelineSupport'" + " parameter to true when creating MatrixClient to enable it."); } + if (!timelineSet?.room) { + throw new Error("getEventTimeline only supports room timelines"); + } + if (timelineSet.getTimelineForEvent(eventId)) { return timelineSet.getTimelineForEvent(eventId); } @@ -5317,7 +5361,7 @@ export class MatrixClient extends TypedEventEmitter = undefined; + let params: Record | undefined = undefined; if (this.clientOpts.lazyLoadMembers) { params = { filter: JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER) }; } @@ -5355,12 +5399,12 @@ export class MatrixClient extends TypedEventEmitter { + public async getLatestTimeline(timelineSet: EventTimelineSet): Promise> { // don't allow any timeline support unless it's been enabled. if (!this.timelineSupport) { throw new Error("timeline support is disabled. Set the 'timelineSupport'" + " parameter to true when creating MatrixClient to enable it."); } - const messagesPath = utils.encodeUri( - "/rooms/$roomId/messages", { - $roomId: timelineSet.room.roomId, - }, - ); - - const params: Record = { - dir: 'b', - }; - if (this.clientOpts.lazyLoadMembers) { - params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER); + if (!timelineSet.room) { + throw new Error("getLatestTimeline only supports room timelines"); } - const res = await this.http.authedRequest(undefined, Method.Get, messagesPath, params); + let res: IMessagesResponse; + const roomId = timelineSet.room.roomId; + if (timelineSet.isThreadTimeline) { + res = await this.createThreadListMessagesRequest( + roomId, + null, + 1, + Direction.Backward, + timelineSet.getFilter(), + ); + } else { + res = await this.createMessagesRequest( + roomId, + null, + 1, + Direction.Backward, + timelineSet.getFilter(), + ); + } const event = res.chunk?.[0]; if (!event) { throw new Error("No message returned from /messages when trying to construct getLatestTimeline"); @@ -5470,7 +5523,7 @@ export class MatrixClient extends TypedEventEmitter { + const path = utils.encodeUri("/rooms/$roomId/threads", { $roomId: roomId }); + + const params: Record = { + limit: limit.toString(), + dir: dir, + include: 'all', + }; + + if (fromToken) { + params.from = fromToken; + } + + let filter: IRoomEventFilter | null = null; + if (this.clientOpts.lazyLoadMembers) { + // create a shallow copy of LAZY_LOADING_MESSAGES_FILTER, + // so the timelineFilter doesn't get written into it below + filter = { + ...filter, + ...Filter.LAZY_LOADING_MESSAGES_FILTER, + }; + } + if (timelineFilter) { + // XXX: it's horrific that /messages' filter parameter doesn't match + // /sync's one - see https://matrix.org/jira/browse/SPEC-451 + filter = { + ...filter, + ...timelineFilter.getRoomTimelineFilterComponent()?.toJSON(), + }; + } + if (filter) { + params.filter = JSON.stringify(filter); + } + + const opts: { prefix?: string } = {}; + if (Thread.hasServerSideListSupport === FeatureSupport.Experimental) { + opts.prefix = "/_matrix/client/unstable/org.matrix.msc3856"; + } + + return this.http.authedRequest(undefined, Method.Get, path, params, undefined, opts) + .then(res => ({ + ...res, + start: res.prev_batch, + end: res.next_batch, + })); + } + /** * Take an EventTimeline, and back/forward-fill results. * @@ -5503,6 +5622,8 @@ export class MatrixClient extends TypedEventEmitter { const isNotifTimeline = (eventTimeline.getTimelineSet() === this.notifTimelineSet); + const room = this.getRoom(eventTimeline.getRoomId()); + const isThreadTimeline = eventTimeline.getTimelineSet().isThreadTimeline; // TODO: we should implement a backoff (as per scrollback()) to deal more // nicely with HTTP errors. @@ -5536,7 +5657,7 @@ export class MatrixClient extends TypedEventEmitter { const token = res.next_token; - const matrixEvents = []; + const matrixEvents: MatrixEvent[] = []; for (let i = 0; i < res.notifications.length; i++) { const notification = res.notifications[i]; @@ -5568,13 +5689,48 @@ export class MatrixClient extends TypedEventEmitter { + eventTimeline.paginationRequests[dir] = null; + }); + eventTimeline.paginationRequests[dir] = promise; + } else if (isThreadTimeline) { + if (!room) { + throw new Error("Unknown room " + eventTimeline.getRoomId()); + } + + promise = this.createThreadListMessagesRequest( + eventTimeline.getRoomId(), + token, + opts.limit, + dir, + eventTimeline.getFilter(), + ).then((res) => { + if (res.state) { + const roomState = eventTimeline.getState(dir); + const stateEvents = res.state.map(this.getEventMapper()); + roomState.setUnknownStateEvents(stateEvents); + } + const token = res.end; + const matrixEvents = res.chunk.map(this.getEventMapper()); + + const timelineSet = eventTimeline.getTimelineSet(); + timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token); + this.processBeaconEvents(room, matrixEvents); + this.processThreadRoots(room, matrixEvents, backwards); + + // if we've hit the end of the timeline, we need to stop trying to + // paginate. We need to keep the 'forwards' token though, to make sure + // we can recover from gappy syncs. + if (backwards && res.end == res.start) { + eventTimeline.setPaginationToken(null, dir); + } + return res.end !== res.start; }).finally(() => { eventTimeline.paginationRequests[dir] = null; }); eventTimeline.paginationRequests[dir] = promise; } else { - const room = this.getRoom(eventTimeline.getRoomId()); if (!room) { throw new Error("Unknown room " + eventTimeline.getRoomId()); } @@ -5595,18 +5751,20 @@ export class MatrixClient extends TypedEventEmitter { eventTimeline.paginationRequests[dir] = null; }); @@ -6684,23 +6842,28 @@ export class MatrixClient extends TypedEventEmitter { + threads: FeatureSupport; + list: FeatureSupport; + }> { try { - const hasUnstableSupport = await this.doesServerSupportUnstableFeature("org.matrix.msc3440"); - const hasStableSupport = await this.doesServerSupportUnstableFeature("org.matrix.msc3440.stable"); + const [threadUnstable, threadStable, listUnstable, listStable] = await Promise.all([ + this.doesServerSupportUnstableFeature("org.matrix.msc3440"), + this.doesServerSupportUnstableFeature("org.matrix.msc3440.stable"), + this.doesServerSupportUnstableFeature("org.matrix.msc3856"), + this.doesServerSupportUnstableFeature("org.matrix.msc3856.stable"), + ]); // TODO: Use `this.isVersionSupported("v1.3")` for whatever spec version includes MSC3440 formally. return { - serverSupport: hasUnstableSupport || hasStableSupport, - stable: hasStableSupport, + threads: determineFeatureSupport(threadStable, threadUnstable), + list: determineFeatureSupport(listStable, listUnstable), }; } catch (e) { - // Assume server support and stability aren't available: null/no data return. - // XXX: This should just return an object with `false` booleans instead. - return null; + return { + threads: FeatureSupport.None, + list: FeatureSupport.None, + }; } } @@ -6757,7 +6920,7 @@ export class MatrixClient extends TypedEventEmitter} Resolves to the available login flows * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public loginFlows(callback?: Callback): Promise { // TODO: Types + public loginFlows(callback?: Callback): Promise { return this.http.request(callback, Method.Get, "/login"); } @@ -7151,15 +7314,26 @@ export class MatrixClient extends TypedEventEmitter>} Resolves: On success, the token response + * or UIA auth data. + */ + public requestLoginToken(auth?: IAuthData): Promise> { + const body: UIARequest<{}> = { auth }; + return this.http.authedRequest( + undefined, // no callback support + Method.Post, + "/org.matrix.msc3882/login/token", + undefined, // no query params + body, + { prefix: PREFIX_UNSTABLE }, + ); + } + /** * Get the fallback URL to use for unknown interactive-auth stages. * @@ -7303,7 +7498,7 @@ export class MatrixClient extends TypedEventEmitter { const queryString = utils.encodeParams(opts as Record); @@ -7327,7 +7522,7 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomId/aliases", { $roomId: roomId }); const prefix = PREFIX_V3; - return this.http.authedRequest(undefined, Method.Get, path, null, null, { prefix }); + return this.http.authedRequest(undefined, Method.Get, path, undefined, undefined, { prefix }); } /** @@ -7870,7 +8067,7 @@ export class MatrixClient extends TypedEventEmitter { const path = "/account/3pid/add"; const prefix = await this.isVersionSupported("r0.6.0") ? PREFIX_R0 : PREFIX_UNSTABLE; - return this.http.authedRequest(undefined, Method.Post, path, null, data, { prefix }); + return this.http.authedRequest(undefined, Method.Post, path, undefined, data, { prefix }); } /** @@ -7911,7 +8108,7 @@ export class MatrixClient extends TypedEventEmitter { const path = "/account/3pid/delete"; - return this.http.authedRequest(undefined, Method.Post, path, null, { medium, address }); + return this.http.authedRequest(undefined, Method.Post, path, undefined, { medium, address }); } /** @@ -8001,7 +8198,7 @@ export class MatrixClient extends TypedEventEmitter( - callback, Method.Post, path, null, data, + callback, Method.Post, path, undefined, data, ); } @@ -8092,8 +8289,21 @@ export class MatrixClient extends TypedEventEmitter { - return this.http.authedRequest(callback, Method.Get, "/pushers"); + public async getPushers(callback?: Callback): Promise<{ pushers: IPusher[] }> { + const response = await this.http.authedRequest(callback, Method.Get, "/pushers"); + + // Migration path for clients that connect to a homeserver that does not support + // MSC3881 yet, see https://github.com/matrix-org/matrix-spec-proposals/blob/kerry/remote-push-toggle/proposals/3881-remote-push-notification-toggling.md#migration + if (!await this.doesServerSupportUnstableFeature("org.matrix.msc3881")) { + response.pushers = response.pushers.map(pusher => { + if (!pusher.hasOwnProperty(PUSHER_ENABLED.name)) { + pusher[PUSHER_ENABLED.name] = true; + } + return pusher; + }); + } + + return response; } /** @@ -8106,7 +8316,22 @@ export class MatrixClient extends TypedEventEmitter { const path = "/pushers/set"; - return this.http.authedRequest(callback, Method.Post, path, null, pusher); + return this.http.authedRequest(callback, Method.Post, path, undefined, pusher); + } + + /** + * Persists local notification settings + * @param {string} deviceId + * @param {LocalNotificationSettings} notificationSettings + * @return {Promise} Resolves: an empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ + public setLocalNotificationSettings( + deviceId: string, + notificationSettings: LocalNotificationSettings, + ): Promise<{}> { + const key = `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`; + return this.setAccountData(key, notificationSettings); } /** @@ -8893,7 +9118,7 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias }); - return this.http.authedRequest(undefined, Method.Get, path, { via }, null, { + return this.http.authedRequest(undefined, Method.Get, path, { via }, undefined, { qsStringifyOptions: { arrayFormat: 'repeat' }, prefix: "/_matrix/client/unstable/im.nheko.summary", }); @@ -9068,6 +9293,13 @@ export class MatrixClient extends TypedEventEmitter 0) { + // TODO: Handle mentions received while the client is offline + // See also https://github.com/vector-im/element-web/issues/9069 + const hasReadEvent = isThreadEvent + ? room.getThread(event.threadRootId).hasUserReadEvent(cli.getUserId(), event.getId()) + : room.hasUserReadEvent(cli.getUserId(), event.getId()); + + if (!hasReadEvent) { + let newCount = currentCount; + if (newHighlight && !oldHighlight) newCount++; + if (!newHighlight && oldHighlight) newCount--; + + if (isThreadEvent) { + room.setThreadUnreadNotificationCount( + event.threadRootId, + NotificationCountType.Highlight, + newCount, + ); + } else { + room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount); + } + + // Fix 'Mentions Only' rooms from not having the right badge count + const totalCount = (isThreadEvent + ? room.getThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Total) + : room.getUnreadNotificationCount(NotificationCountType.Total)) ?? 0; + + if (totalCount < newCount) { + if (isThreadEvent) { + room.setThreadUnreadNotificationCount( + event.threadRootId, + NotificationCountType.Total, + newCount, + ); + } else { + room.setUnreadNotificationCount(NotificationCountType.Total, newCount); + } + } + } + } +} + /** * Fires whenever the SDK receives a new event. *

diff --git a/src/content-helpers.ts b/src/content-helpers.ts index 82404ea6e..c8c90dd6f 100644 --- a/src/content-helpers.ts +++ b/src/content-helpers.ts @@ -292,16 +292,17 @@ export const makeBeaconContent: MakeBeaconContent = ( }); export type BeaconLocationState = MLocationContent & { - timestamp: number; + uri?: string; // override from MLocationContent to allow optionals + timestamp?: number; }; export const parseBeaconContent = (content: MBeaconEventContent): BeaconLocationState => { - const { description, uri } = M_LOCATION.findIn(content); + const location = M_LOCATION.findIn(content); const timestamp = M_TIMESTAMP.findIn(content); return { - description, - uri, + description: location?.description, + uri: location?.uri, timestamp, }; }; diff --git a/src/crypto/EncryptionSetup.ts b/src/crypto/EncryptionSetup.ts index 61ba34eaf..d7c826d12 100644 --- a/src/crypto/EncryptionSetup.ts +++ b/src/crypto/EncryptionSetup.ts @@ -18,7 +18,7 @@ import { logger } from "../logger"; import { MatrixEvent } from "../models/event"; import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning"; import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; -import { Method, PREFIX_UNSTABLE } from "../http-api"; +import { Method, PREFIX_V3 } from "../http-api"; import { Crypto, IBootstrapCrossSigningOpts } from "./index"; import { ClientEvent, @@ -246,14 +246,14 @@ export class EncryptionSetupOperation { algorithm: this.keyBackupInfo.algorithm, auth_data: this.keyBackupInfo.auth_data, }, - { prefix: PREFIX_UNSTABLE }, + { prefix: PREFIX_V3 }, ); } else { // add new key backup await baseApis.http.authedRequest( undefined, Method.Post, "/room_keys/version", undefined, this.keyBackupInfo, - { prefix: PREFIX_UNSTABLE }, + { prefix: PREFIX_V3 }, ); } } diff --git a/src/crypto/OlmDevice.ts b/src/crypto/OlmDevice.ts index 0b2e616a8..cca3b7db9 100644 --- a/src/crypto/OlmDevice.ts +++ b/src/crypto/OlmDevice.ts @@ -23,6 +23,7 @@ import * as algorithms from './algorithms'; import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base"; import { IOlmDevice, IOutboundGroupSessionKey } from "./algorithms/megolm"; import { IMegolmSessionData } from "./index"; +import { OlmGroupSessionExtraData } from "../@types/crypto"; // The maximum size of an event is 65K, and we base64 the content, so this is a // reasonable approximation to the biggest plaintext we can encrypt. @@ -122,6 +123,7 @@ interface IInboundGroupSessionKey { forwarding_curve25519_key_chain: string[]; sender_claimed_ed25519_key: string; shared_history: boolean; + untrusted: boolean; } /* eslint-enable camelcase */ @@ -1101,7 +1103,7 @@ export class OlmDevice { sessionKey: string, keysClaimed: Record, exportFormat: boolean, - extraSessionData: Record = {}, + extraSessionData: OlmGroupSessionExtraData = {}, ): Promise { await this.cryptoStore.doTxn( 'readwrite', [ @@ -1133,17 +1135,42 @@ export class OlmDevice { "Update for megolm session " + senderKey + "/" + sessionId, ); - if (existingSession.first_known_index() - <= session.first_known_index() - && !(existingSession.first_known_index() == session.first_known_index() - && !extraSessionData.untrusted - && existingSessionData.untrusted)) { - // existing session has lower index (i.e. can - // decrypt more), or they have the same index and - // the new sessions trust does not win over the old - // sessions trust, so keep it - logger.log(`Keeping existing megolm session ${sessionId}`); - return; + if (existingSession.first_known_index() <= session.first_known_index()) { + if (!existingSessionData.untrusted || extraSessionData.untrusted) { + // existing session has less-than-or-equal index + // (i.e. can decrypt at least as much), and the + // new session's trust does not win over the old + // session's trust, so keep it + logger.log(`Keeping existing megolm session ${sessionId}`); + return; + } + if (existingSession.first_known_index() < session.first_known_index()) { + // We want to upgrade the existing session's trust, + // but we can't just use the new session because we'll + // lose the lower index. Check that the sessions connect + // properly, and then manually set the existing session + // as trusted. + if ( + existingSession.export_session(session.first_known_index()) + === session.export_session(session.first_known_index()) + ) { + logger.info( + "Upgrading trust of existing megolm session " + + sessionId + " based on newly-received trusted session", + ); + existingSessionData.untrusted = false; + this.cryptoStore.storeEndToEndInboundGroupSession( + senderKey, sessionId, existingSessionData, txn, + ); + } else { + logger.warn( + "Newly-received megolm session " + sessionId + + " does not match existing session! Keeping existing session", + ); + } + return; + } + // If the sessions have the same index, go ahead and store the new trusted one. } } @@ -1427,13 +1454,23 @@ export class OlmDevice { const claimedKeys = sessionData.keysClaimed || {}; const senderEd25519Key = claimedKeys.ed25519 || null; + const forwardingKeyChain = sessionData.forwardingCurve25519KeyChain || []; + // older forwarded keys didn't set the "untrusted" + // property, but can be identified by having a + // non-empty forwarding key chain. These keys should + // be marked as untrusted since we don't know that they + // can be trusted + const untrusted = "untrusted" in sessionData + ? sessionData.untrusted + : forwardingKeyChain.length > 0; + result = { "chain_index": chainIndex, "key": exportedSession, - "forwarding_curve25519_key_chain": - sessionData.forwardingCurve25519KeyChain || [], + "forwarding_curve25519_key_chain": forwardingKeyChain, "sender_claimed_ed25519_key": senderEd25519Key, "shared_history": sessionData.sharedHistory || false, + "untrusted": untrusted, }; }, ); diff --git a/src/crypto/SecretStorage.ts b/src/crypto/SecretStorage.ts index 0eef2ee7d..9c5a9faf1 100644 --- a/src/crypto/SecretStorage.ts +++ b/src/crypto/SecretStorage.ts @@ -539,7 +539,23 @@ export class SecretStorage { // because someone could be trying to send us bogus data return; } + + if (!olmlib.isOlmEncrypted(event)) { + logger.error("secret event not properly encrypted"); + return; + } + const content = event.getContent(); + + const senderKeyUser = this.baseApis.crypto.deviceList.getUserByIdentityKey( + olmlib.OLM_ALGORITHM, + event.getSenderKey() || "", + ); + if (senderKeyUser !== event.getSender()) { + logger.error("sending device does not belong to the user it claims to be from"); + return; + } + logger.log("got secret share for request", content.request_id); const requestControl = this.requests.get(content.request_id); if (requestControl) { @@ -559,6 +575,14 @@ export class SecretStorage { logger.log("unsolicited secret share from device", deviceInfo.deviceId); return; } + // unsure that the sender is trusted. In theory, this check is + // unnecessary since we only accept secret shares from devices that + // we requested from, but it doesn't hurt. + const deviceTrust = this.baseApis.crypto.checkDeviceInfoTrust(event.getSender(), deviceInfo); + if (!deviceTrust.isVerified()) { + logger.log("secret share from unverified device"); + return; + } logger.log( `Successfully received secret ${requestControl.name} ` + diff --git a/src/crypto/algorithms/base.ts b/src/crypto/algorithms/base.ts index 22bd4505d..070796720 100644 --- a/src/crypto/algorithms/base.ts +++ b/src/crypto/algorithms/base.ts @@ -23,7 +23,7 @@ limitations under the License. import { MatrixClient } from "../../client"; import { Room } from "../../models/room"; import { OlmDevice } from "../OlmDevice"; -import { MatrixEvent, RoomMember } from "../.."; +import { MatrixEvent, RoomMember } from "../../matrix"; import { Crypto, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from ".."; import { DeviceInfo } from "../deviceinfo"; import { IRoomEncryption } from "../RoomList"; @@ -34,7 +34,7 @@ import { IRoomEncryption } from "../RoomList"; * * @type {Object.} */ -export const ENCRYPTION_CLASSES: Record EncryptionAlgorithm> = {}; +export const ENCRYPTION_CLASSES = new Map EncryptionAlgorithm>(); type DecryptionClassParams = Omit; @@ -44,7 +44,7 @@ type DecryptionClassParams = Omit; * * @type {Object.} */ -export const DECRYPTION_CLASSES: Record DecryptionAlgorithm> = {}; +export const DECRYPTION_CLASSES = new Map DecryptionAlgorithm>(); export interface IParams { userId: string; @@ -297,6 +297,6 @@ export function registerAlgorithm( encryptor: new (params: IParams) => EncryptionAlgorithm, decryptor: new (params: Omit) => DecryptionAlgorithm, ): void { - ENCRYPTION_CLASSES[algorithm] = encryptor; - DECRYPTION_CLASSES[algorithm] = decryptor; + ENCRYPTION_CLASSES.set(algorithm, encryptor); + DECRYPTION_CLASSES.set(algorithm, decryptor); } diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts index 1807905ba..a831c5650 100644 --- a/src/crypto/algorithms/megolm.ts +++ b/src/crypto/algorithms/megolm.ts @@ -35,8 +35,10 @@ import { Room } from '../../models/room'; import { DeviceInfo } from "../deviceinfo"; import { IOlmSessionResult } from "../olmlib"; import { DeviceInfoMap } from "../DeviceList"; -import { MatrixEvent } from "../.."; +import { MatrixEvent } from "../../models/event"; import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index"; +import { RoomKeyRequestState } from '../OutgoingRoomKeyRequestManager'; +import { OlmGroupSessionExtraData } from "../../@types/crypto"; // determine whether the key can be shared with invitees export function isRoomSharedHistory(room: Room): boolean { @@ -1189,9 +1191,10 @@ class MegolmEncryption extends EncryptionAlgorithm { * {@link module:crypto/algorithms/DecryptionAlgorithm} */ class MegolmDecryption extends DecryptionAlgorithm { - // events which we couldn't decrypt due to unknown sessions / indexes: map from - // senderKey|sessionId to Set of MatrixEvents - private pendingEvents: Record>> = {}; + // events which we couldn't decrypt due to unknown sessions / + // indexes, or which we could only decrypt with untrusted keys: + // map from senderKey|sessionId to Set of MatrixEvents + private pendingEvents = new Map>>(); // this gets stubbed out by the unit tests. private olmlib = olmlib; @@ -1294,9 +1297,13 @@ class MegolmDecryption extends DecryptionAlgorithm { ); } - // success. We can remove the event from the pending list, if that hasn't - // already happened. - this.removeEventFromPendingList(event); + // Success. We can remove the event from the pending list, if + // that hasn't already happened. However, if the event was + // decrypted with an untrusted key, leave it on the pending + // list so it will be retried if we find a trusted key later. + if (!res.untrusted) { + this.removeEventFromPendingList(event); + } const payload = JSON.parse(res.result); @@ -1343,10 +1350,10 @@ class MegolmDecryption extends DecryptionAlgorithm { const content = event.getWireContent(); const senderKey = content.sender_key; const sessionId = content.session_id; - if (!this.pendingEvents[senderKey]) { - this.pendingEvents[senderKey] = new Map(); + if (!this.pendingEvents.has(senderKey)) { + this.pendingEvents.set(senderKey, new Map>()); } - const senderPendingEvents = this.pendingEvents[senderKey]; + const senderPendingEvents = this.pendingEvents.get(senderKey); if (!senderPendingEvents.has(sessionId)) { senderPendingEvents.set(sessionId, new Set()); } @@ -1364,7 +1371,7 @@ class MegolmDecryption extends DecryptionAlgorithm { const content = event.getWireContent(); const senderKey = content.sender_key; const sessionId = content.session_id; - const senderPendingEvents = this.pendingEvents[senderKey]; + const senderPendingEvents = this.pendingEvents.get(senderKey); const pendingEvents = senderPendingEvents?.get(sessionId); if (!pendingEvents) { return; @@ -1375,7 +1382,7 @@ class MegolmDecryption extends DecryptionAlgorithm { senderPendingEvents.delete(sessionId); } if (senderPendingEvents.size === 0) { - delete this.pendingEvents[senderKey]; + this.pendingEvents.delete(senderKey); } } @@ -1391,6 +1398,8 @@ class MegolmDecryption extends DecryptionAlgorithm { let exportFormat = false; let keysClaimed: ReturnType; + const extraSessionData: OlmGroupSessionExtraData = {}; + if (!content.room_id || !content.session_key || !content.session_id || @@ -1400,12 +1409,59 @@ class MegolmDecryption extends DecryptionAlgorithm { return; } - if (!senderKey) { - logger.error("key event has no sender key (not encrypted?)"); + if (!olmlib.isOlmEncrypted(event)) { + logger.error("key event not properly encrypted"); return; } + if (content["org.matrix.msc3061.shared_history"]) { + extraSessionData.sharedHistory = true; + } + if (event.getType() == "m.forwarded_room_key") { + const deviceInfo = this.crypto.deviceList.getDeviceByIdentityKey( + olmlib.OLM_ALGORITHM, + senderKey, + ); + const senderKeyUser = this.baseApis.crypto.deviceList.getUserByIdentityKey( + olmlib.OLM_ALGORITHM, + senderKey, + ); + if (senderKeyUser !== event.getSender()) { + logger.error("sending device does not belong to the user it claims to be from"); + return; + } + const outgoingRequests = deviceInfo ? await this.crypto.cryptoStore.getOutgoingRoomKeyRequestsByTarget( + event.getSender(), deviceInfo.deviceId, [RoomKeyRequestState.Sent], + ) : []; + const weRequested = outgoingRequests.some((req) => ( + req.requestBody.room_id === content.room_id && req.requestBody.session_id === content.session_id + )); + const room = this.baseApis.getRoom(content.room_id); + const memberEvent = room?.getMember(this.userId)?.events.member; + const fromInviter = memberEvent?.getSender() === event.getSender() || + (memberEvent?.getUnsigned()?.prev_sender === event.getSender() && + memberEvent?.getPrevContent()?.membership === "invite"); + const fromUs = event.getSender() === this.baseApis.getUserId(); + + if (!weRequested && !fromUs) { + // If someone sends us an unsolicited key and they're + // not one of our other devices and it's not shared + // history, ignore it + if (!extraSessionData.sharedHistory) { + logger.log("forwarded key not shared history - ignoring"); + return; + } + + // If someone sends us an unsolicited key for a room + // we're already in, and they're not one of our other + // devices or the one who invited us, ignore it + if (room && !fromInviter) { + logger.log("forwarded key not from inviter or from us - ignoring"); + return; + } + } + exportFormat = true; forwardingKeyChain = Array.isArray(content.forwarding_curve25519_key_chain) ? content.forwarding_curve25519_key_chain : []; @@ -1418,7 +1474,6 @@ class MegolmDecryption extends DecryptionAlgorithm { logger.error("forwarded_room_key event is missing sender_key field"); return; } - senderKey = content.sender_key; const ed25519Key = content.sender_claimed_ed25519_key; if (!ed25519Key) { @@ -1431,11 +1486,45 @@ class MegolmDecryption extends DecryptionAlgorithm { keysClaimed = { ed25519: ed25519Key, }; + + // If this is a key for a room we're not in, don't load it + // yet, just park it in case *this sender* invites us to + // that room later + if (!room) { + const parkedData = { + senderId: event.getSender(), + senderKey: content.sender_key, + sessionId: content.session_id, + sessionKey: content.session_key, + keysClaimed, + forwardingCurve25519KeyChain: forwardingKeyChain, + }; + await this.crypto.cryptoStore.doTxn( + 'readwrite', + ['parked_shared_history'], + (txn) => this.crypto.cryptoStore.addParkedSharedHistory(content.room_id, parkedData, txn), + logger.withPrefix("[addParkedSharedHistory]"), + ); + return; + } + + const sendingDevice = this.crypto.deviceList.getDeviceByIdentityKey(olmlib.OLM_ALGORITHM, senderKey); + const deviceTrust = this.crypto.checkDeviceInfoTrust(event.getSender(), sendingDevice); + + if (fromUs && !deviceTrust.isVerified()) { + return; + } + + // forwarded keys are always untrusted + extraSessionData.untrusted = true; + + // replace the sender key with the sender key of the session + // creator for storage + senderKey = content.sender_key; } else { keysClaimed = event.getKeysClaimed(); } - const extraSessionData: any = {}; if (content["org.matrix.msc3061.shared_history"]) { extraSessionData.sharedHistory = true; } @@ -1453,7 +1542,7 @@ class MegolmDecryption extends DecryptionAlgorithm { ); // have another go at decrypting events sent with this session. - if (await this.retryDecryption(senderKey, content.session_id)) { + if (await this.retryDecryption(senderKey, content.session_id, !extraSessionData.untrusted)) { // cancel any outstanding room key requests for this session. // Only do this if we managed to decrypt every message in the // session, because if we didn't, we leave the other key @@ -1668,7 +1757,7 @@ class MegolmDecryption extends DecryptionAlgorithm { session: IMegolmSessionData, opts: { untrusted?: boolean, source?: string } = {}, ): Promise { - const extraSessionData: any = {}; + const extraSessionData: OlmGroupSessionExtraData = {}; if (opts.untrusted || session.untrusted) { extraSessionData.untrusted = true; } @@ -1696,7 +1785,7 @@ class MegolmDecryption extends DecryptionAlgorithm { }); } // have another go at decrypting events sent with this session. - this.retryDecryption(session.sender_key, session.session_id); + this.retryDecryption(session.sender_key, session.session_id, !extraSessionData.untrusted); }); } @@ -1707,11 +1796,18 @@ class MegolmDecryption extends DecryptionAlgorithm { * @private * @param {String} senderKey * @param {String} sessionId + * @param {Boolean} forceRedecryptIfUntrusted whether messages that were already + * successfully decrypted using untrusted keys should be re-decrypted * - * @return {Boolean} whether all messages were successfully decrypted + * @return {Boolean} whether all messages were successfully + * decrypted with trusted keys */ - private async retryDecryption(senderKey: string, sessionId: string): Promise { - const senderPendingEvents = this.pendingEvents[senderKey]; + private async retryDecryption( + senderKey: string, + sessionId: string, + forceRedecryptIfUntrusted?: boolean, + ): Promise { + const senderPendingEvents = this.pendingEvents.get(senderKey); if (!senderPendingEvents) { return true; } @@ -1725,23 +1821,24 @@ class MegolmDecryption extends DecryptionAlgorithm { await Promise.all([...pending].map(async (ev) => { try { - await ev.attemptDecryption(this.crypto, { isRetry: true }); + await ev.attemptDecryption(this.crypto, { isRetry: true, forceRedecryptIfUntrusted }); } catch (e) { // don't die if something goes wrong } })); - // If decrypted successfully, they'll have been removed from pendingEvents - return !this.pendingEvents[senderKey]?.has(sessionId); + // If decrypted successfully with trusted keys, they'll have + // been removed from pendingEvents + return !this.pendingEvents.get(senderKey)?.has(sessionId); } public async retryDecryptionFromSender(senderKey: string): Promise { - const senderPendingEvents = this.pendingEvents[senderKey]; + const senderPendingEvents = this.pendingEvents.get(senderKey); if (!senderPendingEvents) { return true; } - delete this.pendingEvents[senderKey]; + this.pendingEvents.delete(senderKey); await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => { await Promise.all([...pending].map(async (ev) => { @@ -1753,7 +1850,7 @@ class MegolmDecryption extends DecryptionAlgorithm { })); })); - return !this.pendingEvents[senderKey]; + return !this.pendingEvents.has(senderKey); } public async sendSharedHistoryInboundSessions(devicesByUser: Record): Promise { diff --git a/src/crypto/algorithms/olm.ts b/src/crypto/algorithms/olm.ts index aec39d49e..14ee7516a 100644 --- a/src/crypto/algorithms/olm.ts +++ b/src/crypto/algorithms/olm.ts @@ -30,7 +30,7 @@ import { registerAlgorithm, } from "./base"; import { Room } from '../../models/room'; -import { MatrixEvent } from "../.."; +import { MatrixEvent } from "../../models/event"; import { IEventDecryptionResult } from "../index"; import { IInboundSession } from "../OlmDevice"; @@ -222,6 +222,26 @@ class OlmDecryption extends DecryptionAlgorithm { ); } + // check that the device that encrypted the event belongs to the user + // that the event claims it's from. We need to make sure that our + // device list is up-to-date. If the device is unknown, we can only + // assume that the device logged out. Some event handlers, such as + // secret sharing, may be more strict and reject events that come from + // unknown devices. + await this.crypto.deviceList.downloadKeys([event.getSender()], false); + const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey( + olmlib.OLM_ALGORITHM, + deviceKey, + ); + if (senderKeyUser !== event.getSender() && senderKeyUser !== undefined) { + throw new DecryptionError( + "OLM_BAD_SENDER", + "Message claimed to be from " + event.getSender(), { + real_sender: senderKeyUser, + }, + ); + } + // check that the original sender matches what the homeserver told us, to // avoid people masquerading as others. // (this check is also provided via the sender's embedded ed25519 key, diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index 2f0386582..c6c4d66e2 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -431,7 +431,6 @@ export class BackupManager { ) ); }); - ret.usable = ret.usable || ret.trusted_locally; return ret; } diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 451d2d8c8..7c0092bf6 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -278,9 +278,9 @@ export class Crypto extends TypedEventEmitter = {}; + private roomEncryptors = new Map(); // map from algorithm to DecryptionAlgorithm instance, for each room - private roomDecryptors: Record> = {}; + private roomDecryptors = new Map>(); private deviceKeys: Record = {}; // type: key @@ -422,7 +422,7 @@ export class Crypto extends TypedEventEmitter} keys The list of keys that was present + * during the device verification. This will be double checked with the list + * of keys the given device has currently. + * * @return {Promise} updated DeviceInfo */ public async setDeviceVerification( @@ -2113,6 +2117,7 @@ export class Crypto extends TypedEventEmitter, ): Promise { // get rid of any `undefined`s here so we can just check // for null rather than null or undefined @@ -2131,6 +2136,10 @@ export class Crypto extends TypedEventEmitter 0) { - // we got the key this event from somewhere else - // TODO: check if we can trust the forwarders. - return null; - } - if (event.isKeySourceUntrusted()) { // we got the key for this event from a source that we consider untrusted return null; @@ -2478,8 +2487,7 @@ export class Crypto extends TypedEventEmitter 0 || event.isKeySourceUntrusted()) { + if (event.isKeySourceUntrusted()) { // we got the key this event from somewhere else // TODO: check if we can trust the forwarders. ret.authenticated = false; @@ -2527,7 +2535,7 @@ export class Crypto extends TypedEventEmitter { const trackMembers = async () => { // not an encrypted room - if (!this.roomEncryptors[roomId]) { + if (!this.roomEncryptors.has(roomId)) { return; } const room = this.clientStore.getRoom(roomId); @@ -2785,7 +2793,7 @@ export class Crypto extends TypedEventEmitter { // check for rooms with encryption enabled - const alg = this.roomEncryptors[room.roomId]; + const alg = this.roomEncryptors.get(room.roomId); if (!alg) { return false; } @@ -3533,7 +3541,7 @@ export class Crypto extends TypedEventEmitter; + let decryptors: Map; let alg: DecryptionAlgorithm; roomId = roomId || null; if (roomId) { - decryptors = this.roomDecryptors[roomId]; + decryptors = this.roomDecryptors.get(roomId); if (!decryptors) { - this.roomDecryptors[roomId] = decryptors = {}; + decryptors = new Map(); + this.roomDecryptors.set(roomId, decryptors); } - alg = decryptors[algorithm]; + alg = decryptors.get(algorithm); if (alg) { return alg; } } - const AlgClass = algorithms.DECRYPTION_CLASSES[algorithm]; + const AlgClass = algorithms.DECRYPTION_CLASSES.get(algorithm); if (!AlgClass) { throw new algorithms.DecryptionError( 'UNKNOWN_ENCRYPTION_ALGORITHM', @@ -3777,7 +3786,7 @@ export class Crypto extends TypedEventEmitter; + addParkedSharedHistory(roomId: string, data: ParkedSharedHistory, txn?: unknown): void; + takeParkedSharedHistory(roomId: string, txn?: unknown): Promise; // Session key backups doTxn(mode: Mode, stores: Iterable, func: (txn: unknown) => T, log?: PrefixedLogger): Promise; @@ -203,3 +206,12 @@ export interface OutgoingRoomKeyRequest { requestBody: IRoomKeyRequestBody; state: RoomKeyRequestState; } + +export interface ParkedSharedHistory { + senderId: string; + senderKey: string; + sessionId: string; + sessionKey: string; + keysClaimed: ReturnType; // XXX: Less type dependence on MatrixEvent + forwardingCurve25519KeyChain: string[]; +} diff --git a/src/crypto/store/indexeddb-crypto-store-backend.ts b/src/crypto/store/indexeddb-crypto-store-backend.ts index 6666fcf01..51cd62a1e 100644 --- a/src/crypto/store/indexeddb-crypto-store-backend.ts +++ b/src/crypto/store/indexeddb-crypto-store-backend.ts @@ -25,15 +25,15 @@ import { IWithheld, Mode, OutgoingRoomKeyRequest, + ParkedSharedHistory, } from "./base"; -import { IRoomKeyRequestBody } from "../index"; +import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index"; import { ICrossSigningKey } from "../../client"; import { IOlmDevice } from "../algorithms/megolm"; import { IRoomEncryption } from "../RoomList"; import { InboundGroupSessionData } from "../OlmDevice"; import { IEncryptedPayload } from "../aes"; -export const VERSION = 10; const PROFILE_TRANSACTIONS = false; /** @@ -261,7 +261,9 @@ export class Backend implements CryptoStore { const cursor = this.result; if (cursor) { const keyReq = cursor.value; - if (keyReq.recipients.includes({ userId, deviceId })) { + if (keyReq.recipients.some((recipient: IRoomKeyRequestRecipient) => + recipient.userId === userId && recipient.deviceId === deviceId, + )) { results.push(keyReq); } cursor.continue(); @@ -871,6 +873,50 @@ export class Backend implements CryptoStore { }); } + public addParkedSharedHistory( + roomId: string, + parkedData: ParkedSharedHistory, + txn?: IDBTransaction, + ): void { + if (!txn) { + txn = this.db.transaction( + "parked_shared_history", "readwrite", + ); + } + const objectStore = txn.objectStore("parked_shared_history"); + const req = objectStore.get([roomId]); + req.onsuccess = () => { + const { parked } = req.result || { parked: [] }; + parked.push(parkedData); + objectStore.put({ roomId, parked }); + }; + } + + public takeParkedSharedHistory( + roomId: string, + txn?: IDBTransaction, + ): Promise { + if (!txn) { + txn = this.db.transaction( + "parked_shared_history", "readwrite", + ); + } + const cursorReq = txn.objectStore("parked_shared_history").openCursor(roomId); + return new Promise((resolve, reject) => { + cursorReq.onsuccess = () => { + const cursor = cursorReq.result; + if (!cursor) { + resolve([]); + return; + } + const data = cursor.value; + cursor.delete(); + resolve(data); + }; + cursorReq.onerror = reject; + }); + } + public doTxn( mode: Mode, stores: string | string[], @@ -903,45 +949,34 @@ export class Backend implements CryptoStore { } } -export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void { - logger.log( - `Upgrading IndexedDBCryptoStore from version ${oldVersion}` - + ` to ${VERSION}`, - ); - if (oldVersion < 1) { // The database did not previously exist. - createDatabase(db); - } - if (oldVersion < 2) { - db.createObjectStore("account"); - } - if (oldVersion < 3) { +type DbMigration = (db: IDBDatabase) => void; +const DB_MIGRATIONS: DbMigration[] = [ + (db) => { createDatabase(db); }, + (db) => { db.createObjectStore("account"); }, + (db) => { const sessionsStore = db.createObjectStore("sessions", { keyPath: ["deviceKey", "sessionId"], }); sessionsStore.createIndex("deviceKey", "deviceKey"); - } - if (oldVersion < 4) { + }, + (db) => { db.createObjectStore("inbound_group_sessions", { keyPath: ["senderCurve25519Key", "sessionId"], }); - } - if (oldVersion < 5) { - db.createObjectStore("device_data"); - } - if (oldVersion < 6) { - db.createObjectStore("rooms"); - } - if (oldVersion < 7) { + }, + (db) => { db.createObjectStore("device_data"); }, + (db) => { db.createObjectStore("rooms"); }, + (db) => { db.createObjectStore("sessions_needing_backup", { keyPath: ["senderCurve25519Key", "sessionId"], }); - } - if (oldVersion < 8) { + }, + (db) => { db.createObjectStore("inbound_group_sessions_withheld", { keyPath: ["senderCurve25519Key", "sessionId"], }); - } - if (oldVersion < 9) { + }, + (db) => { const problemsStore = db.createObjectStore("session_problems", { keyPath: ["deviceKey", "time"], }); @@ -950,13 +985,29 @@ export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void { db.createObjectStore("notified_error_devices", { keyPath: ["userId", "deviceId"], }); - } - if (oldVersion < 10) { + }, + (db) => { db.createObjectStore("shared_history_inbound_group_sessions", { keyPath: ["roomId"], }); - } + }, + (db) => { + db.createObjectStore("parked_shared_history", { + keyPath: ["roomId"], + }); + }, // Expand as needed. +]; +export const VERSION = DB_MIGRATIONS.length; + +export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void { + logger.log( + `Upgrading IndexedDBCryptoStore from version ${oldVersion}` + + ` to ${VERSION}`, + ); + DB_MIGRATIONS.forEach((migration, index) => { + if (oldVersion <= index) migration(db); + }); } function createDatabase(db: IDBDatabase): void { diff --git a/src/crypto/store/indexeddb-crypto-store.ts b/src/crypto/store/indexeddb-crypto-store.ts index ecc3d86c3..21f704fd8 100644 --- a/src/crypto/store/indexeddb-crypto-store.ts +++ b/src/crypto/store/indexeddb-crypto-store.ts @@ -29,6 +29,7 @@ import { IWithheld, Mode, OutgoingRoomKeyRequest, + ParkedSharedHistory, } from "./base"; import { IRoomKeyRequestBody } from "../index"; import { ICrossSigningKey } from "../../client"; @@ -55,6 +56,7 @@ export class IndexedDBCryptoStore implements CryptoStore { public static STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions'; public static STORE_INBOUND_GROUP_SESSIONS_WITHHELD = 'inbound_group_sessions_withheld'; public static STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS = 'shared_history_inbound_group_sessions'; + public static STORE_PARKED_SHARED_HISTORY = 'parked_shared_history'; public static STORE_DEVICE_DATA = 'device_data'; public static STORE_ROOMS = 'rooms'; public static STORE_BACKUP = 'sessions_needing_backup'; @@ -669,6 +671,27 @@ export class IndexedDBCryptoStore implements CryptoStore { return this.backend.getSharedHistoryInboundGroupSessions(roomId, txn); } + /** + * Park a shared-history group session for a room we may be invited to later. + */ + public addParkedSharedHistory( + roomId: string, + parkedData: ParkedSharedHistory, + txn?: IDBTransaction, + ): void { + this.backend.addParkedSharedHistory(roomId, parkedData, txn); + } + + /** + * Pop out all shared-history group sessions for a room. + */ + public takeParkedSharedHistory( + roomId: string, + txn?: IDBTransaction, + ): Promise { + return this.backend.takeParkedSharedHistory(roomId, txn); + } + /** * Perform a transaction on the crypto store. Any store methods * that require a transaction (txn) object to be passed in may diff --git a/src/crypto/store/memory-crypto-store.ts b/src/crypto/store/memory-crypto-store.ts index 2e441908e..a0195bb44 100644 --- a/src/crypto/store/memory-crypto-store.ts +++ b/src/crypto/store/memory-crypto-store.ts @@ -25,6 +25,7 @@ import { IWithheld, Mode, OutgoingRoomKeyRequest, + ParkedSharedHistory, } from "./base"; import { IRoomKeyRequestBody } from "../index"; import { ICrossSigningKey } from "../../client"; @@ -58,6 +59,7 @@ export class MemoryCryptoStore implements CryptoStore { private rooms: { [roomId: string]: IRoomEncryption } = {}; private sessionsNeedingBackup: { [sessionKey: string]: boolean } = {}; private sharedHistoryInboundGroupSessions: { [roomId: string]: [senderKey: string, sessionId: string][] } = {}; + private parkedSharedHistory = new Map(); // keyed by room ID /** * Ensure the database exists and is up-to-date. @@ -191,11 +193,13 @@ export class MemoryCryptoStore implements CryptoStore { deviceId: string, wantedStates: number[], ): Promise { - const results = []; + const results: OutgoingRoomKeyRequest[] = []; for (const req of this.outgoingRoomKeyRequests) { for (const state of wantedStates) { - if (req.state === state && req.recipients.includes({ userId, deviceId })) { + if (req.state === state && req.recipients.some( + (recipient) => recipient.userId === userId && recipient.deviceId === deviceId, + )) { results.push(req); } } @@ -524,6 +528,18 @@ export class MemoryCryptoStore implements CryptoStore { return Promise.resolve(this.sharedHistoryInboundGroupSessions[roomId] || []); } + public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory): void { + const parked = this.parkedSharedHistory.get(roomId) ?? []; + parked.push(parkedData); + this.parkedSharedHistory.set(roomId, parked); + } + + public takeParkedSharedHistory(roomId: string): Promise { + const parked = this.parkedSharedHistory.get(roomId) ?? []; + this.parkedSharedHistory.delete(roomId); + return Promise.resolve(parked); + } + // Session key backups public doTxn(mode: Mode, stores: Iterable, func: (txn?: unknown) => T): Promise { diff --git a/src/crypto/verification/Base.ts b/src/crypto/verification/Base.ts index 351004990..ddf38f8ce 100644 --- a/src/crypto/verification/Base.ts +++ b/src/crypto/verification/Base.ts @@ -299,7 +299,13 @@ export class VerificationBase< if (this.doVerification && !this.started) { this.started = true; this.resetTimer(); // restart the timeout - Promise.resolve(this.doVerification()).then(this.done.bind(this), this.cancel.bind(this)); + new Promise((resolve, reject) => { + const crossSignId = this.baseApis.crypto.deviceList.getStoredCrossSigningForUser(this.userId)?.getId(); + if (crossSignId === this.deviceId) { + reject(new Error("Device ID is the same as the cross-signing ID")); + } + resolve(); + }).then(() => this.doVerification()).then(this.done.bind(this), this.cancel.bind(this)); } return this.promise; } @@ -310,14 +316,14 @@ export class VerificationBase< // we try to verify all the keys that we're told about, but we might // not know about all of them, so keep track of the keys that we know // about, and ignore the rest - const verifiedDevices = []; + const verifiedDevices: [string, string, string][] = []; for (const [keyId, keyInfo] of Object.entries(keys)) { const deviceId = keyId.split(':', 2)[1]; const device = this.baseApis.getStoredDevice(userId, deviceId); if (device) { verifier(keyId, device, keyInfo); - verifiedDevices.push(deviceId); + verifiedDevices.push([deviceId, keyId, device.keys[keyId]]); } else { const crossSigningInfo = this.baseApis.crypto.deviceList.getStoredCrossSigningForUser(userId); if (crossSigningInfo && crossSigningInfo.getId() === deviceId) { @@ -326,7 +332,7 @@ export class VerificationBase< [keyId]: deviceId, }, }, deviceId), keyInfo); - verifiedDevices.push(deviceId); + verifiedDevices.push([deviceId, keyId, deviceId]); } else { logger.warn( `verification: Could not find device ${deviceId} to verify`, @@ -348,8 +354,15 @@ export class VerificationBase< // TODO: There should probably be a batch version of this, otherwise it's going // to upload each signature in a separate API call which is silly because the // API supports as many signatures as you like. - for (const deviceId of verifiedDevices) { - await this.baseApis.setDeviceVerified(userId, deviceId); + for (const [deviceId, keyId, key] of verifiedDevices) { + await this.baseApis.crypto.setDeviceVerification(userId, deviceId, true, null, null, { [keyId]: key }); + } + + // if one of the user's own devices is being marked as verified / unverified, + // check the key backup status, since whether or not we use this depends on + // whether it has a signature from a verified device + if (userId == this.baseApis.credentials.userId) { + await this.baseApis.checkKeyBackup(); } } diff --git a/src/event-mapper.ts b/src/event-mapper.ts index 40ef5a824..38ee4267e 100644 --- a/src/event-mapper.ts +++ b/src/event-mapper.ts @@ -22,6 +22,7 @@ export type EventMapper = (obj: Partial) => MatrixEvent; export interface MapperOpts { preventReEmit?: boolean; decrypt?: boolean; + toDevice?: boolean; } export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper { @@ -29,6 +30,10 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event const decrypt = options.decrypt !== false; function mapper(plainOldJsObject: Partial) { + if (options.toDevice) { + delete plainOldJsObject.room_id; + } + const room = client.getRoom(plainOldJsObject.room_id); let event: MatrixEvent; diff --git a/src/feature.ts b/src/feature.ts new file mode 100644 index 000000000..5d7a49224 --- /dev/null +++ b/src/feature.ts @@ -0,0 +1,62 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IServerVersions } from "./client"; + +export enum ServerSupport { + Stable, + Unstable, + Unsupported +} + +export enum Feature { + Thread = "Thread", + ThreadUnreadNotifications = "ThreadUnreadNotifications", +} + +type FeatureSupportCondition = { + unstablePrefixes?: string[]; + matrixVersion?: string; +}; + +const featureSupportResolver: Record = { + [Feature.Thread]: { + unstablePrefixes: ["org.matrix.msc3440"], + matrixVersion: "v1.3", + }, + [Feature.ThreadUnreadNotifications]: { + unstablePrefixes: ["org.matrix.msc3771", "org.matrix.msc3773"], + matrixVersion: "v1.4", + }, +}; + +export async function buildFeatureSupportMap(versions: IServerVersions): Promise> { + const supportMap = new Map(); + for (const [feature, supportCondition] of Object.entries(featureSupportResolver)) { + const supportMatrixVersion = versions.versions?.includes(supportCondition.matrixVersion || "") ?? false; + const supportUnstablePrefixes = supportCondition.unstablePrefixes?.every(unstablePrefix => { + return versions.unstable_features?.[unstablePrefix] === true; + }) ?? false; + if (supportMatrixVersion) { + supportMap.set(feature as Feature, ServerSupport.Stable); + } else if (supportUnstablePrefixes) { + supportMap.set(feature as Feature, ServerSupport.Unstable); + } else { + supportMap.set(feature as Feature, ServerSupport.Unsupported); + } + } + return supportMap; +} diff --git a/src/filter-component.ts b/src/filter-component.ts index 8cfbea667..5e38238c6 100644 --- a/src/filter-component.ts +++ b/src/filter-component.ts @@ -73,7 +73,7 @@ export interface IFilterComponent { * @param {Object} filterJson the definition of this filter JSON, e.g. { 'contains_url': true } */ export class FilterComponent { - constructor(private filterJson: IFilterComponent, public readonly userId?: string) {} + constructor(private filterJson: IFilterComponent, public readonly userId?: string | undefined | null) {} /** * Checks with the filter component matches the given event diff --git a/src/filter.ts b/src/filter.ts index 663ba1bb9..14565c26f 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -22,6 +22,7 @@ import { EventType, RelationType, } from "./@types/event"; +import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync"; import { FilterComponent, IFilterComponent } from "./filter-component"; import { MatrixEvent } from "./models/event"; @@ -57,6 +58,8 @@ export interface IRoomEventFilter extends IFilterComponent { types?: Array; related_by_senders?: Array; related_by_rel_types?: string[]; + unread_thread_notifications?: boolean; + "org.matrix.msc3773.unread_thread_notifications"?: boolean; // Unstable values "io.element.relation_senders"?: Array; @@ -97,7 +100,7 @@ export class Filter { * @param {Object} jsonObj * @return {Filter} */ - public static fromJson(userId: string, filterId: string, jsonObj: IFilterDefinition): Filter { + public static fromJson(userId: string | undefined | null, filterId: string, jsonObj: IFilterDefinition): Filter { const filter = new Filter(userId, filterId); filter.setDefinition(jsonObj); return filter; @@ -107,7 +110,7 @@ export class Filter { private roomFilter: FilterComponent; private roomTimelineFilter: FilterComponent; - constructor(public readonly userId: string, public filterId?: string) {} + constructor(public readonly userId: string | undefined | null, public filterId?: string) {} /** * Get the ID of this filter on your homeserver (if known) @@ -220,7 +223,24 @@ export class Filter { setProp(this.definition, "room.timeline.limit", limit); } - setLazyLoadMembers(enabled: boolean) { + /** + * Enable threads unread notification + * @param {boolean} enabled + */ + public setUnreadThreadNotifications(enabled: boolean): void { + this.definition = { + ...this.definition, + room: { + ...this.definition?.room, + timeline: { + ...this.definition?.room?.timeline, + [UNREAD_THREAD_NOTIFICATIONS.name]: !!enabled, + }, + }, + }; + } + + setLazyLoadMembers(enabled: boolean): void { setProp(this.definition, "room.state.lazy_load_members", !!enabled); } diff --git a/src/http-api.ts b/src/http-api.ts index 3e601c795..1a7376b00 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -365,7 +365,7 @@ export class MatrixHttpApi { // we're setting opts.json=false so that it doesn't JSON-encode the // request, which also means it doesn't JSON-decode the response. Either // way, we have to JSON-parse the response ourselves. - let bodyParser = null; + let bodyParser: ((body: string) => any) | undefined; if (!rawResponse) { bodyParser = function(rawBody: string) { let body = JSON.parse(rawBody); @@ -472,7 +472,7 @@ export class MatrixHttpApi { headers["Content-Length"] = "0"; } - promise = this.authedRequest( + promise = this.authedRequest>( opts.callback, Method.Post, "/upload", queryParams, body, { prefix: "/_matrix/media/r0", headers, @@ -590,10 +590,10 @@ export class MatrixHttpApi { * occurred. This includes network problems and Matrix-specific error JSON. */ public authedRequest = IRequestOpts>( - callback: Callback, + callback: Callback | undefined, method: Method, path: string, - queryParams?: Record, + queryParams?: Record, data?: CoreOptions["body"], opts?: O | number, // number is legacy ): IAbortablePromise> { @@ -667,7 +667,7 @@ export class MatrixHttpApi { * occurred. This includes network problems and Matrix-specific error JSON. */ public request = IRequestOpts>( - callback: Callback, + callback: Callback | undefined, method: Method, path: string, queryParams?: CoreOptions["qs"], @@ -711,7 +711,7 @@ export class MatrixHttpApi { * occurred. This includes network problems and Matrix-specific error JSON. */ public requestOtherUrl = IRequestOpts>( - callback: Callback, + callback: Callback | undefined, method: Method, uri: string, queryParams?: CoreOptions["qs"], @@ -778,7 +778,7 @@ export class MatrixHttpApi { * Generic O should be inferred */ private doRequest = IRequestOpts>( - callback: Callback, + callback: Callback | undefined, method: Method, uri: string, queryParams?: Record, diff --git a/src/models/MSC3089Branch.ts b/src/models/MSC3089Branch.ts index 0c2082a2a..a230a4f1a 100644 --- a/src/models/MSC3089Branch.ts +++ b/src/models/MSC3089Branch.ts @@ -20,7 +20,7 @@ import { IContent, MatrixEvent } from "./event"; import { MSC3089TreeSpace } from "./MSC3089TreeSpace"; import { EventTimeline } from "./event-timeline"; import { FileType } from "../http-api"; -import type { ISendEventResponse } from ".."; +import type { ISendEventResponse } from "../@types/requests"; /** * Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) branch - a reference diff --git a/src/models/beacon.ts b/src/models/beacon.ts index 25abf2842..92e950797 100644 --- a/src/models/beacon.ts +++ b/src/models/beacon.ts @@ -15,7 +15,6 @@ limitations under the License. */ import { MBeaconEventContent } from "../@types/beacon"; -import { M_TIMESTAMP } from "../@types/location"; import { BeaconInfoState, BeaconLocationState, parseBeaconContent, parseBeaconInfoContent } from "../content-helpers"; import { MatrixEvent } from "../matrix"; import { sortEventsByLatestContentTimestamp } from "../utils"; @@ -161,7 +160,9 @@ export class Beacon extends TypedEventEmitter { const content = event.getContent(); - const timestamp = M_TIMESTAMP.findIn(content); + const parsed = parseBeaconContent(content); + if (!parsed.uri || !parsed.timestamp) return false; // we won't be able to process these + const { timestamp } = parsed; return ( // only include positions that were taken inside the beacon's live period isTimestampInDuration(this._beaconInfo.timestamp, this._beaconInfo.timeout, timestamp) && diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index 6341e4820..e7d687425 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -86,7 +86,7 @@ export class EventTimelineSet extends TypedEventEmitter; + private _eventIdToTimeline = new Map(); private filter?: Filter; /** @@ -123,12 +123,15 @@ export class EventTimelineSet extends TypedEventEmitter(); this.filter = opts.filter; @@ -210,7 +213,7 @@ export class EventTimelineSet extends TypedEventEmitter(); } else { this.timelines.push(newTimeline); } @@ -287,8 +290,9 @@ export class EventTimelineSet extends TypedEventEmitter> = { + public paginationRequests: Record | null> = { [Direction.Backward]: null, [Direction.Forward]: null, }; @@ -307,11 +307,11 @@ export class EventTimeline { * * @param {?string} token pagination token * - * @param {string} direction EventTimeline.BACKWARDS to set the pagination + * @param {string} direction EventTimeline.BACKWARDS to set the paginatio * token for going backwards in time; EventTimeline.FORWARDS to set the * pagination token for going forwards in time. */ - public setPaginationToken(token: string, direction: Direction): void { + public setPaginationToken(token: string | null, direction: Direction): void { this.getState(direction).paginationToken = token; } diff --git a/src/models/event.ts b/src/models/event.ts index 0ce12e068..406aded0b 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -26,7 +26,7 @@ import { logger } from '../logger'; import { VerificationRequest } from "../crypto/verification/request/VerificationRequest"; import { EVENT_VISIBILITY_CHANGE_TYPE, EventType, MsgType, RelationType } from "../@types/event"; import { Crypto, IEventDecryptionResult } from "../crypto"; -import { deepSortedObjectEntries } from "../utils"; +import { deepSortedObjectEntries, internaliseString } from "../utils"; import { RoomMember } from "./room-member"; import { Thread, ThreadEvent, EventHandlerMap as ThreadEventHandlerMap, THREAD_RELATION_TYPE } from "./thread"; import { IActionsObject } from '../pushprocessor'; @@ -37,14 +37,6 @@ import { EventStatus } from "./event-status"; export { EventStatus } from "./event-status"; -const interns: Record = {}; -function intern(str: string): string { - if (!interns[str]) { - interns[str] = str; - } - return interns[str]; -} - /* eslint-disable camelcase */ export interface IContent { [key: string]: any; @@ -159,6 +151,7 @@ interface IKeyRequestRecipient { export interface IDecryptOptions { emit?: boolean; isRetry?: boolean; + forceRedecryptIfUntrusted?: boolean; } /** @@ -326,17 +319,17 @@ export class MatrixEvent extends TypedEventEmitter { if (typeof event[prop] !== "string") return; - event[prop] = intern(event[prop]); + event[prop] = internaliseString(event[prop]); }); ["membership", "avatar_url", "displayname"].forEach((prop) => { if (typeof event.content?.[prop] !== "string") return; - event.content[prop] = intern(event.content[prop]); + event.content[prop] = internaliseString(event.content[prop]); }); ["rel_type"].forEach((prop) => { if (typeof event.content?.["m.relates_to"]?.[prop] !== "string") return; - event.content["m.relates_to"][prop] = intern(event.content["m.relates_to"][prop]); + event.content["m.relates_to"][prop] = internaliseString(event.content["m.relates_to"][prop]); }); this.txnId = event.txn_id || null; @@ -685,6 +678,8 @@ export class MatrixEvent extends TypedEventEmitter { + const target = await this.getOrCreateTargetRoom(); + const response = await this.client.sendStateEvent(target.roomId, scope, { + entity, + reason, + recommendation: PolicyRecommendation.Ban, + }); + return response.event_id; + } + + /** + * Remove a rule. + */ + public async removeRule(event: MatrixEvent) { + await this.client.redactEvent(event.getRoomId()!, event.getId()!); + } + + /** + * Add a new room to the list of sources. If the user isn't a member of the + * room, attempt to join it. + * + * @param roomId A valid room id. If this room is already in the list + * of sources, it will not be duplicated. + * @return `true` if the source was added, `false` if it was already present. + * @throws If `roomId` isn't the id of a room that the current user is already + * member of or can join. + * + * # Safety + * + * This method will rewrite the `Policies` object in the user's account data. + * This rewrite is inherently racy and could overwrite or be overwritten by + * other concurrent rewrites of the same object. + */ + public async addSource(roomId: string): Promise { + // We attempt to join the room *before* calling + // `await this.getOrCreateSourceRooms()` to decrease the duration + // of the racy section. + await this.client.joinRoom(roomId); + // Race starts. + const sources = (await this.getOrCreateSourceRooms()) + .map(room => room.roomId); + if (sources.includes(roomId)) { + return false; + } + sources.push(roomId); + await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => { + ignoreInvitesPolicies.sources = sources; + }); + + // Race ends. + return true; + } + + /** + * Find out whether an invite should be ignored. + * + * @param sender The user id for the user who issued the invite. + * @param roomId The room to which the user is invited. + * @returns A rule matching the entity, if any was found, `null` otherwise. + */ + public async getRuleForInvite({ sender, roomId }: { + sender: string; + roomId: string; + }): Promise> { + // In this implementation, we perform a very naive lookup: + // - search in each policy room; + // - turn each (potentially glob) rule entity into a regexp. + // + // Real-world testing will tell us whether this is performant enough. + // In the (unfortunately likely) case it isn't, there are several manners + // in which we could optimize this: + // - match several entities per go; + // - pre-compile each rule entity into a regexp; + // - pre-compile entire rooms into a single regexp. + const policyRooms = await this.getOrCreateSourceRooms(); + const senderServer = sender.split(":")[1]; + const roomServer = roomId.split(":")[1]; + for (const room of policyRooms) { + const state = room.getUnfilteredTimelineSet().getLiveTimeline().getState(EventTimeline.FORWARDS); + + for (const { scope, entities } of [ + { scope: PolicyScope.Room, entities: [roomId] }, + { scope: PolicyScope.User, entities: [sender] }, + { scope: PolicyScope.Server, entities: [senderServer, roomServer] }, + ]) { + const events = state.getStateEvents(scope); + for (const event of events) { + const content = event.getContent(); + if (content?.recommendation != PolicyRecommendation.Ban) { + // Ignoring invites only looks at `m.ban` recommendations. + continue; + } + const glob = content?.entity; + if (!glob) { + // Invalid event. + continue; + } + let regexp: RegExp; + try { + regexp = new RegExp(globToRegexp(glob, false)); + } catch (ex) { + // Assume invalid event. + continue; + } + for (const entity of entities) { + if (entity && regexp.test(entity)) { + return event; + } + } + // No match. + } + } + } + return null; + } + + /** + * Get the target room, i.e. the room in which any new rule should be written. + * + * If there is no target room setup, a target room is created. + * + * Note: This method is public for testing reasons. Most clients should not need + * to call it directly. + * + * # Safety + * + * This method will rewrite the `Policies` object in the user's account data. + * This rewrite is inherently racy and could overwrite or be overwritten by + * other concurrent rewrites of the same object. + */ + public async getOrCreateTargetRoom(): Promise { + const ignoreInvitesPolicies = this.getIgnoreInvitesPolicies(); + let target = ignoreInvitesPolicies.target; + // Validate `target`. If it is invalid, trash out the current `target` + // and create a new room. + if (typeof target !== "string") { + target = null; + } + if (target) { + // Check that the room exists and is valid. + const room = this.client.getRoom(target); + if (room) { + return room; + } else { + target = null; + } + } + // We need to create our own policy room for ignoring invites. + target = (await this.client.createRoom({ + name: "Individual Policy Room", + preset: Preset.PrivateChat, + })).room_id; + await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => { + ignoreInvitesPolicies.target = target; + }); + + // Since we have just called `createRoom`, `getRoom` should not be `null`. + return this.client.getRoom(target)!; + } + + /** + * Get the list of source rooms, i.e. the rooms from which rules need to be read. + * + * If no source rooms are setup, the target room is used as sole source room. + * + * Note: This method is public for testing reasons. Most clients should not need + * to call it directly. + * + * # Safety + * + * This method will rewrite the `Policies` object in the user's account data. + * This rewrite is inherently racy and could overwrite or be overwritten by + * other concurrent rewrites of the same object. + */ + public async getOrCreateSourceRooms(): Promise { + const ignoreInvitesPolicies = this.getIgnoreInvitesPolicies(); + let sources = ignoreInvitesPolicies.sources; + + // Validate `sources`. If it is invalid, trash out the current `sources` + // and create a new list of sources from `target`. + let hasChanges = false; + if (!Array.isArray(sources)) { + // `sources` could not be an array. + hasChanges = true; + sources = []; + } + let sourceRooms: Room[] = sources + // `sources` could contain non-string / invalid room ids + .filter(roomId => typeof roomId === "string") + .map(roomId => this.client.getRoom(roomId)) + .filter(room => !!room); + if (sourceRooms.length != sources.length) { + hasChanges = true; + } + if (sourceRooms.length == 0) { + // `sources` could be empty (possibly because we've removed + // invalid content) + const target = await this.getOrCreateTargetRoom(); + hasChanges = true; + sourceRooms = [target]; + } + if (hasChanges) { + // Reload `policies`/`ignoreInvitesPolicies` in case it has been changed + // during or by our call to `this.getTargetRoom()`. + await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => { + ignoreInvitesPolicies.sources = sources; + }); + } + return sourceRooms; + } + + /** + * Fetch the `IGNORE_INVITES_POLICIES` object from account data. + * + * If both an unstable prefix version and a stable prefix version are available, + * it will return the stable prefix version preferentially. + * + * The result is *not* validated but is guaranteed to be a non-null object. + * + * @returns A non-null object. + */ + private getIgnoreInvitesPolicies(): {[key: string]: any} { + return this.getPoliciesAndIgnoreInvitesPolicies().ignoreInvitesPolicies; + } + + /** + * Modify in place the `IGNORE_INVITES_POLICIES` object from account data. + */ + private async withIgnoreInvitesPolicies(cb: (ignoreInvitesPolicies: {[key: string]: any}) => void) { + const { policies, ignoreInvitesPolicies } = this.getPoliciesAndIgnoreInvitesPolicies(); + cb(ignoreInvitesPolicies); + policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name] = ignoreInvitesPolicies; + await this.client.setAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name, policies); + } + + /** + * As `getIgnoreInvitesPolicies` but also return the `POLICIES_ACCOUNT_EVENT_TYPE` + * object. + */ + private getPoliciesAndIgnoreInvitesPolicies(): + {policies: {[key: string]: any}, ignoreInvitesPolicies: {[key: string]: any}} { + let policies = {}; + for (const key of [POLICIES_ACCOUNT_EVENT_TYPE.name, POLICIES_ACCOUNT_EVENT_TYPE.altName]) { + if (!key) { + continue; + } + const value = this.client.getAccountData(key)?.getContent(); + if (value) { + policies = value; + break; + } + } + + let ignoreInvitesPolicies = {}; + let hasIgnoreInvitesPolicies = false; + for (const key of [IGNORE_INVITES_ACCOUNT_EVENT_KEY.name, IGNORE_INVITES_ACCOUNT_EVENT_KEY.altName]) { + if (!key) { + continue; + } + const value = policies[key]; + if (value && typeof value == "object") { + ignoreInvitesPolicies = value; + hasIgnoreInvitesPolicies = true; + break; + } + } + if (!hasIgnoreInvitesPolicies) { + policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name] = ignoreInvitesPolicies; + } + + return { policies, ignoreInvitesPolicies }; + } +} diff --git a/src/models/read-receipt.ts b/src/models/read-receipt.ts new file mode 100644 index 000000000..e6d558766 --- /dev/null +++ b/src/models/read-receipt.ts @@ -0,0 +1,306 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ReceiptType } from "../@types/read_receipts"; +import { EventTimelineSet, EventType, MatrixEvent } from "../matrix"; +import { ListenerMap, TypedEventEmitter } from "./typed-event-emitter"; +import * as utils from "../utils"; + +export const MAIN_ROOM_TIMELINE = "main"; + +export function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: ReceiptType): MatrixEvent { + return new MatrixEvent({ + content: { + [event.getId()]: { + [receiptType]: { + [userId]: { + ts: event.getTs(), + threadId: event.threadRootId ?? MAIN_ROOM_TIMELINE, + }, + }, + }, + }, + type: EventType.Receipt, + room_id: event.getRoomId(), + }); +} + +export interface Receipt { + ts: number; + thread_id?: string; +} + +export interface WrappedReceipt { + eventId: string; + data: Receipt; +} + +interface CachedReceipt { + type: ReceiptType; + userId: string; + data: Receipt; +} + +type ReceiptCache = {[eventId: string]: CachedReceipt[]}; + +export interface ReceiptContent { + [eventId: string]: { + [key in ReceiptType]: { + [userId: string]: Receipt; + }; + }; +} + +const ReceiptPairRealIndex = 0; +const ReceiptPairSyntheticIndex = 1; +// We will only hold a synthetic receipt if we do not have a real receipt or the synthetic is newer. +type Receipts = { + [receiptType: string]: { + [userId: string]: [WrappedReceipt | null, WrappedReceipt | null]; // Pair (both nullable) + }; +}; + +export abstract class ReadReceipt< + Events extends string, + Arguments extends ListenerMap, + SuperclassArguments extends ListenerMap = Arguments, +> extends TypedEventEmitter { + // receipts should clobber based on receipt_type and user_id pairs hence + // the form of this structure. This is sub-optimal for the exposed APIs + // which pass in an event ID and get back some receipts, so we also store + // a pre-cached list for this purpose. + private receipts: Receipts = {}; // { receipt_type: { user_id: Receipt } } + private receiptCacheByEventId: ReceiptCache = {}; // { event_id: CachedReceipt[] } + + public abstract getUnfilteredTimelineSet(): EventTimelineSet; + public abstract timeline: MatrixEvent[]; + + /** + * Gets the latest receipt for a given user in the room + * @param userId The id of the user for which we want the receipt + * @param ignoreSynthesized Whether to ignore synthesized receipts or not + * @param receiptType Optional. The type of the receipt we want to get + * @returns the latest receipts of the chosen type for the chosen user + */ + public getReadReceiptForUserId( + userId: string, ignoreSynthesized = false, receiptType = ReceiptType.Read, + ): WrappedReceipt | null { + const [realReceipt, syntheticReceipt] = this.receipts[receiptType]?.[userId] ?? []; + if (ignoreSynthesized) { + return realReceipt; + } + + return syntheticReceipt ?? realReceipt; + } + + /** + * Get the ID of the event that a given user has read up to, or null if we + * have received no read receipts from them. + * @param {String} userId The user ID to get read receipt event ID for + * @param {Boolean} ignoreSynthesized If true, return only receipts that have been + * sent by the server, not implicit ones generated + * by the JS SDK. + * @return {String} ID of the latest event that the given user has read, or null. + */ + public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null { + // XXX: This is very very ugly and I hope I won't have to ever add a new + // receipt type here again. IMHO this should be done by the server in + // some more intelligent manner or the client should just use timestamps + + const timelineSet = this.getUnfilteredTimelineSet(); + const publicReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.Read); + const privateReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.ReadPrivate); + + // If we have both, compare them + let comparison: number | null | undefined; + if (publicReadReceipt?.eventId && privateReadReceipt?.eventId) { + comparison = timelineSet.compareEventOrdering(publicReadReceipt?.eventId, privateReadReceipt?.eventId); + } + + // If we didn't get a comparison try to compare the ts of the receipts + if (!comparison && publicReadReceipt?.data?.ts && privateReadReceipt?.data?.ts) { + comparison = publicReadReceipt?.data?.ts - privateReadReceipt?.data?.ts; + } + + // The public receipt is more likely to drift out of date so the private + // one has precedence + if (!comparison) return privateReadReceipt?.eventId ?? publicReadReceipt?.eventId ?? null; + + // If public read receipt is older, return the private one + return ((comparison < 0) ? privateReadReceipt?.eventId : publicReadReceipt?.eventId) ?? null; + } + + public addReceiptToStructure( + eventId: string, + receiptType: ReceiptType, + userId: string, + receipt: Receipt, + synthetic: boolean, + ): void { + if (!this.receipts[receiptType]) { + this.receipts[receiptType] = {}; + } + if (!this.receipts[receiptType][userId]) { + this.receipts[receiptType][userId] = [null, null]; + } + + const pair = this.receipts[receiptType][userId]; + + let existingReceipt = pair[ReceiptPairRealIndex]; + if (synthetic) { + existingReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex]; + } + + if (existingReceipt) { + // we only want to add this receipt if we think it is later than the one we already have. + // This is managed server-side, but because we synthesize RRs locally we have to do it here too. + const ordering = this.getUnfilteredTimelineSet().compareEventOrdering( + existingReceipt.eventId, + eventId, + ); + if (ordering !== null && ordering >= 0) { + return; + } + } + + const wrappedReceipt: WrappedReceipt = { + eventId, + data: receipt, + }; + + const realReceipt = synthetic ? pair[ReceiptPairRealIndex] : wrappedReceipt; + const syntheticReceipt = synthetic ? wrappedReceipt : pair[ReceiptPairSyntheticIndex]; + + let ordering: number | null = null; + if (realReceipt && syntheticReceipt) { + ordering = this.getUnfilteredTimelineSet().compareEventOrdering( + realReceipt.eventId, + syntheticReceipt.eventId, + ); + } + + const preferSynthetic = ordering === null || ordering < 0; + + // we don't bother caching just real receipts by event ID as there's nothing that would read it. + // Take the current cached receipt before we overwrite the pair elements. + const cachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex]; + + if (synthetic && preferSynthetic) { + pair[ReceiptPairSyntheticIndex] = wrappedReceipt; + } else if (!synthetic) { + pair[ReceiptPairRealIndex] = wrappedReceipt; + + if (!preferSynthetic) { + pair[ReceiptPairSyntheticIndex] = null; + } + } + + const newCachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex]; + if (cachedReceipt === newCachedReceipt) return; + + // clean up any previous cache entry + if (cachedReceipt && this.receiptCacheByEventId[cachedReceipt.eventId]) { + const previousEventId = cachedReceipt.eventId; + // Remove the receipt we're about to clobber out of existence from the cache + this.receiptCacheByEventId[previousEventId] = ( + this.receiptCacheByEventId[previousEventId].filter(r => { + return r.type !== receiptType || r.userId !== userId; + }) + ); + + if (this.receiptCacheByEventId[previousEventId].length < 1) { + delete this.receiptCacheByEventId[previousEventId]; // clean up the cache keys + } + } + + // cache the new one + if (!this.receiptCacheByEventId[eventId]) { + this.receiptCacheByEventId[eventId] = []; + } + this.receiptCacheByEventId[eventId].push({ + userId: userId, + type: receiptType as ReceiptType, + data: receipt, + }); + } + + /** + * Get a list of receipts for the given event. + * @param {MatrixEvent} event the event to get receipts for + * @return {Object[]} A list of receipts with a userId, type and data keys or + * an empty list. + */ + public getReceiptsForEvent(event: MatrixEvent): CachedReceipt[] { + return this.receiptCacheByEventId[event.getId()] || []; + } + + public abstract addReceipt(event: MatrixEvent, synthetic: boolean): void; + + /** + * Add a temporary local-echo receipt to the room to reflect in the + * client the fact that we've sent one. + * @param {string} userId The user ID if the receipt sender + * @param {MatrixEvent} e The event that is to be acknowledged + * @param {ReceiptType} receiptType The type of receipt + */ + public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType): void { + this.addReceipt(synthesizeReceipt(userId, e, receiptType), true); + } + + /** + * Get a list of user IDs who have read up to the given event. + * @param {MatrixEvent} event the event to get read receipts for. + * @return {String[]} A list of user IDs. + */ + public getUsersReadUpTo(event: MatrixEvent): string[] { + return this.getReceiptsForEvent(event).filter(function(receipt) { + return utils.isSupportedReceiptType(receipt.type); + }).map(function(receipt) { + return receipt.userId; + }); + } + + /** + * Determines if the given user has read a particular event ID with the known + * history of the room. This is not a definitive check as it relies only on + * what is available to the room at the time of execution. + * @param {String} userId The user ID to check the read state of. + * @param {String} eventId The event ID to check if the user read. + * @returns {Boolean} True if the user has read the event, false otherwise. + */ + public hasUserReadEvent(userId: string, eventId: string): boolean { + const readUpToId = this.getEventReadUpTo(userId, false); + if (readUpToId === eventId) return true; + + if (this.timeline?.length + && this.timeline[this.timeline.length - 1].getSender() + && this.timeline[this.timeline.length - 1].getSender() === userId) { + // It doesn't matter where the event is in the timeline, the user has read + // it because they've sent the latest event. + return true; + } + + for (let i = this.timeline?.length - 1; i >= 0; --i) { + const ev = this.timeline[i]; + + // If we encounter the target event first, the user hasn't read it + // however if we encounter the readUpToId first then the user has read + // it. These rules apply because we're iterating bottom-up. + if (ev.getId() === eventId) return false; + if (ev.getId() === readUpToId) return true; + } + + // We don't know if the user has read it, so assume not. + return false; + } +} diff --git a/src/models/relations-container.ts b/src/models/relations-container.ts index 224375efc..9b2f255ee 100644 --- a/src/models/relations-container.ts +++ b/src/models/relations-container.ts @@ -23,14 +23,8 @@ import { Room } from "./room"; export class RelationsContainer { // A tree of objects to access a set of related children for an event, as in: - // this.relations[parentEventId][relationType][relationEventType] - private relations: { - [parentEventId: string]: { - [relationType: RelationType | string]: { - [eventType: EventType | string]: Relations; - }; - }; - } = {}; + // this.relations.get(parentEventId).get(relationType).get(relationEventType) + private relations = new Map>>(); constructor(private readonly client: MatrixClient, private readonly room?: Room) { } @@ -57,14 +51,15 @@ export class RelationsContainer { relationType: RelationType | string, eventType: EventType | string, ): Relations | undefined { - return this.relations[eventId]?.[relationType]?.[eventType]; + return this.relations.get(eventId)?.get(relationType)?.get(eventType); } public getAllChildEventsForEvent(parentEventId: string): MatrixEvent[] { - const relationsForEvent = this.relations[parentEventId] ?? {}; + const relationsForEvent = this.relations.get(parentEventId) + ?? new Map>(); const events: MatrixEvent[] = []; - for (const relationsRecord of Object.values(relationsForEvent)) { - for (const relations of Object.values(relationsRecord)) { + for (const relationsRecord of relationsForEvent.values()) { + for (const relations of relationsRecord.values()) { events.push(...relations.getRelations()); } } @@ -79,11 +74,11 @@ export class RelationsContainer { * @param {MatrixEvent} event The event to check as relation target. */ public aggregateParentEvent(event: MatrixEvent): void { - const relationsForEvent = this.relations[event.getId()]; + const relationsForEvent = this.relations.get(event.getId()); if (!relationsForEvent) return; - for (const relationsWithRelType of Object.values(relationsForEvent)) { - for (const relationsWithEventType of Object.values(relationsWithRelType)) { + for (const relationsWithRelType of relationsForEvent.values()) { + for (const relationsWithEventType of relationsWithRelType.values()) { relationsWithEventType.setTargetEvent(event); } } @@ -123,23 +118,26 @@ export class RelationsContainer { const { event_id: relatesToEventId, rel_type: relationType } = relation; const eventType = event.getType(); - let relationsForEvent = this.relations[relatesToEventId]; + let relationsForEvent = this.relations.get(relatesToEventId); if (!relationsForEvent) { - relationsForEvent = this.relations[relatesToEventId] = {}; + relationsForEvent = new Map>(); + this.relations.set(relatesToEventId, relationsForEvent); } - let relationsWithRelType = relationsForEvent[relationType]; + let relationsWithRelType = relationsForEvent.get(relationType); if (!relationsWithRelType) { - relationsWithRelType = relationsForEvent[relationType] = {}; + relationsWithRelType = new Map(); + relationsForEvent.set(relationType, relationsWithRelType); } - let relationsWithEventType = relationsWithRelType[eventType]; + let relationsWithEventType = relationsWithRelType.get(eventType); if (!relationsWithEventType) { - relationsWithEventType = relationsWithRelType[eventType] = new Relations( + relationsWithEventType = new Relations( relationType, eventType, this.client, ); + relationsWithRelType.set(eventType, relationsWithEventType); const room = this.room ?? timelineSet?.room; const relatesToEvent = timelineSet?.findEventById(relatesToEventId) diff --git a/src/models/room-state.ts b/src/models/room-state.ts index c7d3ac325..3cca4a7b8 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -79,7 +79,7 @@ export class RoomState extends TypedEventEmitter public readonly reEmitter = new TypedReEmitter(this); private sentinels: Record = {}; // userId: RoomMember // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys) - private displayNameToUserIds: Record = {}; + private displayNameToUserIds = new Map(); private userIdsToDisplayNames: Record = {}; private tokenToInvite: Record = {}; // 3pid invite state_key to m.room.member invite private joinedMemberCount: number = null; // cache of the number of joined members @@ -97,7 +97,7 @@ export class RoomState extends TypedEventEmitter // XXX: Should be read-only public members: Record = {}; // userId: RoomMember public events = new Map>(); // Map> - public paginationToken: string = null; + public paginationToken: string | null = null; public readonly beacons = new Map(); private _liveBeaconIds: BeaconIdentifier[] = []; @@ -709,7 +709,7 @@ export class RoomState extends TypedEventEmitter * @return {string[]} An array of user IDs or an empty array. */ public getUserIdsWithDisplayName(displayName: string): string[] { - return this.displayNameToUserIds[utils.removeHiddenChars(displayName)] || []; + return this.displayNameToUserIds.get(utils.removeHiddenChars(displayName)) ?? []; } /** @@ -941,11 +941,11 @@ export class RoomState extends TypedEventEmitter // the lot. const strippedOldName = utils.removeHiddenChars(oldName); - const existingUserIds = this.displayNameToUserIds[strippedOldName]; + const existingUserIds = this.displayNameToUserIds.get(strippedOldName); if (existingUserIds) { // remove this user ID from this array const filteredUserIDs = existingUserIds.filter((id) => id !== userId); - this.displayNameToUserIds[strippedOldName] = filteredUserIDs; + this.displayNameToUserIds.set(strippedOldName, filteredUserIDs); } } @@ -954,10 +954,9 @@ export class RoomState extends TypedEventEmitter const strippedDisplayname = displayName && utils.removeHiddenChars(displayName); // an empty stripped displayname (undefined/'') will be set to MXID in room-member.js if (strippedDisplayname) { - if (!this.displayNameToUserIds[strippedDisplayname]) { - this.displayNameToUserIds[strippedDisplayname] = []; - } - this.displayNameToUserIds[strippedDisplayname].push(userId); + const arr = this.displayNameToUserIds.get(strippedDisplayname) ?? []; + arr.push(userId); + this.displayNameToUserIds.set(strippedDisplayname, arr); } } } diff --git a/src/models/room.ts b/src/models/room.ts index 46c623c5f..aa1ffdd74 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -47,10 +47,16 @@ import { FILTER_RELATED_BY_SENDERS, ThreadFilterType, } from "./thread"; -import { TypedEventEmitter } from "./typed-event-emitter"; import { ReceiptType } from "../@types/read_receipts"; import { IStateEventWithRoomId } from "../@types/search"; import { RelationsContainer } from "./relations-container"; +import { + MAIN_ROOM_TIMELINE, + ReadReceipt, + Receipt, + ReceiptContent, + synthesizeReceipt, +} from "./read-receipt"; // These constants are used as sane defaults when the homeserver doesn't support // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be @@ -61,23 +67,6 @@ import { RelationsContainer } from "./relations-container"; export const KNOWN_SAFE_ROOM_VERSION = '9'; const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; -function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: ReceiptType): MatrixEvent { - // console.log("synthesizing receipt for "+event.getId()); - return new MatrixEvent({ - content: { - [event.getId()]: { - [receiptType]: { - [userId]: { - ts: event.getTs(), - }, - }, - }, - }, - type: EventType.Receipt, - room_id: event.getRoomId(), - }); -} - interface IOpts { storageToken?: string; pendingEventOrdering?: PendingEventOrdering; @@ -91,40 +80,6 @@ export interface IRecommendedVersion { urgent: boolean; } -interface IReceipt { - ts: number; -} - -export interface IWrappedReceipt { - eventId: string; - data: IReceipt; -} - -interface ICachedReceipt { - type: ReceiptType; - userId: string; - data: IReceipt; -} - -type ReceiptCache = {[eventId: string]: ICachedReceipt[]}; - -interface IReceiptContent { - [eventId: string]: { - [key in ReceiptType]: { - [userId: string]: IReceipt; - }; - }; -} - -const ReceiptPairRealIndex = 0; -const ReceiptPairSyntheticIndex = 1; -// We will only hold a synthetic receipt if we do not have a real receipt or the synthetic is newer. -type Receipts = { - [receiptType: string]: { - [userId: string]: [IWrappedReceipt, IWrappedReceipt]; // Pair (both nullable) - }; -}; - // When inserting a visibility event affecting event `eventId`, we // need to scan through existing visibility events for `eventId`. // In theory, this could take an unlimited amount of time if: @@ -141,6 +96,8 @@ type Receipts = { // price to pay to keep matrix-js-sdk responsive. const MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH = 30; +type NotificationCount = Partial>; + export enum NotificationCountType { Highlight = "highlight", Total = "total", @@ -225,16 +182,11 @@ export type RoomEventHandlerMap = { BeaconEvent.Update | BeaconEvent.Destroy | BeaconEvent.LivenessChange >; -export class Room extends TypedEventEmitter { +export class Room extends ReadReceipt { public readonly reEmitter: TypedReEmitter; private txnToEvent: Record = {}; // Pending in-flight requests { string: MatrixEvent } - // receipts should clobber based on receipt_type and user_id pairs hence - // the form of this structure. This is sub-optimal for the exposed APIs - // which pass in an event ID and get back some receipts, so we also store - // a pre-cached list for this purpose. - private receipts: Receipts = {}; // { receipt_type: { user_id: IReceipt } } - private receiptCacheByEventId: ReceiptCache = {}; // { event_id: ICachedReceipt[] } - private notificationCounts: Partial> = {}; + private notificationCounts: NotificationCount = {}; + private threadNotifications: Map = new Map(); private readonly timelineSets: EventTimelineSet[]; public readonly threadsTimelineSets: EventTimelineSet[] = []; // any filtered timeline sets we're maintaining for this room @@ -407,7 +359,7 @@ export class Room extends TypedEventEmitter } private threadTimelineSetsPromise: Promise<[EventTimelineSet, EventTimelineSet]> | null = null; - public async createThreadsTimelineSets(): Promise<[EventTimelineSet, EventTimelineSet]> { + public async createThreadsTimelineSets(): Promise<[EventTimelineSet, EventTimelineSet] | null> { if (this.threadTimelineSetsPromise) { return this.threadTimelineSetsPromise; } @@ -420,10 +372,13 @@ export class Room extends TypedEventEmitter ]); const timelineSets = await this.threadTimelineSetsPromise; this.threadsTimelineSets.push(...timelineSets); + return timelineSets; } catch (e) { this.threadTimelineSetsPromise = null; + return null; } } + return null; } /** @@ -1231,6 +1186,37 @@ export class Room extends TypedEventEmitter return this.notificationCounts[type]; } + /** + * Get one of the notification counts for a thread + * @param threadId the root event ID + * @param type The type of notification count to get. default: 'total' + * @returns The notification count, or undefined if there is no count + * for this type. + */ + public getThreadUnreadNotificationCount(threadId: string, type = NotificationCountType.Total): number | undefined { + return this.threadNotifications.get(threadId)?.[type]; + } + + /** + * Swet one of the notification count for a thread + * @param threadId the root event ID + * @param type The type of notification count to get. default: 'total' + * @returns {void} + */ + public setThreadUnreadNotificationCount(threadId: string, type: NotificationCountType, count: number): void { + this.threadNotifications.set(threadId, { + highlight: this.threadNotifications.get(threadId)?.highlight, + total: this.threadNotifications.get(threadId)?.total, + ...{ + [type]: count, + }, + }); + } + + public resetThreadUnreadNotificationCount(): void { + this.threadNotifications.clear(); + } + /** * Set one of the notification counts for this room * @param {String} type The type of notification count to set. @@ -1629,7 +1615,14 @@ export class Room extends TypedEventEmitter private async createThreadTimelineSet(filterType?: ThreadFilterType): Promise { let timelineSet: EventTimelineSet; - if (Thread.hasServerSideSupport) { + if (Thread.hasServerSideListSupport) { + timelineSet = + new EventTimelineSet(this, this.opts, undefined, undefined, Boolean(Thread.hasServerSideListSupport)); + this.reEmitter.reEmit(timelineSet, [ + RoomEvent.Timeline, + RoomEvent.TimelineReset, + ]); + } else if (Thread.hasServerSideSupport) { const filter = await this.getThreadListFilter(filterType); timelineSet = this.getOrCreateFilteredTimelineSet( @@ -1662,81 +1655,148 @@ export class Room extends TypedEventEmitter return timelineSet; } - public threadsReady = false; + private threadsReady = false; + /** + * Takes the given thread root events and creates threads for them. + * @param events + * @param toStartOfTimeline + */ + public processThreadRoots(events: MatrixEvent[], toStartOfTimeline: boolean): void { + for (const rootEvent of events) { + EventTimeline.setEventMetadata( + rootEvent, + this.currentState, + toStartOfTimeline, + ); + if (!this.getThread(rootEvent.getId())) { + this.createThread(rootEvent.getId(), rootEvent, [], toStartOfTimeline); + } + } + } + + /** + * Fetch the bare minimum of room threads required for the thread list to work reliably. + * With server support that means fetching one page. + * Without server support that means fetching as much at once as the server allows us to. + */ public async fetchRoomThreads(): Promise { if (this.threadsReady || !this.client.supportsExperimentalThreads()) { return; } - const allThreadsFilter = await this.getThreadListFilter(); + if (Thread.hasServerSideListSupport) { + await Promise.all([ + this.fetchRoomThreadList(ThreadFilterType.All), + this.fetchRoomThreadList(ThreadFilterType.My), + ]); + } else { + const allThreadsFilter = await this.getThreadListFilter(); - const { chunk: events } = await this.client.createMessagesRequest( - this.roomId, - "", - Number.MAX_SAFE_INTEGER, - Direction.Backward, - allThreadsFilter, - ); + const { chunk: events } = await this.client.createMessagesRequest( + this.roomId, + "", + Number.MAX_SAFE_INTEGER, + Direction.Backward, + allThreadsFilter, + ); - if (!events.length) return; + if (!events.length) return; - // Sorted by last_reply origin_server_ts - const threadRoots = events - .map(this.client.getEventMapper()) - .sort((eventA, eventB) => { - /** - * `origin_server_ts` in a decentralised world is far from ideal - * but for lack of any better, we will have to use this - * Long term the sorting should be handled by homeservers and this - * is only meant as a short term patch - */ - const threadAMetadata = eventA - .getServerAggregatedRelation(RelationType.Thread); - const threadBMetadata = eventB - .getServerAggregatedRelation(RelationType.Thread); - return threadAMetadata.latest_event.origin_server_ts - threadBMetadata.latest_event.origin_server_ts; - }); + // Sorted by last_reply origin_server_ts + const threadRoots = events + .map(this.client.getEventMapper()) + .sort((eventA, eventB) => { + /** + * `origin_server_ts` in a decentralised world is far from ideal + * but for lack of any better, we will have to use this + * Long term the sorting should be handled by homeservers and this + * is only meant as a short term patch + */ + const threadAMetadata = eventA + .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); + const threadBMetadata = eventB + .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); + return threadAMetadata.latest_event.origin_server_ts - + threadBMetadata.latest_event.origin_server_ts; + }); - let latestMyThreadsRootEvent: MatrixEvent; - const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); - for (const rootEvent of threadRoots) { - this.threadsTimelineSets[0].addLiveEvent(rootEvent, { - duplicateStrategy: DuplicateStrategy.Ignore, - fromCache: false, - roomState, - }); - - const threadRelationship = rootEvent - .getServerAggregatedRelation(RelationType.Thread); - if (threadRelationship.current_user_participated) { - this.threadsTimelineSets[1].addLiveEvent(rootEvent, { + let latestMyThreadsRootEvent: MatrixEvent; + const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); + for (const rootEvent of threadRoots) { + this.threadsTimelineSets[0].addLiveEvent(rootEvent, { duplicateStrategy: DuplicateStrategy.Ignore, fromCache: false, roomState, }); - latestMyThreadsRootEvent = rootEvent; + + const threadRelationship = rootEvent + .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); + if (threadRelationship.current_user_participated) { + this.threadsTimelineSets[1].addLiveEvent(rootEvent, { + duplicateStrategy: DuplicateStrategy.Ignore, + fromCache: false, + roomState, + }); + latestMyThreadsRootEvent = rootEvent; + } } - if (!this.getThread(rootEvent.getId())) { - this.createThread(rootEvent.getId(), rootEvent, [], true); + this.processThreadRoots(threadRoots, true); + + this.client.decryptEventIfNeeded(threadRoots[threadRoots.length -1]); + if (latestMyThreadsRootEvent) { + this.client.decryptEventIfNeeded(latestMyThreadsRootEvent); } } - this.client.decryptEventIfNeeded(threadRoots[threadRoots.length -1]); - if (latestMyThreadsRootEvent) { - this.client.decryptEventIfNeeded(latestMyThreadsRootEvent); - } - - this.threadsReady = true; - this.on(ThreadEvent.NewReply, this.onThreadNewReply); + this.threadsReady = true; + } + + /** + * Fetch a single page of threadlist messages for the specific thread filter + * @param filter + * @private + */ + private async fetchRoomThreadList(filter?: ThreadFilterType): Promise { + const timelineSet = filter === ThreadFilterType.My + ? this.threadsTimelineSets[1] + : this.threadsTimelineSets[0]; + + const { chunk: events, end } = await this.client.createThreadListMessagesRequest( + this.roomId, + null, + undefined, + Direction.Backward, + timelineSet.getFilter(), + ); + + timelineSet.getLiveTimeline().setPaginationToken(end, Direction.Backward); + + if (!events.length) return; + + const matrixEvents = events.map(this.client.getEventMapper()); + this.processThreadRoots(matrixEvents, true); + const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); + for (const rootEvent of matrixEvents) { + timelineSet.addLiveEvent(rootEvent, { + duplicateStrategy: DuplicateStrategy.Replace, + fromCache: false, + roomState, + }); + } } private onThreadNewReply(thread: Thread): void { + const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); for (const timelineSet of this.threadsTimelineSets) { timelineSet.removeEvent(thread.id); - timelineSet.addLiveEvent(thread.rootEvent); + timelineSet.addLiveEvent(thread.rootEvent, { + duplicateStrategy: DuplicateStrategy.Replace, + fromCache: false, + roomState, + }); } } @@ -1882,8 +1942,6 @@ export class Room extends TypedEventEmitter this.lastThread = thread; } - this.emit(ThreadEvent.New, thread, toStartOfTimeline); - if (this.threadsReady) { this.threadsTimelineSets.forEach(timelineSet => { if (thread.rootEvent) { @@ -1900,6 +1958,8 @@ export class Room extends TypedEventEmitter }); } + this.emit(ThreadEvent.New, thread, toStartOfTimeline); + return thread; } @@ -1981,14 +2041,6 @@ export class Room extends TypedEventEmitter } } } - - if (event.getUnsigned().transaction_id) { - const existingEvent = this.txnToEvent[event.getUnsigned().transaction_id]; - if (existingEvent) { - // remote echo of an event we sent earlier - this.handleRemoteEcho(event, existingEvent); - } - } } /** @@ -1996,7 +2048,7 @@ export class Room extends TypedEventEmitter * "Room.timeline". * * @param {MatrixEvent} event Event to be added - * @param {IAddLiveEventOptions} options addLiveEvent options + * @param {IAddLiveEventOptions} addLiveEventOptions addLiveEvent options * @fires module:client~MatrixClient#event:"Room.timeline" * @private */ @@ -2344,7 +2396,7 @@ export class Room extends TypedEventEmitter fromCache = false, ): void { let duplicateStrategy = duplicateStrategyOrOpts as DuplicateStrategy; - let timelineWasEmpty: boolean; + let timelineWasEmpty = false; if (typeof (duplicateStrategyOrOpts) === 'object') { ({ duplicateStrategy, @@ -2383,27 +2435,38 @@ export class Room extends TypedEventEmitter const threadRoots = this.findThreadRoots(events); const eventsByThread: { [threadId: string]: MatrixEvent[] } = {}; + const options: IAddLiveEventOptions = { + duplicateStrategy, + fromCache, + timelineWasEmpty, + }; + for (const event of events) { // TODO: We should have a filter to say "only add state event types X Y Z to the timeline". this.processLiveEvent(event); + if (event.getUnsigned().transaction_id) { + const existingEvent = this.txnToEvent[event.getUnsigned().transaction_id!]; + if (existingEvent) { + // remote echo of an event we sent earlier + this.handleRemoteEcho(event, existingEvent); + continue; // we can skip adding the event to the timeline sets, it is already there + } + } + const { shouldLiveInRoom, shouldLiveInThread, threadId, } = this.eventShouldLiveIn(event, events, threadRoots); - if (shouldLiveInThread && !eventsByThread[threadId]) { - eventsByThread[threadId] = []; + if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) { + eventsByThread[threadId ?? ""] = []; } - eventsByThread[threadId]?.push(event); + eventsByThread[threadId ?? ""]?.push(event); if (shouldLiveInRoom) { - this.addLiveEvent(event, { - duplicateStrategy, - fromCache, - timelineWasEmpty, - }); + this.addLiveEvent(event, options); } } @@ -2433,17 +2496,17 @@ export class Room extends TypedEventEmitter } if (shouldLiveInThread) { - event.setThreadId(threadId); + event.setThreadId(threadId ?? ""); memo[THREAD].push(event); } return memo; - }, [[], []]); + }, [[] as MatrixEvent[], [] as MatrixEvent[]]); } else { // When `experimentalThreadSupport` is disabled treat all events as timelineEvents return [ - events, - [], + events as MatrixEvent[], + [] as MatrixEvent[], ]; } } @@ -2455,12 +2518,43 @@ export class Room extends TypedEventEmitter const threadRoots = new Set(); for (const event of events) { if (event.isRelation(THREAD_RELATION_TYPE.name)) { - threadRoots.add(event.relationEventId); + threadRoots.add(event.relationEventId ?? ""); } } return threadRoots; } + /** + * Add a receipt event to the room. + * @param {MatrixEvent} event The m.receipt event. + * @param {Boolean} synthetic True if this event is implicit. + */ + public addReceipt(event: MatrixEvent, synthetic = false): void { + const content = event.getContent(); + Object.keys(content).forEach((eventId: string) => { + Object.keys(content[eventId]).forEach((receiptType: ReceiptType | string) => { + Object.keys(content[eventId][receiptType]).forEach((userId: string) => { + const receipt = content[eventId][receiptType][userId] as Receipt; + const receiptForMainTimeline = !receipt.thread_id || receipt.thread_id === MAIN_ROOM_TIMELINE; + const receiptDestination: Thread | this | undefined = receiptForMainTimeline + ? this + : this.threads.get(receipt.thread_id ?? ""); + receiptDestination?.addReceiptToStructure( + eventId, + receiptType as ReceiptType, + userId, + receipt, + synthetic, + ); + }); + }); + }); + + // send events after we've regenerated the structure & cache, otherwise things that + // listened for the event would read stale data. + this.emit(RoomEvent.Receipt, event, this); + } + /** * Adds/handles ephemeral events such as typing notifications and read receipts. * @param {MatrixEvent[]} events A list of events to process @@ -2551,276 +2645,6 @@ export class Room extends TypedEventEmitter } } - /** - * Get a list of user IDs who have read up to the given event. - * @param {MatrixEvent} event the event to get read receipts for. - * @return {String[]} A list of user IDs. - */ - public getUsersReadUpTo(event: MatrixEvent): string[] { - return this.getReceiptsForEvent(event).filter(function(receipt) { - return utils.isSupportedReceiptType(receipt.type); - }).map(function(receipt) { - return receipt.userId; - }); - } - - /** - * Gets the latest receipt for a given user in the room - * @param userId The id of the user for which we want the receipt - * @param ignoreSynthesized Whether to ignore synthesized receipts or not - * @param receiptType Optional. The type of the receipt we want to get - * @returns the latest receipts of the chosen type for the chosen user - */ - public getReadReceiptForUserId( - userId: string, ignoreSynthesized = false, receiptType = ReceiptType.Read, - ): IWrappedReceipt | null { - const [realReceipt, syntheticReceipt] = this.receipts[receiptType]?.[userId] ?? []; - if (ignoreSynthesized) { - return realReceipt; - } - - return syntheticReceipt ?? realReceipt; - } - - /** - * Get the ID of the event that a given user has read up to, or null if we - * have received no read receipts from them. - * @param {String} userId The user ID to get read receipt event ID for - * @param {Boolean} ignoreSynthesized If true, return only receipts that have been - * sent by the server, not implicit ones generated - * by the JS SDK. - * @return {String} ID of the latest event that the given user has read, or null. - */ - public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null { - // XXX: This is very very ugly and I hope I won't have to ever add a new - // receipt type here again. IMHO this should be done by the server in - // some more intelligent manner or the client should just use timestamps - - const timelineSet = this.getUnfilteredTimelineSet(); - const publicReadReceipt = this.getReadReceiptForUserId( - userId, - ignoreSynthesized, - ReceiptType.Read, - ); - const privateReadReceipt = this.getReadReceiptForUserId( - userId, - ignoreSynthesized, - ReceiptType.ReadPrivate, - ); - const unstablePrivateReadReceipt = this.getReadReceiptForUserId( - userId, - ignoreSynthesized, - ReceiptType.UnstableReadPrivate, - ); - - // If we have all, compare them - if (publicReadReceipt?.eventId && privateReadReceipt?.eventId && unstablePrivateReadReceipt?.eventId) { - const comparison1 = timelineSet.compareEventOrdering( - publicReadReceipt.eventId, - privateReadReceipt.eventId, - ); - const comparison2 = timelineSet.compareEventOrdering( - publicReadReceipt.eventId, - unstablePrivateReadReceipt.eventId, - ); - const comparison3 = timelineSet.compareEventOrdering( - privateReadReceipt.eventId, - unstablePrivateReadReceipt.eventId, - ); - if (comparison1 && comparison2 && comparison3) { - return (comparison1 > 0) - ? ((comparison2 > 0) ? publicReadReceipt.eventId : unstablePrivateReadReceipt.eventId) - : ((comparison3 > 0) ? privateReadReceipt.eventId : unstablePrivateReadReceipt.eventId); - } - } - - let latest = privateReadReceipt; - [unstablePrivateReadReceipt, publicReadReceipt].forEach((receipt) => { - if (receipt?.data?.ts > latest?.data?.ts || !latest) { - latest = receipt; - } - }); - if (latest?.eventId) return latest?.eventId; - - // The more less likely it is for a read receipt to drift out of date - // the bigger is its precedence - return ( - privateReadReceipt?.eventId ?? - unstablePrivateReadReceipt?.eventId ?? - publicReadReceipt?.eventId ?? - null - ); - } - - /** - * Determines if the given user has read a particular event ID with the known - * history of the room. This is not a definitive check as it relies only on - * what is available to the room at the time of execution. - * @param {String} userId The user ID to check the read state of. - * @param {String} eventId The event ID to check if the user read. - * @returns {Boolean} True if the user has read the event, false otherwise. - */ - public hasUserReadEvent(userId: string, eventId: string): boolean { - const readUpToId = this.getEventReadUpTo(userId, false); - if (readUpToId === eventId) return true; - - if (this.timeline.length - && this.timeline[this.timeline.length - 1].getSender() - && this.timeline[this.timeline.length - 1].getSender() === userId) { - // It doesn't matter where the event is in the timeline, the user has read - // it because they've sent the latest event. - return true; - } - - for (let i = this.timeline.length - 1; i >= 0; --i) { - const ev = this.timeline[i]; - - // If we encounter the target event first, the user hasn't read it - // however if we encounter the readUpToId first then the user has read - // it. These rules apply because we're iterating bottom-up. - if (ev.getId() === eventId) return false; - if (ev.getId() === readUpToId) return true; - } - - // We don't know if the user has read it, so assume not. - return false; - } - - /** - * Get a list of receipts for the given event. - * @param {MatrixEvent} event the event to get receipts for - * @return {Object[]} A list of receipts with a userId, type and data keys or - * an empty list. - */ - public getReceiptsForEvent(event: MatrixEvent): ICachedReceipt[] { - return this.receiptCacheByEventId[event.getId()] || []; - } - - /** - * Add a receipt event to the room. - * @param {MatrixEvent} event The m.receipt event. - * @param {Boolean} synthetic True if this event is implicit. - */ - public addReceipt(event: MatrixEvent, synthetic = false): void { - this.addReceiptsToStructure(event, synthetic); - // send events after we've regenerated the structure & cache, otherwise things that - // listened for the event would read stale data. - this.emit(RoomEvent.Receipt, event, this); - } - - /** - * Add a receipt event to the room. - * @param {MatrixEvent} event The m.receipt event. - * @param {Boolean} synthetic True if this event is implicit. - */ - private addReceiptsToStructure(event: MatrixEvent, synthetic: boolean): void { - const content = event.getContent(); - Object.keys(content).forEach((eventId) => { - Object.keys(content[eventId]).forEach((receiptType) => { - Object.keys(content[eventId][receiptType]).forEach((userId) => { - const receipt = content[eventId][receiptType][userId]; - - if (!this.receipts[receiptType]) { - this.receipts[receiptType] = {}; - } - if (!this.receipts[receiptType][userId]) { - this.receipts[receiptType][userId] = [null, null]; - } - - const pair = this.receipts[receiptType][userId]; - - let existingReceipt = pair[ReceiptPairRealIndex]; - if (synthetic) { - existingReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex]; - } - - if (existingReceipt) { - // we only want to add this receipt if we think it is later than the one we already have. - // This is managed server-side, but because we synthesize RRs locally we have to do it here too. - const ordering = this.getUnfilteredTimelineSet().compareEventOrdering( - existingReceipt.eventId, - eventId, - ); - if (ordering !== null && ordering >= 0) { - return; - } - } - - const wrappedReceipt: IWrappedReceipt = { - eventId, - data: receipt, - }; - - const realReceipt = synthetic ? pair[ReceiptPairRealIndex] : wrappedReceipt; - const syntheticReceipt = synthetic ? wrappedReceipt : pair[ReceiptPairSyntheticIndex]; - - let ordering: number | null = null; - if (realReceipt && syntheticReceipt) { - ordering = this.getUnfilteredTimelineSet().compareEventOrdering( - realReceipt.eventId, - syntheticReceipt.eventId, - ); - } - - const preferSynthetic = ordering === null || ordering < 0; - - // we don't bother caching just real receipts by event ID as there's nothing that would read it. - // Take the current cached receipt before we overwrite the pair elements. - const cachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex]; - - if (synthetic && preferSynthetic) { - pair[ReceiptPairSyntheticIndex] = wrappedReceipt; - } else if (!synthetic) { - pair[ReceiptPairRealIndex] = wrappedReceipt; - - if (!preferSynthetic) { - pair[ReceiptPairSyntheticIndex] = null; - } - } - - const newCachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex]; - if (cachedReceipt === newCachedReceipt) return; - - // clean up any previous cache entry - if (cachedReceipt && this.receiptCacheByEventId[cachedReceipt.eventId]) { - const previousEventId = cachedReceipt.eventId; - // Remove the receipt we're about to clobber out of existence from the cache - this.receiptCacheByEventId[previousEventId] = ( - this.receiptCacheByEventId[previousEventId].filter(r => { - return r.type !== receiptType || r.userId !== userId; - }) - ); - - if (this.receiptCacheByEventId[previousEventId].length < 1) { - delete this.receiptCacheByEventId[previousEventId]; // clean up the cache keys - } - } - - // cache the new one - if (!this.receiptCacheByEventId[eventId]) { - this.receiptCacheByEventId[eventId] = []; - } - this.receiptCacheByEventId[eventId].push({ - userId: userId, - type: receiptType as ReceiptType, - data: receipt, - }); - }); - }); - }); - } - - /** - * Add a temporary local-echo receipt to the room to reflect in the - * client the fact that we've sent one. - * @param {string} userId The user ID if the receipt sender - * @param {MatrixEvent} e The event that is to be acknowledged - * @param {ReceiptType} receiptType The type of receipt - */ - public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType): void { - this.addReceipt(synthesizeReceipt(userId, e, receiptType), true); - } - /** * Update the room-tag event for the room. The previous one is overwritten. * @param {MatrixEvent} event the m.tag event diff --git a/src/models/thread.ts b/src/models/thread.ts index c451ccb8d..dc433a24f 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -23,10 +23,10 @@ import { IThreadBundledRelationship, MatrixEvent } from "./event"; import { Direction, EventTimeline } from "./event-timeline"; import { EventTimelineSet, EventTimelineSetHandlerMap } from './event-timeline-set'; import { Room } from './room'; -import { TypedEventEmitter } from "./typed-event-emitter"; import { RoomState } from "./room-state"; import { ServerControlledNamespacedValue } from "../NamespacedValue"; import { logger } from "../logger"; +import { ReadReceipt } from "./read-receipt"; export enum ThreadEvent { New = "Thread.new", @@ -51,11 +51,28 @@ interface IThreadOpts { client: MatrixClient; } +export enum FeatureSupport { + None = 0, + Experimental = 1, + Stable = 2 +} + +export function determineFeatureSupport(stable: boolean, unstable: boolean): FeatureSupport { + if (stable) { + return FeatureSupport.Stable; + } else if (unstable) { + return FeatureSupport.Experimental; + } else { + return FeatureSupport.None; + } +} + /** * @experimental */ -export class Thread extends TypedEventEmitter { - public static hasServerSideSupport: boolean; +export class Thread extends ReadReceipt { + public static hasServerSideSupport = FeatureSupport.None; + public static hasServerSideListSupport = FeatureSupport.None; /** * A reference to all the events ID at the bottom of the threads @@ -134,15 +151,23 @@ export class Thread extends TypedEventEmitter { this.emit(ThreadEvent.Update, this); } - public static setServerSideSupport(hasServerSideSupport: boolean, useStable: boolean): void { - Thread.hasServerSideSupport = hasServerSideSupport; - if (!useStable) { + public static setServerSideSupport( + status: FeatureSupport, + ): void { + Thread.hasServerSideSupport = status; + if (status !== FeatureSupport.Stable) { FILTER_RELATED_BY_SENDERS.setPreferUnstable(true); FILTER_RELATED_BY_REL_TYPES.setPreferUnstable(true); THREAD_RELATION_TYPE.setPreferUnstable(true); } } + public static setServerSideListSupport( + status: FeatureSupport, + ): void { + Thread.hasServerSideListSupport = status; + } + private onBeforeRedaction = (event: MatrixEvent, redaction: MatrixEvent) => { if (event?.isRelation(THREAD_RELATION_TYPE.name) && this.room.eventShouldLiveIn(event).threadId === this.id && @@ -335,7 +360,7 @@ export class Thread extends TypedEventEmitter { /** * Return last reply to the thread, if known. */ - public lastReply(matches: (ev: MatrixEvent) => boolean = () => true): Optional { + public lastReply(matches: (ev: MatrixEvent) => boolean = () => true): MatrixEvent | null { for (let i = this.events.length - 1; i >= 0; i--) { const event = this.events[i]; if (matches(event)) { @@ -381,10 +406,10 @@ export class Thread extends TypedEventEmitter { return this.timelineSet.getLiveTimeline(); } - public async fetchEvents(opts: IRelationsRequestOpts = { limit: 20, direction: Direction.Backward }): Promise<{ + public async fetchEvents(opts: IRelationsRequestOpts = { limit: 20, dir: Direction.Backward }): Promise<{ originalEvent: MatrixEvent; events: MatrixEvent[]; - nextBatch?: string; + nextBatch?: string | null; prevBatch?: string; }> { let { @@ -413,7 +438,7 @@ export class Thread extends TypedEventEmitter { return this.client.decryptEventIfNeeded(event); })); - const prependEvents = (opts.direction ?? Direction.Backward) === Direction.Backward; + const prependEvents = (opts.dir ?? Direction.Backward) === Direction.Backward; this.timelineSet.addEventsToTimeline( events, @@ -429,6 +454,18 @@ export class Thread extends TypedEventEmitter { nextBatch, }; } + + public getUnfilteredTimelineSet(): EventTimelineSet { + return this.timelineSet; + } + + public get timeline(): MatrixEvent[] { + return this.events; + } + + public addReceipt(event: MatrixEvent, synthetic: boolean): void { + throw new Error("Unsupported function on the thread model"); + } } export const FILTER_RELATED_BY_SENDERS = new ServerControlledNamespacedValue( diff --git a/src/pushprocessor.ts b/src/pushprocessor.ts index 4e736c3a1..1caa78953 100644 --- a/src/pushprocessor.ts +++ b/src/pushprocessor.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { escapeRegExp, globToRegexp, isNullOrUndefined } from "./utils"; +import { deepCompare, escapeRegExp, globToRegexp, isNullOrUndefined } from "./utils"; import { logger } from './logger'; import { MatrixClient } from "./client"; import { MatrixEvent } from "./models/event"; @@ -91,6 +91,20 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [ ], actions: [], }, + { + // For homeservers which don't support MSC3401 yet + rule_id: ".org.matrix.msc3401.rule.room.call", + default: true, + enabled: true, + conditions: [ + { + kind: ConditionKind.EventMatch, + key: "type", + pattern: "org.matrix.msc3401.call", + }, + ], + actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Sound, value: "default" }], + }, ]; export interface IActionsObject { @@ -424,7 +438,7 @@ export class PushProcessor { return {} as IActionsObject; } - const actionObj = PushProcessor.actionListToActionsObject(rule.actions); + let actionObj = PushProcessor.actionListToActionsObject(rule.actions); // Some actions are implicit in some situations: we add those here if (actionObj.tweaks.highlight === undefined) { @@ -433,6 +447,30 @@ export class PushProcessor { actionObj.tweaks.highlight = (rule.kind == PushRuleKind.ContentSpecific); } + actionObj = this.performCustomEventHandling(ev, actionObj); + + return actionObj; + } + + /** + * Some events require custom event handling e.g. due to missing server support + */ + private performCustomEventHandling(ev: MatrixEvent, actionObj: IActionsObject): IActionsObject { + switch (ev.getType()) { + case "m.call": + case "org.matrix.msc3401.call": + // Since servers don't support properly sending push notification + // about MSC3401 call events, we do the handling ourselves + if ( + ev.getContent()["m.intent"] === "m.room" + || ("m.terminated" in ev.getContent()) + || !("m.terminated" in ev.getPrevContent()) && !deepCompare(ev.getPrevContent(), {}) + ) { + actionObj.notify = false; + actionObj.tweaks = {}; + } + } + return actionObj; } diff --git a/src/sliding-sync-sdk.ts b/src/sliding-sync-sdk.ts index 8c749f926..21d2af63f 100644 --- a/src/sliding-sync-sdk.ts +++ b/src/sliding-sync-sdk.ts @@ -293,13 +293,16 @@ export class SlidingSyncSdk { this.processRoomData(this.client, room, roomData); } - private onLifecycle(state: SlidingSyncState, resp: MSC3575SlidingSyncResponse, err?: Error): void { + private onLifecycle(state: SlidingSyncState, resp: MSC3575SlidingSyncResponse | null, err: Error | null): void { if (err) { logger.debug("onLifecycle", state, err); } switch (state) { case SlidingSyncState.Complete: this.purgeNotifications(); + if (!resp) { + break; + } // Element won't stop showing the initial loading spinner unless we fire SyncState.Prepared if (!this.lastPos) { this.updateSyncState(SyncState.Prepared, { @@ -472,6 +475,13 @@ export class SlidingSyncSdk { } } + if (Number.isInteger(roomData.invited_count)) { + room.currentState.setInvitedMemberCount(roomData.invited_count!); + } + if (Number.isInteger(roomData.joined_count)) { + room.currentState.setJoinedMemberCount(roomData.joined_count!); + } + if (roomData.invite_state) { const inviteStateEvents = mapEvents(this.client, room.roomId, roomData.invite_state); this.processRoomEvents(room, inviteStateEvents); @@ -551,6 +561,10 @@ export class SlidingSyncSdk { // we deliberately don't add ephemeral events to the timeline room.addEphemeralEvents(ephemeralEvents); + // local fields must be set before any async calls because call site assumes + // synchronous execution prior to emitting SlidingSyncState.Complete + room.updateMyMembership("join"); + room.recalculate(); if (roomData.initial) { client.store.storeRoom(room); @@ -574,8 +588,6 @@ export class SlidingSyncSdk { client.emit(ClientEvent.Event, e); }); - room.updateMyMembership("join"); - // Decrypt only the last message in all rooms to make sure we can generate a preview // And decrypt all events after the recorded read receipt to ensure an accurate // notification count diff --git a/src/sliding-sync.ts b/src/sliding-sync.ts index 4aa11dfa7..235f8ad40 100644 --- a/src/sliding-sync.ts +++ b/src/sliding-sync.ts @@ -84,6 +84,8 @@ export interface MSC3575RoomData { timeline: (IRoomEvent | IStateEvent)[]; notification_count?: number; highlight_count?: number; + joined_count?: number; + invited_count?: number; invite_state?: IStateEvent[]; initial?: boolean; limited?: boolean; @@ -320,7 +322,9 @@ export enum SlidingSyncEvent { export type SlidingSyncEventHandlerMap = { [SlidingSyncEvent.RoomData]: (roomId: string, roomData: MSC3575RoomData) => void; - [SlidingSyncEvent.Lifecycle]: (state: SlidingSyncState, resp: MSC3575SlidingSyncResponse, err: Error) => void; + [SlidingSyncEvent.Lifecycle]: ( + state: SlidingSyncState, resp: MSC3575SlidingSyncResponse | null, err: Error | null, + ) => void; [SlidingSyncEvent.List]: ( listIndex: number, joinedCount: number, roomIndexToRoomId: Record, ) => void; diff --git a/src/store/index.ts b/src/store/index.ts index ee3137cc5..3645b598b 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -162,7 +162,7 @@ export interface IStore { * Get account data event by event type * @param {string} eventType The event type being queried */ - getAccountData(eventType: EventType | string): MatrixEvent; + getAccountData(eventType: EventType | string): MatrixEvent | undefined; /** * setSyncData does nothing as there is no backing data store. diff --git a/src/store/indexeddb-backend.ts b/src/store/indexeddb-backend.ts index 93d1cb3ab..3a14fed7d 100644 --- a/src/store/indexeddb-backend.ts +++ b/src/store/indexeddb-backend.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { ISavedSync } from "./index"; -import { IEvent, IStartClientOpts, IStateEventWithRoomId, ISyncResponse } from ".."; +import { IEvent, IStartClientOpts, IStateEventWithRoomId, ISyncResponse } from "../matrix"; import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage"; export interface IIndexedDBBackend { diff --git a/src/store/indexeddb-local-backend.ts b/src/store/indexeddb-local-backend.ts index 7e4bd7673..908ecec9e 100644 --- a/src/store/indexeddb-local-backend.ts +++ b/src/store/indexeddb-local-backend.ts @@ -18,41 +18,36 @@ import { IMinimalEvent, ISyncData, ISyncResponse, SyncAccumulator } from "../syn import * as utils from "../utils"; import * as IndexedDBHelpers from "../indexeddb-helpers"; import { logger } from '../logger'; -import { IStartClientOpts, IStateEventWithRoomId } from ".."; +import { IStartClientOpts, IStateEventWithRoomId } from "../matrix"; import { ISavedSync } from "./index"; import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend"; import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage"; -const VERSION = 4; +type DbMigration = (db: IDBDatabase) => void; +const DB_MIGRATIONS: DbMigration[] = [ + (db) => { + // Make user store, clobber based on user ID. (userId property of User objects) + db.createObjectStore("users", { keyPath: ["userId"] }); -function createDatabase(db: IDBDatabase): void { - // Make user store, clobber based on user ID. (userId property of User objects) - db.createObjectStore("users", { keyPath: ["userId"] }); + // Make account data store, clobber based on event type. + // (event.type property of MatrixEvent objects) + db.createObjectStore("accountData", { keyPath: ["type"] }); - // Make account data store, clobber based on event type. - // (event.type property of MatrixEvent objects) - db.createObjectStore("accountData", { keyPath: ["type"] }); - - // Make /sync store (sync tokens, room data, etc), always clobber (const key). - db.createObjectStore("sync", { keyPath: ["clobber"] }); -} - -function upgradeSchemaV2(db: IDBDatabase): void { - const oobMembersStore = db.createObjectStore( - "oob_membership_events", { - keyPath: ["room_id", "state_key"], - }); - oobMembersStore.createIndex("room", "room_id"); -} - -function upgradeSchemaV3(db: IDBDatabase): void { - db.createObjectStore("client_options", - { keyPath: ["clobber"] }); -} - -function upgradeSchemaV4(db: IDBDatabase): void { - db.createObjectStore("to_device_queue", { autoIncrement: true }); -} + // Make /sync store (sync tokens, room data, etc), always clobber (const key). + db.createObjectStore("sync", { keyPath: ["clobber"] }); + }, + (db) => { + const oobMembersStore = db.createObjectStore( + "oob_membership_events", { + keyPath: ["room_id", "state_key"], + }); + oobMembersStore.createIndex("room", "room_id"); + }, + (db) => { db.createObjectStore("client_options", { keyPath: ["clobber"] }); }, + (db) => { db.createObjectStore("to_device_queue", { autoIncrement: true }); }, + // Expand as needed. +]; +const VERSION = DB_MIGRATIONS.length; /** * Helper method to collect results from a Cursor and promiseify it. @@ -172,20 +167,13 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { logger.log( `LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`, ); - if (oldVersion < 1) { // The database did not previously exist. + if (oldVersion < 1) { + // The database did not previously exist this._isNewlyCreated = true; - createDatabase(db); } - if (oldVersion < 2) { - upgradeSchemaV2(db); - } - if (oldVersion < 3) { - upgradeSchemaV3(db); - } - if (oldVersion < 4) { - upgradeSchemaV4(db); - } - // Expand as needed. + DB_MIGRATIONS.forEach((migration, index) => { + if (oldVersion <= index) migration(db); + }); }; req.onblocked = () => { diff --git a/src/store/indexeddb-remote-backend.ts b/src/store/indexeddb-remote-backend.ts index 67ab2ccd2..8be023f2b 100644 --- a/src/store/indexeddb-remote-backend.ts +++ b/src/store/indexeddb-remote-backend.ts @@ -18,7 +18,7 @@ import { logger } from "../logger"; import { defer, IDeferred } from "../utils"; import { ISavedSync } from "./index"; import { IStartClientOpts } from "../client"; -import { IStateEventWithRoomId, ISyncResponse } from ".."; +import { IStateEventWithRoomId, ISyncResponse } from "../matrix"; import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend"; import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage"; diff --git a/src/store/memory.ts b/src/store/memory.ts index 0ed43a5b5..b44f24ca4 100644 --- a/src/store/memory.ts +++ b/src/store/memory.ts @@ -227,7 +227,7 @@ export class MemoryStore implements IStore { * @param {Filter} filter */ public storeFilter(filter: Filter): void { - if (!filter) { + if (!filter?.userId) { return; } if (!this.filters[filter.userId]) { diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index 08686c32d..ec60c1c3a 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -25,6 +25,7 @@ import { IContent, IUnsigned } from "./models/event"; import { IRoomSummary } from "./models/room-summary"; import { EventType } from "./@types/event"; import { ReceiptType } from "./@types/read_receipts"; +import { UNREAD_THREAD_NOTIFICATIONS } from './@types/sync'; interface IOpts { maxTimelineEntries?: number; @@ -41,7 +42,7 @@ export interface IEphemeral { } /* eslint-disable camelcase */ -interface IUnreadNotificationCounts { +interface UnreadNotificationCounts { highlight_count?: number; notification_count?: number; } @@ -66,7 +67,7 @@ interface IState { export interface ITimeline { events: Array; limited?: boolean; - prev_batch: string; + prev_batch: string | null; } export interface IJoinedRoom { @@ -75,7 +76,9 @@ export interface IJoinedRoom { timeline: ITimeline; ephemeral: IEphemeral; account_data: IAccountData; - unread_notifications: IUnreadNotificationCounts; + unread_notifications: UnreadNotificationCounts; + unread_thread_notifications?: Record; + "org.matrix.msc3773.unread_thread_notifications"?: Record; } export interface IStrippedState { @@ -153,7 +156,8 @@ interface IRoom { }[]; _summary: Partial; _accountData: { [eventType: string]: IMinimalEvent }; - _unreadNotifications: Partial; + _unreadNotifications: Partial; + _unreadThreadNotifications?: Record>; _readReceipts: { [userId: string]: { data: IMinimalEvent; @@ -362,6 +366,7 @@ export class SyncAccumulator { _timeline: [], _accountData: Object.create(null), _unreadNotifications: {}, + _unreadThreadNotifications: {}, _summary: {}, _readReceipts: {}, }; @@ -379,6 +384,10 @@ export class SyncAccumulator { if (data.unread_notifications) { currentData._unreadNotifications = data.unread_notifications; } + currentData._unreadThreadNotifications = data[UNREAD_THREAD_NOTIFICATIONS.stable] + ?? data[UNREAD_THREAD_NOTIFICATIONS.unstable] + ?? undefined; + if (data.summary) { const HEROES_KEY = "m.heroes"; const INVITED_COUNT_KEY = "m.invited_member_count"; @@ -401,7 +410,7 @@ export class SyncAccumulator { // typing forever until someone really does start typing (which // will prompt Synapse to send down an actual m.typing event to // clobber the one we persisted). - if (e.type !== "m.receipt" || !e.content) { + if (e.type !== EventType.Receipt || !e.content) { // This means we'll drop unknown ephemeral events but that // seems okay. return; @@ -528,7 +537,7 @@ export class SyncAccumulator { }); Object.keys(this.joinRooms).forEach((roomId) => { const roomData = this.joinRooms[roomId]; - const roomJson = { + const roomJson: IJoinedRoom = { ephemeral: { events: [] }, account_data: { events: [] }, state: { events: [] }, @@ -537,16 +546,17 @@ export class SyncAccumulator { prev_batch: null, }, unread_notifications: roomData._unreadNotifications, + unread_thread_notifications: roomData._unreadThreadNotifications, summary: roomData._summary as IRoomSummary, }; // Add account data Object.keys(roomData._accountData).forEach((evType) => { - roomJson.account_data.events.push(roomData._accountData[evType]); + roomJson.account_data.events.push(roomData._accountData[evType] as IMinimalEvent); }); // Add receipt data const receiptEvent = { - type: "m.receipt", + type: EventType.Receipt, room_id: roomId, content: { // $event_id: { "m.read": { $user_id: $json } } @@ -566,7 +576,7 @@ export class SyncAccumulator { }); // add only if we have some receipt data if (Object.keys(receiptEvent.content).length > 0) { - roomJson.ephemeral.events.push(receiptEvent); + roomJson.ephemeral.events.push(receiptEvent as IMinimalEvent); } // Add timeline data @@ -609,8 +619,8 @@ export class SyncAccumulator { const rollBackState = Object.create(null); for (let i = roomJson.timeline.events.length - 1; i >=0; i--) { const timelineEvent = roomJson.timeline.events[i]; - if (timelineEvent.state_key === null || - timelineEvent.state_key === undefined) { + if ((timelineEvent as IStateEvent).state_key === null || + (timelineEvent as IStateEvent).state_key === undefined) { continue; // not a state event } // since we're going back in time, we need to use the previous diff --git a/src/sync.ts b/src/sync.ts index 0a84c19a7..cee5e7f09 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -23,6 +23,8 @@ limitations under the License. * for HTTP and WS at some point. */ +import { Optional } from "matrix-events-sdk"; + import { User, UserEvent } from "./models/user"; import { NotificationCountType, Room, RoomEvent } from "./models/room"; import * as utils from "./utils"; @@ -56,6 +58,8 @@ import { RoomMemberEvent } from "./models/room-member"; import { BeaconEvent } from "./models/beacon"; import { IEventsResponse } from "./@types/requests"; import { IAbortablePromise } from "./@types/partials"; +import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync"; +import { Feature, ServerSupport } from "./feature"; const DEBUG = true; @@ -100,18 +104,16 @@ const MSC2716_ROOM_VERSIONS = [ function getFilterName(userId: string, suffix?: string): string { // scope this on the user ID because people may login on many accounts // and they all need to be stored! - return "FILTER_SYNC_" + userId + (suffix ? "_" + suffix : ""); + return `FILTER_SYNC_${userId}` + (suffix ? "_" + suffix : ""); } function debuglog(...params) { - if (!DEBUG) { - return; - } + if (!DEBUG) return; logger.log(...params); } interface ISyncOptions { - filterId?: string; + filter?: string; hasSyncedBefore?: boolean; } @@ -161,14 +163,14 @@ type WrappedRoom = T & { * updating presence. */ export class SyncApi { - private _peekRoom: Room = null; - private currentSyncRequest: IAbortablePromise = null; - private syncState: SyncState = null; - private syncStateData: ISyncStateData = null; // additional data (eg. error object for failed sync) + private _peekRoom: Optional = null; + private currentSyncRequest: Optional> = null; + private syncState: Optional = null; + private syncStateData: Optional = null; // additional data (eg. error object for failed sync) private catchingUp = false; private running = false; - private keepAliveTimer: ReturnType = null; - private connectionReturnedDefer: IDeferred = null; + private keepAliveTimer: Optional> = null; + private connectionReturnedDefer: Optional> = null; private notifEvents: MatrixEvent[] = []; // accumulator of sync events in the current sync response private failedSyncCount = 0; // Number of consecutive failed /sync requests private storeIsInvalid = false; // flag set if the store needs to be cleared before we can start @@ -214,7 +216,7 @@ export class SyncApi { * historical messages are shown when we paginate `/messages` again. * @param {Room} room The room where the marker event was sent * @param {MatrixEvent} markerEvent The new marker event - * @param {ISetStateOptions} setStateOptions When `timelineWasEmpty` is set + * @param {IMarkerFoundOptions} setStateOptions When `timelineWasEmpty` is set * as `true`, the given marker event will be ignored */ private onMarkerStateEvent( @@ -367,7 +369,7 @@ export class SyncApi { // XXX: copypasted from /sync until we kill off this minging v1 API stuff) // handle presence events (User objects) - if (response.presence && Array.isArray(response.presence)) { + if (Array.isArray(response.presence)) { response.presence.map(client.getEventMapper()).forEach( function(presenceEvent) { let user = client.store.getUser(presenceEvent.getContent().user_id); @@ -542,20 +544,139 @@ export class SyncApi { return false; } + private getPushRules = async () => { + try { + debuglog("Getting push rules..."); + const result = await this.client.getPushRules(); + debuglog("Got push rules"); + + this.client.pushRules = result; + } catch (err) { + logger.error("Getting push rules failed", err); + if (this.shouldAbortSync(err)) return; + // wait for saved sync to complete before doing anything else, + // otherwise the sync state will end up being incorrect + debuglog("Waiting for saved sync before retrying push rules..."); + await this.recoverFromSyncStartupError(this.savedSyncPromise, err); + return this.getPushRules(); // try again + } + }; + + private buildDefaultFilter = () => { + const filter = new Filter(this.client.credentials.userId); + if (this.client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported) { + filter.setUnreadThreadNotifications(true); + } + return filter; + }; + + private checkLazyLoadStatus = async () => { + debuglog("Checking lazy load status..."); + if (this.opts.lazyLoadMembers && this.client.isGuest()) { + this.opts.lazyLoadMembers = false; + } + if (this.opts.lazyLoadMembers) { + debuglog("Checking server lazy load support..."); + const supported = await this.client.doesServerSupportLazyLoading(); + if (supported) { + debuglog("Enabling lazy load on sync filter..."); + if (!this.opts.filter) { + this.opts.filter = this.buildDefaultFilter(); + } + this.opts.filter.setLazyLoadMembers(true); + } else { + debuglog("LL: lazy loading requested but not supported " + + "by server, so disabling"); + this.opts.lazyLoadMembers = false; + } + } + // need to vape the store when enabling LL and wasn't enabled before + debuglog("Checking whether lazy loading has changed in store..."); + const shouldClear = await this.wasLazyLoadingToggled(this.opts.lazyLoadMembers); + if (shouldClear) { + this.storeIsInvalid = true; + const reason = InvalidStoreError.TOGGLED_LAZY_LOADING; + const error = new InvalidStoreError(reason, !!this.opts.lazyLoadMembers); + this.updateSyncState(SyncState.Error, { error }); + // bail out of the sync loop now: the app needs to respond to this error. + // we leave the state as 'ERROR' which isn't great since this normally means + // we're retrying. The client must be stopped before clearing the stores anyway + // so the app should stop the client, clear the store and start it again. + logger.warn("InvalidStoreError: store is not usable: stopping sync."); + return; + } + if (this.opts.lazyLoadMembers) { + this.opts.crypto?.enableLazyLoading(); + } + try { + debuglog("Storing client options..."); + await this.client.storeClientOptions(); + debuglog("Stored client options"); + } catch (err) { + logger.error("Storing client options failed", err); + throw err; + } + }; + + private getFilter = async (): Promise<{ + filterId?: string; + filter?: Filter; + }> => { + debuglog("Getting filter..."); + let filter: Filter; + if (this.opts.filter) { + filter = this.opts.filter; + } else { + filter = this.buildDefaultFilter(); + } + + let filterId: string; + try { + filterId = await this.client.getOrCreateFilter(getFilterName(this.client.credentials.userId), filter); + } catch (err) { + logger.error("Getting filter failed", err); + if (this.shouldAbortSync(err)) return {}; + // wait for saved sync to complete before doing anything else, + // otherwise the sync state will end up being incorrect + debuglog("Waiting for saved sync before retrying filter..."); + await this.recoverFromSyncStartupError(this.savedSyncPromise, err); + return this.getFilter(); // try again + } + return { filter, filterId }; + }; + + private savedSyncPromise: Promise; + /** * Main entry point */ - public sync(): void { - const client = this.client; - + public async sync(): Promise { this.running = true; - if (global.window && global.window.addEventListener) { - global.window.addEventListener("online", this.onOnline, false); + global.window?.addEventListener?.("online", this.onOnline, false); + + if (this.client.isGuest()) { + // no push rules for guests, no access to POST filter for guests. + return this.doSync({}); } - let savedSyncPromise = Promise.resolve(); - let savedSyncToken = null; + // Pull the saved sync token out first, before the worker starts sending + // all the sync data which could take a while. This will let us send our + // first incremental sync request before we've processed our saved data. + debuglog("Getting saved sync token..."); + const savedSyncTokenPromise = this.client.store.getSavedSyncToken().then(tok => { + debuglog("Got saved sync token"); + return tok; + }); + + this.savedSyncPromise = this.client.store.getSavedSync().then((savedSync) => { + debuglog(`Got reply from saved sync, exists? ${!!savedSync}`); + if (savedSync) { + return this.syncFromCache(savedSync); + } + }).catch(err => { + logger.error("Getting saved sync failed", err); + }); // We need to do one-off checks before we can begin the /sync loop. // These are: @@ -565,149 +686,45 @@ export class SyncApi { // 3) We need to check the lazy loading option matches what was used in the // stored sync. If it doesn't, we can't use the stored sync. - const getPushRules = async () => { - try { - debuglog("Getting push rules..."); - const result = await client.getPushRules(); - debuglog("Got push rules"); + // Now start the first incremental sync request: this can also + // take a while so if we set it going now, we can wait for it + // to finish while we process our saved sync data. + await this.getPushRules(); + await this.checkLazyLoadStatus(); + const { filterId, filter } = await this.getFilter(); + if (!filter) return; // bail, getFilter failed - client.pushRules = result; - } catch (err) { - logger.error("Getting push rules failed", err); - if (this.shouldAbortSync(err)) return; - // wait for saved sync to complete before doing anything else, - // otherwise the sync state will end up being incorrect - debuglog("Waiting for saved sync before retrying push rules..."); - await this.recoverFromSyncStartupError(savedSyncPromise, err); - getPushRules(); - return; - } - checkLazyLoadStatus(); // advance to the next stage - }; + // reset the notifications timeline to prepare it to paginate from + // the current point in time. + // The right solution would be to tie /sync pagination tokens into + // /notifications API somehow. + this.client.resetNotifTimelineSet(); - const buildDefaultFilter = () => { - const filter = new Filter(client.credentials.userId); - filter.setTimelineLimit(this.opts.initialSyncLimit); - return filter; - }; + if (this.currentSyncRequest === null) { + let firstSyncFilter = filterId; + const savedSyncToken = await savedSyncTokenPromise; - const checkLazyLoadStatus = async () => { - debuglog("Checking lazy load status..."); - if (this.opts.lazyLoadMembers && client.isGuest()) { - this.opts.lazyLoadMembers = false; - } - if (this.opts.lazyLoadMembers) { - debuglog("Checking server lazy load support..."); - const supported = await client.doesServerSupportLazyLoading(); - if (supported) { - debuglog("Enabling lazy load on sync filter..."); - if (!this.opts.filter) { - this.opts.filter = buildDefaultFilter(); - } - this.opts.filter.setLazyLoadMembers(true); - } else { - debuglog("LL: lazy loading requested but not supported " + - "by server, so disabling"); - this.opts.lazyLoadMembers = false; - } - } - // need to vape the store when enabling LL and wasn't enabled before - debuglog("Checking whether lazy loading has changed in store..."); - const shouldClear = await this.wasLazyLoadingToggled(this.opts.lazyLoadMembers); - if (shouldClear) { - this.storeIsInvalid = true; - const reason = InvalidStoreError.TOGGLED_LAZY_LOADING; - const error = new InvalidStoreError(reason, !!this.opts.lazyLoadMembers); - this.updateSyncState(SyncState.Error, { error }); - // bail out of the sync loop now: the app needs to respond to this error. - // we leave the state as 'ERROR' which isn't great since this normally means - // we're retrying. The client must be stopped before clearing the stores anyway - // so the app should stop the client, clear the store and start it again. - logger.warn("InvalidStoreError: store is not usable: stopping sync."); - return; - } - if (this.opts.lazyLoadMembers && this.opts.crypto) { - this.opts.crypto.enableLazyLoading(); - } - try { - debuglog("Storing client options..."); - await this.client.storeClientOptions(); - debuglog("Stored client options"); - } catch (err) { - logger.error("Storing client options failed", err); - throw err; - } - - getFilter(); // Now get the filter and start syncing - }; - - const getFilter = async () => { - debuglog("Getting filter..."); - let filter; - if (this.opts.filter) { - filter = this.opts.filter; - } else { - filter = buildDefaultFilter(); - } - - let filterId; - try { - filterId = await client.getOrCreateFilter(getFilterName(client.credentials.userId), filter); - } catch (err) { - logger.error("Getting filter failed", err); - if (this.shouldAbortSync(err)) return; - // wait for saved sync to complete before doing anything else, - // otherwise the sync state will end up being incorrect - debuglog("Waiting for saved sync before retrying filter..."); - await this.recoverFromSyncStartupError(savedSyncPromise, err); - getFilter(); - return; - } - // reset the notifications timeline to prepare it to paginate from - // the current point in time. - // The right solution would be to tie /sync pagination tokens into - // /notifications API somehow. - client.resetNotifTimelineSet(); - - if (this.currentSyncRequest === null) { - // Send this first sync request here so we can then wait for the saved - // sync data to finish processing before we process the results of this one. + if (savedSyncToken) { debuglog("Sending first sync request..."); - this.currentSyncRequest = this.doSyncRequest({ filterId }, savedSyncToken); + } else { + debuglog("Sending initial sync request..."); + const initialFilter = this.buildDefaultFilter(); + initialFilter.setDefinition(filter.getDefinition()); + initialFilter.setTimelineLimit(this.opts.initialSyncLimit); + // Use an inline filter, no point uploading it for a single usage + firstSyncFilter = JSON.stringify(initialFilter.getDefinition()); } - // Now wait for the saved sync to finish... - debuglog("Waiting for saved sync before starting sync processing..."); - await savedSyncPromise; - this.doSync({ filterId }); - }; - - if (client.isGuest()) { - // no push rules for guests, no access to POST filter for guests. - this.doSync({}); - } else { - // Pull the saved sync token out first, before the worker starts sending - // all the sync data which could take a while. This will let us send our - // first incremental sync request before we've processed our saved data. - debuglog("Getting saved sync token..."); - savedSyncPromise = client.store.getSavedSyncToken().then((tok) => { - debuglog("Got saved sync token"); - savedSyncToken = tok; - debuglog("Getting saved sync..."); - return client.store.getSavedSync(); - }).then((savedSync) => { - debuglog(`Got reply from saved sync, exists? ${!!savedSync}`); - if (savedSync) { - return this.syncFromCache(savedSync); - } - }).catch(err => { - logger.error("Getting saved sync failed", err); - }); - // Now start the first incremental sync request: this can also - // take a while so if we set it going now, we can wait for it - // to finish while we process our saved sync data. - getPushRules(); + // Send this first sync request here so we can then wait for the saved + // sync data to finish processing before we process the results of this one. + this.currentSyncRequest = this.doSyncRequest({ filter: firstSyncFilter }, savedSyncToken); } + + // Now wait for the saved sync to finish... + debuglog("Waiting for saved sync before starting sync processing..."); + await this.savedSyncPromise; + // process the first sync request and continue syncing with the normal filterId + return this.doSync({ filter: filterId }); } /** @@ -719,9 +736,7 @@ export class SyncApi { // global.window AND global.window.removeEventListener. // Some platforms (e.g. React Native) register global.window, // but do not have global.window.removeEventListener. - if (global.window && global.window.removeEventListener) { - global.window.removeEventListener("online", this.onOnline, false); - } + global.window?.removeEventListener?.("online", this.onOnline, false); this.running = false; this.currentSyncRequest?.abort(); if (this.keepAliveTimer) { @@ -756,8 +771,7 @@ export class SyncApi { this.client.store.setSyncToken(nextSyncToken); // No previous sync, set old token to null - const syncEventData = { - oldSyncToken: null, + const syncEventData: ISyncStateData = { nextSyncToken, catchingUp: false, fromCache: true, @@ -792,7 +806,91 @@ export class SyncApi { * @param {boolean} syncOptions.hasSyncedBefore */ private async doSync(syncOptions: ISyncOptions): Promise { - const client = this.client; + while (this.running) { + const syncToken = this.client.store.getSyncToken(); + + let data: ISyncResponse; + try { + //debuglog('Starting sync since=' + syncToken); + if (this.currentSyncRequest === null) { + this.currentSyncRequest = this.doSyncRequest(syncOptions, syncToken); + } + data = await this.currentSyncRequest; + } catch (e) { + const abort = await this.onSyncError(e); + if (abort) return; + continue; + } finally { + this.currentSyncRequest = null; + } + + //debuglog('Completed sync, next_batch=' + data.next_batch); + + // set the sync token NOW *before* processing the events. We do this so + // if something barfs on an event we can skip it rather than constantly + // polling with the same token. + this.client.store.setSyncToken(data.next_batch); + + // Reset after a successful sync + this.failedSyncCount = 0; + + await this.client.store.setSyncData(data); + + const syncEventData = { + oldSyncToken: syncToken, + nextSyncToken: data.next_batch, + catchingUp: this.catchingUp, + }; + + if (this.opts.crypto) { + // tell the crypto module we're about to process a sync + // response + await this.opts.crypto.onSyncWillProcess(syncEventData); + } + + try { + await this.processSyncResponse(syncEventData, data); + } catch (e) { + // log the exception with stack if we have it, else fall back + // to the plain description + logger.error("Caught /sync error", e); + + // Emit the exception for client handling + this.client.emit(ClientEvent.SyncUnexpectedError, e); + } + + // update this as it may have changed + syncEventData.catchingUp = this.catchingUp; + + // emit synced events + if (!syncOptions.hasSyncedBefore) { + this.updateSyncState(SyncState.Prepared, syncEventData); + syncOptions.hasSyncedBefore = true; + } + + // tell the crypto module to do its processing. It may block (to do a + // /keys/changes request). + if (this.opts.crypto) { + await this.opts.crypto.onSyncCompleted(syncEventData); + } + + // keep emitting SYNCING -> SYNCING for clients who want to do bulk updates + this.updateSyncState(SyncState.Syncing, syncEventData); + + if (this.client.store.wantsSave()) { + // We always save the device list (if it's dirty) before saving the sync data: + // this means we know the saved device list data is at least as fresh as the + // stored sync data which means we don't have to worry that we may have missed + // device changes. We can also skip the delay since we're not calling this very + // frequently (and we don't really want to delay the sync for it). + if (this.opts.crypto) { + await this.opts.crypto.saveDeviceList(0); + } + + // tell databases that everything is now in a consistent state and can be saved. + this.client.store.save(); + } + } if (!this.running) { debuglog("Sync no longer running: exiting."); @@ -801,94 +899,7 @@ export class SyncApi { this.connectionReturnedDefer = null; } this.updateSyncState(SyncState.Stopped); - return; } - - const syncToken = client.store.getSyncToken(); - - let data; - try { - //debuglog('Starting sync since=' + syncToken); - if (this.currentSyncRequest === null) { - this.currentSyncRequest = this.doSyncRequest(syncOptions, syncToken); - } - data = await this.currentSyncRequest; - } catch (e) { - this.onSyncError(e, syncOptions); - return; - } finally { - this.currentSyncRequest = null; - } - - //debuglog('Completed sync, next_batch=' + data.next_batch); - - // set the sync token NOW *before* processing the events. We do this so - // if something barfs on an event we can skip it rather than constantly - // polling with the same token. - client.store.setSyncToken(data.next_batch); - - // Reset after a successful sync - this.failedSyncCount = 0; - - await client.store.setSyncData(data); - - const syncEventData = { - oldSyncToken: syncToken, - nextSyncToken: data.next_batch, - catchingUp: this.catchingUp, - }; - - if (this.opts.crypto) { - // tell the crypto module we're about to process a sync - // response - await this.opts.crypto.onSyncWillProcess(syncEventData); - } - - try { - await this.processSyncResponse(syncEventData, data); - } catch (e) { - // log the exception with stack if we have it, else fall back - // to the plain description - logger.error("Caught /sync error", e); - - // Emit the exception for client handling - this.client.emit(ClientEvent.SyncUnexpectedError, e); - } - - // update this as it may have changed - syncEventData.catchingUp = this.catchingUp; - - // emit synced events - if (!syncOptions.hasSyncedBefore) { - this.updateSyncState(SyncState.Prepared, syncEventData); - syncOptions.hasSyncedBefore = true; - } - - // tell the crypto module to do its processing. It may block (to do a - // /keys/changes request). - if (this.opts.crypto) { - await this.opts.crypto.onSyncCompleted(syncEventData); - } - - // keep emitting SYNCING -> SYNCING for clients who want to do bulk updates - this.updateSyncState(SyncState.Syncing, syncEventData); - - if (client.store.wantsSave()) { - // We always save the device list (if it's dirty) before saving the sync data: - // this means we know the saved device list data is at least as fresh as the - // stored sync data which means we don't have to worry that we may have missed - // device changes. We can also skip the delay since we're not calling this very - // frequently (and we don't really want to delay the sync for it). - if (this.opts.crypto) { - await this.opts.crypto.saveDeviceList(0); - } - - // tell databases that everything is now in a consistent state and can be saved. - client.store.save(); - } - - // Begin next sync - this.doSync(syncOptions); } private doSyncRequest(syncOptions: ISyncOptions, syncToken: string): IAbortablePromise { @@ -902,7 +913,7 @@ export class SyncApi { private getSyncParams(syncOptions: ISyncOptions, syncToken: string): ISyncParams { let pollTimeout = this.opts.pollTimeout; - if (this.getSyncState() !== 'SYNCING' || this.catchingUp) { + if (this.getSyncState() !== SyncState.Syncing || this.catchingUp) { // unless we are happily syncing already, we want the server to return // as quickly as possible, even if there are no events queued. This // serves two purposes: @@ -918,13 +929,13 @@ export class SyncApi { pollTimeout = 0; } - let filterId = syncOptions.filterId; - if (this.client.isGuest() && !filterId) { - filterId = this.getGuestFilter(); + let filter = syncOptions.filter; + if (this.client.isGuest() && !filter) { + filter = this.getGuestFilter(); } const qps: ISyncParams = { - filter: filterId, + filter, timeout: pollTimeout, }; @@ -941,7 +952,7 @@ export class SyncApi { qps._cacheBuster = Date.now(); } - if (this.getSyncState() == 'ERROR' || this.getSyncState() == 'RECONNECTING') { + if ([SyncState.Reconnecting, SyncState.Error].includes(this.getSyncState())) { // we think the connection is dead. If it comes back up, we won't know // about it till /sync returns. If the timeout= is high, this could // be a long time. Set it to 0 when doing retries so we don't have to wait @@ -952,7 +963,7 @@ export class SyncApi { return qps; } - private onSyncError(err: MatrixError, syncOptions: ISyncOptions): void { + private async onSyncError(err: MatrixError): Promise { if (!this.running) { debuglog("Sync no longer running: exiting"); if (this.connectionReturnedDefer) { @@ -960,14 +971,13 @@ export class SyncApi { this.connectionReturnedDefer = null; } this.updateSyncState(SyncState.Stopped); - return; + return true; // abort } logger.error("/sync error %s", err); - logger.error(err); if (this.shouldAbortSync(err)) { - return; + return true; // abort } this.failedSyncCount++; @@ -981,20 +991,7 @@ export class SyncApi { // erroneous. We set the state to 'reconnecting' // instead, so that clients can observe this state // if they wish. - this.startKeepAlives().then((connDidFail) => { - // Only emit CATCHUP if we detected a connectivity error: if we didn't, - // it's quite likely the sync will fail again for the same reason and we - // want to stay in ERROR rather than keep flip-flopping between ERROR - // and CATCHUP. - if (connDidFail && this.getSyncState() === SyncState.Error) { - this.updateSyncState(SyncState.Catchup, { - oldSyncToken: null, - nextSyncToken: null, - catchingUp: true, - }); - } - this.doSync(syncOptions); - }); + const keepAlivePromise = this.startKeepAlives(); this.currentSyncRequest = null; // Transition from RECONNECTING to ERROR after a given number of failed syncs @@ -1003,6 +1000,19 @@ export class SyncApi { SyncState.Error : SyncState.Reconnecting, { error: err }, ); + + const connDidFail = await keepAlivePromise; + + // Only emit CATCHUP if we detected a connectivity error: if we didn't, + // it's quite likely the sync will fail again for the same reason and we + // want to stay in ERROR rather than keep flip-flopping between ERROR + // and CATCHUP. + if (connDidFail && this.getSyncState() === SyncState.Error) { + this.updateSyncState(SyncState.Catchup, { + catchingUp: true, + }); + } + return false; } /** @@ -1061,7 +1071,7 @@ export class SyncApi { // - The isBrandNewRoom boilerplate is boilerplatey. // handle presence events (User objects) - if (data.presence && Array.isArray(data.presence.events)) { + if (Array.isArray(data.presence?.events)) { data.presence.events.map(client.getEventMapper()).forEach( function(presenceEvent) { let user = client.store.getUser(presenceEvent.getSender()); @@ -1077,7 +1087,7 @@ export class SyncApi { } // handle non-room account_data - if (data.account_data && Array.isArray(data.account_data.events)) { + if (Array.isArray(data.account_data?.events)) { const events = data.account_data.events.map(client.getEventMapper()); const prevEventsMap = events.reduce((m, c) => { m[c.getId()] = client.store.getAccountData(c.getType()); @@ -1105,7 +1115,20 @@ export class SyncApi { if (Array.isArray(data.to_device?.events) && data.to_device.events.length > 0) { const cancelledKeyVerificationTxns = []; data.to_device.events - .map(client.getEventMapper()) + .filter((eventJSON) => { + if ( + eventJSON.type === EventType.RoomMessageEncrypted && + !(["m.olm.v1.curve25519-aes-sha2"].includes(eventJSON.content?.algorithm)) + ) { + logger.log( + 'Ignoring invalid encrypted to-device event from ' + eventJSON.sender, + ); + return false; + } + + return true; + }) + .map(client.getEventMapper({ toDevice: true })) .map((toDeviceEvent) => { // map is a cheap inline forEach // We want to flag m.key.verification.start events as cancelled // if there's an accompanying m.key.verification.cancel event, so @@ -1181,6 +1204,27 @@ export class SyncApi { const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room); await this.processRoomEvents(room, stateEvents); + + const inviter = room.currentState.getStateEvents(EventType.RoomMember, client.getUserId())?.getSender(); + + if (client.isCryptoEnabled()) { + const parkedHistory = await client.crypto.cryptoStore.takeParkedSharedHistory(room.roomId); + for (const parked of parkedHistory) { + if (parked.senderId === inviter) { + await client.crypto.olmDevice.addInboundGroupSession( + room.roomId, + parked.senderKey, + parked.forwardingCurve25519KeyChain, + parked.sessionId, + parked.sessionKey, + parked.keysClaimed, + true, + { sharedHistory: true, untrusted: true }, + ); + } + } + } + if (inviteObj.isBrandNewRoom) { room.recalculate(); client.store.storeRoom(room); @@ -1218,8 +1262,7 @@ export class SyncApi { // bother setting it here. We trust our calculations better than the // server's for this case, and therefore will assume that our non-zero // count is accurate. - if (!encrypted - || (encrypted && room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0)) { + if (!encrypted || room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0) { room.setUnreadNotificationCount( NotificationCountType.Highlight, joinObj.unread_notifications.highlight_count, @@ -1227,13 +1270,37 @@ export class SyncApi { } } + room.resetThreadUnreadNotificationCount(); + const unreadThreadNotifications = joinObj[UNREAD_THREAD_NOTIFICATIONS.name] + ?? joinObj[UNREAD_THREAD_NOTIFICATIONS.altName]; + if (unreadThreadNotifications) { + Object.entries(unreadThreadNotifications).forEach(([threadId, unreadNotification]) => { + room.setThreadUnreadNotificationCount( + threadId, + NotificationCountType.Total, + unreadNotification.notification_count, + ); + + const hasNoNotifications = + room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight) <= 0; + if (!encrypted || (encrypted && hasNoNotifications)) { + room.setThreadUnreadNotificationCount( + threadId, + NotificationCountType.Highlight, + unreadNotification.highlight_count, + ); + } + }); + } + joinObj.timeline = joinObj.timeline || {} as ITimeline; if (joinObj.isBrandNewRoom) { // set the back-pagination token. Do this *before* adding any // events so that clients can start back-paginating. - room.getLiveTimeline().setPaginationToken( - joinObj.timeline.prev_batch, EventTimeline.BACKWARDS); + if (joinObj.timeline.prev_batch !== null) { + room.getLiveTimeline().setPaginationToken(joinObj.timeline.prev_batch, EventTimeline.BACKWARDS); + } } else if (joinObj.timeline.limited) { let limited = true; @@ -1286,7 +1353,11 @@ export class SyncApi { } } - await this.processRoomEvents(room, stateEvents, events, syncEventData.fromCache); + try { + await this.processRoomEvents(room, stateEvents, events, syncEventData.fromCache); + } catch (e) { + logger.error(`Failed to process events on room ${room.roomId}:`, e); + } // set summary after processing events, // because it will trigger a name calculation diff --git a/src/utils.ts b/src/utils.ts index 5dfeffece..e4b8b466e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -24,10 +24,34 @@ import unhomoglyph from "unhomoglyph"; import promiseRetry from "p-retry"; import type * as NodeCrypto from "crypto"; -import { MatrixClient, MatrixEvent } from "."; +import { MatrixEvent } from "./models/event"; import { M_TIMESTAMP } from "./@types/location"; import { ReceiptType } from "./@types/read_receipts"; +const interns = new Map(); + +/** + * Internalises a string, reusing a known pointer or storing the pointer + * if needed for future strings. + * @param str The string to internalise. + * @returns The internalised string. + */ +export function internaliseString(str: string): string { + // Unwrap strings before entering the map, if we somehow got a wrapped + // string as our input. This should only happen from tests. + if ((str as unknown) instanceof String) { + str = str.toString(); + } + + // Check the map to see if we can store the value + if (!interns.has(str)) { + interns.set(str, str); + } + + // Return any cached string reference + return interns.get(str); +} + /** * Encode a dictionary of query parameters. * Omits any undefined/null values. @@ -214,33 +238,24 @@ export function deepCompare(x: any, y: any): boolean { } } } else { - // disable jshint "The body of a for in should be wrapped in an if - // statement" - /* jshint -W089 */ - // check that all of y's direct keys are in x - let p; - for (p in y) { + for (const p in y) { if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) { return false; } } // finally, compare each of x's keys with y - for (p in y) { // eslint-disable-line guard-for-in - if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) { - return false; - } - if (!deepCompare(x[p], y[p])) { + for (const p in x) { + if (y.hasOwnProperty(p) !== x.hasOwnProperty(p) || !deepCompare(x[p], y[p])) { return false; } } } - /* jshint +W089 */ return true; } -// Dev note: This returns a tuple, but jsdoc doesn't like that. https://github.com/jsdoc/jsdoc/issues/1703 +// Dev note: This returns an array of tuples, but jsdoc doesn't like that. https://github.com/jsdoc/jsdoc/issues/1703 /** * Creates an array of object properties/values (entries) then * sorts the result by key, recursively. The input object must @@ -319,14 +334,15 @@ export function normalize(str: string): string { // Arabic Letter RTL mark U+061C // Combining characters U+0300 - U+036F // Zero width no-break space (BOM) U+FEFF +// Blank/invisible characters (U2800, U2062-U2063) // eslint-disable-next-line no-misleading-character-class -const removeHiddenCharsRegex = /[\u2000-\u200F\u202A-\u202F\u0300-\u036F\uFEFF\u061C\s]/g; +const removeHiddenCharsRegex = /[\u2000-\u200F\u202A-\u202F\u0300-\u036F\uFEFF\u061C\u2800\u2062-\u2063\s]/g; export function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -export function globToRegexp(glob: string, extended?: any): string { +export function globToRegexp(glob: string, extended = false): string { // From // https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132 // Because micromatch is about 130KB with dependencies, @@ -334,7 +350,7 @@ export function globToRegexp(glob: string, extended?: any): string { const replacements: ([RegExp, string | ((substring: string, ...args: any[]) => string) ])[] = [ [/\\\*/g, '.*'], [/\?/g, '.'], - extended !== false && [ + !extended && [ /\\\[(!|)(.*)\\]/g, (_match: string, neg: string, pat: string) => [ '[', @@ -655,17 +671,6 @@ export function sortEventsByLatestContentTimestamp(left: MatrixEvent, right: Mat return getContentTimestampWithFallback(right) - getContentTimestampWithFallback(left); } -export async function getPrivateReadReceiptField(client: MatrixClient): Promise { - if (await client.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) return ReceiptType.ReadPrivate; - if (await client.doesServerSupportUnstableFeature("org.matrix.msc2285")) return ReceiptType.UnstableReadPrivate; - return null; -} - export function isSupportedReceiptType(receiptType: string): boolean { - return [ - ReceiptType.Read, - ReceiptType.ReadPrivate, - ReceiptType.UnstableReadPrivate, - ].includes(receiptType as ReceiptType); + return [ReceiptType.Read, ReceiptType.ReadPrivate].includes(receiptType as ReceiptType); } - diff --git a/yarn.lock b/yarn.lock index e869e3751..bf9525555 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,17 +3,17 @@ "@actions/core@^1.4.0": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@actions/core/-/core-1.9.1.tgz#97c0201b1f9856df4f7c3a375cdcdb0c2a2f750b" - integrity sha512-5ad+U2YGrmmiw6du20AQW5XuWo7UKN2052FjSV7MX+Wfjf8sCqcsZe62NfgHys4QI4/Y+vQvLKYL8jWtA1ZBTA== + version "1.10.0" + resolved "https://registry.yarnpkg.com/@actions/core/-/core-1.10.0.tgz#44551c3c71163949a2f06e94d9ca2157a0cfac4f" + integrity sha512-2aZDDa3zrrZbP5ZYg159sNoLRb61nQ7awl5pSvIq5Qpj81vwDzdMRKzkWJGJuwVvWpvZKx7vspJALyvaaIQyug== dependencies: "@actions/http-client" "^2.0.1" uuid "^8.3.2" "@actions/github@^5.0.0": - version "5.0.3" - resolved "https://registry.yarnpkg.com/@actions/github/-/github-5.0.3.tgz#b305765d6173962d113451ea324ff675aa674f35" - integrity sha512-myjA/pdLQfhUGLtRZC/J4L1RXOG4o6aYdiEq+zr5wVVKljzbFld+xv10k1FX6IkIJtNxbAq44BdwSNpQ015P0A== + version "5.1.1" + resolved "https://registry.yarnpkg.com/@actions/github/-/github-5.1.1.tgz#40b9b9e1323a5efcf4ff7dadd33d8ea51651bbcb" + integrity sha512-Nk59rMDoJaV+mHCOJPXuvB1zIbomlKS0dmSIqPGxd0enAXBnOfn4VWF+CGtRCwXZG9Epa54tZA7VIRlJDS8A6g== dependencies: "@actions/http-client" "^2.0.1" "@octokit/core" "^3.6.0" @@ -36,9 +36,9 @@ "@jridgewell/trace-mapping" "^0.3.9" "@babel/cli@^7.12.10": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.18.10.tgz#4211adfc45ffa7d4f3cee6b60bb92e9fe68fe56a" - integrity sha512-dLvWH+ZDFAkd2jPBSghrsFBuXrREvFwjpDycXbmUoeochqKYe4zNSLEJYErpLg8dvxvZYe79/MkN461XCwpnGw== + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.19.3.tgz#55914ed388e658e0b924b3a95da1296267e278e2" + integrity sha512-643/TybmaCAe101m2tSVHi9UKpETXP9c/Ff4mD2tAwkdP6esKIfaauZFc67vGEM6r9fekbEGid+sZhbEnSe3dg== dependencies: "@jridgewell/trace-mapping" "^0.3.8" commander "^4.0.1" @@ -58,26 +58,26 @@ dependencies: "@babel/highlight" "^7.18.6" -"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.18.8": - version "7.18.8" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d" - integrity sha512-HSmX4WZPPK3FUxYp7g2T6EyO8j96HlZJlxmKPSh6KAcqwyDrfx7hKjXpAW/0FhFfTJsR0Yt4lAjLI2coMptIHQ== +"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.18.8", "@babel/compat-data@^7.19.3": + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.19.3.tgz#707b939793f867f5a73b2666e6d9a3396eb03151" + integrity sha512-prBHMK4JYYK+wDjJF1q99KK4JLL+egWS4nmNqdlMUgCExMZ+iZW0hGhyC3VEbsPjvaN0TBhW//VIFwBrk8sEiw== "@babel/core@^7.11.6", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.7.5": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.10.tgz#39ad504991d77f1f3da91be0b8b949a5bc466fb8" - integrity sha512-JQM6k6ENcBFKVtWvLavlvi/mPcpYZ3+R+2EySDEMSMbp7Mn4FexlbbJVrx2R7Ijhr01T8gyqrOaABWIOgxeUyw== + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.19.3.tgz#2519f62a51458f43b682d61583c3810e7dcee64c" + integrity sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ== dependencies: "@ampproject/remapping" "^2.1.0" "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.18.10" - "@babel/helper-compilation-targets" "^7.18.9" - "@babel/helper-module-transforms" "^7.18.9" - "@babel/helpers" "^7.18.9" - "@babel/parser" "^7.18.10" + "@babel/generator" "^7.19.3" + "@babel/helper-compilation-targets" "^7.19.3" + "@babel/helper-module-transforms" "^7.19.0" + "@babel/helpers" "^7.19.0" + "@babel/parser" "^7.19.3" "@babel/template" "^7.18.10" - "@babel/traverse" "^7.18.10" - "@babel/types" "^7.18.10" + "@babel/traverse" "^7.19.3" + "@babel/types" "^7.19.3" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.2" @@ -85,27 +85,27 @@ semver "^6.3.0" "@babel/eslint-parser@^7.12.10": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.18.9.tgz#255a63796819a97b7578751bb08ab9f2a375a031" - integrity sha512-KzSGpMBggz4fKbRbWLNyPVTuQr6cmCcBhOyXTw/fieOVaw5oYAwcAj4a7UKcDYCPxQq+CG1NCDZH9e2JTXquiQ== + version "7.19.1" + resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.19.1.tgz#4f68f6b0825489e00a24b41b6a1ae35414ecd2f4" + integrity sha512-AqNf2QWt1rtu2/1rLswy6CDP7H9Oh3mMhk177Y67Rg8d7RD9WfOLLv8CGn6tisFvS2htm86yIe1yLF6I1UDaGQ== dependencies: - eslint-scope "^5.1.1" + "@nicolo-ribaudo/eslint-scope-5-internals" "5.1.1-v1" eslint-visitor-keys "^2.1.0" semver "^6.3.0" "@babel/eslint-plugin@^7.12.10": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/eslint-plugin/-/eslint-plugin-7.18.10.tgz#11f454b5d1aa64c42fcfd64abe93071c15ebea3c" - integrity sha512-iV1OZj/7eg4wZIcsVEkXS3MUWdhmpLsu2h+9Zr2ppywKWdCRs6VfjxbRzmHHYeurTizrrnaJ9ZkbO8KOv4lauQ== + version "7.19.1" + resolved "https://registry.yarnpkg.com/@babel/eslint-plugin/-/eslint-plugin-7.19.1.tgz#8bfde4b6e4380ea038e7947a765fe536c3057a4c" + integrity sha512-ElGPkQPapKMa3zVqXHkZYzuL7I5LbRw9UWBUArgWsdWDDb9XcACqOpBib5tRPA9XvbVZYrFUkoQPbiJ4BFvu4w== dependencies: eslint-rule-composer "^0.3.0" -"@babel/generator@^7.12.11", "@babel/generator@^7.18.10", "@babel/generator@^7.7.2": - version "7.18.12" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.12.tgz#fa58daa303757bd6f5e4bbca91b342040463d9f4" - integrity sha512-dfQ8ebCN98SvyL7IxNMCUtZQSq5R7kxgN+r8qYTGDmmSion1hX2C0zq2yo1bsCDhXixokv1SAWTZUMYbO/V5zg== +"@babel/generator@^7.12.11", "@babel/generator@^7.19.3", "@babel/generator@^7.7.2": + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.19.3.tgz#d7f4d1300485b4547cb6f94b27d10d237b42bf59" + integrity sha512-fqVZnmp1ncvZU757UzDheKZpfPgatqY59XtW2/j/18H7u76akb8xqvjw82f+i2UKd/ksYsSick/BCLQUUtJ/qQ== dependencies: - "@babel/types" "^7.18.10" + "@babel/types" "^7.19.3" "@jridgewell/gen-mapping" "^0.3.2" jsesc "^2.5.1" @@ -124,41 +124,41 @@ "@babel/helper-explode-assignable-expression" "^7.18.6" "@babel/types" "^7.18.9" -"@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.18.9.tgz#69e64f57b524cde3e5ff6cc5a9f4a387ee5563bf" - integrity sha512-tzLCyVmqUiFlcFoAPLA/gL9TeYrF61VLNtb+hvkuVaB5SUjW7jcfrglBIX1vUIoT7CLP3bBlIMeyEsIl2eFQNg== +"@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.18.9", "@babel/helper-compilation-targets@^7.19.0", "@babel/helper-compilation-targets@^7.19.3": + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.3.tgz#a10a04588125675d7c7ae299af86fa1b2ee038ca" + integrity sha512-65ESqLGyGmLvgR0mst5AdW1FkNlj9rQsCKduzEoEPhBCDFGXvz2jW6bXFG6i0/MrV2s7hhXjjb2yAzcPuQlLwg== dependencies: - "@babel/compat-data" "^7.18.8" + "@babel/compat-data" "^7.19.3" "@babel/helper-validator-option" "^7.18.6" - browserslist "^4.20.2" + browserslist "^4.21.3" semver "^6.3.0" -"@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.18.9.tgz#d802ee16a64a9e824fcbf0a2ffc92f19d58550ce" - integrity sha512-WvypNAYaVh23QcjpMR24CwZY2Nz6hqdOcFdPbNpV56hL5H6KiFheO7Xm1aPdlLQ7d5emYZX7VZwPp9x3z+2opw== +"@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.19.0.tgz#bfd6904620df4e46470bae4850d66be1054c404b" + integrity sha512-NRz8DwF4jT3UfrmUoZjd0Uph9HQnP30t7Ash+weACcyNkiYTywpIjDBgReJMKgr+n86sn2nPVVmJ28Dm053Kqw== dependencies: "@babel/helper-annotate-as-pure" "^7.18.6" "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.18.9" + "@babel/helper-function-name" "^7.19.0" "@babel/helper-member-expression-to-functions" "^7.18.9" "@babel/helper-optimise-call-expression" "^7.18.6" "@babel/helper-replace-supers" "^7.18.9" "@babel/helper-split-export-declaration" "^7.18.6" -"@babel/helper-create-regexp-features-plugin@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.18.6.tgz#3e35f4e04acbbf25f1b3534a657610a000543d3c" - integrity sha512-7LcpH1wnQLGrI+4v+nPp+zUvIkF9x0ddv1Hkdue10tg3gmRnLy97DXh4STiOf1qeIInyD69Qv5kKSZzKD8B/7A== +"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.19.0.tgz#7976aca61c0984202baca73d84e2337a5424a41b" + integrity sha512-htnV+mHX32DF81amCDrwIDr8nrp1PTm+3wfBN9/v8QJOLEioOCOG7qNyq0nHeFiWbT3Eb7gsPwEmV64UCQ1jzw== dependencies: "@babel/helper-annotate-as-pure" "^7.18.6" regexpu-core "^5.1.0" -"@babel/helper-define-polyfill-provider@^0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.2.tgz#bd10d0aca18e8ce012755395b05a79f45eca5073" - integrity sha512-r9QJJ+uDWrd+94BSPcP6/de67ygLtvVy6cK4luE6MOuDsZIdoaPBnfSpbO/+LTifjPckbKXRuI9BB/Z2/y3iTg== +"@babel/helper-define-polyfill-provider@^0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz#8612e55be5d51f0cd1f36b4a5a83924e89884b7a" + integrity sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww== dependencies: "@babel/helper-compilation-targets" "^7.17.7" "@babel/helper-plugin-utils" "^7.16.7" @@ -179,13 +179,13 @@ dependencies: "@babel/types" "^7.18.6" -"@babel/helper-function-name@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.18.9.tgz#940e6084a55dee867d33b4e487da2676365e86b0" - integrity sha512-fJgWlZt7nxGksJS9a0XdSaI4XvpExnNIgRP+rVefWh5U7BL8pPuir6SJUmFKRfjWQ51OtWSzwOxhaH/EBWWc0A== +"@babel/helper-function-name@^7.18.9", "@babel/helper-function-name@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz#941574ed5390682e872e52d3f38ce9d1bef4648c" + integrity sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w== dependencies: - "@babel/template" "^7.18.6" - "@babel/types" "^7.18.9" + "@babel/template" "^7.18.10" + "@babel/types" "^7.19.0" "@babel/helper-hoist-variables@^7.18.6": version "7.18.6" @@ -208,19 +208,19 @@ dependencies: "@babel/types" "^7.18.6" -"@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.18.9.tgz#5a1079c005135ed627442df31a42887e80fcb712" - integrity sha512-KYNqY0ICwfv19b31XzvmI/mfcylOzbLtowkw+mfvGPAQ3kfCnMLYbED3YecL5tPd8nAYFQFAd6JHp2LxZk/J1g== +"@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz#309b230f04e22c58c6a2c0c0c7e50b216d350c30" + integrity sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ== dependencies: "@babel/helper-environment-visitor" "^7.18.9" "@babel/helper-module-imports" "^7.18.6" "@babel/helper-simple-access" "^7.18.6" "@babel/helper-split-export-declaration" "^7.18.6" "@babel/helper-validator-identifier" "^7.18.6" - "@babel/template" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" + "@babel/template" "^7.18.10" + "@babel/traverse" "^7.19.0" + "@babel/types" "^7.19.0" "@babel/helper-optimise-call-expression@^7.18.6": version "7.18.6" @@ -229,10 +229,10 @@ dependencies: "@babel/types" "^7.18.6" -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.18.9.tgz#4b8aea3b069d8cb8a72cdfe28ddf5ceca695ef2f" - integrity sha512-aBXPT3bmtLryXaoJLyYPXPlSD4p1ld9aYeR+sJNOZjJJGiOpb+fKfh3NkcCu7J54nUJwCERPBExCCpyCOHnu/w== +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.19.0", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz#4796bb14961521f0f8715990bee2fb6e51ce21bf" + integrity sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw== "@babel/helper-remap-async-to-generator@^7.18.6", "@babel/helper-remap-async-to-generator@^7.18.9": version "7.18.9" @@ -245,15 +245,15 @@ "@babel/types" "^7.18.9" "@babel/helper-replace-supers@^7.18.6", "@babel/helper-replace-supers@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.18.9.tgz#1092e002feca980fbbb0bd4d51b74a65c6a500e6" - integrity sha512-dNsWibVI4lNT6HiuOIBr1oyxo40HvIVmbwPUm3XZ7wMh4k2WxrxTqZwSqw/eEmXDS9np0ey5M2bz9tBmO9c+YQ== + version "7.19.1" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.19.1.tgz#e1592a9b4b368aa6bdb8784a711e0bcbf0612b78" + integrity sha512-T7ahH7wV0Hfs46SFh5Jz3s0B6+o8g3c+7TMxu7xKfmHikg7EAZ3I2Qk9LFhjxXq8sL7UkP5JflezNwoZa8WvWw== dependencies: "@babel/helper-environment-visitor" "^7.18.9" "@babel/helper-member-expression-to-functions" "^7.18.9" "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" + "@babel/traverse" "^7.19.1" + "@babel/types" "^7.19.0" "@babel/helper-simple-access@^7.18.6": version "7.18.6" @@ -281,10 +281,10 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz#181f22d28ebe1b3857fa575f5c290b1aaf659b56" integrity sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw== -"@babel/helper-validator-identifier@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076" - integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g== +"@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.19.1": + version "7.19.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" + integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== "@babel/helper-validator-option@^7.18.6": version "7.18.6" @@ -292,23 +292,23 @@ integrity sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw== "@babel/helper-wrap-function@^7.18.9": - version "7.18.11" - resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.18.11.tgz#bff23ace436e3f6aefb61f85ffae2291c80ed1fb" - integrity sha512-oBUlbv+rjZLh2Ks9SKi4aL7eKaAXBWleHzU89mP0G6BMUlRxSckk9tSIkgDGydhgFxHuGSlBQZfnaD47oBEB7w== + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.19.0.tgz#89f18335cff1152373222f76a4b37799636ae8b1" + integrity sha512-txX8aN8CZyYGTwcLhlk87KRqncAzhh5TpQamZUa0/u3an36NtDpUP6bQgBCBcLeBs09R/OwQu3OjK0k/HwfNDg== dependencies: - "@babel/helper-function-name" "^7.18.9" + "@babel/helper-function-name" "^7.19.0" "@babel/template" "^7.18.10" - "@babel/traverse" "^7.18.11" - "@babel/types" "^7.18.10" + "@babel/traverse" "^7.19.0" + "@babel/types" "^7.19.0" -"@babel/helpers@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.18.9.tgz#4bef3b893f253a1eced04516824ede94dcfe7ff9" - integrity sha512-Jf5a+rbrLoR4eNdUmnFu8cN5eNJT6qdTdOg5IHIzq87WwyRw9PwguLFOWYgktN/60IP4fgDUawJvs7PjQIzELQ== +"@babel/helpers@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.19.0.tgz#f30534657faf246ae96551d88dd31e9d1fa1fc18" + integrity sha512-DRBCKGwIEdqY3+rPJgG/dKfQy9+08rHIAJx8q2p+HSWP87s2HCrQmaAMMyMll2kIXKCW0cO1RdQskx15Xakftg== dependencies: - "@babel/template" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" + "@babel/template" "^7.18.10" + "@babel/traverse" "^7.19.0" + "@babel/types" "^7.19.0" "@babel/highlight@^7.18.6": version "7.18.6" @@ -319,10 +319,10 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.18.11", "@babel/parser@^7.2.3", "@babel/parser@^7.9.4": - version "7.18.11" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.11.tgz#68bb07ab3d380affa9a3f96728df07969645d2d9" - integrity sha512-9JKn5vN+hDt0Hdqn1PiJ2guflwP+B6Ga8qbDuoF0PzzVhrzsKIJo8yGqVk6CmMHiMei9w1C1Bp9IMJSIK+HPIQ== +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.19.3", "@babel/parser@^7.2.3", "@babel/parser@^7.9.4": + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.19.3.tgz#8dd36d17c53ff347f9e55c328710321b49479a9a" + integrity sha512-pJ9xOlNWHiy9+FuFP09DEAFbAn4JskgRsVcc169w2xRBC3FRGuQEwjeIMMND9L2zc0iEhO/tGv4Zq+km+hxNpQ== "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": version "7.18.6" @@ -340,13 +340,13 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9" "@babel/plugin-proposal-optional-chaining" "^7.18.9" -"@babel/plugin-proposal-async-generator-functions@^7.18.10": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.18.10.tgz#85ea478c98b0095c3e4102bff3b67d306ed24952" - integrity sha512-1mFuY2TOsR1hxbjCo4QL+qlIjV07p4H4EUYw2J/WCqsvFV6V9X9z9YhXbWndc/4fw+hYGlDT7egYxliMp5O6Ew== +"@babel/plugin-proposal-async-generator-functions@^7.19.1": + version "7.19.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.19.1.tgz#34f6f5174b688529342288cd264f80c9ea9fb4a7" + integrity sha512-0yu8vNATgLy4ivqMNBIwb1HebCelqN7YX8SL3FDXORv/RqT0zEEWUCH4GH44JsSrvCu6GqnAdR5EBFAPeNBB4Q== dependencies: "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" + "@babel/helper-plugin-utils" "^7.19.0" "@babel/helper-remap-async-to-generator" "^7.18.9" "@babel/plugin-syntax-async-generators" "^7.8.4" @@ -532,6 +532,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" +"@babel/plugin-syntax-jsx@^7.7.2": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz#a8feef63b010150abd97f1649ec296e849943ca0" + integrity sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/plugin-syntax-logical-assignment-operators@^7.10.4", "@babel/plugin-syntax-logical-assignment-operators@^7.8.3": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" @@ -625,16 +632,17 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.9" -"@babel/plugin-transform-classes@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.18.9.tgz#90818efc5b9746879b869d5ce83eb2aa48bbc3da" - integrity sha512-EkRQxsxoytpTlKJmSPYrsOMjCILacAjtSVkd4gChEe2kXjFCun3yohhW5I7plXJhCemM0gKsaGMcO8tinvCA5g== +"@babel/plugin-transform-classes@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.19.0.tgz#0e61ec257fba409c41372175e7c1e606dc79bb20" + integrity sha512-YfeEE9kCjqTS9IitkgfJuxjcEtLUHMqa8yUJ6zdz8vR7hKuo6mOy2C05P0F1tdMmDCeuyidKnlrw/iTppHcr2A== dependencies: "@babel/helper-annotate-as-pure" "^7.18.6" + "@babel/helper-compilation-targets" "^7.19.0" "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.18.9" + "@babel/helper-function-name" "^7.19.0" "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.9" + "@babel/helper-plugin-utils" "^7.19.0" "@babel/helper-replace-supers" "^7.18.9" "@babel/helper-split-export-declaration" "^7.18.6" globals "^11.1.0" @@ -646,10 +654,10 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.9" -"@babel/plugin-transform-destructuring@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.9.tgz#68906549c021cb231bee1db21d3b5b095f8ee292" - integrity sha512-p5VCYNddPLkZTq4XymQIaIfZNJwT9YsjkPOhkVEqt6QIpQFZVM9IltqqYpOEkJoN1DPznmxUDyZ5CTZs/ZCuHA== +"@babel/plugin-transform-destructuring@^7.18.13": + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.13.tgz#9e03bc4a94475d62b7f4114938e6c5c33372cbf5" + integrity sha512-TodpQ29XekIsex2A+YJPj5ax2plkGa8YYY6mFjCohk/IG9IY42Rtuj1FuDeemfg2ipxIFLzPeA83SIBnlhSIow== dependencies: "@babel/helper-plugin-utils" "^7.18.9" @@ -725,14 +733,14 @@ "@babel/helper-simple-access" "^7.18.6" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-systemjs@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.18.9.tgz#545df284a7ac6a05125e3e405e536c5853099a06" - integrity sha512-zY/VSIbbqtoRoJKo2cDTewL364jSlZGvn0LKOf9ntbfxOvjfmyrdtEEOAdswOswhZEb8UH3jDkCKHd1sPgsS0A== +"@babel/plugin-transform-modules-systemjs@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.19.0.tgz#5f20b471284430f02d9c5059d9b9a16d4b085a1f" + integrity sha512-x9aiR0WXAWmOWsqcsnrzGR+ieaTMVyGyffPVA7F8cXAGt/UxefYv6uSHZLkAFChN5M5Iy1+wjE+xJuPt22H39A== dependencies: "@babel/helper-hoist-variables" "^7.18.6" - "@babel/helper-module-transforms" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" + "@babel/helper-module-transforms" "^7.19.0" + "@babel/helper-plugin-utils" "^7.19.0" "@babel/helper-validator-identifier" "^7.18.6" babel-plugin-dynamic-import-node "^2.3.3" @@ -744,13 +752,13 @@ "@babel/helper-module-transforms" "^7.18.6" "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-named-capturing-groups-regex@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.18.6.tgz#c89bfbc7cc6805d692f3a49bc5fc1b630007246d" - integrity sha512-UmEOGF8XgaIqD74bC8g7iV3RYj8lMf0Bw7NJzvnS9qQhM4mg+1WHKotUIdjxgD2RGrgFLZZPCFPFj3P/kVDYhg== +"@babel/plugin-transform-named-capturing-groups-regex@^7.19.1": + version "7.19.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.19.1.tgz#ec7455bab6cd8fb05c525a94876f435a48128888" + integrity sha512-oWk9l9WItWBQYS4FgXD4Uyy5kq898lvkXpXQxoJEY1RnvPk4R/Dvu2ebXU9q8lP+rlMwUQTFf2Ok6d78ODa0kw== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-create-regexp-features-plugin" "^7.19.0" + "@babel/helper-plugin-utils" "^7.19.0" "@babel/plugin-transform-new-target@^7.18.6": version "7.18.6" @@ -797,15 +805,15 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/plugin-transform-runtime@^7.12.10": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.18.10.tgz#37d14d1fa810a368fd635d4d1476c0154144a96f" - integrity sha512-q5mMeYAdfEbpBAgzl7tBre/la3LeCxmDO1+wMXRdPWbcoMjR3GiXlCLk7JBZVVye0bqTGNMbt0yYVXX1B1jEWQ== + version "7.19.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.19.1.tgz#a3df2d7312eea624c7889a2dcd37fd1dfd25b2c6" + integrity sha512-2nJjTUFIzBMP/f/miLxEK9vxwW/KUXsdvN4sR//TmuDhe6yU2h57WmIOE12Gng3MDP/xpjUV/ToZRdcf8Yj4fA== dependencies: "@babel/helper-module-imports" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.9" - babel-plugin-polyfill-corejs2 "^0.3.2" - babel-plugin-polyfill-corejs3 "^0.5.3" - babel-plugin-polyfill-regenerator "^0.4.0" + "@babel/helper-plugin-utils" "^7.19.0" + babel-plugin-polyfill-corejs2 "^0.3.3" + babel-plugin-polyfill-corejs3 "^0.6.0" + babel-plugin-polyfill-regenerator "^0.4.1" semver "^6.3.0" "@babel/plugin-transform-shorthand-properties@^7.18.6": @@ -815,12 +823,12 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-spread@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.18.9.tgz#6ea7a6297740f381c540ac56caf75b05b74fb664" - integrity sha512-39Q814wyoOPtIB/qGopNIL9xDChOE1pNU0ZY5dO0owhiVt/5kFm4li+/bBtwc7QotG0u5EPzqhZdjMtmqBqyQA== +"@babel/plugin-transform-spread@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.19.0.tgz#dd60b4620c2fec806d60cfaae364ec2188d593b6" + integrity sha512-RsuMk7j6n+r752EtzyScnWkQyuJdli6LdO5Klv8Yx0OfPVTcQkIUfS8clx5e9yHXzlnhOZF3CbQ8C2uP5j074w== dependencies: - "@babel/helper-plugin-utils" "^7.18.9" + "@babel/helper-plugin-utils" "^7.19.0" "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9" "@babel/plugin-transform-sticky-regex@^7.18.6": @@ -845,12 +853,12 @@ "@babel/helper-plugin-utils" "^7.18.9" "@babel/plugin-transform-typescript@^7.18.6": - version "7.18.12" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.18.12.tgz#712e9a71b9e00fde9f8c0238e0cceee86ab2f8fd" - integrity sha512-2vjjam0cum0miPkenUbQswKowuxs/NjMwIKEq0zwegRxXk12C9YOF9STXnaUptITOtOJHKHpzvvWYOjbm6tc0w== + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.19.3.tgz#4f1db1e0fe278b42ddbc19ec2f6cd2f8262e35d6" + integrity sha512-z6fnuK9ve9u/0X0rRvI9MY0xg+DOUaABDYOe+/SQTxtlptaBB/V9JIUxJn6xp3lMBeb9qe8xSFmHU35oZDXD+w== dependencies: - "@babel/helper-create-class-features-plugin" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" + "@babel/helper-create-class-features-plugin" "^7.19.0" + "@babel/helper-plugin-utils" "^7.19.0" "@babel/plugin-syntax-typescript" "^7.18.6" "@babel/plugin-transform-unicode-escapes@^7.18.10": @@ -869,17 +877,17 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/preset-env@^7.12.11": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.18.10.tgz#83b8dfe70d7eea1aae5a10635ab0a5fe60dfc0f4" - integrity sha512-wVxs1yjFdW3Z/XkNfXKoblxoHgbtUF7/l3PvvP4m02Qz9TZ6uZGxRVYjSQeR87oQmHco9zWitW5J82DJ7sCjvA== + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.19.3.tgz#52cd19abaecb3f176a4ff9cc5e15b7bf06bec754" + integrity sha512-ziye1OTc9dGFOAXSWKUqQblYHNlBOaDl8wzqf2iKXJAltYiR3hKHUKmkt+S9PppW7RQpq4fFCrwwpIDj/f5P4w== dependencies: - "@babel/compat-data" "^7.18.8" - "@babel/helper-compilation-targets" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" + "@babel/compat-data" "^7.19.3" + "@babel/helper-compilation-targets" "^7.19.3" + "@babel/helper-plugin-utils" "^7.19.0" "@babel/helper-validator-option" "^7.18.6" "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.18.6" "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.18.9" - "@babel/plugin-proposal-async-generator-functions" "^7.18.10" + "@babel/plugin-proposal-async-generator-functions" "^7.19.1" "@babel/plugin-proposal-class-properties" "^7.18.6" "@babel/plugin-proposal-class-static-block" "^7.18.6" "@babel/plugin-proposal-dynamic-import" "^7.18.6" @@ -913,9 +921,9 @@ "@babel/plugin-transform-async-to-generator" "^7.18.6" "@babel/plugin-transform-block-scoped-functions" "^7.18.6" "@babel/plugin-transform-block-scoping" "^7.18.9" - "@babel/plugin-transform-classes" "^7.18.9" + "@babel/plugin-transform-classes" "^7.19.0" "@babel/plugin-transform-computed-properties" "^7.18.9" - "@babel/plugin-transform-destructuring" "^7.18.9" + "@babel/plugin-transform-destructuring" "^7.18.13" "@babel/plugin-transform-dotall-regex" "^7.18.6" "@babel/plugin-transform-duplicate-keys" "^7.18.9" "@babel/plugin-transform-exponentiation-operator" "^7.18.6" @@ -925,9 +933,9 @@ "@babel/plugin-transform-member-expression-literals" "^7.18.6" "@babel/plugin-transform-modules-amd" "^7.18.6" "@babel/plugin-transform-modules-commonjs" "^7.18.6" - "@babel/plugin-transform-modules-systemjs" "^7.18.9" + "@babel/plugin-transform-modules-systemjs" "^7.19.0" "@babel/plugin-transform-modules-umd" "^7.18.6" - "@babel/plugin-transform-named-capturing-groups-regex" "^7.18.6" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.19.1" "@babel/plugin-transform-new-target" "^7.18.6" "@babel/plugin-transform-object-super" "^7.18.6" "@babel/plugin-transform-parameters" "^7.18.8" @@ -935,18 +943,18 @@ "@babel/plugin-transform-regenerator" "^7.18.6" "@babel/plugin-transform-reserved-words" "^7.18.6" "@babel/plugin-transform-shorthand-properties" "^7.18.6" - "@babel/plugin-transform-spread" "^7.18.9" + "@babel/plugin-transform-spread" "^7.19.0" "@babel/plugin-transform-sticky-regex" "^7.18.6" "@babel/plugin-transform-template-literals" "^7.18.9" "@babel/plugin-transform-typeof-symbol" "^7.18.9" "@babel/plugin-transform-unicode-escapes" "^7.18.10" "@babel/plugin-transform-unicode-regex" "^7.18.6" "@babel/preset-modules" "^0.1.5" - "@babel/types" "^7.18.10" - babel-plugin-polyfill-corejs2 "^0.3.2" - babel-plugin-polyfill-corejs3 "^0.5.3" - babel-plugin-polyfill-regenerator "^0.4.0" - core-js-compat "^3.22.1" + "@babel/types" "^7.19.3" + babel-plugin-polyfill-corejs2 "^0.3.3" + babel-plugin-polyfill-corejs3 "^0.6.0" + babel-plugin-polyfill-regenerator "^0.4.1" + core-js-compat "^3.25.1" semver "^6.3.0" "@babel/preset-modules@^0.1.5": @@ -981,13 +989,13 @@ source-map-support "^0.5.16" "@babel/runtime@^7.12.5", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a" - integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw== + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259" + integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA== dependencies: regenerator-runtime "^0.13.4" -"@babel/template@^7.18.10", "@babel/template@^7.18.6", "@babel/template@^7.3.3": +"@babel/template@^7.18.10", "@babel/template@^7.3.3": version "7.18.10" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" integrity sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA== @@ -996,29 +1004,29 @@ "@babel/parser" "^7.18.10" "@babel/types" "^7.18.10" -"@babel/traverse@^7.1.6", "@babel/traverse@^7.18.10", "@babel/traverse@^7.18.11", "@babel/traverse@^7.18.9", "@babel/traverse@^7.7.2": - version "7.18.11" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.11.tgz#3d51f2afbd83ecf9912bcbb5c4d94e3d2ddaa16f" - integrity sha512-TG9PiM2R/cWCAy6BPJKeHzNbu4lPzOSZpeMfeNErskGpTJx6trEvFaVCbDvpcxwy49BKWmEPwiW8mrysNiDvIQ== +"@babel/traverse@^7.1.6", "@babel/traverse@^7.19.0", "@babel/traverse@^7.19.1", "@babel/traverse@^7.19.3", "@babel/traverse@^7.7.2": + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.19.3.tgz#3a3c5348d4988ba60884e8494b0592b2f15a04b4" + integrity sha512-qh5yf6149zhq2sgIXmwjnsvmnNQC2iw70UFjp4olxucKrWd/dvlUsBI88VSLUsnMNF7/vnOiA+nk1+yLoCqROQ== dependencies: "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.18.10" + "@babel/generator" "^7.19.3" "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.18.9" + "@babel/helper-function-name" "^7.19.0" "@babel/helper-hoist-variables" "^7.18.6" "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/parser" "^7.18.11" - "@babel/types" "^7.18.10" + "@babel/parser" "^7.19.3" + "@babel/types" "^7.19.3" debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.2.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.10.tgz#4908e81b6b339ca7c6b7a555a5fc29446f26dde6" - integrity sha512-MJvnbEiiNkpjo+LknnmRrqbY1GPUUggjv+wQVjetM/AONoupqRALB7I6jGqNUAZsKcRIEu2J6FRFvsczljjsaQ== +"@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.19.3", "@babel/types@^7.2.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.19.3.tgz#fc420e6bbe54880bce6779ffaf315f5e43ec9624" + integrity sha512-hGCaQzIY22DJlDh9CH7NOxgKkFjBk0Cw9xDO1Xmh2151ti7wiGfQ3LauXzL4HP1fmFlTX6XjpRETTpUcv7wQLw== dependencies: "@babel/helper-string-parser" "^7.18.10" - "@babel/helper-validator-identifier" "^7.18.6" + "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" "@bcoe/v8-coverage@^0.2.3": @@ -1026,14 +1034,14 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@eslint/eslintrc@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f" - integrity sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw== +"@eslint/eslintrc@^1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.2.tgz#58b69582f3b7271d8fa67fe5251767a5b38ea356" + integrity sha512-AXYd23w1S/bv3fTs3Lz0vjiYemS08jWkI3hYyS9I1ry+0f+Yjs1wm+sU0BS8qDOPrBIkp4qHYC16I8uVtpLajQ== dependencies: ajv "^6.12.4" debug "^4.3.2" - espree "^9.3.2" + espree "^9.4.0" globals "^13.15.0" ignore "^5.2.0" import-fresh "^3.2.1" @@ -1041,10 +1049,10 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@humanwhocodes/config-array@^0.10.4": - version "0.10.4" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c" - integrity sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw== +"@humanwhocodes/config-array@^0.10.5": + version "0.10.7" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.7.tgz#6d53769fd0c222767e6452e8ebda825c22e9f0dc" + integrity sha512-MDl6D6sBsaV452/QSdX+4CXIjZhIcI0PELsxUjk4U828yd58vk3bTIvk/6w5FY+4hIy9sLW0sfrV7K7Kc++j/w== dependencies: "@humanwhocodes/object-schema" "^1.2.1" debug "^4.1.1" @@ -1055,6 +1063,11 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d" integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA== +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + "@humanwhocodes/object-schema@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" @@ -1076,62 +1089,61 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== -"@jest/console@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/console/-/console-28.1.3.tgz#2030606ec03a18c31803b8a36382762e447655df" - integrity sha512-QPAkP5EwKdK/bxIr6C1I4Vs0rm2nHiANzj/Z5X2JQkrZo6IqvC4ldZ9K95tF0HdidhA8Bo6egxSzUFPYKcEXLw== +"@jest/console@^29.1.2": + version "29.1.2" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.1.2.tgz#0ae975a70004696f8320490fcaa1a4152f7b62e4" + integrity sha512-ujEBCcYs82BTmRxqfHMQggSlkUZP63AE5YEaTPj7eFyJOzukkTorstOUC7L6nE3w5SYadGVAnTsQ/ZjTGL0qYQ== dependencies: - "@jest/types" "^28.1.3" + "@jest/types" "^29.1.2" "@types/node" "*" chalk "^4.0.0" - jest-message-util "^28.1.3" - jest-util "^28.1.3" + jest-message-util "^29.1.2" + jest-util "^29.1.2" slash "^3.0.0" -"@jest/core@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/core/-/core-28.1.3.tgz#0ebf2bd39840f1233cd5f2d1e6fc8b71bd5a1ac7" - integrity sha512-CIKBrlaKOzA7YG19BEqCw3SLIsEwjZkeJzf5bdooVnW4bH5cktqe3JX+G2YV1aK5vP8N9na1IGWFzYaTp6k6NA== +"@jest/core@^29.1.2": + version "29.1.2" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-29.1.2.tgz#e5ce7a71e7da45156a96fb5eeed11d18b67bd112" + integrity sha512-sCO2Va1gikvQU2ynDN8V4+6wB7iVrD2CvT0zaRst4rglf56yLly0NQ9nuRRAWFeimRf+tCdFsb1Vk1N9LrrMPA== dependencies: - "@jest/console" "^28.1.3" - "@jest/reporters" "^28.1.3" - "@jest/test-result" "^28.1.3" - "@jest/transform" "^28.1.3" - "@jest/types" "^28.1.3" + "@jest/console" "^29.1.2" + "@jest/reporters" "^29.1.2" + "@jest/test-result" "^29.1.2" + "@jest/transform" "^29.1.2" + "@jest/types" "^29.1.2" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" ci-info "^3.2.0" exit "^0.1.2" graceful-fs "^4.2.9" - jest-changed-files "^28.1.3" - jest-config "^28.1.3" - jest-haste-map "^28.1.3" - jest-message-util "^28.1.3" - jest-regex-util "^28.0.2" - jest-resolve "^28.1.3" - jest-resolve-dependencies "^28.1.3" - jest-runner "^28.1.3" - jest-runtime "^28.1.3" - jest-snapshot "^28.1.3" - jest-util "^28.1.3" - jest-validate "^28.1.3" - jest-watcher "^28.1.3" + jest-changed-files "^29.0.0" + jest-config "^29.1.2" + jest-haste-map "^29.1.2" + jest-message-util "^29.1.2" + jest-regex-util "^29.0.0" + jest-resolve "^29.1.2" + jest-resolve-dependencies "^29.1.2" + jest-runner "^29.1.2" + jest-runtime "^29.1.2" + jest-snapshot "^29.1.2" + jest-util "^29.1.2" + jest-validate "^29.1.2" + jest-watcher "^29.1.2" micromatch "^4.0.4" - pretty-format "^28.1.3" - rimraf "^3.0.0" + pretty-format "^29.1.2" slash "^3.0.0" strip-ansi "^6.0.0" -"@jest/environment@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-28.1.3.tgz#abed43a6b040a4c24fdcb69eab1f97589b2d663e" - integrity sha512-1bf40cMFTEkKyEf585R9Iz1WayDjHoHqvts0XFYEqyKM3cFWDpeMoqKKTAF9LSYQModPUlh8FKptoM2YcMWAXA== +"@jest/environment@^29.1.2": + version "29.1.2" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.1.2.tgz#bb51a43fce9f960ba9a48f0b5b556f30618ebc0a" + integrity sha512-rG7xZ2UeOfvOVzoLIJ0ZmvPl4tBEQ2n73CZJSlzUjPw4or1oSWC0s0Rk0ZX+pIBJ04aVr6hLWFn1DFtrnf8MhQ== dependencies: - "@jest/fake-timers" "^28.1.3" - "@jest/types" "^28.1.3" + "@jest/fake-timers" "^29.1.2" + "@jest/types" "^29.1.2" "@types/node" "*" - jest-mock "^28.1.3" + jest-mock "^29.1.2" "@jest/expect-utils@^28.1.3": version "28.1.3" @@ -1140,46 +1152,54 @@ dependencies: jest-get-type "^28.0.2" -"@jest/expect@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-28.1.3.tgz#9ac57e1d4491baca550f6bdbd232487177ad6a72" - integrity sha512-lzc8CpUbSoE4dqT0U+g1qODQjBRHPpCPXissXD4mS9+sWQdmmpeJ9zSH1rS1HEkrsMN0fb7nKrJ9giAR1d3wBw== +"@jest/expect-utils@^29.1.2": + version "29.1.2" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.1.2.tgz#66dbb514d38f7d21456bc774419c9ae5cca3f88d" + integrity sha512-4a48bhKfGj/KAH39u0ppzNTABXQ8QPccWAFUFobWBaEMSMp+sB31Z2fK/l47c4a/Mu1po2ffmfAIPxXbVTXdtg== dependencies: - expect "^28.1.3" - jest-snapshot "^28.1.3" + jest-get-type "^29.0.0" -"@jest/fake-timers@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-28.1.3.tgz#230255b3ad0a3d4978f1d06f70685baea91c640e" - integrity sha512-D/wOkL2POHv52h+ok5Oj/1gOG9HSywdoPtFsRCUmlCILXNn5eIWmcnd3DIiWlJnpGvQtmajqBP95Ei0EimxfLw== +"@jest/expect@^29.1.2": + version "29.1.2" + resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-29.1.2.tgz#334a86395f621f1ab63ad95b06a588b9114d7b7a" + integrity sha512-FXw/UmaZsyfRyvZw3M6POgSNqwmuOXJuzdNiMWW9LCYo0GRoRDhg+R5iq5higmRTHQY7hx32+j7WHwinRmoILQ== dependencies: - "@jest/types" "^28.1.3" + expect "^29.1.2" + jest-snapshot "^29.1.2" + +"@jest/fake-timers@^29.1.2": + version "29.1.2" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.1.2.tgz#f157cdf23b4da48ce46cb00fea28ed1b57fc271a" + integrity sha512-GppaEqS+QQYegedxVMpCe2xCXxxeYwQ7RsNx55zc8f+1q1qevkZGKequfTASI7ejmg9WwI+SJCrHe9X11bLL9Q== + dependencies: + "@jest/types" "^29.1.2" "@sinonjs/fake-timers" "^9.1.2" "@types/node" "*" - jest-message-util "^28.1.3" - jest-mock "^28.1.3" - jest-util "^28.1.3" + jest-message-util "^29.1.2" + jest-mock "^29.1.2" + jest-util "^29.1.2" -"@jest/globals@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-28.1.3.tgz#a601d78ddc5fdef542728309894895b4a42dc333" - integrity sha512-XFU4P4phyryCXu1pbcqMO0GSQcYe1IsalYCDzRNyhetyeyxMcIxa11qPNDpVNLeretItNqEmYYQn1UYz/5x1NA== +"@jest/globals@^29.1.2": + version "29.1.2" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.1.2.tgz#826ede84bc280ae7f789cb72d325c48cd048b9d3" + integrity sha512-uMgfERpJYoQmykAd0ffyMq8wignN4SvLUG6orJQRe9WAlTRc9cdpCaE/29qurXixYJVZWUqIBXhSk8v5xN1V9g== dependencies: - "@jest/environment" "^28.1.3" - "@jest/expect" "^28.1.3" - "@jest/types" "^28.1.3" + "@jest/environment" "^29.1.2" + "@jest/expect" "^29.1.2" + "@jest/types" "^29.1.2" + jest-mock "^29.1.2" -"@jest/reporters@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-28.1.3.tgz#9adf6d265edafc5fc4a434cfb31e2df5a67a369a" - integrity sha512-JuAy7wkxQZVNU/V6g9xKzCGC5LVXx9FDcABKsSXp5MiKPEE2144a/vXTEDoyzjUpZKfVwp08Wqg5A4WfTMAzjg== +"@jest/reporters@^29.1.2": + version "29.1.2" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.1.2.tgz#5520898ed0a4ecf69d8b671e1dc8465d0acdfa6e" + integrity sha512-X4fiwwyxy9mnfpxL0g9DD0KcTmEIqP0jUdnc2cfa9riHy+I6Gwwp5vOZiwyg0vZxfSDxrOlK9S4+340W4d+DAA== dependencies: "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^28.1.3" - "@jest/test-result" "^28.1.3" - "@jest/transform" "^28.1.3" - "@jest/types" "^28.1.3" - "@jridgewell/trace-mapping" "^0.3.13" + "@jest/console" "^29.1.2" + "@jest/test-result" "^29.1.2" + "@jest/transform" "^29.1.2" + "@jest/types" "^29.1.2" + "@jridgewell/trace-mapping" "^0.3.15" "@types/node" "*" chalk "^4.0.0" collect-v8-coverage "^1.0.0" @@ -1191,9 +1211,9 @@ istanbul-lib-report "^3.0.0" istanbul-lib-source-maps "^4.0.0" istanbul-reports "^3.1.3" - jest-message-util "^28.1.3" - jest-util "^28.1.3" - jest-worker "^28.1.3" + jest-message-util "^29.1.2" + jest-util "^29.1.2" + jest-worker "^29.1.2" slash "^3.0.0" string-length "^4.0.1" strip-ansi "^6.0.0" @@ -1207,56 +1227,74 @@ dependencies: "@sinclair/typebox" "^0.24.1" -"@jest/source-map@^28.1.2": - version "28.1.2" - resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-28.1.2.tgz#7fe832b172b497d6663cdff6c13b0a920e139e24" - integrity sha512-cV8Lx3BeStJb8ipPHnqVw/IM2VCMWO3crWZzYodSIkxXnRcXJipCdx1JCK0K5MsJJouZQTH73mzf4vgxRaH9ww== +"@jest/schemas@^29.0.0": + version "29.0.0" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.0.0.tgz#5f47f5994dd4ef067fb7b4188ceac45f77fe952a" + integrity sha512-3Ab5HgYIIAnS0HjqJHQYZS+zXc4tUmTmBH3z83ajI6afXp8X3ZtdLX+nXx+I7LNkJD7uN9LAVhgnjDgZa2z0kA== dependencies: - "@jridgewell/trace-mapping" "^0.3.13" + "@sinclair/typebox" "^0.24.1" + +"@jest/source-map@^29.0.0": + version "29.0.0" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-29.0.0.tgz#f8d1518298089f8ae624e442bbb6eb870ee7783c" + integrity sha512-nOr+0EM8GiHf34mq2GcJyz/gYFyLQ2INDhAylrZJ9mMWoW21mLBfZa0BUVPPMxVYrLjeiRe2Z7kWXOGnS0TFhQ== + dependencies: + "@jridgewell/trace-mapping" "^0.3.15" callsites "^3.0.0" graceful-fs "^4.2.9" -"@jest/test-result@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-28.1.3.tgz#5eae945fd9f4b8fcfce74d239e6f725b6bf076c5" - integrity sha512-kZAkxnSE+FqE8YjW8gNuoVkkC9I7S1qmenl8sGcDOLropASP+BkcGKwhXoyqQuGOGeYY0y/ixjrd/iERpEXHNg== +"@jest/test-result@^29.1.2": + version "29.1.2" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-29.1.2.tgz#6a8d006eb2b31ce0287d1fc10d12b8ff8504f3c8" + integrity sha512-jjYYjjumCJjH9hHCoMhA8PCl1OxNeGgAoZ7yuGYILRJX9NjgzTN0pCT5qAoYR4jfOP8htIByvAlz9vfNSSBoVg== dependencies: - "@jest/console" "^28.1.3" - "@jest/types" "^28.1.3" + "@jest/console" "^29.1.2" + "@jest/types" "^29.1.2" "@types/istanbul-lib-coverage" "^2.0.0" collect-v8-coverage "^1.0.0" -"@jest/test-sequencer@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-28.1.3.tgz#9d0c283d906ac599c74bde464bc0d7e6a82886c3" - integrity sha512-NIMPEqqa59MWnDi1kvXXpYbqsfQmSJsIbnd85mdVGkiDfQ9WQQTXOLsvISUfonmnBT+w85WEgneCigEEdHDFxw== +"@jest/test-sequencer@^29.1.2": + version "29.1.2" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-29.1.2.tgz#10bfd89c08bfdba382eb05cc79c1d23a01238a93" + integrity sha512-fU6dsUqqm8sA+cd85BmeF7Gu9DsXVWFdGn9taxM6xN1cKdcP/ivSgXh5QucFRFz1oZxKv3/9DYYbq0ULly3P/Q== dependencies: - "@jest/test-result" "^28.1.3" + "@jest/test-result" "^29.1.2" graceful-fs "^4.2.9" - jest-haste-map "^28.1.3" + jest-haste-map "^29.1.2" slash "^3.0.0" -"@jest/transform@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-28.1.3.tgz#59d8098e50ab07950e0f2fc0fc7ec462371281b0" - integrity sha512-u5dT5di+oFI6hfcLOHGTAfmUxFRrjK+vnaP0kkVow9Md/M7V/MxqQMOz/VV25UZO8pzeA9PjfTpOu6BDuwSPQA== +"@jest/transform@^29.1.2": + version "29.1.2" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-29.1.2.tgz#20f814696e04f090421f6d505c14bbfe0157062a" + integrity sha512-2uaUuVHTitmkx1tHF+eBjb4p7UuzBG7SXIaA/hNIkaMP6K+gXYGxP38ZcrofzqN0HeZ7A90oqsOa97WU7WZkSw== dependencies: "@babel/core" "^7.11.6" - "@jest/types" "^28.1.3" - "@jridgewell/trace-mapping" "^0.3.13" + "@jest/types" "^29.1.2" + "@jridgewell/trace-mapping" "^0.3.15" babel-plugin-istanbul "^6.1.1" chalk "^4.0.0" convert-source-map "^1.4.0" - fast-json-stable-stringify "^2.0.0" + fast-json-stable-stringify "^2.1.0" graceful-fs "^4.2.9" - jest-haste-map "^28.1.3" - jest-regex-util "^28.0.2" - jest-util "^28.1.3" + jest-haste-map "^29.1.2" + jest-regex-util "^29.0.0" + jest-util "^29.1.2" micromatch "^4.0.4" pirates "^4.0.4" slash "^3.0.0" write-file-atomic "^4.0.1" +"@jest/types@^27.5.1": + version "27.5.1" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-27.5.1.tgz#3c79ec4a8ba61c170bf937bcf9e98a9df175ec80" + integrity sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^16.0.0" + chalk "^4.0.0" + "@jest/types@^28.1.3": version "28.1.3" resolved "https://registry.yarnpkg.com/@jest/types/-/types-28.1.3.tgz#b05de80996ff12512bc5ceb1d208285a7d11748b" @@ -1269,6 +1307,18 @@ "@types/yargs" "^17.0.8" chalk "^4.0.0" +"@jest/types@^29.1.2": + version "29.1.2" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.1.2.tgz#7442d32b16bcd7592d9614173078b8c334ec730a" + integrity sha512-DcXGtoTykQB5jiwCmVr8H4vdg2OJhQex3qPkG+ISyDO7xQXbt/4R6dowcRyPemRnkH7JoHvZuxPBdlq+9JxFCg== + dependencies: + "@jest/schemas" "^29.0.0" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + "@jridgewell/gen-mapping@^0.1.0": version "0.1.1" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" @@ -1309,7 +1359,7 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== -"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.13", "@jridgewell/trace-mapping@^0.3.8", "@jridgewell/trace-mapping@^0.3.9": +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.15", "@jridgewell/trace-mapping@^0.3.8", "@jridgewell/trace-mapping@^0.3.9": version "0.3.15" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz#aba35c48a38d3fd84b37e66c9c0423f9744f9774" integrity sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g== @@ -1317,16 +1367,22 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz": - version "3.2.12" - uid "0bce3c86f9d36a4984d3c3e07df1c3fb4c679bd9" - resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz#0bce3c86f9d36a4984d3c3e07df1c3fb4c679bd9" +"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.13.tgz": + version "3.2.13" + resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.13.tgz#0109fde93bcc61def851f79826c9384c073b5175" "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3": version "2.1.8-no-fsevents.3" resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz#323d72dd25103d0c4fbdce89dadf574a787b1f9b" integrity sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ== +"@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": + version "5.1.1-v1" + resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129" + integrity sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg== + dependencies: + eslint-scope "5.1.1" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -1449,10 +1505,22 @@ dependencies: "@octokit/openapi-types" "^12.11.0" +"@pkgr/utils@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@pkgr/utils/-/utils-2.3.1.tgz#0a9b06ffddee364d6642b3cd562ca76f55b34a03" + integrity sha512-wfzX8kc1PMyUILA+1Z/EqoE4UCXGy0iRGMhPwdfae1+f0OXlLqCk+By+aMzgJBzR9AzS4CDizioG6Ss1gvAFJw== + dependencies: + cross-spawn "^7.0.3" + is-glob "^4.0.3" + open "^8.4.0" + picocolors "^1.0.0" + tiny-glob "^0.2.9" + tslib "^2.4.0" + "@sinclair/typebox@^0.24.1": - version "0.24.28" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.28.tgz#15aa0b416f82c268b1573ab653e4413c965fe794" - integrity sha512-dgJd3HLOkLmz4Bw50eZx/zJwtBq65nms3N9VBYu5LTjJ883oBFkTyXRlCB/ZGGwqYpJJHA5zW2Ibhl5ngITfow== + version "0.24.44" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.44.tgz#0a0aa3bf4a155a678418527342a3ee84bd8caa5c" + integrity sha512-ka0W0KN5i6LfrSocduwliMMpqVgohtPFidKdMEOUjoOFCHcOOYkKsPRxfs5f15oPNHTm6ERAm0GV/+/LTKeiWg== "@sinonjs/commons@^1.7.0": version "1.8.3" @@ -1500,9 +1568,9 @@ "@babel/types" "^7.0.0" "@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.18.0.tgz#8134fd78cb39567465be65b9fdc16d378095f41f" - integrity sha512-v4Vwdko+pgymgS+A2UIaJru93zQd85vIGWObM5ekZNdXCKtDYqATlEYnWgfo86Q6I1Lh0oXnksDnMU1cwmlPDw== + version "7.18.2" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.18.2.tgz#235bf339d17185bdec25e024ca19cce257cc7309" + integrity sha512-FcFaxOr2V5KZCviw1TnutEMVUVsGt4D2hP1TAfXZAMKuHYW3xQhe3jTxNPWutgCJ3/X1c5yX8ZoGVEItxKbwBg== dependencies: "@babel/types" "^7.3.0" @@ -1556,13 +1624,13 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@^28.0.0": - version "28.1.7" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-28.1.7.tgz#a680c5d05b69634c2d54a63cb106d7fb1adaba16" - integrity sha512-acDN4VHD40V24tgu0iC44jchXavRNVFXQ/E6Z5XNsswgoSO/4NgsXoEYmPUGookKldlZQyIpmrEXsHI9cA3ZTA== +"@types/jest@^29.0.0": + version "29.1.1" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.1.1.tgz#cf21a0835a1ba9a30ea1966019f1261c6a114c92" + integrity sha512-U9Ey07dGWl6fUFaIaUQUKWG5NoKi/zizeVQCGV8s4nSU0jPgqphVZvS64+8BtWYvrc3ZGw6wo943NSYPxkrp/g== dependencies: - expect "^28.0.0" - pretty-format "^28.0.0" + expect "^29.0.0" + pretty-format "^29.0.0" "@types/json-schema@^7.0.9": version "7.0.11" @@ -1593,19 +1661,19 @@ integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA== "@types/node@*": - version "18.7.6" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.6.tgz#31743bc5772b6ac223845e18c3fc26f042713c83" - integrity sha512-EdxgKRXgYsNITy5mjjXjVE/CS8YENSdhiagGrLqjG0pvA2owgJ6i4l7wy/PFZGC0B1/H20lWKN7ONVDNYDZm7A== + version "18.8.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.2.tgz#17d42c6322d917764dd3d2d3a10d7884925de067" + integrity sha512-cRMwIgdDN43GO4xMWAfJAecYn8wV4JbsOGHNfNUIDiuYkUYAR5ec4Rj7IO2SAhFPEfpPtLtUTbbny/TCT7aDwA== "@types/node@16": - version "16.11.54" - resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.54.tgz#1bc17ff09bf340d9350c32200adab22f22753376" - integrity sha512-ryOpwe15+BtTUxKFfzABjaI/EtXLPBSBEW4B6D5ygWNcORLVKG/1/FC3WwAr5d7t6lCnlVPRsCY0NH680QT+Pg== + version "16.11.64" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.64.tgz#9171f327298b619e2c52238b120c19056415d820" + integrity sha512-z5hPTlVFzNwtJ2LNozTpJcD1Cu44c4LNuzaq1mwxmiHWQh2ULdR6Vjwo1UGldzRpzL0yUEdZddnfqGW2G70z6Q== "@types/prettier@^2.1.5": - version "2.7.0" - resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.0.tgz#ea03e9f0376a4446f44797ca19d9c46c36e352dc" - integrity sha512-RI1L7N4JnW5gQw2spvL7Sllfuf1SaHdrZpCHiBlCXjIlufi1SMNnbu2teze3/QE67Fg2tBlH7W+mi4hVNk4p0A== + version "2.7.1" + resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.1.tgz#dfd20e2dc35f027cdd6c1908e80a5ddc7499670e" + integrity sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow== "@types/request@^2.48.5": version "2.48.8" @@ -1637,91 +1705,98 @@ resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== +"@types/yargs@^16.0.0": + version "16.0.4" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-16.0.4.tgz#26aad98dd2c2a38e421086ea9ad42b9e51642977" + integrity sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw== + dependencies: + "@types/yargs-parser" "*" + "@types/yargs@^17.0.8": - version "17.0.11" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.11.tgz#5e10ca33e219807c0eee0f08b5efcba9b6a42c06" - integrity sha512-aB4y9UDUXTSMxmM4MH+YnuR0g5Cph3FLQBoWoMB21DSvFVAxRVEHEMx3TLh+zUZYMCQtKiqazz0Q4Rre31f/OA== + version "17.0.13" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.13.tgz#34cced675ca1b1d51fcf4d34c3c6f0fa142a5c76" + integrity sha512-9sWaruZk2JGxIQU+IhI1fhPYRcQ0UuTNuKuCW9bR5fp7qi2Llf7WDzNa17Cy7TKnh3cdxDOiyTu6gaLS0eDatg== dependencies: "@types/yargs-parser" "*" "@typescript-eslint/eslint-plugin@^5.6.0": - version "5.33.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.33.1.tgz#c0a480d05211660221eda963cc844732fe9b1714" - integrity sha512-S1iZIxrTvKkU3+m63YUOxYPKaP+yWDQrdhxTglVDVEVBf+aCSw85+BmJnyUaQQsk5TXFG/LpBu9fa+LrAQ91fQ== + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.39.0.tgz#778b2d9e7f293502c7feeea6c74dca8eb3e67511" + integrity sha512-xVfKOkBm5iWMNGKQ2fwX5GVgBuHmZBO1tCRwXmY5oAIsPscfwm2UADDuNB8ZVYCtpQvJK4xpjrK7jEhcJ0zY9A== dependencies: - "@typescript-eslint/scope-manager" "5.33.1" - "@typescript-eslint/type-utils" "5.33.1" - "@typescript-eslint/utils" "5.33.1" + "@typescript-eslint/scope-manager" "5.39.0" + "@typescript-eslint/type-utils" "5.39.0" + "@typescript-eslint/utils" "5.39.0" debug "^4.3.4" - functional-red-black-tree "^1.0.1" ignore "^5.2.0" regexpp "^3.2.0" semver "^7.3.7" tsutils "^3.21.0" "@typescript-eslint/parser@^5.6.0": - version "5.33.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.33.1.tgz#e4b253105b4d2a4362cfaa4e184e2d226c440ff3" - integrity sha512-IgLLtW7FOzoDlmaMoXdxG8HOCByTBXrB1V2ZQYSEV1ggMmJfAkMWTwUjjzagS6OkfpySyhKFkBw7A9jYmcHpZA== + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.39.0.tgz#93fa0bc980a3a501e081824f6097f7ca30aaa22b" + integrity sha512-PhxLjrZnHShe431sBAGHaNe6BDdxAASDySgsBCGxcBecVCi8NQWxQZMcizNA4g0pN51bBAn/FUfkWG3SDVcGlA== dependencies: - "@typescript-eslint/scope-manager" "5.33.1" - "@typescript-eslint/types" "5.33.1" - "@typescript-eslint/typescript-estree" "5.33.1" + "@typescript-eslint/scope-manager" "5.39.0" + "@typescript-eslint/types" "5.39.0" + "@typescript-eslint/typescript-estree" "5.39.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@5.33.1": - version "5.33.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.33.1.tgz#8d31553e1b874210018ca069b3d192c6d23bc493" - integrity sha512-8ibcZSqy4c5m69QpzJn8XQq9NnqAToC8OdH/W6IXPXv83vRyEDPYLdjAlUx8h/rbusq6MkW4YdQzURGOqsn3CA== +"@typescript-eslint/scope-manager@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.39.0.tgz#873e1465afa3d6c78d8ed2da68aed266a08008d0" + integrity sha512-/I13vAqmG3dyqMVSZPjsbuNQlYS082Y7OMkwhCfLXYsmlI0ca4nkL7wJ/4gjX70LD4P8Hnw1JywUVVAwepURBw== dependencies: - "@typescript-eslint/types" "5.33.1" - "@typescript-eslint/visitor-keys" "5.33.1" + "@typescript-eslint/types" "5.39.0" + "@typescript-eslint/visitor-keys" "5.39.0" -"@typescript-eslint/type-utils@5.33.1": - version "5.33.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.33.1.tgz#1a14e94650a0ae39f6e3b77478baff002cec4367" - integrity sha512-X3pGsJsD8OiqhNa5fim41YtlnyiWMF/eKsEZGsHID2HcDqeSC5yr/uLOeph8rNF2/utwuI0IQoAK3fpoxcLl2g== +"@typescript-eslint/type-utils@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.39.0.tgz#0a8c00f95dce4335832ad2dc6bc431c14e32a0a6" + integrity sha512-KJHJkOothljQWzR3t/GunL0TPKY+fGJtnpl+pX+sJ0YiKTz3q2Zr87SGTmFqsCMFrLt5E0+o+S6eQY0FAXj9uA== dependencies: - "@typescript-eslint/utils" "5.33.1" + "@typescript-eslint/typescript-estree" "5.39.0" + "@typescript-eslint/utils" "5.39.0" debug "^4.3.4" tsutils "^3.21.0" -"@typescript-eslint/types@5.33.1": - version "5.33.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.33.1.tgz#3faef41793d527a519e19ab2747c12d6f3741ff7" - integrity sha512-7K6MoQPQh6WVEkMrMW5QOA5FO+BOwzHSNd0j3+BlBwd6vtzfZceJ8xJ7Um2XDi/O3umS8/qDX6jdy2i7CijkwQ== +"@typescript-eslint/types@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.39.0.tgz#f4e9f207ebb4579fd854b25c0bf64433bb5ed78d" + integrity sha512-gQMZrnfEBFXK38hYqt8Lkwt8f4U6yq+2H5VDSgP/qiTzC8Nw8JO3OuSUOQ2qW37S/dlwdkHDntkZM6SQhKyPhw== -"@typescript-eslint/typescript-estree@5.33.1": - version "5.33.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.33.1.tgz#a573bd360790afdcba80844e962d8b2031984f34" - integrity sha512-JOAzJ4pJ+tHzA2pgsWQi4804XisPHOtbvwUyqsuuq8+y5B5GMZs7lI1xDWs6V2d7gE/Ez5bTGojSK12+IIPtXA== +"@typescript-eslint/typescript-estree@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.39.0.tgz#c0316aa04a1a1f4f7f9498e3c13ef1d3dc4cf88b" + integrity sha512-qLFQP0f398sdnogJoLtd43pUgB18Q50QSA+BTE5h3sUxySzbWDpTSdgt4UyxNSozY/oDK2ta6HVAzvGgq8JYnA== dependencies: - "@typescript-eslint/types" "5.33.1" - "@typescript-eslint/visitor-keys" "5.33.1" + "@typescript-eslint/types" "5.39.0" + "@typescript-eslint/visitor-keys" "5.39.0" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@5.33.1": - version "5.33.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.33.1.tgz#171725f924fe1fe82bb776522bb85bc034e88575" - integrity sha512-uphZjkMaZ4fE8CR4dU7BquOV6u0doeQAr8n6cQenl/poMaIyJtBu8eys5uk6u5HiDH01Mj5lzbJ5SfeDz7oqMQ== +"@typescript-eslint/utils@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.39.0.tgz#b7063cca1dcf08d1d21b0d91db491161ad0be110" + integrity sha512-+DnY5jkpOpgj+EBtYPyHRjXampJfC0yUZZzfzLuUWVZvCuKqSdJVC8UhdWipIw7VKNTfwfAPiOWzYkAwuIhiAg== dependencies: "@types/json-schema" "^7.0.9" - "@typescript-eslint/scope-manager" "5.33.1" - "@typescript-eslint/types" "5.33.1" - "@typescript-eslint/typescript-estree" "5.33.1" + "@typescript-eslint/scope-manager" "5.39.0" + "@typescript-eslint/types" "5.39.0" + "@typescript-eslint/typescript-estree" "5.39.0" eslint-scope "^5.1.1" eslint-utils "^3.0.0" -"@typescript-eslint/visitor-keys@5.33.1": - version "5.33.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.33.1.tgz#0155c7571c8cd08956580b880aea327d5c34a18b" - integrity sha512-nwIxOK8Z2MPWltLKMLOEZwmfBZReqUdbEoHQXeCpa+sRVARe5twpJGHCB4dk9903Yaf0nMAlGbQfaAH92F60eg== +"@typescript-eslint/visitor-keys@5.39.0": + version "5.39.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.39.0.tgz#8f41f7d241b47257b081ddba5d3ce80deaae61e2" + integrity sha512-yyE3RPwOG+XJBLrhvsxAidUgybJVQ/hG8BhiJo0k8JSAYfk/CshVcxf0HwP4Jt7WZZ6vLmxdo1p6EyN3tzFTkg== dependencies: - "@typescript-eslint/types" "5.33.1" + "@typescript-eslint/types" "5.39.0" eslint-visitor-keys "^3.3.0" JSONStream@^1.0.3: @@ -1733,9 +1808,9 @@ JSONStream@^1.0.3: through ">=2.2.7 <3" ace-builds@^1.4.13: - version "1.9.6" - resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.9.6.tgz#2d3721f90f0664b79be9288f6319dd57576ff1e7" - integrity sha512-M/Li4hPruMSbkkg35LgdbsIBq0WuwrV4ztP2pKaww47rC/MvDc1bOrYxwJrfgxdlzyLKrja5bn+9KwwuzqB2xQ== + version "1.11.2" + resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.11.2.tgz#93fd7b7770909f3a48a23d71bd212d1b77baaaf5" + integrity sha512-1VNeUF56b6gkaeeWJXMBBuz5n0ceDchjUwwVmTKpNM/N3YRrUEpykGEEsg7Y1PKP7IRyqtXfAu6VJDg7OZaLfA== acorn-globals@^3.0.0: version "3.1.0" @@ -1803,9 +1878,9 @@ align-text@^0.1.1, align-text@^0.1.3: repeat-string "^1.5.2" allchange@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/allchange/-/allchange-1.0.6.tgz#f905918255541dc92d6a1f5cdf758db4597f569c" - integrity sha512-37a4J55oSxhLmlS/DeBOKjKn5dbjkyR4qMJ9is8+CKLPTe7NybcWBYvrPLr9kVLBa6aigWrdovRHrQj/4v6k4w== + version "1.1.0" + resolved "https://registry.yarnpkg.com/allchange/-/allchange-1.1.0.tgz#f8fa129e4b40c0b0a2c072c530f2324c6590e208" + integrity sha512-brDWf2feuL3FRyivSyC6AKOgpX+bYgs1Z7+ZmLti6PnBdZgIjRSnKvlc68N8+1UX2rCISx2I+XuUvE3/GJNG2A== dependencies: "@actions/core" "^1.4.0" "@actions/github" "^5.0.0" @@ -1970,15 +2045,15 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== -babel-jest@^28.0.0, babel-jest@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-28.1.3.tgz#c1187258197c099072156a0a121c11ee1e3917d5" - integrity sha512-epUaPOEWMk3cWX0M/sPvCHHCe9fMFAa/9hXEgKP8nFfNl/jlGkE9ucq9NqkZGXLDduCJYS0UvSlPUwC0S+rH6Q== +babel-jest@^29.0.0, babel-jest@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.1.2.tgz#540d3241925c55240fb0c742e3ffc5f33a501978" + integrity sha512-IuG+F3HTHryJb7gacC7SQ59A9kO56BctUsT67uJHp1mMCHUOMXpDwOHWGifWqdWVknN2WNkCVQELPjXx0aLJ9Q== dependencies: - "@jest/transform" "^28.1.3" + "@jest/transform" "^29.1.2" "@types/babel__core" "^7.1.14" babel-plugin-istanbul "^6.1.1" - babel-preset-jest "^28.1.3" + babel-preset-jest "^29.0.2" chalk "^4.0.0" graceful-fs "^4.2.9" slash "^3.0.0" @@ -2001,39 +2076,39 @@ babel-plugin-istanbul@^6.1.1: istanbul-lib-instrument "^5.0.4" test-exclude "^6.0.0" -babel-plugin-jest-hoist@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-28.1.3.tgz#1952c4d0ea50f2d6d794353762278d1d8cca3fbe" - integrity sha512-Ys3tUKAmfnkRUpPdpa98eYrAR0nV+sSFUZZEGuQ2EbFd1y4SOLtD5QDNHAq+bb9a+bbXvYQC4b+ID/THIMcU6Q== +babel-plugin-jest-hoist@^29.0.2: + version "29.0.2" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.0.2.tgz#ae61483a829a021b146c016c6ad39b8bcc37c2c8" + integrity sha512-eBr2ynAEFjcebVvu8Ktx580BD1QKCrBG1XwEUTXJe285p9HA/4hOhfWCFRQhTKSyBV0VzjhG7H91Eifz9s29hg== dependencies: "@babel/template" "^7.3.3" "@babel/types" "^7.3.3" "@types/babel__core" "^7.1.14" "@types/babel__traverse" "^7.0.6" -babel-plugin-polyfill-corejs2@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.2.tgz#e4c31d4c89b56f3cf85b92558954c66b54bd972d" - integrity sha512-LPnodUl3lS0/4wN3Rb+m+UK8s7lj2jcLRrjho4gLw+OJs+I4bvGXshINesY5xx/apM+biTnQ9reDI8yj+0M5+Q== +babel-plugin-polyfill-corejs2@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz#5d1bd3836d0a19e1b84bbf2d9640ccb6f951c122" + integrity sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q== dependencies: "@babel/compat-data" "^7.17.7" - "@babel/helper-define-polyfill-provider" "^0.3.2" + "@babel/helper-define-polyfill-provider" "^0.3.3" semver "^6.1.1" -babel-plugin-polyfill-corejs3@^0.5.3: - version "0.5.3" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.5.3.tgz#d7e09c9a899079d71a8b670c6181af56ec19c5c7" - integrity sha512-zKsXDh0XjnrUEW0mxIHLfjBfnXSMr5Q/goMe/fxpQnLm07mcOZiIZHBNWCMx60HmdvjxfXcalac0tfFg0wqxyw== +babel-plugin-polyfill-corejs3@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz#56ad88237137eade485a71b52f72dbed57c6230a" + integrity sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA== dependencies: - "@babel/helper-define-polyfill-provider" "^0.3.2" - core-js-compat "^3.21.0" + "@babel/helper-define-polyfill-provider" "^0.3.3" + core-js-compat "^3.25.1" -babel-plugin-polyfill-regenerator@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.0.tgz#8f51809b6d5883e07e71548d75966ff7635527fe" - integrity sha512-RW1cnryiADFeHmfLS+WW/G431p1PsW5qdRdz0SDRi7TKcUgc7Oh/uXkT7MZ/+tGsT1BkczEAmD5XjUyJ5SWDTw== +babel-plugin-polyfill-regenerator@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz#390f91c38d90473592ed43351e801a9d3e0fd747" + integrity sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw== dependencies: - "@babel/helper-define-polyfill-provider" "^0.3.2" + "@babel/helper-define-polyfill-provider" "^0.3.3" babel-preset-current-node-syntax@^1.0.0: version "1.0.1" @@ -2053,12 +2128,12 @@ babel-preset-current-node-syntax@^1.0.0: "@babel/plugin-syntax-optional-chaining" "^7.8.3" "@babel/plugin-syntax-top-level-await" "^7.8.3" -babel-preset-jest@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-28.1.3.tgz#5dfc20b99abed5db994406c2b9ab94c73aaa419d" - integrity sha512-L+fupJvlWAHbQfn74coNX3zf60LXMJsezNvvx8eIh7iOR1luJ1poxYgQk1F8PYtNq/6QODDHCqsSnTFSWC491A== +babel-preset-jest@^29.0.2: + version "29.0.2" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-29.0.2.tgz#e14a7124e22b161551818d89e5bdcfb3b2b0eac7" + integrity sha512-BeVXp7rH5TK96ofyEnHjznjLMQ2nAeDJ+QzxKnHAAMs0RgrQsCywjAN8m4mOm5Di0pxU//3AoEeJJrerMH5UeA== dependencies: - babel-plugin-jest-hoist "^28.1.3" + babel-plugin-jest-hoist "^29.0.2" babel-preset-current-node-syntax "^1.0.0" babel-runtime@^6.26.0: @@ -2326,15 +2401,15 @@ browserify@^17.0.0: vm-browserify "^1.0.0" xtend "^4.0.0" -browserslist@^4.20.2, browserslist@^4.21.3: - version "4.21.3" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.3.tgz#5df277694eb3c48bc5c4b05af3e8b7e09c5a6d1a" - integrity sha512-898rgRXLAyRkM1GryrrBHGkqA5hlpkV5MhtZwg9QXeiyLUYs2k00Un05aX5l2/yJIOObYKOpS2JNo8nJDE7fWQ== +browserslist@^4.21.3, browserslist@^4.21.4: + version "4.21.4" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.4.tgz#e7496bbc67b9e39dd0f98565feccdcb0d4ff6987" + integrity sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw== dependencies: - caniuse-lite "^1.0.30001370" - electron-to-chromium "^1.4.202" + caniuse-lite "^1.0.30001400" + electron-to-chromium "^1.4.251" node-releases "^2.0.6" - update-browserslist-db "^1.0.5" + update-browserslist-db "^1.0.9" bs58@^5.0.0: version "5.0.0" @@ -2424,10 +2499,10 @@ camelcase@^6.2.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -caniuse-lite@^1.0.30001370: - version "1.0.30001378" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001378.tgz#3d2159bf5a8f9ca093275b0d3ecc717b00f27b67" - integrity sha512-JVQnfoO7FK7WvU4ZkBRbPjaot4+YqxogSDosHv0Hv5mWpUESmN+UubMU6L/hGz8QlQ2aY5U0vR6MOs6j/CXpNA== +caniuse-lite@^1.0.30001400: + version "1.0.30001414" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001414.tgz#5f1715e506e71860b4b07c50060ea6462217611e" + integrity sha512-t55jfSaWjCdocnFdKQoO+d2ct9C59UZg4dY3OnUlSZ447r8pUtIKdp0hpAzrGFultmTC+Us+KpKi4GZl/LXlFg== caseless@~0.12.0: version "0.12.0" @@ -2494,9 +2569,9 @@ chokidar@^3.4.0: fsevents "~2.3.2" ci-info@^3.2.0: - version "3.3.2" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.3.2.tgz#6d2967ffa407466481c6c90b6e16b3098f080128" - integrity sha512-xmDt/QIAdeZ9+nfdPsaBCpMvHNLFiLdjj59qjqn+6iPe6YmHGQ35sBnQ8uslRBXFmXkiZQOJRjvQeoGppoTjjg== + version "3.4.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.4.0.tgz#b28484fd436cbc267900364f096c9dc185efb251" + integrity sha512-t5QdPT5jq3o262DOQ8zA6E1tlH2upmUc4Hlvrbx1pGYJuiiHl7O7rvVNI+l8HTVhd/q3Qc9vqimkNk5yiXsAug== cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" @@ -2547,6 +2622,15 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + clone-deep@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" @@ -2674,13 +2758,12 @@ convert-source-map@~1.1.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.1.3.tgz#4829c877e9fe49b3161f3bf3673888e204699860" integrity sha512-Y8L5rp6jo+g9VEPgvqNfEopjTR4OTYct8lXlS8iVQdmnjDvbdbzYe9rjtFCB9egC86JoNCU61WRY+ScjkZpnIg== -core-js-compat@^3.21.0, core-js-compat@^3.22.1: - version "3.24.1" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.24.1.tgz#d1af84a17e18dfdd401ee39da9996f9a7ba887de" - integrity sha512-XhdNAGeRnTpp8xbD+sR/HFDK9CbeeeqXT6TuofXh3urqEevzkWmLRgrVoykodsw8okqo2pu1BOmuCKrHx63zdw== +core-js-compat@^3.25.1: + version "3.25.4" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.25.4.tgz#730a255d4a47a937513abf1672bf278dc24dcebf" + integrity sha512-gCEcIEEqCR6230WroNunK/653CWKhqyCKJ9b+uESqOt/WFJA8B4lTnnQFdpYY5vmBcwJAA90Bo5vXs+CVsf6iA== dependencies: - browserslist "^4.21.3" - semver "7.0.0" + browserslist "^4.21.4" core-js@^2.4.0: version "2.6.12" @@ -2820,6 +2903,11 @@ deepmerge@^4.2.2: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== +define-lazy-prop@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" + integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== + define-properties@^1.1.3, define-properties@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1" @@ -2885,6 +2973,11 @@ diff-sequences@^28.1.1: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-28.1.1.tgz#9989dc731266dc2903457a70e996f3a041913ac6" integrity sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw== +diff-sequences@^29.0.0: + version "29.0.0" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.0.0.tgz#bae49972ef3933556bcb0800b72e8579d19d9e4f" + integrity sha512-7Qe/zd1wxSDL4D/X/FPjOMB+ZMDt71W94KYaq05I2l0oQqgXgs7s4ftYYmV38gBSrPz2vcygxfs1xn0FT+rKNA== + diffie-hellman@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" @@ -2952,10 +3045,10 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" -electron-to-chromium@^1.4.202: - version "1.4.225" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.225.tgz#3e27bdd157cbaf19768141f2e0f0f45071e52338" - integrity sha512-ICHvGaCIQR3P88uK8aRtx8gmejbVJyC6bB4LEC3anzBrIzdzC7aiZHY4iFfXhN4st6I7lMO0x4sgBHf/7kBvRw== +electron-to-chromium@^1.4.251: + version "1.4.270" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.270.tgz#2c6ea409b45cdb5c3e0cb2c08cf6c0ba7e0f2c26" + integrity sha512-KNhIzgLiJmDDC444dj9vEOpZEgsV96ult9Iff98Vanumn+ShJHd5se8aX6KeVxdc0YQeqdrezBZv89rleDbvSg== elliptic@^6.5.3: version "6.5.4" @@ -2980,6 +3073,14 @@ emoji-regex@^8.0.0: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +enhanced-resolve@^5.10.0: + version "5.10.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz#0dc579c3bb2a1032e357ac45b8f3a6f3ad4fb1e6" + integrity sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.2.0" + entities@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" @@ -2993,30 +3094,31 @@ error-ex@^1.2.0, error-ex@^1.3.1: is-arrayish "^0.2.1" es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5, es-abstract@^1.20.0: - version "1.20.1" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.1.tgz#027292cd6ef44bd12b1913b828116f54787d1814" - integrity sha512-WEm2oBhfoI2sImeM4OF2zE2V3BYdSF+KnSi9Sidz51fQHd7+JuF8Xgcj9/0o+OWeIeIS/MiuNnlruQrJf16GQA== + version "1.20.3" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.3.tgz#90b143ff7aedc8b3d189bcfac7f1e3e3f81e9da1" + integrity sha512-AyrnaKVpMzljIdwjzrj+LxGmj8ik2LckwXacHqrJJ/jxz6dDDBcZ7I7nlHM0FvEW8MfbWJwOd+yT2XzYW49Frw== dependencies: call-bind "^1.0.2" es-to-primitive "^1.2.1" function-bind "^1.1.1" function.prototype.name "^1.1.5" - get-intrinsic "^1.1.1" + get-intrinsic "^1.1.3" get-symbol-description "^1.0.0" has "^1.0.3" has-property-descriptors "^1.0.0" has-symbols "^1.0.3" internal-slot "^1.0.3" - is-callable "^1.2.4" + is-callable "^1.2.6" is-negative-zero "^2.0.2" is-regex "^1.1.4" is-shared-array-buffer "^1.0.2" is-string "^1.0.7" is-weakref "^1.0.2" - object-inspect "^1.12.0" + object-inspect "^1.12.2" object-keys "^1.1.1" - object.assign "^4.1.2" + object.assign "^4.1.4" regexp.prototype.flags "^1.4.3" + safe-regex-test "^1.0.0" string.prototype.trimend "^1.0.5" string.prototype.trimstart "^1.0.5" unbox-primitive "^1.0.2" @@ -3106,6 +3208,19 @@ eslint-import-resolver-node@^0.3.6: debug "^3.2.7" resolve "^1.20.0" +eslint-import-resolver-typescript@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.5.1.tgz#c72634da072eebd04fe73007fa58a62c333c8147" + integrity sha512-U7LUjNJPYjNsHvAUAkt/RU3fcTSpbllA0//35B4eLYTX74frmOepbt7F7J3D1IGtj9k21buOpaqtDd4ZlS/BYQ== + dependencies: + debug "^4.3.4" + enhanced-resolve "^5.10.0" + get-tsconfig "^4.2.0" + globby "^13.1.2" + is-core-module "^2.10.0" + is-glob "^4.0.3" + synckit "^0.8.3" + eslint-module-utils@^2.7.3: version "2.7.4" resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz#4f3e41116aaf13a20792261e61d3a2e7e0583974" @@ -3113,7 +3228,7 @@ eslint-module-utils@^2.7.3: dependencies: debug "^3.2.7" -eslint-plugin-import@^2.25.4: +eslint-plugin-import@^2.26.0: version "2.26.0" resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz#f812dc47be4f2b72b478a021605a59fc6fe8b88b" integrity sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA== @@ -3142,7 +3257,7 @@ eslint-rule-composer@^0.3.0: resolved "https://registry.yarnpkg.com/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz#79320c927b0c5c0d3d3d2b76c8b4a488f25bbaf9" integrity sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg== -eslint-scope@^5.1.1: +eslint-scope@5.1.1, eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== @@ -3175,14 +3290,15 @@ eslint-visitor-keys@^3.3.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== -eslint@8.22.0: - version "8.22.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.22.0.tgz#78fcb044196dfa7eef30a9d65944f6f980402c48" - integrity sha512-ci4t0sz6vSRKdmkOGmprBo6fmI4PrphDFMy5JEq/fNS0gQkJM3rLmrqcp8ipMcdobH3KtUP40KniAE9W19S4wA== +eslint@8.24.0: + version "8.24.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.24.0.tgz#489516c927a5da11b3979dbfb2679394523383c8" + integrity sha512-dWFaPhGhTAiPcCgm3f6LI2MBWbogMnTJzFBbhXVRQDJPkr9pGZvVjlVfXd+vyDcWPA2Ic9L2AXPIQM0+vk/cSQ== dependencies: - "@eslint/eslintrc" "^1.3.0" - "@humanwhocodes/config-array" "^0.10.4" + "@eslint/eslintrc" "^1.3.2" + "@humanwhocodes/config-array" "^0.10.5" "@humanwhocodes/gitignore-to-minimatch" "^1.0.2" + "@humanwhocodes/module-importer" "^1.0.1" ajv "^6.10.0" chalk "^4.0.0" cross-spawn "^7.0.2" @@ -3192,13 +3308,12 @@ eslint@8.22.0: eslint-scope "^7.1.1" eslint-utils "^3.0.0" eslint-visitor-keys "^3.3.0" - espree "^9.3.3" + espree "^9.4.0" esquery "^1.4.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" file-entry-cache "^6.0.1" find-up "^5.0.0" - functional-red-black-tree "^1.0.1" glob-parent "^6.0.1" globals "^13.15.0" globby "^11.1.0" @@ -3207,6 +3322,7 @@ eslint@8.22.0: import-fresh "^3.0.0" imurmurhash "^0.1.4" is-glob "^4.0.0" + js-sdsl "^4.1.4" js-yaml "^4.1.0" json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" @@ -3218,12 +3334,11 @@ eslint@8.22.0: strip-ansi "^6.0.1" strip-json-comments "^3.1.0" text-table "^0.2.0" - v8-compile-cache "^2.0.3" -espree@^9.3.2, espree@^9.3.3: - version "9.3.3" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.3.tgz#2dd37c4162bb05f433ad3c1a52ddf8a49dc08e9d" - integrity sha512-ORs1Rt/uQTqUKjDdGCyrtYxbazf5umATSf/K4qxjmZHORR6HJk+2s/2Pqe+Kk49HHINC/xNIrGfgh8sZcll0ng== +espree@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a" + integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw== dependencies: acorn "^8.8.0" acorn-jsx "^5.3.2" @@ -3323,7 +3438,7 @@ exorcist@^2.0.0: mkdirp "^1.0.4" mold-source-map "^0.4.0" -expect@^28.0.0, expect@^28.1.0, expect@^28.1.3: +expect@^28.1.0: version "28.1.3" resolved "https://registry.yarnpkg.com/expect/-/expect-28.1.3.tgz#90a7c1a124f1824133dd4533cce2d2bdcb6603ec" integrity sha512-eEh0xn8HlsuOBxFgIss+2mX85VAS4Qy3OSkjV7rlBWljtA4oWH37glVGyOZSZvErDT/yBywZdPGwCXuTvSG85g== @@ -3334,12 +3449,23 @@ expect@^28.0.0, expect@^28.1.0, expect@^28.1.3: jest-message-util "^28.1.3" jest-util "^28.1.3" -ext@^1.1.2: - version "1.6.0" - resolved "https://registry.yarnpkg.com/ext/-/ext-1.6.0.tgz#3871d50641e874cc172e2b53f919842d19db4c52" - integrity sha512-sdBImtzkq2HpkdRLtlLWDa6w4DX22ijZLKx8BMPUuKe1c5lbN6xwQDQCxSfxBQnHZ13ls/FH0MQZx/q/gr6FQg== +expect@^29.0.0, expect@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.1.2.tgz#82f8f28d7d408c7c68da3a386a490ee683e1eced" + integrity sha512-AuAGn1uxva5YBbBlXb+2JPxJRuemZsmlGcapPXWNSBNsQtAULfjioREGBWuI0EOvYUKjDnrCy8PW5Zlr1md5mw== dependencies: - type "^2.5.0" + "@jest/expect-utils" "^29.1.2" + jest-get-type "^29.0.0" + jest-matcher-utils "^29.1.2" + jest-message-util "^29.1.2" + jest-util "^29.1.2" + +ext@^1.1.2: + version "1.7.0" + resolved "https://registry.yarnpkg.com/ext/-/ext-1.7.0.tgz#0ea4383c0103d60e70be99e9a7f11027a33c4f5f" + integrity sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw== + dependencies: + type "^2.7.2" extend@~3.0.2: version "3.0.2" @@ -3368,10 +3494,10 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-glob@^3.2.9: - version "3.2.11" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9" - integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew== +fast-glob@^3.2.11, fast-glob@^3.2.9: + version "3.2.12" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" + integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== dependencies: "@nodelib/fs.stat" "^2.0.2" "@nodelib/fs.walk" "^1.2.3" @@ -3379,7 +3505,7 @@ fast-glob@^3.2.9: merge2 "^1.3.0" micromatch "^4.0.4" -fast-json-stable-stringify@^2.0.0: +fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== @@ -3402,9 +3528,9 @@ fastq@^1.6.0: reusify "^1.0.4" fb-watchman@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85" - integrity sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg== + version "2.0.2" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" + integrity sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== dependencies: bser "2.1.1" @@ -3463,9 +3589,9 @@ flat-cache@^3.0.4: rimraf "^3.0.2" flatted@^3.1.0: - version "3.2.6" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.6.tgz#022e9218c637f9f3fc9c35ab9c9193f05add60b2" - integrity sha512-0sQoMh9s0BYsm+12Huy/rkKxVu4R1+r96YX5cG44rHV0pQ6iC3Q+mkoMFaGWObMFYQxCVT+ssG1ksneA2MI9KQ== + version "3.2.7" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" + integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== for-each@^0.3.3: version "0.3.3" @@ -3535,11 +3661,6 @@ function.prototype.name@^1.1.5: es-abstract "^1.19.0" functions-have-names "^1.2.2" -functional-red-black-tree@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" - integrity sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g== - functions-have-names@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" @@ -3560,10 +3681,10 @@ get-caller-file@^2.0.5: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.2.tgz#336975123e05ad0b7ba41f152ee4aadbea6cf598" - integrity sha512-Jfm3OyCxHh9DJyc28qGk+JmfkpO41A4XkneDSujN9MDXrm4oDKdHvndhZ2dN94+ERNfkYJWDclW6k2L/ZGHjXA== +get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.3.tgz#063c84329ad93e83893c7f4f243ef63ffa351385" + integrity sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A== dependencies: function-bind "^1.1.1" has "^1.0.3" @@ -3587,6 +3708,11 @@ get-symbol-description@^1.0.0: call-bind "^1.0.2" get-intrinsic "^1.1.1" +get-tsconfig@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.2.0.tgz#ff368dd7104dab47bf923404eb93838245c66543" + integrity sha512-X8u8fREiYOE6S8hLbq99PeykTDoLVnxvF4DjWKJmz9xy2nNRdUcV8ZN9tniJFeKyTU3qnC9lL8n4Chd6LmVKHg== + getpass@^0.1.1: version "0.1.7" resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" @@ -3632,6 +3758,11 @@ globals@^13.15.0: dependencies: type-fest "^0.20.2" +globalyzer@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/globalyzer/-/globalyzer-0.1.0.tgz#cb76da79555669a1519d5a8edf093afaa0bf1465" + integrity sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q== + globby@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" @@ -3644,7 +3775,23 @@ globby@^11.1.0: merge2 "^1.4.1" slash "^3.0.0" -graceful-fs@^4.1.9, graceful-fs@^4.2.9: +globby@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/globby/-/globby-13.1.2.tgz#29047105582427ab6eca4f905200667b056da515" + integrity sha512-LKSDZXToac40u8Q1PQtZihbNdTYSNMuWe+K5l+oa6KgDzSvVrHXlJy40hUP522RjAIoNLJYBJi7ow+rbFpIhHQ== + dependencies: + dir-glob "^3.0.1" + fast-glob "^3.2.11" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^4.0.0" + +globrex@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/globrex/-/globrex-0.1.2.tgz#dd5d9ec826232730cd6793a5e33a9302985e6098" + integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg== + +graceful-fs@^4.1.9, graceful-fs@^4.2.4, graceful-fs@^4.2.9: version "4.2.10" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== @@ -3894,12 +4041,12 @@ is-buffer@^1.1.0, is-buffer@^1.1.5: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" - integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== +is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.6: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-core-module@^2.8.1, is-core-module@^2.9.0: +is-core-module@^2.10.0, is-core-module@^2.8.1, is-core-module@^2.9.0: version "2.10.0" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed" integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg== @@ -3913,6 +4060,11 @@ is-date-object@^1.0.1: dependencies: has-tostringtag "^1.0.0" +is-docker@^2.0.0, is-docker@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" + integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== + is-expression@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-expression/-/is-expression-3.0.0.tgz#39acaa6be7fd1f3471dc42c7416e61c24317ac9f" @@ -4046,6 +4198,13 @@ is-weakref@^1.0.2: dependencies: call-bind "^1.0.2" +is-wsl@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -4108,82 +4267,82 @@ istanbul-reports@^3.1.3, istanbul-reports@^3.1.4: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" -jest-changed-files@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-28.1.3.tgz#d9aeee6792be3686c47cb988a8eaf82ff4238831" - integrity sha512-esaOfUWJXk2nfZt9SPyC8gA1kNfdKLkQWyzsMlqq8msYSlNKfmZxfRgZn4Cd4MGVUF+7v6dBs0d5TOAKa7iIiA== +jest-changed-files@^29.0.0: + version "29.0.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.0.0.tgz#aa238eae42d9372a413dd9a8dadc91ca1806dce0" + integrity sha512-28/iDMDrUpGoCitTURuDqUzWQoWmOmOKOFST1mi2lwh62X4BFf6khgH3uSuo1e49X/UDjuApAj3w0wLOex4VPQ== dependencies: execa "^5.0.0" p-limit "^3.1.0" -jest-circus@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-28.1.3.tgz#d14bd11cf8ee1a03d69902dc47b6bd4634ee00e4" - integrity sha512-cZ+eS5zc79MBwt+IhQhiEp0OeBddpc1n8MBo1nMB8A7oPMKEO+Sre+wHaLJexQUj9Ya/8NOBY0RESUgYjB6fow== +jest-circus@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-29.1.2.tgz#4551068e432f169a53167fe1aef420cf51c8a735" + integrity sha512-ajQOdxY6mT9GtnfJRZBRYS7toNIJayiiyjDyoZcnvPRUPwJ58JX0ci0PKAKUo2C1RyzlHw0jabjLGKksO42JGA== dependencies: - "@jest/environment" "^28.1.3" - "@jest/expect" "^28.1.3" - "@jest/test-result" "^28.1.3" - "@jest/types" "^28.1.3" + "@jest/environment" "^29.1.2" + "@jest/expect" "^29.1.2" + "@jest/test-result" "^29.1.2" + "@jest/types" "^29.1.2" "@types/node" "*" chalk "^4.0.0" co "^4.6.0" dedent "^0.7.0" is-generator-fn "^2.0.0" - jest-each "^28.1.3" - jest-matcher-utils "^28.1.3" - jest-message-util "^28.1.3" - jest-runtime "^28.1.3" - jest-snapshot "^28.1.3" - jest-util "^28.1.3" + jest-each "^29.1.2" + jest-matcher-utils "^29.1.2" + jest-message-util "^29.1.2" + jest-runtime "^29.1.2" + jest-snapshot "^29.1.2" + jest-util "^29.1.2" p-limit "^3.1.0" - pretty-format "^28.1.3" + pretty-format "^29.1.2" slash "^3.0.0" stack-utils "^2.0.3" -jest-cli@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-28.1.3.tgz#558b33c577d06de55087b8448d373b9f654e46b2" - integrity sha512-roY3kvrv57Azn1yPgdTebPAXvdR2xfezaKKYzVxZ6It/5NCxzJym6tUI5P1zkdWhfUYkxEI9uZWcQdaFLo8mJQ== +jest-cli@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-29.1.2.tgz#423b9c5d3ea20a50b1354b8bf3f2a20e72110e89" + integrity sha512-vsvBfQ7oS2o4MJdAH+4u9z76Vw5Q8WBQF5MchDbkylNknZdrPTX1Ix7YRJyTlOWqRaS7ue/cEAn+E4V1MWyMzw== dependencies: - "@jest/core" "^28.1.3" - "@jest/test-result" "^28.1.3" - "@jest/types" "^28.1.3" + "@jest/core" "^29.1.2" + "@jest/test-result" "^29.1.2" + "@jest/types" "^29.1.2" chalk "^4.0.0" exit "^0.1.2" graceful-fs "^4.2.9" import-local "^3.0.2" - jest-config "^28.1.3" - jest-util "^28.1.3" - jest-validate "^28.1.3" + jest-config "^29.1.2" + jest-util "^29.1.2" + jest-validate "^29.1.2" prompts "^2.0.1" yargs "^17.3.1" -jest-config@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-28.1.3.tgz#e315e1f73df3cac31447eed8b8740a477392ec60" - integrity sha512-MG3INjByJ0J4AsNBm7T3hsuxKQqFIiRo/AUqb1q9LRKI5UU6Aar9JHbr9Ivn1TVwfUD9KirRoM/T6u8XlcQPHQ== +jest-config@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-29.1.2.tgz#7d004345ca4c09f5d8f802355f54494e90842f4d" + integrity sha512-EC3Zi86HJUOz+2YWQcJYQXlf0zuBhJoeyxLM6vb6qJsVmpP7KcCP1JnyF0iaqTaXdBP8Rlwsvs7hnKWQWWLwwA== dependencies: "@babel/core" "^7.11.6" - "@jest/test-sequencer" "^28.1.3" - "@jest/types" "^28.1.3" - babel-jest "^28.1.3" + "@jest/test-sequencer" "^29.1.2" + "@jest/types" "^29.1.2" + babel-jest "^29.1.2" chalk "^4.0.0" ci-info "^3.2.0" deepmerge "^4.2.2" glob "^7.1.3" graceful-fs "^4.2.9" - jest-circus "^28.1.3" - jest-environment-node "^28.1.3" - jest-get-type "^28.0.2" - jest-regex-util "^28.0.2" - jest-resolve "^28.1.3" - jest-runner "^28.1.3" - jest-util "^28.1.3" - jest-validate "^28.1.3" + jest-circus "^29.1.2" + jest-environment-node "^29.1.2" + jest-get-type "^29.0.0" + jest-regex-util "^29.0.0" + jest-resolve "^29.1.2" + jest-runner "^29.1.2" + jest-util "^29.1.2" + jest-validate "^29.1.2" micromatch "^4.0.4" parse-json "^5.2.0" - pretty-format "^28.1.3" + pretty-format "^29.1.2" slash "^3.0.0" strip-json-comments "^3.1.1" @@ -4197,67 +4356,82 @@ jest-diff@^28.1.3: jest-get-type "^28.0.2" pretty-format "^28.1.3" -jest-docblock@^28.1.1: - version "28.1.1" - resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-28.1.1.tgz#6f515c3bf841516d82ecd57a62eed9204c2f42a8" - integrity sha512-3wayBVNiOYx0cwAbl9rwm5kKFP8yHH3d/fkEaL02NPTkDojPtheGB7HZSFY4wzX+DxyrvhXz0KSCVksmCknCuA== +jest-diff@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.1.2.tgz#bb7aaf5353227d6f4f96c5e7e8713ce576a607dc" + integrity sha512-4GQts0aUopVvecIT4IwD/7xsBaMhKTYoM4/njE/aVw9wpw+pIUVp8Vab/KnSzSilr84GnLBkaP3JLDnQYCKqVQ== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.0.0" + jest-get-type "^29.0.0" + pretty-format "^29.1.2" + +jest-docblock@^29.0.0: + version "29.0.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.0.0.tgz#3151bcc45ed7f5a8af4884dcc049aee699b4ceae" + integrity sha512-s5Kpra/kLzbqu9dEjov30kj1n4tfu3e7Pl8v+f8jOkeWNqM6Ds8jRaJfZow3ducoQUrf2Z4rs2N5S3zXnb83gw== dependencies: detect-newline "^3.0.0" -jest-each@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-28.1.3.tgz#bdd1516edbe2b1f3569cfdad9acd543040028f81" - integrity sha512-arT1z4sg2yABU5uogObVPvSlSMQlDA48owx07BDPAiasW0yYpYHYOo4HHLz9q0BVzDVU4hILFjzJw0So9aCL/g== +jest-each@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-29.1.2.tgz#d4c8532c07a846e79f194f7007ce7cb1987d1cd0" + integrity sha512-AmTQp9b2etNeEwMyr4jc0Ql/LIX/dhbgP21gHAizya2X6rUspHn2gysMXaj6iwWuOJ2sYRgP8c1P4cXswgvS1A== dependencies: - "@jest/types" "^28.1.3" + "@jest/types" "^29.1.2" chalk "^4.0.0" - jest-get-type "^28.0.2" - jest-util "^28.1.3" - pretty-format "^28.1.3" + jest-get-type "^29.0.0" + jest-util "^29.1.2" + pretty-format "^29.1.2" -jest-environment-node@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-28.1.3.tgz#7e74fe40eb645b9d56c0c4b70ca4357faa349be5" - integrity sha512-ugP6XOhEpjAEhGYvp5Xj989ns5cB1K6ZdjBYuS30umT4CQEETaxSiPcZ/E1kFktX4GkrcM4qu07IIlDYX1gp+A== +jest-environment-node@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.1.2.tgz#005e05cc6ea4b9b5ba55906ab1ce53c82f6907a7" + integrity sha512-C59yVbdpY8682u6k/lh8SUMDJPbOyCHOTgLVVi1USWFxtNV+J8fyIwzkg+RJIVI30EKhKiAGNxYaFr3z6eyNhQ== dependencies: - "@jest/environment" "^28.1.3" - "@jest/fake-timers" "^28.1.3" - "@jest/types" "^28.1.3" + "@jest/environment" "^29.1.2" + "@jest/fake-timers" "^29.1.2" + "@jest/types" "^29.1.2" "@types/node" "*" - jest-mock "^28.1.3" - jest-util "^28.1.3" + jest-mock "^29.1.2" + jest-util "^29.1.2" jest-get-type@^28.0.2: version "28.0.2" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-28.0.2.tgz#34622e628e4fdcd793d46db8a242227901fcf203" integrity sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA== -jest-haste-map@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-28.1.3.tgz#abd5451129a38d9841049644f34b034308944e2b" - integrity sha512-3S+RQWDXccXDKSWnkHa/dPwt+2qwA8CJzR61w3FoYCvoo3Pn8tvGcysmMF0Bj0EX5RYvAI2EIvC57OmotfdtKA== +jest-get-type@^29.0.0: + version "29.0.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.0.0.tgz#843f6c50a1b778f7325df1129a0fd7aa713aef80" + integrity sha512-83X19z/HuLKYXYHskZlBAShO7UfLFXu/vWajw9ZNJASN32li8yHMaVGAQqxFW1RCFOkB7cubaL6FaJVQqqJLSw== + +jest-haste-map@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.1.2.tgz#93f3634aa921b6b654e7c94137b24e02e7ca6ac9" + integrity sha512-xSjbY8/BF11Jh3hGSPfYTa/qBFrm3TPM7WU8pU93m2gqzORVLkHFWvuZmFsTEBPRKndfewXhMOuzJNHyJIZGsw== dependencies: - "@jest/types" "^28.1.3" + "@jest/types" "^29.1.2" "@types/graceful-fs" "^4.1.3" "@types/node" "*" anymatch "^3.0.3" fb-watchman "^2.0.0" graceful-fs "^4.2.9" - jest-regex-util "^28.0.2" - jest-util "^28.1.3" - jest-worker "^28.1.3" + jest-regex-util "^29.0.0" + jest-util "^29.1.2" + jest-worker "^29.1.2" micromatch "^4.0.4" walker "^1.0.8" optionalDependencies: fsevents "^2.3.2" -jest-leak-detector@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-28.1.3.tgz#a6685d9b074be99e3adee816ce84fd30795e654d" - integrity sha512-WFVJhnQsiKtDEo5lG2mM0v40QWnBM+zMdHHyJs8AWZ7J0QZJS59MsyKeJHWhpBZBH32S48FOVvGyOFT1h0DlqA== +jest-leak-detector@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-29.1.2.tgz#4c846db14c58219430ccbc4f01a1ec52ebee4fc2" + integrity sha512-TG5gAZJpgmZtjb6oWxBLf2N6CfQ73iwCe6cofu/Uqv9iiAm6g502CAnGtxQaTfpHECBdVEMRBhomSXeLnoKjiQ== dependencies: - jest-get-type "^28.0.2" - pretty-format "^28.1.3" + jest-get-type "^29.0.0" + pretty-format "^29.1.2" jest-localstorage-mock@^2.4.6: version "2.4.22" @@ -4274,6 +4448,16 @@ jest-matcher-utils@^28.1.3: jest-get-type "^28.0.2" pretty-format "^28.1.3" +jest-matcher-utils@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.1.2.tgz#e68c4bcc0266e70aa1a5c13fb7b8cd4695e318a1" + integrity sha512-MV5XrD3qYSW2zZSHRRceFzqJ39B2z11Qv0KPyZYxnzDHFeYZGJlgGi0SW+IXSJfOewgJp/Km/7lpcFT+cgZypw== + dependencies: + chalk "^4.0.0" + jest-diff "^29.1.2" + jest-get-type "^29.0.0" + pretty-format "^29.1.2" + jest-message-util@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-28.1.3.tgz#232def7f2e333f1eecc90649b5b94b0055e7c43d" @@ -4289,129 +4473,154 @@ jest-message-util@^28.1.3: slash "^3.0.0" stack-utils "^2.0.3" -jest-mock@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-28.1.3.tgz#d4e9b1fc838bea595c77ab73672ebf513ab249da" - integrity sha512-o3J2jr6dMMWYVH4Lh/NKmDXdosrsJgi4AviS8oXLujcjpCMBb1FMsblDnOXKZKfSiHLxYub1eS0IHuRXsio9eA== +jest-message-util@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.1.2.tgz#c21a33c25f9dc1ebfcd0f921d89438847a09a501" + integrity sha512-9oJ2Os+Qh6IlxLpmvshVbGUiSkZVc2FK+uGOm6tghafnB2RyjKAxMZhtxThRMxfX1J1SOMhTn9oK3/MutRWQJQ== dependencies: - "@jest/types" "^28.1.3" + "@babel/code-frame" "^7.12.13" + "@jest/types" "^29.1.2" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.9" + micromatch "^4.0.4" + pretty-format "^29.1.2" + slash "^3.0.0" + stack-utils "^2.0.3" + +jest-mock@^27.5.1: + version "27.5.1" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-27.5.1.tgz#19948336d49ef4d9c52021d34ac7b5f36ff967d6" + integrity sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og== + dependencies: + "@jest/types" "^27.5.1" "@types/node" "*" +jest-mock@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.1.2.tgz#de47807edbb9d4abf8423f1d8d308d670105678c" + integrity sha512-PFDAdjjWbjPUtQPkQufvniXIS3N9Tv7tbibePEjIIprzjgo0qQlyUiVMrT4vL8FaSJo1QXifQUOuPH3HQC/aMA== + dependencies: + "@jest/types" "^29.1.2" + "@types/node" "*" + jest-util "^29.1.2" + jest-pnp-resolver@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz#b704ac0ae028a89108a4d040b3f919dfddc8e33c" integrity sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w== -jest-regex-util@^28.0.2: - version "28.0.2" - resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-28.0.2.tgz#afdc377a3b25fb6e80825adcf76c854e5bf47ead" - integrity sha512-4s0IgyNIy0y9FK+cjoVYoxamT7Zeo7MhzqRGx7YDYmaQn1wucY9rotiGkBzzcMXTtjrCAP/f7f+E0F7+fxPNdw== +jest-regex-util@^29.0.0: + version "29.0.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.0.0.tgz#b442987f688289df8eb6c16fa8df488b4cd007de" + integrity sha512-BV7VW7Sy0fInHWN93MMPtlClweYv2qrSCwfeFWmpribGZtQPWNvRSq9XOVgOEjU1iBGRKXUZil0o2AH7Iy9Lug== -jest-resolve-dependencies@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-28.1.3.tgz#8c65d7583460df7275c6ea2791901fa975c1fe66" - integrity sha512-qa0QO2Q0XzQoNPouMbCc7Bvtsem8eQgVPNkwn9LnS+R2n8DaVDPL/U1gngC0LTl1RYXJU0uJa2BMC2DbTfFrHA== +jest-resolve-dependencies@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.1.2.tgz#a6919e58a0c7465582cb8ec2d745b4e64ae8647f" + integrity sha512-44yYi+yHqNmH3OoWZvPgmeeiwKxhKV/0CfrzaKLSkZG9gT973PX8i+m8j6pDrTYhhHoiKfF3YUFg/6AeuHw4HQ== dependencies: - jest-regex-util "^28.0.2" - jest-snapshot "^28.1.3" + jest-regex-util "^29.0.0" + jest-snapshot "^29.1.2" -jest-resolve@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-28.1.3.tgz#cfb36100341ddbb061ec781426b3c31eb51aa0a8" - integrity sha512-Z1W3tTjE6QaNI90qo/BJpfnvpxtaFTFw5CDgwpyE/Kz8U/06N1Hjf4ia9quUhCh39qIGWF1ZuxFiBiJQwSEYKQ== +jest-resolve@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.1.2.tgz#9dd8c2fc83e59ee7d676b14bd45a5f89e877741d" + integrity sha512-7fcOr+k7UYSVRJYhSmJHIid3AnDBcLQX3VmT9OSbPWsWz1MfT7bcoerMhADKGvKCoMpOHUQaDHtQoNp/P9JMGg== dependencies: chalk "^4.0.0" graceful-fs "^4.2.9" - jest-haste-map "^28.1.3" + jest-haste-map "^29.1.2" jest-pnp-resolver "^1.2.2" - jest-util "^28.1.3" - jest-validate "^28.1.3" + jest-util "^29.1.2" + jest-validate "^29.1.2" resolve "^1.20.0" resolve.exports "^1.1.0" slash "^3.0.0" -jest-runner@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-28.1.3.tgz#5eee25febd730b4713a2cdfd76bdd5557840f9a1" - integrity sha512-GkMw4D/0USd62OVO0oEgjn23TM+YJa2U2Wu5zz9xsQB1MxWKDOlrnykPxnMsN0tnJllfLPinHTka61u0QhaxBA== +jest-runner@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-29.1.2.tgz#f18b2b86101341e047de8c2f51a5fdc4e97d053a" + integrity sha512-yy3LEWw8KuBCmg7sCGDIqKwJlULBuNIQa2eFSVgVASWdXbMYZ9H/X0tnXt70XFoGf92W2sOQDOIFAA6f2BG04Q== dependencies: - "@jest/console" "^28.1.3" - "@jest/environment" "^28.1.3" - "@jest/test-result" "^28.1.3" - "@jest/transform" "^28.1.3" - "@jest/types" "^28.1.3" + "@jest/console" "^29.1.2" + "@jest/environment" "^29.1.2" + "@jest/test-result" "^29.1.2" + "@jest/transform" "^29.1.2" + "@jest/types" "^29.1.2" "@types/node" "*" chalk "^4.0.0" emittery "^0.10.2" graceful-fs "^4.2.9" - jest-docblock "^28.1.1" - jest-environment-node "^28.1.3" - jest-haste-map "^28.1.3" - jest-leak-detector "^28.1.3" - jest-message-util "^28.1.3" - jest-resolve "^28.1.3" - jest-runtime "^28.1.3" - jest-util "^28.1.3" - jest-watcher "^28.1.3" - jest-worker "^28.1.3" + jest-docblock "^29.0.0" + jest-environment-node "^29.1.2" + jest-haste-map "^29.1.2" + jest-leak-detector "^29.1.2" + jest-message-util "^29.1.2" + jest-resolve "^29.1.2" + jest-runtime "^29.1.2" + jest-util "^29.1.2" + jest-watcher "^29.1.2" + jest-worker "^29.1.2" p-limit "^3.1.0" source-map-support "0.5.13" -jest-runtime@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-28.1.3.tgz#a57643458235aa53e8ec7821949e728960d0605f" - integrity sha512-NU+881ScBQQLc1JHG5eJGU7Ui3kLKrmwCPPtYsJtBykixrM2OhVQlpMmFWJjMyDfdkGgBMNjXCGB/ebzsgNGQw== +jest-runtime@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-29.1.2.tgz#dbcd57103d61115479108d5864bdcd661d9c6783" + integrity sha512-jr8VJLIf+cYc+8hbrpt412n5jX3tiXmpPSYTGnwcvNemY+EOuLNiYnHJ3Kp25rkaAcTWOEI4ZdOIQcwYcXIAZw== dependencies: - "@jest/environment" "^28.1.3" - "@jest/fake-timers" "^28.1.3" - "@jest/globals" "^28.1.3" - "@jest/source-map" "^28.1.2" - "@jest/test-result" "^28.1.3" - "@jest/transform" "^28.1.3" - "@jest/types" "^28.1.3" + "@jest/environment" "^29.1.2" + "@jest/fake-timers" "^29.1.2" + "@jest/globals" "^29.1.2" + "@jest/source-map" "^29.0.0" + "@jest/test-result" "^29.1.2" + "@jest/transform" "^29.1.2" + "@jest/types" "^29.1.2" + "@types/node" "*" chalk "^4.0.0" cjs-module-lexer "^1.0.0" collect-v8-coverage "^1.0.0" - execa "^5.0.0" glob "^7.1.3" graceful-fs "^4.2.9" - jest-haste-map "^28.1.3" - jest-message-util "^28.1.3" - jest-mock "^28.1.3" - jest-regex-util "^28.0.2" - jest-resolve "^28.1.3" - jest-snapshot "^28.1.3" - jest-util "^28.1.3" + jest-haste-map "^29.1.2" + jest-message-util "^29.1.2" + jest-mock "^29.1.2" + jest-regex-util "^29.0.0" + jest-resolve "^29.1.2" + jest-snapshot "^29.1.2" + jest-util "^29.1.2" slash "^3.0.0" strip-bom "^4.0.0" -jest-snapshot@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-28.1.3.tgz#17467b3ab8ddb81e2f605db05583d69388fc0668" - integrity sha512-4lzMgtiNlc3DU/8lZfmqxN3AYD6GGLbl+72rdBpXvcV+whX7mDrREzkPdp2RnmfIiWBg1YbuFSkXduF2JcafJg== +jest-snapshot@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.1.2.tgz#7dd277e88c45f2d2ff5888de1612e63c7ceb575b" + integrity sha512-rYFomGpVMdBlfwTYxkUp3sjD6usptvZcONFYNqVlaz4EpHPnDvlWjvmOQ9OCSNKqYZqLM2aS3wq01tWujLg7gg== dependencies: "@babel/core" "^7.11.6" "@babel/generator" "^7.7.2" + "@babel/plugin-syntax-jsx" "^7.7.2" "@babel/plugin-syntax-typescript" "^7.7.2" "@babel/traverse" "^7.7.2" "@babel/types" "^7.3.3" - "@jest/expect-utils" "^28.1.3" - "@jest/transform" "^28.1.3" - "@jest/types" "^28.1.3" + "@jest/expect-utils" "^29.1.2" + "@jest/transform" "^29.1.2" + "@jest/types" "^29.1.2" "@types/babel__traverse" "^7.0.6" "@types/prettier" "^2.1.5" babel-preset-current-node-syntax "^1.0.0" chalk "^4.0.0" - expect "^28.1.3" + expect "^29.1.2" graceful-fs "^4.2.9" - jest-diff "^28.1.3" - jest-get-type "^28.0.2" - jest-haste-map "^28.1.3" - jest-matcher-utils "^28.1.3" - jest-message-util "^28.1.3" - jest-util "^28.1.3" + jest-diff "^29.1.2" + jest-get-type "^29.0.0" + jest-haste-map "^29.1.2" + jest-matcher-utils "^29.1.2" + jest-message-util "^29.1.2" + jest-util "^29.1.2" natural-compare "^1.4.0" - pretty-format "^28.1.3" + pretty-format "^29.1.2" semver "^7.3.5" jest-sonar-reporter@^2.0.0: @@ -4433,50 +4642,68 @@ jest-util@^28.1.3: graceful-fs "^4.2.9" picomatch "^2.2.3" -jest-validate@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-28.1.3.tgz#e322267fd5e7c64cea4629612c357bbda96229df" - integrity sha512-SZbOGBWEsaTxBGCOpsRWlXlvNkvTkY0XxRfh7zYmvd8uL5Qzyg0CHAXiXKROflh801quA6+/DsT4ODDthOC/OA== +jest-util@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.1.2.tgz#ac5798e93cb6a6703084e194cfa0898d66126df1" + integrity sha512-vPCk9F353i0Ymx3WQq3+a4lZ07NXu9Ca8wya6o4Fe4/aO1e1awMMprZ3woPFpKwghEOW+UXgd15vVotuNN9ONQ== dependencies: - "@jest/types" "^28.1.3" + "@jest/types" "^29.1.2" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + +jest-validate@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.1.2.tgz#83a728b8f6354da2e52346878c8bc7383516ca51" + integrity sha512-k71pOslNlV8fVyI+mEySy2pq9KdXdgZtm7NHrBX8LghJayc3wWZH0Yr0mtYNGaCU4F1OLPXRkwZR0dBm/ClshA== + dependencies: + "@jest/types" "^29.1.2" camelcase "^6.2.0" chalk "^4.0.0" - jest-get-type "^28.0.2" + jest-get-type "^29.0.0" leven "^3.1.0" - pretty-format "^28.1.3" + pretty-format "^29.1.2" -jest-watcher@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-28.1.3.tgz#c6023a59ba2255e3b4c57179fc94164b3e73abd4" - integrity sha512-t4qcqj9hze+jviFPUN3YAtAEeFnr/azITXQEMARf5cMwKY2SMBRnCQTXLixTl20OR6mLh9KLMrgVJgJISym+1g== +jest-watcher@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.1.2.tgz#de21439b7d889e2fcf62cc2a4779ef1a3f1f3c62" + integrity sha512-6JUIUKVdAvcxC6bM8/dMgqY2N4lbT+jZVsxh0hCJRbwkIEnbr/aPjMQ28fNDI5lB51Klh00MWZZeVf27KBUj5w== dependencies: - "@jest/test-result" "^28.1.3" - "@jest/types" "^28.1.3" + "@jest/test-result" "^29.1.2" + "@jest/types" "^29.1.2" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" emittery "^0.10.2" - jest-util "^28.1.3" + jest-util "^29.1.2" string-length "^4.0.1" -jest-worker@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-28.1.3.tgz#7e3c4ce3fa23d1bb6accb169e7f396f98ed4bb98" - integrity sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g== +jest-worker@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.1.2.tgz#a68302af61bce82b42a9a57285ca7499d29b2afc" + integrity sha512-AdTZJxKjTSPHbXT/AIOjQVmoFx0LHFcVabWu0sxI7PAy7rFf8c0upyvgBKgguVXdM4vY74JdwkyD4hSmpTW8jA== dependencies: "@types/node" "*" + jest-util "^29.1.2" merge-stream "^2.0.0" supports-color "^8.0.0" -jest@^28.0.0: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest/-/jest-28.1.3.tgz#e9c6a7eecdebe3548ca2b18894a50f45b36dfc6b" - integrity sha512-N4GT5on8UkZgH0O5LUavMRV1EDEhNTL0KEfRmDIeZHSV7p2XgLoY9t9VDUgL6o+yfdgYHVxuz81G8oB9VG5uyA== +jest@^29.0.0: + version "29.1.2" + resolved "https://registry.yarnpkg.com/jest/-/jest-29.1.2.tgz#f821a1695ffd6cd0efc3b59d2dfcc70a98582499" + integrity sha512-5wEIPpCezgORnqf+rCaYD1SK+mNN7NsstWzIsuvsnrhR/hSxXWd82oI7DkrbJ+XTD28/eG8SmxdGvukrGGK6Tw== dependencies: - "@jest/core" "^28.1.3" - "@jest/types" "^28.1.3" + "@jest/core" "^29.1.2" + "@jest/types" "^29.1.2" import-local "^3.0.2" - jest-cli "^28.1.3" + jest-cli "^29.1.2" + +js-sdsl@^4.1.4: + version "4.1.5" + resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.1.5.tgz#1ff1645e6b4d1b028cd3f862db88c9d887f26e2a" + integrity sha512-08bOAKweV2NUC1wqTtf3qZlnpOX/R2DU9ikpjOHs0H+ibQv3zpncVQg6um4uYtRtrwIX8M4Nh3ytK4HGlYAq7Q== js-stringify@^1.0.1: version "1.0.2" @@ -4782,9 +5009,9 @@ makeerror@1.0.12: tmpl "1.0.5" markdown-it-anchor@^8.4.1: - version "8.6.4" - resolved "https://registry.yarnpkg.com/markdown-it-anchor/-/markdown-it-anchor-8.6.4.tgz#affb8aa0910a504c114e9fcad53ac3a5b907b0e6" - integrity sha512-Ul4YVYZNxMJYALpKtu+ZRdrryYt/GlQ5CK+4l1bp/gWXOG2QWElt6AqF3Mih/wfUKdZbNAZVXGR73/n6U/8img== + version "8.6.5" + resolved "https://registry.yarnpkg.com/markdown-it-anchor/-/markdown-it-anchor-8.6.5.tgz#30c4bc5bbff327f15ce3c429010ec7ba75e7b5f8" + integrity sha512-PI1qEHHkTNWT+X6Ip9w+paonfIQ+QZP9sCeMYi47oqhH+EsW8CrJ8J7CzV19QVOj6il8ATGbK2nTECj22ZHGvQ== markdown-it@^12.3.2: version "12.3.2" @@ -4798,9 +5025,9 @@ markdown-it@^12.3.2: uc.micro "^1.0.5" marked@^4.0.10: - version "4.0.18" - resolved "https://registry.yarnpkg.com/marked/-/marked-4.0.18.tgz#cd0ac54b2e5610cfb90e8fd46ccaa8292c9ed569" - integrity sha512-wbLDJ7Zh0sqA0Vdg6aqlbT+yPxqLblpAZh1mK2+AO2twQkPywvvqQNfEPVwSSRjZ7dZcdeVBIAgiO7MMp3Dszw== + version "4.1.1" + resolved "https://registry.yarnpkg.com/marked/-/marked-4.1.1.tgz#2f709a4462abf65a283f2453dc1c42ab177d302e" + integrity sha512-0cNMnTcUJPxbA6uWmCmjWz4NJRe/0Xfk2NhXCUHjew9qJzFN20krFnsUe7QynwqOwa5m1fZ4UDg0ycKFVC0ccw== matrix-events-sdk@^0.0.1-beta.7: version "0.0.1-beta.7" @@ -4808,9 +5035,9 @@ matrix-events-sdk@^0.0.1-beta.7: integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA== matrix-mock-request@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/matrix-mock-request/-/matrix-mock-request-2.1.2.tgz#11e38ed1233dced88a6f2bfba1684d5c5b3aa2c2" - integrity sha512-/OXCIzDGSLPJ3fs+uzDrtaOHI/Sqp4iEuniRn31U8S06mPXbvAnXknHqJ4c6A/KVwJj/nPFbGXpK4wPM038I6A== + version "2.4.1" + resolved "https://registry.yarnpkg.com/matrix-mock-request/-/matrix-mock-request-2.4.1.tgz#a9c7dbb6b466f582ba2ca21f17cf18ceb41c7657" + integrity sha512-QMNpKUeHS2RHovSKybUySFTXTJ11EQPkp3bgvEXmNqAc3TYM23gKYqgI288BoBDYwQrK3WJFT0d4bvMiNIS/vA== dependencies: expect "^28.1.0" @@ -5027,7 +5254,7 @@ object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== -object-inspect@^1.12.0, object-inspect@^1.9.0: +object-inspect@^1.12.2, object-inspect@^1.9.0: version "1.12.2" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== @@ -5037,7 +5264,7 @@ object-keys@^1.1.1: resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== -object.assign@^4.1.0, object.assign@^4.1.2: +object.assign@^4.1.0, object.assign@^4.1.4: version "4.1.4" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f" integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ== @@ -5070,6 +5297,15 @@ onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +open@^8.4.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8" + integrity sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q== + dependencies: + define-lazy-prop "^2.0.0" + is-docker "^2.1.1" + is-wsl "^2.2.0" + optionator@^0.9.1: version "0.9.1" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" @@ -5277,7 +5513,7 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -pretty-format@^28.0.0, pretty-format@^28.1.3: +pretty-format@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-28.1.3.tgz#c9fba8cedf99ce50963a11b27d982a9ae90970d5" integrity sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q== @@ -5287,6 +5523,15 @@ pretty-format@^28.0.0, pretty-format@^28.1.3: ansi-styles "^5.0.0" react-is "^18.0.0" +pretty-format@^29.0.0, pretty-format@^29.1.2: + version "29.1.2" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.1.2.tgz#b1f6b75be7d699be1a051f5da36e8ae9e76a8e6a" + integrity sha512-CGJ6VVGXVRP2o2Dorl4mAwwvDWT25luIsYhkyVQW32E4nL+TgW939J7LlKT/npq5Cpq6j3s+sy+13yk7xYpBmg== + dependencies: + "@jest/schemas" "^29.0.0" + ansi-styles "^5.0.0" + react-is "^18.0.0" + private@^0.1.8: version "0.1.8" resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" @@ -5607,10 +5852,10 @@ recast@^0.17.3: private "^0.1.8" source-map "~0.6.1" -regenerate-unicode-properties@^10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.0.1.tgz#7f442732aa7934a3740c779bb9b3340dccc1fb56" - integrity sha512-vn5DU6yg6h8hP/2OkQo3K7uVILvY4iu0oI4t3HFa81UPkhGJwkRwM10JEc3upjdhHjs/k8GJY1sRBhk5sr69Bw== +regenerate-unicode-properties@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz#7c3192cab6dd24e21cb4461e5ddd7dd24fa8374c" + integrity sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ== dependencies: regenerate "^1.4.2" @@ -5651,26 +5896,26 @@ regexpp@^3.2.0: integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== regexpu-core@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.1.0.tgz#2f8504c3fd0ebe11215783a41541e21c79942c6d" - integrity sha512-bb6hk+xWd2PEOkj5It46A16zFMs2mv86Iwpdu94la4S3sJ7C973h2dHpYKwIBGaWSO7cIRJ+UX0IeMaWcO4qwA== + version "5.2.1" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.2.1.tgz#a69c26f324c1e962e9ffd0b88b055caba8089139" + integrity sha512-HrnlNtpvqP1Xkb28tMhBUO2EbyUHdQlsnlAhzWcwHy8WJR53UWr7/MAvqrsQKMbV4qdpv03oTMG8iIhfsPFktQ== dependencies: regenerate "^1.4.2" - regenerate-unicode-properties "^10.0.1" - regjsgen "^0.6.0" - regjsparser "^0.8.2" + regenerate-unicode-properties "^10.1.0" + regjsgen "^0.7.1" + regjsparser "^0.9.1" unicode-match-property-ecmascript "^2.0.0" unicode-match-property-value-ecmascript "^2.0.0" -regjsgen@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.6.0.tgz#83414c5354afd7d6627b16af5f10f41c4e71808d" - integrity sha512-ozE883Uigtqj3bx7OhL1KNbCzGyW2NQZPl6Hs09WTvCuZD5sTI4JY58bkbQWa/Y9hxIsvJ3M8Nbf7j54IqeZbA== +regjsgen@^0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.7.1.tgz#ee5ef30e18d3f09b7c369b76e7c2373ed25546f6" + integrity sha512-RAt+8H2ZEzHeYWxZ3H2z6tF18zyyOnlcdaafLrm21Bguj7uZy6ULibiAFdXEtKQY4Sy7wDTwDiOazasMLc4KPA== -regjsparser@^0.8.2: - version "0.8.4" - resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.8.4.tgz#8a14285ffcc5de78c5b95d62bbf413b6bc132d5f" - integrity sha512-J3LABycON/VNEu3abOviqGHuB/LOtOQj8SKmfP9anY5GfAVw/SPjwzSjxGjbZXIxbGfqTHtJw58C2Li/WkStmA== +regjsparser@^0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.9.1.tgz#272d05aa10c7c1f67095b1ff0addae8442fc5709" + integrity sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ== dependencies: jsesc "~0.5.0" @@ -5765,7 +6010,7 @@ right-align@^0.1.1: dependencies: align-text "^0.1.1" -rimraf@^3.0.0, rimraf@^3.0.2: +rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== @@ -5797,16 +6042,20 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== +safe-regex-test@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295" + integrity sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.3" + is-regex "^1.1.4" + safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -semver@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" - integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== - semver@^5.6.0: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" @@ -5817,13 +6066,20 @@ semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== -semver@^7.3.5, semver@^7.3.7: +semver@^7.3.5: version "7.3.7" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== dependencies: lru-cache "^6.0.0" +semver@^7.3.7: + version "7.3.8" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" + integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A== + dependencies: + lru-cache "^6.0.0" + sha.js@^2.4.0, sha.js@^2.4.8: version "2.4.11" resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" @@ -5897,6 +6153,11 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +slash@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" + integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== + source-map-support@0.5.13: version "0.5.13" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" @@ -6108,9 +6369,9 @@ supports-color@^8.0.0: has-flag "^4.0.0" supports-hyperlinks@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.2.0.tgz#4f77b42488765891774b70c79babd87f9bd594bb" - integrity sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ== + version "2.3.0" + resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz#3943544347c1ff90b15effb03fc14ae45ec10624" + integrity sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA== dependencies: has-flag "^4.0.0" supports-color "^7.0.0" @@ -6120,6 +6381,14 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +synckit@^0.8.3: + version "0.8.4" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.8.4.tgz#0e6b392b73fafdafcde56692e3352500261d64ec" + integrity sha512-Dn2ZkzMdSX827QbowGbU/4yjWuvNaCoScLLoMo/yKbu+P4GBR6cRGKZH27k6a9bRzdqcyd1DE96pQtQ6uNkmyw== + dependencies: + "@pkgr/utils" "^2.3.1" + tslib "^2.4.0" + syntax-error@^1.1.1: version "1.4.0" resolved "https://registry.yarnpkg.com/syntax-error/-/syntax-error-1.4.0.tgz#2d9d4ff5c064acb711594a3e3b95054ad51d907c" @@ -6132,6 +6401,11 @@ taffydb@2.6.2: resolved "https://registry.yarnpkg.com/taffydb/-/taffydb-2.6.2.tgz#7cbcb64b5a141b6a2efc2c5d2c67b4e150b2a268" integrity sha512-y3JaeRSplks6NYQuCOj3ZFMO3j60rTwbuKCvZxsAraGYH2epusatvZ0baZYA01WsGqJBq/Dl6vOrMUJqyMj8kA== +tapable@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" + integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== + terminal-link@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" @@ -6141,9 +6415,9 @@ terminal-link@^2.0.0: supports-hyperlinks "^2.0.0" terser@^5.5.1: - version "5.14.2" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.14.2.tgz#9ac9f22b06994d736174f4091aa368db896f1c10" - integrity sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA== + version "5.15.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.15.0.tgz#e16967894eeba6e1091509ec83f0c60e179f2425" + integrity sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA== dependencies: "@jridgewell/source-map" "^0.3.2" acorn "^8.5.0" @@ -6197,6 +6471,14 @@ timers-ext@^0.1.7: es5-ext "~0.10.46" next-tick "1" +tiny-glob@^0.2.9: + version "0.2.9" + resolved "https://registry.yarnpkg.com/tiny-glob/-/tiny-glob-0.2.9.tgz#2212d441ac17928033b110f8b3640683129d31e2" + integrity sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg== + dependencies: + globalyzer "0.1.0" + globrex "^0.1.2" + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -6286,7 +6568,7 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.1: +tslib@^2.0.1, tslib@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== @@ -6347,7 +6629,7 @@ type@^1.0.1: resolved "https://registry.yarnpkg.com/type/-/type-1.2.0.tgz#848dd7698dafa3e54a6c479e759c4bc3f18847a0" integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg== -type@^2.5.0: +type@^2.7.2: version "2.7.2" resolved "https://registry.yarnpkg.com/type/-/type-2.7.2.tgz#2376a15a3a28b1efa0f5350dcf72d24df6ef98d0" integrity sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw== @@ -6363,9 +6645,9 @@ typescript@^3.2.2: integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q== typescript@^4.5.3, typescript@^4.5.4: - version "4.7.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" - integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== + version "4.8.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6" + integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ== typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39" @@ -6428,9 +6710,9 @@ undeclared-identifiers@^1.1.2: xtend "^4.0.1" underscore@^1.13.2, underscore@~1.13.2: - version "1.13.4" - resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.4.tgz#7886b46bbdf07f768e0052f1828e1dcab40c0dee" - integrity sha512-BQFnUDuAQ4Yf/cYY5LNrK9NCJFKriaRbD9uR1fTeXnBeoa97W0i41qkZfGO9pSo8I5KzjAcSY2XYtdf0oKd7KQ== + version "1.13.6" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441" + integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A== unhomoglyph@^1.0.6: version "1.0.6" @@ -6456,19 +6738,19 @@ unicode-match-property-value-ecmascript@^2.0.0: integrity sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw== unicode-property-aliases-ecmascript@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz#0a36cb9a585c4f6abd51ad1deddb285c165297c8" - integrity sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ== + version "2.1.0" + resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" + integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== universal-user-agent@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee" integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w== -update-browserslist-db@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.5.tgz#be06a5eedd62f107b7c19eb5bcefb194411abf38" - integrity sha512-dteFFpCyvuDdr9S/ff1ISkKt/9YZxKjI9WlRR99c180GaztJtRa/fn18FdxGVKVsnPY7/a/FDN68mcvUmP4U7Q== +update-browserslist-db@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.9.tgz#2924d3927367a38d5c555413a7ce138fc95fcb18" + integrity sha512-/xsqn21EGVdXI3EXSum1Yckj3ZVZugqyOZQ/CxYPBD/R+ko9NSUScf8tFF4dOKY+2pvSSJA/S+5B8s4Zr4kyvg== dependencies: escalade "^3.1.1" picocolors "^1.0.0" @@ -6522,11 +6804,6 @@ uuid@^8.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -v8-compile-cache@^2.0.3: - version "2.3.0" - resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" - integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== - v8-to-istanbul@^9.0.0, v8-to-istanbul@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz#b6f994b0b5d4ef255e17a0d17dc444a9f5132fa4" @@ -6572,9 +6849,9 @@ vue-docgen-api@^3.26.0: vue-template-compiler "^2.0.0" vue-template-compiler@^2.0.0: - version "2.7.9" - resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.7.9.tgz#ffbeb1769ae6af65cd405a6513df6b1e20e33616" - integrity sha512-NPJxt6OjVlzmkixYg0SdIN2Lw/rMryQskObY89uAMcM9flS/HrmLK5LaN1ReBTuhBgnYuagZZEkSS6FESATQUQ== + version "2.7.10" + resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.7.10.tgz#9e20f35b2fdccacacf732dd7dedb49bf65f4556b" + integrity sha512-QO+8R9YRq1Gudm8ZMdo/lImZLJVUIAM8c07Vp84ojdDAf8HmPJc7XB556PcXV218k2AkKznsRz6xB5uOjAC4EQ== dependencies: de-indent "^1.0.2" he "^1.2.0" @@ -6754,11 +7031,11 @@ yargs@^16.2.0: yargs-parser "^20.2.2" yargs@^17.0.1, yargs@^17.3.1: - version "17.5.1" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.5.1.tgz#e109900cab6fcb7fd44b1d8249166feb0b36e58e" - integrity sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA== + version "17.6.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.6.0.tgz#e134900fc1f218bc230192bdec06a0a5f973e46c" + integrity sha512-8H/wTDqlSwoSnScvV2N/JHfLWOKuh5MVla9hqLjK3nsfyy6Y4kDSYSvkU5YCUEPOSnRXfIyx3Sq+B/IWudTo4g== dependencies: - cliui "^7.0.2" + cliui "^8.0.1" escalade "^3.1.1" get-caller-file "^2.0.5" require-directory "^2.1.1"