diff --git a/.babelrc b/.babelrc index 6f4f54a93f1..6e6720bbf11 100644 --- a/.babelrc +++ b/.babelrc @@ -1,12 +1,15 @@ { "sourceMaps": true, "presets": [ - ["@babel/preset-env", { - "targets": { - "node": 10 - }, - "modules": "commonjs" - }], + [ + "@babel/preset-env", + { + "targets": { + "node": 10 + }, + "modules": "commonjs" + } + ], "@babel/preset-typescript" ], "plugins": [ diff --git a/.eslintrc.js b/.eslintrc.js index 3059df17f4d..fd77e6c5e42 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,12 +1,6 @@ module.exports = { - plugins: [ - "matrix-org", - "import", - ], - extends: [ - "plugin:matrix-org/babel", - "plugin:import/typescript", - ], + plugins: ["matrix-org", "import", "jsdoc"], + extends: ["plugin:matrix-org/babel", "plugin:import/typescript"], env: { browser: true, node: true, @@ -27,72 +21,95 @@ module.exports = { "padded-blocks": ["error"], "no-extend-native": ["error"], "camelcase": ["error"], - "no-multi-spaces": ["error", { "ignoreEOLComments": true }], - "space-before-function-paren": ["error", { - "anonymous": "never", - "named": "never", - "asyncArrow": "always", - }], + "no-multi-spaces": ["error", { ignoreEOLComments: true }], + "space-before-function-paren": [ + "error", + { + anonymous: "never", + named: "never", + asyncArrow: "always", + }, + ], "arrow-parens": "off", "prefer-promise-reject-errors": "off", - "quotes": "off", - "indent": "off", "no-constant-condition": "off", "no-async-promise-executor": "off", // We use a `logger` intermediary module "no-console": "error", // restrict EventEmitters to force callers to use TypedEventEmitter - "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: [ - "**/*.ts", + "no-restricted-imports": [ + "error", + { + name: "events", + message: "Please use TypedEventEmitter instead", + }, ], - extends: [ - "plugin:matrix-org/typescript", + + "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", + }, + ], + }, ], - rules: { - // TypeScript has its own version of this - "@babel/no-invalid-this": "off", + }, + overrides: [ + { + files: ["**/*.ts"], + plugins: ["eslint-plugin-tsdoc"], + extends: ["plugin:matrix-org/typescript"], + rules: { + // TypeScript has its own version of this + "@babel/no-invalid-this": "off", - // We're okay being explicit at the moment - "@typescript-eslint/no-empty-interface": "off", - // We disable this while we're transitioning - "@typescript-eslint/no-explicit-any": "off", - // We'd rather not do this but we do - "@typescript-eslint/ban-ts-comment": "off", - // We're okay with assertion errors when we ask for them - "@typescript-eslint/no-non-null-assertion": "off", + // We're okay being explicit at the moment + "@typescript-eslint/no-empty-interface": "off", + // We disable this while we're transitioning + "@typescript-eslint/no-explicit-any": "off", + // We'd rather not do this but we do + "@typescript-eslint/ban-ts-comment": "off", + // We're okay with assertion errors when we ask for them + "@typescript-eslint/no-non-null-assertion": "off", - // The non-TypeScript rule produces false positives - "func-call-spacing": "off", - "@typescript-eslint/func-call-spacing": ["error"], + // The non-TypeScript rule produces false positives + "func-call-spacing": "off", + "@typescript-eslint/func-call-spacing": ["error"], - "quotes": "off", - // We use a `logger` intermediary module - "no-console": "error", + "quotes": "off", + // We use a `logger` intermediary module + "no-console": "error", + }, }, - }, { - files: [ - "spec/**/*.ts", - ], - rules: { - // We don't need super strict typing in test utilities - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/explicit-member-accessibility": "off", + { + // We don't need amazing docs in our spec files + files: ["src/**/*.ts"], + rules: { + "tsdoc/syntax": "error", + // We use some select jsdoc rules as the tsdoc linter has only one rule + "jsdoc/no-types": "error", + "jsdoc/empty-tags": "error", + "jsdoc/check-property-names": "error", + "jsdoc/check-values": "error", + // These need a bit more work before we can enable + // "jsdoc/check-param-names": "error", + // "jsdoc/check-indentation": "error", + }, }, - }], + { + files: ["spec/**/*.ts"], + rules: { + // We don't need super strict typing in test utilities + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-member-accessibility": "off", + }, + }, + ], }; diff --git a/.github/renovate.json b/.github/renovate.json index 91ed4799766..76320426d9e 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,6 +1,4 @@ { - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "github>matrix-org/renovate-config-element-web" - ] + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["github>matrix-org/renovate-config-element-web"] } diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 10bda8e205f..32deb2f2b57 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -1,30 +1,30 @@ name: Backport on: - pull_request_target: - types: - - closed - - labeled - branches: - - develop + pull_request_target: + types: + - closed + - labeled + branches: + - develop jobs: - backport: - name: Backport - runs-on: ubuntu-latest - # Only react to merged PRs for security reasons. - # See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target. - if: > - github.event.pull_request.merged - && ( - github.event.action == 'closed' - || ( - github.event.action == 'labeled' - && contains(github.event.label.name, 'backport') - ) - ) - steps: - - uses: tibdex/backport@v2 - with: - 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 }} + backport: + name: Backport + runs-on: ubuntu-latest + # Only react to merged PRs for security reasons. + # See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target. + if: > + github.event.pull_request.merged + && ( + github.event.action == 'closed' + || ( + github.event.action == 'labeled' + && contains(github.event.label.name, 'backport') + ) + ) + steps: + - uses: tibdex/backport@v2 + with: + 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/docs-pr-netlify.yaml b/.github/workflows/docs-pr-netlify.yaml index 4e1b09c5b0f..c53f981b6ea 100644 --- a/.github/workflows/docs-pr-netlify.yaml +++ b/.github/workflows/docs-pr-netlify.yaml @@ -1,34 +1,34 @@ name: Deploy documentation PR preview on: - workflow_run: - workflows: [ "Static Analysis" ] - types: - - completed + workflow_run: + workflows: ["Static Analysis"] + types: + - completed jobs: - netlify: - if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' - runs-on: ubuntu-latest - steps: - # There's a 'download artifact' action, but it hasn't been updated for the workflow_run action - # (https://github.com/actions/download-artifact/issues/60) so instead we get this mess: - - name: 📥 Download artifact - uses: dawidd6/action-download-artifact@e6e25ac3a2b93187502a8be1ef9e9603afc34925 # v2.24.2 - with: - workflow: static_analysis.yml - run_id: ${{ github.event.workflow_run.id }} - name: docs - path: docs + netlify: + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + steps: + # There's a 'download artifact' action, but it hasn't been updated for the workflow_run action + # (https://github.com/actions/download-artifact/issues/60) so instead we get this mess: + - name: 📥 Download artifact + uses: dawidd6/action-download-artifact@e6e25ac3a2b93187502a8be1ef9e9603afc34925 # v2.24.2 + with: + workflow: static_analysis.yml + run_id: ${{ github.event.workflow_run.id }} + name: docs + path: docs - - name: 📤 Deploy to Netlify - uses: matrix-org/netlify-pr-preview@v1 - with: - path: docs - owner: ${{ github.event.workflow_run.head_repository.owner.login }} - branch: ${{ github.event.workflow_run.head_branch }} - revision: ${{ github.event.workflow_run.head_sha }} - token: ${{ secrets.NETLIFY_AUTH_TOKEN }} - site_id: ${{ secrets.NETLIFY_SITE_ID }} - desc: Documentation preview - deployment_env: PR Documentation Preview + - name: 📤 Deploy to Netlify + uses: matrix-org/netlify-pr-preview@v1 + with: + path: docs + owner: ${{ github.event.workflow_run.head_repository.owner.login }} + branch: ${{ github.event.workflow_run.head_branch }} + revision: ${{ github.event.workflow_run.head_sha }} + token: ${{ secrets.NETLIFY_AUTH_TOKEN }} + site_id: ${{ secrets.NETLIFY_SITE_ID }} + desc: Documentation preview + deployment_env: PR Documentation Preview diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index 84d88ea5e5e..88f452faf5a 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -1,41 +1,41 @@ # Must only be called from `release#published` triggers name: Publish to npm on: - workflow_call: - secrets: - NPM_TOKEN: - required: true + 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 + 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: 🔧 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: 🔨 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: 🚀 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 }} + - 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/static_analysis.yml b/.github/workflows/static_analysis.yml index 5f612727815..c66451559d3 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -1,109 +1,74 @@ name: Static Analysis on: - pull_request: { } - push: - branches: [ develop, master ] + pull_request: {} + push: + branches: [develop, master] concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - ts_lint: - name: "Typescript Syntax Check" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-node@v3 - with: - cache: 'yarn' - - - name: Install Deps - run: "yarn install" - - - 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 - steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-node@v3 - with: - cache: 'yarn' - - - name: Install Deps - run: "yarn install" - - - name: Run Linter - run: "yarn run lint:js" - - docs: - name: "JSDoc Checker" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-node@v3 - with: - cache: 'yarn' - - - name: Install Deps - run: "yarn install" - - - name: Generate Docs - run: "yarn run gendoc" - - - name: Upload Artifact - uses: actions/upload-artifact@v3 - with: - name: docs - path: _docs - # We'll only use this in a workflow_run, then we're done with it - retention-days: 1 - - tsc-strict: - name: Typescript Strict Error Checker - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - permissions: - pull-requests: read - checks: write - steps: - - uses: actions/checkout@v3 - - - name: Get diff lines - id: diff - uses: Equip-Collaboration/diff-line-numbers@v1.0.0 - with: - include: '["\\.tsx?$"]' - - - name: Detecting files changed - id: files - uses: futuratrepadeira/changed-files@v4.0.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - pattern: '^.*\.tsx?$' - - - uses: t3chguy/typescript-check-action@main - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - use-check: false - check-fail-mode: added - output-behaviour: annotate - ts-extra-args: '--noImplicitAny' - files-changed: ${{ steps.files.outputs.files_updated }} - files-added: ${{ steps.files.outputs.files_created }} - files-deleted: ${{ steps.files.outputs.files_deleted }} - line-numbers: ${{ steps.diff.outputs.lineNumbers }} + ts_lint: + name: "Typescript Syntax Check" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + cache: "yarn" + + - name: Install Deps + run: "yarn install" + + - 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 + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + cache: "yarn" + + - name: Install Deps + run: "yarn install" + + - name: Run Linter + run: "yarn run lint:js" + + docs: + name: "JSDoc Checker" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + cache: "yarn" + + - name: Install Deps + run: "yarn install" + + - name: Generate Docs + run: "yarn run gendoc" + + - name: Upload Artifact + uses: actions/upload-artifact@v3 + with: + name: docs + path: _docs + # We'll only use this in a workflow_run, then we're done with it + retention-days: 1 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000000..6d56d969f02 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,26 @@ +/_docs +.DS_Store + +/.npmrc +/*.log +package-lock.json +.lock-wscript +build/Release +coverage +lib-cov +out +/dist +/lib +/examples/browser/lib +/examples/crypto-browser/lib +/examples/voip/lib + +# version file and tarball created by `npm pack` / `yarn pack` +/git-revision.txt +/matrix-js-sdk-*.tgz + +.vscode +.vscode/ + +# This file is owned, parsed, and generated by allchange, which doesn't comply with prettier +/CHANGELOG.md diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000000..6a17910f1a0 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1 @@ +module.exports = require("eslint-plugin-matrix-org/.prettierrc.js"); diff --git a/CHANGELOG.md b/CHANGELOG.md index e5dd9237cf8..e05be659ebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +Changes in [23.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.0.0) (2022-12-21) +================================================================================================== + +## 🚨 BREAKING CHANGES + * Process `m.room.encryption` events before emitting `RoomMember` events ([\#2914](https://github.com/matrix-org/matrix-js-sdk/pull/2914)). Fixes vector-im/element-web#23819. + * Don't expose `calls` on `GroupCall` ([\#2941](https://github.com/matrix-org/matrix-js-sdk/pull/2941)). + +## ✨ Features + * Support MSC3391: Account data deletion ([\#2967](https://github.com/matrix-org/matrix-js-sdk/pull/2967)). + * Add a message ID on each to-device message ([\#2938](https://github.com/matrix-org/matrix-js-sdk/pull/2938)). + * Enable multiple users' power levels to be set at once ([\#2892](https://github.com/matrix-org/matrix-js-sdk/pull/2892)). Contributed by @GoodGuyMarco. + * Include pending events in thread summary and count again ([\#2922](https://github.com/matrix-org/matrix-js-sdk/pull/2922)). Fixes vector-im/element-web#23642. + * Make GroupCall work better with widgets ([\#2935](https://github.com/matrix-org/matrix-js-sdk/pull/2935)). + * Add method to get outgoing room key requests for a given event ([\#2930](https://github.com/matrix-org/matrix-js-sdk/pull/2930)). + +## 🐛 Bug Fixes + * Fix messages loaded during initial fetch ending up out of order ([\#2971](https://github.com/matrix-org/matrix-js-sdk/pull/2971)). Fixes vector-im/element-web#23972. + * Fix #23919: Root message for new thread loaded from network ([\#2965](https://github.com/matrix-org/matrix-js-sdk/pull/2965)). Fixes vector-im/element-web#23919. + * Fix #23916: Prevent edits of the last message in a thread getting lost ([\#2951](https://github.com/matrix-org/matrix-js-sdk/pull/2951)). Fixes vector-im/element-web#23916 and vector-im/element-web#23942. + * Fix infinite loop when restoring cached read receipts ([\#2963](https://github.com/matrix-org/matrix-js-sdk/pull/2963)). Fixes vector-im/element-web#23951. + * Don't swallow errors coming from the shareSession call ([\#2962](https://github.com/matrix-org/matrix-js-sdk/pull/2962)). Fixes vector-im/element-web#23792. + * Make sure that MegolmEncryption.setupPromise always resolves ([\#2960](https://github.com/matrix-org/matrix-js-sdk/pull/2960)). + * Do not calculate highlight notifs for threads unknown to the room ([\#2957](https://github.com/matrix-org/matrix-js-sdk/pull/2957)). + * Cache read receipts for unknown threads ([\#2953](https://github.com/matrix-org/matrix-js-sdk/pull/2953)). + * bugfix: sliding sync initial room timelines shouldn't notify ([\#2933](https://github.com/matrix-org/matrix-js-sdk/pull/2933)). + * Redo key sharing after own device verification ([\#2921](https://github.com/matrix-org/matrix-js-sdk/pull/2921)). Fixes vector-im/element-web#23333. + * Move updated threads to the end of the thread list ([\#2923](https://github.com/matrix-org/matrix-js-sdk/pull/2923)). Fixes vector-im/element-web#23876. + * Fix highlight notifications increasing when total notification is zero ([\#2937](https://github.com/matrix-org/matrix-js-sdk/pull/2937)). Fixes vector-im/element-web#23885. + * Fix synthesizeReceipt ([\#2916](https://github.com/matrix-org/matrix-js-sdk/pull/2916)). Fixes vector-im/element-web#23827 vector-im/element-web#23754 and vector-im/element-web#23847. + Changes in [22.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v22.0.0) (2022-12-06) ================================================================================================== diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7405ed23fd5..a7fd0989fc6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,3 @@ -Contributing code to matrix-js-sdk -================================== +# Contributing code to matrix-js-sdk matrix-js-sdk follows the same pattern as https://github.com/vector-im/element-web/blob/develop/CONTRIBUTING.md - diff --git a/README.md b/README.md index da9368cee3d..9f0e191ac67 100644 --- a/README.md +++ b/README.md @@ -7,27 +7,25 @@ `-- matrix-js-sdk <-- this repo (Matrix client js sdk) -Matrix Javascript SDK -===================== +# Matrix JavaScript SDK This is the [Matrix](https://matrix.org) Client-Server SDK for JavaScript and TypeScript. This SDK can be run in a browser or in Node.js. The Matrix specification is constantly evolving - while this SDK aims for maximum backwards compatibility, it only guarantees that a feature will be supported for at least 4 spec releases. For example, if a feature the js-sdk supports -is removed in v1.4 then the feature is *eligible* for removal from the SDK when v1.8 is released. This SDK has no +is removed in v1.4 then the feature is _eligible_ for removal from the SDK when v1.8 is released. This SDK has no guarantee on implementing all features of any particular spec release, currently. This can mean that the SDK will call endpoints from before Matrix 1.1, for example. -Quickstart -========== +# Quickstart + +## In a browser -In a browser ------------- Download the browser version from https://github.com/matrix-org/matrix-js-sdk/releases/latest and add that as a -`` - - - - Sanity Testing (check the console) : This example is here to make sure that - the SDK works inside a browser. It simply does a GET /publicRooms on - matrix.org -
- You should see a message confirming that the SDK works below: -
-
- + + Test + + + + + + + Sanity Testing (check the console) : This example is here to make sure that the SDK works inside a browser. It + simply does a GET /publicRooms on matrix.org +
+ You should see a message confirming that the SDK works below: +
+
+ diff --git a/examples/crypto-browser/olm-device-export-import.html b/examples/crypto-browser/olm-device-export-import.html index 7f4b356b1f8..c6efc76e1c3 100644 --- a/examples/crypto-browser/olm-device-export-import.html +++ b/examples/crypto-browser/olm-device-export-import.html @@ -1,59 +1,60 @@ - - - - - Test Crypto in Browser - - - - -

Testing export/import of Olm devices in the browser

- - - - \ No newline at end of file + + + diff --git a/examples/crypto-browser/olm-device-export-import.js b/examples/crypto-browser/olm-device-export-import.js index 0529b73fd8c..ef5be8a65d4 100644 --- a/examples/crypto-browser/olm-device-export-import.js +++ b/examples/crypto-browser/olm-device-export-import.js @@ -1,34 +1,26 @@ if (!Olm) { - console.error( - "global.Olm does not seem to be present." - + " Did you forget to add olm in the lib/ directory?" - ); + console.error("global.Olm does not seem to be present." + " Did you forget to add olm in the lib/ directory?"); } -const BASE_URL = 'http://localhost:8008'; -const ROOM_CRYPTO_CONFIG = { algorithm: 'm.megolm.v1.aes-sha2' }; -const PASSWORD = 'password'; +const BASE_URL = "http://localhost:8008"; +const ROOM_CRYPTO_CONFIG = { algorithm: "m.megolm.v1.aes-sha2" }; +const PASSWORD = "password"; // useful to create new usernames -window.randomHex = () => Math.floor(Math.random() * (10**6)).toString(16); +window.randomHex = () => Math.floor(Math.random() * 10 ** 6).toString(16); window.newMatrixClient = async function (username) { const registrationClient = matrixcs.createClient(BASE_URL); - const userRegisterResult = await registrationClient.register( - username, - PASSWORD, - null, - { type: 'm.login.dummy' } - ); - + const userRegisterResult = await registrationClient.register(username, PASSWORD, null, { type: "m.login.dummy" }); + const matrixClient = matrixcs.createClient({ - baseUrl: BASE_URL, - userId: userRegisterResult.user_id, - accessToken: userRegisterResult.access_token, - deviceId: userRegisterResult.device_id, - sessionStore: new matrixcs.WebStorageSessionStore(window.localStorage), - cryptoStore: new matrixcs.MemoryCryptoStore(), + baseUrl: BASE_URL, + userId: userRegisterResult.user_id, + accessToken: userRegisterResult.access_token, + deviceId: userRegisterResult.device_id, + sessionStore: new matrixcs.WebStorageSessionStore(window.localStorage), + cryptoStore: new matrixcs.MemoryCryptoStore(), }); extendMatrixClient(matrixClient); @@ -36,15 +28,15 @@ window.newMatrixClient = async function (username) { await matrixClient.initCrypto(); await matrixClient.startClient(); return matrixClient; -} +}; window.importMatrixClient = async function (exportedDevice, accessToken) { const matrixClient = matrixcs.createClient({ - baseUrl: BASE_URL, - deviceToImport: exportedDevice, - accessToken, - sessionStore: new matrixcs.WebStorageSessionStore(window.localStorage), - cryptoStore: new matrixcs.MemoryCryptoStore(), + baseUrl: BASE_URL, + deviceToImport: exportedDevice, + accessToken, + sessionStore: new matrixcs.WebStorageSessionStore(window.localStorage), + cryptoStore: new matrixcs.MemoryCryptoStore(), }); extendMatrixClient(matrixClient); @@ -52,71 +44,62 @@ window.importMatrixClient = async function (exportedDevice, accessToken) { await matrixClient.initCrypto(); await matrixClient.startClient(); return matrixClient; -} +}; function extendMatrixClient(matrixClient) { // automatic join - matrixClient.on('RoomMember.membership', async (event, member) => { - if (member.membership === 'invite' && member.userId === matrixClient.getUserId()) { + matrixClient.on("RoomMember.membership", async (event, member) => { + if (member.membership === "invite" && member.userId === matrixClient.getUserId()) { await matrixClient.joinRoom(member.roomId); // setting up of room encryption seems to be triggered automatically // but if we don't wait for it the first messages we send are unencrypted - await matrixClient.setRoomEncryption(member.roomId, { algorithm: 'm.megolm.v1.aes-sha2' }) + await matrixClient.setRoomEncryption(member.roomId, { algorithm: "m.megolm.v1.aes-sha2" }); } }); - matrixClient.onDecryptedMessage = message => { - console.log('Got encrypted message: ', message); - } + matrixClient.onDecryptedMessage = (message) => { + console.log("Got encrypted message: ", message); + }; - matrixClient.on('Event.decrypted', (event) => { - if (event.getType() === 'm.room.message'){ + matrixClient.on("Event.decrypted", (event) => { + if (event.getType() === "m.room.message") { matrixClient.onDecryptedMessage(event.getContent().body); } else { - console.log('decrypted an event of type', event.getType()); + console.log("decrypted an event of type", event.getType()); console.log(event); } }); - - matrixClient.createEncryptedRoom = async function(usersToInvite) { - const { - room_id: roomId, - } = await this.createRoom({ - visibility: 'private', - invite: usersToInvite, + + matrixClient.createEncryptedRoom = async function (usersToInvite) { + const { room_id: roomId } = await this.createRoom({ + visibility: "private", + invite: usersToInvite, }); // matrixClient.setRoomEncryption() only updates local state // but does not send anything to the server // (see https://github.com/matrix-org/matrix-js-sdk/issues/905) // so we do it ourselves with 'sendStateEvent' - await this.sendStateEvent( - roomId, 'm.room.encryption', ROOM_CRYPTO_CONFIG, - ); - await this.setRoomEncryption( - roomId, ROOM_CRYPTO_CONFIG, - ); + await this.sendStateEvent(roomId, "m.room.encryption", ROOM_CRYPTO_CONFIG); + await this.setRoomEncryption(roomId, ROOM_CRYPTO_CONFIG); // Marking all devices as verified let room = this.getRoom(roomId); - let members = (await room.getEncryptionTargetMembers()).map(x => x["userId"]) + let members = (await room.getEncryptionTargetMembers()).map((x) => x["userId"]); let memberkeys = await this.downloadKeys(members); for (const userId in memberkeys) { - for (const deviceId in memberkeys[userId]) { - await this.setDeviceVerified(userId, deviceId); - } + for (const deviceId in memberkeys[userId]) { + await this.setDeviceVerified(userId, deviceId); + } } return roomId; - } - - matrixClient.sendTextMessage = async function(message, roomId) { - return matrixClient.sendMessage( - roomId, - { - body: message, - msgtype: 'm.text', - } - ) - } -} \ No newline at end of file + }; + + matrixClient.sendTextMessage = async function (message, roomId) { + return matrixClient.sendMessage(roomId, { + body: message, + msgtype: "m.text", + }); + }; +} diff --git a/examples/node/README.md b/examples/node/README.md index 0c96940dfd1..6947931e6e6 100644 --- a/examples/node/README.md +++ b/examples/node/README.md @@ -1,6 +1,5 @@ This is a functional terminal app which allows you to see the room list for a user, join rooms, send messages and view room membership lists. - To try it out, you will need to edit `app.js` to configure it for your `homeserver`, `access_token` and `user_id`. Then run: ``` @@ -24,7 +23,7 @@ Room list index commands: Room commands: '/exit' Return to the room list index. '/members' Show the room member list. - + $ /enter 2 [2015-06-12 15:14:54] Megan2 <<< herro diff --git a/examples/node/app.js b/examples/node/app.js index ae7eb707690..e381f6c51cb 100644 --- a/examples/node/app.js +++ b/examples/node/app.js @@ -5,7 +5,7 @@ var clc = require("cli-color"); var matrixClient = sdk.createClient({ baseUrl: "http://localhost:8008", accessToken: myAccessToken, - userId: myUserId + userId: myUserId, }); // Data structures @@ -14,15 +14,15 @@ var viewingRoom = null; var numMessagesToShow = 20; // Reading from stdin -var CLEAR_CONSOLE = '\x1B[2J'; +var CLEAR_CONSOLE = "\x1B[2J"; var readline = require("readline"); var rl = readline.createInterface({ input: process.stdin, output: process.stdout, - completer: completer + completer: completer, }); rl.setPrompt("$ "); -rl.on('line', function(line) { +rl.on("line", function (line) { if (line.trim().length === 0) { rl.prompt(); return; @@ -37,14 +37,11 @@ rl.on('line', function(line) { if (line === "/exit") { viewingRoom = null; printRoomList(); - } - else if (line === "/members") { + } else if (line === "/members") { printMemberList(viewingRoom); - } - else if (line === "/roominfo") { + } else if (line === "/roominfo") { printRoomInfo(viewingRoom); - } - else if (line === "/resend") { + } else if (line === "/resend") { // get the oldest not sent event. var notSentEvent; for (var i = 0; i < viewingRoom.timeline.length; i++) { @@ -54,76 +51,84 @@ rl.on('line', function(line) { } } if (notSentEvent) { - matrixClient.resendEvent(notSentEvent, viewingRoom).then(function() { - printMessages(); - rl.prompt(); - }, function(err) { - printMessages(); - print("/resend Error: %s", err); - rl.prompt(); - }); + matrixClient.resendEvent(notSentEvent, viewingRoom).then( + function () { + printMessages(); + rl.prompt(); + }, + function (err) { + printMessages(); + print("/resend Error: %s", err); + rl.prompt(); + }, + ); printMessages(); rl.prompt(); } - } - else if (line.indexOf("/more ") === 0) { + } else if (line.indexOf("/more ") === 0) { var amount = parseInt(line.split(" ")[1]) || 20; - matrixClient.scrollback(viewingRoom, amount).then(function(room) { - printMessages(); - rl.prompt(); - }, function(err) { - print("/more Error: %s", err); - }); - } - else if (line.indexOf("/invite ") === 0) { + matrixClient.scrollback(viewingRoom, amount).then( + function (room) { + printMessages(); + rl.prompt(); + }, + function (err) { + print("/more Error: %s", err); + }, + ); + } else if (line.indexOf("/invite ") === 0) { var userId = line.split(" ")[1].trim(); - matrixClient.invite(viewingRoom.roomId, userId).then(function() { - printMessages(); - rl.prompt(); - }, function(err) { - print("/invite Error: %s", err); - }); - } - else if (line.indexOf("/file ") === 0) { + matrixClient.invite(viewingRoom.roomId, userId).then( + function () { + printMessages(); + rl.prompt(); + }, + function (err) { + print("/invite Error: %s", err); + }, + ); + } else if (line.indexOf("/file ") === 0) { var filename = line.split(" ")[1].trim(); var stream = fs.createReadStream(filename); - matrixClient.uploadContent({ - stream: stream, - name: filename - }).then(function(url) { - var content = { - msgtype: "m.file", - body: filename, - url: JSON.parse(url).content_uri - }; - matrixClient.sendMessage(viewingRoom.roomId, content); - }); - } - else { - matrixClient.sendTextMessage(viewingRoom.roomId, line).finally(function() { + matrixClient + .uploadContent({ + stream: stream, + name: filename, + }) + .then(function (url) { + var content = { + msgtype: "m.file", + body: filename, + url: JSON.parse(url).content_uri, + }; + matrixClient.sendMessage(viewingRoom.roomId, content); + }); + } else { + matrixClient.sendTextMessage(viewingRoom.roomId, line).finally(function () { printMessages(); rl.prompt(); }); // print local echo immediately printMessages(); } - } - else { + } else { if (line.indexOf("/join ") === 0) { var roomIndex = line.split(" ")[1]; viewingRoom = roomList[roomIndex]; if (viewingRoom.getMember(myUserId).membership === "invite") { // join the room first - matrixClient.joinRoom(viewingRoom.roomId).then(function(room) { - setRoomList(); - viewingRoom = room; - printMessages(); - rl.prompt(); - }, function(err) { - print("/join Error: %s", err); - }); - } - else { + matrixClient.joinRoom(viewingRoom.roomId).then( + function (room) { + setRoomList(); + viewingRoom = room; + printMessages(); + rl.prompt(); + }, + function (err) { + print("/join Error: %s", err); + }, + ); + } else { printMessages(); } } @@ -133,18 +138,18 @@ rl.on('line', function(line) { // ==== END User input // show the room list after syncing. -matrixClient.on("sync", function(state, prevState, data) { +matrixClient.on("sync", function (state, prevState, data) { switch (state) { case "PREPARED": - setRoomList(); - printRoomList(); - printHelp(); - rl.prompt(); - break; - } + setRoomList(); + printRoomList(); + printHelp(); + rl.prompt(); + break; + } }); -matrixClient.on("Room", function() { +matrixClient.on("Room", function () { setRoomList(); if (!viewingRoom) { printRoomList(); @@ -153,7 +158,7 @@ matrixClient.on("Room", function() { }); // print incoming messages. -matrixClient.on("Room.timeline", function(event, room, toStartOfTimeline) { +matrixClient.on("Room.timeline", function (event, room, toStartOfTimeline) { if (toStartOfTimeline) { return; // don't print paginated results } @@ -165,20 +170,19 @@ matrixClient.on("Room.timeline", function(event, room, toStartOfTimeline) { function setRoomList() { roomList = matrixClient.getRooms(); - roomList.sort(function(a,b) { + roomList.sort(function (a, b) { // < 0 = a comes first (lower index) - we want high indexes = newer - var aMsg = a.timeline[a.timeline.length-1]; + var aMsg = a.timeline[a.timeline.length - 1]; if (!aMsg) { return -1; } - var bMsg = b.timeline[b.timeline.length-1]; + var bMsg = b.timeline[b.timeline.length - 1]; if (!bMsg) { return 1; } if (aMsg.getTs() > bMsg.getTs()) { return 1; - } - else if (aMsg.getTs() < bMsg.getTs()) { + } else if (aMsg.getTs() < bMsg.getTs()) { return -1; } return 0; @@ -189,16 +193,15 @@ function printRoomList() { print(CLEAR_CONSOLE); print("Room List:"); var fmts = { - "invite": clc.cyanBright, - "leave": clc.blackBright + invite: clc.cyanBright, + leave: clc.blackBright, }; for (var i = 0; i < roomList.length; i++) { - var msg = roomList[i].timeline[roomList[i].timeline.length-1]; + var msg = roomList[i].timeline[roomList[i].timeline.length - 1]; var dateStr = "---"; var fmt; if (msg) { - dateStr = new Date(msg.getTs()).toISOString().replace( - /T/, ' ').replace(/\..+/, ''); + dateStr = new Date(msg.getTs()).toISOString().replace(/T/, " ").replace(/\..+/, ""); } var myMembership = roomList[i].getMyMembership(); if (myMembership) { @@ -207,9 +210,10 @@ function printRoomList() { var roomName = fixWidth(roomList[i].name, 25); print( "[%s] %s (%s members) %s", - i, fmt ? fmt(roomName) : roomName, + i, + fmt ? fmt(roomName) : roomName, roomList[i].getJoinedMembers().length, - dateStr + dateStr, ); } } @@ -230,12 +234,12 @@ function printHelp() { } function completer(line) { - var completions = [ - "/help", "/join ", "/exit", "/members", "/more ", "/resend", "/invite" - ]; - var hits = completions.filter(function(c) { return c.indexOf(line) == 0 }); + var completions = ["/help", "/join ", "/exit", "/members", "/more ", "/resend", "/invite"]; + var hits = completions.filter(function (c) { + return c.indexOf(line) == 0; + }); // show all completions if none found - return [hits.length ? hits : completions, line] + return [hits.length ? hits : completions, line]; } function printMessages() { @@ -252,14 +256,14 @@ function printMessages() { function printMemberList(room) { var fmts = { - "join": clc.green, - "ban": clc.red, - "invite": clc.blue, - "leave": clc.blackBright + join: clc.green, + ban: clc.red, + invite: clc.blue, + leave: clc.blackBright, }; var members = room.currentState.getMembers(); // sorted based on name. - members.sort(function(a, b) { + members.sort(function (a, b) { if (a.name > b.name) { return -1; } @@ -268,21 +272,24 @@ function printMemberList(room) { } return 0; }); - print("Membership list for room \"%s\"", room.name); + print('Membership list for room "%s"', room.name); print(new Array(room.name.length + 28).join("-")); - room.currentState.getMembers().forEach(function(member) { + room.currentState.getMembers().forEach(function (member) { if (!member.membership) { return; } - var fmt = fmts[member.membership] || function(a){return a;}; - var membershipWithPadding = ( - member.membership + new Array(10 - member.membership.length).join(" ") - ); + var fmt = + fmts[member.membership] || + function (a) { + return a; + }; + var membershipWithPadding = member.membership + new Array(10 - member.membership.length).join(" "); print( - "%s"+fmt(" :: ")+"%s"+fmt(" (")+"%s"+fmt(")"), - membershipWithPadding, member.name, - (member.userId === myUserId ? "Me" : member.userId), - fmt + "%s" + fmt(" :: ") + "%s" + fmt(" (") + "%s" + fmt(")"), + membershipWithPadding, + member.name, + member.userId === myUserId ? "Me" : member.userId, + fmt, ); }); } @@ -292,38 +299,31 @@ function printRoomInfo(room) { var eTypeHeader = " Event Type(state_key) "; var sendHeader = " Sender "; // pad content to 100 - var restCount = ( - 100 - "Content".length - " | ".length - " | ".length - - eTypeHeader.length - sendHeader.length - ); - var padSide = new Array(Math.floor(restCount/2)).join(" "); + var restCount = 100 - "Content".length - " | ".length - " | ".length - eTypeHeader.length - sendHeader.length; + var padSide = new Array(Math.floor(restCount / 2)).join(" "); var contentHeader = padSide + "Content" + padSide; - print(eTypeHeader+sendHeader+contentHeader); + print(eTypeHeader + sendHeader + contentHeader); print(new Array(100).join("-")); - eventMap.keys().forEach(function(eventType) { - if (eventType === "m.room.member") { return; } // use /members instead. + eventMap.keys().forEach(function (eventType) { + if (eventType === "m.room.member") { + return; + } // use /members instead. var eventEventMap = eventMap.get(eventType); - eventEventMap.keys().forEach(function(stateKey) { - var typeAndKey = eventType + ( - stateKey.length > 0 ? "("+stateKey+")" : "" - ); + eventEventMap.keys().forEach(function (stateKey) { + var typeAndKey = eventType + (stateKey.length > 0 ? "(" + stateKey + ")" : ""); var typeStr = fixWidth(typeAndKey, eTypeHeader.length); var event = eventEventMap.get(stateKey); var sendStr = fixWidth(event.getSender(), sendHeader.length); - var contentStr = fixWidth( - JSON.stringify(event.getContent()), contentHeader.length - ); - print(typeStr+" | "+sendStr+" | "+contentStr); + var contentStr = fixWidth(JSON.stringify(event.getContent()), contentHeader.length); + print(typeStr + " | " + sendStr + " | " + contentStr); }); - }) + }); } function printLine(event) { var fmt; var name = event.sender ? event.sender.name : event.getSender(); - var time = new Date( - event.getTs() - ).toISOString().replace(/T/, ' ').replace(/\..+/, ''); + var time = new Date(event.getTs()).toISOString().replace(/T/, " ").replace(/\..+/, ""); var separator = "<<<"; if (event.getSender() === myUserId) { name = "Me"; @@ -331,8 +331,7 @@ function printLine(event) { if (event.status === sdk.EventStatus.SENDING) { separator = "..."; fmt = clc.xterm(8); - } - else if (event.status === sdk.EventStatus.NOT_SENT) { + } else if (event.status === sdk.EventStatus.NOT_SENT) { separator = " x "; fmt = clc.redBright; } @@ -341,69 +340,58 @@ function printLine(event) { var maxNameWidth = 15; if (name.length > maxNameWidth) { - name = name.slice(0, maxNameWidth-1) + "\u2026"; + name = name.slice(0, maxNameWidth - 1) + "\u2026"; } if (event.getType() === "m.room.message") { body = event.getContent().body; - } - else if (event.isState()) { + } else if (event.isState()) { var stateName = event.getType(); if (event.getStateKey().length > 0) { - stateName += " ("+event.getStateKey()+")"; + stateName += " (" + event.getStateKey() + ")"; } - body = ( - "[State: "+stateName+" updated to: "+JSON.stringify(event.getContent())+"]" - ); + body = "[State: " + stateName + " updated to: " + JSON.stringify(event.getContent()) + "]"; separator = "---"; fmt = clc.xterm(249).italic; - } - else { + } else { // random message event - body = ( - "[Message: "+event.getType()+" Content: "+JSON.stringify(event.getContent())+"]" - ); + body = "[Message: " + event.getType() + " Content: " + JSON.stringify(event.getContent()) + "]"; separator = "---"; fmt = clc.xterm(249).italic; } if (fmt) { - print( - "[%s] %s %s %s", time, name, separator, body, fmt - ); - } - else { + print("[%s] %s %s %s", time, name, separator, body, fmt); + } else { print("[%s] %s %s %s", time, name, separator, body); } } function print(str, formatter) { - if (typeof arguments[arguments.length-1] === "function") { + if (typeof arguments[arguments.length - 1] === "function") { // last arg is the formatter so get rid of it and use it on each // param passed in but not the template string. var newArgs = []; var i = 0; - for (i=0; i len) { - return str.substring(0, len-2) + "\u2026"; - } - else if (str.length < len) { + return str.substring(0, len - 2) + "\u2026"; + } else if (str.length < len) { return str + new Array(len - str.length).join(" "); } return str; } -matrixClient.startClient(numMessagesToShow); // messages for each room. +matrixClient.startClient(numMessagesToShow); // messages for each room. diff --git a/examples/node/package.json b/examples/node/package.json index 6319e367db4..dc81b0e0071 100644 --- a/examples/node/package.json +++ b/examples/node/package.json @@ -1,14 +1,14 @@ { - "name": "example-app", - "version": "0.0.0", - "description": "", - "main": "app.js", - "scripts": { - "preinstall": "npm install ../.." - }, - "author": "", - "license": "Apache 2.0", - "dependencies": { - "cli-color": "^1.0.0" - } + "name": "example-app", + "version": "0.0.0", + "description": "", + "main": "app.js", + "scripts": { + "preinstall": "npm install ../.." + }, + "author": "", + "license": "Apache 2.0", + "dependencies": { + "cli-color": "^1.0.0" + } } diff --git a/examples/voip/README.md b/examples/voip/README.md index c3f3d67ddaf..f4a9d6cc1c7 100644 --- a/examples/voip/README.md +++ b/examples/voip/README.md @@ -6,4 +6,4 @@ To try it out, **you must build the SDK first** and then host this folder: $ python -m SimpleHTTPServer 8003 ``` -Then visit ``http://localhost:8003``. +Then visit `http://localhost:8003`. diff --git a/examples/voip/browserTest.js b/examples/voip/browserTest.js index 4a78db3ef2b..397cb67dba0 100644 --- a/examples/voip/browserTest.js +++ b/examples/voip/browserTest.js @@ -9,7 +9,7 @@ const client = matrixcs.createClient({ baseUrl: BASE_URL, accessToken: TOKEN, userId: USER_ID, - deviceId: DEVICE_ID + deviceId: DEVICE_ID, }); let call; @@ -21,18 +21,16 @@ function disableButtons(place, answer, hangup) { function addListeners(call) { let lastError = ""; - call.on("hangup", function() { + call.on("hangup", function () { disableButtons(false, true, true); - document.getElementById("result").innerHTML = ( - "

Call ended. Last error: "+lastError+"

" - ); + document.getElementById("result").innerHTML = "

Call ended. Last error: " + lastError + "

"; }); - call.on("error", function(err) { + call.on("error", function (err) { lastError = err.message; call.hangup(); disableButtons(false, true, true); }); - call.on("feeds_changed", function(feeds) { + call.on("feeds_changed", function (feeds) { const localFeed = feeds.find((feed) => feed.isLocal()); const remoteFeed = feeds.find((feed) => !feed.isLocal()); @@ -51,33 +49,38 @@ function addListeners(call) { }); } -window.onload = function() { +window.onload = function () { document.getElementById("result").innerHTML = "

Please wait. Syncing...

"; - document.getElementById("config").innerHTML = "

" + - "Homeserver: "+BASE_URL+"
"+ - "Room: "+ROOM_ID+"
"+ - "User: "+USER_ID+"
"+ + document.getElementById("config").innerHTML = + "

" + + "Homeserver: " + + BASE_URL + + "
" + + "Room: " + + ROOM_ID + + "
" + + "User: " + + USER_ID + + "
" + "

"; disableButtons(true, true, true); }; -client.on("sync", function(state, prevState, data) { +client.on("sync", function (state, prevState, data) { switch (state) { case "PREPARED": - syncComplete(); - break; - } + syncComplete(); + break; + } }); function syncComplete() { document.getElementById("result").innerHTML = "

Ready for calls.

"; disableButtons(false, true, true); - document.getElementById("call").onclick = function() { + document.getElementById("call").onclick = function () { console.log("Placing call..."); - call = matrixcs.createNewMatrixCall( - client, ROOM_ID - ); + call = matrixcs.createNewMatrixCall(client, ROOM_ID); console.log("Call => %s", call); addListeners(call); call.placeVideoCall(); @@ -85,14 +88,14 @@ function syncComplete() { disableButtons(true, true, false); }; - document.getElementById("hangup").onclick = function() { + document.getElementById("hangup").onclick = function () { console.log("Hanging up call..."); console.log("Call => %s", call); call.hangup(); document.getElementById("result").innerHTML = "

Hungup call.

"; }; - document.getElementById("answer").onclick = function() { + document.getElementById("answer").onclick = function () { console.log("Answering call..."); console.log("Call => %s", call); call.answer(); @@ -100,7 +103,7 @@ function syncComplete() { document.getElementById("result").innerHTML = "

Answered call.

"; }; - client.on("Call.incoming", function(c) { + client.on("Call.incoming", function (c) { console.log("Call ringing"); disableButtons(true, false, false); document.getElementById("result").innerHTML = "

Incoming call...

"; diff --git a/examples/voip/index.html b/examples/voip/index.html index ab96790a60d..5d33bd43767 100644 --- a/examples/voip/index.html +++ b/examples/voip/index.html @@ -1,25 +1,23 @@ + + VoIP Test + + + - - VoIP Test - - - - - - You can place and receive calls with this example. Make sure to edit the - constants in browserTest.js first. -
-
- - - -
- - -
- - + + You can place and receive calls with this example. Make sure to edit the constants in + browserTest.js first. +
+
+ + + +
+ + +
+ \ No newline at end of file + diff --git a/package.json b/package.json index 850e37561da..68c7102840b 100644 --- a/package.json +++ b/package.json @@ -1,141 +1,147 @@ { - "name": "matrix-js-sdk", - "version": "22.0.0", - "description": "Matrix Client-Server SDK for Javascript", - "engines": { - "node": ">=16.0.0" - }, - "scripts": { - "prepublishOnly": "yarn build", - "start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"", - "dist": "echo 'This is for the release script so it can make assets (browser bundle).' && yarn build", - "clean": "rimraf lib dist", - "build": "yarn build:dev && yarn build:compile-browser && yarn build:minify-browser", - "build:dev": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types", - "build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly", - "build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src", - "build:compile-browser": "mkdirp dist && browserify -d src/browser-index.js -p [ tsify -p ./tsconfig-build.json ] -t [ babelify --sourceMaps=inline --presets [ @babel/preset-env @babel/preset-typescript ] ] | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js", - "build:minify-browser": "terser dist/browser-matrix.js --compress --mangle --source-map --output dist/browser-matrix.min.js", - "gendoc": "typedoc", - "lint": "yarn lint:types && yarn lint:js", - "lint:js": "eslint --max-warnings 0 src spec", - "lint:js-fix": "eslint --fix src spec", - "lint:types": "tsc --noEmit", - "test": "jest", - "test:watch": "jest --watch", - "coverage": "yarn test --coverage" - }, - "repository": { - "type": "git", - "url": "https://github.com/matrix-org/matrix-js-sdk" - }, - "keywords": [ - "matrix-org" - ], - "main": "./lib/index.js", - "browser": "./lib/browser-index.js", - "matrix_src_main": "./src/index.ts", - "matrix_src_browser": "./src/browser-index.js", - "matrix_lib_main": "./lib/index.js", - "matrix_lib_typings": "./lib/index.d.ts", - "author": "matrix.org", - "license": "Apache-2.0", - "files": [ - "dist", - "lib", - "src", - "git-revision.txt", - "CHANGELOG.md", - "CONTRIBUTING.rst", - "LICENSE", - "README.md", - "package.json", - "release.sh" - ], - "dependencies": { - "@babel/runtime": "^7.12.5", - "@types/sdp-transform": "^2.4.5", - "another-json": "^0.2.0", - "bs58": "^5.0.0", - "content-type": "^1.0.4", - "loglevel": "^1.7.1", - "matrix-events-sdk": "0.0.1", - "matrix-widget-api": "^1.0.0", - "p-retry": "4", - "qs": "^6.9.6", - "sdp-transform": "^2.14.1", - "unhomoglyph": "^1.0.6" - }, - "devDependencies": { - "@babel/cli": "^7.12.10", - "@babel/core": "^7.12.10", - "@babel/eslint-parser": "^7.12.10", - "@babel/eslint-plugin": "^7.12.10", - "@babel/plugin-proposal-class-properties": "^7.12.1", - "@babel/plugin-proposal-numeric-separator": "^7.12.7", - "@babel/plugin-proposal-object-rest-spread": "^7.12.1", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-transform-runtime": "^7.12.10", - "@babel/preset-env": "^7.12.11", - "@babel/preset-typescript": "^7.12.7", - "@babel/register": "^7.12.10", - "@casualbot/jest-sonar-reporter": "^2.2.5", - "@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/domexception": "^4.0.0", - "@types/jest": "^29.0.0", - "@types/node": "18", - "@typescript-eslint/eslint-plugin": "^5.6.0", - "@typescript-eslint/parser": "^5.6.0", - "allchange": "^1.0.6", - "babel-jest": "^29.0.0", - "babelify": "^10.0.0", - "better-docs": "^2.4.0-beta.9", - "browserify": "^17.0.0", - "docdash": "^2.0.0", - "domexception": "^4.0.0", - "eslint": "8.28.0", - "eslint-config-google": "^0.14.0", - "eslint-import-resolver-typescript": "^3.5.1", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-matrix-org": "^0.8.0", - "eslint-plugin-unicorn": "^45.0.0", - "exorcist": "^2.0.0", - "fake-indexeddb": "^4.0.0", - "jest": "^29.0.0", - "jest-environment-jsdom": "^29.0.0", - "jest-localstorage-mock": "^2.4.6", - "jest-mock": "^29.0.0", - "matrix-mock-request": "^2.5.0", - "rimraf": "^3.0.2", - "terser": "^5.5.1", - "tsify": "^5.0.2", - "typedoc": "^0.23.20", - "typedoc-plugin-missing-exports": "^1.0.0", - "typescript": "^4.5.3" - }, - "jest": { - "testEnvironment": "node", - "testMatch": [ - "/spec/**/*.spec.{js,ts}" + "name": "matrix-js-sdk", + "version": "23.0.0", + "description": "Matrix Client-Server SDK for Javascript", + "engines": { + "node": ">=16.0.0" + }, + "scripts": { + "prepublishOnly": "yarn build", + "start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"", + "dist": "echo 'This is for the release script so it can make assets (browser bundle).' && yarn build", + "clean": "rimraf lib dist", + "build": "yarn build:dev && yarn build:compile-browser && yarn build:minify-browser", + "build:dev": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types", + "build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly", + "build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src", + "build:compile-browser": "mkdirp dist && browserify -d src/browser-index.ts -p [ tsify -p ./tsconfig-build.json ] -t [ babelify --sourceMaps=inline --presets [ @babel/preset-env @babel/preset-typescript ] ] | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js", + "build:minify-browser": "terser dist/browser-matrix.js --compress --mangle --source-map --output dist/browser-matrix.min.js", + "gendoc": "typedoc", + "lint": "yarn lint:types && yarn lint:js", + "lint:js": "eslint --max-warnings 0 src spec && prettier --check .", + "lint:js-fix": "prettier --loglevel=warn --write . && eslint --fix src spec", + "lint:types": "tsc --noEmit", + "test": "jest", + "test:watch": "jest --watch", + "coverage": "yarn test --coverage" + }, + "repository": { + "type": "git", + "url": "https://github.com/matrix-org/matrix-js-sdk" + }, + "keywords": [ + "matrix-org" ], - "setupFilesAfterEnv": [ - "/spec/setupTests.ts" + "main": "./lib/index.js", + "browser": "./lib/browser-index.ts", + "matrix_src_main": "./src/index.ts", + "matrix_src_browser": "./src/browser-index.ts", + "matrix_lib_main": "./lib/index.js", + "matrix_lib_typings": "./lib/index.d.ts", + "author": "matrix.org", + "license": "Apache-2.0", + "files": [ + "dist", + "lib", + "src", + "git-revision.txt", + "CHANGELOG.md", + "CONTRIBUTING.rst", + "LICENSE", + "README.md", + "package.json", + "release.sh" ], - "collectCoverageFrom": [ - "/src/**/*.{js,ts}" - ], - "coverageReporters": [ - "text-summary", - "lcov" - ], - "testResultsProcessor": "@casualbot/jest-sonar-reporter" - }, - "@casualbot/jest-sonar-reporter": { - "outputDirectory": "coverage", - "outputName": "jest-sonar-report.xml", - "relativePaths": true - }, - "typings": "./lib/index.d.ts" + "dependencies": { + "@babel/runtime": "^7.12.5", + "another-json": "^0.2.0", + "bs58": "^5.0.0", + "content-type": "^1.0.4", + "loglevel": "^1.7.1", + "matrix-events-sdk": "0.0.1", + "matrix-widget-api": "^1.0.0", + "p-retry": "4", + "qs": "^6.9.6", + "sdp-transform": "^2.14.1", + "unhomoglyph": "^1.0.6", + "uuid": "7" + }, + "devDependencies": { + "@babel/cli": "^7.12.10", + "@babel/core": "^7.12.10", + "@babel/eslint-parser": "^7.12.10", + "@babel/eslint-plugin": "^7.12.10", + "@babel/plugin-proposal-class-properties": "^7.12.1", + "@babel/plugin-proposal-numeric-separator": "^7.12.7", + "@babel/plugin-proposal-object-rest-spread": "^7.12.1", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-runtime": "^7.12.10", + "@babel/preset-env": "^7.12.11", + "@babel/preset-typescript": "^7.12.7", + "@babel/register": "^7.12.10", + "@casualbot/jest-sonar-reporter": "^2.2.5", + "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz", + "@types/bs58": "^4.0.1", + "@types/content-type": "^1.1.5", + "@types/domexception": "^4.0.0", + "@types/jest": "^29.0.0", + "@types/node": "18", + "@types/sdp-transform": "^2.4.5", + "@types/uuid": "7", + "@typescript-eslint/eslint-plugin": "^5.45.0", + "@typescript-eslint/parser": "^5.45.0", + "allchange": "^1.0.6", + "babel-jest": "^29.0.0", + "babelify": "^10.0.0", + "better-docs": "^2.4.0-beta.9", + "browserify": "^17.0.0", + "docdash": "^2.0.0", + "domexception": "^4.0.0", + "eslint": "8.28.0", + "eslint-config-google": "^0.14.0", + "eslint-config-prettier": "^8.5.0", + "eslint-import-resolver-typescript": "^3.5.1", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-jsdoc": "^39.6.4", + "eslint-plugin-matrix-org": "^0.9.0", + "eslint-plugin-tsdoc": "^0.2.17", + "eslint-plugin-unicorn": "^45.0.0", + "exorcist": "^2.0.0", + "fake-indexeddb": "^4.0.0", + "jest": "^29.0.0", + "jest-environment-jsdom": "^29.0.0", + "jest-localstorage-mock": "^2.4.6", + "jest-mock": "^29.0.0", + "matrix-mock-request": "^2.5.0", + "prettier": "2.8.0", + "rimraf": "^3.0.2", + "terser": "^5.5.1", + "tsify": "^5.0.2", + "typedoc": "^0.23.20", + "typedoc-plugin-missing-exports": "^1.0.0", + "typescript": "^4.5.3" + }, + "jest": { + "testEnvironment": "node", + "testMatch": [ + "/spec/**/*.spec.{js,ts}" + ], + "setupFilesAfterEnv": [ + "/spec/setupTests.ts" + ], + "collectCoverageFrom": [ + "/src/**/*.{js,ts}" + ], + "coverageReporters": [ + "text-summary", + "lcov" + ], + "testResultsProcessor": "@casualbot/jest-sonar-reporter" + }, + "@casualbot/jest-sonar-reporter": { + "outputDirectory": "coverage", + "outputName": "jest-sonar-report.xml", + "relativePaths": true + }, + "typings": "./lib/index.d.ts" } diff --git a/release.sh b/release.sh index 92a593e180b..c5f798f400b 100755 --- a/release.sh +++ b/release.sh @@ -184,7 +184,7 @@ for i in main typings do lib_value=$(jq -r ".matrix_lib_$i" package.json) if [ "$lib_value" != "null" ]; then - jq ".$i = .matrix_lib_$i" package.json > package.json.new && mv package.json.new package.json + jq ".$i = .matrix_lib_$i" package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json fi done diff --git a/scripts/switch_package_to_release.js b/scripts/switch_package_to_release.js index 830c92dc46e..2ef9bb09cb2 100755 --- a/scripts/switch_package_to_release.js +++ b/scripts/switch_package_to_release.js @@ -1,14 +1,14 @@ #!/usr/bin/env node -const fsProm = require('fs/promises'); +const fsProm = require("fs/promises"); -const PKGJSON = 'package.json'; +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]; + 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)); diff --git a/spec/TestClient.ts b/spec/TestClient.ts index 249f5b39e16..19a8d42c5e5 100644 --- a/spec/TestClient.ts +++ b/spec/TestClient.ts @@ -17,12 +17,12 @@ limitations under the License. */ // load olm before the sdk if possible -import './olm-loader'; +import "./olm-loader"; -import MockHttpBackend from 'matrix-mock-request'; +import MockHttpBackend from "matrix-mock-request"; -import { LocalStorageCryptoStore } from '../src/crypto/store/localStorage-crypto-store'; -import { logger } from '../src/logger'; +import { LocalStorageCryptoStore } from "../src/crypto/store/localStorage-crypto-store"; +import { logger } from "../src/logger"; import { syncPromise } from "./test-utils/test-utils"; import { createClient } from "../src/matrix"; import { ICreateClientOpts, IDownloadKeyResult, MatrixClient, PendingEventOrdering } from "../src/client"; @@ -30,7 +30,7 @@ import { MockStorageApi } from "./MockStorageApi"; import { encodeUri } from "../src/utils"; import { IDeviceKeys, IOneTimeKey } from "../src/crypto/dehydration"; import { IKeyBackupSession } from "../src/crypto/keybackup"; -import { IKeysUploadResponse, IUploadKeysRequest } from '../src/client'; +import { IKeysUploadResponse, IUploadKeysRequest } from "../src/client"; /** * Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient @@ -73,14 +73,14 @@ export class TestClient { } public toString(): string { - return 'TestClient[' + this.userId + ']'; + return "TestClient[" + this.userId + "]"; } /** * start the client, and wait for it to initialise. */ public start(): Promise { - logger.log(this + ': starting'); + logger.log(this + ": starting"); this.httpBackend.when("GET", "/versions").respond(200, {}); this.httpBackend.when("GET", "/pushrules").respond(200, {}); this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); @@ -95,17 +95,14 @@ export class TestClient { pendingEventOrdering: PendingEventOrdering.Detached, }); - return Promise.all([ - this.httpBackend.flushAllExpected(), - syncPromise(this.client), - ]).then(() => { - logger.log(this + ': started'); + return Promise.all([this.httpBackend.flushAllExpected(), syncPromise(this.client)]).then(() => { + logger.log(this + ": started"); }); } /** * stop the client - * @return {Promise} Resolves once the mock http backend has finished all pending flushes + * @returns Promise which resolves once the mock http backend has finished all pending flushes */ public async stop(): Promise { this.client.stopClient(); @@ -116,12 +113,13 @@ export class TestClient { * Set up expectations that the client will upload device keys. */ public expectDeviceKeyUpload() { - this.httpBackend.when("POST", "/keys/upload") + this.httpBackend + .when("POST", "/keys/upload") .respond(200, (_path, content) => { expect(content.one_time_keys).toBe(undefined); expect(content.device_keys).toBeTruthy(); - logger.log(this + ': received device keys'); + logger.log(this + ": received device keys"); // we expect this to happen before any one-time keys are uploaded. expect(Object.keys(this.oneTimeKeys!).length).toEqual(0); @@ -135,7 +133,7 @@ export class TestClient { * set up an expectation that the keys will be uploaded, and wait for * that to happen. * - * @returns {Promise} for the one-time keys + * @returns Promise for the one-time keys */ public awaitOneTimeKeyUpload(): Promise> { if (Object.keys(this.oneTimeKeys!).length != 0) { @@ -143,30 +141,35 @@ export class TestClient { return Promise.resolve(this.oneTimeKeys!); } - this.httpBackend.when("POST", "/keys/upload") + this.httpBackend + .when("POST", "/keys/upload") .respond(200, (_path, content: IUploadKeysRequest) => { expect(content.device_keys).toBe(undefined); expect(content.one_time_keys).toBe(undefined); - return { one_time_key_counts: { - signed_curve25519: Object.keys(this.oneTimeKeys!).length, - } }; + return { + one_time_key_counts: { + signed_curve25519: Object.keys(this.oneTimeKeys!).length, + }, + }; }); - this.httpBackend.when("POST", "/keys/upload") + this.httpBackend + .when("POST", "/keys/upload") .respond(200, (_path, content: IUploadKeysRequest) => { expect(content.device_keys).toBe(undefined); expect(content.one_time_keys).toBeTruthy(); expect(content.one_time_keys).not.toEqual({}); - logger.log('%s: received %i one-time keys', this, - Object.keys(content.one_time_keys!).length); + logger.log("%s: received %i one-time keys", this, Object.keys(content.one_time_keys!).length); this.oneTimeKeys = content.one_time_keys; - return { one_time_key_counts: { - signed_curve25519: Object.keys(this.oneTimeKeys!).length, - } }; + return { + one_time_key_counts: { + signed_curve25519: Object.keys(this.oneTimeKeys!).length, + }, + }; }); // this can take ages - return this.httpBackend.flush('/keys/upload', 2, 1000).then((flushed) => { + return this.httpBackend.flush("/keys/upload", 2, 1000).then((flushed) => { expect(flushed).toEqual(2); return this.oneTimeKeys!; }); @@ -177,45 +180,49 @@ export class TestClient { * * We check that the query contains each of the users in `response`. * - * @param {Object} response response to the query. + * @param response - response to the query. */ public expectKeyQuery(response: IDownloadKeyResult) { - this.httpBackend.when('POST', '/keys/query').respond( - 200, (_path, content) => { - Object.keys(response.device_keys).forEach((userId) => { - expect(content.device_keys![userId]).toEqual([]); - }); - return response; + this.httpBackend.when("POST", "/keys/query").respond(200, (_path, content) => { + Object.keys(response.device_keys).forEach((userId) => { + expect((content.device_keys! as Record)[userId]).toEqual([]); }); + return response; + }); } /** * Set up expectations that the client will query key backups for a particular session */ public expectKeyBackupQuery(roomId: string, sessionId: string, status: number, response: IKeyBackupSession) { - this.httpBackend.when('GET', encodeUri("/room_keys/keys/$roomId/$sessionId", { - $roomId: roomId, - $sessionId: sessionId, - })).respond(status, response); + this.httpBackend + .when( + "GET", + encodeUri("/room_keys/keys/$roomId/$sessionId", { + $roomId: roomId, + $sessionId: sessionId, + }), + ) + .respond(status, response); } /** * get the uploaded curve25519 device key * - * @return {string} base64 device key + * @returns base64 device key */ public getDeviceKey(): string { - const keyId = 'curve25519:' + this.deviceId; + const keyId = "curve25519:" + this.deviceId; return this.deviceKeys!.keys[keyId]; } /** * get the uploaded ed25519 device key * - * @return {string} base64 device key + * @returns base64 device key */ public getSigningKey(): string { - const keyId = 'ed25519:' + this.deviceId; + const keyId = "ed25519:" + this.deviceId; return this.deviceKeys!.keys[keyId]; } @@ -224,10 +231,7 @@ export class TestClient { */ public flushSync(): Promise { logger.log(`${this}: flushSync`); - return Promise.all([ - this.httpBackend.flush('/sync', 1), - syncPromise(this.client), - ]).then(() => { + return Promise.all([this.httpBackend.flush("/sync", 1), syncPromise(this.client)]).then(() => { logger.log(`${this}: flushSync completed`); }); } diff --git a/spec/browserify/setupTests.ts b/spec/browserify/setupTests.ts index a92a70e23b3..789c0218db3 100644 --- a/spec/browserify/setupTests.ts +++ b/spec/browserify/setupTests.ts @@ -15,19 +15,7 @@ limitations under the License. */ import "../../dist/browser-matrix"; // uses browser-matrix instead of the src -import type { MatrixClient, ClientEvent } from "../../src"; - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace NodeJS { - interface Global { - matrixcs: { - MatrixClient: typeof MatrixClient; - ClientEvent: typeof ClientEvent; - }; - } - } -} +import type { default as BrowserMatrix } from "../../src/browser-index"; // stub for browser-matrix browserify tests // @ts-ignore @@ -43,4 +31,4 @@ afterAll(() => { global.matrixcs = { ...global.matrixcs, timeoutSignal: () => new AbortController().signal, -}; +} as typeof BrowserMatrix; diff --git a/spec/browserify/sync-browserify.spec.ts b/spec/browserify/sync-browserify.spec.ts index 172cb0c475d..82cade65df0 100644 --- a/spec/browserify/sync-browserify.spec.ts +++ b/spec/browserify/sync-browserify.spec.ts @@ -16,7 +16,7 @@ limitations under the License. import HttpBackend from "matrix-mock-request"; -import "./setupTests";// uses browser-matrix instead of the src +import "./setupTests"; // uses browser-matrix instead of the src import type { MatrixClient } from "../../src"; const USER_ID = "@user:test.server"; @@ -24,7 +24,7 @@ const DEVICE_ID = "device_id"; const ACCESS_TOKEN = "access_token"; const ROOM_ID = "!room_id:server.test"; -describe("Browserify Test", function() { +describe("Browserify Test", function () { let client: MatrixClient; let httpBackend: HttpBackend; @@ -65,22 +65,21 @@ describe("Browserify Test", function() { const syncData = { next_batch: "batch1", rooms: { - join: {}, - }, - }; - syncData.rooms.join[ROOM_ID] = { - timeline: { - events: [ - event, - ], - limited: false, + join: { + [ROOM_ID]: { + timeline: { + events: [event], + limited: false, + }, + }, + }, }, }; httpBackend.when("GET", "/sync").respond(200, syncData); httpBackend.when("GET", "/sync").respond(200, syncData); - const syncPromise = new Promise(r => client.once(global.matrixcs.ClientEvent.Sync, r)); + const syncPromise = new Promise((r) => client.once(global.matrixcs.ClientEvent.Sync, r)); const unexpectedErrorFn = jest.fn(); client.once(global.matrixcs.ClientEvent.SyncUnexpectedError, unexpectedErrorFn); diff --git a/spec/integ/devicelist-integ.spec.ts b/spec/integ/devicelist-integ.spec.ts index acd8f9c8081..e9dac66efe6 100644 --- a/spec/integ/devicelist-integ.spec.ts +++ b/spec/integ/devicelist-integ.spec.ts @@ -16,9 +16,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { TestClient } from '../TestClient'; -import * as testUtils from '../test-utils/test-utils'; -import { logger } from '../../src/logger'; +import { TestClient } from "../TestClient"; +import * as testUtils from "../test-utils/test-utils"; +import { logger } from "../../src/logger"; const ROOM_ID = "!room:id"; @@ -26,26 +26,24 @@ const ROOM_ID = "!room:id"; * get a /sync response which contains a single e2e room (ROOM_ID), with the * members given * - * @param {string[]} roomMembers - * - * @return {object} sync response + * @returns sync response */ -function getSyncResponse(roomMembers) { +function getSyncResponse(roomMembers: string[]) { const stateEvents = [ testUtils.mkEvent({ - type: 'm.room.encryption', - skey: '', + type: "m.room.encryption", + skey: "", content: { - algorithm: 'm.megolm.v1.aes-sha2', + algorithm: "m.megolm.v1.aes-sha2", }, }), ]; Array.prototype.push.apply( stateEvents, - roomMembers.map( - (m) => testUtils.mkMembership({ - mship: 'join', + roomMembers.map((m) => + testUtils.mkMembership({ + mship: "join", sender: m, }), ), @@ -67,24 +65,22 @@ function getSyncResponse(roomMembers) { return syncResponse; } -describe("DeviceList management:", function() { +describe("DeviceList management:", function () { if (!global.Olm) { - logger.warn('not running deviceList tests: Olm not present'); + logger.warn("not running deviceList tests: Olm not present"); return; } - let sessionStoreBackend; - let aliceTestClient; + let aliceTestClient: TestClient; + let sessionStoreBackend: Storage; async function createTestClient() { - const testClient = new TestClient( - "@alice:localhost", "xzcvb", "akjgkrgjs", sessionStoreBackend, - ); + const testClient = new TestClient("@alice:localhost", "xzcvb", "akjgkrgjs", sessionStoreBackend); await testClient.client.initCrypto(); return testClient; } - beforeEach(async function() { + beforeEach(async function () { // we create our own sessionStoreBackend so that we can use it for // another TestClient. sessionStoreBackend = new testUtils.MockStorageApi(); @@ -92,305 +88,314 @@ describe("DeviceList management:", function() { aliceTestClient = await createTestClient(); }); - afterEach(function() { + afterEach(function () { return aliceTestClient.stop(); }); - it("Alice shouldn't do a second /query for non-e2e-capable devices", function() { - aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} } }); - return aliceTestClient.start().then(function() { - const syncResponse = getSyncResponse(['@bob:xyz']); - aliceTestClient.httpBackend.when('GET', '/sync').respond(200, syncResponse); + it("Alice shouldn't do a second /query for non-e2e-capable devices", function () { + aliceTestClient.expectKeyQuery({ + device_keys: { "@alice:localhost": {} }, + failures: {}, + }); + return aliceTestClient + .start() + .then(function () { + const syncResponse = getSyncResponse(["@bob:xyz"]); + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); - return aliceTestClient.flushSync(); - }).then(function() { - logger.log("Forcing alice to download our device keys"); + return aliceTestClient.flushSync(); + }) + .then(function () { + logger.log("Forcing alice to download our device keys"); - aliceTestClient.httpBackend.when('POST', '/keys/query').respond(200, { - device_keys: { - '@bob:xyz': {}, - }, - }); + aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, { + device_keys: { + "@bob:xyz": {}, + }, + }); - return Promise.all([ - aliceTestClient.client.downloadKeys(['@bob:xyz']), - aliceTestClient.httpBackend.flush('/keys/query', 1), - ]); - }).then(function() { - logger.log("Telling alice to send a megolm message"); - - aliceTestClient.httpBackend.when( - 'PUT', '/send/', - ).respond(200, { - event_id: '$event_id', - }); + return Promise.all([ + aliceTestClient.client.downloadKeys(["@bob:xyz"]), + aliceTestClient.httpBackend.flush("/keys/query", 1), + ]); + }) + .then(function () { + logger.log("Telling alice to send a megolm message"); + + aliceTestClient.httpBackend.when("PUT", "/send/").respond(200, { + event_id: "$event_id", + }); - return Promise.all([ - aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'), + return Promise.all([ + aliceTestClient.client.sendTextMessage(ROOM_ID, "test"), - // the crypto stuff can take a while, so give the requests a whole second. - aliceTestClient.httpBackend.flushAllExpected({ - timeout: 1000, - }), - ]); - }); + // the crypto stuff can take a while, so give the requests a whole second. + aliceTestClient.httpBackend.flushAllExpected({ + timeout: 1000, + }), + ]); + }); }); it.skip("We should not get confused by out-of-order device query responses", () => { // https://github.com/vector-im/element-web/issues/3126 - aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} } }); - return aliceTestClient.start().then(() => { - aliceTestClient.httpBackend.when('GET', '/sync').respond( - 200, getSyncResponse(['@bob:xyz', '@chris:abc'])); - return aliceTestClient.flushSync(); - }).then(() => { - // to make sure the initial device queries are flushed out, we - // attempt to send a message. - - aliceTestClient.httpBackend.when('POST', '/keys/query').respond( - 200, { + aliceTestClient.expectKeyQuery({ + device_keys: { "@alice:localhost": {} }, + failures: {}, + }); + return aliceTestClient + .start() + .then(() => { + aliceTestClient.httpBackend + .when("GET", "/sync") + .respond(200, getSyncResponse(["@bob:xyz", "@chris:abc"])); + return aliceTestClient.flushSync(); + }) + .then(() => { + // to make sure the initial device queries are flushed out, we + // attempt to send a message. + + aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, { device_keys: { - '@bob:xyz': {}, - '@chris:abc': {}, + "@bob:xyz": {}, + "@chris:abc": {}, }, - }, - ); - - aliceTestClient.httpBackend.when('PUT', '/send/').respond( - 200, { event_id: '$event1' }); - - return Promise.all([ - aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'), - aliceTestClient.httpBackend.flush('/keys/query', 1).then( - () => aliceTestClient.httpBackend.flush('/send/', 1), - ), - aliceTestClient.client.crypto.deviceList.saveIfDirty(), - ]); - }).then(() => { - aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { - expect(data.syncToken).toEqual(1); - }); + }); - // invalidate bob's and chris's device lists in separate syncs - aliceTestClient.httpBackend.when('GET', '/sync').respond(200, { - next_batch: '2', - device_lists: { - changed: ['@bob:xyz'], - }, - }); - aliceTestClient.httpBackend.when('GET', '/sync').respond(200, { - next_batch: '3', - device_lists: { - changed: ['@chris:abc'], - }, - }); - // flush both syncs - return aliceTestClient.flushSync().then(() => { - return aliceTestClient.flushSync(); - }); - }).then(() => { - // check that we don't yet have a request for chris's devices. - aliceTestClient.httpBackend.when('POST', '/keys/query', { - device_keys: { - '@chris:abc': {}, - }, - token: '3', - }).respond(200, { - device_keys: { '@chris:abc': {} }, - }); - return aliceTestClient.httpBackend.flush('/keys/query', 1); - }).then((flushed) => { - expect(flushed).toEqual(0); - return aliceTestClient.client.crypto.deviceList.saveIfDirty(); - }).then(() => { - aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { - const bobStat = data.trackingStatus['@bob:xyz']; - if (bobStat != 1 && bobStat != 2) { - throw new Error('Unexpected status for bob: wanted 1 or 2, got ' + - bobStat); - } - const chrisStat = data.trackingStatus['@chris:abc']; - if (chrisStat != 1 && chrisStat != 2) { - throw new Error( - 'Unexpected status for chris: wanted 1 or 2, got ' + chrisStat, - ); - } - }); + aliceTestClient.httpBackend.when("PUT", "/send/").respond(200, { event_id: "$event1" }); + + return Promise.all([ + aliceTestClient.client.sendTextMessage(ROOM_ID, "test"), + aliceTestClient.httpBackend + .flush("/keys/query", 1) + .then(() => aliceTestClient.httpBackend.flush("/send/", 1)), + aliceTestClient.client.crypto!.deviceList.saveIfDirty(), + ]); + }) + .then(() => { + // @ts-ignore accessing a protected field + aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => { + expect(data!.syncToken).toEqual(1); + }); - // now add an expectation for a query for bob's devices, and let - // it complete. - aliceTestClient.httpBackend.when('POST', '/keys/query', { - device_keys: { - '@bob:xyz': {}, - }, - token: '2', - }).respond(200, { - device_keys: { '@bob:xyz': {} }, - }); - return aliceTestClient.httpBackend.flush('/keys/query', 1); - }).then((flushed) => { - expect(flushed).toEqual(1); - - // wait for the client to stop processing the response - return aliceTestClient.client.downloadKeys(['@bob:xyz']); - }).then(() => { - return aliceTestClient.client.crypto.deviceList.saveIfDirty(); - }).then(() => { - aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { - const bobStat = data.trackingStatus['@bob:xyz']; - expect(bobStat).toEqual(3); - const chrisStat = data.trackingStatus['@chris:abc']; - if (chrisStat != 1 && chrisStat != 2) { - throw new Error( - 'Unexpected status for chris: wanted 1 or 2, got ' + bobStat, - ); - } - }); + // invalidate bob's and chris's device lists in separate syncs + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { + next_batch: "2", + device_lists: { + changed: ["@bob:xyz"], + }, + }); + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { + next_batch: "3", + device_lists: { + changed: ["@chris:abc"], + }, + }); + // flush both syncs + return aliceTestClient.flushSync().then(() => { + return aliceTestClient.flushSync(); + }); + }) + .then(() => { + // check that we don't yet have a request for chris's devices. + aliceTestClient.httpBackend + .when("POST", "/keys/query", { + device_keys: { + "@chris:abc": {}, + }, + token: "3", + }) + .respond(200, { + device_keys: { "@chris:abc": {} }, + }); + return aliceTestClient.httpBackend.flush("/keys/query", 1); + }) + .then((flushed) => { + expect(flushed).toEqual(0); + return aliceTestClient.client.crypto!.deviceList.saveIfDirty(); + }) + .then(() => { + // @ts-ignore accessing a protected field + aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => { + const bobStat = data!.trackingStatus["@bob:xyz"]; + if (bobStat != 1 && bobStat != 2) { + throw new Error("Unexpected status for bob: wanted 1 or 2, got " + bobStat); + } + const chrisStat = data!.trackingStatus["@chris:abc"]; + if (chrisStat != 1 && chrisStat != 2) { + throw new Error("Unexpected status for chris: wanted 1 or 2, got " + chrisStat); + } + }); + + // now add an expectation for a query for bob's devices, and let + // it complete. + aliceTestClient.httpBackend + .when("POST", "/keys/query", { + device_keys: { + "@bob:xyz": {}, + }, + token: "2", + }) + .respond(200, { + device_keys: { "@bob:xyz": {} }, + }); + return aliceTestClient.httpBackend.flush("/keys/query", 1); + }) + .then((flushed) => { + expect(flushed).toEqual(1); + + // wait for the client to stop processing the response + return aliceTestClient.client.downloadKeys(["@bob:xyz"]); + }) + .then(() => { + return aliceTestClient.client.crypto!.deviceList.saveIfDirty(); + }) + .then(() => { + // @ts-ignore accessing a protected field + aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => { + const bobStat = data!.trackingStatus["@bob:xyz"]; + expect(bobStat).toEqual(3); + const chrisStat = data!.trackingStatus["@chris:abc"]; + if (chrisStat != 1 && chrisStat != 2) { + throw new Error("Unexpected status for chris: wanted 1 or 2, got " + bobStat); + } + }); - // now let the query for chris's devices complete. - return aliceTestClient.httpBackend.flush('/keys/query', 1); - }).then((flushed) => { - expect(flushed).toEqual(1); - - // wait for the client to stop processing the response - return aliceTestClient.client.downloadKeys(['@chris:abc']); - }).then(() => { - return aliceTestClient.client.crypto.deviceList.saveIfDirty(); - }).then(() => { - aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { - const bobStat = data.trackingStatus['@bob:xyz']; - const chrisStat = data.trackingStatus['@bob:xyz']; - - expect(bobStat).toEqual(3); - expect(chrisStat).toEqual(3); - expect(data.syncToken).toEqual(3); + // now let the query for chris's devices complete. + return aliceTestClient.httpBackend.flush("/keys/query", 1); + }) + .then((flushed) => { + expect(flushed).toEqual(1); + + // wait for the client to stop processing the response + return aliceTestClient.client.downloadKeys(["@chris:abc"]); + }) + .then(() => { + return aliceTestClient.client.crypto!.deviceList.saveIfDirty(); + }) + .then(() => { + // @ts-ignore accessing a protected field + aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => { + const bobStat = data!.trackingStatus["@bob:xyz"]; + const chrisStat = data!.trackingStatus["@bob:xyz"]; + + expect(bobStat).toEqual(3); + expect(chrisStat).toEqual(3); + expect(data!.syncToken).toEqual(3); + }); }); - }); }); // https://github.com/vector-im/element-web/issues/4983 describe("Alice should know she has stale device lists", () => { - beforeEach(async function() { + beforeEach(async function () { await aliceTestClient.start(); - aliceTestClient.httpBackend.when('GET', '/sync').respond( - 200, getSyncResponse(['@bob:xyz'])); + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse(["@bob:xyz"])); await aliceTestClient.flushSync(); - aliceTestClient.httpBackend.when('POST', '/keys/query').respond( - 200, { - device_keys: { - '@bob:xyz': {}, - }, + aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, { + device_keys: { + "@bob:xyz": {}, }, - ); - await aliceTestClient.httpBackend.flush('/keys/query', 1); - await aliceTestClient.client.crypto.deviceList.saveIfDirty(); + }); + await aliceTestClient.httpBackend.flush("/keys/query", 1); + await aliceTestClient.client.crypto!.deviceList.saveIfDirty(); - aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { - const bobStat = data.trackingStatus['@bob:xyz']; + // @ts-ignore accessing a protected field + aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => { + const bobStat = data!.trackingStatus["@bob:xyz"]; // Alice should be tracking bob's device list - expect(bobStat).toBeGreaterThan( - 0, - ); + expect(bobStat).toBeGreaterThan(0); }); }); - it("when Bob leaves", async function() { - aliceTestClient.httpBackend.when('GET', '/sync').respond( - 200, { - next_batch: 2, - device_lists: { - left: ['@bob:xyz'], - }, - rooms: { - join: { - [ROOM_ID]: { - timeline: { - events: [ - testUtils.mkMembership({ - mship: 'leave', - sender: '@bob:xyz', - }), - ], - }, + it("when Bob leaves", async function () { + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { + next_batch: 2, + device_lists: { + left: ["@bob:xyz"], + }, + rooms: { + join: { + [ROOM_ID]: { + timeline: { + events: [ + testUtils.mkMembership({ + mship: "leave", + sender: "@bob:xyz", + }), + ], }, }, }, }, - ); + }); await aliceTestClient.flushSync(); - await aliceTestClient.client.crypto.deviceList.saveIfDirty(); + await aliceTestClient.client.crypto!.deviceList.saveIfDirty(); - aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { - const bobStat = data.trackingStatus['@bob:xyz']; + // @ts-ignore accessing a protected field + 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, - ); + expect(bobStat).toEqual(0); }); }); - it("when Alice leaves", async function() { - aliceTestClient.httpBackend.when('GET', '/sync').respond( - 200, { - next_batch: 2, - device_lists: { - left: ['@bob:xyz'], - }, - rooms: { - leave: { - [ROOM_ID]: { - timeline: { - events: [ - testUtils.mkMembership({ - mship: 'leave', - sender: '@bob:xyz', - }), - ], - }, + it("when Alice leaves", async function () { + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { + next_batch: 2, + device_lists: { + left: ["@bob:xyz"], + }, + rooms: { + leave: { + [ROOM_ID]: { + timeline: { + events: [ + testUtils.mkMembership({ + mship: "leave", + sender: "@bob:xyz", + }), + ], }, }, }, }, - ); + }); await aliceTestClient.flushSync(); - await aliceTestClient.client.crypto.deviceList.saveIfDirty(); + await aliceTestClient.client.crypto!.deviceList.saveIfDirty(); - aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { - const bobStat = data.trackingStatus['@bob:xyz']; + // @ts-ignore accessing a protected field + 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, - ); + expect(bobStat).toEqual(0); }); }); - it("when Bob leaves whilst Alice is offline", async function() { + it("when Bob leaves whilst Alice is offline", async function () { aliceTestClient.stop(); const anotherTestClient = await createTestClient(); try { await anotherTestClient.start(); - anotherTestClient.httpBackend.when('GET', '/sync').respond( - 200, getSyncResponse([])); + anotherTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse([])); await anotherTestClient.flushSync(); 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, - ); + expect(bobStat).toEqual(0); }); } finally { anotherTestClient.stop(); diff --git a/spec/integ/matrix-client-crypto.spec.ts b/spec/integ/matrix-client-crypto.spec.ts index 38de34aa59d..326b8578bc7 100644 --- a/spec/integ/matrix-client-crypto.spec.ts +++ b/spec/integ/matrix-client-crypto.spec.ts @@ -26,14 +26,16 @@ limitations under the License. */ // load olm before the sdk if possible -import '../olm-loader'; +import "../olm-loader"; -import { logger } from '../../src/logger'; +import type { Session } from "@matrix-org/olm"; +import { logger } from "../../src/logger"; import * as testUtils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; -import { CRYPTO_ENABLED, IUploadKeysRequest } from "../../src/client"; +import { CRYPTO_ENABLED, IClaimKeysRequest, IQueryKeysRequest, IUploadKeysRequest } from "../../src/client"; import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../src/matrix"; -import { DeviceInfo } from '../../src/crypto/deviceinfo'; +import { DeviceInfo } from "../../src/crypto/deviceinfo"; +import { IDeviceKeys, IOneTimeKey } from "../../src/crypto/dehydration"; let aliTestClient: TestClient; const roomId = "!room:localhost"; @@ -47,39 +49,31 @@ const bobAccessToken = "fewgfkuesa"; let aliMessages: IContent[]; let bobMessages: IContent[]; -// IMessage isn't exported by src/crypto/algorithms/olm.ts -interface OlmPayload { - type: number; - body: string; -} +type OlmPayload = ReturnType; async function bobUploadsDeviceKeys(): Promise { bobTestClient.expectDeviceKeyUpload(); - await Promise.all([ - bobTestClient.client.uploadKeys(), - bobTestClient.httpBackend.flushAllExpected(), - ]); + await bobTestClient.httpBackend.flushAllExpected(); expect(Object.keys(bobTestClient.deviceKeys!).length).not.toEqual(0); } /** * Set an expectation that querier will query uploader's keys; then flush the http request. * - * @return {promise} resolves once the http request has completed. + * @returns resolves once the http request has completed. */ function expectQueryKeys(querier: TestClient, uploader: TestClient): Promise { // can't query keys before bob has uploaded them expect(uploader.deviceKeys).toBeTruthy(); - const uploaderKeys = {}; - uploaderKeys[uploader.deviceId!] = uploader.deviceKeys; - querier.httpBackend.when("POST", "/keys/query") - .respond(200, function(_path, content: IUploadKeysRequest) { - expect(content.device_keys![uploader.userId!]).toEqual([]); - const result = {}; - result[uploader.userId!] = uploaderKeys; - return { device_keys: result }; - }); + const uploaderKeys: Record = {}; + uploaderKeys[uploader.deviceId!] = uploader.deviceKeys!; + querier.httpBackend.when("POST", "/keys/query").respond(200, function (_path, content: IQueryKeysRequest) { + expect(content.device_keys![uploader.userId!]).toEqual([]); + const result: Record> = {}; + result[uploader.userId!] = uploaderKeys; + return { device_keys: result }; + }); return querier.httpBackend.flush("/keys/query", 1); } const expectAliQueryKeys = () => expectQueryKeys(aliTestClient, bobTestClient); @@ -88,16 +82,14 @@ const expectBobQueryKeys = () => expectQueryKeys(bobTestClient, aliTestClient); /** * Set an expectation that ali will claim one of bob's keys; then flush the http request. * - * @return {promise} resolves once the http request has completed. + * @returns resolves once the http request has completed. */ async function expectAliClaimKeys(): Promise { const keys = await bobTestClient.awaitOneTimeKeyUpload(); - aliTestClient.httpBackend.when( - "POST", "/keys/claim", - ).respond(200, function(_path, content: IUploadKeysRequest) { + aliTestClient.httpBackend.when("POST", "/keys/claim").respond(200, function (_path, content: IClaimKeysRequest) { const claimType = content.one_time_keys![bobUserId][bobDeviceId]; expect(claimType).toEqual("signed_curve25519"); - let keyId = ''; + let keyId = ""; for (keyId in keys) { if (bobTestClient.oneTimeKeys!.hasOwnProperty(keyId)) { if (keyId.indexOf(claimType + ":") === 0) { @@ -105,7 +97,7 @@ async function expectAliClaimKeys(): Promise { } } } - const result = {}; + const result: Record>> = {}; result[bobUserId] = {}; result[bobUserId][bobDeviceId] = {}; result[bobUserId][bobDeviceId][keyId] = keys[keyId]; @@ -138,8 +130,7 @@ async function aliDownloadsKeys(): Promise { aliTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const devices = data!.devices[bobUserId]!; expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys!.keys); - expect(devices[bobDeviceId].verified). - toBe(DeviceInfo.DeviceVerification.UNVERIFIED); + expect(devices[bobDeviceId].verified).toBe(DeviceInfo.DeviceVerification.UNVERIFIED); }); } @@ -156,15 +147,13 @@ const bobEnablesEncryption = () => clientEnablesEncryption(bobTestClient.client) * Ali sends a message, first claiming e2e keys. Set the expectations and * check the results. * - * @return {promise} which resolves to the ciphertext for Bob's device. + * @returns which resolves to the ciphertext for Bob's device. */ async function aliSendsFirstMessage(): Promise { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_, ciphertext] = await Promise.all([ sendMessage(aliTestClient.client), - expectAliQueryKeys() - .then(expectAliClaimKeys) - .then(expectAliSendMessageRequest), + expectAliQueryKeys().then(expectAliClaimKeys).then(expectAliSendMessageRequest), ]); return ciphertext; } @@ -173,14 +162,11 @@ async function aliSendsFirstMessage(): Promise { * Ali sends a message without first claiming e2e keys. Set the expectations * and check the results. * - * @return {promise} which resolves to the ciphertext for Bob's device. + * @returns which resolves to the ciphertext for Bob's device. */ async function aliSendsMessage(): Promise { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, ciphertext] = await Promise.all([ - sendMessage(aliTestClient.client), - expectAliSendMessageRequest(), - ]); + const [_, ciphertext] = await Promise.all([sendMessage(aliTestClient.client), expectAliSendMessageRequest()]); return ciphertext; } @@ -188,14 +174,13 @@ async function aliSendsMessage(): Promise { * Bob sends a message, first querying (but not claiming) e2e keys. Set the * expectations and check the results. * - * @return {promise} which resolves to the ciphertext for Ali's device. + * @returns which resolves to the ciphertext for Ali's device. */ async function bobSendsReplyMessage(): Promise { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_, ciphertext] = await Promise.all([ sendMessage(bobTestClient.client), - expectBobQueryKeys() - .then(expectBobSendMessageRequest), + expectBobQueryKeys().then(expectBobSendMessageRequest), ]); return ciphertext; } @@ -203,7 +188,7 @@ async function bobSendsReplyMessage(): Promise { /** * Set an expectation that Ali will send a message, and flush the request * - * @return {promise} which resolves to the ciphertext for Bob's device. + * @returns which resolves to the ciphertext for Bob's device. */ async function expectAliSendMessageRequest(): Promise { const content = await expectSendMessageRequest(aliTestClient.httpBackend); @@ -217,7 +202,7 @@ async function expectAliSendMessageRequest(): Promise { /** * Set an expectation that Bob will send a message, and flush the request * - * @return {promise} which resolves to the ciphertext for Bob's device. + * @returns which resolves to the ciphertext for Bob's device. */ async function expectBobSendMessageRequest(): Promise { const content = await expectSendMessageRequest(bobTestClient.httpBackend); @@ -231,15 +216,13 @@ async function expectBobSendMessageRequest(): Promise { } function sendMessage(client: MatrixClient): Promise { - return client.sendMessage( - roomId, { msgtype: "m.text", body: "Hello, World" }, - ); + return client.sendMessage(roomId, { msgtype: "m.text", body: "Hello, World" }); } async function expectSendMessageRequest(httpBackend: TestClient["httpBackend"]): Promise { const path = "/send/m.room.encrypted/"; const prom = new Promise((resolve) => { - httpBackend.when("PUT", path).respond(200, function(_path, content) { + httpBackend.when("PUT", path).respond(200, function (_path, content) { resolve(content); return { event_id: "asdfgh", @@ -254,16 +237,12 @@ async function expectSendMessageRequest(httpBackend: TestClient["httpBackend"]): function aliRecvMessage(): Promise { const message = bobMessages.shift()!; - return recvMessage( - aliTestClient.httpBackend, aliTestClient.client, bobUserId, message, - ); + return recvMessage(aliTestClient.httpBackend, aliTestClient.client, bobUserId, message); } function bobRecvMessage(): Promise { const message = aliMessages.shift()!; - return recvMessage( - bobTestClient.httpBackend, bobTestClient.client, aliUserId, message, - ); + return recvMessage(bobTestClient.httpBackend, bobTestClient.client, aliUserId, message); } async function recvMessage( @@ -276,32 +255,30 @@ async function recvMessage( next_batch: "x", rooms: { join: { - + [roomId]: { + timeline: { + events: [ + testUtils.mkEvent({ + type: "m.room.encrypted", + room: roomId, + content: message, + sender: sender, + }), + ], + }, + }, }, }, }; - syncData.rooms.join[roomId] = { - timeline: { - events: [ - testUtils.mkEvent({ - type: "m.room.encrypted", - room: roomId, - content: message, - sender: sender, - }), - ], - }, - }; httpBackend.when("GET", "/sync").respond(200, syncData); const eventPromise = new Promise((resolve) => { - const onEvent = function(event: MatrixEvent) { + const onEvent = function (event: MatrixEvent) { // ignore the m.room.member events if (event.getType() == "m.room.member") { return; } - logger.log(client.credentials.userId + " received event", - event); + logger.log(client.credentials.userId + " received event", event); client.removeListener(ClientEvent.Event, onEvent); resolve(event); @@ -327,32 +304,32 @@ async function recvMessage( * Send an initial sync response to the client (which just includes the member * list for our test room). * - * @param {TestClient} testClient - * @returns {Promise} which resolves when the sync has been flushed. + * @returns which resolves when the sync has been flushed. */ function firstSync(testClient: TestClient): Promise { // send a sync response including our test room. const syncData = { next_batch: "x", rooms: { - join: { }, - }, - }; - syncData.rooms.join[roomId] = { - state: { - events: [ - testUtils.mkMembership({ - mship: "join", - user: aliUserId, - }), - testUtils.mkMembership({ - mship: "join", - user: bobUserId, - }), - ], - }, - timeline: { - events: [], + join: { + [roomId]: { + state: { + events: [ + testUtils.mkMembership({ + mship: "join", + user: aliUserId, + }), + testUtils.mkMembership({ + mship: "join", + user: bobUserId, + }), + ], + }, + timeline: { + events: [], + }, + }, + }, }, }; @@ -385,6 +362,13 @@ describe("MatrixClient crypto", () => { it("Bob uploads device keys", bobUploadsDeviceKeys); + it("handles failures to upload device keys", async () => { + // since device keys are uploaded asynchronously, there's not really much to do here other than fail the + // upload. + bobTestClient.httpBackend.when("POST", "/keys/upload").fail(0, new Error("bleh")); + await bobTestClient.httpBackend.flushAllExpected(); + }); + it("Ali downloads Bobs device keys", async () => { await bobUploadsDeviceKeys(); await aliDownloadsKeys(); @@ -396,10 +380,7 @@ describe("MatrixClient crypto", () => { const bobDeviceKeys = bobTestClient.deviceKeys!; expect(bobDeviceKeys.keys["curve25519:" + bobDeviceId]).toBeTruthy(); bobDeviceKeys.keys["curve25519:" + bobDeviceId] += "abc"; - await Promise.all([ - aliTestClient.client.downloadKeys([bobUserId]), - expectAliQueryKeys(), - ]); + await Promise.all([aliTestClient.client.downloadKeys([bobUserId]), expectAliQueryKeys()]); const devices = aliTestClient.client.getStoredDevicesForUser(bobUserId); // should get an empty list expect(devices).toEqual([]); @@ -409,26 +390,24 @@ describe("MatrixClient crypto", () => { const eveUserId = "@eve:localhost"; const bobDeviceKeys = { - algorithms: ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2'], - device_id: 'bvcxz', + algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], + device_id: "bvcxz", keys: { - 'ed25519:bvcxz': 'pYuWKMCVuaDLRTM/eWuB8OlXEb61gZhfLVJ+Y54tl0Q', - 'curve25519:bvcxz': '7Gni0loo/nzF0nFp9847RbhElGewzwUXHPrljjBGPTQ', + "ed25519:bvcxz": "pYuWKMCVuaDLRTM/eWuB8OlXEb61gZhfLVJ+Y54tl0Q", + "curve25519:bvcxz": "7Gni0loo/nzF0nFp9847RbhElGewzwUXHPrljjBGPTQ", }, - user_id: '@eve:localhost', + user_id: "@eve:localhost", signatures: { - '@eve:localhost': { - 'ed25519:bvcxz': 'CliUPZ7dyVPBxvhSA1d+X+LYa5b2AYdjcTwG' + - '0stXcIxjaJNemQqtdgwKDtBFl3pN2I13SEijRDCf1A8bYiQMDg', + "@eve:localhost": { + "ed25519:bvcxz": + "CliUPZ7dyVPBxvhSA1d+X+LYa5b2AYdjcTwG" + "0stXcIxjaJNemQqtdgwKDtBFl3pN2I13SEijRDCf1A8bYiQMDg", }, }, }; - const bobKeys = {}; + const bobKeys: Record = {}; bobKeys[bobDeviceId] = bobDeviceKeys; - aliTestClient.httpBackend.when( - "POST", "/keys/query", - ).respond(200, { device_keys: { [bobUserId]: bobKeys } }); + aliTestClient.httpBackend.when("POST", "/keys/query").respond(200, { device_keys: { [bobUserId]: bobKeys } }); await Promise.all([ aliTestClient.client.downloadKeys([bobUserId, eveUserId]), @@ -445,26 +424,24 @@ describe("MatrixClient crypto", () => { it("Ali gets keys with an incorrect deviceId", async () => { const bobDeviceKeys = { - algorithms: ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2'], - device_id: 'bad_device', + algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], + device_id: "bad_device", keys: { - 'ed25519:bad_device': 'e8XlY5V8x2yJcwa5xpSzeC/QVOrU+D5qBgyTK0ko+f0', - 'curve25519:bad_device': 'YxuuLG/4L5xGeP8XPl5h0d7DzyYVcof7J7do+OXz0xc', + "ed25519:bad_device": "e8XlY5V8x2yJcwa5xpSzeC/QVOrU+D5qBgyTK0ko+f0", + "curve25519:bad_device": "YxuuLG/4L5xGeP8XPl5h0d7DzyYVcof7J7do+OXz0xc", }, - user_id: '@bob:localhost', + user_id: "@bob:localhost", signatures: { - '@bob:localhost': { - 'ed25519:bad_device': 'fEFTq67RaSoIEVBJ8DtmRovbwUBKJ0A' + - 'me9m9PDzM9azPUwZ38Xvf6vv1A7W1PSafH4z3Y2ORIyEnZgHaNby3CQ', + "@bob:localhost": { + "ed25519:bad_device": + "fEFTq67RaSoIEVBJ8DtmRovbwUBKJ0A" + "me9m9PDzM9azPUwZ38Xvf6vv1A7W1PSafH4z3Y2ORIyEnZgHaNby3CQ", }, }, }; - const bobKeys = {}; + const bobKeys: Record = {}; bobKeys[bobDeviceId] = bobDeviceKeys; - aliTestClient.httpBackend.when( - "POST", "/keys/query", - ).respond(200, { device_keys: { [bobUserId]: bobKeys } }); + aliTestClient.httpBackend.when("POST", "/keys/query").respond(200, { device_keys: { [bobUserId]: bobKeys } }); await Promise.all([ aliTestClient.client.downloadKeys([bobUserId]), @@ -515,26 +492,25 @@ describe("MatrixClient crypto", () => { next_batch: "x", rooms: { join: { - + [roomId]: { + timeline: { + events: [ + testUtils.mkEvent({ + type: "m.room.encrypted", + room: roomId, + content: message, + sender: "@bogus:sender", + }), + ], + }, + }, }, }, }; - syncData.rooms.join[roomId] = { - timeline: { - events: [ - testUtils.mkEvent({ - type: "m.room.encrypted", - room: roomId, - content: message, - sender: "@bogus:sender", - }), - ], - }, - }; bobTestClient.httpBackend.when("GET", "/sync").respond(200, syncData); const eventPromise = new Promise((resolve) => { - const onEvent = function(event: MatrixEvent) { + const onEvent = function (event: MatrixEvent) { logger.log(bobUserId + " received event", event); resolve(event); }; @@ -558,11 +534,10 @@ describe("MatrixClient crypto", () => { await aliDownloadsKeys(); aliTestClient.client.setDeviceBlocked(bobUserId, bobDeviceId, true); const p1 = sendMessage(aliTestClient.client); - const p2 = expectSendMessageRequest(aliTestClient.httpBackend) - .then(function(sentContent) { - // no unblocked devices, so the ciphertext should be empty - expect(sentContent.ciphertext).toEqual({}); - }); + const p2 = expectSendMessageRequest(aliTestClient.httpBackend).then(function (sentContent) { + // no unblocked devices, so the ciphertext should be empty + expect(sentContent.ciphertext).toEqual({}); + }); await Promise.all([p1, p2]); }); @@ -588,9 +563,7 @@ describe("MatrixClient crypto", () => { await firstSync(bobTestClient); await aliEnablesEncryption(); await aliSendsFirstMessage(); - bobTestClient.httpBackend.when('POST', '/keys/query').respond( - 200, {}, - ); + bobTestClient.httpBackend.when("POST", "/keys/query").respond(200, {}); await bobRecvMessage(); await bobEnablesEncryption(); const ciphertext = await bobSendsReplyMessage(); @@ -605,28 +578,28 @@ describe("MatrixClient crypto", () => { await aliTestClient.start(); await firstSync(aliTestClient); const syncData = { - next_batch: '2', + next_batch: "2", rooms: { - join: {}, - }, - }; - syncData.rooms.join[roomId] = { - state: { - events: [ - testUtils.mkEvent({ - type: 'm.room.encryption', - skey: '', - content: { - algorithm: 'm.olm.v1.curve25519-aes-sha2', + join: { + [roomId]: { + state: { + events: [ + testUtils.mkEvent({ + type: "m.room.encryption", + skey: "", + content: { + algorithm: "m.olm.v1.curve25519-aes-sha2", + }, + }), + ], }, - }), - ], + }, + }, }, }; - aliTestClient.httpBackend.when('GET', '/sync').respond( - 200, syncData); - await aliTestClient.httpBackend.flush('/sync', 1); + aliTestClient.httpBackend.when("GET", "/sync").respond(200, syncData); + await aliTestClient.httpBackend.flush("/sync", 1); aliTestClient.expectKeyQuery({ device_keys: { [bobUserId]: {}, @@ -649,7 +622,7 @@ describe("MatrixClient crypto", () => { // enqueue expectations: // * Sync with empty one_time_keys => upload keys - logger.log(aliTestClient + ': starting'); + logger.log(aliTestClient + ": starting"); httpBackend.when("GET", "/versions").respond(200, {}); httpBackend.when("GET", "/pushrules").respond(200, {}); httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); @@ -659,24 +632,61 @@ describe("MatrixClient crypto", () => { // it will upload one-time keys. httpBackend.when("GET", "/sync").respond(200, syncDataEmpty); - await Promise.all([ - aliTestClient.client.startClient({}), - httpBackend.flushAllExpected(), - ]); - logger.log(aliTestClient + ': started'); - httpBackend.when("POST", "/keys/upload") - .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); - // cancel futher calls by telling the client - // we have more than we need - return { - one_time_key_counts: { - signed_curve25519: 70, - }, - }; - }); + await Promise.all([aliTestClient.client.startClient({}), httpBackend.flushAllExpected()]); + logger.log(aliTestClient + ": started"); + httpBackend.when("POST", "/keys/upload").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); + // cancel futher calls by telling the client + // we have more than we need + return { + one_time_key_counts: { + signed_curve25519: 70, + }, + }; + }); await httpBackend.flushAllExpected(); }); + + it("Checks for outgoing room key requests for a given event's session", async () => { + const eventA0 = new MatrixEvent({ + sender: "@bob:example.com", + room_id: "!someroom", + content: { + algorithm: "m.megolm.v1.aes-sha2", + session_id: "sessionid", + sender_key: "senderkey", + }, + }); + const eventA1 = new MatrixEvent({ + sender: "@bob:example.com", + room_id: "!someroom", + content: { + algorithm: "m.megolm.v1.aes-sha2", + session_id: "sessionid", + sender_key: "senderkey", + }, + }); + const eventB = new MatrixEvent({ + sender: "@bob:example.com", + room_id: "!someroom", + content: { + algorithm: "m.megolm.v1.aes-sha2", + session_id: "othersessionid", + sender_key: "senderkey", + }, + }); + const nonEncryptedEvent = new MatrixEvent({ + sender: "@bob:example.com", + room_id: "!someroom", + content: {}, + }); + + aliTestClient.client.crypto?.onSyncCompleted({}); + await aliTestClient.client.cancelAndResendEventRoomKeyRequest(eventA0); + expect(await aliTestClient.client.getOutgoingRoomKeyRequest(eventA1)).not.toBeNull(); + expect(await aliTestClient.client.getOutgoingRoomKeyRequest(eventB)).toBeNull(); + expect(await aliTestClient.client.getOutgoingRoomKeyRequest(nonEncryptedEvent)).toBeNull(); + }); }); diff --git a/spec/integ/matrix-client-event-emitter.spec.ts b/spec/integ/matrix-client-event-emitter.spec.ts index d0050e77e21..7e949f08a77 100644 --- a/spec/integ/matrix-client-event-emitter.spec.ts +++ b/spec/integ/matrix-client-event-emitter.spec.ts @@ -29,7 +29,7 @@ import { import * as utils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; -describe("MatrixClient events", function() { +describe("MatrixClient events", function () { const selfUserId = "@alice:localhost"; const selfAccessToken = "aseukfgwef"; let client: MatrixClient | undefined; @@ -46,23 +46,25 @@ describe("MatrixClient events", function() { return [client!, httpBackend]; }; - beforeEach(function() { + beforeEach(function () { [client!, httpBackend] = setupTests(); }); - afterEach(function() { + afterEach(function () { httpBackend?.verifyNoOutstandingExpectation(); client?.stopClient(); return httpBackend?.stop(); }); - describe("emissions", function() { + describe("emissions", function () { const SYNC_DATA = { next_batch: "s_5_3", presence: { events: [ utils.mkPresence({ - user: "@foo:bar", name: "Foo Bar", presence: "online", + user: "@foo:bar", + name: "Foo Bar", + presence: "online", }), ], }, @@ -72,7 +74,9 @@ describe("MatrixClient events", function() { timeline: { events: [ utils.mkMessage({ - room: "!erufh:bar", user: "@foo:bar", msg: "hmmm", + room: "!erufh:bar", + user: "@foo:bar", + msg: "hmmm", }), ], prev_batch: "s", @@ -80,10 +84,13 @@ describe("MatrixClient events", function() { state: { events: [ utils.mkMembership({ - room: "!erufh:bar", mship: "join", user: "@foo:bar", + room: "!erufh:bar", + mship: "join", + user: "@foo:bar", }), utils.mkEvent({ - type: "m.room.create", room: "!erufh:bar", + type: "m.room.create", + room: "!erufh:bar", user: "@foo:bar", content: { creator: "@foo:bar", @@ -103,18 +110,23 @@ describe("MatrixClient events", function() { timeline: { events: [ utils.mkMessage({ - room: "!erufh:bar", user: "@foo:bar", + room: "!erufh:bar", + user: "@foo:bar", msg: "ello ello", }), utils.mkMessage({ - room: "!erufh:bar", user: "@foo:bar", msg: ":D", + room: "!erufh:bar", + user: "@foo:bar", + msg: ":D", }), ], }, ephemeral: { events: [ utils.mkEvent({ - type: "m.typing", room: "!erufh:bar", content: { + type: "m.typing", + room: "!erufh:bar", + content: { user_ids: ["@foo:bar"], }, }), @@ -125,50 +137,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); - - 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(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; - } + 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); + + 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(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); - }); + } + 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); - }); + utils.syncPromise(client!).then(() => { + return utils.syncPromise(client!); + }), + httpBackend!.flushAllExpected(), + ]).then(() => { + expect(expectedEvents.length).toEqual(0); }); + }); - it("should emit User events", function(done) { + it("should emit User events", function (done) { httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA); httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); let fired = false; - client!.on(UserEvent.Presence, function(event, user) { + client!.on(UserEvent.Presence, function (event, user) { fired = true; expect(user).toBeTruthy(); expect(event).toBeTruthy(); @@ -177,59 +188,52 @@ describe("MatrixClient events", function() { } expect(event.event).toEqual(SYNC_DATA.presence.events[0]); - expect(user.presence).toEqual( - SYNC_DATA.presence.events[0]?.content?.presence, - ); + expect(user.presence).toEqual(SYNC_DATA.presence.events[0]?.content?.presence); }); client!.startClient(); - httpBackend!.flushAllExpected().then(function() { + httpBackend!.flushAllExpected().then(function () { expect(fired).toBe(true); done(); }); }); - it("should emit Room events", function() { + it("should emit Room events", function () { 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(ClientEvent.Room, function(room) { + client!.on(ClientEvent.Room, function (room) { roomInvokeCount++; expect(room.roomId).toEqual("!erufh:bar"); }); - client!.on(RoomEvent.Timeline, function(event, room) { + client!.on(RoomEvent.Timeline, function (event, room) { timelineFireCount++; expect(room?.roomId).toEqual("!erufh:bar"); }); - client!.on(RoomEvent.Name, function(room) { + client!.on(RoomEvent.Name, function (room) { roomNameInvokeCount++; }); client!.startClient(); - return Promise.all([ - httpBackend!.flushAllExpected(), - utils.syncPromise(client!, 2), - ]).then(function() { + return Promise.all([httpBackend!.flushAllExpected(), utils.syncPromise(client!, 2)]).then(function () { expect(roomInvokeCount).toEqual(1); expect(roomNameInvokeCount).toEqual(1); expect(timelineFireCount).toEqual(3); }); }); - it("should emit RoomState events", function() { + it("should emit RoomState events", function () { 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", - ]; + const roomStateEventTypes = ["m.room.member", "m.room.create"]; let eventsInvokeCount = 0; let membersInvokeCount = 0; let newMemberInvokeCount = 0; - client!.on(RoomStateEvent.Events, function(event, state) { + client!.on(RoomStateEvent.Events, function (event, state) { eventsInvokeCount++; const index = roomStateEventTypes.indexOf(event.getType()); expect(index).not.toEqual(-1); @@ -237,13 +241,13 @@ describe("MatrixClient events", function() { roomStateEventTypes.splice(index, 1); } }); - client!.on(RoomStateEvent.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(RoomStateEvent.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"); @@ -252,17 +256,14 @@ describe("MatrixClient events", function() { client!.startClient(); - return Promise.all([ - httpBackend!.flushAllExpected(), - utils.syncPromise(client!, 2), - ]).then(function() { + return Promise.all([httpBackend!.flushAllExpected(), utils.syncPromise(client!, 2)]).then(function () { expect(membersInvokeCount).toEqual(1); expect(newMemberInvokeCount).toEqual(1); expect(eventsInvokeCount).toEqual(2); }); }); - it("should emit RoomMember events", function() { + it("should emit RoomMember events", function () { httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA); httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); @@ -270,27 +271,24 @@ describe("MatrixClient events", function() { let powerLevelInvokeCount = 0; let nameInvokeCount = 0; let membershipInvokeCount = 0; - client!.on(RoomMemberEvent.Name, function(event, member) { + client!.on(RoomMemberEvent.Name, function (event, member) { nameInvokeCount++; }); - client!.on(RoomMemberEvent.Typing, function(event, member) { + client!.on(RoomMemberEvent.Typing, function (event, member) { typingInvokeCount++; expect(member.typing).toBe(true); }); - client!.on(RoomMemberEvent.PowerLevel, function(event, member) { + client!.on(RoomMemberEvent.PowerLevel, function (event, member) { powerLevelInvokeCount++; }); - client!.on(RoomMemberEvent.Membership, function(event, member) { + client!.on(RoomMemberEvent.Membership, function (event, member) { membershipInvokeCount++; expect(member.membership).toEqual("join"); }); client!.startClient(); - return Promise.all([ - httpBackend!.flushAllExpected(), - utils.syncPromise(client!, 2), - ]).then(function() { + return Promise.all([httpBackend!.flushAllExpected(), utils.syncPromise(client!, 2)]).then(function () { expect(typingInvokeCount).toEqual(1); expect(powerLevelInvokeCount).toEqual(0); expect(nameInvokeCount).toEqual(0); @@ -298,36 +296,36 @@ describe("MatrixClient events", function() { }); }); - it("should emit Session.logged_out on M_UNKNOWN_TOKEN", function() { - const error = { errcode: 'M_UNKNOWN_TOKEN' }; + it("should emit Session.logged_out on M_UNKNOWN_TOKEN", function () { + const error = { errcode: "M_UNKNOWN_TOKEN" }; httpBackend!.when("GET", "/sync").respond(401, error); let sessionLoggedOutCount = 0; - client!.on(HttpApiEvent.SessionLoggedOut, function(errObj) { + client!.on(HttpApiEvent.SessionLoggedOut, function (errObj) { sessionLoggedOutCount++; expect(errObj.data).toEqual(error); }); client!.startClient(); - return httpBackend!.flushAllExpected().then(function() { + 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 }; + 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); let sessionLoggedOutCount = 0; - client!.on(HttpApiEvent.SessionLoggedOut, function(errObj) { + client!.on(HttpApiEvent.SessionLoggedOut, function (errObj) { sessionLoggedOutCount++; expect(errObj.data).toEqual(error); }); client!.startClient(); - return httpBackend!.flushAllExpected().then(function() { + 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 fe7393bfd73..9d71f060b48 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -29,9 +29,10 @@ import { Room, } from "../../src/matrix"; import { logger } from "../../src/logger"; -import { encodeUri } from "../../src/utils"; +import { encodeParams, encodeUri, QueryDict, replaceParam } from "../../src/utils"; import { TestClient } from "../TestClient"; -import { FeatureSupport, Thread, THREAD_RELATION_TYPE } from "../../src/models/thread"; +import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread"; +import { emitPromise } from "../test-utils/test-utils"; const userId = "@alice:localhost"; const userName = "Alice"; @@ -45,12 +46,30 @@ const withoutRoomId = (e: Partial): Partial => { return copy; }; +/** + * Our httpBackend only allows matching calls if we have the exact same query, in the exact same order + * This method allows building queries with the exact same parameter order as the fetchRelations method in client + * @param params query parameters + */ +const buildRelationPaginationQuery = (params: QueryDict): string => { + if (Thread.hasServerSideFwdPaginationSupport === FeatureSupport.Experimental) { + params = replaceParam("dir", "org.matrix.msc3715.dir", params); + } + return "?" + encodeParams(params).toString(); +}; + const USER_MEMBERSHIP_EVENT = utils.mkMembership({ - room: roomId, mship: "join", user: userId, name: userName, event: false, + room: roomId, + mship: "join", + user: userId, + name: userName, + event: false, }); const ROOM_NAME_EVENT = utils.mkEvent({ - type: "m.room.name", room: roomId, user: otherUserId, + type: "m.room.name", + room: roomId, + user: otherUserId, content: { name: "Old room name", }, @@ -65,7 +84,9 @@ const INITIAL_SYNC_DATA = { timeline: { events: [ utils.mkMessage({ - user: otherUserId, msg: "hello", event: false, + user: otherUserId, + msg: "hello", + event: false, }), ], prev_batch: "f_1_1", @@ -75,12 +96,14 @@ const INITIAL_SYNC_DATA = { withoutRoomId(ROOM_NAME_EVENT), utils.mkMembership({ mship: "join", - user: otherUserId, name: "Bob", + user: otherUserId, + name: "Bob", event: false, }), withoutRoomId(USER_MEMBERSHIP_EVENT), utils.mkEvent({ - type: "m.room.create", user: userId, + type: "m.room.create", + user: userId, content: { creator: userId, }, @@ -95,16 +118,28 @@ const INITIAL_SYNC_DATA = { const EVENTS = [ utils.mkMessage({ - room: roomId, user: userId, msg: "we", event: false, + room: roomId, + user: userId, + msg: "we", + event: false, }), utils.mkMessage({ - room: roomId, user: userId, msg: "could", event: false, + room: roomId, + user: userId, + msg: "could", + event: false, }), utils.mkMessage({ - room: roomId, user: userId, msg: "be", event: false, + room: roomId, + user: userId, + msg: "be", + event: false, }), utils.mkMessage({ - room: roomId, user: userId, msg: "heroes", event: false, + room: roomId, + user: userId, + msg: "heroes", + event: false, }), ]; @@ -113,15 +148,15 @@ const THREAD_ROOT = utils.mkEvent({ user: userId, type: "m.room.message", content: { - "body": "thread root", - "msgtype": "m.text", + body: "thread root", + msgtype: "m.text", }, unsigned: { "m.relations": { "io.element.thread": { //"latest_event": undefined, - "count": 1, - "current_user_participated": true, + count: 1, + current_user_participated: true, }, }, }, @@ -152,9 +187,9 @@ const SYNC_THREAD_REPLY = withoutRoomId(THREAD_REPLY); SYNC_THREAD_ROOT.unsigned = { "m.relations": { "io.element.thread": { - "latest_event": SYNC_THREAD_REPLY, - "count": 1, - "current_user_participated": true, + latest_event: SYNC_THREAD_REPLY, + count: 1, + current_user_participated: true, }, }, }; @@ -173,7 +208,7 @@ function startClient(httpBackend: HttpBackend, client: MatrixClient) { // set up a promise which will resolve once the client is initialised const prom = new Promise((resolve) => { - client.on(ClientEvent.Sync, function(state) { + client.on(ClientEvent.Sync, function (state) { logger.log("sync", state); if (state != "SYNCING") { return; @@ -182,55 +217,40 @@ function startClient(httpBackend: HttpBackend, client: MatrixClient) { }); }); - return Promise.all([ - httpBackend.flushAllExpected(), - prom, - ]); + return Promise.all([httpBackend.flushAllExpected(), prom]); } -describe("getEventTimeline support", function() { +describe("getEventTimeline support", function () { let httpBackend: HttpBackend; let client: MatrixClient; - beforeEach(function() { + beforeEach(function () { const testClient = new TestClient(userId, "DEVICE", accessToken); client = testClient.client; httpBackend = testClient.httpBackend; }); - afterEach(function() { + afterEach(function () { if (client) { client.stopClient(); } return httpBackend.stop(); }); - it("timeline support must be enabled to work", function() { - const testClient = new TestClient( - userId, - "DEVICE", - accessToken, - undefined, - { timelineSupport: false }, - ); + 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() { + return startClient(httpBackend, client).then(function () { const room = client.getRoom(roomId)!; const timelineSet = room!.getTimelineSets()[0]; expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy(); }); }); - it("timeline support works when enabled", function() { - const testClient = new TestClient( - userId, - "DEVICE", - accessToken, - undefined, - { timelineSupport: true }, - ); + it("timeline support works when enabled", function () { + const testClient = new TestClient(userId, "DEVICE", accessToken, undefined, { timelineSupport: true }); client = testClient.client; httpBackend = testClient.httpBackend; @@ -241,106 +261,90 @@ describe("getEventTimeline support", function() { }); }); - it("only works with room timelines", function() { - const testClient = new TestClient( - userId, - "DEVICE", - accessToken, - undefined, - { timelineSupport: true }, - ); + 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() { + 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() { + 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 | undefined; - return startClient(httpBackend, client).then(function() { - room = client.getRoom(roomId)!; - - httpBackend.when("GET", "/sync").respond(200, { - next_batch: "s_5_4", - rooms: { - join: { - "!foo:bar": { - timeline: { - events: [ - withoutRoomId(EVENTS[0]), - ], - prev_batch: "f_1_1", + return startClient(httpBackend, client) + .then(function () { + room = client.getRoom(roomId)!; + + httpBackend.when("GET", "/sync").respond(200, { + next_batch: "s_5_4", + rooms: { + join: { + "!foo:bar": { + timeline: { + events: [withoutRoomId(EVENTS[0])], + prev_batch: "f_1_1", + }, }, }, }, - }, - }); + }); - httpBackend.when("GET", "/sync").respond(200, { - next_batch: "s_5_5", - rooms: { - join: { - "!foo:bar": { - timeline: { - events: [ - withoutRoomId(EVENTS[1]), - ], - limited: true, - prev_batch: "f_1_2", + httpBackend.when("GET", "/sync").respond(200, { + next_batch: "s_5_5", + rooms: { + join: { + "!foo:bar": { + timeline: { + events: [withoutRoomId(EVENTS[1])], + limited: true, + prev_batch: "f_1_2", + }, }, }, }, - }, - }); + }); - return Promise.all([ - httpBackend.flushAllExpected(), - utils.syncPromise(client, 2), - ]); - }).then(function() { - expect(room!.timeline.length).toEqual(1); - expect(room!.timeline[0].event).toEqual(EVENTS[1]); - - httpBackend.when("GET", "/messages").respond(200, { - chunk: [EVENTS[0]], - start: "pagin_start", - end: "pagin_end", + return Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client, 2)]); + }) + .then(function () { + expect(room!.timeline.length).toEqual(1); + expect(room!.timeline[0].event).toEqual(EVENTS[1]); + + httpBackend.when("GET", "/messages").respond(200, { + chunk: [EVENTS[0]], + start: "pagin_start", + end: "pagin_end", + }); + httpBackend.flush("/messages", 1); + 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"); }); - httpBackend.flush("/messages", 1); - 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"); - }); }); }); -describe("MatrixClient event timelines", function() { +describe("MatrixClient event timelines", function () { let client: MatrixClient; let httpBackend: HttpBackend; - beforeEach(function() { - const testClient = new TestClient( - userId, - "DEVICE", - accessToken, - undefined, - { timelineSupport: true }, - ); + beforeEach(function () { + const testClient = new TestClient(userId, "DEVICE", accessToken, undefined, { timelineSupport: true }); client = testClient.client; httpBackend = testClient.httpBackend; return startClient(httpBackend, client); }); - afterEach(function() { + afterEach(function () { httpBackend.verifyNoOutstandingExpectation(); client.stopClient(); Thread.setServerSideSupport(FeatureSupport.None); @@ -352,42 +356,36 @@ describe("MatrixClient event timelines", function() { return Promise.all([promise, httpBackend.flushAllExpected()]).then(([result]) => result); } - describe("getEventTimeline", function() { - it("should create a new timeline for new events", function() { + describe("getEventTimeline", function () { + it("should create a new timeline for new events", function () { const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; - httpBackend.when("GET", "/rooms/!foo%3Abar/context/event1%3Abar") - .respond(200, function() { - return { - start: "start_token", - events_before: [EVENTS[1], EVENTS[0]], - event: EVENTS[2], - events_after: [EVENTS[3]], - state: [ - ROOM_NAME_EVENT, - USER_MEMBERSHIP_EVENT, - ], - end: "end_token", - }; - }); + httpBackend.when("GET", "/rooms/!foo%3Abar/context/event1%3Abar").respond(200, function () { + return { + start: "start_token", + events_before: [EVENTS[1], EVENTS[0]], + event: EVENTS[2], + events_after: [EVENTS[3]], + state: [ROOM_NAME_EVENT, USER_MEMBERSHIP_EVENT], + end: "end_token", + }; + }); return Promise.all([ - client.getEventTimeline(timelineSet, "event1:bar").then(function(tl) { + client.getEventTimeline(timelineSet, "event1:bar").then(function (tl) { 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!.getPaginationToken(EventTimeline.BACKWARDS)) - .toEqual("start_token"); - expect(tl!.getPaginationToken(EventTimeline.FORWARDS)) - .toEqual("end_token"); + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("start_token"); + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token"); }), httpBackend.flushAllExpected(), ]); }); - it("should return existing timeline for known events", function() { + it("should return existing timeline for known events", function () { const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/sync").respond(200, { @@ -396,9 +394,7 @@ describe("MatrixClient event timelines", function() { join: { "!foo:bar": { timeline: { - events: [ - withoutRoomId(EVENTS[0]), - ], + events: [withoutRoomId(EVENTS[0])], prev_batch: "f_1_2", }, }, @@ -406,23 +402,21 @@ describe("MatrixClient event timelines", function() { }, }); - return Promise.all([ - httpBackend.flush("/sync"), - utils.syncPromise(client), - ]).then(function() { - 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)) - .toEqual("f_1_1"); - // expect(tl.getPaginationToken(EventTimeline.FORWARDS)) - // .toEqual("s_5_4"); - }); + return Promise.all([httpBackend.flush("/sync"), utils.syncPromise(client)]) + .then(function () { + 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)).toEqual("f_1_1"); + // expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + // .toEqual("s_5_4"); + }); }); - it("should update timelines where they overlap a previous /sync", function() { + it("should update timelines where they overlap a previous /sync", function () { const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/sync").respond(200, { @@ -431,9 +425,7 @@ describe("MatrixClient event timelines", function() { join: { "!foo:bar": { timeline: { - events: [ - withoutRoomId(EVENTS[3]), - ], + events: [withoutRoomId(EVENTS[3])], prev_batch: "f_1_2", }, }, @@ -441,9 +433,9 @@ describe("MatrixClient event timelines", function() { }, }); - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[2].event_id!)) - .respond(200, function() { + httpBackend + .when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[2].event_id!)) + .respond(200, function () { return { start: "start_token", events_before: [EVENTS[1]], @@ -455,36 +447,34 @@ describe("MatrixClient event timelines", function() { }); const prom = new Promise((resolve, reject) => { - client.on(ClientEvent.Sync, function() { - 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)) - .toEqual("start_token"); - // expect(tl.getPaginationToken(EventTimeline.FORWARDS)) - // .toEqual("s_5_4"); - }).then(resolve, reject); + client.on(ClientEvent.Sync, function () { + 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)).toEqual("start_token"); + // expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + // .toEqual("s_5_4"); + }) + .then(resolve, reject); }); }); - return Promise.all([ - httpBackend.flushAllExpected(), - prom, - ]); + return Promise.all([httpBackend.flushAllExpected(), prom]); }); - it("should join timelines where they overlap a previous /context", function() { + it("should join timelines where they overlap a previous /context", function () { 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!)) - .respond(200, function() { + httpBackend + .when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!)) + .respond(200, function () { return { start: "start_token0", events_before: [], @@ -495,9 +485,9 @@ describe("MatrixClient event timelines", function() { }; }); - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[2].event_id!)) - .respond(200, function() { + httpBackend + .when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[2].event_id!)) + .respond(200, function () { return { start: "start_token2", events_before: [], @@ -508,9 +498,9 @@ describe("MatrixClient event timelines", function() { }; }); - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[3].event_id!)) - .respond(200, function() { + httpBackend + .when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[3].event_id!)) + .respond(200, function () { return { start: "start_token3", events_before: [], @@ -521,9 +511,9 @@ describe("MatrixClient event timelines", function() { }; }); - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[1].event_id!)) - .respond(200, function() { + httpBackend + .when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[1].event_id!)) + .respond(200, function () { return { start: "start_token4", events_before: [EVENTS[0]], @@ -534,67 +524,66 @@ describe("MatrixClient event timelines", function() { }; }); - let tl0; - let tl3; + let tl0: EventTimeline; + let tl3: EventTimeline; return Promise.all([ - client.getEventTimeline(timelineSet, EVENTS[0].event_id!, - ).then(function(tl) { - expect(tl!.getEvents().length).toEqual(1); - tl0 = tl; - 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!); - }).then(function(tl) { - expect(tl!.getEvents().length).toEqual(1); - tl3 = tl; - 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)) - .toBe(tl0); - expect(tl!.getNeighbouringTimeline(EventTimeline.FORWARDS)) - .toBe(tl3); - expect(tl0.getPaginationToken(EventTimeline.BACKWARDS)) - .toEqual("start_token0"); - expect(tl0.getPaginationToken(EventTimeline.FORWARDS)) - .toBe(null); - expect(tl3.getPaginationToken(EventTimeline.BACKWARDS)) - .toBe(null); - expect(tl3.getPaginationToken(EventTimeline.FORWARDS)) - .toEqual("end_token3"); - }), + client + .getEventTimeline(timelineSet, EVENTS[0].event_id!) + .then(function (tl) { + expect(tl!.getEvents().length).toEqual(1); + tl0 = tl!; + 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!); + }) + .then(function (tl) { + expect(tl!.getEvents().length).toEqual(1); + tl3 = tl!; + 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)).toBe(tl0); + expect(tl!.getNeighbouringTimeline(EventTimeline.FORWARDS)).toBe(tl3); + expect(tl0.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("start_token0"); + expect(tl0.getPaginationToken(EventTimeline.FORWARDS)).toBe(null); + expect(tl3.getPaginationToken(EventTimeline.BACKWARDS)).toBe(null); + expect(tl3.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token3"); + }), httpBackend.flushAllExpected(), ]); }); - it("should fail gracefully if there is no event field", function() { + it("should fail gracefully if there is no event field", function () { 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/event1") - .respond(200, function() { - return { - start: "start_token", - events_before: [], - events_after: [], - end: "end_token", - state: [], - }; - }); + httpBackend.when("GET", "/rooms/!foo%3Abar/context/event1").respond(200, function () { + return { + start: "start_token", + events_before: [], + events_after: [], + end: "end_token", + state: [], + }; + }); return Promise.all([ - client.getEventTimeline(timelineSet, "event1", - ).then(function(tl) { - // could do with a fail() - expect(true).toBeFalsy(); - }, function(e) { - expect(String(e)).toMatch(/'event'/); - }), + client.getEventTimeline(timelineSet, "event1").then( + function (tl) { + // could do with a fail() + expect(true).toBeFalsy(); + }, + function (e) { + expect(String(e)).toMatch(/'event'/); + }, + ), httpBackend.flushAllExpected(), ]); }); @@ -606,51 +595,34 @@ describe("MatrixClient event timelines", function() { await client.stopClient(); // we don't need the client to be syncing at this time const room = client.getRoom(roomId)!; - httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!)) - .respond(200, function() { + 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_RELATION_TYPE.name) + "?dir=b&limit=1") - .respond(200, function() { - return { - original_event: THREAD_ROOT, - chunk: [THREAD_REPLY], - // no next batch as this is the oldest end of the timeline - }; + httpBackend + .when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!)) + .respond(200, function () { + return THREAD_ROOT; }); - - const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false); - await httpBackend.flushAllExpected(); - const timelineSet = thread.timelineSet; - - const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!); - 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(); - }); - - it("should handle thread replies with server support by fetching a contiguous thread timeline", async () => { - // @ts-ignore - client.clientOpts.experimentalThreadSupport = true; - Thread.setServerSideSupport(FeatureSupport.Experimental); - await client.stopClient(); // we don't need the client to be syncing at this time - const room = client.getRoom(roomId)!; - - httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!)) - .respond(200, function() { + 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_RELATION_TYPE.name) + "?dir=b&limit=1") - .respond(200, function() { + httpBackend + .when( + "GET", + "/rooms/!foo%3Abar/relations/" + + encodeURIComponent(THREAD_ROOT.event_id!) + + "/" + + encodeURIComponent(THREAD_RELATION_TYPE.name) + + buildRelationPaginationQuery({ dir: Direction.Backward, limit: 1 }), + ) + .respond(200, function () { return { - original_event: THREAD_ROOT, chunk: [THREAD_REPLY], // no next batch as this is the oldest end of the timeline }; @@ -659,12 +631,18 @@ describe("MatrixClient event timelines", function() { const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false); await httpBackend.flushAllExpected(); const timelineSet = thread.timelineSet; + httpBackend + .when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!)) + .respond(200, function () { + return THREAD_ROOT; + }); + await flushHttp(emitPromise(thread, ThreadEvent.Update)); - const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!); - const timeline = await timelinePromise; + const timeline = await client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!); - expect(timeline!.getEvents().find(e => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy(); - expect(timeline!.getEvents().find(e => e.getId() === THREAD_REPLY.event_id!)).toBeTruthy(); + const eventIds = timeline!.getEvents().map((it) => it.getId()); + expect(eventIds).toContain(THREAD_ROOT.event_id); + expect(eventIds).toContain(THREAD_REPLY.event_id); }); it("should return relevant timeline from non-thread timelineSet when asking for the thread root", async () => { @@ -677,8 +655,9 @@ describe("MatrixClient event timelines", function() { 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!)) - .respond(200, function() { + httpBackend + .when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!)) + .respond(200, function () { return { start: "start_token0", events_before: [], @@ -696,7 +675,7 @@ describe("MatrixClient event timelines", function() { 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", () => { @@ -709,8 +688,9 @@ describe("MatrixClient event timelines", function() { 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!)) - .respond(200, function() { + httpBackend + .when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!)) + .respond(200, function () { return { start: "start_token0", events_before: [], @@ -735,8 +715,9 @@ describe("MatrixClient event timelines", function() { const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id!)) - .respond(200, function() { + httpBackend + .when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id!)) + .respond(200, function () { return { start: "start_token0", events_before: [], @@ -761,7 +742,7 @@ describe("MatrixClient event timelines", function() { const timelineSet = room.getTimelineSets()[0]; const req = httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!)); - req.respond(200, function() { + req.respond(200, function () { return { start: "start_token0", events_before: [], @@ -782,17 +763,11 @@ describe("MatrixClient event timelines", function() { }); }); - describe("getLatestTimeline", function() { - it("timeline support must be enabled to work", async function() { + describe("getLatestTimeline", function () { + it("timeline support must be enabled to work", async function () { await client.stopClient(); - const testClient = new TestClient( - userId, - "DEVICE", - accessToken, - undefined, - { timelineSupport: false }, - ); + const testClient = new TestClient(userId, "DEVICE", accessToken, undefined, { timelineSupport: false }); client = testClient.client; httpBackend = testClient.httpBackend; await startClient(httpBackend, client); @@ -802,16 +777,10 @@ describe("MatrixClient event timelines", function() { await expect(client.getLatestTimeline(timelineSet)).rejects.toBeTruthy(); }); - it("timeline support works when enabled", async function() { + it("timeline support works when enabled", async function () { await client.stopClient(); - const testClient = new TestClient( - userId, - "DEVICE", - accessToken, - undefined, - { timelineSupport: true }, - ); + const testClient = new TestClient(userId, "DEVICE", accessToken, undefined, { timelineSupport: true }); client = testClient.client; httpBackend = testClient.httpBackend; @@ -822,16 +791,10 @@ describe("MatrixClient event timelines", function() { }); }); - it("only works with room timelines", async function() { + it("only works with room timelines", async function () { await client.stopClient(); - const testClient = new TestClient( - userId, - "DEVICE", - accessToken, - undefined, - { timelineSupport: true }, - ); + const testClient = new TestClient(userId, "DEVICE", accessToken, undefined, { timelineSupport: true }); client = testClient.client; httpBackend = testClient.httpBackend; await startClient(httpBackend, client); @@ -840,38 +803,37 @@ describe("MatrixClient event timelines", function() { await expect(client.getLatestTimeline(timelineSet)).rejects.toBeTruthy(); }); - it("should create a new timeline for new events", function() { + it("should create a new timeline for new events", function () { const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; - const latestMessageId = 'event1:bar'; + const latestMessageId = "event1:bar"; - httpBackend.when("GET", "/rooms/!foo%3Abar/messages") - .respond(200, function() { - return { - chunk: [{ + httpBackend.when("GET", "/rooms/!foo%3Abar/messages").respond(200, function () { + return { + chunk: [ + { event_id: latestMessageId, - }], - }; - }); + }, + ], + }; + }); - httpBackend.when("GET", `/rooms/!foo%3Abar/context/${encodeURIComponent(latestMessageId)}`) - .respond(200, function() { + httpBackend + .when("GET", `/rooms/!foo%3Abar/context/${encodeURIComponent(latestMessageId)}`) + .respond(200, function () { return { start: "start_token", events_before: [EVENTS[1], EVENTS[0]], event: EVENTS[2], events_after: [EVENTS[3]], - state: [ - ROOM_NAME_EVENT, - USER_MEMBERSHIP_EVENT, - ], + state: [ROOM_NAME_EVENT, USER_MEMBERSHIP_EVENT], end: "end_token", }; }); return Promise.all([ - client.getLatestTimeline(timelineSet).then(function(tl) { + client.getLatestTimeline(timelineSet).then(function (tl) { // Instead of this assertion logic, we could just add a spy // for `getEventTimeline` and make sure it's called with the // correct parameters. This doesn't feel too bad to make sure @@ -881,10 +843,8 @@ describe("MatrixClient event timelines", function() { expect(tl!.getEvents()[i].event).toEqual(EVENTS[i]); expect(tl!.getEvents()[i]?.sender?.name).toEqual(userName); } - expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)) - .toEqual("start_token"); - expect(tl!.getPaginationToken(EventTimeline.FORWARDS)) - .toEqual("end_token"); + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("start_token"); + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token"); }), httpBackend.flushAllExpected(), ]); @@ -894,14 +854,13 @@ describe("MatrixClient event timelines", function() { const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; - httpBackend.when("GET", "/rooms/!foo%3Abar/messages") - .respond(200, () => { - return { - chunk: [ - // No messages to return - ], - }; - }); + httpBackend.when("GET", "/rooms/!foo%3Abar/messages").respond(200, () => { + return { + chunk: [ + // No messages to return + ], + }; + }); return Promise.all([ expect(client.getLatestTimeline(timelineSet)).rejects.toThrow(), @@ -910,14 +869,14 @@ describe("MatrixClient event timelines", function() { }); }); - describe("paginateEventTimeline", function() { - it("should allow you to paginate backwards", function() { + describe("paginateEventTimeline", function () { + it("should allow you to paginate backwards", 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() { + httpBackend + .when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!)) + .respond(200, function () { return { start: "start_token0", events_before: [], @@ -928,36 +887,38 @@ describe("MatrixClient event timelines", function() { }; }); - httpBackend.when("GET", "/rooms/!foo%3Abar/messages") - .check(function(req) { + 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, function() { + }) + .respond(200, function () { return { chunk: [EVENTS[1], EVENTS[2]], end: "start_token1", }; }); - let tl; + let tl: EventTimeline; return Promise.all([ - 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)) - .toEqual("start_token1"); - expect(tl!.getPaginationToken(EventTimeline.FORWARDS)) - .toEqual("end_token0"); - }), + 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)).toEqual("start_token1"); + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token0"); + }), httpBackend.flushAllExpected(), ]); }); @@ -966,8 +927,8 @@ describe("MatrixClient event timelines", function() { const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; - 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, () => ({ start: "start_token0", events_before: [], @@ -977,13 +938,15 @@ describe("MatrixClient event timelines", function() { state: [], })); - httpBackend.when("GET", "/rooms/!foo%3Abar/messages") - .check(function(req) { + 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, () => ({ + }) + .respond(200, () => ({ start: "start_token0", chunk: [], })); @@ -1001,13 +964,13 @@ describe("MatrixClient event timelines", function() { ]); }); - it("should allow you to paginate forwards", function() { + 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() { + httpBackend + .when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!)) + .respond(200, function () { return { start: "start_token0", events_before: [], @@ -1018,43 +981,99 @@ describe("MatrixClient event timelines", function() { }; }); - httpBackend.when("GET", "/rooms/!foo%3Abar/messages") - .check(function(req) { + httpBackend + .when("GET", "/rooms/!foo%3Abar/messages") + .check(function (req) { const params = req.queryParams!; expect(params.dir).toEqual("f"); expect(params.from).toEqual("end_token0"); expect(params.limit).toEqual("20"); - }).respond(200, function() { + }) + .respond(200, function () { return { chunk: [EVENTS[1], EVENTS[2]], end: "end_token1", }; }); - let tl; + let tl: EventTimeline; return Promise.all([ - 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)) - .toEqual("start_token0"); - expect(tl!.getPaginationToken(EventTimeline.FORWARDS)) - .toEqual("end_token1"); - }), + 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)).toEqual("start_token0"); + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token1"); + }), + httpBackend.flushAllExpected(), + ]); + }); + + it("should create threads for thread roots discovered", 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", + 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, function () { + return { + chunk: [EVENTS[1], EVENTS[2], THREAD_ROOT], + end: "start_token1", + }; + }); + + let tl: EventTimeline; + return Promise.all([ + 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(4); + expect(tl!.getEvents()[0].event).toEqual(THREAD_ROOT); + expect(tl!.getEvents()[1].event).toEqual(EVENTS[2]); + expect(tl!.getEvents()[2].event).toEqual(EVENTS[1]); + expect(tl!.getEvents()[3].event).toEqual(EVENTS[0]); + expect(room.getThreads().map((it) => it.id)).toEqual([THREAD_ROOT.event_id!]); + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("start_token1"); + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token0"); + }), httpBackend.flushAllExpected(), ]); }); }); - describe("paginateEventTimeline for thread list timeline", function() { + describe("paginateEventTimeline for thread list timeline", function () { const RANDOM_TOKEN = "7280349c7bee430f91defe2a38a0a08c"; function respondToFilter(): ExpectedHttpRequest { @@ -1076,32 +1095,71 @@ describe("MatrixClient event timelines", function() { next_batch: RANDOM_TOKEN as string | null, }, ): ExpectedHttpRequest { - const request = httpBackend.when("GET", encodeUri("/_matrix/client/v1/rooms/$roomId/threads", { - $roomId: roomId, - })); + const request = httpBackend.when( + "GET", + encodeUri("/_matrix/client/v1/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!, - })); + function respondToThread(root: Partial, replies: Partial[]): ExpectedHttpRequest { + const request = httpBackend.when( + "GET", + "/_matrix/client/v1/rooms/!foo%3Abar/relations/" + + encodeURIComponent(root.event_id!) + + "/" + + encodeURIComponent(THREAD_RELATION_TYPE.name) + + "?dir=b&limit=1", + ); + request.respond(200, function () { + return { + original_event: root, + chunk: [replies], + // no next batch as this is the oldest end of the timeline + }; + }); + return request; + } + + function respondToContext(event: Partial = THREAD_ROOT): ExpectedHttpRequest { + const request = httpBackend.when( + "GET", + encodeUri("/_matrix/client/r0/rooms/$roomId/context/$eventId", { + $roomId: roomId, + $eventId: event.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, + event: event, }); return request; } + function respondToEvent(event: Partial = THREAD_ROOT): ExpectedHttpRequest { + const request = httpBackend.when( + "GET", + encodeUri("/_matrix/client/r0/rooms/$roomId/event/$eventId", { + $roomId: roomId, + $eventId: event.event_id!, + }), + ); + request.respond(200, event); + return request; + } function respondToMessagesRequest(): ExpectedHttpRequest { - const request = httpBackend.when("GET", encodeUri("/_matrix/client/r0/rooms/$roomId/messages", { - $roomId: roomId, - })); + const request = httpBackend.when( + "GET", + encodeUri("/_matrix/client/r0/rooms/$roomId/messages", { + $roomId: roomId, + }), + ); request.respond(200, { chunk: [THREAD_ROOT], state: [], @@ -1111,7 +1169,7 @@ describe("MatrixClient event timelines", function() { return request; } - describe("with server compatibility", function() { + describe("with server compatibility", function () { beforeEach(() => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; @@ -1128,15 +1186,17 @@ describe("MatrixClient event timelines", function() { expect(timeline).not.toBeNull(); respondToThreads(); - const success = await flushHttp(client.paginateEventTimeline(timeline!, { - backwards: direction === Direction.Backward, - })); + 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!.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() { + 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(); @@ -1145,7 +1205,7 @@ describe("MatrixClient event timelines", function() { await testPagination(myThreads, Direction.Backward); }); - it("should allow you to paginate all threads forwards", async function() { + 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(); @@ -1155,7 +1215,7 @@ describe("MatrixClient event timelines", function() { await testPagination(myThreads, Direction.Forward); }); - it("should allow fetching all threads", async function() { + it("should allow fetching all threads", async function () { const room = client.getRoom(roomId)!; const timelineSets = await room!.createThreadsTimelineSets(); expect(timelineSets).not.toBeNull(); @@ -1165,7 +1225,7 @@ describe("MatrixClient event timelines", function() { await flushHttp(room.fetchRoomThreads()); }); - it("should prevent displaying pending events", async function() { + it("should prevent displaying pending events", async function () { const room = new Room("room123", client, "john", { pendingEventOrdering: PendingEventOrdering.Detached, }); @@ -1173,7 +1233,10 @@ describe("MatrixClient event timelines", function() { expect(timelineSets).not.toBeNull(); const event = utils.mkMessage({ - room: roomId, user: userId, msg: "a body", event: true, + room: roomId, + user: userId, + msg: "a body", + event: true, }); event.status = EventStatus.SENDING; room.addPendingEvent(event, "txn"); @@ -1183,9 +1246,140 @@ describe("MatrixClient event timelines", function() { expect(myThreads.getPendingEvents()).toHaveLength(0); expect(room.getPendingEvents()).toHaveLength(1); }); + + it("should handle thread updates by reordering the thread list", async () => { + // Test data for a second thread + const THREAD2_ROOT = utils.mkEvent({ + room: roomId, + user: userId, + type: "m.room.message", + content: { + body: "thread root", + msgtype: "m.text", + }, + unsigned: { + "m.relations": { + "io.element.thread": { + //"latest_event": undefined, + count: 1, + current_user_participated: true, + }, + }, + }, + event: false, + }); + + const THREAD2_REPLY = utils.mkEvent({ + room: roomId, + user: userId, + type: "m.room.message", + content: { + "body": "thread reply", + "msgtype": "m.text", + "m.relates_to": { + // We can't use the const here because we change server support mode for test + rel_type: "io.element.thread", + event_id: THREAD_ROOT.event_id, + }, + }, + event: false, + }); + + // @ts-ignore we know this is a defined path for THREAD ROOT + THREAD2_ROOT.unsigned["m.relations"]["io.element.thread"].latest_event = THREAD2_REPLY; + + // Test data for a second reply to the first thread + const THREAD_REPLY2 = utils.mkEvent({ + room: roomId, + user: userId, + type: "m.room.message", + content: { + "body": "thread reply", + "msgtype": "m.text", + "m.relates_to": { + // We can't use the const here because we change server support mode for test + rel_type: "io.element.thread", + event_id: THREAD_ROOT.event_id, + }, + }, + event: true, + }); + THREAD_REPLY2.localTimestamp += 1000; + + // Test data for the first thread, with the second reply + const THREAD_ROOT_UPDATED = { + ...THREAD_ROOT, + unsigned: { + ...THREAD_ROOT.unsigned, + "m.relations": { + ...THREAD_ROOT.unsigned!["m.relations"], + "io.element.thread": { + ...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"], + count: 2, + latest_event: THREAD_REPLY2.event, + }, + }, + }, + }; + + // Response with test data for the thread list request + const threadsResponse = { + chunk: [THREAD2_ROOT, THREAD_ROOT], + state: [], + next_batch: RANDOM_TOKEN as string | null, + }; + + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Stable); + Thread.setServerSideListSupport(FeatureSupport.Stable); + Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable); + + await client.stopClient(); // we don't need the client to be syncing at this time + const room = client.getRoom(roomId)!; + + // Setup room threads + const timelineSets = await room!.createThreadsTimelineSets(); + expect(timelineSets).not.toBeNull(); + respondToThreads(threadsResponse); + respondToThreads(threadsResponse); + respondToEvent(THREAD_ROOT); + respondToEvent(THREAD2_ROOT); + respondToThread(THREAD_ROOT, [THREAD_REPLY]); + respondToThread(THREAD2_ROOT, [THREAD2_REPLY]); + await flushHttp(room.fetchRoomThreads()); + const threadIds = room.getThreads().map((thread) => thread.id); + expect(threadIds).toContain(THREAD_ROOT.event_id); + expect(threadIds).toContain(THREAD2_ROOT.event_id); + const [allThreads] = timelineSets!; + const timeline = allThreads.getLiveTimeline()!; + // Test threads are in chronological order + expect(timeline.getEvents().map((it) => it.event.event_id)).toEqual([ + THREAD_ROOT.event_id, + THREAD2_ROOT.event_id, + ]); + + // Test adding a second event to the first thread + const thread = room.getThread(THREAD_ROOT.event_id!)!; + thread.initialEventsFetched = true; + const prom = emitPromise(room, ThreadEvent.NewReply); + respondToEvent(THREAD_ROOT_UPDATED); + respondToEvent(THREAD_ROOT_UPDATED); + respondToEvent(THREAD_ROOT_UPDATED); + respondToEvent(THREAD2_ROOT); + room.addLiveEvents([THREAD_REPLY2]); + await httpBackend.flushAllExpected(); + await prom; + expect(thread.length).toBe(2); + // Test threads are in chronological order + expect(timeline!.getEvents().map((it) => it.event.event_id)).toEqual([ + THREAD2_ROOT.event_id, + THREAD_ROOT.event_id, + ]); + }); }); - describe("without server compatibility", function() { + describe("without server compatibility", function () { beforeEach(() => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; @@ -1203,16 +1397,18 @@ describe("MatrixClient event timelines", function() { expect(timeline).not.toBeNull(); respondToMessagesRequest(); - const success = await flushHttp(client.paginateEventTimeline(timeline!, { - backwards: direction === Direction.Backward, - })); + 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!.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() { + it("should allow you to paginate all threads", async function () { const room = client.getRoom(roomId); respondToFilter(); @@ -1230,7 +1426,7 @@ describe("MatrixClient event timelines", function() { await testPagination(myThreads, Direction.Backward); }); - it("should allow fetching all threads", async function() { + it("should allow fetching all threads", async function () { const room = client.getRoom(roomId)!; respondToFilter(); @@ -1264,14 +1460,18 @@ describe("MatrixClient event timelines", function() { const [allThreads] = timelineSets!; respondToThreads().check((request) => { - expect(request.queryParams?.filter).toEqual(JSON.stringify({ - "lazy_load_members": true, - })); + expect(request.queryParams?.filter).toEqual( + JSON.stringify({ + lazy_load_members: true, + }), + ); }); - await flushHttp(client.paginateEventTimeline(allThreads.getLiveTimeline(), { - backwards: true, - })); + await flushHttp( + client.paginateEventTimeline(allThreads.getLiveTimeline(), { + backwards: true, + }), + ); }); it("should correctly pass pagination token", async () => { @@ -1294,35 +1494,36 @@ describe("MatrixClient event timelines", function() { }); allThreads.getLiveTimeline().setPaginationToken(RANDOM_TOKEN, Direction.Backward); - await flushHttp(client.paginateEventTimeline(allThreads.getLiveTimeline(), { - backwards: true, - })); + await flushHttp( + client.paginateEventTimeline(allThreads.getLiveTimeline(), { + backwards: true, + }), + ); }); }); - describe("event timeline for sent events", function() { + describe("event timeline for sent events", function () { const TXN_ID = "txn1"; const event = utils.mkMessage({ - room: roomId, user: userId, msg: "a body", + room: roomId, + user: userId, + msg: "a body", }); event.unsigned = { transaction_id: TXN_ID }; - beforeEach(function() { + beforeEach(function () { // set up handlers for both the message send, and the // /sync - httpBackend.when("PUT", "/send/m.room.message/" + TXN_ID) - .respond(200, { - event_id: event.event_id, - }); + httpBackend.when("PUT", "/send/m.room.message/" + TXN_ID).respond(200, { + event_id: event.event_id, + }); httpBackend.when("GET", "/sync").respond(200, { next_batch: "s_5_4", rooms: { join: { "!foo:bar": { timeline: { - events: [ - withoutRoomId(event), - ], + events: [withoutRoomId(event)], prev_batch: "f_1_1", }, }, @@ -1331,75 +1532,80 @@ describe("MatrixClient event timelines", function() { }); }); - it("should work when /send returns before /sync", function() { + it("should work when /send returns before /sync", function () { 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!); - }).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"); - - // now let the sync complete, and check it again - return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), - ]); - }).then(function() { - return client.getEventTimeline(timelineSet, event.event_id!); - }).then(function(tl) { - expect(tl!.getEvents().length).toEqual(2); - expect(tl!.getEvents()[1].event).toEqual(event); - }), + 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!); + }) + .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"); + + // now let the sync complete, and check it again + return Promise.all([httpBackend.flush("/sync", 1), utils.syncPromise(client)]); + }) + .then(function () { + return client.getEventTimeline(timelineSet, event.event_id!); + }) + .then(function (tl) { + expect(tl!.getEvents().length).toEqual(2); + expect(tl!.getEvents()[1].event).toEqual(event); + }), httpBackend.flush("/send/m.room.message/" + TXN_ID, 1), ]); }); - it("should work when /send returns after /sync", function() { + it("should work when /send returns after /sync", function () { const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; return Promise.all([ // initiate the send, and set up checks to be done when it completes // - 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!); - }).then(function(tl) { - logger.log("getEventTimeline completed (2)"); - 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!); - }).then(function(tl) { - logger.log("getEventTimeline completed (1)"); - 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); - }), + 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!); + }) + .then(function (tl) { + logger.log("getEventTimeline completed (2)"); + 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!); + }) + .then(function (tl) { + logger.log("getEventTimeline completed (1)"); + 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); + }), ]); }); }); - it("should handle gappy syncs after redactions", function() { + it("should handle gappy syncs after redactions", function () { // https://github.com/vector-im/vector-web/issues/1389 // a state event, followed by a redaction thereof const event = utils.mkMembership({ - mship: "join", user: otherUserId, + mship: "join", + user: otherUserId, }); const redaction = utils.mkEvent({ type: "m.room.redaction", @@ -1411,57 +1617,53 @@ describe("MatrixClient event timelines", function() { const syncData = { next_batch: "batch1", rooms: { - join: {}, - }, - }; - syncData.rooms.join[roomId] = { - timeline: { - events: [ - event, - redaction, - ], - limited: false, + join: { + [roomId]: { + timeline: { + events: [event, redaction], + limited: false, + }, + }, + }, }, }; httpBackend.when("GET", "/sync").respond(200, syncData); - return Promise.all([ - 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 sync2 = { - next_batch: "batch2", - rooms: { - join: {}, - }, - }; - sync2.rooms.join[roomId] = { - timeline: { - events: [ - utils.mkMessage({ - user: otherUserId, msg: "world", - }), - ], - limited: true, - prev_batch: "newerTok", - }, - }; - httpBackend.when("GET", "/sync").respond(200, sync2); + return Promise.all([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 sync2 = { + next_batch: "batch2", + rooms: { + join: { + [roomId]: { + timeline: { + events: [ + utils.mkMessage({ + user: otherUserId, + msg: "world", + }), + ], + limited: true, + prev_batch: "newerTok", + }, + }, + }, + }, + }; + httpBackend.when("GET", "/sync").respond(200, sync2); - return Promise.all([ - httpBackend.flushAllExpected(), - utils.syncPromise(client), - ]); - }).then(function() { - const room = client.getRoom(roomId)!; - const tl = room.getLiveTimeline()!; - expect(tl.getEvents().length).toEqual(1); - }); + return Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]); + }) + .then(function () { + const room = client.getRoom(roomId)!; + const tl = room.getLiveTimeline()!; + expect(tl.getEvents().length).toEqual(1); + }); }); describe("should re-insert room IDs for bundled thread relation events", () => { @@ -1472,56 +1674,110 @@ describe("MatrixClient event timelines", function() { join: { [roomId]: { timeline: { - events: [ - SYNC_THREAD_ROOT, - ], + events: [SYNC_THREAD_ROOT], prev_batch: "f_1_1", }, }, }, }, }); + httpBackend + .when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!)) + .respond(200, function () { + return THREAD_ROOT; + }); + httpBackend + .when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!)) + .respond(200, function () { + return THREAD_ROOT; + }); + httpBackend + .when( + "GET", + "/_matrix/client/v1/rooms/!foo%3Abar/relations/" + + encodeURIComponent(THREAD_ROOT.event_id!) + + "/" + + encodeURIComponent(THREAD_RELATION_TYPE.name) + + buildRelationPaginationQuery({ dir: Direction.Backward, limit: 1 }), + ) + .respond(200, function () { + return { + chunk: [THREAD_REPLY], + }; + }); await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]); const room = client.getRoom(roomId)!; const thread = room.getThread(THREAD_ROOT.event_id!)!; + expect(thread.initialEventsFetched).toBeTruthy(); const timelineSet = thread.timelineSet; - const buildParams = (direction: Direction, token: string): string => { - if (Thread.hasServerSideFwdPaginationSupport === FeatureSupport.Experimental) { - return `?from=${token}&org.matrix.msc3715.dir=${direction}`; - } else { - return `?dir=${direction}&from=${token}`; - } - }; - - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!)) - .respond(200, { - start: "start_token", - events_before: [], - event: THREAD_ROOT, - events_after: [], - state: [], - end: "end_token", + 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_RELATION_TYPE.name) + buildParams(Direction.Backward, "start_token")) - .respond(200, function() { + httpBackend + .when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!)) + .respond(200, function () { + return THREAD_ROOT; + }); + httpBackend + .when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!)) + .respond(200, function () { + return THREAD_ROOT; + }); + httpBackend + .when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!)) + .respond(200, function () { + return THREAD_ROOT; + }); + httpBackend + .when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!)) + .respond(200, function () { + return THREAD_ROOT; + }); + httpBackend + .when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!)) + .respond(200, function () { + return { + start: "start_token", + events_before: [], + event: THREAD_ROOT, + events_after: [], + end: "end_token", + state: [], + }; + }); + httpBackend + .when( + "GET", + "/_matrix/client/v1/rooms/!foo%3Abar/relations/" + + encodeURIComponent(THREAD_ROOT.event_id!) + + "/" + + encodeURIComponent(THREAD_RELATION_TYPE.name) + + buildRelationPaginationQuery({ dir: Direction.Backward, from: "start_token" }), + ) + .respond(200, function () { return { - original_event: THREAD_ROOT, chunk: [], }; }); - httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + - encodeURIComponent(THREAD_ROOT.event_id!) + "/" + - encodeURIComponent(THREAD_RELATION_TYPE.name) + buildParams(Direction.Forward, "end_token")) - .respond(200, function() { + httpBackend + .when( + "GET", + "/_matrix/client/v1/rooms/!foo%3Abar/relations/" + + encodeURIComponent(THREAD_ROOT.event_id!) + + "/" + + encodeURIComponent(THREAD_RELATION_TYPE.name) + + buildRelationPaginationQuery({ dir: Direction.Forward, from: "end_token" }), + ) + .respond(200, function () { return { - original_event: THREAD_ROOT, chunk: [THREAD_REPLY], }; }); + const timeline = await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!)); httpBackend.when("GET", "/sync").respond(200, { @@ -1530,9 +1786,7 @@ describe("MatrixClient event timelines", function() { join: { [roomId]: { timeline: { - events: [ - SYNC_THREAD_REPLY, - ], + events: [SYNC_THREAD_REPLY], prev_batch: "f_1_2", }, }, diff --git a/spec/integ/matrix-client-methods.spec.ts b/spec/integ/matrix-client-methods.spec.ts index 5508ceaf031..c83ada2d597 100644 --- a/spec/integ/matrix-client-methods.spec.ts +++ b/spec/integ/matrix-client-methods.spec.ts @@ -25,7 +25,7 @@ import { IFilterDefinition } from "../../src/filter"; import { ISearchResults } from "../../src/@types/search"; import { IStore } from "../../src/store"; -describe("MatrixClient", function() { +describe("MatrixClient", function () { const userId = "@alice:localhost"; const accessToken = "aseukfgwef"; const idServerDomain = "identity.localhost"; // not a real server @@ -35,9 +35,9 @@ describe("MatrixClient", function() { let store: MemoryStore | undefined; const defaultClientOpts: IStoredClientOpts = { - canResetEntireTimeline: roomId => false, + canResetEntireTimeline: (roomId) => false, experimentalThreadSupport: false, - crypto: {} as unknown as IStoredClientOpts['crypto'], + crypto: {} as unknown as IStoredClientOpts["crypto"], }; const setupTests = (): [MatrixClient, HttpBackend, MemoryStore] => { const store = new MemoryStore(); @@ -53,36 +53,37 @@ describe("MatrixClient", function() { return [testClient.client, testClient.httpBackend, store]; }; - beforeEach(function() { + beforeEach(function () { [client, httpBackend, store] = setupTests(); }); - afterEach(function() { + afterEach(function () { httpBackend!.verifyNoOutstandingExpectation(); return httpBackend!.stop(); }); - describe("uploadContent", function() { - const buf = Buffer.from('hello world'); + describe("uploadContent", function () { + const buf = Buffer.from("hello world"); const file = buf; const opts = { type: "text/plain", name: "hi.txt", }; - it("should upload the file", function() { - httpBackend!.when( - "POST", "/_matrix/media/r0/upload", - ).check(function(req) { - expect(req.rawData).toEqual(buf); - expect(req.queryParams?.filename).toEqual("hi.txt"); - expect(req.headers["Authorization"]).toBe("Bearer " + accessToken); - 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_uri": "content"}', true); + it("should upload the file", function () { + httpBackend! + .when("POST", "/_matrix/media/r0/upload") + .check(function (req) { + expect(req.rawData).toEqual(buf); + expect(req.queryParams?.filename).toEqual("hi.txt"); + expect(req.headers["Authorization"]).toBe("Bearer " + accessToken); + 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_uri": "content"}', true); const prom = client!.uploadContent(file, opts); @@ -93,38 +94,42 @@ describe("MatrixClient", function() { expect(uploads[0].promise).toBe(prom); expect(uploads[0].loaded).toEqual(0); - const prom2 = prom.then(function(response) { + const prom2 = prom.then(function (response) { expect(response.content_uri).toEqual("content"); const uploads = client!.getCurrentUploads(); expect(uploads.length).toEqual(0); }); - httpBackend!.flush(''); + httpBackend!.flush(""); return prom2; }); - it("should parse errors into a MatrixError", function() { - 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", - }); + it("should parse errors into a MatrixError", function () { + 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(file, opts).then(function(response) { - throw Error("request not failed"); - }, function(error) { - expect(error.httpStatus).toEqual(400); - expect(error.errcode).toEqual("M_SNAFU"); - expect(error.message).toEqual("MatrixError: [400] broken"); - }); + const prom = client!.uploadContent(file, opts).then( + function (response) { + throw Error("request not failed"); + }, + function (error) { + expect(error.httpStatus).toEqual(400); + expect(error.errcode).toEqual("M_SNAFU"); + expect(error.message).toEqual("MatrixError: [400] broken"); + }, + ); - httpBackend!.flush(''); + httpBackend!.flush(""); return prom; }); @@ -143,17 +148,21 @@ describe("MatrixClient", function() { }); }); - describe("joinRoom", function() { - it("should no-op if you've already joined a room", 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({ - type: 'test', - content: {}, - }); + client!.fetchRoomEvent = () => + Promise.resolve({ + type: "test", + content: {}, + }); room.addLiveEvents([ utils.mkMembership({ - user: userId, room: roomId, mship: "join", event: true, + user: userId, + room: roomId, + mship: "join", + event: true, }), ]); httpBackend!.verifyNoOutstandingRequests(); @@ -173,12 +182,18 @@ describe("MatrixClient", function() { signatures: {}, }; - httpBackend!.when("POST", inviteSignUrl).check(request => { - expect(request.queryParams?.mxid).toEqual(client!.getUserId()); - }).respond(200, signature); - httpBackend!.when("POST", "/join/" + encodeURIComponent(roomId)).check(request => { - expect(request.data.third_party_signed).toEqual(signature); - }).respond(200, { room_id: roomId }); + httpBackend! + .when("POST", inviteSignUrl) + .check((request) => { + expect(request.queryParams?.mxid).toEqual(client!.getUserId()); + }) + .respond(200, signature); + httpBackend! + .when("POST", "/join/" + encodeURIComponent(roomId)) + .check((request) => { + expect(request.data.third_party_signed).toEqual(signature); + }) + .respond(200, { room_id: roomId }); const prom = client!.joinRoom(roomId, { inviteSignUrl, @@ -189,110 +204,111 @@ describe("MatrixClient", function() { }); }); - describe("getFilter", function() { + describe("getFilter", function () { const filterId = "f1lt3r1d"; - it("should return a filter from the store if allowCached", function(done) { + it("should return a filter from the store if allowCached", function (done) { const filter = Filter.fromJson(userId, filterId, { event_format: "client", }); store!.storeFilter(filter); - client!.getFilter(userId, filterId, true).then(function(gotFilter) { + client!.getFilter(userId, filterId, true).then(function (gotFilter) { expect(gotFilter).toEqual(filter); done(); }); httpBackend!.verifyNoOutstandingRequests(); }); - it("should do an HTTP request if !allowCached even if one exists", - function(done) { - const httpFilterDefinition = { - event_format: "federation", - }; - - httpBackend!.when( - "GET", "/user/" + encodeURIComponent(userId) + "/filter/" + filterId, - ).respond(200, httpFilterDefinition); + it("should do an HTTP request if !allowCached even if one exists", function (done) { + const httpFilterDefinition = { + event_format: "federation", + }; - 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! + .when("GET", "/user/" + encodeURIComponent(userId) + "/filter/" + filterId) + .respond(200, httpFilterDefinition); - httpBackend!.flush(''); + 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(); }); - 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); - - 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(""); + }); + + 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); - httpBackend!.flush(''); + 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(""); + }); }); - describe("createFilter", function() { + describe("createFilter", function () { const filterId = "f1llllllerid"; - it("should do an HTTP request and then store the filter", function(done) { + it("should do an HTTP request and then store the filter", function (done) { expect(store!.getFilter(userId, filterId)).toBe(null); const filterDefinition = { - event_format: "client" as IFilterDefinition['event_format'], + event_format: "client" as IFilterDefinition["event_format"], }; - httpBackend!.when( - "POST", "/user/" + encodeURIComponent(userId) + "/filter", - ).check(function(req) { - expect(req.data).toEqual(filterDefinition); - }).respond(200, { - filter_id: filterId, - }); + httpBackend! + .when("POST", "/user/" + encodeURIComponent(userId) + "/filter") + .check(function (req) { + expect(req.data).toEqual(filterDefinition); + }) + .respond(200, { + 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); done(); }); - httpBackend!.flush(''); + httpBackend!.flush(""); }); }); - describe("searching", function() { - it("searchMessageText should perform a /search for room_events", function() { + describe("searching", function () { + it("searchMessageText should perform a /search for room_events", function () { const response = { search_categories: { room_events: { count: 24, - results: [{ - rank: 0.1, - result: { - event_id: "$flibble:localhost", - type: "m.room.message", - user_id: "@alice:localhost", - room_id: "!feuiwhf:localhost", - content: { - body: "a result", - msgtype: "m.text", + results: [ + { + rank: 0.1, + result: { + event_id: "$flibble:localhost", + type: "m.room.message", + user_id: "@alice:localhost", + room_id: "!feuiwhf:localhost", + content: { + body: "a result", + msgtype: "m.text", + }, }, }, - }], + ], }, }, }; @@ -300,17 +316,20 @@ describe("MatrixClient", function() { client!.searchMessageText({ query: "monkeys", }); - httpBackend!.when("POST", "/search").check(function(req) { - expect(req.data).toEqual({ - search_categories: { - room_events: { - search_term: "monkeys", + httpBackend! + .when("POST", "/search") + .check(function (req) { + expect(req.data).toEqual({ + search_categories: { + room_events: { + search_term: "monkeys", + }, }, - }, - }); - }).respond(200, response); + }); + }) + .respond(200, response); - return httpBackend!.flush(''); + return httpBackend!.flush(""); }); describe("should filter out context from different timelines (threads)", () => { @@ -320,52 +339,58 @@ describe("MatrixClient", function() { 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: { - body: "main timeline", - msgtype: "m.text", - }, - }, - 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: { - "body": "thread reply", - "msgtype": "m.text", - "m.relates_to": { - "event_id": "$some-thread:server", - "rel_type": THREAD_RELATION_TYPE.name, - }, - }, - }], - events_before: [{ - event_id: "$ev-before:server", + results: [ + { + rank: 0.1, + result: { + event_id: "$flibble:localhost", type: "m.room.message", - sender: '@test:locahost', + sender: "@test:locahost", origin_server_ts: 123, user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", content: { - body: "main timeline again", + body: "main timeline", msgtype: "m.text", }, - }], + }, + 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: { + "body": "thread reply", + "msgtype": "m.text", + "m.relates_to": { + event_id: "$some-thread:server", + rel_type: THREAD_RELATION_TYPE.name, + }, + }, + }, + ], + 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: { + body: "main timeline again", + msgtype: "m.text", + }, + }, + ], + }, }, - }], + ], }, }, }; @@ -378,9 +403,7 @@ describe("MatrixClient", function() { expect(data.results).toHaveLength(1); expect(data.results[0].context.getTimeline()).toHaveLength(2); - expect( - data.results[0].context.getTimeline().find(e => e.getId() === "$ev-after:server"), - ).toBeFalsy(); + 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", () => { @@ -389,45 +412,49 @@ describe("MatrixClient", function() { 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: { - "body": "thread 1 reply 1", - "msgtype": "m.text", - "m.relates_to": { - "event_id": "$thread1:server", - "rel_type": THREAD_RELATION_TYPE.name, - }, - }, - }, - context: { - profile_info: {}, - events_after: [{ - event_id: "$ev-after:server", + results: [ + { + rank: 0.1, + result: { + event_id: "$flibble:localhost", type: "m.room.message", - sender: '@test:locahost', + sender: "@test:locahost", origin_server_ts: 123, user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", content: { - "body": "thread 2 reply 2", + "body": "thread 1 reply 1", "msgtype": "m.text", "m.relates_to": { - "event_id": "$thread2:server", - "rel_type": THREAD_RELATION_TYPE.name, + event_id: "$thread1:server", + rel_type: THREAD_RELATION_TYPE.name, }, }, - }], - events_before: [], + }, + 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: { + "body": "thread 2 reply 2", + "msgtype": "m.text", + "m.relates_to": { + event_id: "$thread2:server", + rel_type: THREAD_RELATION_TYPE.name, + }, + }, + }, + ], + events_before: [], + }, }, - }], + ], }, }, }; @@ -441,7 +468,7 @@ describe("MatrixClient", function() { expect(data.results).toHaveLength(1); expect(data.results[0].context.getTimeline()).toHaveLength(1); expect( - data.results[0].context.getTimeline().find(e => e.getId() === "$flibble:localhost"), + data.results[0].context.getTimeline().find((e) => e.getId() === "$flibble:localhost"), ).toBeTruthy(); }); @@ -451,41 +478,45 @@ describe("MatrixClient", function() { 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", - content: { - "body": "thread 1 reply 1", - "msgtype": "m.text", - "m.relates_to": { - "event_id": "$thread1:server", - "rel_type": THREAD_RELATION_TYPE.name, - }, - }, - }, - context: { - events_after: [{ - event_id: "$ev-after:server", - sender: '@test:locahost', + 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", content: { - "body": "main timeline", + "body": "thread 1 reply 1", "msgtype": "m.text", + "m.relates_to": { + event_id: "$thread1:server", + rel_type: THREAD_RELATION_TYPE.name, + }, }, - }], - events_before: [], - profile_info: {}, + }, + 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", + content: { + body: "main timeline", + msgtype: "m.text", + }, + }, + ], + events_before: [], + profile_info: {}, + }, }, - }], + ], }, }, }; @@ -499,26 +530,28 @@ describe("MatrixClient", function() { expect(data.results).toHaveLength(1); expect(data.results[0].context.getTimeline()).toHaveLength(1); expect( - data.results[0].context.getTimeline().find(e => e.getId() === "$flibble:localhost"), + data.results[0].context.getTimeline().find((e) => e.getId() === "$flibble:localhost"), ).toBeTruthy(); }); }); }); - describe("downloadKeys", function() { + describe("downloadKeys", function () { if (!CRYPTO_ENABLED) { return; } - beforeEach(function() { - return client!.initCrypto(); + beforeEach(function () { + // running initCrypto should trigger a key upload + httpBackend!.when("POST", "/keys/upload").respond(200, {}); + return Promise.all([client!.initCrypto(), httpBackend!.flush("/keys/upload", 1)]); }); afterEach(() => { client!.stopClient(); }); - it("should do an HTTP request and then store the keys", function() { + it("should do an HTTP request and then store the keys", function () { const ed25519key = "7wG2lzAqbjcyEkOP7O4gU7ItYcn+chKzh5sT/5r2l78"; // ed25519key = client!.getDeviceEd25519Key(); const borisKeys = { @@ -533,7 +566,7 @@ describe("MatrixClient", function() { "JVwLdP032mg5QsKt/pBDUGtggBcnk43n3nBWlA88WAw", }, }, - unsigned: { "abc": "def" }, + unsigned: { abc: "def" }, user_id: "boris", }, }; @@ -549,7 +582,7 @@ describe("MatrixClient", function() { "EkrK9/XVN8jl4h7thGuRITQ01siBQnNmMK9t45QfcCQ", }, }, - unsigned: { "ghi": "def" }, + unsigned: { ghi: "def" }, user_id: "chaz", }, }; @@ -568,69 +601,75 @@ describe("MatrixClient", function() { logger.log("chaz:", sign(chazKeys.dev2)); */ - httpBackend!.when("POST", "/keys/query").check(function(req) { - expect(req.data).toEqual({ device_keys: { - 'boris': [], - 'chaz': [], - } }); - }).respond(200, { - device_keys: { - boris: borisKeys, - chaz: chazKeys, - }, - }); + httpBackend! + .when("POST", "/keys/query") + .check(function (req) { + expect(req.data).toEqual({ + device_keys: { + boris: [], + chaz: [], + }, + }); + }) + .respond(200, { + device_keys: { + boris: borisKeys, + chaz: chazKeys, + }, + }); - 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 }, algorithms: ["1"], - unsigned: { "abc": "def" }, + unsigned: { abc: "def" }, }); assertObjectContains(res.chaz.dev2, { verified: 0, // DeviceVerification.UNVERIFIED keys: { "ed25519:dev2": ed25519key }, algorithms: ["2"], - unsigned: { "ghi": "def" }, + unsigned: { ghi: "def" }, }); }); - httpBackend!.flush(''); + httpBackend!.flush(""); return prom; }); }); - describe("deleteDevice", function() { + describe("deleteDevice", function () { const auth = { identifier: 1 }; - it("should pass through an auth dict", function() { - httpBackend!.when( - "DELETE", "/_matrix/client/r0/devices/my_device", - ).check(function(req) { - expect(req.data).toEqual({ auth: auth }); - }).respond(200); + it("should pass through an auth dict", function () { + 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); - httpBackend!.flush(''); + httpBackend!.flush(""); return prom; }); }); - describe("partitionThreadedEvents", function() { - let room; + describe("partitionThreadedEvents", function () { + let room: Room; beforeEach(() => { room = new Room("!STrMRsukXHtqQdSeHa:matrix.org", client!, userId); }); - it("returns empty arrays when given an empty arrays", function() { - const events = []; + it("returns empty arrays when given an empty arrays", function () { + const events: MatrixEvent[] = []; const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([]); expect(threaded).toEqual([]); }); - it("copies pre-thread in-timeline vote events onto both timelines", function() { + it("copies pre-thread in-timeline vote events onto both timelines", function () { // @ts-ignore setting private property client!.clientOpts = { ...defaultClientOpts, @@ -641,11 +680,7 @@ describe("MatrixClient", function() { const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); - const events = [ - eventPollStartThreadRoot, - eventMessageInThread, - eventPollResponseReference, - ]; + const events = [eventPollStartThreadRoot, eventMessageInThread, eventPollResponseReference]; // Vote has no threadId yet // @ts-ignore private property expect(eventPollResponseReference.threadId).toBeFalsy(); @@ -659,18 +694,13 @@ describe("MatrixClient", function() { ]); // The vote event has been copied into the thread - const eventRefWithThreadId = withThreadId( - eventPollResponseReference, eventPollStartThreadRoot.getId()!); + const eventRefWithThreadId = withThreadId(eventPollResponseReference, eventPollStartThreadRoot.getId()!); expect(eventRefWithThreadId.threadRootId).toBeTruthy(); - expect(threaded).toEqual([ - eventPollStartThreadRoot, - eventMessageInThread, - eventRefWithThreadId, - ]); + expect(threaded).toEqual([eventPollStartThreadRoot, eventMessageInThread, eventRefWithThreadId]); }); - it("copies pre-thread in-timeline reactions onto both timelines", function() { + it("copies pre-thread in-timeline reactions onto both timelines", function () { // @ts-ignore setting private property client!.clientOpts = { ...defaultClientOpts, @@ -681,18 +711,11 @@ describe("MatrixClient", function() { const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); const eventReaction = buildEventReaction(eventPollStartThreadRoot); - const events = [ - eventPollStartThreadRoot, - eventMessageInThread, - eventReaction, - ]; + const events = [eventPollStartThreadRoot, eventMessageInThread, eventReaction]; const [timeline, threaded] = room.partitionThreadedEvents(events); - expect(timeline).toEqual([ - eventPollStartThreadRoot, - eventReaction, - ]); + expect(timeline).toEqual([eventPollStartThreadRoot, eventReaction]); expect(threaded).toEqual([ eventPollStartThreadRoot, @@ -701,7 +724,7 @@ describe("MatrixClient", function() { ]); }); - it("copies post-thread in-timeline vote events onto both timelines", function() { + it("copies post-thread in-timeline vote events onto both timelines", function () { // @ts-ignore setting private property client!.clientOpts = { ...defaultClientOpts, @@ -712,18 +735,11 @@ describe("MatrixClient", function() { const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); - const events = [ - eventPollStartThreadRoot, - eventPollResponseReference, - eventMessageInThread, - ]; + const events = [eventPollStartThreadRoot, eventPollResponseReference, eventMessageInThread]; const [timeline, threaded] = room.partitionThreadedEvents(events); - expect(timeline).toEqual([ - eventPollStartThreadRoot, - eventPollResponseReference, - ]); + expect(timeline).toEqual([eventPollStartThreadRoot, eventPollResponseReference]); expect(threaded).toEqual([ eventPollStartThreadRoot, @@ -732,7 +748,7 @@ describe("MatrixClient", function() { ]); }); - it("copies post-thread in-timeline reactions onto both timelines", function() { + it("copies post-thread in-timeline reactions onto both timelines", function () { // @ts-ignore setting private property client!.clientOpts = { ...defaultClientOpts, @@ -743,18 +759,11 @@ describe("MatrixClient", function() { const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); const eventReaction = buildEventReaction(eventPollStartThreadRoot); - const events = [ - eventPollStartThreadRoot, - eventMessageInThread, - eventReaction, - ]; + const events = [eventPollStartThreadRoot, eventMessageInThread, eventReaction]; const [timeline, threaded] = room.partitionThreadedEvents(events); - expect(timeline).toEqual([ - eventPollStartThreadRoot, - eventReaction, - ]); + expect(timeline).toEqual([eventPollStartThreadRoot, eventReaction]); expect(threaded).toEqual([ eventPollStartThreadRoot, @@ -763,7 +772,7 @@ describe("MatrixClient", function() { ]); }); - it("sends room state events to the main timeline only", function() { + it("sends room state events to the main timeline only", function () { // @ts-ignore setting private property client!.clientOpts = { ...defaultClientOpts, @@ -832,18 +841,11 @@ describe("MatrixClient", function() { const threadedReaction = buildEventReaction(eventMessageInThread); const threadedReactionRedaction = buildEventRedaction(threadedReaction); - const events = [ - threadRootEvent, - eventMessageInThread, - threadedReaction, - threadedReactionRedaction, - ]; + const events = [threadRootEvent, eventMessageInThread, threadedReaction, threadedReactionRedaction]; const [timeline, threaded] = room.partitionThreadedEvents(events); - expect(timeline).toEqual([ - threadRootEvent, - ]); + expect(timeline).toEqual([threadRootEvent]); expect(threaded).toEqual([ threadRootEvent, @@ -865,25 +867,13 @@ describe("MatrixClient", function() { const directReplyToThreadRoot = buildEventReply(threadRootEvent); const replyToReply = buildEventReply(directReplyToThreadRoot); - const events = [ - threadRootEvent, - eventMessageInThread, - directReplyToThreadRoot, - replyToReply, - ]; + const events = [threadRootEvent, eventMessageInThread, directReplyToThreadRoot, replyToReply]; const [timeline, threaded] = room.partitionThreadedEvents(events); - expect(timeline).toEqual([ - threadRootEvent, - directReplyToThreadRoot, - replyToReply, - ]); + expect(timeline).toEqual([threadRootEvent, directReplyToThreadRoot, replyToReply]); - expect(threaded).toEqual([ - threadRootEvent, - eventMessageInThread, - ]); + expect(threaded).toEqual([threadRootEvent, eventMessageInThread]); }); it("sends reply to thread responses to main timeline only", () => { @@ -897,52 +887,46 @@ describe("MatrixClient", function() { const eventMessageInThread = buildEventMessageInThread(threadRootEvent); const replyToThreadResponse = buildEventReply(eventMessageInThread); - const events = [ - threadRootEvent, - eventMessageInThread, - replyToThreadResponse, - ]; + const events = [threadRootEvent, eventMessageInThread, replyToThreadResponse]; const [timeline, threaded] = room.partitionThreadedEvents(events); - expect(timeline).toEqual([ - threadRootEvent, - replyToThreadResponse, - ]); + expect(timeline).toEqual([threadRootEvent, replyToThreadResponse]); - expect(threaded).toEqual([ - threadRootEvent, - eventMessageInThread, - ]); + expect(threaded).toEqual([threadRootEvent, eventMessageInThread]); }); }); describe("getThirdpartyUser", () => { it("should hit the expected API endpoint", async () => { - const response = [{ - userid: "@Bob", - protocol: "irc", - fields: {}, - }]; + const response = [ + { + userid: "@Bob", + protocol: "irc", + fields: {}, + }, + ]; const prom = client!.getThirdpartyUser("irc", {}); httpBackend!.when("GET", "/thirdparty/user/irc").respond(200, response); - await httpBackend!.flush(''); + await httpBackend!.flush(""); expect(await prom).toStrictEqual(response); }); }); describe("getThirdpartyLocation", () => { it("should hit the expected API endpoint", async () => { - const response = [{ - alias: "#alias", - protocol: "irc", - fields: {}, - }]; + const response = [ + { + alias: "#alias", + protocol: "irc", + fields: {}, + }, + ]; const prom = client!.getThirdpartyLocation("irc", {}); httpBackend!.when("GET", "/thirdparty/location/irc").respond(200, response); - await httpBackend!.flush(''); + await httpBackend!.flush(""); expect(await prom).toStrictEqual(response); }); }); @@ -956,7 +940,7 @@ describe("MatrixClient", function() { const prom = client!.getPushers(); httpBackend!.when("GET", "/_matrix/client/versions").respond(200, {}); httpBackend!.when("GET", "/pushers").respond(200, response); - await httpBackend!.flush(''); + await httpBackend!.flush(""); expect(await prom).toStrictEqual(response); }); }); @@ -969,11 +953,14 @@ describe("MatrixClient", function() { }; 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(''); + 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(""); expect(await prom).toStrictEqual(response); }); }); @@ -986,7 +973,7 @@ describe("MatrixClient", function() { const prom = client!.getDevices(); httpBackend!.when("GET", "/devices").respond(200, response); - await httpBackend!.flush(''); + await httpBackend!.flush(""); expect(await prom).toStrictEqual(response); }); }); @@ -1002,7 +989,7 @@ describe("MatrixClient", function() { const prom = client!.getDevice("DEADBEEF"); httpBackend!.when("GET", "/devices/DEADBEEF").respond(200, response); - await httpBackend!.flush(''); + await httpBackend!.flush(""); expect(await prom).toStrictEqual(response); }); }); @@ -1015,7 +1002,7 @@ describe("MatrixClient", function() { const prom = client!.getThreePids(); httpBackend!.when("GET", "/account/3pid").respond(200, response); - await httpBackend!.flush(''); + await httpBackend!.flush(""); expect(await prom).toStrictEqual(response); }); }); @@ -1025,7 +1012,7 @@ describe("MatrixClient", function() { const response = {}; const prom = client!.deleteAlias("#foo:bar"); httpBackend!.when("DELETE", "/directory/room/" + encodeURIComponent("#foo:bar")).respond(200, response); - await httpBackend!.flush(''); + await httpBackend!.flush(""); expect(await prom).toStrictEqual(response); }); }); @@ -1036,7 +1023,7 @@ describe("MatrixClient", function() { 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(''); + await httpBackend!.flush(""); expect(await prom).toStrictEqual(response); }); }); @@ -1054,7 +1041,7 @@ describe("MatrixClient", function() { 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(''); + await httpBackend!.flush(""); expect(await prom).toStrictEqual(response); }); }); @@ -1071,14 +1058,17 @@ describe("MatrixClient", function() { }); 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(''); + 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(""); expect(await prom).toStrictEqual(response); }); }); @@ -1091,17 +1081,20 @@ describe("MatrixClient", function() { versions: ["r0.6.0"], }); - httpBackend!.when("POST", "/invite").check(req => { - expect(req.data).toStrictEqual({ - id_server: idServerDomain, - id_access_token: identityAccessToken, - medium: "email", - address: targetEmail, - }); - }).respond(200, {}); + httpBackend! + .when("POST", "/invite") + .check((req) => { + expect(req.data).toStrictEqual({ + id_server: idServerDomain, + id_access_token: identityAccessToken, + medium: "email", + address: targetEmail, + }); + }) + .respond(200, {}); const prom = client!.inviteByThreePid("!room:example.org", "email", targetEmail); - await httpBackend!.flush(''); + await httpBackend!.flush(""); await prom; // returns empty object, so no validation needed }); }); @@ -1113,30 +1106,37 @@ describe("MatrixClient", function() { room_id: "!room:localhost", }; const input = { - invite_3pid: [{ - // we intentionally exclude the access token here, so it can be populated for us - id_server: idServerDomain, - medium: "email", - address: targetEmail, - }], + invite_3pid: [ + { + // we intentionally exclude the access token here, so it can be populated for us + id_server: idServerDomain, + medium: "email", + address: targetEmail, + }, + ], }; httpBackend!.when("GET", "/_matrix/client/versions").respond(200, { versions: ["r0.6.0"], }); - httpBackend!.when("POST", "/createRoom").check(req => { - expect(req.data).toMatchObject({ - invite_3pid: expect.arrayContaining([{ - ...input.invite_3pid[0], - id_access_token: identityAccessToken, - }]), - }); - expect(req.data.invite_3pid.length).toBe(1); - }).respond(200, response); + httpBackend! + .when("POST", "/createRoom") + .check((req) => { + expect(req.data).toMatchObject({ + invite_3pid: expect.arrayContaining([ + { + ...input.invite_3pid[0], + id_access_token: identityAccessToken, + }, + ]), + }); + expect(req.data.invite_3pid.length).toBe(1); + }) + .respond(200, response); const prom = client!.createRoom(input); - await httpBackend!.flush(''); + await httpBackend!.flush(""); expect(await prom).toStrictEqual(response); }); }); @@ -1149,17 +1149,15 @@ describe("MatrixClient", function() { httpBackend! .when("POST", "/unstable/org.matrix.msc3882/login/token", { auth: uiaData }) .respond(200, response); - await httpBackend!.flush(''); + 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(''); + httpBackend!.when("POST", "/unstable/org.matrix.msc3882/login/token", {}).respond(200, response); + await httpBackend!.flush(""); expect(await prom).toStrictEqual(response); }); }); @@ -1177,15 +1175,18 @@ describe("MatrixClient", function() { describe("sendHtmlEmote", () => { it("should send valid html emote", async () => { - httpBackend!.when("PUT", "/send").check(req => { - expect(req.data).toStrictEqual({ - "msgtype": "m.emote", - "body": "Body", - "formatted_body": "

Body

", - "format": "org.matrix.custom.html", - "org.matrix.msc1767.message": expect.anything(), - }); - }).respond(200, { event_id: "$foobar" }); + httpBackend! + .when("PUT", "/send") + .check((req) => { + expect(req.data).toStrictEqual({ + "msgtype": "m.emote", + "body": "Body", + "formatted_body": "

Body

", + "format": "org.matrix.custom.html", + "org.matrix.msc1767.message": expect.anything(), + }); + }) + .respond(200, { event_id: "$foobar" }); const prom = client!.sendHtmlEmote("!room:server", "Body", "

Body

"); await httpBackend!.flush(undefined); await expect(prom).resolves.toStrictEqual({ event_id: "$foobar" }); @@ -1194,15 +1195,18 @@ describe("MatrixClient", function() { describe("sendHtmlMessage", () => { it("should send valid html message", async () => { - httpBackend!.when("PUT", "/send").check(req => { - expect(req.data).toStrictEqual({ - "msgtype": "m.text", - "body": "Body", - "formatted_body": "

Body

", - "format": "org.matrix.custom.html", - "org.matrix.msc1767.message": expect.anything(), - }); - }).respond(200, { event_id: "$foobar" }); + httpBackend! + .when("PUT", "/send") + .check((req) => { + expect(req.data).toStrictEqual({ + "msgtype": "m.text", + "body": "Body", + "formatted_body": "

Body

", + "format": "org.matrix.custom.html", + "org.matrix.msc1767.message": expect.anything(), + }); + }) + .respond(200, { event_id: "$foobar" }); const prom = client!.sendHtmlMessage("!room:server", "Body", "

Body

"); await httpBackend!.flush(undefined); await expect(prom).resolves.toStrictEqual({ event_id: "$foobar" }); @@ -1216,10 +1220,7 @@ describe("MatrixClient", function() { expect(client!.store.getRooms()).toContain(room); httpBackend!.when("POST", "/forget").respond(200, {}); - await Promise.all([ - client!.forget(room.roomId), - httpBackend!.flushAllExpected(), - ]); + await Promise.all([client!.forget(room.roomId), httpBackend!.flushAllExpected()]); expect(client!.store.getRooms()).not.toContain(room); }); }); @@ -1260,9 +1261,12 @@ describe("MatrixClient", function() { it("should send `user_accepts` via body of POST request", async () => { const terms = ["https://vector.im/notice-1"]; - httpBackend!.when("POST", "/terms").check(req => { - expect(req.data.user_accepts).toStrictEqual(terms); - }).respond(200, {}); + httpBackend! + .when("POST", "/terms") + .check((req) => { + expect(req.data.user_accepts).toStrictEqual(terms); + }) + .respond(200, {}); const prom = client!.agreeToTerms(SERVICE_TYPES.IS, "https://vector.im", "at", terms); await httpBackend!.flushAllExpected(); @@ -1278,17 +1282,23 @@ describe("MatrixClient", function() { }); it("should use GET request if only server is specified", () => { - httpBackend!.when("GET", "/publicRooms").check(request => { - expect(request.queryParams?.server).toBe("server1"); - }).respond(200, {}); + httpBackend! + .when("GET", "/publicRooms") + .check((request) => { + expect(request.queryParams?.server).toBe("server1"); + }) + .respond(200, {}); client!.publicRooms({ server: "server1" }); return httpBackend!.flushAllExpected(); }); it("should use POST request if filter is specified", () => { - httpBackend!.when("POST", "/publicRooms").check(request => { - expect(request.data.filter.generic_search_term).toBe("foobar"); - }).respond(200, {}); + httpBackend! + .when("POST", "/publicRooms") + .check((request) => { + expect(request.data.filter.generic_search_term).toBe("foobar"); + }) + .respond(200, {}); client!.publicRooms({ filter: { generic_search_term: "foobar" } }); return httpBackend!.flushAllExpected(); }); @@ -1322,12 +1332,15 @@ describe("MatrixClient", function() { expires_in: 12345, }; - httpBackend!.when("POST", "/account/register").check(req => { - expect(req.data).toStrictEqual(token); - }).respond(200, { - access_token: "at", - token: "tt", - }); + httpBackend! + .when("POST", "/account/register") + .check((req) => { + expect(req.data).toStrictEqual(token); + }) + .respond(200, { + access_token: "at", + token: "tt", + }); const prom = client!.registerWithIdentityServer(token); await httpBackend!.flushAllExpected(); @@ -1337,27 +1350,48 @@ describe("MatrixClient", function() { }); }); - describe("registerWithIdentityServer", () => { - it("should pass data to POST request", async () => { - const token = { - access_token: "access_token", - token_type: "Bearer", - matrix_server_name: "server_name", - expires_in: 12345, - }; + describe("setPowerLevel", () => { + it.each([ + { + userId: "alice@localhost", + expectation: { + "alice@localhost": 100, + }, + }, + { + userId: ["alice@localhost", "bob@localhost"], + expectation: { + "alice@localhost": 100, + "bob@localhost": 100, + }, + }, + ])("should modify power levels of $userId correctly", async ({ userId, expectation }) => { + const event = { + getType: () => "m.room.power_levels", + getContent: () => ({ + users: { + "alice@localhost": 50, + }, + }), + } as MatrixEvent; - httpBackend!.when("POST", "/account/register").check(req => { - expect(req.data).toStrictEqual(token); - }).respond(200, { - access_token: "at", - token: "tt", - }); + httpBackend! + .when("PUT", "/state/m.room.power_levels") + .check((req) => { + expect(req.data.users).toStrictEqual(expectation); + }) + .respond(200, {}); - const prom = client!.registerWithIdentityServer(token); + const prom = client!.setPowerLevel("!room_id:server", userId, 100, event); await httpBackend!.flushAllExpected(); - const resp = await prom; - expect(resp.access_token).toBe("at"); - expect(resp.token).toBe("tt"); + await prom; + }); + }); + + describe("uploadKeys", () => { + // uploadKeys() is a no-op nowadays, so there's not much to test here. + it("should complete successfully", async () => { + await client!.uploadKeys(); }); }); }); @@ -1368,273 +1402,285 @@ function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent { return ret; } -const buildEventMessageInThread = (root: MatrixEvent) => new MatrixEvent({ - "age": 80098509, - "content": { - "algorithm": "m.megolm.v1.aes-sha2", - "ciphertext": "ENCRYPTEDSTUFF", - "device_id": "XISFUZSKHH", - "m.relates_to": { - "event_id": root.getId(), - "m.in_reply_to": { - "event_id": root.getId()!, +const buildEventMessageInThread = (root: MatrixEvent) => + new MatrixEvent({ + age: 80098509, + content: { + "algorithm": "m.megolm.v1.aes-sha2", + "ciphertext": "ENCRYPTEDSTUFF", + "device_id": "XISFUZSKHH", + "m.relates_to": { + "event_id": root.getId(), + "m.in_reply_to": { + event_id: root.getId()!, + }, + "rel_type": "m.thread", }, - "rel_type": "m.thread", + "sender_key": "i3N3CtG/CD2bGB8rA9fW6adLYSDvlUhf2iuU73L65Vg", + "session_id": "Ja11R/KG6ua0wdk8zAzognrxjio1Gm/RK2Gn6lFL804", }, - "sender_key": "i3N3CtG/CD2bGB8rA9fW6adLYSDvlUhf2iuU73L65Vg", - "session_id": "Ja11R/KG6ua0wdk8zAzognrxjio1Gm/RK2Gn6lFL804", - }, - "event_id": "$W4chKIGYowtBblVLkRimeIg8TcdjETnxhDPGfi6NpDg", - "origin_server_ts": 1643815466378, - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", - "sender": "@andybalaam-test1:matrix.org", - "type": "m.room.encrypted", - "unsigned": { "age": 80098509 }, - "user_id": "@andybalaam-test1:matrix.org", -}); + event_id: "$W4chKIGYowtBblVLkRimeIg8TcdjETnxhDPGfi6NpDg", + origin_server_ts: 1643815466378, + room_id: "!STrMRsukXHtqQdSeHa:matrix.org", + sender: "@andybalaam-test1:matrix.org", + type: "m.room.encrypted", + unsigned: { age: 80098509 }, + user_id: "@andybalaam-test1:matrix.org", + }); -const buildEventPollResponseReference = () => new MatrixEvent({ - "age": 80098509, - "content": { - "algorithm": "m.megolm.v1.aes-sha2", - "ciphertext": "ENCRYPTEDSTUFF", - "device_id": "XISFUZSKHH", - "m.relates_to": { - "event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", - "rel_type": "m.reference", +const buildEventPollResponseReference = () => + new MatrixEvent({ + age: 80098509, + content: { + "algorithm": "m.megolm.v1.aes-sha2", + "ciphertext": "ENCRYPTEDSTUFF", + "device_id": "XISFUZSKHH", + "m.relates_to": { + event_id: "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", + rel_type: "m.reference", + }, + "sender_key": "i3N3CtG/CD2bGB8rA9fW6adLYSDvlUhf2iuU73L65Vg", + "session_id": "Ja11R/KG6ua0wdk8zAzognrxjio1Gm/RK2Gn6lFL804", }, - "sender_key": "i3N3CtG/CD2bGB8rA9fW6adLYSDvlUhf2iuU73L65Vg", - "session_id": "Ja11R/KG6ua0wdk8zAzognrxjio1Gm/RK2Gn6lFL804", - }, - "event_id": "$91JvpezvsF0cKgav3g8W-uEVS4WkDHgxbJZvL3uMR1g", - "origin_server_ts": 1643815458650, - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", - "sender": "@andybalaam-test1:matrix.org", - "type": "m.room.encrypted", - "unsigned": { "age": 80106237 }, - "user_id": "@andybalaam-test1:matrix.org", -}); + event_id: "$91JvpezvsF0cKgav3g8W-uEVS4WkDHgxbJZvL3uMR1g", + origin_server_ts: 1643815458650, + room_id: "!STrMRsukXHtqQdSeHa:matrix.org", + sender: "@andybalaam-test1:matrix.org", + type: "m.room.encrypted", + unsigned: { age: 80106237 }, + user_id: "@andybalaam-test1:matrix.org", + }); -const buildEventReaction = (event: MatrixEvent) => new MatrixEvent({ - "content": { - "m.relates_to": { - "event_id": event.getId(), - "key": "🤗", - "rel_type": "m.annotation", +const buildEventReaction = (event: MatrixEvent) => + new MatrixEvent({ + content: { + "m.relates_to": { + event_id: event.getId(), + key: "🤗", + rel_type: "m.annotation", + }, }, - }, - "origin_server_ts": 1643977249238, - "sender": "@andybalaam-test1:matrix.org", - "type": "m.reaction", - "unsigned": { - "age": 22598, - "transaction_id": "m1643977249073.16", - }, - "event_id": "$86B2b-x3LgE4DlV4y24b7UHnt72LIA3rzjvMysTtAfA", - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", -}); + origin_server_ts: 1643977249238, + sender: "@andybalaam-test1:matrix.org", + type: "m.reaction", + unsigned: { + age: 22598, + transaction_id: "m1643977249073.16", + }, + event_id: "$86B2b-x3LgE4DlV4y24b7UHnt72LIA3rzjvMysTtAfA", + room_id: "!STrMRsukXHtqQdSeHa:matrix.org", + }); -const buildEventRedaction = (event: MatrixEvent) => new MatrixEvent({ - "content": { - - }, - "origin_server_ts": 1643977249239, - "sender": "@andybalaam-test1:matrix.org", - "redacts": event.getId(), - "type": "m.room.redaction", - "unsigned": { - "age": 22597, - "transaction_id": "m1643977249073.17", - }, - "event_id": "$86B2b-x3LgE4DlV4y24b7UHnt72LIA3rzjvMysTtAfB", - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", -}); +const buildEventRedaction = (event: MatrixEvent) => + new MatrixEvent({ + content: {}, + origin_server_ts: 1643977249239, + sender: "@andybalaam-test1:matrix.org", + redacts: event.getId(), + type: "m.room.redaction", + unsigned: { + age: 22597, + transaction_id: "m1643977249073.17", + }, + event_id: "$86B2b-x3LgE4DlV4y24b7UHnt72LIA3rzjvMysTtAfB", + room_id: "!STrMRsukXHtqQdSeHa:matrix.org", + }); -const buildEventPollStartThreadRoot = () => new MatrixEvent({ - "age": 80108647, - "content": { - "algorithm": "m.megolm.v1.aes-sha2", - "ciphertext": "ENCRYPTEDSTUFF", - "device_id": "XISFUZSKHH", - "sender_key": "i3N3CtG/CD2bGB8rA9fW6adLYSDvlUhf2iuU73L65Vg", - "session_id": "Ja11R/KG6ua0wdk8zAzognrxjio1Gm/RK2Gn6lFL804", - }, - "event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", - "origin_server_ts": 1643815456240, - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", - "sender": "@andybalaam-test1:matrix.org", - "type": "m.room.encrypted", - "unsigned": { "age": 80108647 }, - "user_id": "@andybalaam-test1:matrix.org", -}); +const buildEventPollStartThreadRoot = () => + new MatrixEvent({ + age: 80108647, + content: { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "ENCRYPTEDSTUFF", + device_id: "XISFUZSKHH", + sender_key: "i3N3CtG/CD2bGB8rA9fW6adLYSDvlUhf2iuU73L65Vg", + session_id: "Ja11R/KG6ua0wdk8zAzognrxjio1Gm/RK2Gn6lFL804", + }, + event_id: "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", + origin_server_ts: 1643815456240, + room_id: "!STrMRsukXHtqQdSeHa:matrix.org", + sender: "@andybalaam-test1:matrix.org", + type: "m.room.encrypted", + unsigned: { age: 80108647 }, + user_id: "@andybalaam-test1:matrix.org", + }); -const buildEventReply = (target: MatrixEvent) => new MatrixEvent({ - "age": 80098509, - "content": { - "algorithm": "m.megolm.v1.aes-sha2", - "ciphertext": "ENCRYPTEDSTUFF", - "device_id": "XISFUZSKHH", - "m.relates_to": { - "m.in_reply_to": { - "event_id": target.getId()!, +const buildEventReply = (target: MatrixEvent) => + new MatrixEvent({ + age: 80098509, + content: { + "algorithm": "m.megolm.v1.aes-sha2", + "ciphertext": "ENCRYPTEDSTUFF", + "device_id": "XISFUZSKHH", + "m.relates_to": { + "m.in_reply_to": { + event_id: target.getId()!, + }, }, + "sender_key": "i3N3CtG/CD2bGB8rA9fW6adLYSDvlUhf2iuU73L65Vg", + "session_id": "Ja11R/KG6ua0wdk8zAzognrxjio1Gm/RK2Gn6lFL804", }, - "sender_key": "i3N3CtG/CD2bGB8rA9fW6adLYSDvlUhf2iuU73L65Vg", - "session_id": "Ja11R/KG6ua0wdk8zAzognrxjio1Gm/RK2Gn6lFL804", - }, - "event_id": target.getId()! + Math.random(), - "origin_server_ts": 1643815466378, - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", - "sender": "@andybalaam-test1:matrix.org", - "type": "m.room.encrypted", - "unsigned": { "age": 80098509 }, - "user_id": "@andybalaam-test1:matrix.org", -}); - -const buildEventRoomName = () => new MatrixEvent({ - "age": 80123249, - "content": { - "name": "1 poll, 1 vote, 1 thread", - }, - "event_id": "$QAdyNJtKnl1j7or2yMycbOCvb6bCgvHs5lg3ZMd5xWk", - "origin_server_ts": 1643815441638, - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", - "sender": "@andybalaam-test1:matrix.org", - "state_key": "", - "type": "m.room.name", - "unsigned": { "age": 80123249 }, - "user_id": "@andybalaam-test1:matrix.org", -}); + event_id: target.getId()! + Math.random(), + origin_server_ts: 1643815466378, + room_id: "!STrMRsukXHtqQdSeHa:matrix.org", + sender: "@andybalaam-test1:matrix.org", + type: "m.room.encrypted", + unsigned: { age: 80098509 }, + user_id: "@andybalaam-test1:matrix.org", + }); -const buildEventEncryption = () => new MatrixEvent({ - "age": 80123383, - "content": { - "algorithm": "m.megolm.v1.aes-sha2", - }, - "event_id": "$1hGykogKQkXbHw8bVuyE3BjHnFBEJBcUWnakd0ck2K0", - "origin_server_ts": 1643815441504, - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", - "sender": "@andybalaam-test1:matrix.org", - "state_key": "", - "type": "m.room.encryption", - "unsigned": { "age": 80123383 }, - "user_id": "@andybalaam-test1:matrix.org", -}); +const buildEventRoomName = () => + new MatrixEvent({ + age: 80123249, + content: { + name: "1 poll, 1 vote, 1 thread", + }, + event_id: "$QAdyNJtKnl1j7or2yMycbOCvb6bCgvHs5lg3ZMd5xWk", + origin_server_ts: 1643815441638, + room_id: "!STrMRsukXHtqQdSeHa:matrix.org", + sender: "@andybalaam-test1:matrix.org", + state_key: "", + type: "m.room.name", + unsigned: { age: 80123249 }, + user_id: "@andybalaam-test1:matrix.org", + }); -const buildEventGuestAccess = () => new MatrixEvent({ - "age": 80123473, - "content": { - "guest_access": "can_join", - }, - "event_id": "$4_2n-H6K9-0nPbnjjtIue2SU44tGJsnuTmi6UuSrh-U", - "origin_server_ts": 1643815441414, - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", - "sender": "@andybalaam-test1:matrix.org", - "state_key": "", - "type": "m.room.guest_access", - "unsigned": { "age": 80123473 }, - "user_id": "@andybalaam-test1:matrix.org", -}); +const buildEventEncryption = () => + new MatrixEvent({ + age: 80123383, + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + event_id: "$1hGykogKQkXbHw8bVuyE3BjHnFBEJBcUWnakd0ck2K0", + origin_server_ts: 1643815441504, + room_id: "!STrMRsukXHtqQdSeHa:matrix.org", + sender: "@andybalaam-test1:matrix.org", + state_key: "", + type: "m.room.encryption", + unsigned: { age: 80123383 }, + user_id: "@andybalaam-test1:matrix.org", + }); -const buildEventHistoryVisibility = () => new MatrixEvent({ - "age": 80123556, - "content": { - "history_visibility": "shared", - }, - "event_id": "$W6kp44CTnvciOiHSPyhp8dh4n2v1_9kclUPddeaQj0E", - "origin_server_ts": 1643815441331, - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", - "sender": "@andybalaam-test1:matrix.org", - "state_key": "", - "type": "m.room.history_visibility", - "unsigned": { "age": 80123556 }, - "user_id": "@andybalaam-test1:matrix.org", -}); +const buildEventGuestAccess = () => + new MatrixEvent({ + age: 80123473, + content: { + guest_access: "can_join", + }, + event_id: "$4_2n-H6K9-0nPbnjjtIue2SU44tGJsnuTmi6UuSrh-U", + origin_server_ts: 1643815441414, + room_id: "!STrMRsukXHtqQdSeHa:matrix.org", + sender: "@andybalaam-test1:matrix.org", + state_key: "", + type: "m.room.guest_access", + unsigned: { age: 80123473 }, + user_id: "@andybalaam-test1:matrix.org", + }); -const buildEventJoinRules = () => new MatrixEvent({ - "age": 80123696, - "content": { - "join_rule": "invite", - }, - "event_id": "$6JDDeDp7fEc0F6YnTWMruNcKWFltR3e9wk7wWDDJrAU", - "origin_server_ts": 1643815441191, - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", - "sender": "@andybalaam-test1:matrix.org", - "state_key": "", - "type": "m.room.join_rules", - "unsigned": { "age": 80123696 }, - "user_id": "@andybalaam-test1:matrix.org", -}); +const buildEventHistoryVisibility = () => + new MatrixEvent({ + age: 80123556, + content: { + history_visibility: "shared", + }, + event_id: "$W6kp44CTnvciOiHSPyhp8dh4n2v1_9kclUPddeaQj0E", + origin_server_ts: 1643815441331, + room_id: "!STrMRsukXHtqQdSeHa:matrix.org", + sender: "@andybalaam-test1:matrix.org", + state_key: "", + type: "m.room.history_visibility", + unsigned: { age: 80123556 }, + user_id: "@andybalaam-test1:matrix.org", + }); -const buildEventPowerLevels = () => new MatrixEvent({ - "age": 80124105, - "content": { - "ban": 50, - "events": { - "m.room.avatar": 50, - "m.room.canonical_alias": 50, - "m.room.encryption": 100, - "m.room.history_visibility": 100, - "m.room.name": 50, - "m.room.power_levels": 100, - "m.room.server_acl": 100, - "m.room.tombstone": 100, +const buildEventJoinRules = () => + new MatrixEvent({ + age: 80123696, + content: { + join_rule: "invite", }, - "events_default": 0, - "historical": 100, - "invite": 0, - "kick": 50, - "redact": 50, - "state_default": 50, - "users": { - "@andybalaam-test1:matrix.org": 100, + event_id: "$6JDDeDp7fEc0F6YnTWMruNcKWFltR3e9wk7wWDDJrAU", + origin_server_ts: 1643815441191, + room_id: "!STrMRsukXHtqQdSeHa:matrix.org", + sender: "@andybalaam-test1:matrix.org", + state_key: "", + type: "m.room.join_rules", + unsigned: { age: 80123696 }, + user_id: "@andybalaam-test1:matrix.org", + }); + +const buildEventPowerLevels = () => + new MatrixEvent({ + age: 80124105, + content: { + ban: 50, + events: { + "m.room.avatar": 50, + "m.room.canonical_alias": 50, + "m.room.encryption": 100, + "m.room.history_visibility": 100, + "m.room.name": 50, + "m.room.power_levels": 100, + "m.room.server_acl": 100, + "m.room.tombstone": 100, + }, + events_default: 0, + historical: 100, + invite: 0, + kick: 50, + redact: 50, + state_default: 50, + users: { + "@andybalaam-test1:matrix.org": 100, + }, + users_default: 0, }, - "users_default": 0, - }, - "event_id": "$XZY2YgQhXskpc7gmJJG3S0VmS9_QjjCUVeeFTfgfC2E", - "origin_server_ts": 1643815440782, - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", - "sender": "@andybalaam-test1:matrix.org", - "state_key": "", - "type": "m.room.power_levels", - "unsigned": { "age": 80124105 }, - "user_id": "@andybalaam-test1:matrix.org", -}); + event_id: "$XZY2YgQhXskpc7gmJJG3S0VmS9_QjjCUVeeFTfgfC2E", + origin_server_ts: 1643815440782, + room_id: "!STrMRsukXHtqQdSeHa:matrix.org", + sender: "@andybalaam-test1:matrix.org", + state_key: "", + type: "m.room.power_levels", + unsigned: { age: 80124105 }, + user_id: "@andybalaam-test1:matrix.org", + }); -const buildEventMember = () => new MatrixEvent({ - "age": 80125279, - "content": { - "avatar_url": "mxc://matrix.org/aNtbVcFfwotudypZcHsIcPOc", - "displayname": "andybalaam-test1", - "membership": "join", - }, - "event_id": "$Ex5eVmMs_ti784mo8bgddynbwLvy6231lCycJr7Cl9M", - "origin_server_ts": 1643815439608, - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", - "sender": "@andybalaam-test1:matrix.org", - "state_key": "@andybalaam-test1:matrix.org", - "type": "m.room.member", - "unsigned": { "age": 80125279 }, - "user_id": "@andybalaam-test1:matrix.org", -}); +const buildEventMember = () => + new MatrixEvent({ + age: 80125279, + content: { + avatar_url: "mxc://matrix.org/aNtbVcFfwotudypZcHsIcPOc", + displayname: "andybalaam-test1", + membership: "join", + }, + event_id: "$Ex5eVmMs_ti784mo8bgddynbwLvy6231lCycJr7Cl9M", + origin_server_ts: 1643815439608, + room_id: "!STrMRsukXHtqQdSeHa:matrix.org", + sender: "@andybalaam-test1:matrix.org", + state_key: "@andybalaam-test1:matrix.org", + type: "m.room.member", + unsigned: { age: 80125279 }, + user_id: "@andybalaam-test1:matrix.org", + }); -const buildEventCreate = () => new MatrixEvent({ - "age": 80126105, - "content": { - "creator": "@andybalaam-test1:matrix.org", - "room_version": "6", - }, - "event_id": "$e7j2Gt37k5NPwB6lz2N3V9lO5pUdNK8Ai7i2FPEK-oI", - "origin_server_ts": 1643815438782, - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", - "sender": "@andybalaam-test1:matrix.org", - "state_key": "", - "type": "m.room.create", - "unsigned": { "age": 80126105 }, - "user_id": "@andybalaam-test1:matrix.org", -}); +const buildEventCreate = () => + new MatrixEvent({ + age: 80126105, + content: { + creator: "@andybalaam-test1:matrix.org", + room_version: "6", + }, + event_id: "$e7j2Gt37k5NPwB6lz2N3V9lO5pUdNK8Ai7i2FPEK-oI", + origin_server_ts: 1643815438782, + room_id: "!STrMRsukXHtqQdSeHa:matrix.org", + sender: "@andybalaam-test1:matrix.org", + state_key: "", + type: "m.room.create", + unsigned: { age: 80126105 }, + user_id: "@andybalaam-test1:matrix.org", + }); -function assertObjectContains(obj: object, expected: any): void { +function assertObjectContains(obj: Record, expected: any): void { for (const k in expected) { if (expected.hasOwnProperty(k)) { expect(obj[k]).toEqual(expected[k]); diff --git a/spec/integ/matrix-client-opts.spec.ts b/spec/integ/matrix-client-opts.spec.ts index 5ea4fba7718..328152a208e 100644 --- a/spec/integ/matrix-client-opts.spec.ts +++ b/spec/integ/matrix-client-opts.spec.ts @@ -1,13 +1,13 @@ import HttpBackend from "matrix-mock-request"; import * as utils from "../test-utils/test-utils"; -import { MatrixClient } from "../../src/matrix"; +import { ClientEvent, MatrixClient } from "../../src/matrix"; import { MatrixScheduler } from "../../src/scheduler"; import { MemoryStore } from "../../src/store/memory"; import { MatrixError } from "../../src/http-api"; import { IStore } from "../../src/store"; -describe("MatrixClient opts", function() { +describe("MatrixClient opts", function () { const baseUrl = "http://localhost.or.something"; let httpBackend = new HttpBackend(); const userId = "@alice:localhost"; @@ -19,11 +19,14 @@ describe("MatrixClient opts", function() { presence: {}, rooms: { join: { - "!foo:bar": { // roomId + "!foo:bar": { + // roomId timeline: { events: [ utils.mkMessage({ - room: roomId, user: userB, msg: "hello", + room: roomId, + user: userB, + msg: "hello", }), ], prev_batch: "f_1_1", @@ -31,19 +34,29 @@ describe("MatrixClient opts", function() { state: { events: [ utils.mkEvent({ - type: "m.room.name", room: roomId, user: userB, + type: "m.room.name", + room: roomId, + user: userB, content: { name: "Old room name", }, }), utils.mkMembership({ - room: roomId, mship: "join", user: userB, name: "Bob", + room: roomId, + mship: "join", + user: userB, + name: "Bob", }), utils.mkMembership({ - room: roomId, mship: "join", user: userId, name: "Alice", + room: roomId, + mship: "join", + user: userId, + name: "Alice", }), utils.mkEvent({ - type: "m.room.create", room: roomId, user: userId, + type: "m.room.create", + room: roomId, + user: userId, content: { creator: userId, }, @@ -55,18 +68,18 @@ describe("MatrixClient opts", function() { }, }; - beforeEach(function() { + beforeEach(function () { httpBackend = new HttpBackend(); }); - afterEach(function() { + afterEach(function () { httpBackend.verifyNoOutstandingExpectation(); return httpBackend.stop(); }); - describe("without opts.store", function() { - let client; - beforeEach(function() { + describe("without opts.store", function () { + let client: MatrixClient; + beforeEach(function () { client = new MatrixClient({ fetchFn: httpBackend.fetchFn as typeof global.fetch, store: undefined, @@ -77,34 +90,34 @@ describe("MatrixClient opts", function() { }); }); - afterEach(function() { + afterEach(function () { client.stopClient(); }); - it("should be able to send messages", function(done) { + it("should be able to send messages", function (done) { const eventId = "$flibble:wibble"; httpBackend.when("PUT", "/txn1").respond(200, { event_id: eventId, }); - client.sendTextMessage("!foo:bar", "a body", "txn1").then(function(res) { + client.sendTextMessage("!foo:bar", "a body", "txn1").then(function (res) { expect(res.event_id).toEqual(eventId); done(); }); httpBackend.flush("/txn1", 1); }); - it("should be able to sync / get new events", async function() { - const expectedEventTypes = [ // from /initialSync - "m.room.message", "m.room.name", "m.room.member", "m.room.member", + it("should be able to sync / get new events", async function () { + const expectedEventTypes = [ + // from /initialSync + "m.room.message", + "m.room.name", + "m.room.member", + "m.room.member", "m.room.create", ]; - client.on("event", function(event) { - expect(expectedEventTypes.indexOf(event.getType())).not.toEqual( - -1, - ); - expectedEventTypes.splice( - expectedEventTypes.indexOf(event.getType()), 1, - ); + client.on(ClientEvent.Event, function (event) { + expect(expectedEventTypes.indexOf(event.getType())).not.toEqual(-1); + expectedEventTypes.splice(expectedEventTypes.indexOf(event.getType()), 1); }); httpBackend.when("GET", "/versions").respond(200, {}); httpBackend.when("GET", "/pushrules").respond(200, {}); @@ -114,19 +127,14 @@ describe("MatrixClient opts", function() { await httpBackend.flush("/versions", 1); await httpBackend.flush("/pushrules", 1); await httpBackend.flush("/filter", 1); - await Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), - ]); - expect(expectedEventTypes.length).toEqual( - 0, - ); + await Promise.all([httpBackend.flush("/sync", 1), utils.syncPromise(client)]); + expect(expectedEventTypes.length).toEqual(0); }); }); - describe("without opts.scheduler", function() { - let client; - beforeEach(function() { + describe("without opts.scheduler", function () { + let client: MatrixClient; + beforeEach(function () { client = new MatrixClient({ fetchFn: httpBackend.fetchFn as typeof global.fetch, store: new MemoryStore() as IStore, @@ -137,25 +145,31 @@ describe("MatrixClient opts", function() { }); }); - afterEach(function() { + afterEach(function () { client.stopClient(); }); - it("shouldn't retry sending events", function(done) { - httpBackend.when("PUT", "/txn1").respond(500, new MatrixError({ - errcode: "M_SOMETHING", - error: "Ruh roh", - })); - client.sendTextMessage("!foo:bar", "a body", "txn1").then(function(res) { - expect(false).toBe(true); - }, function(err) { - expect(err.errcode).toEqual("M_SOMETHING"); - done(); - }); + it("shouldn't retry sending events", function (done) { + httpBackend.when("PUT", "/txn1").respond( + 500, + new MatrixError({ + errcode: "M_SOMETHING", + error: "Ruh roh", + }), + ); + client.sendTextMessage("!foo:bar", "a body", "txn1").then( + function (res) { + expect(false).toBe(true); + }, + function (err) { + expect(err.errcode).toEqual("M_SOMETHING"); + done(); + }, + ); httpBackend.flush("/txn1", 1); }); - it("shouldn't queue events", function(done) { + it("shouldn't queue events", function (done) { httpBackend.when("PUT", "/txn1").respond(200, { event_id: "AAA", }); @@ -164,26 +178,26 @@ describe("MatrixClient opts", function() { }); let sentA = false; let sentB = false; - client.sendTextMessage("!foo:bar", "a body", "txn1").then(function(res) { + client.sendTextMessage("!foo:bar", "a body", "txn1").then(function (res) { sentA = true; expect(sentB).toBe(true); }); - client.sendTextMessage("!foo:bar", "b body", "txn2").then(function(res) { + client.sendTextMessage("!foo:bar", "b body", "txn2").then(function (res) { sentB = true; expect(sentA).toBe(false); }); - httpBackend.flush("/txn2", 1).then(function() { - httpBackend.flush("/txn1", 1).then(function() { + httpBackend.flush("/txn2", 1).then(function () { + httpBackend.flush("/txn1", 1).then(function () { done(); }); }); }); - it("should be able to send messages", function(done) { + it("should be able to send messages", function (done) { httpBackend.when("PUT", "/txn1").respond(200, { event_id: "foo", }); - client.sendTextMessage("!foo:bar", "a body", "txn1").then(function(res) { + client.sendTextMessage("!foo:bar", "a body", "txn1").then(function (res) { expect(res.event_id).toEqual("foo"); done(); }); diff --git a/spec/integ/matrix-client-relations.spec.ts b/spec/integ/matrix-client-relations.spec.ts index 456db2efb07..1a7f370c22a 100644 --- a/spec/integ/matrix-client-relations.spec.ts +++ b/spec/integ/matrix-client-relations.spec.ts @@ -29,13 +29,7 @@ describe("MatrixClient relations", () => { const setupTests = (): [MatrixClient, HttpBackend] => { const scheduler = new MatrixScheduler(); - const testClient = new TestClient( - userId, - "DEVICE", - accessToken, - undefined, - { scheduler }, - ); + const testClient = new TestClient(userId, "DEVICE", accessToken, undefined, { scheduler }); const httpBackend = testClient.httpBackend; const client = testClient.client; @@ -52,76 +46,71 @@ describe("MatrixClient relations", () => { }); it("should read related events with the default options", async () => { - const response = client!.relations(roomId, '$event-0', null, null); + const response = client!.relations(roomId, "$event-0", null, null); + httpBackend!.when("GET", "/rooms/!room%3Ahere/event/%24event-0").respond(200, null); httpBackend! - .when("GET", "/rooms/!room%3Ahere/relations/%24event-0?dir=b") - .respond(200, { chunk: [], next_batch: 'NEXT' }); + .when("GET", "/_matrix/client/v1/rooms/!room%3Ahere/relations/%24event-0?dir=b") + .respond(200, { chunk: [], next_batch: "NEXT" }); await httpBackend!.flushAllExpected(); - expect(await response).toEqual({ "events": [], "nextBatch": "NEXT", "originalEvent": null, "prevBatch": null }); + expect(await response).toEqual({ events: [], nextBatch: "NEXT", originalEvent: null, prevBatch: null }); }); it("should read related events with relation type", async () => { - const response = client!.relations(roomId, '$event-0', 'm.reference', null); + const response = client!.relations(roomId, "$event-0", "m.reference", null); + httpBackend!.when("GET", "/rooms/!room%3Ahere/event/%24event-0").respond(200, null); httpBackend! - .when("GET", "/rooms/!room%3Ahere/relations/%24event-0/m.reference?dir=b") - .respond(200, { chunk: [], next_batch: 'NEXT' }); + .when("GET", "/_matrix/client/v1/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", "originalEvent": null, "prevBatch": null }); + expect(await response).toEqual({ events: [], nextBatch: "NEXT", originalEvent: null, prevBatch: null }); }); it("should read related events with relation type and event type", async () => { - const response = client!.relations(roomId, '$event-0', 'm.reference', 'm.room.message'); + const response = client!.relations(roomId, "$event-0", "m.reference", "m.room.message"); + httpBackend!.when("GET", "/rooms/!room%3Ahere/event/%24event-0").respond(200, null); httpBackend! - .when( - "GET", - "/rooms/!room%3Ahere/relations/%24event-0/m.reference/m.room.message?dir=b", - ) - .respond(200, { chunk: [], next_batch: 'NEXT' }); + .when("GET", "/_matrix/client/v1/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", "originalEvent": null, "prevBatch": null }); + expect(await response).toEqual({ events: [], nextBatch: "NEXT", originalEvent: null, prevBatch: null }); }); it("should read related events with custom options", async () => { - const response = client!.relations(roomId, '$event-0', null, null, { + const response = client!.relations(roomId, "$event-0", null, null, { dir: Direction.Forward, - from: 'FROM', + from: "FROM", limit: 10, - to: 'TO', + to: "TO", }); + httpBackend!.when("GET", "/rooms/!room%3Ahere/event/%24event-0").respond(200, null); httpBackend! - .when( - "GET", - "/rooms/!room%3Ahere/relations/%24event-0?dir=f&from=FROM&limit=10&to=TO", - ) - .respond(200, { chunk: [], next_batch: 'NEXT' }); + .when("GET", "/_matrix/client/v1/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", "originalEvent": null, "prevBatch": null }); + expect(await response).toEqual({ events: [], nextBatch: "NEXT", originalEvent: null, prevBatch: null }); }); - it('should use default direction in the fetchRelations endpoint', async () => { - const response = client!.fetchRelations(roomId, '$event-0', null, null); + 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' }); + .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" }); + 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 877e80ac97c..c890230b816 100644 --- a/spec/integ/matrix-client-retrying.spec.ts +++ b/spec/integ/matrix-client-retrying.spec.ts @@ -20,7 +20,7 @@ import { EventStatus, RoomEvent, MatrixClient, MatrixScheduler } from "../../src import { Room } from "../../src/models/room"; import { TestClient } from "../TestClient"; -describe("MatrixClient retrying", function() { +describe("MatrixClient retrying", function () { const userId = "@alice:localhost"; const accessToken = "aseukfgwef"; const roomId = "!room:here"; @@ -30,13 +30,7 @@ describe("MatrixClient retrying", function() { const setupTests = (): [MatrixClient, HttpBackend, Room] => { const scheduler = new MatrixScheduler(); - const testClient = new TestClient( - userId, - "DEVICE", - accessToken, - undefined, - { scheduler }, - ); + const testClient = new TestClient(userId, "DEVICE", accessToken, undefined, { scheduler }); const httpBackend = testClient.httpBackend; const client = testClient.client; const room = new Room(roomId, client, userId); @@ -45,49 +39,46 @@ describe("MatrixClient retrying", function() { return [client, httpBackend, room]; }; - beforeEach(function() { + beforeEach(function () { [client, httpBackend, room] = setupTests(); }); - afterEach(function() { + afterEach(function () { httpBackend!.verifyNoOutstandingExpectation(); return httpBackend!.stop(); }); - xit("should retry according to MatrixScheduler.retryFn", function() { + xit("should retry according to MatrixScheduler.retryFn", function () {}); - }); - - xit("should queue according to MatrixScheduler.queueFn", function() { - - }); + xit("should queue according to MatrixScheduler.queueFn", function () {}); - xit("should mark events as EventStatus.NOT_SENT when giving up", function() { + xit("should mark events as EventStatus.NOT_SENT when giving up", function () {}); - }); - - xit("should mark events as EventStatus.QUEUED when queued", function() { - - }); + xit("should mark events as EventStatus.QUEUED when queued", function () {}); - it("should mark events as EventStatus.CANCELLED when cancelled", 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, { - "msgtype": "m.text", - "body": "m1", - }).then(function() { - // we expect the first message to fail - throw new Error('Message 1 unexpectedly sent successfully'); - }, () => { - // this is expected - }); + const p1 = client! + .sendMessage(roomId, { + msgtype: "m.text", + body: "m1", + }) + .then( + function () { + // we expect the first message to fail + throw new Error("Message 1 unexpectedly sent successfully"); + }, + () => { + // this is expected + }, + ); // 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, { - "msgtype": "m.text", - "body": "m2", + msgtype: "m.text", + body: "m2", }); // both events should be in the timeline at this point @@ -100,20 +91,23 @@ 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() { - // 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); - 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); - }).toThrow(); - }).respond(400); // fail the first message + 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); + 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); + }).toThrow(); + }) + .respond(400); // fail the first message // wait for the localecho of ev1 to be updated const p3 = new Promise((resolve, reject) => { @@ -122,7 +116,7 @@ describe("MatrixClient retrying", function() { resolve(); } }); - }).then(function() { + }).then(function () { expect(ev1.status).toEqual(EventStatus.NOT_SENT); expect(tl.length).toEqual(1); @@ -132,19 +126,11 @@ describe("MatrixClient retrying", function() { expect(tl.length).toEqual(0); }); - return Promise.all([ - p1, - p3, - httpBackend!.flushAllExpected(), - ]); + return Promise.all([p1, p3, httpBackend!.flushAllExpected()]); }); - describe("resending", function() { - xit("should be able to resend a NOT_SENT event", function() { - - }); - xit("should be able to resend a sent event", function() { - - }); + describe("resending", function () { + xit("should be able to resend a NOT_SENT event", function () {}); + xit("should be able to resend a sent event", function () {}); }); }); diff --git a/spec/integ/matrix-client-room-timeline.spec.ts b/spec/integ/matrix-client-room-timeline.spec.ts index f89ab04e2b8..8d7bcba2e23 100644 --- a/spec/integ/matrix-client-room-timeline.spec.ts +++ b/spec/integ/matrix-client-room-timeline.spec.ts @@ -18,10 +18,20 @@ import HttpBackend from "matrix-mock-request"; import * as utils from "../test-utils/test-utils"; import { EventStatus } from "../../src/models/event"; -import { MatrixError, ClientEvent, IEvent, MatrixClient, RoomEvent } from "../../src"; +import { + MatrixError, + ClientEvent, + IEvent, + MatrixClient, + RoomEvent, + ISyncResponse, + IMinimalEvent, + IRoomEvent, + Room, +} from "../../src"; import { TestClient } from "../TestClient"; -describe("MatrixClient room timelines", function() { +describe("MatrixClient room timelines", function () { const userId = "@alice:localhost"; const userName = "Alice"; const accessToken = "aseukfgwef"; @@ -31,24 +41,32 @@ describe("MatrixClient room timelines", function() { let httpBackend: HttpBackend | undefined; const USER_MEMBERSHIP_EVENT = utils.mkMembership({ - room: roomId, mship: "join", user: userId, name: userName, + room: roomId, + mship: "join", + user: userId, + name: userName, }); const ROOM_NAME_EVENT = utils.mkEvent({ - type: "m.room.name", room: roomId, user: otherUserId, + type: "m.room.name", + room: roomId, + user: otherUserId, content: { name: "Old room name", }, }); - let NEXT_SYNC_DATA; + let NEXT_SYNC_DATA: Partial; const SYNC_DATA = { next_batch: "s_5_3", rooms: { join: { - "!foo:bar": { // roomId + "!foo:bar": { + // roomId timeline: { events: [ utils.mkMessage({ - room: roomId, user: otherUserId, msg: "hello", + room: roomId, + user: otherUserId, + msg: "hello", }), ], prev_batch: "f_1_1", @@ -57,12 +75,16 @@ describe("MatrixClient room timelines", function() { events: [ ROOM_NAME_EVENT, utils.mkMembership({ - room: roomId, mship: "join", - user: otherUserId, name: "Bob", + room: roomId, + mship: "join", + user: otherUserId, + name: "Bob", }), USER_MEMBERSHIP_EVENT, utils.mkEvent({ - type: "m.room.create", room: roomId, user: userId, + type: "m.room.create", + room: roomId, + user: userId, content: { creator: userId, }, @@ -88,32 +110,26 @@ describe("MatrixClient room timelines", function() { }, }, leave: {}, - }, + } as unknown as ISyncResponse["rooms"], }; - events.forEach(function(e) { + events.forEach(function (e) { if (e.room_id !== roomId) { throw new Error("setNextSyncData only works with one room id"); } if (e.state_key) { // push the current - NEXT_SYNC_DATA.rooms.join[roomId].timeline.events.push(e); + NEXT_SYNC_DATA.rooms!.join[roomId].timeline.events.push(e as unknown as IRoomEvent); } else if (["m.typing", "m.receipt"].indexOf(e.type!) !== -1) { - NEXT_SYNC_DATA.rooms.join[roomId].ephemeral.events.push(e); + NEXT_SYNC_DATA.rooms!.join[roomId].ephemeral.events.push(e as unknown as IMinimalEvent); } else { - NEXT_SYNC_DATA.rooms.join[roomId].timeline.events.push(e); + NEXT_SYNC_DATA.rooms!.join[roomId].timeline.events.push(e as unknown as IRoomEvent); } }); } const setupTestClient = (): [MatrixClient, HttpBackend] => { // these tests should work with or without timelineSupport - const testClient = new TestClient( - userId, - "DEVICE", - accessToken, - undefined, - { timelineSupport: true }, - ); + const testClient = new TestClient(userId, "DEVICE", accessToken, undefined, { timelineSupport: true }); const httpBackend = testClient.httpBackend; const client = testClient.client; @@ -122,7 +138,7 @@ describe("MatrixClient room timelines", function() { 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", "/sync").respond(200, function () { return NEXT_SYNC_DATA; }); client!.startClient(); @@ -130,119 +146,131 @@ describe("MatrixClient room timelines", function() { return [client!, httpBackend]; }; - beforeEach(async function() { + beforeEach(async function () { [client!, httpBackend] = setupTestClient(); await httpBackend.flush("/versions"); await httpBackend.flush("/pushrules"); await httpBackend.flush("/filter"); }); - afterEach(function() { + afterEach(function () { 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(ClientEvent.Sync, function(state) { - if (state !== "PREPARED") { - return; - } - const room = client!.getRoom(roomId)!; - expect(room.timeline.length).toEqual(1); + 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(ClientEvent.Sync, function (state) { + if (state !== "PREPARED") { + return; + } + const room = client!.getRoom(roomId)!; + expect(room.timeline.length).toEqual(1); - client!.sendTextMessage(roomId, "I am a fish", "txn1"); - // check it was added - expect(room.timeline.length).toEqual(2); - // check status - expect(room.timeline[1].status).toEqual(EventStatus.SENDING); - // check member - const member = room.timeline[1].sender; - expect(member?.userId).toEqual(userId); - expect(member?.name).toEqual(userName); - - httpBackend!.flush("/sync", 1).then(function() { - done(); + client!.sendTextMessage(roomId, "I am a fish", "txn1"); + // check it was added + expect(room.timeline.length).toEqual(2); + // check status + expect(room.timeline[1].status).toEqual(EventStatus.SENDING); + // check member + const member = room.timeline[1].sender; + expect(member?.userId).toEqual(userId); + expect(member?.name).toEqual(userName); + + 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, { - event_id: eventId, - }); + 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, { + event_id: eventId, + }); - const ev = utils.mkMessage({ - msg: "I am a fish", user: userId, room: roomId, - }); - ev.event_id = eventId; - ev.unsigned = { transaction_id: "txn1" }; - setNextSyncData([ev]); + const ev = utils.mkMessage({ + msg: "I am a fish", + user: userId, + room: roomId, + }); + ev.event_id = eventId; + ev.unsigned = { transaction_id: "txn1" }; + setNextSyncData([ev]); - 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() { + 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() { + httpBackend!.flush("/sync", 1).then(function () { expect(room.timeline[1].getId()).toEqual(eventId); done(); }); }); - httpBackend!.flush("/txn1", 1); - }); - httpBackend!.flush("/sync", 1); - }); + httpBackend!.flush("/txn1", 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, { - event_id: eventId, - }); + 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, { + event_id: eventId, + }); - const ev = utils.mkMessage({ - msg: "I am a fish", user: userId, room: roomId, - }); - ev.event_id = eventId; - ev.unsigned = { transaction_id: "txn1" }; - setNextSyncData([ev]); + const ev = utils.mkMessage({ + msg: "I am a fish", + user: userId, + room: roomId, + }); + ev.event_id = eventId; + ev.unsigned = { transaction_id: "txn1" }; + setNextSyncData([ev]); - 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() { - expect(room.timeline.length).toEqual(2); - httpBackend!.flush("/txn1", 1); - promise.then(function() { + 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 () { expect(room.timeline.length).toEqual(2); - expect(room.timeline[1].getId()).toEqual(eventId); - done(); + httpBackend!.flush("/txn1", 1); + promise.then(function () { + expect(room.timeline.length).toEqual(2); + expect(room.timeline[1].getId()).toEqual(eventId); + done(); + }); }); }); - }); - httpBackend!.flush("/sync", 1); - }); + httpBackend!.flush("/sync", 1); + }, + ); }); - describe("paginated events", function() { - let sbEvents; + describe("paginated events", function () { + let sbEvents: Partial[]; const sbEndTok = "pagin_end"; - beforeEach(function() { + beforeEach(function () { sbEvents = []; - httpBackend!.when("GET", "/messages").respond(200, function() { + httpBackend!.when("GET", "/messages").respond(200, function () { return { chunk: sbEvents, start: "pagin_start", @@ -251,16 +279,15 @@ describe("MatrixClient room timelines", function() { }); }); - it("should set Room.oldState.paginationToken to null at the start" + - " of the timeline.", function(done) { - client!.on(ClientEvent.Sync, function(state) { + it("should set Room.oldState.paginationToken to null at the start" + " of the timeline.", function (done) { + client!.on(ClientEvent.Sync, function (state) { if (state !== "PREPARED") { return; } 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); @@ -275,7 +302,7 @@ describe("MatrixClient room timelines", function() { httpBackend!.flush("/sync", 1); }); - it("should set the right event.sender values", function(done) { + it("should set the right event.sender values", function (done) { // We're aiming for an eventual timeline of: // // 'Old Alice' joined the room @@ -287,14 +314,20 @@ 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", + mship: "join", + user: userId, + room: roomId, + name: "Old Alice", url: undefined, }); // make an m.room.member event with prev_content for alice's nick // change const oldMshipEvent = utils.mkMembership({ - mship: "join", user: userId, room: roomId, name: userName, + mship: "join", + user: userId, + room: roomId, + name: userName, url: "mxc://some/url", }); oldMshipEvent.prev_content = { @@ -307,16 +340,20 @@ describe("MatrixClient room timelines", function() { // N.B. synapse returns /messages in reverse chronological order sbEvents = [ utils.mkMessage({ - user: userId, room: roomId, msg: "I'm alice", + user: userId, + room: roomId, + msg: "I'm alice", }), oldMshipEvent, utils.mkMessage({ - user: userId, room: roomId, msg: "I'm old alice", + user: userId, + room: roomId, + msg: "I'm old alice", }), joinMshipEvent, ]; - client!.on(ClientEvent.Sync, function(state) { + client!.on(ClientEvent.Sync, function (state) { if (state !== "PREPARED") { return; } @@ -324,7 +361,7 @@ describe("MatrixClient room timelines", function() { // 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"); @@ -344,25 +381,29 @@ describe("MatrixClient room timelines", function() { httpBackend!.flush("/sync", 1); }); - it("should add it them to the right place in the timeline", function(done) { + it("should add it them to the right place in the timeline", function (done) { // set the list of events to return on scrollback sbEvents = [ utils.mkMessage({ - user: userId, room: roomId, msg: "I am new", + user: userId, + room: roomId, + msg: "I am new", }), utils.mkMessage({ - user: userId, room: roomId, msg: "I am old", + user: userId, + room: roomId, + msg: "I am old", }), ]; - client!.on(ClientEvent.Sync, function(state) { + client!.on(ClientEvent.Sync, function (state) { if (state !== "PREPARED") { return; } 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]); @@ -378,26 +419,28 @@ describe("MatrixClient room timelines", function() { httpBackend!.flush("/sync", 1); }); - it("should use 'end' as the next pagination token", function(done) { + it("should use 'end' as the next pagination token", function (done) { // set the list of events to return on scrollback sbEvents = [ utils.mkMessage({ - user: userId, room: roomId, msg: "I am new", + user: userId, + room: roomId, + msg: "I am new", }), ]; - client!.on(ClientEvent.Sync, function(state) { + client!.on(ClientEvent.Sync, function (state) { if (state !== "PREPARED") { return; } 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(() => { done(); @@ -408,22 +451,19 @@ describe("MatrixClient room timelines", function() { }); }); - describe("new events", function() { - it("should be added to the right place in the timeline", function() { + describe("new events", function () { + it("should be added to the right place in the timeline", function () { const eventData = [ utils.mkMessage({ user: userId, room: roomId }), utils.mkMessage({ user: userId, room: roomId }), ]; setNextSyncData(eventData); - return Promise.all([ - httpBackend!.flush("/sync", 1), - utils.syncPromise(client!), - ]).then(() => { + return Promise.all([httpBackend!.flush("/sync", 1), utils.syncPromise(client!)]).then(() => { const room = client!.getRoom(roomId)!; let index = 0; - client!.on(RoomEvent.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]); @@ -431,41 +471,31 @@ describe("MatrixClient room timelines", function() { }); httpBackend!.flush("/messages", 1); - return Promise.all([ - httpBackend!.flush("/sync", 1), - utils.syncPromise(client!), - ]).then(function() { + return Promise.all([httpBackend!.flush("/sync", 1), utils.syncPromise(client!)]).then(function () { expect(index).toEqual(2); expect(room.timeline.length).toEqual(3); - expect(room.timeline[2].event).toEqual( - eventData[1], - ); - expect(room.timeline[1].event).toEqual( - eventData[0], - ); + expect(room.timeline[2].event).toEqual(eventData[1]); + expect(room.timeline[1].event).toEqual(eventData[0]); }); }); }); - it("should set the right event.sender values", function() { + it("should set the right event.sender values", function () { const eventData = [ utils.mkMessage({ user: userId, room: roomId }), utils.mkMembership({ - user: userId, room: roomId, mship: "join", name: "New Name", + user: userId, + room: roomId, + mship: "join", + name: "New Name", }), utils.mkMessage({ user: userId, room: roomId }), ]; setNextSyncData(eventData); - return Promise.all([ - httpBackend!.flush("/sync", 1), - utils.syncPromise(client!), - ]).then(() => { + return Promise.all([httpBackend!.flush("/sync", 1), utils.syncPromise(client!)]).then(() => { const room = client!.getRoom(roomId)!; - return Promise.all([ - httpBackend!.flush("/sync", 1), - utils.syncPromise(client!), - ]).then(function() { + return Promise.all([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]; expect(preNameEvent.sender?.name).toEqual(userName); @@ -474,92 +504,85 @@ describe("MatrixClient room timelines", function() { }); }); - it("should set the right room.name", function() { + it("should set the right room.name", function () { const secondRoomNameEvent = utils.mkEvent({ - user: userId, room: roomId, type: "m.room.name", content: { + user: userId, + room: roomId, + type: "m.room.name", + content: { name: "Room 2", }, }); setNextSyncData([secondRoomNameEvent]); - return Promise.all([ - httpBackend!.flush("/sync", 1), - utils.syncPromise(client!), - ]).then(() => { + return Promise.all([httpBackend!.flush("/sync", 1), utils.syncPromise(client!)]).then(() => { const room = client!.getRoom(roomId)!; let nameEmitCount = 0; - client!.on(RoomEvent.Name, function(rm) { + client!.on(RoomEvent.Name, function (rm) { nameEmitCount += 1; }); - return Promise.all([ - httpBackend!.flush("/sync", 1), - utils.syncPromise(client!), - ]).then(function() { - expect(nameEmitCount).toEqual(1); - expect(room.name).toEqual("Room 2"); - // do another round - const thirdRoomNameEvent = utils.mkEvent({ - user: userId, room: roomId, type: "m.room.name", content: { - name: "Room 3", - }, + return Promise.all([httpBackend!.flush("/sync", 1), utils.syncPromise(client!)]) + .then(function () { + expect(nameEmitCount).toEqual(1); + expect(room.name).toEqual("Room 2"); + // do another round + const thirdRoomNameEvent = utils.mkEvent({ + user: userId, + room: roomId, + type: "m.room.name", + content: { + name: "Room 3", + }, + }); + setNextSyncData([thirdRoomNameEvent]); + httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + return Promise.all([httpBackend!.flush("/sync", 1), utils.syncPromise(client!)]); + }) + .then(function () { + expect(nameEmitCount).toEqual(2); + expect(room.name).toEqual("Room 3"); }); - setNextSyncData([thirdRoomNameEvent]); - httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); - return Promise.all([ - httpBackend!.flush("/sync", 1), - utils.syncPromise(client!), - ]); - }).then(function() { - expect(nameEmitCount).toEqual(2); - expect(room.name).toEqual("Room 3"); - }); }); }); - it("should set the right room members", function() { + it("should set the right room members", function () { const userC = "@cee:bar"; const userD = "@dee:bar"; const eventData = [ utils.mkMembership({ - user: userC, room: roomId, mship: "join", name: "C", + user: userC, + room: roomId, + mship: "join", + name: "C", }), utils.mkMembership({ - user: userC, room: roomId, mship: "invite", skey: userD, + user: userC, + room: roomId, + mship: "invite", + skey: userD, }), ]; setNextSyncData(eventData); - return Promise.all([ - httpBackend!.flush("/sync", 1), - utils.syncPromise(client!), - ]).then(() => { + return Promise.all([httpBackend!.flush("/sync", 1), utils.syncPromise(client!)]).then(() => { const room = client!.getRoom(roomId)!; - return Promise.all([ - httpBackend!.flush("/sync", 1), - utils.syncPromise(client!), - ]).then(function() { + return Promise.all([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( - "join", - ); + expect(room.currentState.getMember(userC)!.membership).toEqual("join"); expect(room.currentState.getMember(userD)!.name).toEqual(userD); - expect(room.currentState.getMember(userD)!.membership).toEqual( - "invite", - ); + expect(room.currentState.getMember(userD)!.membership).toEqual("invite"); }); }); }); }); - describe("gappy sync", function() { - it("should copy the last known state to the new timeline", function() { - const eventData = [ - utils.mkMessage({ user: userId, room: roomId }), - ]; + describe("gappy sync", function () { + it("should copy the last known state to the new timeline", function () { + const eventData = [utils.mkMessage({ user: userId, room: roomId })]; setNextSyncData(eventData); - NEXT_SYNC_DATA.rooms.join[roomId].timeline.limited = true; + NEXT_SYNC_DATA.rooms!.join[roomId].timeline.limited = true; return Promise.all([ httpBackend!.flush("/versions", 1), @@ -569,106 +592,83 @@ describe("MatrixClient room timelines", function() { const room = client!.getRoom(roomId)!; httpBackend!.flush("/messages", 1); - return Promise.all([ - httpBackend!.flush("/sync", 1), - utils.syncPromise(client!), - ]).then(function() { + return Promise.all([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( - "join", - ); + expect(room.currentState.getMember(userId)!.membership).toEqual("join"); expect(room.currentState.getMember(otherUserId)!.name).toEqual("Bob"); - expect(room.currentState.getMember(otherUserId)!.membership).toEqual( - "join", - ); + expect(room.currentState.getMember(otherUserId)!.membership).toEqual("join"); }); }); }); - it("should emit a `RoomEvent.TimelineReset` event when the sync response is `limited`", function() { - const eventData = [ - utils.mkMessage({ user: userId, room: roomId }), - ]; + it("should emit a `RoomEvent.TimelineReset` event when the sync response is `limited`", function () { + const eventData = [utils.mkMessage({ user: userId, room: roomId })]; setNextSyncData(eventData); - NEXT_SYNC_DATA.rooms.join[roomId].timeline.limited = true; + NEXT_SYNC_DATA.rooms!.join[roomId].timeline.limited = true; - return Promise.all([ - httpBackend!.flush("/sync", 1), - utils.syncPromise(client!), - ]).then(() => { + return Promise.all([httpBackend!.flush("/sync", 1), utils.syncPromise(client!)]).then(() => { const room = client!.getRoom(roomId)!; let emitCount = 0; - client!.on(RoomEvent.TimelineReset, function(emitRoom) { + client!.on(RoomEvent.TimelineReset, function (emitRoom) { expect(emitRoom).toEqual(room); emitCount++; }); httpBackend!.flush("/messages", 1); - return Promise.all([ - httpBackend!.flush("/sync", 1), - utils.syncPromise(client!), - ]).then(function() { + return Promise.all([httpBackend!.flush("/sync", 1), utils.syncPromise(client!)]).then(function () { expect(emitCount).toEqual(1); }); }); }); }); - describe('Refresh live timeline', () => { + describe("Refresh live timeline", () => { const initialSyncEventData = [ utils.mkMessage({ user: userId, room: roomId }), utils.mkMessage({ user: userId, room: roomId }), utils.mkMessage({ user: userId, room: roomId }), ]; - const contextUrl = `/rooms/${encodeURIComponent(roomId)}/context/` + - `${encodeURIComponent(initialSyncEventData[2].event_id!)}`; + const contextUrl = + `/rooms/${encodeURIComponent(roomId)}/context/` + + `${encodeURIComponent(initialSyncEventData[2].event_id!)}`; const contextResponse = { start: "start_token", events_before: [initialSyncEventData[1], initialSyncEventData[0]], event: initialSyncEventData[2], events_after: [], - state: [ - USER_MEMBERSHIP_EVENT, - ], + state: [USER_MEMBERSHIP_EVENT], end: "end_token", }; - let room; + let room: Room; beforeEach(async () => { setNextSyncData(initialSyncEventData); // Create a room from the sync - await Promise.all([ - httpBackend!.flushAllExpected(), - utils.syncPromise(client!, 1), - ]); + await Promise.all([httpBackend!.flushAllExpected(), utils.syncPromise(client!, 1)]); // Get the room after the first sync so the room is created room = client!.getRoom(roomId)!; expect(room).toBeTruthy(); }); - it('should clear and refresh messages in timeline', async () => { + it("should clear and refresh messages in timeline", async () => { // `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()` // to construct a new timeline from. - httpBackend!.when("GET", contextUrl) - .respond(200, function() { - // The timeline should be cleared at this point in the refresh - expect(room.timeline.length).toEqual(0); + httpBackend!.when("GET", contextUrl).respond(200, function () { + // The timeline should be cleared at this point in the refresh + expect(room.timeline.length).toEqual(0); - return contextResponse; - }); + return contextResponse; + }); // Refresh the timeline. - await Promise.all([ - room.refreshLiveTimeline(), - httpBackend!.flushAllExpected(), - ]); + await Promise.all([room.refreshLiveTimeline(), httpBackend!.flushAllExpected()]); // Make sure the message are visible const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents(); @@ -680,7 +680,7 @@ describe("MatrixClient room timelines", function() { ]); }); - it('Perfectly merges timelines if a sync finishes while refreshing the timeline', async () => { + it("Perfectly merges timelines if a sync finishes while refreshing the timeline", async () => { // `/context` request for `refreshLiveTimeline()` -> // `getEventTimeline()` to construct a new timeline from. // @@ -689,11 +689,10 @@ 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) - .respond(200, () => { - // Now finally return and make the `/context` request respond - return contextResponse; - }); + httpBackend!.when("GET", contextUrl).respond(200, () => { + // Now finally return and make the `/context` request respond + return contextResponse; + }); // Wait for the timeline to reset(when it goes blank) which means // it's in the middle of the refrsh logic right before the @@ -705,22 +704,20 @@ describe("MatrixClient room timelines", function() { // // We define this here so the event listener is in place before we // call `room.refreshLiveTimeline()`. - const racingSyncEventData = [ - utils.mkMessage({ user: userId, room: roomId }), - ]; + const racingSyncEventData = [utils.mkMessage({ user: userId, room: roomId })]; 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(() => { if (eventFired) { - reject(new Error( - 'TestError: `RoomEvent.TimelineReset` fired but we timed out trying to make' + - 'a `/sync` happen in time.', - )); + reject( + new Error( + "TestError: `RoomEvent.TimelineReset` fired but we timed out trying to make" + + "a `/sync` happen in time.", + ), + ); } else { - reject(new Error( - 'TestError: Timed out while waiting for `RoomEvent.TimelineReset` to fire.', - )); + reject(new Error("TestError: Timed out while waiting for `RoomEvent.TimelineReset` to fire.")); } }, 4000 /* FIXME: Is there a way to reference the current timeout of this test in Jest? */); @@ -734,23 +731,19 @@ 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), - ]); + await Promise.all([httpBackend!.flush("/sync", 1), utils.syncPromise(client!, 1)]); // Make sure the timeline has the racey sync data const afterRaceySyncTimelineEvents = room .getUnfilteredTimelineSet() .getLiveTimeline() .getEvents(); - const afterRaceySyncTimelineEventIds = afterRaceySyncTimelineEvents - .map((event) => event.getId()); - expect(afterRaceySyncTimelineEventIds).toEqual([ - racingSyncEventData[0].event_id, - ]); + const afterRaceySyncTimelineEventIds = afterRaceySyncTimelineEvents.map((event) => + event.getId(), + ); + expect(afterRaceySyncTimelineEventIds).toEqual([racingSyncEventData[0].event_id]); clearTimeout(failTimeout); resolve(); @@ -774,17 +767,12 @@ describe("MatrixClient room timelines", function() { // Make sure sync pagination still works by seeing a new message show up // after refreshing the timeline. - const afterRefreshEventData = [ - utils.mkMessage({ user: userId, room: roomId }), - ]; + const afterRefreshEventData = [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), - ]); + await Promise.all([httpBackend!.flushAllExpected(), utils.syncPromise(client!, 1)]); // Make sure the timeline includes the the events from the `/sync` // that raced and beat us in the middle of everything and the @@ -799,17 +787,24 @@ describe("MatrixClient room timelines", function() { ]); }); - it('Timeline recovers after `/context` request to generate new timeline fails', async () => { + 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).check(() => { - // The timeline should be cleared at this point in the refresh - expect(room.timeline.length).toEqual(0); - }).respond(500, new MatrixError({ - errcode: 'TEST_FAKE_ERROR', - error: 'We purposely intercepted this /context request to make it fail ' + - 'in order to test whether the refresh timeline code is resilient', - })); + httpBackend! + .when("GET", contextUrl) + .check(() => { + // The timeline should be cleared at this point in the refresh + expect(room.timeline.length).toEqual(0); + }) + .respond( + 500, + new MatrixError({ + errcode: "TEST_FAKE_ERROR", + error: + "We purposely intercepted this /context request to make it fail " + + "in order to test whether the refresh timeline code is resilient", + }), + ); // Refresh the timeline and expect it to fail const settledFailedRefreshPromises = await Promise.allSettled([ @@ -818,9 +813,9 @@ describe("MatrixClient room timelines", function() { ]); // We only expect `TEST_FAKE_ERROR` here. Anything else is // unexpected and should fail the test. - if (settledFailedRefreshPromises[0].status === 'fulfilled') { - throw new Error('Expected the /context request to fail with a 500'); - } else if (settledFailedRefreshPromises[0].reason.errcode !== 'TEST_FAKE_ERROR') { + if (settledFailedRefreshPromises[0].status === "fulfilled") { + throw new Error("Expected the /context request to fail with a 500"); + } else if (settledFailedRefreshPromises[0].reason.errcode !== "TEST_FAKE_ERROR") { throw settledFailedRefreshPromises[0].reason; } @@ -830,45 +825,37 @@ describe("MatrixClient room timelines", function() { // `/messages` request for `refreshLiveTimeline()` -> // `getLatestTimeline()` to construct a new timeline from. - httpBackend!.when("GET", `/rooms/${encodeURIComponent(roomId)}/messages`) - .respond(200, function() { - return { - chunk: [{ + httpBackend!.when("GET", `/rooms/${encodeURIComponent(roomId)}/messages`).respond(200, function () { + return { + chunk: [ + { // The latest message in the room event_id: initialSyncEventData[2].event_id, - }], - }; - }); + }, + ], + }; + }); // `/context` request for `refreshLiveTimeline()` -> // `getLatestTimeline()` -> `getEventTimeline()` to construct a new // timeline from. - httpBackend!.when("GET", contextUrl) - .respond(200, function() { - // The timeline should be cleared at this point in the refresh - expect(room.timeline.length).toEqual(0); + httpBackend!.when("GET", contextUrl).respond(200, function () { + // The timeline should be cleared at this point in the refresh + expect(room.timeline.length).toEqual(0); - return contextResponse; - }); + return contextResponse; + }); // Refresh the timeline again but this time it should pass - await Promise.all([ - room.refreshLiveTimeline(), - httpBackend!.flushAllExpected(), - ]); + await Promise.all([room.refreshLiveTimeline(), httpBackend!.flushAllExpected()]); // Make sure sync pagination still works by seeing a new message show up // after refreshing the timeline. - const afterRefreshEventData = [ - utils.mkMessage({ user: userId, room: roomId }), - ]; + const afterRefreshEventData = [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), - ]); + await Promise.all([httpBackend!.flushAllExpected(), utils.syncPromise(client!, 1)]); // Make sure the message are visible const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents(); diff --git a/spec/integ/matrix-client-syncing.spec.ts b/spec/integ/matrix-client-syncing.spec.ts index 8c3969bee7d..0f92dc60d95 100644 --- a/spec/integ/matrix-client-syncing.spec.ts +++ b/spec/integ/matrix-client-syncing.spec.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import 'fake-indexeddb/auto'; +import "fake-indexeddb/auto"; import HttpBackend from "matrix-mock-request"; @@ -34,8 +34,11 @@ import { IStateEvent, IMinimalEvent, NotificationCountType, + IEphemeral, + Room, } from "../../src"; -import { UNREAD_THREAD_NOTIFICATIONS } from '../../src/@types/sync'; +import { ReceiptType } from "../../src/@types/read_receipts"; +import { UNREAD_THREAD_NOTIFICATIONS } from "../../src/@types/sync"; import * as utils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; @@ -90,9 +93,12 @@ describe("MatrixClient syncing", () => { 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); + httpBackend! + .when("GET", "/sync") + .check((req) => { + expect(req.queryParams!.since).toEqual(syncData.next_batch); + }) + .respond(200, syncData); client!.startClient(); @@ -111,13 +117,15 @@ describe("MatrixClient syncing", () => { invite: { [roomId]: { invite_state: { - events: [{ - type: "m.room.member", - state_key: selfUserId, - content: { - membership: "invite", + events: [ + { + type: "m.room.member", + state_key: selfUserId, + content: { + membership: "invite", + }, }, - }], + ], }, }, }, @@ -137,31 +145,35 @@ describe("MatrixClient syncing", () => { account_data: { events: [] }, ephemeral: { events: [] }, state: { - events: [{ - type: "m.room.member", - state_key: selfUserId, - content: { - membership: "leave", - }, - prev_content: { - membership: "invite", + events: [ + { + type: "m.room.member", + state_key: selfUserId, + content: { + membership: "leave", + }, + prev_content: { + membership: "invite", + }, + // XXX: And other fields required on an event }, - // XXX: And other fields required on an event - }], + ], }, timeline: { limited: false, - events: [{ - type: "m.room.member", - state_key: selfUserId, - content: { - membership: "leave", - }, - prev_content: { - membership: "invite", + events: [ + { + type: "m.room.member", + state_key: selfUserId, + content: { + membership: "leave", + }, + prev_content: { + membership: "invite", + }, + // XXX: And other fields required on an event }, - // XXX: And other fields required on an event - }], + ], }, }, }, @@ -176,7 +188,8 @@ describe("MatrixClient syncing", () => { // 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"); @@ -212,9 +225,12 @@ describe("MatrixClient syncing", () => { 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); + 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 }); @@ -227,9 +243,12 @@ describe("MatrixClient syncing", () => { 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); + 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 }); @@ -245,13 +264,15 @@ describe("MatrixClient syncing", () => { invite: { [roomId]: { invite_state: { - events: [{ - type: "m.room.member", - state_key: selfUserId, - content: { - membership: "invite", + events: [ + { + type: "m.room.member", + state_key: selfUserId, + content: { + membership: "invite", + }, }, - }], + ], }, }, }, @@ -279,10 +300,7 @@ describe("MatrixClient syncing", () => { httpBackend!.expectedRequests = []; httpBackend!.when("GET", "").fail(0, new Error("CORS or something")); const prom = client!.startClient(); - await Promise.all([ - expect(prom).resolves.toBeUndefined(), - httpBackend!.flushAllExpected(), - ]); + await Promise.all([expect(prom).resolves.toBeUndefined(), httpBackend!.flushAllExpected()]); }); }); @@ -295,13 +313,19 @@ describe("MatrixClient syncing", () => { 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); + 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); + httpBackend! + .when("GET", "/sync") + .check((req) => { + expect(req.queryParams!.filter).toEqual("a filter id"); + }) + .respond(200, syncData); client!.startClient({ initialSyncLimit: 1 }); @@ -310,9 +334,12 @@ describe("MatrixClient syncing", () => { }); 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); + 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 }); @@ -343,20 +370,28 @@ describe("MatrixClient syncing", () => { timeline: { events: [ utils.mkMessage({ - room: roomOne, user: otherUserId, msg: "hello", + room: roomOne, + user: otherUserId, + msg: "hello", }) as IRoomEvent, ], }, state: { events: [ utils.mkMembership({ - room: roomOne, mship: "join", user: otherUserId, + room: roomOne, + mship: "join", + user: otherUserId, }), utils.mkMembership({ - room: roomOne, mship: "join", user: selfUserId, + room: roomOne, + mship: "join", + user: selfUserId, }), utils.mkEvent({ - type: "m.room.create", room: roomOne, user: selfUserId, + type: "m.room.create", + room: roomOne, + user: selfUserId, content: { creator: selfUserId, }, @@ -369,31 +404,26 @@ describe("MatrixClient syncing", () => { it("should resolve incoming invites from /sync", () => { syncData.rooms.join[roomOne].state.events.push( utils.mkMembership({ - room: roomOne, mship: "invite", user: userC, + room: roomOne, + mship: "invite", + user: userC, }) as IStateEvent, ); httpBackend!.when("GET", "/sync").respond(200, syncData); - httpBackend!.when("GET", "/profile/" + encodeURIComponent(userC)).respond( - 200, { - avatar_url: "mxc://flibble/wibble", - displayname: "The Boss", - }, - ); + httpBackend!.when("GET", "/profile/" + encodeURIComponent(userC)).respond(200, { + avatar_url: "mxc://flibble/wibble", + displayname: "The Boss", + }); client!.startClient({ resolveInvitesToProfiles: true, }); - return Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]).then(() => { + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]).then(() => { const member = client!.getRoom(roomOne)!.getMember(userC)!; expect(member.name).toEqual("The Boss"); - expect( - member.getAvatarUrl("home.server.url", 1, 1, '', false, false), - ).toBeTruthy(); + expect(member.getAvatarUrl("home.server.url", 1, 1, "", false, false)).toBeTruthy(); }); }); @@ -407,7 +437,9 @@ describe("MatrixClient syncing", () => { ]; syncData.rooms.join[roomOne].state.events.push( utils.mkMembership({ - room: roomOne, mship: "invite", user: userC, + room: roomOne, + mship: "invite", + user: userC, }) as IStateEvent, ); @@ -417,10 +449,7 @@ describe("MatrixClient syncing", () => { resolveInvitesToProfiles: true, }); - return Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]).then(() => { + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]).then(() => { const member = client!.getRoom(roomOne)!.getMember(userC)!; expect(member.name).toEqual("The Ghost"); }); @@ -436,7 +465,9 @@ describe("MatrixClient syncing", () => { ]; syncData.rooms.join[roomOne].state.events.push( utils.mkMembership({ - room: roomOne, mship: "invite", user: userC, + room: roomOne, + mship: "invite", + user: userC, }) as IStateEvent, ); @@ -453,10 +484,7 @@ describe("MatrixClient syncing", () => { resolveInvitesToProfiles: true, }); - return Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]).then(() => { + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]).then(() => { expect(latestFiredName).toEqual("The Ghost"); }); }); @@ -464,7 +492,9 @@ describe("MatrixClient syncing", () => { it("should no-op if resolveInvitesToProfiles is not set", () => { syncData.rooms.join[roomOne].state.events.push( utils.mkMembership({ - room: roomOne, mship: "invite", user: userC, + room: roomOne, + mship: "invite", + user: userC, }) as IStateEvent, ); @@ -472,15 +502,10 @@ describe("MatrixClient syncing", () => { client!.startClient(); - return Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]).then(() => { + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]).then(() => { const member = client!.getRoom(roomOne)!.getMember(userC)!; expect(member.name).toEqual(userC); - expect( - member.getAvatarUrl("home.server.url", 1, 1, '', false, false), - ).toBe(null); + expect(member.getAvatarUrl("home.server.url", 1, 1, "", false, false)).toBe(null); }); }); }); @@ -507,10 +532,7 @@ describe("MatrixClient syncing", () => { client!.startClient(); - return Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]).then(() => { + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]).then(() => { expect(client!.getUser(userA)!.presence).toEqual("online"); expect(client!.getUser(userB)!.presence).toEqual("unavailable"); }); @@ -524,120 +546,134 @@ describe("MatrixClient syncing", () => { const syncData = { rooms: { join: { - - }, - }, - }; - syncData.rooms.join[roomOne] = { - timeline: { - events: [ - utils.mkMessage({ - room: roomOne, user: otherUserId, msg: "hello", - }), - ], - }, - state: { - events: [ - utils.mkEvent({ - type: "m.room.name", room: roomOne, user: otherUserId, - content: { - name: "Old room name", + [roomOne]: { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, + user: otherUserId, + msg: "hello", + }), + ], }, - }), - 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, + state: { + events: [ + utils.mkEvent({ + type: "m.room.name", + room: roomOne, + user: otherUserId, + content: { + name: "Old 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, + }, + }), + ], }, - }), - ], - }, - }; - syncData.rooms.join[roomTwo] = { - timeline: { - events: [ - utils.mkMessage({ - room: roomTwo, user: otherUserId, msg: "hiii", - }), - ], - }, - state: { - events: [ - utils.mkMembership({ - room: roomTwo, mship: "join", user: otherUserId, - name: otherDisplayName, - }), - utils.mkMembership({ - room: roomTwo, mship: "join", user: selfUserId, - }), - utils.mkEvent({ - type: "m.room.create", room: roomTwo, user: selfUserId, - content: { - creator: selfUserId, + }, + [roomTwo]: { + timeline: { + events: [ + utils.mkMessage({ + room: roomTwo, + user: otherUserId, + msg: "hiii", + }), + ], }, - }), - ], + state: { + events: [ + utils.mkMembership({ + room: roomTwo, + mship: "join", + user: otherUserId, + name: otherDisplayName, + }), + utils.mkMembership({ + room: roomTwo, + mship: "join", + user: selfUserId, + }), + utils.mkEvent({ + type: "m.room.create", + room: roomTwo, + user: selfUserId, + content: { + creator: selfUserId, + }, + }), + ], + }, + }, + }, }, }; const nextSyncData = { rooms: { join: { - + [roomOne]: { + state: { + events: [ + utils.mkEvent({ + type: "m.room.name", + room: roomOne, + user: selfUserId, + content: { name: "A new room name" }, + }), + ], + }, + }, + [roomTwo]: { + timeline: { + events: [ + utils.mkMessage({ + room: roomTwo, + user: otherUserId, + msg: msgText, + }), + ], + }, + ephemeral: { + events: [ + utils.mkEvent({ + type: "m.typing", + room: roomTwo, + content: { user_ids: [otherUserId] }, + }), + ], + }, + }, }, }, }; - nextSyncData.rooms.join[roomOne] = { - state: { - events: [ - utils.mkEvent({ - type: "m.room.name", room: roomOne, user: selfUserId, - content: { name: "A new room name" }, - }), - ], - }, - }; - - nextSyncData.rooms.join[roomTwo] = { - timeline: { - events: [ - utils.mkMessage({ - room: roomTwo, user: otherUserId, msg: msgText, - }), - ], - }, - ephemeral: { - events: [ - utils.mkEvent({ - type: "m.typing", room: roomTwo, - content: { user_ids: [otherUserId] }, - }), - ], - }, - }; - it("should continually recalculate the right room name.", () => { httpBackend!.when("GET", "/sync").respond(200, syncData); httpBackend!.when("GET", "/sync").respond(200, nextSyncData); client!.startClient(); - return Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(2), - ]).then(() => { + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent(2)]).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, - ); + expect(room.name).toEqual(nextSyncData.rooms.join[roomOne].state.events[0].content?.name); }); }); @@ -647,10 +683,7 @@ describe("MatrixClient syncing", () => { client!.startClient(); - return Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(2), - ]).then(() => { + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent(2)]).then(() => { const room = client!.getRoom(roomTwo)!; // should have added the message from /events expect(room.timeline.length).toEqual(2); @@ -663,10 +696,7 @@ describe("MatrixClient syncing", () => { httpBackend!.when("GET", "/sync").respond(200, nextSyncData); client!.startClient(); - return Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(2), - ]).then(() => { + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent(2)]).then(() => { const room = client!.getRoom(roomTwo)!; // should use the display name of the other person. expect(room.name).toEqual(otherDisplayName); @@ -679,10 +709,7 @@ describe("MatrixClient syncing", () => { client!.startClient(); - return Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(2), - ]).then(() => { + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent(2)]).then(() => { const room = client!.getRoom(roomTwo)!; let member = room.getMember(otherUserId)!; expect(member).toBeTruthy(); @@ -702,118 +729,122 @@ describe("MatrixClient syncing", () => { httpBackend!.when("GET", "/sync").respond(200, nextSyncData); client!.startClient(); - return Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(2), - ]).then(() => { + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent(2)]).then(() => { const room = client!.getRoom(roomOne)!; const stateAtStart = room.getLiveTimeline().getState(EventTimeline.BACKWARDS)!; - const startRoomNameEvent = stateAtStart.getStateEvents('m.room.name', ''); - expect(startRoomNameEvent!.getContent().name).toEqual('Old room name'); + const startRoomNameEvent = stateAtStart.getStateEvents("m.room.name", ""); + expect(startRoomNameEvent!.getContent().name).toEqual("Old room name"); const stateAtEnd = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - const endRoomNameEvent = stateAtEnd.getStateEvents('m.room.name', ''); - expect(endRoomNameEvent!.getContent().name).toEqual('A new room name'); + const endRoomNameEvent = stateAtEnd.getStateEvents("m.room.name", ""); + expect(endRoomNameEvent!.getContent().name).toEqual("A new room name"); }); }); - xit("should update power levels for users in a room", () => { - - }); - - xit("should update the room topic", () => { + xit("should update power levels for users in a room", () => {}); - }); + xit("should update the room topic", () => {}); describe("onMarkerStateEvent", () => { const normalMessageEvent = utils.mkMessage({ - room: roomOne, user: otherUserId, msg: "hello", + room: roomOne, + user: otherUserId, + msg: "hello", }); - it('new marker event *NOT* from the room creator in a subsequent syncs ' + - 'should *NOT* mark the timeline as needing a refresh', async () => { - const roomCreateEvent = utils.mkEvent({ - type: "m.room.create", room: roomOne, user: otherUserId, - content: { - creator: otherUserId, - room_version: '9', - }, - }); - const normalFirstSync = { - next_batch: "batch_token", - rooms: { - join: {}, - }, - }; - normalFirstSync.rooms.join[roomOne] = { - timeline: { - events: [normalMessageEvent], - prev_batch: "pagTok", - }, - state: { - events: [roomCreateEvent], - }, - }; + it( + "new marker event *NOT* from the room creator in a subsequent syncs " + + "should *NOT* mark the timeline as needing a refresh", + async () => { + const roomCreateEvent = utils.mkEvent({ + type: "m.room.create", + room: roomOne, + user: otherUserId, + content: { + creator: otherUserId, + room_version: "9", + }, + }); + const normalFirstSync = { + next_batch: "batch_token", + rooms: { + join: { + [roomOne]: { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }, + }, + }, + }; - const nextSyncData = { - next_batch: "batch_token", - rooms: { - join: {}, - }, - }; - nextSyncData.rooms.join[roomOne] = { - timeline: { - events: [ - // In subsequent syncs, a marker event in timeline - // range should normally trigger - // `timelineNeedsRefresh=true` but this marker isn't - // being sent by the room creator so it has no - // special meaning in existing room versions. - utils.mkEvent({ - type: UNSTABLE_MSC2716_MARKER.name, - room: roomOne, - // The important part we're testing is here! - // `userC` is not the room creator. - user: userC, - skey: "", - content: { - "m.insertion_id": "$abc", + const nextSyncData = { + next_batch: "batch_token", + rooms: { + join: { + [roomOne]: { + timeline: { + events: [ + // In subsequent syncs, a marker event in timeline + // range should normally trigger + // `timelineNeedsRefresh=true` but this marker isn't + // being sent by the room creator so it has no + // special meaning in existing room versions. + utils.mkEvent({ + type: UNSTABLE_MSC2716_MARKER.name, + room: roomOne, + // The important part we're testing is here! + // `userC` is not the room creator. + user: userC, + skey: "", + content: { + "m.insertion_id": "$abc", + }, + }), + ], + prev_batch: "pagTok", + }, }, - }), - ], - prev_batch: "pagTok", - }, - }; + }, + }, + }; - // Ensure the marker is being sent by someone who is not the room creator - // because this is the main thing we're testing in this spec. - const markerEvent = nextSyncData.rooms.join[roomOne].timeline.events[0]; - expect(markerEvent.sender).toBeDefined(); - expect(markerEvent.sender).not.toEqual(roomCreateEvent.sender); + // Ensure the marker is being sent by someone who is not the room creator + // because this is the main thing we're testing in this spec. + const markerEvent = nextSyncData.rooms.join[roomOne].timeline.events[0]; + 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(); - await Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(2), - ]); + client!.startClient(); + await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent(2)]); - const room = client!.getRoom(roomOne)!; - expect(room.getTimelineNeedsRefresh()).toEqual(false); - }); + const room = client!.getRoom(roomOne)!; + expect(room.getTimelineNeedsRefresh()).toEqual(false); + }, + ); - [{ - label: 'In existing room versions (when the room creator sends the MSC2716 events)', - roomVersion: '9', - }, { - label: 'In a MSC2716 supported room version', - roomVersion: 'org.matrix.msc2716v3', - }].forEach((testMeta) => { + [ + { + label: "In existing room versions (when the room creator sends the MSC2716 events)", + roomVersion: "9", + }, + { + label: "In a MSC2716 supported room version", + roomVersion: "org.matrix.msc2716v3", + }, + ].forEach((testMeta) => { describe(testMeta.label, () => { const roomCreateEvent = utils.mkEvent({ - type: "m.room.create", room: roomOne, user: otherUserId, + type: "m.room.create", + room: roomOne, + user: otherUserId, content: { creator: otherUserId, room_version: testMeta.roomVersion, @@ -821,7 +852,9 @@ describe("MatrixClient syncing", () => { }); const markerEventFromRoomCreator = utils.mkEvent({ - type: UNSTABLE_MSC2716_MARKER.name, room: roomOne, user: otherUserId, + type: UNSTABLE_MSC2716_MARKER.name, + room: roomOne, + user: otherUserId, skey: "", content: { "m.insertion_id": "$abc", @@ -831,312 +864,316 @@ describe("MatrixClient syncing", () => { const normalFirstSync = { next_batch: "batch_token", rooms: { - join: {}, + join: { + [roomOne]: { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }, + }, }, }; - normalFirstSync.rooms.join[roomOne] = { - timeline: { - events: [normalMessageEvent], - prev_batch: "pagTok", + + it( + "no marker event in sync response " + + "should *NOT* mark the timeline as needing a refresh (check for a sane default)", + async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: { + [roomOne]: { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }, + }, + }, + }; + + httpBackend!.when("GET", "/sync").respond(200, syncData); + + client!.startClient(); + await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]); + + const room = client!.getRoom(roomOne)!; + expect(room.getTimelineNeedsRefresh()).toEqual(false); }, - state: { - events: [roomCreateEvent], + ); + + it( + "marker event already sent within timeline range when you join " + + "should *NOT* mark the timeline as needing a refresh (timelineWasEmpty)", + async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: { + [roomOne]: { + timeline: { + events: [markerEventFromRoomCreator], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }, + }, + }, + }; + + httpBackend!.when("GET", "/sync").respond(200, syncData); + + client!.startClient(); + await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]); + + const room = client!.getRoom(roomOne)!; + expect(room.getTimelineNeedsRefresh()).toEqual(false); }, - }; + ); + + it( + "marker event already sent before joining (in state) " + + "should *NOT* mark the timeline as needing a refresh (timelineWasEmpty)", + async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: { + [roomOne]: { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent, markerEventFromRoomCreator], + }, + }, + }, + }, + }; + + httpBackend!.when("GET", "/sync").respond(200, syncData); + + client!.startClient(); + await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]); + + const room = client!.getRoom(roomOne)!; + expect(room.getTimelineNeedsRefresh()).toEqual(false); + }, + ); + + it( + "new marker event in a subsequent syncs timeline range " + + "should mark the timeline as needing a refresh", + async () => { + const nextSyncData = { + next_batch: "batch_token", + rooms: { + join: { + [roomOne]: { + timeline: { + events: [ + // In subsequent syncs, a marker event in timeline + // range should trigger `timelineNeedsRefresh=true` + markerEventFromRoomCreator, + ], + prev_batch: "pagTok", + }, + }, + }, + }, + }; + + const markerEventId = nextSyncData.rooms.join[roomOne].timeline.events[0].event_id; - it('no marker event in sync response '+ - 'should *NOT* mark the timeline as needing a refresh (check for a sane default)', async () => { - const syncData = { + // Only do the first sync + httpBackend!.when("GET", "/sync").respond(200, normalFirstSync); + client!.startClient(); + await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]); + + // Get the room after the first sync so the room is created + const room = client!.getRoom(roomOne)!; + + let emitCount = 0; + 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); + await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]); + + expect(room.getTimelineNeedsRefresh()).toEqual(true); + // Make sure `RoomEvent.HistoryImportedWithinTimeline` was emitted + expect(emitCount).toEqual(1); + }, + ); + + // Mimic a marker event being sent far back in the scroll back but since our last sync + it("new marker event in sync state should mark the timeline as needing a refresh", async () => { + const nextSyncData = { next_batch: "batch_token", rooms: { - join: {}, - }, - }; - syncData.rooms.join[roomOne] = { - timeline: { - events: [normalMessageEvent], - prev_batch: "pagTok", - }, - state: { - events: [roomCreateEvent], + join: { + [roomOne]: { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, + user: otherUserId, + msg: "hello again", + }), + ], + prev_batch: "pagTok", + }, + state: { + events: [ + // In subsequent syncs, a marker event in state + // should trigger `timelineNeedsRefresh=true` + markerEventFromRoomCreator, + ], + }, + }, + }, }, }; - httpBackend!.when("GET", "/sync").respond(200, syncData); + httpBackend!.when("GET", "/sync").respond(200, normalFirstSync); + httpBackend!.when("GET", "/sync").respond(200, nextSyncData); client!.startClient(); - await Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]); + await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent(2)]); const room = client!.getRoom(roomOne)!; - expect(room.getTimelineNeedsRefresh()).toEqual(false); + expect(room.getTimelineNeedsRefresh()).toEqual(true); }); - - it('marker event already sent within timeline range when you join ' + - 'should *NOT* mark the timeline as needing a refresh (timelineWasEmpty)', async () => { - const syncData = { - next_batch: "batch_token", - rooms: { - join: {}, - }, - }; - syncData.rooms.join[roomOne] = { - timeline: { - events: [markerEventFromRoomCreator], - prev_batch: "pagTok", - }, - state: { - events: [roomCreateEvent], - }, - }; - - httpBackend!.when("GET", "/sync").respond(200, syncData); - - client!.startClient(); - await Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]); - - const room = client!.getRoom(roomOne)!; - expect(room.getTimelineNeedsRefresh()).toEqual(false); - }); - - it('marker event already sent before joining (in state) ' + - 'should *NOT* mark the timeline as needing a refresh (timelineWasEmpty)', async () => { - const syncData = { - next_batch: "batch_token", - rooms: { - join: {}, - }, - }; - syncData.rooms.join[roomOne] = { - timeline: { - events: [normalMessageEvent], - prev_batch: "pagTok", - }, - state: { - events: [ - roomCreateEvent, - markerEventFromRoomCreator, - ], - }, - }; - - httpBackend!.when("GET", "/sync").respond(200, syncData); - - client!.startClient(); - await Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]); - - const room = client!.getRoom(roomOne)!; - expect(room.getTimelineNeedsRefresh()).toEqual(false); - }); - - it('new marker event in a subsequent syncs timeline range ' + - 'should mark the timeline as needing a refresh', async () => { - const nextSyncData = { - next_batch: "batch_token", - rooms: { - join: {}, - }, - }; - nextSyncData.rooms.join[roomOne] = { - timeline: { - events: [ - // In subsequent syncs, a marker event in timeline - // range should trigger `timelineNeedsRefresh=true` - markerEventFromRoomCreator, - ], - prev_batch: "pagTok", - }, - }; - - 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(); - await Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]); - - // Get the room after the first sync so the room is created - const room = client!.getRoom(roomOne)!; - - let emitCount = 0; - 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); - await Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]); - - expect(room.getTimelineNeedsRefresh()).toEqual(true); - // Make sure `RoomEvent.HistoryImportedWithinTimeline` was emitted - expect(emitCount).toEqual(1); - }); - - // Mimic a marker event being sent far back in the scroll back but since our last sync - it('new marker event in sync state should mark the timeline as needing a refresh', async () => { - const nextSyncData = { - next_batch: "batch_token", - rooms: { - join: {}, - }, - }; - nextSyncData.rooms.join[roomOne] = { - timeline: { - events: [ - utils.mkMessage({ - room: roomOne, user: otherUserId, msg: "hello again", - }), - ], - prev_batch: "pagTok", - }, - state: { - events: [ - // In subsequent syncs, a marker event in state - // should trigger `timelineNeedsRefresh=true` - markerEventFromRoomCreator, - ], - }, - }; - - httpBackend!.when("GET", "/sync").respond(200, normalFirstSync); - httpBackend!.when("GET", "/sync").respond(200, nextSyncData); - - client!.startClient(); - await Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(2), - ]); - - const room = client!.getRoom(roomOne)!; - expect(room.getTimelineNeedsRefresh()).toEqual(true); - }); - }); - }); - }); + }); + }); + }); // Make sure the state listeners work and events are re-emitted properly from // the client regardless if we reset and refresh the timeline. - describe('state listeners and re-registered when RoomEvent.CurrentStateUpdated is fired', () => { + describe("state listeners and re-registered when RoomEvent.CurrentStateUpdated is fired", () => { const EVENTS = [ utils.mkMessage({ - room: roomOne, user: userA, msg: "we", + room: roomOne, + user: userA, + msg: "we", }), utils.mkMessage({ - room: roomOne, user: userA, msg: "could", + room: roomOne, + user: userA, + msg: "could", }), utils.mkMessage({ - room: roomOne, user: userA, msg: "be", + room: roomOne, + user: userA, + msg: "be", }), utils.mkMessage({ - room: roomOne, user: userA, msg: "heroes", + room: roomOne, + user: userA, + msg: "heroes", }), ]; const SOME_STATE_EVENT = utils.mkEvent({ event: true, - type: 'org.matrix.test_state', + type: "org.matrix.test_state", room: roomOne, user: userA, skey: "", content: { - "foo": "bar", + foo: "bar", }, }); const USER_MEMBERSHIP_EVENT = utils.mkMembership({ - room: roomOne, mship: "join", user: userA, + room: roomOne, + mship: "join", + user: userA, }); // This appears to work even if we comment out // `RoomEvent.CurrentStateUpdated` part which triggers everything to // re-listen after the `room.currentState` reference changes. I'm // not sure how it's getting re-emitted. - 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(); - await Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]); - - // Get the room after the first sync so the room is created - const room = client!.getRoom(roomOne)!; - expect(room).toBeTruthy(); + 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(); + await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]); + + // Get the room after the first sync so the room is created + const room = client!.getRoom(roomOne)!; + expect(room).toBeTruthy(); + + let stateEventEmitCount = 0; + client!.on(RoomStateEvent.Update, () => { + stateEventEmitCount += 1; + }); - let stateEventEmitCount = 0; - client!.on(RoomStateEvent.Update, () => { - stateEventEmitCount += 1; - }); + // Cause `RoomStateEvent.Update` to be fired + room.currentState.setStateEvents([SOME_STATE_EVENT]); + // Make sure we can listen to the room state events before the reset + expect(stateEventEmitCount).toEqual(1); - // Cause `RoomStateEvent.Update` to be fired - room.currentState.setStateEvents([SOME_STATE_EVENT]); - // Make sure we can listen to the room state events before the reset - expect(stateEventEmitCount).toEqual(1); - - // Make a `limited` sync which will cause a `room.resetLiveTimeline` - const limitedSyncData = { - next_batch: "batch_token", - rooms: { - join: {}, - }, - }; - limitedSyncData.rooms.join[roomOne] = { - timeline: { - events: [ - utils.mkMessage({ - room: roomOne, user: otherUserId, msg: "world", - }), - ], - // The important part, make the sync `limited` - limited: true, - prev_batch: "newerTok", - }, - }; - httpBackend!.when("GET", "/sync").respond(200, limitedSyncData); + // Make a `limited` sync which will cause a `room.resetLiveTimeline` + const limitedSyncData = { + next_batch: "batch_token", + rooms: { + join: { + [roomOne]: { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, + user: otherUserId, + msg: "world", + }), + ], + // The important part, make the sync `limited` + limited: true, + prev_batch: "newerTok", + }, + }, + }, + }, + }; + httpBackend!.when("GET", "/sync").respond(200, limitedSyncData); - await Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]); + await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]); - // This got incremented again from processing the sync response - expect(stateEventEmitCount).toEqual(2); + // This got incremented again from processing the sync response + expect(stateEventEmitCount).toEqual(2); - // Cause `RoomStateEvent.Update` to be fired - room.currentState.setStateEvents([SOME_STATE_EVENT]); - // Make sure we can still listen to the room state events after the reset - expect(stateEventEmitCount).toEqual(3); - }); + // Cause `RoomStateEvent.Update` to be fired + room.currentState.setStateEvents([SOME_STATE_EVENT]); + // Make sure we can still listen to the room state events after the reset + expect(stateEventEmitCount).toEqual(3); + }, + ); // Make sure it re-registers the state listeners after the // `room.currentState` reference changes - it("should be able to listen to state events even after " + - "refreshing the timeline", async () => { - const testClientWithTimelineSupport = new TestClient( - selfUserId, - "DEVICE", - selfAccessToken, - undefined, - { timelineSupport: true }, - ); + it("should be able to listen to state events even after " + "refreshing the timeline", async () => { + const testClientWithTimelineSupport = new TestClient(selfUserId, "DEVICE", selfAccessToken, undefined, { + timelineSupport: true, + }); httpBackend = testClientWithTimelineSupport.httpBackend; httpBackend!.when("GET", "/versions").respond(200, {}); httpBackend!.when("GET", "/pushrules").respond(200, {}); @@ -1146,10 +1183,7 @@ describe("MatrixClient syncing", () => { // Create a room from the sync httpBackend!.when("GET", "/sync").respond(200, syncData); client!.startClient(); - await Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]); + await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]); // Get the room after the first sync so the room is created const room = client!.getRoom(roomOne)!; @@ -1166,28 +1200,23 @@ describe("MatrixClient syncing", () => { expect(stateEventEmitCount).toEqual(1); 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, () => { - return { - start: "start_token", - events_before: [EVENTS[1], EVENTS[0]], - event: EVENTS[2], - events_after: [EVENTS[3]], - state: [ - USER_MEMBERSHIP_EVENT, - ], - end: "end_token", - }; - }); + const contextUrl = + `/rooms/${encodeURIComponent(roomOne)}/context/` + + `${encodeURIComponent(eventsInRoom[0].event_id!)}`; + httpBackend!.when("GET", contextUrl).respond(200, () => { + return { + start: "start_token", + events_before: [EVENTS[1], EVENTS[0]], + event: EVENTS[2], + events_after: [EVENTS[3]], + state: [USER_MEMBERSHIP_EVENT], + end: "end_token", + }; + }); // Refresh the timeline. This will cause the `room.currentState` // reference to change - await Promise.all([ - room.refreshLiveTimeline(), - httpBackend!.flushAllExpected(), - ]); + await Promise.all([room.refreshLiveTimeline(), httpBackend!.flushAllExpected()]); // Cause `RoomStateEvent.Update` to be fired room.currentState.setStateEvents([SOME_STATE_EVENT]); @@ -1202,57 +1231,56 @@ describe("MatrixClient syncing", () => { const syncData = { next_batch: "batch_token", rooms: { - join: {}, - }, - }; - syncData.rooms.join[roomOne] = { - timeline: { - events: [ - utils.mkMessage({ - room: roomOne, user: otherUserId, msg: "hello", - }), - ], - prev_batch: "pagTok", + join: { + [roomOne]: { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, + user: otherUserId, + msg: "hello", + }), + ], + prev_batch: "pagTok", + }, + }, + }, }, }; httpBackend!.when("GET", "/sync").respond(200, syncData); client!.startClient(); - return Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]); + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]); }); it("should set the back-pagination token on new rooms", () => { const syncData = { next_batch: "batch_token", rooms: { - join: {}, - }, - }; - syncData.rooms.join[roomTwo] = { - timeline: { - events: [ - utils.mkMessage({ - room: roomTwo, user: otherUserId, msg: "roomtwo", - }), - ], - prev_batch: "roomtwotok", + join: { + [roomTwo]: { + timeline: { + events: [ + utils.mkMessage({ + room: roomTwo, + user: otherUserId, + msg: "roomtwo", + }), + ], + prev_batch: "roomtwotok", + }, + }, + }, }, }; httpBackend!.when("GET", "/sync").respond(200, syncData); - return Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]).then(() => { + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]).then(() => { const room = client!.getRoom(roomTwo)!; expect(room).toBeTruthy(); - const tok = room.getLiveTimeline() - .getPaginationToken(EventTimeline.BACKWARDS); + const tok = room.getLiveTimeline().getPaginationToken(EventTimeline.BACKWARDS); expect(tok).toEqual("roomtwotok"); }); }); @@ -1261,18 +1289,21 @@ describe("MatrixClient syncing", () => { const syncData = { next_batch: "batch_token", rooms: { - join: {}, - }, - }; - syncData.rooms.join[roomOne] = { - timeline: { - events: [ - utils.mkMessage({ - room: roomOne, user: otherUserId, msg: "world", - }), - ], - limited: true, - prev_batch: "newerTok", + join: { + [roomOne]: { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, + user: otherUserId, + msg: "world", + }), + ], + limited: true, + prev_batch: "newerTok", + }, + }, + }, }, }; httpBackend!.when("GET", "/sync").respond(200, syncData); @@ -1288,10 +1319,7 @@ describe("MatrixClient syncing", () => { expect(tok).toEqual("newerTok"); }); - return Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]).then(() => { + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]).then(() => { const room = client!.getRoom(roomOne)!; const tl = room.getLiveTimeline(); expect(tl.getEvents().length).toEqual(1); @@ -1304,42 +1332,56 @@ describe("MatrixClient syncing", () => { const syncData = { rooms: { join: { - - }, - }, - }; - 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: "Old 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, + [roomOne]: { + ephemeral: { + events: [], + } as IEphemeral, + 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: "Old 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, + }, + }), + ], + } as Partial, + }, + }, }, }; @@ -1351,34 +1393,34 @@ describe("MatrixClient syncing", () => { it("should sync receipts from /sync.", () => { const ackEvent = syncData.rooms.join[roomOne].timeline.events[0]; - const receipt = {}; - receipt[ackEvent.event_id] = { + const receipt: Record = {}; + receipt[ackEvent.event_id!] = { "m.read": {}, }; - receipt[ackEvent.event_id]["m.read"][userC] = { + receipt[ackEvent.event_id!]["m.read"][userC] = { ts: 176592842636, }; - syncData.rooms.join[roomOne].ephemeral.events = [{ - content: receipt, - room_id: roomOne, - type: "m.receipt", - }]; + syncData.rooms.join[roomOne].ephemeral.events = [ + { + content: receipt, + type: "m.receipt", + }, + ]; httpBackend!.when("GET", "/sync").respond(200, syncData); client!.startClient(); - return Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]).then(() => { + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]).then(() => { const room = client!.getRoom(roomOne)!; - expect(room.getReceiptsForEvent(new MatrixEvent(ackEvent))).toEqual([{ - type: "m.read", - userId: userC, - data: { - ts: 176592842636, + expect(room.getReceiptsForEvent(new MatrixEvent(ackEvent))).toEqual([ + { + type: "m.read", + userId: userC, + data: { + ts: 176592842636, + }, }, - }]); + ]); }); }); }); @@ -1390,32 +1432,47 @@ describe("MatrixClient syncing", () => { rooms: { join: { [roomOne]: { + ephemeral: { + events: [], + }, timeline: { events: [ utils.mkMessage({ - room: roomOne, user: otherUserId, msg: "hello", + room: roomOne, + user: otherUserId, + msg: "hello", }), utils.mkMessage({ - room: roomOne, user: otherUserId, msg: "world", + room: roomOne, + user: otherUserId, + msg: "world", }), ], }, state: { events: [ utils.mkEvent({ - type: "m.room.name", room: roomOne, user: otherUserId, + type: "m.room.name", + room: roomOne, + user: otherUserId, content: { name: "Room name", }, }), utils.mkMembership({ - room: roomOne, mship: "join", user: otherUserId, + room: roomOne, + mship: "join", + user: otherUserId, }), utils.mkMembership({ - room: roomOne, mship: "join", user: selfUserId, + room: roomOne, + mship: "join", + user: selfUserId, }), utils.mkEvent({ - type: "m.room.create", room: roomOne, user: selfUserId, + type: "m.room.create", + room: roomOne, + user: selfUserId, content: { creator: selfUserId, }, @@ -1425,12 +1482,12 @@ describe("MatrixClient syncing", () => { }, }, }, - }; + } as unknown as ISyncResponse; it("should sync unread notifications.", () => { syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = { [THREAD_ID]: { - "highlight_count": 2, - "notification_count": 5, + highlight_count: 2, + notification_count: 5, }, }; @@ -1438,29 +1495,66 @@ describe("MatrixClient syncing", () => { client!.startClient(); - return Promise.all([ - httpBackend!.flushAllExpected(), - awaitSyncEvent(), - ]).then(() => { + 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)", () => { + it("caches unknown threads receipts and replay them when the thread is created", async () => { + const THREAD_ID = "$unknownthread:localhost"; - }); + const receipt = { + type: "m.receipt", + room_id: "!foo:bar", + content: { + "$event1:localhost": { + [ReceiptType.Read]: { + "@alice:localhost": { ts: 666, thread_id: THREAD_ID }, + }, + }, + }, + }; + syncData.rooms.join[roomOne].ephemeral.events = [receipt]; - xit("should sync when the user explicitly calls joinRoom", () => { + httpBackend!.when("GET", "/sync").respond(200, syncData); + client!.startClient(); + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]).then(() => { + const room = client?.getRoom(roomOne); + expect(room).toBeInstanceOf(Room); + + expect(room?.cachedThreadReadReceipts.has(THREAD_ID)).toBe(true); + + const thread = room!.createThread(THREAD_ID, undefined, [], true); + + expect(room?.cachedThreadReadReceipts.has(THREAD_ID)).toBe(false); + + const receipt = thread.getReadReceiptForUserId("@alice:localhost"); + + expect(receipt).toStrictEqual({ + data: { + thread_id: "$unknownthread:localhost", + ts: 666, + }, + eventId: "$event1:localhost", + }); + }); }); }); + 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)", + () => {}, + ); + + xit("should sync when the user explicitly calls joinRoom", () => {}); + }); + describe("syncLeftRooms", () => { beforeEach((done) => { client!.startClient(); @@ -1476,20 +1570,26 @@ describe("MatrixClient syncing", () => { }); 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, - }, - }); - }).respond(200, { filter_id: "another_id" }); + httpBackend! + .when("POST", "/filter") + .check((req) => { + expect(req.data).toEqual({ + room: { + timeline: { limit: 1 }, + include_leave: true, + }, + }); + }) + .respond(200, { filter_id: "another_id" }); const prom = new Promise((resolve) => { - httpBackend!.when("GET", "/sync").check((req) => { - expect(req.queryParams!.filter).toEqual("another_id"); - resolve(); - }).respond(200, {}); + httpBackend! + .when("GET", "/sync") + .check((req) => { + expect(req.queryParams!.filter).toEqual("another_id"); + resolve(); + }) + .respond(200, {}); }); client!.syncLeftRooms(); @@ -1509,18 +1609,20 @@ describe("MatrixClient syncing", () => { const syncData = { next_batch: "batch_token", rooms: { - leave: {}, - }, - }; - - syncData.rooms.leave[roomTwo] = { - timeline: { - events: [ - utils.mkMessage({ - room: roomTwo, user: otherUserId, msg: "hello", - }), - ], - prev_batch: "pagTok", + leave: { + [roomTwo]: { + timeline: { + events: [ + utils.mkMessage({ + room: roomTwo, + user: otherUserId, + msg: "hello", + }), + ], + prev_batch: "pagTok", + }, + }, + }, }, }; @@ -1533,8 +1635,7 @@ describe("MatrixClient syncing", () => { return Promise.all([ client!.syncLeftRooms().then(() => { const room = client!.getRoom(roomTwo)!; - const tok = room.getLiveTimeline().getPaginationToken( - EventTimeline.BACKWARDS); + const tok = room.getLiveTimeline().getPaginationToken(EventTimeline.BACKWARDS); expect(tok).toEqual("pagTok"); }), @@ -1559,36 +1660,43 @@ describe("MatrixClient syncing", () => { messages: { start: "start", end: "end", - chunk: [{ - content: { body: "Message 1" }, - type: "m.room.message", - event_id: "$eventId1", + chunk: [ + { + content: { body: "Message 1" }, + type: "m.room.message", + event_id: "$eventId1", + sender: userA, + origin_server_ts: 12313525, + room_id: roomOne, + }, + { + content: { body: "Message 2" }, + type: "m.room.message", + event_id: "$eventId2", + sender: userB, + origin_server_ts: 12315625, + room_id: roomOne, + }, + ], + }, + state: [ + { + content: { name: "Room Name" }, + type: "m.room.name", + event_id: "$eventId", sender: userA, - origin_server_ts: 12313525, - room_id: roomOne, - }, { - content: { body: "Message 2" }, - type: "m.room.message", - event_id: "$eventId2", - sender: userB, - origin_server_ts: 12315625, + origin_server_ts: 12314525, + state_key: "", room_id: roomOne, - }], - }, - state: [{ - content: { name: "Room Name" }, - type: "m.room.name", - event_id: "$eventId", - sender: userA, - origin_server_ts: 12314525, - state_key: "", - room_id: roomOne, - }], - presence: [{ - content: {}, - type: "m.presence", - sender: userA, - }], + }, + ], + presence: [ + { + content: {}, + type: "m.presence", + sender: userA, + }, + ], }); httpBackend!.when("GET", "/events").respond(200, { chunk: [] }); @@ -1628,10 +1736,7 @@ describe("MatrixClient syncing", () => { }, }); - await Promise.all([ - client!.startClient(), - httpBackend!.flushAllExpected(), - ]); + await Promise.all([client!.startClient(), httpBackend!.flushAllExpected()]); const eventA = client?.getAccountData("a"); expect(eventA).not.toBe(eventA1); @@ -1651,8 +1756,8 @@ describe("MatrixClient syncing", () => { /** * waits for the MatrixClient to emit one or more 'sync' events. * - * @param {Number?} numSyncs number of syncs to wait for - * @returns {Promise} promise which resolves after the sync events have happened + * @param numSyncs - number of syncs to wait for + * @returns promise which resolves after the sync events have happened */ function awaitSyncEvent(numSyncs?: number) { return utils.syncPromise(client!, numSyncs); @@ -1669,13 +1774,9 @@ describe("MatrixClient syncing (IndexedDB version)", () => { }; 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 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, {}); @@ -1691,13 +1792,15 @@ describe("MatrixClient syncing (IndexedDB version)", () => { invite: { [roomId]: { invite_state: { - events: [{ - type: "m.room.member", - state_key: selfUserId, - content: { - membership: "invite", + events: [ + { + type: "m.room.member", + state_key: selfUserId, + content: { + membership: "invite", + }, }, - }], + ], }, }, }, diff --git a/spec/integ/megolm-backup.spec.ts b/spec/integ/megolm-backup.spec.ts index 492e4f1dc13..adbfb70d7fa 100644 --- a/spec/integ/megolm-backup.spec.ts +++ b/spec/integ/megolm-backup.spec.ts @@ -23,22 +23,23 @@ import { TestClient } from "../TestClient"; import { IEvent } from "../../src"; import { MatrixEvent, MatrixEventEvent } from "../../src/models/event"; -const ROOM_ID = '!ROOM:ID'; +const ROOM_ID = "!ROOM:ID"; -const SESSION_ID = 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc'; +const SESSION_ID = "o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc"; const ENCRYPTED_EVENT: Partial = { - type: 'm.room.encrypted', + type: "m.room.encrypted", content: { - algorithm: 'm.megolm.v1.aes-sha2', - sender_key: 'SENDER_CURVE25519', + algorithm: "m.megolm.v1.aes-sha2", + sender_key: "SENDER_CURVE25519", session_id: SESSION_ID, - ciphertext: 'AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N' - + 'CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl' - + 'mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs', + ciphertext: + "AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N" + + "CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl" + + "mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs", }, - room_id: '!ROOM:ID', - event_id: '$event1', + room_id: "!ROOM:ID", + event_id: "$event1", origin_server_ts: 1507753886000, }; @@ -47,19 +48,20 @@ const CURVE25519_KEY_BACKUP_DATA: IKeyBackupSession = { forwarded_count: 0, is_verified: false, session_data: { - ciphertext: '2z2M7CZ+azAiTHN1oFzZ3smAFFt+LEOYY6h3QO3XXGdw' - + '6YpNn/gpHDO6I/rgj1zNd4FoTmzcQgvKdU8kN20u5BWRHxaHTZ' - + 'Slne5RxE6vUdREsBgZePglBNyG0AogR/PVdcrv/v18Y6rLM5O9' - + 'SELmwbV63uV9Kuu/misMxoqbuqEdG7uujyaEKtjlQsJ5MGPQOy' - + 'Syw7XrnesSwF6XWRMxcPGRV0xZr3s9PI350Wve3EncjRgJ9IGF' - + 'ru1bcptMqfXgPZkOyGvrphHoFfoK7nY3xMEHUiaTRfRIjq8HNV' - + '4o8QY1qmWGnxNBQgOlL8MZlykjg3ULmQ3DtFfQPj/YYGS3jzxv' - + 'C+EBjaafmsg+52CTeK3Rswu72PX450BnSZ1i3If4xWAUKvjTpe' - + 'Ug5aDLqttOv1pITolTJDw5W/SD+b5rjEKg1CFCHGEGE9wwV3Nf' - + 'QHVCQL+dfpd7Or0poy4dqKMAi3g0o3Tg7edIF8d5rREmxaALPy' - + 'iie8PHD8mj/5Y0GLqrac4CD6+Mop7eUTzVovprjg', - mac: '5lxYBHQU80M', - ephemeral: '/Bn0A4UMFwJaDDvh0aEk1XZj3k1IfgCxgFY9P9a0b14', + ciphertext: + "2z2M7CZ+azAiTHN1oFzZ3smAFFt+LEOYY6h3QO3XXGdw" + + "6YpNn/gpHDO6I/rgj1zNd4FoTmzcQgvKdU8kN20u5BWRHxaHTZ" + + "Slne5RxE6vUdREsBgZePglBNyG0AogR/PVdcrv/v18Y6rLM5O9" + + "SELmwbV63uV9Kuu/misMxoqbuqEdG7uujyaEKtjlQsJ5MGPQOy" + + "Syw7XrnesSwF6XWRMxcPGRV0xZr3s9PI350Wve3EncjRgJ9IGF" + + "ru1bcptMqfXgPZkOyGvrphHoFfoK7nY3xMEHUiaTRfRIjq8HNV" + + "4o8QY1qmWGnxNBQgOlL8MZlykjg3ULmQ3DtFfQPj/YYGS3jzxv" + + "C+EBjaafmsg+52CTeK3Rswu72PX450BnSZ1i3If4xWAUKvjTpe" + + "Ug5aDLqttOv1pITolTJDw5W/SD+b5rjEKg1CFCHGEGE9wwV3Nf" + + "QHVCQL+dfpd7Or0poy4dqKMAi3g0o3Tg7edIF8d5rREmxaALPy" + + "iie8PHD8mj/5Y0GLqrac4CD6+Mop7eUTzVovprjg", + mac: "5lxYBHQU80M", + ephemeral: "/Bn0A4UMFwJaDDvh0aEk1XZj3k1IfgCxgFY9P9a0b14", }, }; @@ -82,16 +84,14 @@ function createOlmSession(olmAccount: Olm.Account, recipientTestClient: TestClie const otk = keys[otkId]; const session = new global.Olm.Session(); - session.create_outbound( - olmAccount, recipientTestClient.getDeviceKey(), otk.key, - ); + session.create_outbound(olmAccount, recipientTestClient.getDeviceKey(), otk.key); return session; }); } -describe("megolm key backups", function() { +describe("megolm key backups", function () { if (!global.Olm) { - logger.warn('not running megolm tests: Olm not present'); + logger.warn("not running megolm tests: Olm not present"); return; } const Olm = global.Olm; @@ -99,72 +99,72 @@ describe("megolm key backups", function() { let aliceTestClient: TestClient; const setupTestClient = (): [Account, TestClient] => { - const aliceTestClient = new TestClient( - "@alice:localhost", "xzcvb", "akjgkrgjs", - ); + const aliceTestClient = new TestClient("@alice:localhost", "xzcvb", "akjgkrgjs"); const testOlmAccount = new Olm.Account(); testOlmAccount!.create(); return [testOlmAccount, aliceTestClient]; }; - beforeAll(function() { + beforeAll(function () { return Olm.init(); }); - beforeEach(async function() { + beforeEach(async function () { [testOlmAccount, aliceTestClient] = setupTestClient(); await aliceTestClient!.client.initCrypto(); aliceTestClient!.client.crypto!.backupManager.backupInfo = CURVE25519_BACKUP_INFO; }); - afterEach(function() { + afterEach(function () { return aliceTestClient!.stop(); }); - it("Alice checks key backups when receiving a message she can't decrypt", function() { + it("Alice checks key backups when receiving a message she can't decrypt", function () { const syncResponse = { next_batch: 1, rooms: { - join: {}, - }, - }; - syncResponse.rooms.join[ROOM_ID] = { - timeline: { - events: [ENCRYPTED_EVENT], + join: { + [ROOM_ID]: { + timeline: { + events: [ENCRYPTED_EVENT], + }, + }, + }, }, }; - return aliceTestClient!.start().then(() => { - return createOlmSession(testOlmAccount, aliceTestClient); - }).then(() => { - const privkey = decodeRecoveryKey(RECOVERY_KEY); - return aliceTestClient!.client!.crypto!.storeSessionBackupPrivateKey(privkey); - }).then(() => { - aliceTestClient!.httpBackend.when("GET", "/sync").respond(200, syncResponse); - aliceTestClient!.expectKeyBackupQuery( - ROOM_ID, - SESSION_ID, - 200, - CURVE25519_KEY_BACKUP_DATA, - ); - return aliceTestClient!.httpBackend.flushAllExpected(); - }).then(function(): Promise { - const room = aliceTestClient!.client.getRoom(ROOM_ID)!; - const event = room.getLiveTimeline().getEvents()[0]; - - if (event.getContent()) { - return Promise.resolve(event); - } - - return new Promise((resolve, reject) => { - event.once(MatrixEventEvent.Decrypted, (ev) => { - logger.log(`${Date.now()} event ${event.getId()} now decrypted`); - resolve(ev); + return aliceTestClient! + .start() + .then(() => { + return createOlmSession(testOlmAccount, aliceTestClient); + }) + .then(() => { + const privkey = decodeRecoveryKey(RECOVERY_KEY); + return aliceTestClient!.client!.crypto!.storeSessionBackupPrivateKey(privkey); + }) + .then(() => { + aliceTestClient!.httpBackend.when("GET", "/sync").respond(200, syncResponse); + aliceTestClient!.expectKeyBackupQuery(ROOM_ID, SESSION_ID, 200, CURVE25519_KEY_BACKUP_DATA); + return aliceTestClient!.httpBackend.flushAllExpected(); + }) + .then(function (): Promise { + const room = aliceTestClient!.client.getRoom(ROOM_ID)!; + const event = room.getLiveTimeline().getEvents()[0]; + + if (event.getContent()) { + return Promise.resolve(event); + } + + return new Promise((resolve, reject) => { + event.once(MatrixEventEvent.Decrypted, (ev) => { + logger.log(`${Date.now()} event ${event.getId()} now decrypted`); + resolve(ev); + }); }); + }) + .then((event) => { + expect(event.getContent()).toEqual("testytest"); }); - }).then((event) => { - expect(event.getContent()).toEqual('testytest'); - }); }); }); diff --git a/spec/integ/megolm-integ.spec.ts b/spec/integ/megolm-integ.spec.ts index a4891b702f6..334800333a6 100644 --- a/spec/integ/megolm-integ.spec.ts +++ b/spec/integ/megolm-integ.spec.ts @@ -1,6 +1,6 @@ /* Copyright 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. @@ -16,21 +16,25 @@ limitations under the License. */ import anotherjson from "another-json"; +import MockHttpBackend from "matrix-mock-request"; import * as testUtils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; import { logger } from "../../src/logger"; import { + IClaimOTKsResult, IContent, + IDownloadKeyResult, IEvent, - IClaimOTKsResult, IJoinedRoom, + IndexedDBCryptoStore, ISyncResponse, - IDownloadKeyResult, + IUploadKeysRequest, MatrixEvent, MatrixEventEvent, - IndexedDBCryptoStore, Room, + RoomMember, + RoomStateEvent, } from "../../src/matrix"; import { IDeviceKeys } from "../../src/crypto/dehydration"; import { DeviceInfo } from "../../src/crypto/deviceinfo"; @@ -74,20 +78,20 @@ function encryptOlmEvent(opts: { recipient_keys: { ed25519: opts.recipient.getSigningKey(), }, - sender: opts.sender || '@bob:xyz', - type: opts.plaintype || 'm.test', + sender: opts.sender || "@bob:xyz", + type: opts.plaintype || "m.test", }; return { content: { - algorithm: 'm.olm.v1.curve25519-aes-sha2', + algorithm: "m.olm.v1.curve25519-aes-sha2", ciphertext: { [opts.recipient.getDeviceKey()]: opts.p2pSession.encrypt(JSON.stringify(plaintext)), }, sender_key: opts.senderKey, }, - sender: opts.sender || '@bob:xyz', - type: 'm.room.encrypted', + sender: opts.sender || "@bob:xyz", + type: "m.room.encrypted", }; } @@ -104,7 +108,7 @@ function encryptMegolmEvent(opts: { const plaintext = opts.plaintext || {}; if (!plaintext.content) { plaintext.content = { - body: '42', + body: "42", msgtype: "m.text", }; } @@ -117,7 +121,7 @@ function encryptMegolmEvent(opts: { } return { - event_id: 'test_megolm_event_' + Math.random(), + event_id: "test_megolm_event_" + Math.random(), content: { algorithm: "m.megolm.v1.aes-sha2", ciphertext: opts.groupSession.encrypt(JSON.stringify(plaintext)), @@ -142,12 +146,12 @@ function encryptGroupSessionKey(opts: { recipient: opts.recipient, p2pSession: opts.p2pSession, plaincontent: { - algorithm: 'm.megolm.v1.aes-sha2', + algorithm: "m.megolm.v1.aes-sha2", room_id: opts.room_id, session_id: opts.groupSession.session_id(), session_key: opts.groupSession.session_key(), }, - plaintype: 'm.room_key', + plaintype: "m.room_key", }); } @@ -163,17 +167,17 @@ function getSyncResponse(roomMembers: string[]): ISyncResponse { events: [ testUtils.mkEventCustom({ sender: roomMembers[0], - type: 'm.room.encryption', - state_key: '', + type: "m.room.encryption", + state_key: "", content: { - algorithm: 'm.megolm.v1.aes-sha2', + algorithm: "m.megolm.v1.aes-sha2", }, }), ], }, timeline: { events: [], - prev_batch: '', + prev_batch: "", }, ephemeral: { events: [] }, account_data: { events: [] }, @@ -183,7 +187,7 @@ function getSyncResponse(roomMembers: string[]): ISyncResponse { for (let i = 0; i < roomMembers.length; i++) { roomResponse.state.events.push( testUtils.mkMembershipCustom({ - membership: 'join', + membership: "join", sender: roomMembers[i], }), ); @@ -200,43 +204,152 @@ function getSyncResponse(roomMembers: string[]): ISyncResponse { }; } +/** + * Establish an Olm Session with the test user + * + * Waits for the test user to upload their keys, then sends a /sync response with a to-device message which will + * establish an Olm session. + * + * @param testClient: a TestClient for the user under test, which we expect to upload account keys, and to make a + * /sync request which we will respond to. + * @param peerOlmAccount: an OlmAccount which will be used to initiate the Olm session. + */ +async function establishOlmSession(testClient: TestClient, peerOlmAccount: Olm.Account): Promise { + const peerE2EKeys = JSON.parse(peerOlmAccount.identity_keys()); + const p2pSession = await createOlmSession(peerOlmAccount, testClient); + const olmEvent = encryptOlmEvent({ + senderKey: peerE2EKeys.curve25519, + recipient: testClient, + p2pSession: p2pSession, + }); + testClient.httpBackend.when("GET", "/sync").respond(200, { + next_batch: 1, + to_device: { events: [olmEvent] }, + }); + await testClient.flushSync(); + return p2pSession; +} + +/** + * Expect that the client shares keys with the given recipient + * + * Waits for an HTTP request to send the encrypted m.room_key to-device message; decrypts it and uses it + * to establish an Olm InboundGroupSession. + * + * @param senderMockHttpBackend - MockHttpBackend for the sender + * + * @param recipientUserID - the user id of the expected recipient + * + * @param recipientOlmAccount - Olm.Account for the recipient + * + * @param recipientOlmSession - an Olm.Session for the recipient, which must already have exchanged pre-key + * messages with the sender. Alternatively, null, in which case we will expect a pre-key message. + * + * @returns the established inbound group session + */ +async function expectSendRoomKey( + senderMockHttpBackend: MockHttpBackend, + recipientUserID: string, + recipientOlmAccount: Olm.Account, + recipientOlmSession: Olm.Session | null = null, +): Promise { + const Olm = global.Olm; + const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"]; + + let inboundGroupSession: Olm.InboundGroupSession; + + senderMockHttpBackend.when("PUT", "/sendToDevice/m.room.encrypted/").respond(200, (_path, content: any) => { + const m = content.messages[recipientUserID].DEVICE_ID; + const ct = m.ciphertext[testRecipientKey]; + + if (!recipientOlmSession) { + expect(ct.type).toEqual(0); // pre-key message + recipientOlmSession = new Olm.Session(); + recipientOlmSession.create_inbound(recipientOlmAccount, ct.body); + } else { + expect(ct.type).toEqual(1); // regular message + } + + const decrypted = JSON.parse(recipientOlmSession.decrypt(ct.type, ct.body)); + expect(decrypted.type).toEqual("m.room_key"); + inboundGroupSession = new Olm.InboundGroupSession(); + inboundGroupSession.create(decrypted.content.session_key); + return {}; + }); + + expect(await senderMockHttpBackend.flush("/sendToDevice/m.room.encrypted/", 1, 1000)).toEqual(1); + return inboundGroupSession!; +} + +/** + * Expect that the client sends an encrypted event + * + * Waits for an HTTP request to send an encrypted message in the test room. + * + * @param senderMockHttpBackend - MockHttpBackend for the sender + * + * @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will + * be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed. + * + * @returns The content of the successfully-decrypted event + */ +async function expectSendMegolmMessage( + senderMockHttpBackend: MockHttpBackend, + inboundGroupSessionPromise: Promise, +): Promise> { + let encryptedMessageContent: IContent | null = null; + senderMockHttpBackend.when("PUT", "/send/m.room.encrypted/").respond(200, function (_path, content: IContent) { + encryptedMessageContent = content; + return { + event_id: "$event_id", + }; + }); + + expect(await senderMockHttpBackend.flush("/send/m.room.encrypted/", 1, 1000)).toEqual(1); + + // In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now. + const inboundGroupSession = await inboundGroupSessionPromise; + + const r: any = inboundGroupSession.decrypt(encryptedMessageContent!.ciphertext); + logger.log("Decrypted received megolm message", r); + return JSON.parse(r.plaintext); +} + describe("megolm", () => { if (!global.Olm) { - logger.warn('not running megolm tests: Olm not present'); + logger.warn("not running megolm tests: Olm not present"); return; } const Olm = global.Olm; let testOlmAccount = {} as unknown as Olm.Account; - let testSenderKey = ''; - let aliceTestClient = new TestClient( - "@alice:localhost", "device2", "access_token2", - ); + let testSenderKey = ""; + let aliceTestClient = new TestClient("@alice:localhost", "device2", "access_token2"); /** * Get the device keys for testOlmAccount in a format suitable for a * response to /keys/query * - * @param {string} userId The user ID to query for - * @returns {IDownloadKeyResult} The fake query response + * @param userId - The user ID to query for + * @returns The fake query response */ function getTestKeysQueryResponse(userId: string): IDownloadKeyResult { const testE2eKeys = JSON.parse(testOlmAccount.identity_keys()); const testDeviceKeys: IDeviceKeys = { - algorithms: ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2'], - device_id: 'DEVICE_ID', + algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], + device_id: "DEVICE_ID", keys: { - 'curve25519:DEVICE_ID': testE2eKeys.curve25519, - 'ed25519:DEVICE_ID': testE2eKeys.ed25519, + "curve25519:DEVICE_ID": testE2eKeys.curve25519, + "ed25519:DEVICE_ID": testE2eKeys.ed25519, }, user_id: userId, }; const j = anotherjson.stringify(testDeviceKeys); const sig = testOlmAccount.sign(j); - testDeviceKeys.signatures = { [userId]: { 'ed25519:DEVICE_ID': sig } }; + testDeviceKeys.signatures = { [userId]: { "ed25519:DEVICE_ID": sig } }; return { - device_keys: { [userId]: { 'DEVICE_ID': testDeviceKeys } }, + device_keys: { [userId]: { DEVICE_ID: testDeviceKeys } }, failures: {}, }; } @@ -245,8 +358,8 @@ describe("megolm", () => { * Get a one-time key for testOlmAccount in a format suitable for a * response to /keys/claim - * @param {string} userId The user ID to query for - * @returns {IClaimOTKsResult} The fake key claim response + * @param userId - The user ID to query for + * @returns The fake key claim response */ function getTestKeysClaimResponse(userId: string): IClaimOTKsResult { testOlmAccount.generate_one_time_keys(1); @@ -260,19 +373,17 @@ describe("megolm", () => { const sig = testOlmAccount.sign(j); const keyResult = { ...unsignedKeyResult, - signatures: { [userId]: { 'ed25519:DEVICE_ID': sig } }, + signatures: { [userId]: { "ed25519:DEVICE_ID": sig } }, }; return { - one_time_keys: { [userId]: { 'DEVICE_ID': { ['signed_curve25519:' + keyId]: keyResult } } }, + one_time_keys: { [userId]: { DEVICE_ID: { ["signed_curve25519:" + keyId]: keyResult } } }, failures: {}, }; } beforeEach(async () => { - aliceTestClient = new TestClient( - "@alice:localhost", "xzcvb", "akjgkrgjs", - ); + aliceTestClient = new TestClient("@alice:localhost", "xzcvb", "akjgkrgjs"); await aliceTestClient.client.initCrypto(); testOlmAccount = new Olm.Account(); @@ -327,8 +438,10 @@ describe("megolm", () => { const room = aliceTestClient.client.getRoom(ROOM_ID)!; const event = room.getLiveTimeline().getEvents()[0]; expect(event.isEncrypted()).toBe(true); - const decryptedEvent = await testUtils.awaitDecryption(event); - expect(decryptedEvent.getContent().body).toEqual('42'); + + // it probably won't be decrypted yet, because it takes a while to process the olm keys + const decryptedEvent = await testUtils.awaitDecryption(event, { waitOnDecryptionFailure: true }); + expect(decryptedEvent.getContent().body).toEqual("42"); }); it("Alice receives a megolm message before the session keys", async () => { @@ -365,7 +478,7 @@ describe("megolm", () => { await aliceTestClient.flushSync(); const room = aliceTestClient.client.getRoom(ROOM_ID)!; - expect(room.getLiveTimeline().getEvents()[0].getContent().msgtype).toEqual('m.bad.encrypted'); + expect(room.getLiveTimeline().getEvents()[0].getContent().msgtype).toEqual("m.bad.encrypted"); // now she gets the room_key event aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { @@ -379,7 +492,7 @@ describe("megolm", () => { const event = room.getLiveTimeline().getEvents()[0]; let decryptedEvent: MatrixEvent; - if (event.getContent().msgtype != 'm.bad.encrypted') { + if (event.getContent().msgtype != "m.bad.encrypted") { decryptedEvent = event; } else { decryptedEvent = await new Promise((resolve) => { @@ -389,7 +502,7 @@ describe("megolm", () => { }); }); } - expect(decryptedEvent.getContent().body).toEqual('42'); + expect(decryptedEvent.getContent().body).toEqual("42"); }); it("Alice gets a second room_key message", async () => { @@ -456,228 +569,265 @@ describe("megolm", () => { const room = aliceTestClient.client.getRoom(ROOM_ID)!; await room.decryptCriticalEvents(); const event = room.getLiveTimeline().getEvents()[0]; - expect(event.getContent().body).toEqual('42'); + expect(event.getContent().body).toEqual("42"); }); - it('Alice sends a megolm message', async () => { - aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} }, failures: {} }); + it("Alice sends a megolm message", async () => { + aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await aliceTestClient.start(); - // establish an olm session with alice - const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); - - const syncResponse = getSyncResponse(['@bob:xyz']); - - const olmEvent = encryptOlmEvent({ - senderKey: testSenderKey, - recipient: aliceTestClient, - p2pSession: p2pSession, - }); - - syncResponse.to_device = { events: [olmEvent] }; + const p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount); - aliceTestClient.httpBackend.when('GET', '/sync').respond(200, syncResponse); + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse(["@bob:xyz"])); await aliceTestClient.flushSync(); // start out with the device unknown - the send should be rejected. - aliceTestClient.httpBackend.when('POST', '/keys/query').respond( - 200, getTestKeysQueryResponse('@bob:xyz'), - ); - aliceTestClient.httpBackend.when('POST', '/keys/query').respond( - 200, getTestKeysQueryResponse('@bob:xyz'), - ); + 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(() => { - throw new Error("sendTextMessage failed on an unknown device"); - }, (e) => { - expect(e.name).toEqual("UnknownDeviceError"); - }), + aliceTestClient.client.sendTextMessage(ROOM_ID, "test").then( + () => { + throw new Error("sendTextMessage failed on an unknown device"); + }, + (e) => { + expect(e.name).toEqual("UnknownDeviceError"); + }, + ), aliceTestClient.httpBackend.flushAllExpected(), ]); // mark the device as known, and resend. - aliceTestClient.client.setDeviceKnown('@bob:xyz', 'DEVICE_ID'); - - let inboundGroupSession: Olm.InboundGroupSession; - aliceTestClient.httpBackend.when( - 'PUT', '/sendToDevice/m.room.encrypted/', - ).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)); - - expect(decrypted.type).toEqual('m.room_key'); - inboundGroupSession = new Olm.InboundGroupSession(); - inboundGroupSession.create(decrypted.content.session_key); - return {}; - }); - - aliceTestClient.httpBackend.when( - 'PUT', '/send/', - ).respond(200, (_path, content: IContent) => { - const ct = content.ciphertext; - const r: any = inboundGroupSession.decrypt(ct); - logger.log('Decrypted received megolm message', r); - - expect(r.message_index).toEqual(0); - const decrypted = JSON.parse(r.plaintext); - expect(decrypted.type).toEqual('m.room.message'); - expect(decrypted.content.body).toEqual('test'); - - return { event_id: '$event_id' }; - }); + aliceTestClient.client.setDeviceKnown("@bob:xyz", "DEVICE_ID"); const room = aliceTestClient.client.getRoom(ROOM_ID)!; const pendingMsg = room.getPendingEvents()[0]; + const inboundGroupSessionPromise = expectSendRoomKey( + aliceTestClient.httpBackend, + "@bob:xyz", + testOlmAccount, + p2pSession, + ); + await Promise.all([ aliceTestClient.client.resendEvent(pendingMsg, room), - - // the crypto stuff can take a while, so give the requests a whole second. - aliceTestClient.httpBackend.flushAllExpected({ timeout: 1000 }), + expectSendMegolmMessage(aliceTestClient.httpBackend, inboundGroupSessionPromise), ]); }); it("We shouldn't attempt to send to blocked devices", async () => { - aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} }, failures: {} }); + aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await aliceTestClient.start(); - // establish an olm session with alice - const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); - const syncResponse = getSyncResponse(['@bob:xyz']); - - const olmEvent = encryptOlmEvent({ - senderKey: testSenderKey, - recipient: aliceTestClient, - p2pSession: p2pSession, - }); - - syncResponse.to_device = { events: [olmEvent] }; - aliceTestClient.httpBackend.when('GET', '/sync').respond(200, syncResponse); + await establishOlmSession(aliceTestClient, testOlmAccount); + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse(["@bob:xyz"])); await aliceTestClient.flushSync(); - logger.log('Forcing alice to download our device keys'); + 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'), - ); + 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', 2), + aliceTestClient.client.downloadKeys(["@bob:xyz"]), + aliceTestClient.httpBackend.flush("/keys/query", 2), ]); - logger.log('Telling alice to block our device'); - aliceTestClient.client.setDeviceBlocked('@bob:xyz', 'DEVICE_ID'); + logger.log("Telling alice to block our device"); + aliceTestClient.client.setDeviceBlocked("@bob:xyz", "DEVICE_ID"); - logger.log('Telling alice to send a megolm message'); - aliceTestClient.httpBackend.when( - 'PUT', '/send/', - ).respond(200, { - event_id: '$event_id', + logger.log("Telling alice to send a megolm message"); + aliceTestClient.httpBackend.when("PUT", "/send/").respond(200, { + event_id: "$event_id", }); - aliceTestClient.httpBackend.when( - 'PUT', '/sendToDevice/m.room_key.withheld/', - ).respond(200, {}); + aliceTestClient.httpBackend.when("PUT", "/sendToDevice/m.room_key.withheld/").respond(200, {}); await Promise.all([ - aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'), + aliceTestClient.client.sendTextMessage(ROOM_ID, "test"), // the crypto stuff can take a while, so give the requests a whole second. aliceTestClient.httpBackend.flushAllExpected({ timeout: 1000 }), ]); }); - it("We should start a new megolm session when a device is blocked", async () => { - aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} }, failures: {} }); - await aliceTestClient.start(); - // establish an olm session with alice - const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); + describe("get|setGlobalErrorOnUnknownDevices", () => { + it("should raise an error if crypto is disabled", () => { + aliceTestClient.client["cryptoBackend"] = undefined; + expect(() => aliceTestClient.client.setGlobalErrorOnUnknownDevices(true)).toThrowError( + "encryption disabled", + ); + expect(() => aliceTestClient.client.getGlobalErrorOnUnknownDevices()).toThrowError("encryption disabled"); + }); - const syncResponse = getSyncResponse(['@bob:xyz']); + it("should permit sending to unknown devices", async () => { + expect(aliceTestClient.client.getGlobalErrorOnUnknownDevices()).toBeTruthy(); - const olmEvent = encryptOlmEvent({ - senderKey: testSenderKey, - recipient: aliceTestClient, - p2pSession: p2pSession, + aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await aliceTestClient.start(); + const p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount); + + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse(["@bob:xyz"])); + await aliceTestClient.flushSync(); + + // start out with the device unknown - the send should be rejected. + 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( + () => { + throw new Error("sendTextMessage failed on an unknown device"); + }, + (e) => { + expect(e.name).toEqual("UnknownDeviceError"); + }, + ), + aliceTestClient.httpBackend.flushAllExpected(), + ]); + + // enable sending to unknown devices, and resend + aliceTestClient.client.setGlobalErrorOnUnknownDevices(false); + expect(aliceTestClient.client.getGlobalErrorOnUnknownDevices()).toBeFalsy(); + + const room = aliceTestClient.client.getRoom(ROOM_ID)!; + const pendingMsg = room.getPendingEvents()[0]; + + const inboundGroupSessionPromise = expectSendRoomKey( + aliceTestClient.httpBackend, + "@bob:xyz", + testOlmAccount, + p2pSession, + ); + + await Promise.all([ + aliceTestClient.client.resendEvent(pendingMsg, room), + expectSendMegolmMessage(aliceTestClient.httpBackend, inboundGroupSessionPromise), + ]); + }); + }); + + describe("get|setGlobalBlacklistUnverifiedDevices", () => { + it("should raise an error if crypto is disabled", () => { + aliceTestClient.client["cryptoBackend"] = undefined; + expect(() => aliceTestClient.client.setGlobalBlacklistUnverifiedDevices(true)).toThrowError( + "encryption disabled", + ); + expect(() => aliceTestClient.client.getGlobalBlacklistUnverifiedDevices()).toThrowError( + "encryption disabled", + ); + }); + + it("should disable sending to unverified devices", async () => { + aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await aliceTestClient.start(); + const p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount); + + // tell alice we share a room with bob + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse(["@bob:xyz"])); + await aliceTestClient.flushSync(); + + 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", 2), + ]); + + logger.log("Telling alice to block messages to unverified devices"); + expect(aliceTestClient.client.getGlobalBlacklistUnverifiedDevices()).toBeFalsy(); + aliceTestClient.client.setGlobalBlacklistUnverifiedDevices(true); + expect(aliceTestClient.client.getGlobalBlacklistUnverifiedDevices()).toBeTruthy(); + + logger.log("Telling alice to send a megolm message"); + aliceTestClient.httpBackend.when("PUT", "/send/").respond(200, { event_id: "$event_id" }); + aliceTestClient.httpBackend.when("PUT", "/sendToDevice/m.room_key.withheld/").respond(200, {}); + + await Promise.all([ + aliceTestClient.client.sendTextMessage(ROOM_ID, "test"), + aliceTestClient.httpBackend.flushAllExpected({ timeout: 1000 }), + ]); + + // Now, let's mark the device as verified, and check that keys are sent to it. + + logger.log("Marking the device as verified"); + // XXX: this is an integration test; we really ought to do this via the cross-signing dance + const d = aliceTestClient.client.crypto!.deviceList.getStoredDevice("@bob:xyz", "DEVICE_ID")!; + d.verified = DeviceInfo.DeviceVerification.VERIFIED; + aliceTestClient.client.crypto?.deviceList.storeDevicesForUser("@bob:xyz", { DEVICE_ID: d }); + + const inboundGroupSessionPromise = expectSendRoomKey( + aliceTestClient.httpBackend, + "@bob:xyz", + testOlmAccount, + p2pSession, + ); + + logger.log("Asking alice to re-send"); + await Promise.all([ + expectSendMegolmMessage(aliceTestClient.httpBackend, inboundGroupSessionPromise).then((decrypted) => { + expect(decrypted.type).toEqual("m.room.message"); + expect(decrypted.content!.body).toEqual("test"); + }), + aliceTestClient.client.sendTextMessage(ROOM_ID, "test"), + ]); }); + }); - syncResponse.to_device = { events: [olmEvent] }; - aliceTestClient.httpBackend.when('GET', '/sync').respond(200, syncResponse); + it("We should start a new megolm session when a device is blocked", async () => { + aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await aliceTestClient.start(); + const p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount); + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse(["@bob:xyz"])); await aliceTestClient.flushSync(); 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'), - ); + 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.client.downloadKeys(["@bob:xyz"]), aliceTestClient.httpBackend.flushAllExpected(), ]); - await aliceTestClient.client.setDeviceKnown('@bob:xyz', 'DEVICE_ID'); + await aliceTestClient.client.setDeviceKnown("@bob:xyz", "DEVICE_ID"); - logger.log('Telling alice to send a megolm message'); + logger.log("Telling alice to send a megolm message"); let megolmSessionId: string; - aliceTestClient.httpBackend.when( - 'PUT', '/sendToDevice/m.room.encrypted/', - ).respond(200, function(_path, content: any) { - logger.log('sendToDevice: ', content); - const m = content.messages['@bob:xyz'].DEVICE_ID; - const ct = m.ciphertext[testSenderKey]; - expect(ct.type).toEqual(1); // normal message - const decrypted = JSON.parse(p2pSession.decrypt(ct.type, ct.body)); - logger.log('decrypted sendToDevice:', decrypted); - expect(decrypted.type).toEqual('m.room_key'); - megolmSessionId = decrypted.content.session_id; - return {}; - }); - - aliceTestClient.httpBackend.when( - 'PUT', '/send/', - ).respond(200, function(_path, content) { - logger.log('/send:', content); - expect(content.session_id).toEqual(megolmSessionId); - return { - event_id: '$event_id', - }; + const inboundGroupSessionPromise = expectSendRoomKey( + aliceTestClient.httpBackend, + "@bob:xyz", + testOlmAccount, + p2pSession, + ); + inboundGroupSessionPromise.then((igs) => { + megolmSessionId = igs.session_id(); }); await Promise.all([ - aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'), - - // the crypto stuff can take a while, so give the requests a whole second. - aliceTestClient.httpBackend.flushAllExpected({ timeout: 1000 }), + aliceTestClient.client.sendTextMessage(ROOM_ID, "test"), + expectSendMegolmMessage(aliceTestClient.httpBackend, inboundGroupSessionPromise), ]); - logger.log('Telling alice to block our device'); - aliceTestClient.client.setDeviceBlocked('@bob:xyz', 'DEVICE_ID'); + logger.log("Telling alice to block our device"); + aliceTestClient.client.setDeviceBlocked("@bob:xyz", "DEVICE_ID"); - logger.log('Telling alice to send another megolm message'); - aliceTestClient.httpBackend.when( - 'PUT', '/send/', - ).respond(200, function(_path, content) { - logger.log('/send:', content); + logger.log("Telling alice to send another megolm message"); + aliceTestClient.httpBackend.when("PUT", "/send/").respond(200, function (_path, content) { + logger.log("/send:", content); + // make sure that a new session is used expect(content.session_id).not.toEqual(megolmSessionId); return { - event_id: '$event_id', + event_id: "$event_id", }; }); - aliceTestClient.httpBackend.when( - 'PUT', '/sendToDevice/m.room_key.withheld/', - ).respond(200, {}); + aliceTestClient.httpBackend.when("PUT", "/sendToDevice/m.room_key.withheld/").respond(200, {}); await Promise.all([ - aliceTestClient.client.sendTextMessage(ROOM_ID, 'test2'), + aliceTestClient.client.sendTextMessage(ROOM_ID, "test2"), aliceTestClient.httpBackend.flushAllExpected(), ]); }); @@ -690,84 +840,61 @@ describe("megolm", () => { // an encrypted room with just alice const syncResponse = { next_batch: 1, - rooms: { join: { [ROOM_ID]: { state: { events: [ - testUtils.mkEvent({ - type: 'm.room.encryption', - skey: '', - content: { algorithm: 'm.megolm.v1.aes-sha2' }, - }), - testUtils.mkMembership({ - mship: 'join', - sender: aliceTestClient.userId, - }), - ] } } } }, + rooms: { + join: { + [ROOM_ID]: { + state: { + events: [ + testUtils.mkEvent({ + type: "m.room.encryption", + skey: "", + content: { algorithm: "m.megolm.v1.aes-sha2" }, + }), + testUtils.mkMembership({ + mship: "join", + sender: aliceTestClient.userId, + }), + ], + }, + }, + }, + }, }; - aliceTestClient.httpBackend.when('GET', '/sync').respond(200, syncResponse); + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); // the completion of the first initialsync should make Alice // invalidate the device cache for all members in e2e rooms (ie, // herself), and do a key query. - aliceTestClient.expectKeyQuery( - getTestKeysQueryResponse(aliceTestClient.userId!), - ); + aliceTestClient.expectKeyQuery(getTestKeysQueryResponse(aliceTestClient.userId!)); await aliceTestClient.httpBackend.flushAllExpected(); // start out with the device unknown - the send should be rejected. try { - await aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'); + await aliceTestClient.client.sendTextMessage(ROOM_ID, "test"); throw new Error("sendTextMessage succeeded on an unknown device"); } catch (e) { 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']); + 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.httpBackend.when('POST', '/keys/claim').respond( - 200, function(_path, content: IClaimOTKsResult) { - expect(content.one_time_keys[aliceTestClient.userId!].DEVICE_ID) - .toEqual("signed_curve25519"); + aliceTestClient.client.setDeviceKnown(aliceTestClient.userId!, "DEVICE_ID"); + aliceTestClient.httpBackend + .when("POST", "/keys/claim") + .respond(200, function (_path, content: IClaimOTKsResult) { + expect(content.one_time_keys[aliceTestClient.userId!].DEVICE_ID).toEqual("signed_curve25519"); 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: { - messages: { [userId: string]: { [deviceId: string]: Record }}; - }) { - logger.log("sendToDevice: ", content); - const m = content.messages[aliceTestClient.userId!].DEVICE_ID; - const ct = m.ciphertext[testSenderKey]; - expect(ct.type).toEqual(0); // pre-key message - - p2pSession = new Olm.Session(); - p2pSession.create_inbound(testOlmAccount, ct.body); - const decrypted = JSON.parse(p2pSession.decrypt(ct.type, ct.body)); - - expect(decrypted.type).toEqual('m.room_key'); - inboundGroupSession = new Olm.InboundGroupSession(); - inboundGroupSession.create(decrypted.content.session_key); - return {}; - }); + const inboundGroupSessionPromise = expectSendRoomKey( + aliceTestClient.httpBackend, + aliceTestClient.userId!, + testOlmAccount, + ); let decrypted: Partial = {}; - aliceTestClient.httpBackend.when( - 'PUT', '/send/', - ).respond(200, function(_path, content: IContent) { - const ct = content.ciphertext; - const r: any = inboundGroupSession.decrypt(ct); - logger.log('Decrypted received megolm message', r); - decrypted = JSON.parse(r.plaintext); - - return { - event_id: '$event_id', - }; - }); // Grab the event that we'll need to resend const room = aliceTestClient.client.getRoom(ROOM_ID)!; @@ -776,63 +903,49 @@ describe("megolm", () => { const unsentEvent = pendingEvents[0]; await Promise.all([ - aliceTestClient.client.resendEvent(unsentEvent, room), - - // the crypto stuff can take a while, so give the requests a whole second. - aliceTestClient.httpBackend.flushAllExpected({ - timeout: 1000, + aliceTestClient.httpBackend.flush("/keys/claim", 1, 1000), + expectSendMegolmMessage(aliceTestClient.httpBackend, inboundGroupSessionPromise).then((d) => { + decrypted = d; }), + aliceTestClient.client.resendEvent(unsentEvent, room), ]); - expect(decrypted.type).toEqual('m.room.message'); - expect(decrypted.content?.body).toEqual('test'); + expect(decrypted.type).toEqual("m.room.message"); + expect(decrypted.content?.body).toEqual("test"); }); - it('Alice should wait for device list to complete when sending a megolm message', async () => { - aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} }, failures: {} }); + it("Alice should wait for device list to complete when sending a megolm message", async () => { + aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await aliceTestClient.start(); - // establish an olm session with alice - const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); - - const syncResponse = getSyncResponse(['@bob:xyz']); - - const olmEvent = encryptOlmEvent({ - senderKey: testSenderKey, - recipient: aliceTestClient, - p2pSession: p2pSession, - }); - - syncResponse.to_device = { events: [olmEvent] }; + await establishOlmSession(aliceTestClient, testOlmAccount); - aliceTestClient.httpBackend.when('GET', '/sync').respond(200, syncResponse); + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse(["@bob:xyz"])); await aliceTestClient.flushSync(); // this will block - logger.log('Forcing alice to download our device keys'); - const downloadPromise = aliceTestClient.client.downloadKeys(['@bob:xyz']); + 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'), - ); + aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz")); // so will this. - const sendPromise = aliceTestClient.client.sendTextMessage(ROOM_ID, 'test') - .then(() => { + const sendPromise = aliceTestClient.client.sendTextMessage(ROOM_ID, "test").then( + () => { throw new Error("sendTextMessage failed on an unknown device"); - }, (e) => { + }, + (e) => { expect(e.name).toEqual("UnknownDeviceError"); - }); - - aliceTestClient.httpBackend.when('POST', '/keys/query').respond( - 200, getTestKeysQueryResponse('@bob:xyz'), + }, ); + aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz")); + await aliceTestClient.httpBackend.flushAllExpected(); await Promise.all([downloadPromise, sendPromise]); }); it("Alice exports megolm keys and imports them to a new device", async () => { - aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} }, failures: {} }); + aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await aliceTestClient.start(); aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); // establish an olm session with alice @@ -873,16 +986,19 @@ describe("megolm", () => { const room = aliceTestClient.client.getRoom(ROOM_ID)!; await room.decryptCriticalEvents(); - expect(room.getLiveTimeline().getEvents()[0].getContent().body).toEqual('42'); + + // it probably won't be decrypted yet, because it takes a while to process the olm keys + const decryptedEvent = await testUtils.awaitDecryption(room.getLiveTimeline().getEvents()[0], { + waitOnDecryptionFailure: true, + }); + expect(decryptedEvent.getContent().body).toEqual("42"); const exported = await aliceTestClient.client.exportRoomKeys(); // start a new client aliceTestClient.stop(); - aliceTestClient = new TestClient( - "@alice:localhost", "device2", "access_token2", - ); + aliceTestClient = new TestClient("@alice:localhost", "device2", "access_token2"); await aliceTestClient.client.initCrypto(); await aliceTestClient.client.importRoomKeys(exported); await aliceTestClient.start(); @@ -900,7 +1016,7 @@ describe("megolm", () => { await aliceTestClient.flushSync(); const event = room.getLiveTimeline().getEvents()[0]; - expect(event.getContent().body).toEqual('42'); + expect(event.getContent().body).toEqual("42"); }); it("Alice receives an untrusted megolm key, only to receive the trusted one shortly after", async () => { @@ -915,15 +1031,17 @@ describe("megolm", () => { room_id: ROOM_ID, }); await testClient.client.initCrypto(); - const keys = [{ - room_id: ROOM_ID, - algorithm: 'm.megolm.v1.aes-sha2', - session_id: groupSession.session_id(), - session_key: inboundGroupSession.export_session(0), - sender_key: testSenderKey, - forwarding_curve25519_key_chain: [], - sender_claimed_keys: {}, - }]; + const keys = [ + { + room_id: ROOM_ID, + algorithm: "m.megolm.v1.aes-sha2", + session_id: groupSession.session_id(), + session_key: inboundGroupSession.export_session(0), + sender_key: testSenderKey, + forwarding_curve25519_key_chain: [], + sender_claimed_keys: {}, + }, + ]; await testClient.client.importRoomKeys(keys, { untrusted: true }); const event1 = testUtils.mkEvent({ @@ -935,10 +1053,10 @@ describe("megolm", () => { expect(event1.isKeySourceUntrusted()).toBeTruthy(); const event2 = testUtils.mkEvent({ - type: 'm.room_key', + type: "m.room_key", content: { room_id: ROOM_ID, - algorithm: 'm.megolm.v1.aes-sha2', + algorithm: "m.megolm.v1.aes-sha2", session_id: groupSession.session_id(), session_key: groupSession.session_key(), }, @@ -984,7 +1102,7 @@ describe("megolm", () => { }; const messageEncrypted = { - event_id: 'test_megolm_event', + event_id: "test_megolm_event", content: { algorithm: "m.megolm.v1.aes-sha2", ciphertext: groupSession.encrypt(JSON.stringify(plaintext)), @@ -1012,84 +1130,81 @@ describe("megolm", () => { const room = aliceTestClient.client.getRoom(ROOM_ID)!; const event = room.getLiveTimeline().getEvents()[0]; expect(event.isEncrypted()).toBe(true); - const decryptedEvent = await testUtils.awaitDecryption(event); + + // it probably won't be decrypted yet, because it takes a while to process the olm keys + const decryptedEvent = await testUtils.awaitDecryption(event, { waitOnDecryptionFailure: true }); expect(decryptedEvent.getRoomId()).toEqual(ROOM_ID); expect(decryptedEvent.getContent()).toEqual({}); expect(decryptedEvent.getClearContent()).toBeUndefined(); }); - it( - "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, - recipient: aliceTestClient, - p2pSession: p2pSession, - groupSession: groupSession, - room_id: ROOM_ID, - }); + it("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(); - // encrypt a message with the group session - const messageEncrypted = encryptMegolmEvent({ - senderKey: testSenderKey, - groupSession: groupSession, - room_id: ROOM_ID, - }); + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; - const redactionEncrypted = encryptMegolmEvent({ - senderKey: testSenderKey, - groupSession: groupSession, - plaintext: { - room_id: ROOM_ID, - type: "m.room.redaction", - redacts: messageEncrypted.event_id, - content: { reason: "redaction test" }, - }, - }); + // make the room_key event + const roomKeyEncrypted = encryptGroupSessionKey({ + senderKey: testSenderKey, + recipient: aliceTestClient, + p2pSession: p2pSession, + groupSession: groupSession, + room_id: ROOM_ID, + }); - const messageEncryptedWithRedaction = { - ...messageEncrypted, - unsigned: { redacted_because: redactionEncrypted }, - }; + // encrypt a message with the group session + const messageEncrypted = encryptMegolmEvent({ + senderKey: testSenderKey, + groupSession: groupSession, + room_id: ROOM_ID, + }); - const syncResponse = { - next_batch: 1, - to_device: { - events: [roomKeyEncrypted], - }, - rooms: { - join: { - [ROOM_ID]: { timeline: { events: [messageEncryptedWithRedaction] } }, - }, + const redactionEncrypted = encryptMegolmEvent({ + senderKey: testSenderKey, + groupSession: groupSession, + plaintext: { + room_id: ROOM_ID, + type: "m.room.redaction", + redacts: messageEncrypted.event_id, + content: { reason: "redaction test" }, + }, + }); + + const messageEncryptedWithRedaction = { + ...messageEncrypted, + unsigned: { redacted_because: redactionEncrypted }, + }; + + const syncResponse = { + next_batch: 1, + to_device: { + events: [roomKeyEncrypted], + }, + rooms: { + join: { + [ROOM_ID]: { timeline: { events: [messageEncryptedWithRedaction] } }, }, - }; + }, + }; - aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); - await aliceTestClient.flushSync(); + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); + await aliceTestClient.flushSync(); - const room = aliceTestClient.client.getRoom(ROOM_ID)!; - const event = room.getLiveTimeline().getEvents()[0]; - expect(event.isEncrypted()).toBe(true); - await event.attemptDecryption(aliceTestClient.client.crypto!); - expect(event.getContent()).toEqual({}); - const redactionEvent: any = event.getRedactionEvent(); - expect(redactionEvent.content.reason).toEqual("redaction test"); - }, - ); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; + const event = room.getLiveTimeline().getEvents()[0]; + expect(event.isEncrypted()).toBe(true); + 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", - ); + const beccaTestClient = new TestClient("@becca:localhost", "foobar", "bazquux"); await beccaTestClient.client.initCrypto(); await aliceTestClient.start(); @@ -1098,7 +1213,7 @@ describe("megolm", () => { 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" }); + await beccaTestClient.client.setRoomEncryption(ROOM_ID, { algorithm: "m.megolm.v1.aes-sha2" }); const event = new MatrixEvent({ type: "m.room.message", @@ -1130,7 +1245,7 @@ describe("megolm", () => { const aliceOtk = aliceOtks[aliceOtkId]; const p2pSession = new global.Olm.Session(); await beccaTestClient.client.crypto!.cryptoStore.doTxn( - 'readonly', + "readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { beccaTestClient.client.crypto!.cryptoStore.getAccount(txn, (pickledAccount: string | null) => { @@ -1157,7 +1272,7 @@ describe("megolm", () => { recipient: aliceTestClient, p2pSession: p2pSession, plaincontent: { - "algorithm": 'm.megolm.v1.aes-sha2', + "algorithm": "m.megolm.v1.aes-sha2", "room_id": ROOM_ID, "sender_key": content.sender_key, "sender_claimed_ed25519_key": groupSessionKey!.sender_claimed_ed25519_key, @@ -1167,7 +1282,7 @@ describe("megolm", () => { "forwarding_curve25519_key_chain": groupSessionKey!.forwarding_curve25519_key_chain, "org.matrix.msc3061.shared_history": true, }, - plaintype: 'm.forwarded_room_key', + plaintype: "m.forwarded_room_key", }); // Alice receives shared history @@ -1180,31 +1295,39 @@ describe("megolm", () => { // 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', + 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"]), - ); + aliceTestClient.httpBackend + .when("GET", "/sync") + .respond(200, getSyncResponse(["@alice:localhost", "@becca:localhost"])); await aliceTestClient.flushSync(); aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { @@ -1221,15 +1344,13 @@ describe("megolm", () => { const roomEvent = room.getLiveTimeline().getEvents()[0]; expect(roomEvent.isEncrypted()).toBe(true); const decryptedEvent = await testUtils.awaitDecryption(roomEvent); - expect(decryptedEvent.getContent().body).toEqual('test message'); + 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", - ); + const beccaTestClient = new TestClient("@becca:localhost", "foobar", "bazquux"); await beccaTestClient.client.initCrypto(); await aliceTestClient.start(); @@ -1237,7 +1358,7 @@ describe("megolm", () => { 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" }); + await beccaTestClient.client.setRoomEncryption(ROOM_ID, { algorithm: "m.megolm.v1.aes-sha2" }); const event = new MatrixEvent({ type: "m.room.message", @@ -1268,7 +1389,7 @@ describe("megolm", () => { const aliceOtk = aliceOtks[aliceOtkId]; const p2pSession = new global.Olm.Session(); await beccaTestClient.client.crypto!.cryptoStore.doTxn( - 'readonly', + "readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { beccaTestClient.client.crypto!.cryptoStore.getAccount(txn, (pickledAccount: string | null) => { @@ -1295,7 +1416,7 @@ describe("megolm", () => { recipient: aliceTestClient, p2pSession: p2pSession, plaincontent: { - "algorithm": 'm.megolm.v1.aes-sha2', + "algorithm": "m.megolm.v1.aes-sha2", "room_id": ROOM_ID, "sender_key": content.sender_key, "sender_claimed_ed25519_key": groupSessionKey!.sender_claimed_ed25519_key, @@ -1305,7 +1426,7 @@ describe("megolm", () => { "forwarding_curve25519_key_chain": groupSessionKey!.forwarding_curve25519_key_chain, "org.matrix.msc3061.shared_history": true, }, - plaintype: 'm.forwarded_room_key', + plaintype: "m.forwarded_room_key", }); // Alice receives forwarded history from Becca @@ -1318,31 +1439,39 @@ describe("megolm", () => { // 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', + 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"]), - ); + aliceTestClient.httpBackend + .when("GET", "/sync") + .respond(200, getSyncResponse(["@alice:localhost", "@becca:localhost", "@charlie:localhost"])); await aliceTestClient.flushSync(); aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { @@ -1364,4 +1493,101 @@ describe("megolm", () => { await beccaTestClient.stop(); }); + + it("allows sending an encrypted event as soon as room state arrives", async () => { + /* Empirically, clients expect to be able to send encrypted events as soon as the + * RoomStateEvent.NewMember notification is emitted, so test that works correctly. + */ + const testRoomId = "!testRoom:id"; + await aliceTestClient.start(); + + aliceTestClient.httpBackend + .when("POST", "/keys/query") + .respond(200, function (_path, content: IUploadKeysRequest) { + return { device_keys: {} }; + }); + + /* Alice makes the /createRoom call */ + aliceTestClient.httpBackend.when("POST", "/createRoom").respond(200, { room_id: testRoomId }); + await Promise.all([ + aliceTestClient.client.createRoom({ + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { algorithm: "m.megolm.v1.aes-sha2" }, + }, + ], + }), + aliceTestClient.httpBackend.flushAllExpected(), + ]); + + /* The sync arrives in two parts; first the m.room.create... */ + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { + rooms: { + join: { + [testRoomId]: { + timeline: { + events: [ + { + type: "m.room.create", + state_key: "", + event_id: "$create", + }, + { + type: "m.room.member", + state_key: aliceTestClient.getUserId(), + content: { membership: "join" }, + event_id: "$alijoin", + }, + ], + }, + }, + }, + }, + }); + await aliceTestClient.flushSync(); + + // ... and then the e2e event and an invite ... + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { + rooms: { + join: { + [testRoomId]: { + timeline: { + events: [ + { + type: "m.room.encryption", + state_key: "", + content: { algorithm: "m.megolm.v1.aes-sha2" }, + event_id: "$e2e", + }, + { + type: "m.room.member", + state_key: "@other:user", + content: { membership: "invite" }, + event_id: "$otherinvite", + }, + ], + }, + }, + }, + }, + }); + + // as soon as the roomMember arrives, try to send a message + aliceTestClient.client.on(RoomStateEvent.NewMember, (_e, _s, member: RoomMember) => { + if (member.userId == "@other:user") { + aliceTestClient.client.sendMessage(testRoomId, { msgtype: "m.text", body: "Hello, World" }); + } + }); + + // flush the sync and wait for the /send/ request. + aliceTestClient.httpBackend + .when("PUT", "/send/m.room.encrypted/") + .respond(200, (_path, _content) => ({ event_id: "asdfgh" })); + await Promise.all([ + aliceTestClient.flushSync(), + aliceTestClient.httpBackend.flush("/send/m.room.encrypted/", 1), + ]); + }); }); diff --git a/spec/integ/sliding-sync-sdk.spec.ts b/spec/integ/sliding-sync-sdk.spec.ts index 72a7eeaa2e9..165e3142012 100644 --- a/spec/integ/sliding-sync-sdk.spec.ts +++ b/spec/integ/sliding-sync-sdk.spec.ts @@ -22,8 +22,20 @@ import { SlidingSync, SlidingSyncEvent, MSC3575RoomData, SlidingSyncState, Exten import { TestClient } from "../TestClient"; import { IRoomEvent, IStateEvent } from "../../src/sync-accumulator"; import { - MatrixClient, MatrixEvent, NotificationCountType, JoinRule, MatrixError, - EventType, IPushRules, PushRuleKind, TweakName, ClientEvent, RoomMemberEvent, + MatrixClient, + MatrixEvent, + NotificationCountType, + JoinRule, + MatrixError, + EventType, + IPushRules, + PushRuleKind, + TweakName, + ClientEvent, + RoomMemberEvent, + RoomEvent, + Room, + IRoomTimelineData, } from "../../src"; import { SlidingSyncSdk } from "../../src/sliding-sync-sdk"; import { SyncState } from "../../src/sync"; @@ -67,7 +79,7 @@ describe("SlidingSyncSdk", () => { event_id: "$" + eventIdCounter, }; }; - const mkOwnStateEvent = (evType: string, content: object, stateKey = ''): IStateEvent => { + const mkOwnStateEvent = (evType: string, content: object, stateKey = ""): IStateEvent => { eventIdCounter++; return { type: evType, @@ -97,7 +109,7 @@ describe("SlidingSyncSdk", () => { }; // assign client/httpBackend globals - const setupClient = async (testOpts?: Partial) => { + const setupClient = async (testOpts?: Partial) => { testOpts = testOpts || {}; const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken); httpBackend = testClient.httpBackend; @@ -119,13 +131,13 @@ describe("SlidingSyncSdk", () => { }; // find an extension on a SlidingSyncSdk instance - const findExtension = (name: string): Extension => { + const findExtension = (name: string): Extension => { 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; - if (calledExtension && calledExtension.name() === name) { + const calledExtension = mockFn.mock.calls[i][0] as Extension; + if (calledExtension?.name() === name) { return calledExtension; } } @@ -170,6 +182,7 @@ describe("SlidingSyncSdk", () => { const roomE = "!e_with_invite:localhost"; const roomF = "!f_calc_room_name:localhost"; const roomG = "!g_join_invite_counts:localhost"; + const roomH = "!g_num_live:localhost"; const data: Record = { [roomA]: { name: "A", @@ -194,7 +207,6 @@ describe("SlidingSyncSdk", () => { mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""), mkOwnEvent(EventType.RoomMessage, { body: "hello B" }), mkOwnEvent(EventType.RoomMessage, { body: "world B" }), - ], initial: true, }, @@ -275,13 +287,27 @@ describe("SlidingSyncSdk", () => { invited_count: 2, initial: true, }, + [roomH]: { + name: "H", + required_state: [], + timeline: [ + mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""), + mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId), + mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""), + mkOwnEvent(EventType.RoomMessage, { body: "live event" }), + ], + initial: true, + num_live: 1, + }, }; it("can be created with required_state and timeline", () => { mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]); const gotRoom = client!.getRoom(roomA); expect(gotRoom).toBeDefined(); - if (gotRoom == null) { return; } + 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); @@ -291,7 +317,9 @@ describe("SlidingSyncSdk", () => { mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]); const gotRoom = client!.getRoom(roomB); expect(gotRoom).toBeDefined(); - if (gotRoom == null) { return; } + 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); @@ -301,36 +329,73 @@ describe("SlidingSyncSdk", () => { 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); + 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); expect(gotRoom).toBeDefined(); - if (gotRoom == null) { return; } - expect( - gotRoom.getUnreadNotificationCount(NotificationCountType.Total), - ).toEqual(data[roomD].notification_count); + if (gotRoom == null) { + return; + } + expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Total)).toEqual( + data[roomD].notification_count, + ); }); 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; } + 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 live events", () => { + let seenLiveEvent = false; + const listener = ( + ev: MatrixEvent, + room?: Room, + toStartOfTimeline?: boolean, + deleted?: boolean, + timelineData?: IRoomTimelineData, + ) => { + if (timelineData?.liveEvent) { + assertTimelineEvents([ev], data[roomH].timeline.slice(-1)); + seenLiveEvent = true; + } + }; + client!.on(RoomEvent.Timeline, listener); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomH, data[roomH]); + client!.off(RoomEvent.Timeline, listener); + const gotRoom = client!.getRoom(roomH); + expect(gotRoom).toBeDefined(); + if (gotRoom == null) { + return; + } + expect(gotRoom.name).toEqual(data[roomH].name); + expect(gotRoom.getMyMembership()).toEqual("join"); + // check the entire timeline is correct + assertTimelineEvents(gotRoom.getLiveTimeline().getEvents(), data[roomH].timeline); + expect(seenLiveEvent).toBe(true); + }); + 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; } + if (gotRoom == null) { + return; + } expect(gotRoom.getMyMembership()).toEqual("invite"); expect(gotRoom.currentState.getJoinRule()).toEqual(JoinRule.Invite); }); @@ -339,10 +404,10 @@ describe("SlidingSyncSdk", () => { 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); + if (gotRoom == null) { + return; + } + expect(gotRoom.name).toEqual(data[roomF].name); }); describe("updating", () => { @@ -355,7 +420,9 @@ describe("SlidingSyncSdk", () => { }); const gotRoom = client!.getRoom(roomA); expect(gotRoom).toBeDefined(); - if (gotRoom == null) { return; } + if (gotRoom == null) { + return; + } const newTimeline = data[roomA].timeline; newTimeline.push(newEvent); assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-3), newTimeline); @@ -364,18 +431,20 @@ describe("SlidingSyncSdk", () => { it("can update with a new required_state event", async () => { let gotRoom = client!.getRoom(roomB); expect(gotRoom).toBeDefined(); - if (gotRoom == null) { return; } + if (gotRoom == null) { + return; + } expect(gotRoom.getJoinRule()).toEqual(JoinRule.Invite); // default mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, { - required_state: [ - mkOwnStateEvent("m.room.join_rules", { join_rule: "restricted" }, ""), - ], + required_state: [mkOwnStateEvent("m.room.join_rules", { join_rule: "restricted" }, "")], timeline: [], name: data[roomB].name, }); gotRoom = client!.getRoom(roomB); expect(gotRoom).toBeDefined(); - if (gotRoom == null) { return; } + if (gotRoom == null) { + return; + } expect(gotRoom.getJoinRule()).toEqual(JoinRule.Restricted); }); @@ -388,10 +457,10 @@ describe("SlidingSyncSdk", () => { }); const gotRoom = client!.getRoom(roomC); expect(gotRoom).toBeDefined(); - if (gotRoom == null) { return; } - expect( - gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight), - ).toEqual(1); + if (gotRoom == null) { + return; + } + expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(1); }); it("can update with a new notification_count", async () => { @@ -403,10 +472,10 @@ describe("SlidingSyncSdk", () => { }); const gotRoom = client!.getRoom(roomD); expect(gotRoom).toBeDefined(); - if (gotRoom == null) { return; } - expect( - gotRoom.getUnreadNotificationCount(NotificationCountType.Total), - ).toEqual(1); + if (gotRoom == null) { + return; + } + expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(1); }); it("can update with a new joined_count", () => { @@ -418,7 +487,9 @@ describe("SlidingSyncSdk", () => { }); const gotRoom = client!.getRoom(roomG); expect(gotRoom).toBeDefined(); - if (gotRoom == null) { return; } + if (gotRoom == null) { + return; + } expect(gotRoom.getJoinedMemberCount()).toEqual(1); }); @@ -442,11 +513,20 @@ describe("SlidingSyncSdk", () => { }); const gotRoom = client!.getRoom(roomA); expect(gotRoom).toBeDefined(); - if (gotRoom == null) { return; } + if (gotRoom == null) { + return; + } - logger.log("want:", oldTimeline.map((e) => (e.type + " : " + (e.content || {}).body))); - logger.log("got:", gotRoom.getLiveTimeline().getEvents().map( - (e) => (e.getType() + " : " + e.getContent().body)), + logger.log( + "want:", + oldTimeline.map((e) => e.type + " : " + (e.content || {}).body), + ); + logger.log( + "got:", + gotRoom + .getLiveTimeline() + .getEvents() + .map((e) => e.getType() + " : " + e.getContent().body), ); // we expect the timeline now to be oldTimeline (so the old events are in fact old) @@ -466,40 +546,54 @@ describe("SlidingSyncSdk", () => { 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( - SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, - { pos: "h", lists: [], rooms: {}, extensions: {} }, - ); + mockSlidingSync!.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, { + pos: "h", + lists: [], + rooms: {}, + extensions: {}, + }); expect(sdk!.getSyncState()).toEqual(SyncState.Syncing); mockSlidingSync!.emit( - SlidingSyncEvent.Lifecycle, SlidingSyncState.RequestFinished, null, new Error("generic"), + SlidingSyncEvent.Lifecycle, + SlidingSyncState.RequestFinished, + null, + new Error("generic"), ); expect(sdk!.getSyncState()).toEqual(SyncState.Reconnecting); for (let i = 0; i < FAILED_SYNC_ERROR_THRESHOLD; i++) { mockSlidingSync!.emit( - SlidingSyncEvent.Lifecycle, SlidingSyncState.RequestFinished, null, new Error("generic"), + SlidingSyncEvent.Lifecycle, + SlidingSyncState.RequestFinished, + null, + new Error("generic"), ); } expect(sdk!.getSyncState()).toEqual(SyncState.Error); }); it("emits SyncState.Syncing after a previous SyncState.Error", async () => { - mockSlidingSync!.emit( - SlidingSyncEvent.Lifecycle, - SlidingSyncState.Complete, - { pos: "i", lists: [], rooms: {}, extensions: {} }, - ); + mockSlidingSync!.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, { + pos: "i", + lists: [], + rooms: {}, + extensions: {}, + }); 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({ - errcode: "M_UNKNOWN_TOKEN", - message: "Oh no your access token is no longer valid", - })); + 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(); }); @@ -541,7 +635,7 @@ describe("SlidingSyncSdk", () => { }); describe("ExtensionE2EE", () => { - let ext: Extension; + let ext: Extension; beforeAll(async () => { await setupClient({ @@ -607,7 +701,7 @@ describe("SlidingSyncSdk", () => { }); describe("ExtensionAccountData", () => { - let ext: Extension; + let ext: Extension; beforeAll(async () => { await setupClient(); @@ -654,7 +748,6 @@ describe("SlidingSyncSdk", () => { mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId), mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""), mkOwnEvent(EventType.RoomMessage, { body: "hello" }), - ], initial: true, }); @@ -703,18 +796,20 @@ describe("SlidingSyncSdk", () => { const roomId = "!foo:bar"; const pushRulesContent: IPushRules = { global: { - [PushRuleKind.RoomSpecific]: [{ - enabled: true, - default: true, - pattern: "monkey", - actions: [ - { - set_tweak: TweakName.Sound, - value: "default", - }, - ], - rule_id: roomId, - }], + [PushRuleKind.RoomSpecific]: [ + { + enabled: true, + default: true, + pattern: "monkey", + actions: [ + { + set_tweak: TweakName.Sound, + value: "default", + }, + ], + rule_id: roomId, + }, + ], }, }; let pushRule = client!.getRoomPushRule("global", roomId); @@ -733,7 +828,7 @@ describe("SlidingSyncSdk", () => { }); describe("ExtensionToDevice", () => { - let ext: Extension; + let ext: Extension; beforeAll(async () => { await setupClient(); @@ -831,7 +926,7 @@ describe("SlidingSyncSdk", () => { }); describe("ExtensionTyping", () => { - let ext: Extension; + let ext: Extension; beforeAll(async () => { await setupClient(); @@ -930,10 +1025,14 @@ describe("SlidingSyncSdk", () => { }); describe("ExtensionReceipts", () => { - let ext: Extension; + let ext: Extension; const generateReceiptResponse = ( - userId: string, roomId: string, eventId: string, recType: string, ts: number, + userId: string, + roomId: string, + eventId: string, + recType: string, + ts: number, ) => { return { rooms: { @@ -994,9 +1093,7 @@ describe("SlidingSyncSdk", () => { const room = client!.getRoom(roomId)!; expect(room).toBeDefined(); expect(room.getReadReceiptForUserId(alice, true)).toBeNull(); - ext.onResponse( - generateReceiptResponse(alice, roomId, lastEvent.event_id, "m.read", 1234567), - ); + ext.onResponse(generateReceiptResponse(alice, roomId, lastEvent.event_id, "m.read", 1234567)); const receipt = room.getReadReceiptForUserId(alice); expect(receipt).toBeDefined(); expect(receipt?.eventId).toEqual(lastEvent.event_id); @@ -1008,9 +1105,7 @@ describe("SlidingSyncSdk", () => { const roomId = "!room:id"; const alice = "@alice:alice"; const eventId = "$something"; - ext.onResponse( - generateReceiptResponse(alice, roomId, eventId, "m.read", 1234567), - ); + ext.onResponse(generateReceiptResponse(alice, roomId, eventId, "m.read", 1234567)); // we expect it not to crash }); }); diff --git a/spec/integ/sliding-sync.spec.ts b/spec/integ/sliding-sync.spec.ts index 0b1e9fedd4b..4655ca0efd0 100644 --- a/spec/integ/sliding-sync.spec.ts +++ b/spec/integ/sliding-sync.spec.ts @@ -18,7 +18,15 @@ limitations under the License. import EventEmitter from "events"; import MockHttpBackend from "matrix-mock-request"; -import { SlidingSync, SlidingSyncState, ExtensionState, SlidingSyncEvent } from "../../src/sliding-sync"; +import { + SlidingSync, + SlidingSyncState, + ExtensionState, + SlidingSyncEvent, + Extension, + SlidingSyncEventHandlerMap, + MSC3575RoomData, +} from "../../src/sliding-sync"; import { TestClient } from "../TestClient"; import { logger } from "../../src/logger"; import { MatrixClient } from "../../src"; @@ -94,10 +102,14 @@ describe("SlidingSync", () => { is_dm: true, }, }; - const ext = { + const ext: Extension = { name: () => "custom_extension", - onRequest: (initial) => { return { initial: initial }; }, - onResponse: (res) => { return {}; }, + onRequest: (initial) => { + return { initial: initial }; + }, + onResponse: (res) => { + return {}; + }, when: () => ExtensionState.PreProcess, }; slidingSync.modifyRoomSubscriptions(new Set([roomId])); @@ -107,50 +119,56 @@ describe("SlidingSync", () => { slidingSync.start(); // expect everything to be sent - let txnId; - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.debug("got ", body); - expect(body.room_subscriptions).toEqual({ - [roomId]: subInfo, + let txnId: string | undefined; + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.debug("got ", body); + expect(body.room_subscriptions).toEqual({ + [roomId]: subInfo, + }); + expect(body.lists[0]).toEqual(listInfo); + expect(body.extensions).toBeTruthy(); + expect(body.extensions["custom_extension"]).toEqual({ initial: true }); + expect(req.queryParams!["pos"]).toBeUndefined(); + txnId = body.txn_id; + }) + .respond(200, function () { + return { + pos: "11", + lists: [{ count: 5 }], + extensions: {}, + txn_id: txnId, + }; }); - expect(body.lists[0]).toEqual(listInfo); - expect(body.extensions).toBeTruthy(); - expect(body.extensions["custom_extension"]).toEqual({ initial: true }); - expect(req.queryParams!["pos"]).toBeUndefined(); - txnId = body.txn_id; - }).respond(200, function() { - return { - pos: "11", - lists: [{ count: 5 }], - extensions: {}, - txn_id: txnId, - }; - }); await httpBackend!.flushAllExpected(); // expect nothing but ranges and non-initial extensions to be sent - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.debug("got ", body); - expect(body.room_subscriptions).toBeFalsy(); - expect(body.lists[0]).toEqual({ - ranges: [[0, 10]], + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.debug("got ", body); + expect(body.room_subscriptions).toBeFalsy(); + expect(body.lists[0]).toEqual({ + ranges: [[0, 10]], + }); + expect(body.extensions).toBeTruthy(); + expect(body.extensions["custom_extension"]).toEqual({ initial: false }); + expect(req.queryParams!["pos"]).toEqual("11"); + }) + .respond(200, function () { + return { + pos: "12", + lists: [{ count: 5 }], + extensions: {}, + }; }); - expect(body.extensions).toBeTruthy(); - expect(body.extensions["custom_extension"]).toEqual({ initial: false }); - expect(req.queryParams!["pos"]).toEqual("11"); - }).respond(200, function() { - return { - pos: "12", - lists: [{ count: 5 }], - extensions: {}, - }; - }); await httpBackend!.flushAllExpected(); // now we expire the session - httpBackend!.when("POST", syncUrl).respond(400, function() { + httpBackend!.when("POST", syncUrl).respond(400, function () { logger.debug("sending session expired 400"); return { error: "HTTP 400 : session expired", @@ -159,23 +177,26 @@ describe("SlidingSync", () => { await httpBackend!.flushAllExpected(); // ...and everything should be sent again - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.debug("got ", body); - expect(body.room_subscriptions).toEqual({ - [roomId]: subInfo, + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.debug("got ", body); + expect(body.room_subscriptions).toEqual({ + [roomId]: subInfo, + }); + expect(body.lists[0]).toEqual(listInfo); + expect(body.extensions).toBeTruthy(); + expect(body.extensions["custom_extension"]).toEqual({ initial: true }); + expect(req.queryParams!["pos"]).toBeUndefined(); + }) + .respond(200, function () { + return { + pos: "1", + lists: [{ count: 6 }], + extensions: {}, + }; }); - expect(body.lists[0]).toEqual(listInfo); - expect(body.extensions).toBeTruthy(); - expect(body.extensions["custom_extension"]).toEqual({ initial: true }); - expect(req.queryParams!["pos"]).toBeUndefined(); - }).respond(200, function() { - return { - pos: "1", - lists: [{ count: 6 }], - extensions: {}, - }; - }); await httpBackend!.flushAllExpected(); slidingSync.stop(); }); @@ -188,9 +209,7 @@ describe("SlidingSync", () => { const anotherRoomID = "!another:room"; let roomSubInfo = { timeline_limit: 1, - required_state: [ - ["m.room.name", ""], - ], + required_state: [["m.room.name", ""]], }; const wantRoomData = { name: "foo bar", @@ -204,19 +223,22 @@ describe("SlidingSync", () => { // add the subscription slidingSync = new SlidingSync(proxyBaseUrl, [], roomSubInfo, client!, 1); slidingSync.modifyRoomSubscriptions(new Set([roomId])); - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.log("room sub", body); - expect(body.room_subscriptions).toBeTruthy(); - expect(body.room_subscriptions[roomId]).toEqual(roomSubInfo); - }).respond(200, { - pos: "a", - lists: [], - extensions: {}, - rooms: { - [roomId]: wantRoomData, - }, - }); + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("room sub", body); + expect(body.room_subscriptions).toBeTruthy(); + expect(body.room_subscriptions[roomId]).toEqual(roomSubInfo); + }) + .respond(200, { + pos: "a", + lists: [], + extensions: {}, + rooms: { + [roomId]: wantRoomData, + }, + }); const p = listenUntil(slidingSync, "SlidingSync.RoomData", (gotRoomId, gotRoomData) => { expect(gotRoomId).toEqual(roomId); @@ -232,23 +254,24 @@ describe("SlidingSync", () => { // listen for updated request const newSubInfo = { timeline_limit: 100, - required_state: [ - ["m.room.member", "*"], - ], + required_state: [["m.room.member", "*"]], }; - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.log("adjusted sub", body); - expect(body.room_subscriptions).toBeTruthy(); - expect(body.room_subscriptions[roomId]).toEqual(newSubInfo); - }).respond(200, { - pos: "a", - lists: [], - extensions: {}, - rooms: { - [roomId]: wantRoomData, - }, - }); + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("adjusted sub", body); + expect(body.room_subscriptions).toBeTruthy(); + expect(body.room_subscriptions[roomId]).toEqual(newSubInfo); + }) + .respond(200, { + pos: "a", + lists: [], + extensions: {}, + rooms: { + [roomId]: wantRoomData, + }, + }); const p = listenUntil(slidingSync, "SlidingSync.RoomData", (gotRoomId, gotRoomData) => { expect(gotRoomId).toEqual(roomId); @@ -278,21 +301,24 @@ describe("SlidingSync", () => { required_state: [], timeline: [], }; - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.log("new subs", body); - expect(body.room_subscriptions).toBeTruthy(); - // only the new room is sent, the other is sticky - expect(body.room_subscriptions[anotherRoomID]).toEqual(roomSubInfo); - expect(body.room_subscriptions[roomId]).toBeUndefined(); - }).respond(200, { - pos: "b", - lists: [], - extensions: {}, - rooms: { - [anotherRoomID]: anotherRoomData, - }, - }); + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("new subs", body); + expect(body.room_subscriptions).toBeTruthy(); + // only the new room is sent, the other is sticky + expect(body.room_subscriptions[anotherRoomID]).toEqual(roomSubInfo); + expect(body.room_subscriptions[roomId]).toBeUndefined(); + }) + .respond(200, { + pos: "b", + lists: [], + extensions: {}, + rooms: { + [anotherRoomID]: anotherRoomData, + }, + }); const p = listenUntil(slidingSync, "SlidingSync.RoomData", (gotRoomId, gotRoomData) => { expect(gotRoomId).toEqual(anotherRoomID); @@ -308,15 +334,18 @@ describe("SlidingSync", () => { }); it("should be able to unsubscribe from a room", async () => { - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.log("unsub request", body); - expect(body.room_subscriptions).toBeFalsy(); - expect(body.unsubscribe_rooms).toEqual([roomId]); - }).respond(200, { - pos: "b", - lists: [], - }); + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("unsub request", body); + expect(body.room_subscriptions).toBeFalsy(); + expect(body.unsubscribe_rooms).toEqual([roomId]); + }) + .respond(200, { + pos: "b", + lists: [], + }); const p = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; @@ -356,7 +385,10 @@ describe("SlidingSync", () => { timeline: [], }, }; - const newRanges = [[0, 2], [3, 5]]; + const newRanges = [ + [0, 2], + [3, 5], + ]; let slidingSync: SlidingSync; it("should be possible to subscribe to a list", async () => { @@ -365,33 +397,38 @@ describe("SlidingSync", () => { ranges: [[0, 2]], sort: ["by_name"], timeline_limit: 1, - required_state: [ - ["m.room.topic", ""], - ], + required_state: [["m.room.topic", ""]], filters: { is_dm: true, }, }; 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(); - expect(body.lists[0]).toEqual(listReq); - }).respond(200, { - pos: "a", - lists: [{ - count: 500, - ops: [{ - op: "SYNC", - range: [0, 2], - room_ids: Object.keys(rooms), - }], - }], - rooms: rooms, - }); - const listenerData = {}; - const dataListener = (roomId, roomData) => { + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("list", body); + expect(body.lists).toBeTruthy(); + expect(body.lists[0]).toEqual(listReq); + }) + .respond(200, { + pos: "a", + lists: [ + { + count: 500, + ops: [ + { + op: "SYNC", + range: [0, 2], + room_ids: Object.keys(rooms), + }, + ], + }, + ], + rooms: rooms, + }); + const listenerData: Record = {}; + const dataListener: SlidingSyncEventHandlerMap[SlidingSyncEvent.RoomData] = (roomId, roomData) => { expect(listenerData[roomId]).toBeFalsy(); listenerData[roomId] = roomData; }; @@ -426,25 +463,32 @@ describe("SlidingSync", () => { it("should be possible to adjust list ranges", async () => { // modify the list ranges - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.log("next ranges", body.lists[0].ranges); - expect(body.lists).toBeTruthy(); - expect(body.lists[0]).toEqual({ - // only the ranges should be sent as the rest are unchanged and sticky - ranges: newRanges, + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("next ranges", body.lists[0].ranges); + expect(body.lists).toBeTruthy(); + expect(body.lists[0]).toEqual({ + // only the ranges should be sent as the rest are unchanged and sticky + ranges: newRanges, + }); + }) + .respond(200, { + pos: "b", + lists: [ + { + count: 500, + ops: [ + { + op: "SYNC", + range: [0, 2], + room_ids: Object.keys(rooms), + }, + ], + }, + ], }); - }).respond(200, { - pos: "b", - lists: [{ - count: 500, - ops: [{ - op: "SYNC", - range: [0, 2], - room_ids: Object.keys(rooms), - }], - }], - }); const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.RequestFinished; @@ -460,34 +504,39 @@ describe("SlidingSync", () => { ranges: [[0, 100]], sort: ["by_name"], filters: { - "is_dm": true, + is_dm: true, }, }; - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.log("extra list", body); - expect(body.lists).toBeTruthy(); - expect(body.lists[0]).toEqual({ - // only the ranges should be sent as the rest are unchanged and sticky - ranges: newRanges, + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("extra list", body); + expect(body.lists).toBeTruthy(); + expect(body.lists[0]).toEqual({ + // only the ranges should be sent as the rest are unchanged and sticky + ranges: newRanges, + }); + expect(body.lists[1]).toEqual(extraListReq); + }) + .respond(200, { + pos: "c", + lists: [ + { + count: 500, + }, + { + count: 50, + ops: [ + { + op: "SYNC", + range: [0, 2], + room_ids: Object.keys(rooms), + }, + ], + }, + ], }); - expect(body.lists[1]).toEqual(extraListReq); - }).respond(200, { - pos: "c", - lists: [ - { - count: 500, - }, - { - count: 50, - ops: [{ - op: "SYNC", - range: [0, 2], - room_ids: Object.keys(rooms), - }], - }, - ], - }); listenUntil(slidingSync, "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => { expect(listIndex).toEqual(1); expect(joinedCount).toEqual(50); @@ -510,22 +559,29 @@ describe("SlidingSync", () => { // move C (2) to A (0) httpBackend!.when("POST", syncUrl).respond(200, { pos: "e", - lists: [{ - count: 500, - ops: [{ - op: "DELETE", - index: 2, - }, { - op: "INSERT", - index: 0, - room_id: roomC, - }], - }, - { - count: 50, - }], + lists: [ + { + count: 500, + ops: [ + { + op: "DELETE", + index: 2, + }, + { + op: "INSERT", + index: 0, + room_id: roomC, + }, + ], + }, + { + count: 50, + }, + ], }); - let listPromise = listenUntil(slidingSync, "SlidingSync.List", + let listPromise = listenUntil( + slidingSync, + "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => { expect(listIndex).toEqual(0); expect(joinedCount).toEqual(500); @@ -535,7 +591,8 @@ describe("SlidingSync", () => { 2: roomB, }); return true; - }); + }, + ); let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); @@ -546,32 +603,36 @@ describe("SlidingSync", () => { // move C (0) back to A (2) httpBackend!.when("POST", syncUrl).respond(200, { pos: "f", - lists: [{ - count: 500, - ops: [{ - op: "DELETE", - index: 0, - }, { - op: "INSERT", - index: 2, - room_id: roomC, - }], - }, - { - count: 50, - }], + lists: [ + { + count: 500, + ops: [ + { + op: "DELETE", + index: 0, + }, + { + op: "INSERT", + index: 2, + room_id: roomC, + }, + ], + }, + { + count: 50, + }, + ], }); - listPromise = listenUntil(slidingSync, "SlidingSync.List", - (listIndex, joinedCount, roomIndexToRoomId) => { - expect(listIndex).toEqual(0); - expect(joinedCount).toEqual(500); - expect(roomIndexToRoomId).toEqual({ - 0: roomA, - 1: roomB, - 2: roomC, - }); - return true; + listPromise = listenUntil(slidingSync, "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => { + expect(listIndex).toEqual(0); + expect(joinedCount).toEqual(500); + expect(roomIndexToRoomId).toEqual({ + 0: roomA, + 1: roomB, + 2: roomC, }); + return true; + }); responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); @@ -583,18 +644,24 @@ describe("SlidingSync", () => { it("should ignore invalid list indexes", async () => { httpBackend!.when("POST", syncUrl).respond(200, { pos: "e", - lists: [{ - count: 500, - ops: [{ - op: "DELETE", - index: 2324324, - }], - }, - { - count: 50, - }], + lists: [ + { + count: 500, + ops: [ + { + op: "DELETE", + index: 2324324, + }, + ], + }, + { + count: 50, + }, + ], }); - const listPromise = listenUntil(slidingSync, "SlidingSync.List", + const listPromise = listenUntil( + slidingSync, + "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => { expect(listIndex).toEqual(0); expect(joinedCount).toEqual(500); @@ -604,7 +671,8 @@ describe("SlidingSync", () => { 2: roomC, }); return true; - }); + }, + ); const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); @@ -616,23 +684,25 @@ describe("SlidingSync", () => { it("should be possible to update a list", async () => { httpBackend!.when("POST", syncUrl).respond(200, { pos: "g", - lists: [{ - count: 42, - ops: [ - { - op: "INVALIDATE", - range: [0, 2], - }, - { - op: "SYNC", - range: [0, 1], - room_ids: [roomB, roomC], - }, - ], - }, - { - count: 50, - }], + lists: [ + { + count: 42, + ops: [ + { + op: "INVALIDATE", + range: [0, 2], + }, + { + op: "SYNC", + range: [0, 1], + room_ids: [roomB, roomC], + }, + ], + }, + { + count: 50, + }, + ], }); // update the list with a new filter slidingSync.setList(0, { @@ -641,7 +711,9 @@ describe("SlidingSync", () => { }, ranges: [[0, 100]], }); - const listPromise = listenUntil(slidingSync, "SlidingSync.List", + const listPromise = listenUntil( + slidingSync, + "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => { expect(listIndex).toEqual(0); expect(joinedCount).toEqual(42); @@ -650,7 +722,8 @@ describe("SlidingSync", () => { 1: roomC, }); return true; - }); + }, + ); const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); @@ -669,31 +742,40 @@ describe("SlidingSync", () => { httpBackend!.when("POST", syncUrl).respond(200, { pos: "f", // currently the list is [B,C] so we will insert D then immediately delete it - lists: [{ - count: 500, - ops: [ - { - op: "DELETE", index: 2, - }, - { - op: "INSERT", index: 0, room_id: roomA, - }, - { - op: "DELETE", index: 0, - }, - ], - }, - { - count: 50, - }], + lists: [ + { + count: 500, + ops: [ + { + op: "DELETE", + index: 2, + }, + { + op: "INSERT", + index: 0, + room_id: roomA, + }, + { + op: "DELETE", + index: 0, + }, + ], + }, + { + count: 50, + }, + ], }); - const listPromise = listenUntil(slidingSync, "SlidingSync.List", + const listPromise = listenUntil( + slidingSync, + "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => { expect(listIndex).toEqual(0); expect(joinedCount).toEqual(500); expect(roomIndexToRoomId).toEqual(indexToRoomId); return true; - }); + }, + ); const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); @@ -709,19 +791,24 @@ describe("SlidingSync", () => { }); httpBackend!.when("POST", syncUrl).respond(200, { pos: "g", - lists: [{ - count: 499, - ops: [ - { - op: "DELETE", index: 0, - }, - ], - }, - { - count: 50, - }], + lists: [ + { + count: 499, + ops: [ + { + op: "DELETE", + index: 0, + }, + ], + }, + { + count: 50, + }, + ], }); - const listPromise = listenUntil(slidingSync, "SlidingSync.List", + const listPromise = listenUntil( + slidingSync, + "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => { expect(listIndex).toEqual(0); expect(joinedCount).toEqual(499); @@ -729,7 +816,8 @@ describe("SlidingSync", () => { 0: roomC, }); return true; - }); + }, + ); const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); @@ -744,19 +832,25 @@ describe("SlidingSync", () => { }); httpBackend!.when("POST", syncUrl).respond(200, { pos: "h", - lists: [{ - count: 500, - ops: [ - { - op: "INSERT", index: 1, room_id: roomA, - }, - ], - }, - { - count: 50, - }], + lists: [ + { + count: 500, + ops: [ + { + op: "INSERT", + index: 1, + room_id: roomA, + }, + ], + }, + { + count: 50, + }, + ], }); - let listPromise = listenUntil(slidingSync, "SlidingSync.List", + let listPromise = listenUntil( + slidingSync, + "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => { expect(listIndex).toEqual(0); expect(joinedCount).toEqual(500); @@ -765,7 +859,8 @@ describe("SlidingSync", () => { 1: roomA, }); return true; - }); + }, + ); let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); @@ -775,29 +870,32 @@ describe("SlidingSync", () => { httpBackend!.when("POST", syncUrl).respond(200, { pos: "h", - lists: [{ - count: 501, - ops: [ - { - op: "INSERT", index: 1, room_id: roomB, - }, - ], - }, - { - count: 50, - }], + lists: [ + { + count: 501, + ops: [ + { + op: "INSERT", + index: 1, + room_id: roomB, + }, + ], + }, + { + count: 50, + }, + ], }); - listPromise = listenUntil(slidingSync, "SlidingSync.List", - (listIndex, joinedCount, roomIndexToRoomId) => { - expect(listIndex).toEqual(0); - expect(joinedCount).toEqual(501); - expect(roomIndexToRoomId).toEqual({ - 0: roomC, - 1: roomB, - 2: roomA, - }); - return true; + listPromise = listenUntil(slidingSync, "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => { + expect(listIndex).toEqual(0); + expect(joinedCount).toEqual(501); + expect(roomIndexToRoomId).toEqual({ + 0: roomC, + 1: roomB, + 2: roomA, }); + return true; + }); responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); @@ -810,18 +908,26 @@ describe("SlidingSync", () => { // Regression test to make sure things like DELETE 0 INSERT 0 work correctly and we don't // end up losing room IDs. it("should handle insertions with a spurious DELETE correctly", async () => { - slidingSync = new SlidingSync(proxyBaseUrl, [ - { - ranges: [[0, 20]], - }, - ], {}, client!, 1); + slidingSync = new SlidingSync( + proxyBaseUrl, + [ + { + ranges: [[0, 20]], + }, + ], + {}, + client!, + 1, + ); // initially start with nothing httpBackend!.when("POST", syncUrl).respond(200, { pos: "a", - lists: [{ - count: 0, - ops: [], - }], + lists: [ + { + count: 0, + ops: [], + }, + ], }); slidingSync.start(); await httpBackend!.flushAllExpected(); @@ -830,17 +936,22 @@ describe("SlidingSync", () => { // insert a room httpBackend!.when("POST", syncUrl).respond(200, { pos: "b", - lists: [{ - count: 1, - ops: [ - { - op: "DELETE", index: 0, - }, - { - op: "INSERT", index: 0, room_id: roomA, - }, - ], - }], + lists: [ + { + count: 1, + ops: [ + { + op: "DELETE", + index: 0, + }, + { + op: "INSERT", + index: 0, + room_id: roomA, + }, + ], + }, + ], }); await httpBackend!.flushAllExpected(); expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual({ @@ -850,17 +961,22 @@ describe("SlidingSync", () => { // insert another room httpBackend!.when("POST", syncUrl).respond(200, { pos: "c", - lists: [{ - count: 1, - ops: [ - { - op: "DELETE", index: 1, - }, - { - op: "INSERT", index: 0, room_id: roomB, - }, - ], - }], + lists: [ + { + count: 1, + ops: [ + { + op: "DELETE", + index: 1, + }, + { + op: "INSERT", + index: 0, + room_id: roomB, + }, + ], + }, + ], }); await httpBackend!.flushAllExpected(); expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual({ @@ -871,17 +987,22 @@ describe("SlidingSync", () => { // insert a final room httpBackend!.when("POST", syncUrl).respond(200, { pos: "c", - lists: [{ - count: 1, - ops: [ - { - op: "DELETE", index: 2, - }, - { - op: "INSERT", index: 0, room_id: roomC, - }, - ], - }], + lists: [ + { + count: 1, + ops: [ + { + op: "DELETE", + index: 2, + }, + { + op: "INSERT", + index: 0, + room_id: roomC, + }, + ], + }, + ], }); await httpBackend!.flushAllExpected(); expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual({ @@ -904,37 +1025,38 @@ describe("SlidingSync", () => { it("should resolve modifyRoomSubscriptions after SlidingSync.start() is called", async () => { const roomSubInfo = { timeline_limit: 1, - required_state: [ - ["m.room.name", ""], - ], + required_state: [["m.room.name", ""]], }; // add the subscription 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) { - const body = req.data; - logger.debug("got ", body); - expect(body.room_subscriptions).toBeTruthy(); - expect(body.room_subscriptions[roomId]).toEqual(roomSubInfo); - expect(body.txn_id).toBeTruthy(); - txnId = body.txn_id; - }).respond(200, function() { - return { - pos: "aaa", - txn_id: txnId, - lists: [], - extensions: {}, - rooms: { - [roomId]: { - name: "foo bar", - required_state: [], - timeline: [], + let txnId: string | undefined; + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.debug("got ", body); + expect(body.room_subscriptions).toBeTruthy(); + expect(body.room_subscriptions[roomId]).toEqual(roomSubInfo); + expect(body.txn_id).toBeTruthy(); + txnId = body.txn_id; + }) + .respond(200, function () { + return { + pos: "aaa", + txn_id: txnId, + lists: [], + extensions: {}, + rooms: { + [roomId]: { + name: "foo bar", + required_state: [], + timeline: [], + }, }, - }, - }; - }); + }; + }); slidingSync.start(); await httpBackend!.flushAllExpected(); await subscribePromise; @@ -944,46 +1066,52 @@ describe("SlidingSync", () => { ranges: [[0, 20]], }; const promise = slidingSync.setList(0, newList); - let txnId; - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.debug("got ", body); - expect(body.room_subscriptions).toBeFalsy(); - expect(body.lists[0]).toEqual(newList); - expect(body.txn_id).toBeTruthy(); - txnId = body.txn_id; - }).respond(200, function() { - return { - pos: "bbb", - txn_id: txnId, - lists: [{ count: 5 }], - extensions: {}, - }; - }); + let txnId: string | undefined; + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.debug("got ", body); + expect(body.room_subscriptions).toBeFalsy(); + expect(body.lists[0]).toEqual(newList); + expect(body.txn_id).toBeTruthy(); + txnId = body.txn_id; + }) + .respond(200, function () { + return { + pos: "bbb", + txn_id: txnId, + lists: [{ count: 5 }], + extensions: {}, + }; + }); 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) { - const body = req.data; - logger.debug("got ", body); - expect(body.room_subscriptions).toBeFalsy(); - expect(body.lists[0]).toEqual({ - ranges: [[20, 40]], + let txnId: string | undefined; + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.debug("got ", body); + expect(body.room_subscriptions).toBeFalsy(); + expect(body.lists[0]).toEqual({ + ranges: [[20, 40]], + }); + expect(body.txn_id).toBeTruthy(); + txnId = body.txn_id; + }) + .respond(200, function () { + return { + pos: "ccc", + txn_id: txnId, + lists: [{ count: 5 }], + extensions: {}, + }; }); - expect(body.txn_id).toBeTruthy(); - txnId = body.txn_id; - }).respond(200, function() { - return { - pos: "ccc", - txn_id: txnId, - lists: [{ count: 5 }], - extensions: {}, - }; - }); await httpBackend!.flushAllExpected(); await promise; expect(txnId).toBeDefined(); @@ -992,23 +1120,26 @@ describe("SlidingSync", () => { const promise = slidingSync.modifyRoomSubscriptionInfo({ timeline_limit: 99, }); - let txnId; - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.debug("got ", body); - expect(body.room_subscriptions).toBeTruthy(); - expect(body.room_subscriptions[roomId]).toEqual({ - timeline_limit: 99, + let txnId: string | undefined; + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.debug("got ", body); + expect(body.room_subscriptions).toBeTruthy(); + expect(body.room_subscriptions[roomId]).toEqual({ + timeline_limit: 99, + }); + expect(body.txn_id).toBeTruthy(); + txnId = body.txn_id; + }) + .respond(200, function () { + return { + pos: "ddd", + txn_id: txnId, + extensions: {}, + }; }); - expect(body.txn_id).toBeTruthy(); - txnId = body.txn_id; - }).respond(200, function() { - return { - pos: "ddd", - txn_id: txnId, - extensions: {}, - }; - }); await httpBackend!.flushAllExpected(); await promise; expect(txnId).toBeDefined(); @@ -1016,7 +1147,7 @@ describe("SlidingSync", () => { 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: any[] = []; - const pushTxn = function(req) { + const pushTxn = function (req: MockHttpBackend["requests"][0]) { gotTxnIds.push(req.data.txn_id); }; const failPromise = slidingSync.setListRanges(0, [[20, 40]]); @@ -1032,16 +1163,19 @@ describe("SlidingSync", () => { expect(failPromise2).rejects.toEqual(gotTxnIds[1]); const okPromise = slidingSync.setListRanges(0, [[0, 20]]); - let txnId; - httpBackend!.when("POST", syncUrl).check((req) => { - txnId = req.data.txn_id; - }).respond(200, () => { - // include the txn_id, earlier requests should now be reject()ed. - return { - pos: "g", - txn_id: txnId, - }; - }); + let txnId: string | undefined; + httpBackend! + .when("POST", syncUrl) + .check((req) => { + txnId = req.data.txn_id; + }) + .respond(200, () => { + // include the txn_id, earlier requests should now be reject()ed. + return { + pos: "g", + txn_id: txnId, + }; + }); await httpBackend!.flushAllExpected(); await okPromise; @@ -1050,7 +1184,7 @@ describe("SlidingSync", () => { 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: any[] = []; - const pushTxn = function(req) { + const pushTxn = function (req: MockHttpBackend["requests"][0]) { gotTxnIds.push(req.data?.txn_id); }; const A = slidingSync.setListRanges(0, [[20, 40]]); @@ -1069,13 +1203,16 @@ describe("SlidingSync", () => { C.finally(() => { pendingC = false; }); - 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], - }; - }); + 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(); // A is rejected, see above expect(B).resolves.toEqual(gotTxnIds[1]); // B is resolved @@ -1087,24 +1224,27 @@ describe("SlidingSync", () => { promise.finally(() => { pending = false; }); - let txnId; - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.debug("got ", body); - expect(body.room_subscriptions).toBeFalsy(); - expect(body.lists[0]).toEqual({ - ranges: [[20, 40]], + let txnId: string | undefined; + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.debug("got ", body); + expect(body.room_subscriptions).toBeFalsy(); + expect(body.lists[0]).toEqual({ + ranges: [[20, 40]], + }); + expect(body.txn_id).toBeTruthy(); + txnId = body.txn_id; + }) + .respond(200, function () { + return { + pos: "ccc", + txn_id: "bogus transaction id", + lists: [{ count: 5 }], + extensions: {}, + }; }); - expect(body.txn_id).toBeTruthy(); - txnId = body.txn_id; - }).respond(200, function() { - return { - pos: "ccc", - txn_id: "bogus transaction id", - lists: [{ count: 5 }], - extensions: {}, - }; - }); await httpBackend!.flushAllExpected(); expect(txnId).toBeDefined(); expect(pending).toBe(true); @@ -1149,20 +1289,23 @@ describe("SlidingSync", () => { slidingSync.useCustomSubscription(roomC, customSubName2); slidingSync.modifyRoomSubscriptions(new Set([roomA, roomB, roomC, roomD])); - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.log("custom subs", body); - expect(body.room_subscriptions).toBeTruthy(); - expect(body.room_subscriptions[roomA]).toEqual(customSub1); - expect(body.room_subscriptions[roomB]).toEqual(customSub1); - expect(body.room_subscriptions[roomC]).toEqual(customSub2); - expect(body.room_subscriptions[roomD]).toEqual(defaultSub); - }).respond(200, { - pos: "b", - lists: [], - extensions: {}, - rooms: {}, - }); + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("custom subs", body); + expect(body.room_subscriptions).toBeTruthy(); + expect(body.room_subscriptions[roomA]).toEqual(customSub1); + expect(body.room_subscriptions[roomB]).toEqual(customSub1); + expect(body.room_subscriptions[roomC]).toEqual(customSub2); + expect(body.room_subscriptions[roomD]).toEqual(defaultSub); + }) + .respond(200, { + pos: "b", + lists: [], + extensions: {}, + rooms: {}, + }); slidingSync.start(); await httpBackend!.flushAllExpected(); slidingSync.stop(); @@ -1174,64 +1317,76 @@ describe("SlidingSync", () => { slidingSync.addCustomSubscription(customSubName1, customSub1); slidingSync.addCustomSubscription(customSubName2, customSub2); // initially no subs - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.log("custom subs", body); - expect(body.room_subscriptions).toBeFalsy(); - }).respond(200, { - pos: "b", - lists: [], - extensions: {}, - rooms: {}, - }); + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("custom subs", body); + expect(body.room_subscriptions).toBeFalsy(); + }) + .respond(200, { + pos: "b", + lists: [], + extensions: {}, + rooms: {}, + }); slidingSync.start(); await httpBackend!.flushAllExpected(); // now the user clicks on a room which uses the default sub - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.log("custom subs", body); - expect(body.room_subscriptions).toBeTruthy(); - expect(body.room_subscriptions[roomA]).toEqual(defaultSub); - }).respond(200, { - pos: "b", - lists: [], - extensions: {}, - rooms: {}, - }); + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("custom subs", body); + expect(body.room_subscriptions).toBeTruthy(); + expect(body.room_subscriptions[roomA]).toEqual(defaultSub); + }) + .respond(200, { + pos: "b", + lists: [], + extensions: {}, + rooms: {}, + }); slidingSync.modifyRoomSubscriptions(new Set([roomA])); await httpBackend!.flushAllExpected(); // now the user clicks on a room which uses a custom sub - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.log("custom subs", body); - expect(body.room_subscriptions).toBeTruthy(); - expect(body.room_subscriptions[roomB]).toEqual(customSub1); - expect(body.unsubscribe_rooms).toEqual([roomA]); - }).respond(200, { - pos: "b", - lists: [], - extensions: {}, - rooms: {}, - }); + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("custom subs", body); + expect(body.room_subscriptions).toBeTruthy(); + expect(body.room_subscriptions[roomB]).toEqual(customSub1); + expect(body.unsubscribe_rooms).toEqual([roomA]); + }) + .respond(200, { + pos: "b", + lists: [], + extensions: {}, + rooms: {}, + }); slidingSync.useCustomSubscription(roomB, customSubName1); slidingSync.modifyRoomSubscriptions(new Set([roomB])); await httpBackend!.flushAllExpected(); // now the user uses a different sub for the same room: we don't unsub but just resend - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.log("custom subs", body); - expect(body.room_subscriptions).toBeTruthy(); - expect(body.room_subscriptions[roomB]).toEqual(customSub2); - expect(body.unsubscribe_rooms).toBeFalsy(); - }).respond(200, { - pos: "b", - lists: [], - extensions: {}, - rooms: {}, - }); + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("custom subs", body); + expect(body.room_subscriptions).toBeTruthy(); + expect(body.room_subscriptions[roomB]).toEqual(customSub2); + expect(body.unsubscribe_rooms).toBeFalsy(); + }) + .respond(200, { + pos: "b", + lists: [], + extensions: {}, + rooms: {}, + }); slidingSync.useCustomSubscription(roomB, customSubName2); slidingSync.modifyRoomSubscriptions(new Set([roomB])); await httpBackend!.flushAllExpected(); @@ -1245,17 +1400,20 @@ describe("SlidingSync", () => { slidingSync.useCustomSubscription(roomA, "unknown name"); slidingSync.modifyRoomSubscriptions(new Set([roomA])); - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.log("custom subs", body); - expect(body.room_subscriptions).toBeTruthy(); - expect(body.room_subscriptions[roomA]).toEqual(defaultSub); - }).respond(200, { - pos: "b", - lists: [], - extensions: {}, - rooms: {}, - }); + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("custom subs", body); + expect(body.room_subscriptions).toBeTruthy(); + expect(body.room_subscriptions[roomA]).toEqual(defaultSub); + }) + .respond(200, { + pos: "b", + lists: [], + extensions: {}, + rooms: {}, + }); slidingSync.start(); await httpBackend!.flushAllExpected(); slidingSync.stop(); @@ -1275,24 +1433,32 @@ describe("SlidingSync", () => { // Pre-extensions get called BEFORE processing the sync response const preExtName = "foobar"; - let onPreExtensionRequest; - let onPreExtensionResponse; + let onPreExtensionRequest: Extension["onRequest"]; + let onPreExtensionResponse: Extension["onResponse"]; // Post-extensions get called AFTER processing the sync response const postExtName = "foobar2"; - let onPostExtensionRequest; - let onPostExtensionResponse; + let onPostExtensionRequest: Extension["onRequest"]; + let onPostExtensionResponse: Extension["onResponse"]; - const extPre = { + const extPre: Extension = { name: () => preExtName, - onRequest: (initial) => { return onPreExtensionRequest(initial); }, - onResponse: (res) => { return onPreExtensionResponse(res); }, + onRequest: (initial) => { + return onPreExtensionRequest(initial); + }, + onResponse: (res) => { + return onPreExtensionResponse(res); + }, when: () => ExtensionState.PreProcess, }; - const extPost = { + const extPost: Extension = { name: () => postExtName, - onRequest: (initial) => { return onPostExtensionRequest(initial); }, - onResponse: (res) => { return onPostExtensionResponse(res); }, + onRequest: (initial) => { + return onPostExtensionRequest(initial); + }, + onResponse: (res) => { + return onPostExtensionResponse(res); + }, when: () => ExtensionState.PostProcess, }; @@ -1311,19 +1477,22 @@ describe("SlidingSync", () => { expect(resp).toEqual(extResp); }; - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.log("ext req", body); - expect(body.extensions).toBeTruthy(); - expect(body.extensions[preExtName]).toEqual(extReq); - }).respond(200, { - pos: "a", - ops: [], - counts: [], - extensions: { - [preExtName]: extResp, - }, - }); + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("ext req", body); + expect(body.extensions).toBeTruthy(); + expect(body.extensions[preExtName]).toEqual(extReq); + }) + .respond(200, { + pos: "a", + ops: [], + counts: [], + extensions: { + [preExtName]: extResp, + }, + }); const p = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state, resp, err) => { if (state === SlidingSyncState.Complete) { @@ -1346,17 +1515,20 @@ describe("SlidingSync", () => { onPreExtensionResponse = (resp) => { responseCalled = true; }; - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.log("ext req nothing", body); - expect(body.extensions).toBeTruthy(); - expect(body.extensions[preExtName]).toBeUndefined(); - }).respond(200, { - pos: "a", - ops: [], - counts: [], - extensions: {}, - }); + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("ext req nothing", body); + expect(body.extensions).toBeTruthy(); + expect(body.extensions[preExtName]).toBeUndefined(); + }) + .respond(200, { + pos: "a", + ops: [], + counts: [], + extensions: {}, + }); // we need to resend as sliding sync will already have a buffered request with the old // extension values from the previous test. slidingSync.resend(); @@ -1381,20 +1553,23 @@ describe("SlidingSync", () => { responseCalled = true; callbackOrder.push("onPostExtensionResponse"); }; - httpBackend!.when("POST", syncUrl).check(function(req) { - const body = req.data; - logger.log("ext req after start", body); - expect(body.extensions).toBeTruthy(); - expect(body.extensions[preExtName]).toBeUndefined(); // from the earlier test - expect(body.extensions[postExtName]).toEqual(extReq); - }).respond(200, { - pos: "c", - ops: [], - counts: [], - extensions: { - [postExtName]: extResp, - }, - }); + httpBackend! + .when("POST", syncUrl) + .check(function (req) { + const body = req.data; + logger.log("ext req after start", body); + expect(body.extensions).toBeTruthy(); + expect(body.extensions[preExtName]).toBeUndefined(); // from the earlier test + expect(body.extensions[postExtName]).toEqual(extReq); + }) + .respond(200, { + pos: "c", + ops: [], + counts: [], + extensions: { + [postExtName]: extResp, + }, + }); // we need to resend as sliding sync will already have a buffered request with the old // extension values from the previous test. slidingSync.resend(); @@ -1415,13 +1590,15 @@ describe("SlidingSync", () => { it("is not possible to register the same extension name twice", async () => { slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client!, 1); slidingSync.registerExtension(extPre); - expect(() => { slidingSync.registerExtension(extPre); }).toThrow(); + expect(() => { + slidingSync.registerExtension(extPre); + }).toThrow(); }); }); }); -function timeout(delayMs: number, reason: string): { promise: Promise, cancel: () => void } { - let timeoutId; +function timeout(delayMs: number, reason: string): { promise: Promise; cancel: () => void } { + let timeoutId: NodeJS.Timeout; return { promise: new Promise((resolve, reject) => { timeoutId = setTimeout(() => { @@ -1438,11 +1615,11 @@ function timeout(delayMs: number, reason: string): { promise: Promise, ca /** * Listen until a callback returns data. - * @param {EventEmitter} emitter The event emitter - * @param {string} eventName The event to listen for - * @param {function} callback The callback which will be invoked when events fire. Return something truthy from this to resolve the promise. - * @param {number} timeoutMs The number of milliseconds to wait for the callback to return data. Default: 500ms. - * @returns {Promise} A promise which will be resolved when the callback returns data. If the callback throws or the timeout is reached, + * @param emitter - The event emitter + * @param eventName - The event to listen for + * @param callback - The callback which will be invoked when events fire. Return something truthy from this to resolve the promise. + * @param timeoutMs - The number of milliseconds to wait for the callback to return data. Default: 500ms. + * @returns A promise which will be resolved when the callback returns data. If the callback throws or the timeout is reached, * the promise is rejected. */ function listenUntil( @@ -1453,20 +1630,23 @@ function listenUntil( ): Promise { const trace = new Error().stack?.split(`\n`)[2]; const t = timeout(timeoutMs, "timed out waiting for event " + eventName + " " + trace); - return Promise.race([new Promise((resolve, reject) => { - const wrapper = (...args) => { - try { - const data = callback(...args); - if (data) { - emitter.off(eventName, wrapper); + return Promise.race([ + new Promise((resolve, reject) => { + const wrapper = (...args: any[]) => { + try { + const data = callback(...args); + if (data) { + emitter.off(eventName, wrapper); + t.cancel(); + resolve(data); + } + } catch (err) { + reject(err); t.cancel(); - resolve(data); } - } catch (err) { - reject(err); - t.cancel(); - } - }; - emitter.on(eventName, wrapper); - }), t.promise]); + }; + emitter.on(eventName, wrapper); + }), + t.promise, + ]); } diff --git a/spec/olm-loader.ts b/spec/olm-loader.ts index 3ab07254df6..a139a48672b 100644 --- a/spec/olm-loader.ts +++ b/spec/olm-loader.ts @@ -15,13 +15,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { logger } from '../src/logger'; +import { logger } from "../src/logger"; // 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'); + global.Olm = require("@matrix-org/olm"); + logger.log("loaded libolm"); } catch (e) { logger.warn("unable to run crypto tests: libolm not available"); } diff --git a/spec/slowReporter.js b/spec/slowReporter.js index 3a653ffda28..b50d9ea79de 100644 --- a/spec/slowReporter.js +++ b/spec/slowReporter.js @@ -38,18 +38,18 @@ class JestSlowTestReporter { if (isTestSuite) { console.log( `Top ${slowestTests.length} slowest test suites (${slowTestTime / 1000} seconds,` + - ` ${percentTime.toFixed(1)}% of total time):`, + ` ${percentTime.toFixed(1)}% of total time):`, ); } else { console.log( `Top ${slowestTests.length} slowest tests (${slowTestTime / 1000} seconds,` + - ` ${percentTime.toFixed(1)}% of total time):`, + ` ${percentTime.toFixed(1)}% of total time):`, ); } for (let i = 0; i < slowestTests.length; i++) { const duration = slowestTests[i].duration; - const filePath = slowestTests[i].filePath.replace(rootPathRegex, '.'); + const filePath = slowestTests[i].filePath.replace(rootPathRegex, "."); if (isTestSuite) { console.log(` ${duration / 1000} seconds ${filePath}`); diff --git a/spec/test-utils/beacon.ts b/spec/test-utils/beacon.ts index 252c85c8150..9cfebf95675 100644 --- a/spec/test-utils/beacon.ts +++ b/spec/test-utils/beacon.ts @@ -17,10 +17,7 @@ limitations under the License. import { MatrixEvent } from "../../src"; import { M_BEACON, M_BEACON_INFO } from "../../src/@types/beacon"; import { LocationAssetType } from "../../src/@types/location"; -import { - makeBeaconContent, - makeBeaconInfoContent, -} from "../../src/content-helpers"; +import { makeBeaconContent, makeBeaconInfoContent } from "../../src/content-helpers"; type InfoContentProps = { timeout: number; @@ -44,13 +41,7 @@ export const makeBeaconInfoEvent = ( contentProps: Partial = {}, eventId?: string, ): MatrixEvent => { - const { - timeout, - isLive, - description, - assetType, - timestamp, - } = { + const { timeout, isLive, description, assetType, timestamp } = { ...DEFAULT_INFO_CONTENT_PROPS, ...contentProps, }; @@ -77,9 +68,9 @@ type ContentProps = { description?: string; }; const DEFAULT_CONTENT_PROPS: ContentProps = { - uri: 'geo:-36.24484561954707,175.46884959563613;u=10', + uri: "geo:-36.24484561954707,175.46884959563613;u=10", timestamp: 123, - beaconInfoId: '$123', + beaconInfoId: "$123", }; /** @@ -87,10 +78,7 @@ const DEFAULT_CONTENT_PROPS: ContentProps = { * all required properties are mocked * override with contentProps */ -export const makeBeaconEvent = ( - sender: string, - contentProps: Partial = {}, -): MatrixEvent => { +export const makeBeaconEvent = (sender: string, contentProps: Partial = {}): MatrixEvent => { const { uri, timestamp, beaconInfoId, description } = { ...DEFAULT_CONTENT_PROPS, ...contentProps, @@ -107,10 +95,13 @@ export const makeBeaconEvent = ( * Create a mock geolocation position * defaults all required properties */ -export const makeGeolocationPosition = ( - { timestamp, coords }: - { timestamp?: number, coords: Partial }, -): GeolocationPosition => ({ +export const makeGeolocationPosition = ({ + timestamp, + coords, +}: { + timestamp?: number; + coords: Partial; +}): GeolocationPosition => ({ timestamp: timestamp ?? 1647256791840, coords: { accuracy: 1, diff --git a/spec/test-utils/client.ts b/spec/test-utils/client.ts index 79af87022bb..800730fd528 100644 --- a/spec/test-utils/client.ts +++ b/spec/test-utils/client.ts @@ -58,11 +58,11 @@ export const getMockClientWithEventEmitter = ( }); * ``` */ -export const mockClientMethodsUser = (userId = '@alice: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'), + mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"), credentials: { userId }, getThreePids: jest.fn().mockResolvedValue({ threepids: [] }), getAccessToken: jest.fn(), @@ -91,4 +91,3 @@ export const mockClientMethodsServer = (): Partial { + return new Promise((r) => { realSetTimeout(r, 1); }); } diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts index af87ebbe64f..2588a6e1606 100644 --- a/spec/test-utils/test-utils.ts +++ b/spec/test-utils/test-utils.ts @@ -2,9 +2,9 @@ import EventEmitter from "events"; // load olm before the sdk if possible -import '../olm-loader'; +import "../olm-loader"; -import { logger } from '../../src/logger'; +import { logger } from "../../src/logger"; import { IContent, IEvent, IEventRelation, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event"; import { ClientEvent, EventType, IPusher, MatrixClient, MsgType } from "../../src"; import { SyncState } from "../../src/sync"; @@ -13,9 +13,9 @@ import { eventMapperFor } from "../../src/event-mapper"; /** * Return a promise that is resolved when the client next emits a * SYNCING event. - * @param {Object} client The client - * @param {Number=} count Number of syncs to wait for (default 1) - * @return {Promise} Resolves once the client has emitted a SYNCING event + * @param client - The client + * @param count - Number of syncs to wait for (default 1) + * @returns Promise which resolves once the client has emitted a SYNCING event */ export function syncPromise(client: MatrixClient, count = 1): Promise { if (count <= 0) { @@ -41,20 +41,21 @@ export function syncPromise(client: MatrixClient, count = 1): Promise { /** * Create a spy for an object and automatically spy its methods. - * @param {*} constr The class constructor (used with 'new') - * @param {string} name The name of the class - * @return {Object} An instantiated object with spied methods/properties. + * @param constr - The class constructor (used with 'new') + * @param name - The name of the class + * @returns An instantiated object with spied methods/properties. */ -export function mock(constr: { new(...args: any[]): T }, name: string): T { +export function mock(constr: { new (...args: any[]): T }, name: string): T { // Based on http://eclipsesource.com/blogs/2014/03/27/mocks-in-jasmine-tests/ const HelperConstr = new Function(); // jshint ignore:line HelperConstr.prototype = constr.prototype; // @ts-ignore const result = new HelperConstr(); - result.toString = function() { + result.toString = function () { return "mock" + (name ? " of " + name : ""); }; - for (const key of Object.getOwnPropertyNames(constr.prototype)) { // eslint-disable-line guard-for-in + for (const key of Object.getOwnPropertyNames(constr.prototype)) { + // eslint-disable-line guard-for-in try { if (constr.prototype[key] instanceof Function) { result[key] = jest.fn(); @@ -84,15 +85,15 @@ interface IEventOpts { let testEventIndex = 1; // counter for events, easier for comparison of randomly generated events /** * Create an Event. - * @param {Object} opts Values for the event. - * @param {string} opts.type The event.type - * @param {string} opts.room The event.room_id - * @param {string} opts.sender The event.sender - * @param {string} opts.skey Optional. The state key (auto inserts empty string) - * @param {Object} opts.content The event.content - * @param {boolean} opts.event True to make a MatrixEvent. - * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. - * @return {Object} a JSON object representing this event. + * @param opts - Values for the event. + * @param opts.type - The event.type + * @param opts.room - The event.room_id + * @param opts.sender - The event.sender + * @param opts.skey - Optional. The state key (auto inserts empty string) + * @param opts.content - The event.content + * @param opts.event - True to make a MatrixEvent. + * @param client - If passed along with opts.event=true will be used to set up re-emitters. + * @returns a JSON object representing this event. */ export function mkEvent(opts: IEventOpts & { event: true }, client?: MatrixClient): MatrixEvent; export function mkEvent(opts: IEventOpts & { event?: false }, client?: MatrixClient): Partial; @@ -114,15 +115,17 @@ export function mkEvent(opts: IEventOpts & { event?: boolean }, client?: MatrixC }; if (opts.skey !== undefined) { event.state_key = opts.skey; - } else if ([ - EventType.RoomName, - EventType.RoomTopic, - EventType.RoomCreate, - EventType.RoomJoinRules, - EventType.RoomPowerLevels, - EventType.RoomTopic, - "com.example.state", - ].includes(opts.type)) { + } else if ( + [ + EventType.RoomName, + EventType.RoomTopic, + EventType.RoomCreate, + EventType.RoomJoinRules, + EventType.RoomPowerLevels, + EventType.RoomTopic, + "com.example.state", + ].includes(opts.type) + ) { event.state_key = ""; } @@ -160,8 +163,8 @@ interface IPresenceOpts { /** * Create an m.presence event. - * @param {Object} opts Values for the presence. - * @return {Object|MatrixEvent} The event + * @param opts - Values for the presence. + * @returns The event */ export function mkPresence(opts: IPresenceOpts & { event: true }): MatrixEvent; export function mkPresence(opts: IPresenceOpts & { event?: false }): Partial; @@ -193,16 +196,16 @@ interface IMembershipOpts { /** * Create an m.room.member event. - * @param {Object} opts Values for the membership. - * @param {string} opts.room The room ID for the event. - * @param {string} opts.mship The content.membership for the event. - * @param {string} opts.sender The sender user ID for the event. - * @param {string} opts.skey The target user ID for the event if applicable + * @param opts - Values for the membership. + * @param opts.room - The room ID for the event. + * @param opts.mship - The content.membership for the event. + * @param opts.sender - The sender user ID for the event. + * @param opts.skey - The target user ID for the event if applicable * e.g. for invites/bans. - * @param {string} opts.name The content.displayname for the event. - * @param {string} opts.url The content.avatar_url for the event. - * @param {boolean} opts.event True to make a MatrixEvent. - * @return {Object|MatrixEvent} The event + * @param opts.name - The content.displayname for the event. + * @param opts.url - The content.avatar_url for the event. + * @param opts.event - True to make a MatrixEvent. + * @returns The event */ export function mkMembership(opts: IMembershipOpts & { event: true }): MatrixEvent; export function mkMembership(opts: IMembershipOpts & { event?: false }): Partial; @@ -228,8 +231,8 @@ export function mkMembership(opts: IMembershipOpts & { event?: boolean }): Parti } export function mkMembershipCustom( - base: T & { membership: string, sender: string, content?: IContent }, -): T & { type: EventType, sender: string, state_key: string, content: IContent } & GeneratedMetadata { + base: T & { membership: string; sender: string; content?: IContent }, +): T & { type: EventType; sender: string; state_key: string; content: IContent } & GeneratedMetadata { const content = base.content || {}; return mkEventCustom({ ...base, @@ -250,13 +253,13 @@ export interface IMessageOpts { /** * Create an m.room.message event. - * @param {Object} opts Values for the message - * @param {string} opts.room The room ID for the event. - * @param {string} opts.user The user ID for the event. - * @param {string} opts.msg Optional. The content.body for the event. - * @param {boolean} opts.event True to make a MatrixEvent. - * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. - * @return {Object|MatrixEvent} The event + * @param opts - Values for the message + * @param opts.room - The room ID for the event. + * @param opts.user - The user ID for the event. + * @param opts.msg - Optional. The content.body for the event. + * @param opts.event - True to make a MatrixEvent. + * @param client - If passed along with opts.event=true will be used to set up re-emitters. + * @returns The event */ export function mkMessage(opts: IMessageOpts & { event: true }, client?: MatrixClient): MatrixEvent; export function mkMessage(opts: IMessageOpts & { event?: false }, client?: MatrixClient): Partial; @@ -290,14 +293,14 @@ interface IReplyMessageOpts extends IMessageOpts { /** * Create a reply message. * - * @param {Object} opts Values for the message - * @param {string} opts.room The room ID for the event. - * @param {string} opts.user The user ID for the event. - * @param {string} opts.msg Optional. The content.body for the event. - * @param {MatrixEvent} opts.replyToMessage The replied message - * @param {boolean} opts.event True to make a MatrixEvent. - * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. - * @return {Object|MatrixEvent} The event + * @param opts - Values for the message + * @param opts.room - The room ID for the event. + * @param opts.user - The user ID for the event. + * @param opts.msg - Optional. The content.body for the event. + * @param opts.replyToMessage - The replied message + * @param opts.event - True to make a MatrixEvent. + * @param client - If passed along with opts.event=true will be used to set up re-emitters. + * @returns The event */ export function mkReplyMessage(opts: IReplyMessageOpts & { event: true }, client?: MatrixClient): MatrixEvent; export function mkReplyMessage(opts: IReplyMessageOpts & { event?: false }, client?: MatrixClient): Partial; @@ -315,7 +318,7 @@ export function mkReplyMessage( "rel_type": "m.in_reply_to", "event_id": opts.replyToMessage.getId(), "m.in_reply_to": { - "event_id": opts.replyToMessage.getId()!, + event_id: opts.replyToMessage.getId()!, }, }, }, @@ -329,10 +332,8 @@ export function mkReplyMessage( /** * A mock implementation of webstorage - * - * @constructor */ -export class MockStorageApi { +export class MockStorageApi implements Storage { private data: Record = {}; public get length() { @@ -354,33 +355,43 @@ export class MockStorageApi { public removeItem(k: string): void { delete this.data[k]; } + + public clear(): void { + this.data = {}; + } } /** * If an event is being decrypted, wait for it to finish being decrypted. * - * @param {MatrixEvent} event - * @returns {Promise} promise which resolves (to `event`) when the event has been decrypted + * @returns promise which resolves (to `event`) when the event has been decrypted */ -export async function awaitDecryption(event: MatrixEvent): Promise { +export async function awaitDecryption( + event: MatrixEvent, + { waitOnDecryptionFailure = false } = {}, +): Promise { // An event is not always decrypted ahead of time // getClearContent is a good signal to know whether an event has been decrypted // already if (event.getClearContent() !== null) { - return event; + if (waitOnDecryptionFailure && event.isDecryptionFailure()) { + logger.log(`${Date.now()} event ${event.getId()} got decryption error; waiting`); + } else { + return event; + } } else { - logger.log(`${Date.now()} event ${event.getId()} is being decrypted; waiting`); + logger.log(`${Date.now()} event ${event.getId()} is not yet decrypted; waiting`); + } - return new Promise((resolve) => { - event.once(MatrixEventEvent.Decrypted, (ev) => { - logger.log(`${Date.now()} event ${event.getId()} now decrypted`); - resolve(ev); - }); + return new Promise((resolve) => { + event.once(MatrixEventEvent.Decrypted, (ev) => { + logger.log(`${Date.now()} event ${event.getId()} now decrypted`); + resolve(ev); }); - } + }); } -export const emitPromise = (e: EventEmitter, k: string): Promise => new Promise(r => e.once(k, r)); +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", diff --git a/spec/test-utils/thread.ts b/spec/test-utils/thread.ts index 376b8d81121..6232f20d70c 100644 --- a/spec/test-utils/thread.ts +++ b/spec/test-utils/thread.ts @@ -21,18 +21,25 @@ import { Room } from "../../src/models/room"; import { Thread } from "../../src/models/thread"; import { mkMessage } from "./test-utils"; -export const makeThreadEvent = ({ rootEventId, replyToEventId, ...props }: any & { - rootEventId: string; replyToEventId: string; event?: boolean; -}): MatrixEvent => mkMessage({ - ...props, - relatesTo: { - event_id: rootEventId, - rel_type: "m.thread", - ['m.in_reply_to']: { - event_id: replyToEventId, +export const makeThreadEvent = ({ + rootEventId, + replyToEventId, + ...props +}: any & { + rootEventId: string; + replyToEventId: string; + event?: boolean; +}): MatrixEvent => + mkMessage({ + ...props, + relatesTo: { + event_id: rootEventId, + rel_type: "m.thread", + ["m.in_reply_to"]: { + event_id: replyToEventId, + }, }, - }, -}); + }); type MakeThreadEventsProps = { roomId: Room["roomId"]; @@ -50,12 +57,17 @@ type MakeThreadEventsProps = { }; export const makeThreadEvents = ({ - roomId, authorId, participantUserIds, length = 2, ts = 1, currentUserId, -}: MakeThreadEventsProps): { rootEvent: MatrixEvent, events: MatrixEvent[] } => { + roomId, + authorId, + participantUserIds, + length = 2, + ts = 1, + currentUserId, +}: MakeThreadEventsProps): { rootEvent: MatrixEvent; events: MatrixEvent[] } => { const rootEvent = mkMessage({ user: authorId, room: roomId, - msg: 'root event message ' + Math.random(), + msg: "root event message " + Math.random(), ts, event: true, }); @@ -67,16 +79,18 @@ export const makeThreadEvents = ({ const prevEvent = events[i - 1]; const replyToEventId = prevEvent.getId(); const user = participantUserIds[i % participantUserIds.length]; - events.push(makeThreadEvent({ - user, - room: roomId, - event: true, - msg: `reply ${i} by ${user}`, - rootEventId, - replyToEventId, - // replies are 1ms after each other - ts: ts + i, - })); + events.push( + makeThreadEvent({ + user, + room: roomId, + event: true, + msg: `reply ${i} by ${user}`, + rootEventId, + replyToEventId, + // replies are 1ms after each other + ts: ts + i, + }), + ); } rootEvent.setUnsigned({ @@ -108,7 +122,7 @@ export const mkThread = ({ participantUserIds, length = 2, ts = 1, -}: MakeThreadProps): { thread: Thread, rootEvent: MatrixEvent, events: MatrixEvent[] } => { +}: MakeThreadProps): { thread: Thread; rootEvent: MatrixEvent; events: MatrixEvent[] } => { const { rootEvent, events } = makeThreadEvents({ roomId: room.roomId, authorId, @@ -120,15 +134,10 @@ export const mkThread = ({ expect(rootEvent).toBeTruthy(); for (const evt of events) { - room?.reEmitter.reEmit(evt, [ - MatrixEventEvent.BeforeRedaction, - ]); + room?.reEmitter.reEmit(evt, [MatrixEventEvent.BeforeRedaction]); } const thread = room.createThread(rootEvent.getId() ?? "", rootEvent, events, true); - // So that we do not have to mock the thread loading - thread.initialEventsFetched = true; - thread.addEvents(events, true); return { thread, rootEvent, events }; }; diff --git a/spec/test-utils/webrtc.ts b/spec/test-utils/webrtc.ts index 6a1b1a35794..d8e350030f1 100644 --- a/spec/test-utils/webrtc.ts +++ b/spec/test-utils/webrtc.ts @@ -26,6 +26,7 @@ import { MatrixClient, MatrixEvent, Room, + RoomMember, RoomState, RoomStateEvent, RoomStateEventHandlerMap, @@ -33,14 +34,14 @@ import { import { TypedEventEmitter } from "../../src/models/typed-event-emitter"; import { ReEmitter } from "../../src/ReEmitter"; import { SyncState } from "../../src/sync"; -import { CallEvent, CallEventHandlerMap, MatrixCall } from "../../src/webrtc/call"; +import { CallEvent, CallEventHandlerMap, CallState, MatrixCall } from "../../src/webrtc/call"; import { CallEventHandlerEvent, CallEventHandlerEventHandlerMap } from "../../src/webrtc/callEventHandler"; import { CallFeed } from "../../src/webrtc/callFeed"; import { GroupCallEventHandlerMap } from "../../src/webrtc/groupCall"; import { GroupCallEventHandlerEvent } from "../../src/webrtc/groupCallEventHandler"; import { IScreensharingOpts, MediaHandler } from "../../src/webrtc/mediaHandler"; -export const DUMMY_SDP = ( +export const DUMMY_SDP = "v=0\r\n" + "o=- 5022425983810148698 2 IN IP4 127.0.0.1\r\n" + "s=-\r\nt=0 0\r\na=group:BUNDLE 0\r\n" + @@ -77,24 +78,40 @@ export const DUMMY_SDP = ( "a=rtpmap:112 telephone-event/32000\r\n" + "a=rtpmap:113 telephone-event/16000\r\n" + "a=rtpmap:126 telephone-event/8000\r\n" + - "a=ssrc:3619738545 cname:2RWtmqhXLdoF4sOi\r\n" -); + "a=ssrc:3619738545 cname:2RWtmqhXLdoF4sOi\r\n"; export const USERMEDIA_STREAM_ID = "mock_stream_from_media_handler"; export const SCREENSHARE_STREAM_ID = "mock_screen_stream_from_media_handler"; +export const FAKE_ROOM_ID = "!fake:test.dummy"; +export const FAKE_CONF_ID = "fakegroupcallid"; + +export const FAKE_USER_ID_1 = "@alice:test.dummy"; +export const FAKE_DEVICE_ID_1 = "@AAAAAA"; +export const FAKE_SESSION_ID_1 = "alice1"; +export const FAKE_USER_ID_2 = "@bob:test.dummy"; +export const FAKE_DEVICE_ID_2 = "@BBBBBB"; +export const FAKE_SESSION_ID_2 = "bob1"; +export const FAKE_USER_ID_3 = "@charlie:test.dummy"; + class MockMediaStreamAudioSourceNode { public connect() {} } class MockAnalyser { - public getFloatFrequencyData() { return 0.0; } + public getFloatFrequencyData() { + return 0.0; + } } export class MockAudioContext { constructor() {} - public createAnalyser() { return new MockAnalyser(); } - public createMediaStreamSource() { return new MockMediaStreamAudioSourceNode(); } + public createAnalyser() { + return new MockAnalyser(); + } + public createMediaStreamSource() { + return new MockMediaStreamAudioSourceNode(); + } public close() {} } @@ -103,12 +120,14 @@ export class MockRTCPeerConnection { private negotiationNeededListener?: () => void; public iceCandidateListener?: (e: RTCPeerConnectionIceEvent) => void; + public iceConnectionStateChangeListener?: () => void; public onTrackListener?: (e: RTCTrackEvent) => void; public needsNegotiation = false; public readyToNegotiate: Promise; private onReadyToNegotiate?: () => void; public localDescription: RTCSessionDescription; public signalingState: RTCSignalingState = "stable"; + public iceConnectionState: RTCIceConnectionState = "connected"; public transceivers: MockRTCRtpTransceiver[] = []; public static triggerAllNegotiations(): void { @@ -118,7 +137,7 @@ export class MockRTCPeerConnection { } public static hasAnyPendingNegotiations(): boolean { - return this.instances.some(i => i.needsNegotiation); + return this.instances.some((i) => i.needsNegotiation); } public static resetInstances() { @@ -128,11 +147,11 @@ export class MockRTCPeerConnection { constructor() { this.localDescription = { sdp: DUMMY_SDP, - type: 'offer', - toJSON: function() { }, + type: "offer", + toJSON: function () {}, }; - this.readyToNegotiate = new Promise(resolve => { + this.readyToNegotiate = new Promise((resolve) => { this.onReadyToNegotiate = resolve; }); @@ -140,24 +159,28 @@ export class MockRTCPeerConnection { } public addEventListener(type: string, listener: () => void) { - if (type === 'negotiationneeded') { + if (type === "negotiationneeded") { this.negotiationNeededListener = listener; - } else if (type == 'icecandidate') { + } else if (type == "icecandidate") { this.iceCandidateListener = listener; - } else if (type == 'track') { + } else if (type === "iceconnectionstatechange") { + this.iceConnectionStateChangeListener = listener; + } else if (type == "track") { this.onTrackListener = listener; } } - public createDataChannel(label: string, opts: RTCDataChannelInit) { return { label, ...opts }; } + public createDataChannel(label: string, opts: RTCDataChannelInit) { + return { label, ...opts }; + } public createOffer() { return Promise.resolve({ - type: 'offer', + type: "offer", sdp: DUMMY_SDP, }); } public createAnswer() { return Promise.resolve({ - type: 'answer', + type: "answer", sdp: DUMMY_SDP, }); } @@ -167,8 +190,10 @@ export class MockRTCPeerConnection { public setLocalDescription() { return Promise.resolve(); } - public close() { } - public getStats() { return []; } + public close() {} + public getStats() { + return []; + } public addTransceiver(track: MockMediaStreamTrack): MockRTCRtpTransceiver { this.needsNegotiation = true; if (this.onReadyToNegotiate) this.onReadyToNegotiate(); @@ -193,9 +218,11 @@ export class MockRTCPeerConnection { if (this.onReadyToNegotiate) this.onReadyToNegotiate(); } - public getTransceivers(): MockRTCRtpTransceiver[] { return this.transceivers; } + public getTransceivers(): MockRTCRtpTransceiver[] { + return this.transceivers; + } public getSenders(): MockRTCRtpSender[] { - return this.transceivers.map(t => t.sender as unknown as MockRTCRtpSender); + return this.transceivers.map((t) => t.sender as unknown as MockRTCRtpSender); } public doNegotiation() { @@ -207,13 +234,15 @@ export class MockRTCPeerConnection { } export class MockRTCRtpSender { - constructor(public track: MockMediaStreamTrack) { } + constructor(public track: MockMediaStreamTrack) {} - public replaceTrack(track: MockMediaStreamTrack) { this.track = track; } + public replaceTrack(track: MockMediaStreamTrack) { + this.track = track; + } } export class MockRTCRtpReceiver { - constructor(public track: MockMediaStreamTrack) { } + constructor(public track: MockMediaStreamTrack) {} } export class MockRTCRtpTransceiver { @@ -230,7 +259,7 @@ export class MockRTCRtpTransceiver { } export class MockMediaStreamTrack { - constructor(public readonly id: string, public readonly kind: "audio" | "video", public enabled = true) { } + constructor(public readonly id: string, public readonly kind: "audio" | "video", public enabled = true) {} public stop = jest.fn(); @@ -238,7 +267,9 @@ export class MockMediaStreamTrack { public isStopped = false; public settings?: MediaTrackSettings; - public getSettings(): MediaTrackSettings { return this.settings!; } + public getSettings(): MediaTrackSettings { + return this.settings!; + } // XXX: Using EventTarget in jest doesn't seem to work, so we write our own // implementation @@ -257,16 +288,15 @@ export class MockMediaStreamTrack { }); } - public typed(): MediaStreamTrack { return this as unknown as MediaStreamTrack; } + public typed(): MediaStreamTrack { + return this as unknown as MediaStreamTrack; + } } // XXX: Using EventTarget in jest doesn't seem to work, so we write our own // implementation export class MockMediaStream { - constructor( - public id: string, - private tracks: MockMediaStreamTrack[] = [], - ) {} + constructor(public id: string, private tracks: MockMediaStreamTrack[] = []) {} public listeners: [string, (...args: any[]) => any][] = []; public isStopped = false; @@ -277,9 +307,15 @@ export class MockMediaStream { c(); }); } - public getTracks() { return this.tracks; } - public getAudioTracks() { return this.tracks.filter((track) => track.kind === "audio"); } - public getVideoTracks() { return this.tracks.filter((track) => track.kind === "video"); } + public getTracks() { + return this.tracks; + } + public getAudioTracks() { + return this.tracks.filter((track) => track.kind === "audio"); + } + public getVideoTracks() { + return this.tracks.filter((track) => track.kind === "video"); + } public addEventListener(eventType: string, callback: (...args: any[]) => any) { this.listeners.push([eventType, callback]); } @@ -292,7 +328,9 @@ export class MockMediaStream { this.tracks.push(track); this.dispatchEvent("addtrack"); } - public removeTrack(track: MockMediaStreamTrack) { this.tracks.splice(this.tracks.indexOf(track), 1); } + public removeTrack(track: MockMediaStreamTrack) { + this.tracks.splice(this.tracks.indexOf(track), 1); + } public clone(): MediaStream { return new MockMediaStream(this.id + ".clone", this.tracks).typed(); @@ -309,11 +347,11 @@ export class MockMediaStream { } export class MockMediaDeviceInfo { - constructor( - public kind: "audioinput" | "videoinput" | "audiooutput", - ) { } + constructor(public kind: "audioinput" | "videoinput" | "audiooutput") {} - public typed(): MediaDeviceInfo { return this as unknown as MediaDeviceInfo; } + public typed(): MediaDeviceInfo { + return this as unknown as MediaDeviceInfo; + } } export class MockMediaHandler { @@ -343,28 +381,38 @@ export class MockMediaHandler { public stopScreensharingStream(stream: MockMediaStream) { stream.isStopped = true; } - public hasAudioDevice() { return true; } - public hasVideoDevice() { return true; } + public hasAudioDevice() { + return true; + } + public hasVideoDevice() { + return true; + } public stopAllStreams() {} - public typed(): MediaHandler { return this as unknown as MediaHandler; } + public typed(): MediaHandler { + return this as unknown as MediaHandler; + } } export class MockMediaDevices { - public enumerateDevices = jest.fn, []>().mockResolvedValue([ - new MockMediaDeviceInfo("audioinput").typed(), - new MockMediaDeviceInfo("videoinput").typed(), - ]); - - public getUserMedia = jest.fn, [MediaStreamConstraints]>().mockReturnValue( - Promise.resolve(new MockMediaStream("local_stream").typed()), - ); - - public getDisplayMedia = jest.fn, [MediaStreamConstraints]>().mockReturnValue( - Promise.resolve(new MockMediaStream("local_display_stream").typed()), - ); - - public typed(): MediaDevices { return this as unknown as MediaDevices; } + public enumerateDevices = jest + .fn, []>() + .mockResolvedValue([ + new MockMediaDeviceInfo("audioinput").typed(), + new MockMediaDeviceInfo("videoinput").typed(), + ]); + + public getUserMedia = jest + .fn, [MediaStreamConstraints]>() + .mockReturnValue(Promise.resolve(new MockMediaStream("local_stream").typed())); + + public getDisplayMedia = jest + .fn, [MediaStreamConstraints]>() + .mockReturnValue(Promise.resolve(new MockMediaStream("local_display_stream").typed())); + + public typed(): MediaDevices { + return this as unknown as MediaDevices; + } } type EmittedEvents = CallEventHandlerEvent | CallEvent | ClientEvent | RoomStateEvent | GroupCallEventHandlerEvent; @@ -389,21 +437,33 @@ export class MockCallMatrixClient extends TypedEventEmitter(), }; - public sendStateEvent = jest.fn, [ - roomId: string, eventType: EventType, content: any, statekey: string, - ]>(); - public sendToDevice = jest.fn, [ - eventType: string, - contentMap: { [userId: string]: { [deviceId: string]: Record } }, - txnId?: string, - ]>(); - - public getMediaHandler(): MediaHandler { return this.mediaHandler.typed(); } + public sendStateEvent = jest.fn< + Promise, + [roomId: string, eventType: EventType, content: any, statekey: string] + >(); + public sendToDevice = jest.fn< + Promise<{}>, + [ + eventType: string, + contentMap: { [userId: string]: { [deviceId: string]: Record } }, + txnId?: string, + ] + >(); + + public getMediaHandler(): MediaHandler { + return this.mediaHandler.typed(); + } - public getUserId(): string { return this.userId; } + public getUserId(): string { + return this.userId; + } - public getDeviceId(): string { return this.deviceId; } - public getSessionId(): string { return this.sessionId; } + public getDeviceId(): string { + return this.deviceId; + } + public getSessionId(): string { + return this.sessionId; + } public getTurnServers = () => []; public isFallbackICEServerAllowed = () => false; @@ -416,27 +476,61 @@ export class MockCallMatrixClient extends TypedEventEmitter().mockReturnValue([]); public getRoom = jest.fn(); - public supportsExperimentalThreads(): boolean { return true; } + public supportsExperimentalThreads(): boolean { + return true; + } public async decryptEventIfNeeded(): Promise {} - public typed(): MatrixClient { return this as unknown as MatrixClient; } + public typed(): MatrixClient { + return this as unknown as MatrixClient; + } public emitRoomState(event: MatrixEvent, state: RoomState): void { - this.emit( - RoomStateEvent.Events, - event, - state, - null, - ); + this.emit(RoomStateEvent.Events, event, state, null); + } +} + +export class MockMatrixCall extends TypedEventEmitter { + constructor(public roomId: string, public groupCallId?: string) { + super(); + } + + public state = CallState.Ringing; + public opponentUserId = FAKE_USER_ID_1; + public opponentDeviceId = FAKE_DEVICE_ID_1; + public opponentMember = { userId: this.opponentUserId }; + public callId = "1"; + public localUsermediaFeed = { + setAudioVideoMuted: jest.fn(), + stream: new MockMediaStream("stream"), + }; + public remoteUsermediaFeed?: CallFeed; + public remoteScreensharingFeed?: CallFeed; + + public reject = jest.fn(); + public answerWithCallFeeds = jest.fn(); + public hangup = jest.fn(); + + public sendMetadataUpdate = jest.fn(); + + public on = jest.fn(); + public removeListener = jest.fn(); + + public getOpponentMember(): Partial { + return this.opponentMember; + } + + public getOpponentDeviceId(): string | undefined { + return this.opponentDeviceId; + } + + public typed(): MatrixCall { + return this as unknown as MatrixCall; } } export class MockCallFeed { - constructor( - public userId: string, - public deviceId: string | undefined, - public stream: MockMediaStream, - ) {} + constructor(public userId: string, public deviceId: string | undefined, public stream: MockMediaStream) {} public measureVolumeActivity(val: boolean) {} public dispose() {} @@ -483,10 +577,14 @@ export function installWebRTCMocks() { }; } -export function makeMockGroupCallStateEvent(roomId: string, groupCallId: string, content: IContent = { - "m.type": GroupCallType.Video, - "m.intent": GroupCallIntent.Prompt, -}): MatrixEvent { +export function makeMockGroupCallStateEvent( + roomId: string, + groupCallId: string, + content: IContent = { + "m.type": GroupCallType.Video, + "m.intent": GroupCallIntent.Prompt, + }, +): MatrixEvent { return { getType: jest.fn().mockReturnValue(EventType.GroupCallPrefix), getRoomId: jest.fn().mockReturnValue(roomId), diff --git a/spec/unit/ReEmitter.spec.ts b/spec/unit/ReEmitter.spec.ts index 4ce28429d12..9c4ea19255b 100644 --- a/spec/unit/ReEmitter.spec.ts +++ b/spec/unit/ReEmitter.spec.ts @@ -27,16 +27,14 @@ class EventSource extends EventEmitter { } doAnError() { - this.emit('error'); + this.emit("error"); } } -class EventTarget extends EventEmitter { +class EventTarget extends EventEmitter {} -} - -describe("ReEmitter", function() { - it("Re-Emits events with the same args", function() { +describe("ReEmitter", function () { + it("Re-Emits events with the same args", function () { const src = new EventSource(); const tgt = new EventTarget(); @@ -53,18 +51,18 @@ describe("ReEmitter", function() { expect(handler).toHaveBeenCalledWith("foo", "bar", src); }); - it("Doesn't throw if no handler for 'error' event", function() { + it("Doesn't throw if no handler for 'error' event", function () { const src = new EventSource(); const tgt = new EventTarget(); const reEmitter = new ReEmitter(tgt); - reEmitter.reEmit(src, ['error']); + reEmitter.reEmit(src, ["error"]); // without the workaround in ReEmitter, this would throw src.doAnError(); const handler = jest.fn(); - tgt.on('error', handler); + tgt.on("error", handler); src.doAnError(); diff --git a/spec/unit/autodiscovery.spec.ts b/spec/unit/autodiscovery.spec.ts index fae5089c50b..0b6f8036d61 100644 --- a/spec/unit/autodiscovery.spec.ts +++ b/spec/unit/autodiscovery.spec.ts @@ -19,44 +19,56 @@ import MockHttpBackend from "matrix-mock-request"; import { AutoDiscovery } from "../../src/autodiscovery"; -describe("AutoDiscovery", function() { +describe("AutoDiscovery", function () { const getHttpBackend = (): MockHttpBackend => { const httpBackend = new MockHttpBackend(); AutoDiscovery.setFetchFn(httpBackend.fetchFn as typeof global.fetch); return httpBackend; }; - it("should throw an error when no domain is specified", function() { + it("should throw an error when no domain is specified", function () { getHttpBackend(); return Promise.all([ // @ts-ignore testing no args - AutoDiscovery.findClientConfig(/* no args */).then(() => { - throw new Error("Expected a failure, not success with no args"); - }, () => { - return true; - }), + AutoDiscovery.findClientConfig(/* no args */).then( + () => { + throw new Error("Expected a failure, not success with no args"); + }, + () => { + return true; + }, + ), - AutoDiscovery.findClientConfig("").then(() => { - throw new Error("Expected a failure, not success with an empty string"); - }, () => { - return true; - }), + AutoDiscovery.findClientConfig("").then( + () => { + throw new Error("Expected a failure, not success with an empty string"); + }, + () => { + return true; + }, + ), - AutoDiscovery.findClientConfig(null as any).then(() => { - throw new Error("Expected a failure, not success with null"); - }, () => { - return true; - }), + AutoDiscovery.findClientConfig(null as any).then( + () => { + throw new Error("Expected a failure, not success with null"); + }, + () => { + return true; + }, + ), - AutoDiscovery.findClientConfig(true as any).then(() => { - throw new Error("Expected a failure, not success with a non-string"); - }, () => { - return true; - }), + AutoDiscovery.findClientConfig(true as any).then( + () => { + throw new Error("Expected a failure, not success with a non-string"); + }, + () => { + return true; + }, + ), ]); }); - it("should return PROMPT when .well-known 404s", function() { + it("should return PROMPT when .well-known 404s", function () { const httpBackend = getHttpBackend(); httpBackend.when("GET", "/.well-known/matrix/client").respond(404, {}); return Promise.all([ @@ -80,7 +92,7 @@ describe("AutoDiscovery", function() { ]); }); - it("should return FAIL_PROMPT when .well-known returns a 500 error", function() { + it("should return FAIL_PROMPT when .well-known returns a 500 error", function () { const httpBackend = getHttpBackend(); httpBackend.when("GET", "/.well-known/matrix/client").respond(500, {}); return Promise.all([ @@ -104,7 +116,7 @@ describe("AutoDiscovery", function() { ]); }); - it("should return FAIL_PROMPT when .well-known returns a 400 error", function() { + it("should return FAIL_PROMPT when .well-known returns a 400 error", function () { const httpBackend = getHttpBackend(); httpBackend.when("GET", "/.well-known/matrix/client").respond(400, {}); return Promise.all([ @@ -128,7 +140,7 @@ describe("AutoDiscovery", function() { ]); }); - it("should return FAIL_PROMPT when .well-known returns an empty body", function() { + it("should return FAIL_PROMPT when .well-known returns an empty body", function () { const httpBackend = getHttpBackend(); httpBackend.when("GET", "/.well-known/matrix/client").respond(200, ""); return Promise.all([ @@ -169,9 +181,7 @@ describe("AutoDiscovery", function() { }; return Promise.all([ httpBackend.flushAllExpected(), - AutoDiscovery.findClientConfig("example.org").then( - expect(expected).toEqual, - ), + AutoDiscovery.findClientConfig("example.org").then(expect(expected).toEqual), ]); }); @@ -257,106 +267,117 @@ describe("AutoDiscovery", function() { ]); }); - it("should return FAIL_ERROR when .well-known has an invalid base_url for " + - "m.homeserver (verification failure: 404)", function() { - const httpBackend = getHttpBackend(); - httpBackend.when("GET", "/_matrix/client/versions").respond(404, {}); - httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { - "m.homeserver": { - base_url: "https://example.org", - }, - }); - return Promise.all([ - httpBackend.flushAllExpected(), - AutoDiscovery.findClientConfig("example.org").then((conf) => { - const expected = { - "m.homeserver": { - state: "FAIL_ERROR", - error: AutoDiscovery.ERROR_INVALID_HOMESERVER, - base_url: "https://example.org", - }, - "m.identity_server": { - state: "PROMPT", - error: null, - base_url: null, - }, - }; - - expect(conf).toEqual(expected); - }), - ]); - }); - - it("should return FAIL_ERROR when .well-known has an invalid base_url for " + - "m.homeserver (verification failure: 500)", function() { - const httpBackend = getHttpBackend(); - httpBackend.when("GET", "/_matrix/client/versions").respond(500, {}); - httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { - "m.homeserver": { - base_url: "https://example.org", - }, - }); - return Promise.all([ - httpBackend.flushAllExpected(), - AutoDiscovery.findClientConfig("example.org").then((conf) => { - const expected = { - "m.homeserver": { - state: "FAIL_ERROR", - error: AutoDiscovery.ERROR_INVALID_HOMESERVER, - base_url: "https://example.org", - }, - "m.identity_server": { - state: "PROMPT", - error: null, - base_url: null, - }, - }; - - expect(conf).toEqual(expected); - }), - ]); - }); - - it("should return FAIL_ERROR when .well-known has an invalid base_url for " + - "m.homeserver (verification failure: 200 but wrong content)", function() { - const httpBackend = getHttpBackend(); - httpBackend.when("GET", "/_matrix/client/versions").respond(200, { - not_matrix_versions: ["r0.0.1"], - }); - httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { - "m.homeserver": { - base_url: "https://example.org", - }, - }); - return Promise.all([ - httpBackend.flushAllExpected(), - AutoDiscovery.findClientConfig("example.org").then((conf) => { - const expected = { - "m.homeserver": { - state: "FAIL_ERROR", - error: AutoDiscovery.ERROR_INVALID_HOMESERVER, - base_url: "https://example.org", - }, - "m.identity_server": { - state: "PROMPT", - error: null, - base_url: null, - }, - }; - - expect(conf).toEqual(expected); - }), - ]); - }); - - it("should return SUCCESS when .well-known has a verifiably accurate base_url for " + - "m.homeserver", function() { + it( + "should return FAIL_ERROR when .well-known has an invalid base_url for " + + "m.homeserver (verification failure: 404)", + function () { + const httpBackend = getHttpBackend(); + httpBackend.when("GET", "/_matrix/client/versions").respond(404, {}); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + base_url: "https://example.org", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_ERROR", + error: AutoDiscovery.ERROR_INVALID_HOMESERVER, + base_url: "https://example.org", + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }, + ); + + it( + "should return FAIL_ERROR when .well-known has an invalid base_url for " + + "m.homeserver (verification failure: 500)", + function () { + const httpBackend = getHttpBackend(); + httpBackend.when("GET", "/_matrix/client/versions").respond(500, {}); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + base_url: "https://example.org", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_ERROR", + error: AutoDiscovery.ERROR_INVALID_HOMESERVER, + base_url: "https://example.org", + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }, + ); + + it( + "should return FAIL_ERROR when .well-known has an invalid base_url for " + + "m.homeserver (verification failure: 200 but wrong content)", + function () { + const httpBackend = getHttpBackend(); + httpBackend.when("GET", "/_matrix/client/versions").respond(200, { + not_matrix_versions: ["r0.0.1"], + }); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + base_url: "https://example.org", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_ERROR", + error: AutoDiscovery.ERROR_INVALID_HOMESERVER, + base_url: "https://example.org", + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }, + ); + + it("should return SUCCESS when .well-known has a verifiably accurate base_url for " + "m.homeserver", function () { const httpBackend = getHttpBackend(); - httpBackend.when("GET", "/_matrix/client/versions").check((req) => { - expect(req.path).toEqual("https://example.org/_matrix/client/versions"); - }).respond(200, { - versions: ["r0.0.1"], - }); + httpBackend + .when("GET", "/_matrix/client/versions") + .check((req) => { + expect(req.path).toEqual("https://example.org/_matrix/client/versions"); + }) + .respond(200, { + versions: ["r0.0.1"], + }); httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { "m.homeserver": { base_url: "https://example.org", @@ -383,14 +404,16 @@ describe("AutoDiscovery", function() { ]); }); - it("should return SUCCESS with the right homeserver URL", function() { + it("should return SUCCESS with the right homeserver URL", function () { const httpBackend = getHttpBackend(); - httpBackend.when("GET", "/_matrix/client/versions").check((req) => { - expect(req.path) - .toEqual("https://chat.example.org/_matrix/client/versions"); - }).respond(200, { - versions: ["r0.0.1"], - }); + httpBackend + .when("GET", "/_matrix/client/versions") + .check((req) => { + expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions"); + }) + .respond(200, { + versions: ["r0.0.1"], + }); httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { "m.homeserver": { // Note: we also expect this test to trim the trailing slash @@ -418,185 +441,206 @@ describe("AutoDiscovery", function() { ]); }); - it("should return SUCCESS / FAIL_PROMPT when the identity server configuration " + - "is wrong (missing base_url)", function() { + it( + "should return SUCCESS / FAIL_PROMPT when the identity server configuration " + "is wrong (missing base_url)", + function () { + const httpBackend = getHttpBackend(); + httpBackend + .when("GET", "/_matrix/client/versions") + .check((req) => { + expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions"); + }) + .respond(200, { + versions: ["r0.0.1"], + }); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + // Note: we also expect this test to trim the trailing slash + base_url: "https://chat.example.org/", + }, + "m.identity_server": { + not_base_url: "https://identity.example.org", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "SUCCESS", + error: null, + + // We still expect the base_url to be here for debugging purposes. + base_url: "https://chat.example.org", + }, + "m.identity_server": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID_IS_BASE_URL, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }, + ); + + it( + "should return SUCCESS / FAIL_PROMPT when the identity server configuration " + "is wrong (empty base_url)", + function () { + const httpBackend = getHttpBackend(); + httpBackend + .when("GET", "/_matrix/client/versions") + .check((req) => { + expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions"); + }) + .respond(200, { + versions: ["r0.0.1"], + }); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + // Note: we also expect this test to trim the trailing slash + base_url: "https://chat.example.org/", + }, + "m.identity_server": { + base_url: "", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "SUCCESS", + error: null, + + // We still expect the base_url to be here for debugging purposes. + base_url: "https://chat.example.org", + }, + "m.identity_server": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID_IS_BASE_URL, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }, + ); + + it( + "should return SUCCESS / FAIL_PROMPT when the identity server configuration " + + "is wrong (validation error: 404)", + function () { + const httpBackend = getHttpBackend(); + httpBackend + .when("GET", "/_matrix/client/versions") + .check((req) => { + expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions"); + }) + .respond(200, { + versions: ["r0.0.1"], + }); + httpBackend.when("GET", "/_matrix/identity/api/v1").respond(404, {}); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + // Note: we also expect this test to trim the trailing slash + base_url: "https://chat.example.org/", + }, + "m.identity_server": { + base_url: "https://identity.example.org", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "SUCCESS", + error: null, + + // We still expect the base_url to be here for debugging purposes. + base_url: "https://chat.example.org", + }, + "m.identity_server": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER, + base_url: "https://identity.example.org", + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }, + ); + + it( + "should return SUCCESS / FAIL_PROMPT when the identity server configuration " + + "is wrong (validation error: 500)", + function () { + const httpBackend = getHttpBackend(); + httpBackend + .when("GET", "/_matrix/client/versions") + .check((req) => { + expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions"); + }) + .respond(200, { + versions: ["r0.0.1"], + }); + httpBackend.when("GET", "/_matrix/identity/api/v1").respond(500, {}); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + // Note: we also expect this test to trim the trailing slash + base_url: "https://chat.example.org/", + }, + "m.identity_server": { + base_url: "https://identity.example.org", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "SUCCESS", + error: null, + + // We still expect the base_url to be here for debugging purposes + base_url: "https://chat.example.org", + }, + "m.identity_server": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER, + base_url: "https://identity.example.org", + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }, + ); + + it("should return SUCCESS when the identity server configuration is " + "verifiably accurate", function () { const httpBackend = getHttpBackend(); - httpBackend.when("GET", "/_matrix/client/versions").check((req) => { - expect(req.path) - .toEqual("https://chat.example.org/_matrix/client/versions"); - }).respond(200, { - versions: ["r0.0.1"], - }); - httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { - "m.homeserver": { - // Note: we also expect this test to trim the trailing slash - base_url: "https://chat.example.org/", - }, - "m.identity_server": { - not_base_url: "https://identity.example.org", - }, - }); - return Promise.all([ - httpBackend.flushAllExpected(), - AutoDiscovery.findClientConfig("example.org").then((conf) => { - const expected = { - "m.homeserver": { - state: "SUCCESS", - error: null, - - // We still expect the base_url to be here for debugging purposes. - base_url: "https://chat.example.org", - }, - "m.identity_server": { - state: "FAIL_PROMPT", - error: AutoDiscovery.ERROR_INVALID_IS_BASE_URL, - base_url: null, - }, - }; - - expect(conf).toEqual(expected); - }), - ]); - }); - - it("should return SUCCESS / FAIL_PROMPT when the identity server configuration " + - "is wrong (empty base_url)", function() { - const httpBackend = getHttpBackend(); - httpBackend.when("GET", "/_matrix/client/versions").check((req) => { - expect(req.path) - .toEqual("https://chat.example.org/_matrix/client/versions"); - }).respond(200, { - versions: ["r0.0.1"], - }); - httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { - "m.homeserver": { - // Note: we also expect this test to trim the trailing slash - base_url: "https://chat.example.org/", - }, - "m.identity_server": { - base_url: "", - }, - }); - return Promise.all([ - httpBackend.flushAllExpected(), - AutoDiscovery.findClientConfig("example.org").then((conf) => { - const expected = { - "m.homeserver": { - state: "SUCCESS", - error: null, - - // We still expect the base_url to be here for debugging purposes. - base_url: "https://chat.example.org", - }, - "m.identity_server": { - state: "FAIL_PROMPT", - error: AutoDiscovery.ERROR_INVALID_IS_BASE_URL, - base_url: null, - }, - }; - - expect(conf).toEqual(expected); - }), - ]); - }); - - it("should return SUCCESS / FAIL_PROMPT when the identity server configuration " + - "is wrong (validation error: 404)", function() { - const httpBackend = getHttpBackend(); - httpBackend.when("GET", "/_matrix/client/versions").check((req) => { - expect(req.path) - .toEqual("https://chat.example.org/_matrix/client/versions"); - }).respond(200, { - versions: ["r0.0.1"], - }); - httpBackend.when("GET", "/_matrix/identity/api/v1").respond(404, {}); - httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { - "m.homeserver": { - // Note: we also expect this test to trim the trailing slash - base_url: "https://chat.example.org/", - }, - "m.identity_server": { - base_url: "https://identity.example.org", - }, - }); - return Promise.all([ - httpBackend.flushAllExpected(), - AutoDiscovery.findClientConfig("example.org").then((conf) => { - const expected = { - "m.homeserver": { - state: "SUCCESS", - error: null, - - // We still expect the base_url to be here for debugging purposes. - base_url: "https://chat.example.org", - }, - "m.identity_server": { - state: "FAIL_PROMPT", - error: AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER, - base_url: "https://identity.example.org", - }, - }; - - expect(conf).toEqual(expected); - }), - ]); - }); - - it("should return SUCCESS / FAIL_PROMPT when the identity server configuration " + - "is wrong (validation error: 500)", function() { - const httpBackend = getHttpBackend(); - httpBackend.when("GET", "/_matrix/client/versions").check((req) => { - expect(req.path) - .toEqual("https://chat.example.org/_matrix/client/versions"); - }).respond(200, { - versions: ["r0.0.1"], - }); - httpBackend.when("GET", "/_matrix/identity/api/v1").respond(500, {}); - httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { - "m.homeserver": { - // Note: we also expect this test to trim the trailing slash - base_url: "https://chat.example.org/", - }, - "m.identity_server": { - base_url: "https://identity.example.org", - }, - }); - return Promise.all([ - httpBackend.flushAllExpected(), - AutoDiscovery.findClientConfig("example.org").then((conf) => { - const expected = { - "m.homeserver": { - state: "SUCCESS", - error: null, - - // We still expect the base_url to be here for debugging purposes - base_url: "https://chat.example.org", - }, - "m.identity_server": { - state: "FAIL_PROMPT", - error: AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER, - base_url: "https://identity.example.org", - }, - }; - - expect(conf).toEqual(expected); - }), - ]); - }); - - it("should return SUCCESS when the identity server configuration is " + - "verifiably accurate", function() { - const httpBackend = getHttpBackend(); - httpBackend.when("GET", "/_matrix/client/versions").check((req) => { - expect(req.path) - .toEqual("https://chat.example.org/_matrix/client/versions"); - }).respond(200, { - versions: ["r0.0.1"], - }); - httpBackend.when("GET", "/_matrix/identity/api/v1").check((req) => { - expect(req.path) - .toEqual("https://identity.example.org/_matrix/identity/api/v1"); - }).respond(200, {}); + httpBackend + .when("GET", "/_matrix/client/versions") + .check((req) => { + expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions"); + }) + .respond(200, { + versions: ["r0.0.1"], + }); + httpBackend + .when("GET", "/_matrix/identity/api/v1") + .check((req) => { + expect(req.path).toEqual("https://identity.example.org/_matrix/identity/api/v1"); + }) + .respond(200, {}); httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { "m.homeserver": { // Note: we also expect this test to trim the trailing slash @@ -627,19 +671,22 @@ describe("AutoDiscovery", function() { ]); }); - it("should return SUCCESS and preserve non-standard keys from the " + - ".well-known response", function() { + it("should return SUCCESS and preserve non-standard keys from the " + ".well-known response", function () { const httpBackend = getHttpBackend(); - httpBackend.when("GET", "/_matrix/client/versions").check((req) => { - expect(req.path) - .toEqual("https://chat.example.org/_matrix/client/versions"); - }).respond(200, { - versions: ["r0.0.1"], - }); - httpBackend.when("GET", "/_matrix/identity/api/v1").check((req) => { - expect(req.path) - .toEqual("https://identity.example.org/_matrix/identity/api/v1"); - }).respond(200, {}); + httpBackend + .when("GET", "/_matrix/client/versions") + .check((req) => { + expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions"); + }) + .respond(200, { + versions: ["r0.0.1"], + }); + httpBackend + .when("GET", "/_matrix/identity/api/v1") + .check((req) => { + expect(req.path).toEqual("https://identity.example.org/_matrix/identity/api/v1"); + }) + .respond(200, {}); httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { "m.homeserver": { // Note: we also expect this test to trim the trailing slash diff --git a/spec/unit/content-helpers.spec.ts b/spec/unit/content-helpers.spec.ts index 35971600715..9a5ddb143c0 100644 --- a/spec/unit/content-helpers.spec.ts +++ b/spec/unit/content-helpers.spec.ts @@ -26,23 +26,18 @@ import { parseTopicContent, } from "../../src/content-helpers"; -describe('Beacon content helpers', () => { - describe('makeBeaconInfoContent()', () => { +describe("Beacon content helpers", () => { + describe("makeBeaconInfoContent()", () => { const mockDateNow = 123456789; beforeEach(() => { - jest.spyOn(global.Date, 'now').mockReturnValue(mockDateNow); + jest.spyOn(global.Date, "now").mockReturnValue(mockDateNow); }); afterAll(() => { - jest.spyOn(global.Date, 'now').mockRestore(); + jest.spyOn(global.Date, "now").mockRestore(); }); - it('create fully defined event content', () => { - expect(makeBeaconInfoContent( - 1234, - true, - 'nice beacon_info', - LocationAssetType.Pin, - )).toEqual({ - description: 'nice beacon_info', + it("create fully defined event content", () => { + expect(makeBeaconInfoContent(1234, true, "nice beacon_info", LocationAssetType.Pin)).toEqual({ + description: "nice beacon_info", timeout: 1234, live: true, [M_TIMESTAMP.name]: mockDateNow, @@ -52,78 +47,72 @@ describe('Beacon content helpers', () => { }); }); - it('defaults timestamp to current time', () => { - expect(makeBeaconInfoContent( - 1234, - true, - 'nice beacon_info', - LocationAssetType.Pin, - )).toEqual(expect.objectContaining({ - [M_TIMESTAMP.name]: mockDateNow, - })); + it("defaults timestamp to current time", () => { + expect(makeBeaconInfoContent(1234, true, "nice beacon_info", LocationAssetType.Pin)).toEqual( + expect.objectContaining({ + [M_TIMESTAMP.name]: mockDateNow, + }), + ); }); - it('uses timestamp when provided', () => { - expect(makeBeaconInfoContent( - 1234, - true, - 'nice beacon_info', - LocationAssetType.Pin, - 99999, - )).toEqual(expect.objectContaining({ - [M_TIMESTAMP.name]: 99999, - })); + it("uses timestamp when provided", () => { + expect(makeBeaconInfoContent(1234, true, "nice beacon_info", LocationAssetType.Pin, 99999)).toEqual( + expect.objectContaining({ + [M_TIMESTAMP.name]: 99999, + }), + ); }); - it('defaults asset type to self when not set', () => { - expect(makeBeaconInfoContent( - 1234, - true, - 'nice beacon_info', - // no assetType passed - )).toEqual(expect.objectContaining({ - [M_ASSET.name]: { - type: LocationAssetType.Self, - }, - })); + it("defaults asset type to self when not set", () => { + expect( + makeBeaconInfoContent( + 1234, + true, + "nice beacon_info", + // no assetType passed + ), + ).toEqual( + expect.objectContaining({ + [M_ASSET.name]: { + type: LocationAssetType.Self, + }, + }), + ); }); }); - describe('makeBeaconContent()', () => { - it('creates event content without description', () => { - expect(makeBeaconContent( - 'geo:foo', - 123, - '$1234', - // no description - )).toEqual({ + describe("makeBeaconContent()", () => { + it("creates event content without description", () => { + expect( + makeBeaconContent( + "geo:foo", + 123, + "$1234", + // no description + ), + ).toEqual({ [M_LOCATION.name]: { description: undefined, - uri: 'geo:foo', + uri: "geo:foo", }, [M_TIMESTAMP.name]: 123, "m.relates_to": { rel_type: REFERENCE_RELATION.name, - event_id: '$1234', + event_id: "$1234", }, }); }); - it('creates event content with description', () => { - expect(makeBeaconContent( - 'geo:foo', - 123, - '$1234', - 'test description', - )).toEqual({ + it("creates event content with description", () => { + expect(makeBeaconContent("geo:foo", 123, "$1234", "test description")).toEqual({ [M_LOCATION.name]: { - description: 'test description', - uri: 'geo:foo', + description: "test description", + uri: "geo:foo", }, [M_TIMESTAMP.name]: 123, "m.relates_to": { rel_type: REFERENCE_RELATION.name, - event_id: '$1234', + event_id: "$1234", }, }); }); @@ -190,64 +179,81 @@ describe('Beacon content helpers', () => { }); }); -describe('Topic content helpers', () => { - describe('makeTopicContent()', () => { - it('creates fully defined event content without html', () => { +describe("Topic content helpers", () => { + describe("makeTopicContent()", () => { + it("creates fully defined event content without html", () => { expect(makeTopicContent("pizza")).toEqual({ topic: "pizza", - [M_TOPIC.name]: [{ - body: "pizza", - mimetype: "text/plain", - }], + [M_TOPIC.name]: [ + { + body: "pizza", + mimetype: "text/plain", + }, + ], }); }); - it('creates fully defined event content with html', () => { + it("creates fully defined event content with html", () => { expect(makeTopicContent("pizza", "pizza")).toEqual({ topic: "pizza", - [M_TOPIC.name]: [{ - body: "pizza", - mimetype: "text/plain", - }, { - body: "pizza", - mimetype: "text/html", - }], + [M_TOPIC.name]: [ + { + body: "pizza", + mimetype: "text/plain", + }, + { + body: "pizza", + mimetype: "text/html", + }, + ], }); }); }); - describe('parseTopicContent()', () => { - it('parses event content with plain text topic without mimetype', () => { - expect(parseTopicContent({ - topic: "pizza", - [M_TOPIC.name]: [{ - body: "pizza", - }], - })).toEqual({ + describe("parseTopicContent()", () => { + it("parses event content with plain text topic without mimetype", () => { + expect( + parseTopicContent({ + topic: "pizza", + [M_TOPIC.name]: [ + { + body: "pizza", + }, + ], + }), + ).toEqual({ text: "pizza", }); }); - it('parses event content with plain text topic', () => { - expect(parseTopicContent({ - topic: "pizza", - [M_TOPIC.name]: [{ - body: "pizza", - mimetype: "text/plain", - }], - })).toEqual({ + it("parses event content with plain text topic", () => { + expect( + parseTopicContent({ + topic: "pizza", + [M_TOPIC.name]: [ + { + body: "pizza", + mimetype: "text/plain", + }, + ], + }), + ).toEqual({ text: "pizza", }); }); - it('parses event content with html topic', () => { - expect(parseTopicContent({ - topic: "pizza", - [M_TOPIC.name]: [{ - body: "pizza", - mimetype: "text/html", - }], - })).toEqual({ + it("parses event content with html topic", () => { + expect( + parseTopicContent({ + topic: "pizza", + [M_TOPIC.name]: [ + { + body: "pizza", + mimetype: "text/html", + }, + ], + }), + ).toEqual({ text: "pizza", html: "pizza", }); diff --git a/spec/unit/content-repo.spec.ts b/spec/unit/content-repo.spec.ts index 27e7fbc9966..2cbd361239e 100644 --- a/spec/unit/content-repo.spec.ts +++ b/spec/unit/content-repo.spec.ts @@ -16,60 +16,50 @@ limitations under the License. import { getHttpUriForMxc } from "../../src/content-repo"; -describe("ContentRepo", function() { +describe("ContentRepo", function () { const baseUrl = "https://my.home.server"; - describe("getHttpUriForMxc", function() { - it("should do nothing to HTTP URLs when allowing direct links", function() { + describe("getHttpUriForMxc", function () { + it("should do nothing to HTTP URLs when allowing direct links", function () { const httpUrl = "http://example.com/image.jpeg"; - expect( - getHttpUriForMxc( - baseUrl, httpUrl, undefined, undefined, undefined, true, - ), - ).toEqual(httpUrl); + expect(getHttpUriForMxc(baseUrl, httpUrl, undefined, undefined, undefined, true)).toEqual(httpUrl); }); - it("should return the empty string HTTP URLs by default", function() { + it("should return the empty string HTTP URLs by default", function () { const httpUrl = "http://example.com/image.jpeg"; expect(getHttpUriForMxc(baseUrl, httpUrl)).toEqual(""); }); - it("should return a download URL if no width/height/resize are specified", - function() { - const mxcUri = "mxc://server.name/resourceid"; - expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual( - baseUrl + "/_matrix/media/r0/download/server.name/resourceid", - ); - }); + it("should return a download URL if no width/height/resize are specified", function () { + const mxcUri = "mxc://server.name/resourceid"; + expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual( + baseUrl + "/_matrix/media/r0/download/server.name/resourceid", + ); + }); - it("should return the empty string for null input", function() { - expect(getHttpUriForMxc(null as any, '')).toEqual(""); + it("should return the empty string for null input", function () { + expect(getHttpUriForMxc(null as any, "")).toEqual(""); }); - it("should return a thumbnail URL if a width/height/resize is specified", - function() { - const mxcUri = "mxc://server.name/resourceid"; - expect(getHttpUriForMxc(baseUrl, mxcUri, 32, 64, "crop")).toEqual( - baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" + - "?width=32&height=64&method=crop", - ); - }); + it("should return a thumbnail URL if a width/height/resize is specified", function () { + const mxcUri = "mxc://server.name/resourceid"; + expect(getHttpUriForMxc(baseUrl, mxcUri, 32, 64, "crop")).toEqual( + baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" + "?width=32&height=64&method=crop", + ); + }); - it("should put fragments from mxc:// URIs after any query parameters", - function() { - const mxcUri = "mxc://server.name/resourceid#automade"; - expect(getHttpUriForMxc(baseUrl, mxcUri, 32)).toEqual( - baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" + - "?width=32#automade", - ); - }); + it("should put fragments from mxc:// URIs after any query parameters", function () { + const mxcUri = "mxc://server.name/resourceid#automade"; + expect(getHttpUriForMxc(baseUrl, mxcUri, 32)).toEqual( + baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" + "?width=32#automade", + ); + }); - it("should put fragments from mxc:// URIs at the end of the HTTP URI", - function() { - const mxcUri = "mxc://server.name/resourceid#automade"; - expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual( - baseUrl + "/_matrix/media/r0/download/server.name/resourceid#automade", - ); - }); + it("should put fragments from mxc:// URIs at the end of the HTTP URI", function () { + const mxcUri = "mxc://server.name/resourceid#automade"; + expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual( + baseUrl + "/_matrix/media/r0/download/server.name/resourceid#automade", + ); + }); }); }); diff --git a/spec/unit/crypto.spec.ts b/spec/unit/crypto.spec.ts index e8a95370c61..3b7689ca0ce 100644 --- a/spec/unit/crypto.spec.ts +++ b/spec/unit/crypto.spec.ts @@ -1,9 +1,9 @@ -import '../olm-loader'; +import "../olm-loader"; // eslint-disable-next-line no-restricted-imports import { EventEmitter } from "events"; import type { PkDecryption, PkSigning } from "@matrix-org/olm"; -import { MatrixClient } from "../../src/client"; +import { IClaimOTKsResult, MatrixClient } from "../../src/client"; import { Crypto } from "../../src/crypto"; import { MemoryCryptoStore } from "../../src/crypto/store/memory-crypto-store"; import { MockStorageApi } from "../MockStorageApi"; @@ -14,24 +14,25 @@ import * as olmlib from "../../src/crypto/olmlib"; import { sleep } from "../../src/utils"; import { CRYPTO_ENABLED } from "../../src/client"; import { DeviceInfo } from "../../src/crypto/deviceinfo"; -import { logger } from '../../src/logger'; +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'; +import { RoomKeyRequestState } from "../../src/crypto/OutgoingRoomKeyRequestManager"; +import { RoomMember } from "../../src/models/room-member"; +import { IStore } from "../../src/store"; +import { IRoomEncryption, RoomList } from "../../src/crypto/RoomList"; const Olm = global.Olm; -function awaitEvent(emitter, event) { - return new Promise((resolve, reject) => { +function awaitEvent(emitter: EventEmitter, event: string): Promise { + return new Promise((resolve) => { emitter.once(event, (result) => { resolve(result); }); }); } -async function keyshareEventForEvent(client, event, index): Promise { - const roomId = event.getRoomId(); +async function keyshareEventForEvent(client: MatrixClient, event: MatrixEvent, index?: number): Promise { + const roomId = event.getRoomId()!; const eventContent = event.getWireContent(); const key = await client.crypto!.olmDevice.getInboundGroupSessionKey( roomId, @@ -41,16 +42,16 @@ async function keyshareEventForEvent(client, event, index): Promise ); const ksEvent = new MatrixEvent({ type: "m.forwarded_room_key", - sender: client.getUserId(), + 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, + "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, + "session_key": key?.key, + "chain_index": key?.chain_index, + "forwarding_curve25519_key_chain": key?.forwarding_curve25519_key_chain, "org.matrix.msc3061.shared_history": true, }, }); @@ -74,10 +75,10 @@ function roomKeyEventForEvent(client: MatrixClient, event: MatrixEvent): MatrixE type: "m.room_key", sender: client.getUserId()!, content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": roomId, - "session_id": eventContent.session_id, - "session_key": key.key, + algorithm: olmlib.MEGOLM_ALGORITHM, + room_id: roomId, + session_id: eventContent.session_id, + session_key: key.key, }, }); // make onRoomKeyEvent think this was an encrypted event @@ -92,12 +93,12 @@ function roomKeyEventForEvent(client: MatrixClient, event: MatrixEvent): MatrixE return ksEvent; } -describe("Crypto", function() { +describe("Crypto", function () { if (!CRYPTO_ENABLED) { return; } - beforeAll(function() { + beforeAll(function () { return Olm.init(); }); @@ -105,34 +106,35 @@ describe("Crypto", function() { jest.useRealTimers(); }); - it("Crypto exposes the correct olm library version", function() { + it("Crypto exposes the correct olm library version", function () { expect(Crypto.getOlmVersion()[0]).toEqual(3); }); - describe("encrypted events", function() { - it("provides encryption information", async function() { - const client = (new TestClient( - "@alice:example.com", "deviceid", - )).client; + describe("encrypted events", function () { + it("provides encryption information", async function () { + const client = new TestClient("@alice:example.com", "deviceid").client; await client.initCrypto(); // unencrypted event const event = { getId: () => "$event_id", getSenderKey: () => null, - getWireContent: () => {return {};}, + getWireContent: () => { + return {}; + }, } as unknown as MatrixEvent; let encryptionInfo = client.getEventEncryptionInfo(event); expect(encryptionInfo.encrypted).toBeFalsy(); // unknown sender (e.g. deleted device), forwarded megolm key (untrusted) - event.getSenderKey = () => 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI'; - event.getWireContent = () => {return { algorithm: olmlib.MEGOLM_ALGORITHM };}; + event.getSenderKey = () => "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI"; + event.getWireContent = () => { + return { algorithm: olmlib.MEGOLM_ALGORITHM }; + }; event.getForwardingCurve25519KeyChain = () => ["not empty"]; event.isKeySourceUntrusted = () => true; - event.getClaimedEd25519Key = - () => 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + event.getClaimedEd25519Key = () => "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; encryptionInfo = client.getEventEncryptionInfo(event); expect(encryptionInfo.encrypted).toBeTruthy(); @@ -143,10 +145,8 @@ describe("Crypto", function() { event.getForwardingCurve25519KeyChain = () => []; event.isKeySourceUntrusted = () => true; const device = new DeviceInfo("FLIBBLE"); - device.keys["curve25519:FLIBBLE"] = - 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI'; - device.keys["ed25519:FLIBBLE"] = - 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + device.keys["curve25519:FLIBBLE"] = "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI"; + device.keys["ed25519:FLIBBLE"] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; client.crypto!.deviceList.getDeviceByIdentityKey = () => device; encryptionInfo = client.getEventEncryptionInfo(event); @@ -157,8 +157,7 @@ describe("Crypto", function() { // known sender, trusted megolm key, but bad ed25519key event.isKeySourceUntrusted = () => false; - device.keys["ed25519:FLIBBLE"] = - 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; + device.keys["ed25519:FLIBBLE"] = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; encryptionInfo = client.getEventEncryptionInfo(event); expect(encryptionInfo.encrypted).toBeTruthy(); @@ -170,16 +169,17 @@ describe("Crypto", function() { }); }); - describe('Session management', function() { - const otkResponse = { + describe("Session management", function () { + const otkResponse: IClaimOTKsResult = { + failures: {}, one_time_keys: { - '@alice:home.server': { + "@alice:home.server": { aliceDevice: { - 'signed_curve25519:FLIBBLE': { - key: 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI', + "signed_curve25519:FLIBBLE": { + key: "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI", signatures: { - '@alice:home.server': { - 'ed25519:aliceDevice': 'totally a valid signature', + "@alice:home.server": { + "ed25519:aliceDevice": "totally a valid signature", }, }, }, @@ -187,39 +187,43 @@ describe("Crypto", function() { }, }, }; - let crypto; - let mockBaseApis; - let mockRoomList; - let fakeEmitter; + let crypto: Crypto; + let mockBaseApis: MatrixClient; + let mockRoomList: RoomList; + + let fakeEmitter: EventEmitter; - beforeEach(async function() { + beforeEach(async function () { const mockStorage = new MockStorageApi() as unknown as Storage; const clientStore = new MemoryStore({ localStorage: mockStorage }) as unknown as IStore; const cryptoStore = new MemoryCryptoStore(); - cryptoStore.storeEndToEndDeviceData({ - devices: { - '@bob:home.server': { - 'BOBDEVICE': { - algorithms: [], - verified: 1, - known: false, - keys: { - 'curve25519:BOBDEVICE': 'this is a key', + cryptoStore.storeEndToEndDeviceData( + { + devices: { + "@bob:home.server": { + BOBDEVICE: { + algorithms: [], + verified: 1, + known: false, + keys: { + "curve25519:BOBDEVICE": "this is a key", + }, }, }, }, + trackingStatus: {}, }, - trackingStatus: {}, - }, {}); + {}, + ); mockBaseApis = { sendToDevice: jest.fn(), getKeyBackupVersion: jest.fn(), isGuest: jest.fn(), - }; - mockRoomList = {}; + } as unknown as MatrixClient; + mockRoomList = {} as unknown as RoomList; fakeEmitter = new EventEmitter(); @@ -232,80 +236,76 @@ describe("Crypto", function() { mockRoomList, [], ); - crypto.registerEventHandlers(fakeEmitter); + crypto.registerEventHandlers(fakeEmitter as any); await crypto.init(); }); - afterEach(async function() { + afterEach(async function () { await crypto.stop(); }); - it("restarts wedged Olm sessions", async function() { + it("restarts wedged Olm sessions", async function () { const prom = new Promise((resolve) => { - mockBaseApis.claimOneTimeKeys = function() { + mockBaseApis.claimOneTimeKeys = function () { resolve(); - return otkResponse; + return Promise.resolve(otkResponse); }; }); - fakeEmitter.emit('toDeviceEvent', { + fakeEmitter.emit("toDeviceEvent", { getId: jest.fn().mockReturnValue("$wedged"), - getType: jest.fn().mockReturnValue('m.room.message'), + getType: jest.fn().mockReturnValue("m.room.message"), getContent: jest.fn().mockReturnValue({ - msgtype: 'm.bad.encrypted', + msgtype: "m.bad.encrypted", }), getWireContent: jest.fn().mockReturnValue({ - algorithm: 'm.olm.v1.curve25519-aes-sha2', - sender_key: 'this is a key', + algorithm: "m.olm.v1.curve25519-aes-sha2", + sender_key: "this is a key", }), - getSender: jest.fn().mockReturnValue('@bob:home.server'), + getSender: jest.fn().mockReturnValue("@bob:home.server"), }); await prom; }); }); - describe('Key requests', function() { + describe("Key requests", function () { let aliceClient: MatrixClient; let bobClient: MatrixClient; let claraClient: MatrixClient; - beforeEach(async function() { - aliceClient = (new TestClient( - "@alice:example.com", "alicedevice", - )).client; - bobClient = (new TestClient( - "@bob:example.com", "bobdevice", - )).client; - claraClient = (new TestClient( - "@clara:example.com", "claradevice", - )).client; + beforeEach(async function () { + aliceClient = new TestClient("@alice:example.com", "alicedevice").client; + 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() { + afterEach(async function () { aliceClient.stopClient(); bobClient.stopClient(); claraClient.stopClient(); }); - it("does not cancel keyshare requests until all messages are decrypted with trusted keys", 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", + 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", - })]); + 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); @@ -332,35 +332,37 @@ describe("Crypto", function() { }, }), ]; - 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 - } - })); + 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; - const bobDecryptor = bobClient.crypto!.getRoomDecryptor( - roomId, olmlib.MEGOLM_ALGORITHM, - ); + const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); - const decryptEventsPromise = Promise.all(events.map((ev) => { - return awaitEvent(ev, "Event.decrypted"); - })); + const decryptEventsPromise = Promise.all( + events.map((ev) => { + return awaitEvent(ev, "Event.decrypted"); + }), + ); // keyshare the session key starting at the second message, so // the first message can't be decrypted yet, but the second one @@ -415,9 +417,9 @@ describe("Crypto", function() { expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeFalsy(); }); - it("should error if a forwarded room key lacks a content.sender_key", async function() { + it("should error if a forwarded room key lacks a content.sender_key", async function () { const encryptionCfg = { - "algorithm": "m.megolm.v1.aes-sha2", + algorithm: "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); @@ -456,9 +458,7 @@ describe("Crypto", function() { const device = new DeviceInfo(aliceClient.deviceId!); bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; - const bobDecryptor = bobClient.crypto!.getRoomDecryptor( - roomId, olmlib.MEGOLM_ALGORITHM, - ); + const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); const ksEvent = await keyshareEventForEvent(aliceClient, event, 1); ksEvent.getContent().sender_key = undefined; // test @@ -467,7 +467,7 @@ describe("Crypto", function() { expect(bobClient.crypto!.olmDevice.addInboundGroupSession).not.toHaveBeenCalled(); }); - it("creates a new keyshare request if we request a keyshare", async function() { + it("creates a new keyshare request if we request a keyshare", async function () { // make sure that cancelAndResend... creates a new keyshare request // if there wasn't an already-existing one const event = new MatrixEvent({ @@ -487,11 +487,10 @@ describe("Crypto", function() { session_id: "sessionid", sender_key: "senderkey", }; - expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)) - .toBeDefined(); + expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined(); }); - it("uses a new txnid for re-requesting keys", async function() { + it("uses a new txnid for re-requesting keys", async function () { jest.useFakeTimers(); const event = new MatrixEvent({ @@ -536,9 +535,9 @@ describe("Crypto", function() { expect(aliceSendToDevice.mock.calls[2][2]).not.toBe(txnId); }); - it("should accept forwarded keys which it requested", async function() { + it("should accept forwarded keys which it requested", async function () { const encryptionCfg = { - "algorithm": "m.megolm.v1.aes-sha2", + algorithm: "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); @@ -569,24 +568,26 @@ describe("Crypto", function() { }, }), ]; - 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 - } - })); + 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; @@ -604,18 +605,17 @@ describe("Crypto", function() { }; const outgoingReq = await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody); expect(outgoingReq).toBeDefined(); - await cryptoStore.updateOutgoingRoomKeyRequest( - outgoingReq!.requestId, RoomKeyRequestState.Unsent, - { state: RoomKeyRequestState.Sent }, - ); + await cryptoStore.updateOutgoingRoomKeyRequest(outgoingReq!.requestId, RoomKeyRequestState.Unsent, { + state: RoomKeyRequestState.Sent, + }); - const bobDecryptor = bobClient.crypto!.getRoomDecryptor( - roomId, olmlib.MEGOLM_ALGORITHM, - ); + const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); - const decryptEventsPromise = Promise.all(events.map((ev) => { - return awaitEvent(ev, "Event.decrypted"); - })); + 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( @@ -629,22 +629,24 @@ describe("Crypto", function() { 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() { + it("should accept forwarded keys from the user who invited it to the room", async function () { const encryptionCfg = { - "algorithm": "m.megolm.v1.aes-sha2", + 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", - })]); + 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); @@ -673,36 +675,38 @@ describe("Crypto", function() { }, }), ]; - 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 - } - })); + 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 bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); - const decryptEventsPromise = Promise.all(events.map((ev) => { - return awaitEvent(ev, "Event.decrypted"); - })); + 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()!); @@ -718,9 +722,9 @@ describe("Crypto", function() { 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() { + it("should accept forwarded keys from one of its own user's other devices", async function () { const encryptionCfg = { - "algorithm": "m.megolm.v1.aes-sha2", + algorithm: "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); @@ -751,37 +755,39 @@ describe("Crypto", function() { }, }), ]; - 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 - } - })); + 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 bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); - const decryptEventsPromise = Promise.all(events.map((ev) => { - return awaitEvent(ev, "Event.decrypted"); - })); + 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()!); @@ -797,9 +803,9 @@ describe("Crypto", function() { 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() { + it("should not accept unexpected forwarded keys for a room it's in", async function () { const encryptionCfg = { - "algorithm": "m.megolm.v1.aes-sha2", + algorithm: "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); @@ -833,32 +839,32 @@ describe("Crypto", function() { }, }), ]; - 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 - } - })); + 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 bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); ksEvent.event.sender = claraClient.getUserId()!; @@ -872,9 +878,9 @@ describe("Crypto", function() { expect(key).toBeNull(); }); - it("should park forwarded keys for a room it's not in", async function() { + it("should park forwarded keys for a room it's not in", async function () { const encryptionCfg = { - "algorithm": "m.megolm.v1.aes-sha2", + algorithm: "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); @@ -902,26 +908,26 @@ describe("Crypto", function() { }, }), ]; - 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; - })); + 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 bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); const content = events[0].getWireContent(); @@ -940,22 +946,24 @@ describe("Crypto", function() { 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"], - }]); + 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() { - it("creates secret storage even if there is no keyInfo", async function() { - jest.spyOn(logger, 'log').mockImplementation(() => {}); + describe("Secret storage", function () { + it("creates secret storage even if there is no keyInfo", async function () { + jest.spyOn(logger, "log").mockImplementation(() => {}); jest.setTimeout(10000); - const client = (new TestClient("@a:example.com", "dev")).client; + const client = new TestClient("@a:example.com", "dev").client; await client.initCrypto(); client.crypto!.getSecretStorageKey = jest.fn().mockResolvedValue(null); client.crypto!.isCrossSigningReady = async () => false; @@ -987,17 +995,20 @@ describe("Crypto", function() { ensureOlmSessionsForDevices = jest.spyOn(olmlib, "ensureOlmSessionsForDevices"); ensureOlmSessionsForDevices.mockResolvedValue({}); encryptMessageForDevice = jest.spyOn(olmlib, "encryptMessageForDevice"); - encryptMessageForDevice.mockImplementation(async (...[result,,,,,, payload]) => { - result.plaintext = JSON.stringify(payload); + encryptMessageForDevice.mockImplementation(async (...[result, , , , , , payload]) => { + result.plaintext = { type: 0, body: JSON.stringify(payload) }; }); client = new TestClient("@alice:example.org", "aliceweb"); - await client.client.initCrypto(); + + // running initCrypto should trigger a key upload + client.httpBackend.when("POST", "/keys/upload").respond(200, {}); + await Promise.all([client.client.initCrypto(), client.httpBackend.flush("/keys/upload", 1)]); encryptedPayload = { algorithm: "m.olm.v1.curve25519-aes-sha2", sender_key: client.client.crypto!.olmDevice.deviceCurve25519Key, - ciphertext: { plaintext: JSON.stringify(payload) }, + ciphertext: { plaintext: { type: 0, body: JSON.stringify(payload) } }, }; }); @@ -1009,16 +1020,23 @@ describe("Crypto", function() { it("encrypts and sends to devices", async () => { client.httpBackend - .when("PUT", "/sendToDevice/m.room.encrypted", { - messages: { - "@bob:example.org": { - bobweb: encryptedPayload, - bobmobile: encryptedPayload, - }, - "@carol:example.org": { - caroldesktop: encryptedPayload, + .when("PUT", "/sendToDevice/m.room.encrypted") + .check((request) => { + const data = request.data; + delete data.messages["@bob:example.org"]["bobweb"]["org.matrix.msgid"]; + delete data.messages["@bob:example.org"]["bobmobile"]["org.matrix.msgid"]; + delete data.messages["@carol:example.org"]["caroldesktop"]["org.matrix.msgid"]; + expect(data).toStrictEqual({ + messages: { + "@bob:example.org": { + bobweb: encryptedPayload, + bobmobile: encryptedPayload, + }, + "@carol:example.org": { + caroldesktop: encryptedPayload, + }, }, - }, + }); }) .respond(200, {}); @@ -1036,16 +1054,21 @@ describe("Crypto", function() { }); it("sends nothing to devices that couldn't be encrypted to", async () => { - encryptMessageForDevice.mockImplementation(async (...[result,,,, userId, device, payload]) => { + encryptMessageForDevice.mockImplementation(async (...[result, , , , userId, device, payload]) => { // Refuse to encrypt to Carol's desktop device if (userId === "@carol:example.org" && device.deviceId === "caroldesktop") return; - result.plaintext = JSON.stringify(payload); + result.plaintext = { type: 0, body: JSON.stringify(payload) }; }); client.httpBackend - .when("PUT", "/sendToDevice/m.room.encrypted", { + .when("PUT", "/sendToDevice/m.room.encrypted") + .check((req) => { + const data = req.data; + delete data.messages["@bob:example.org"]["bobweb"]["org.matrix.msgid"]; // Carol is nowhere to be seen - messages: { "@bob:example.org": { bobweb: encryptedPayload } }, + expect(data).toStrictEqual({ + messages: { "@bob:example.org": { bobweb: encryptedPayload } }, + }); }) .respond(200, {}); @@ -1091,10 +1114,13 @@ describe("Crypto", function() { it("should free PkDecryption", () => { const free = jest.fn(); - jest.spyOn(Olm, "PkDecryption").mockImplementation(() => ({ - init_with_private_key: jest.fn(), - free, - }) as unknown as PkDecryption); + jest.spyOn(Olm, "PkDecryption").mockImplementation( + () => + ({ + init_with_private_key: jest.fn(), + free, + } as unknown as PkDecryption), + ); client.client.checkSecretStoragePrivateKey(new Uint8Array(), ""); expect(free).toHaveBeenCalled(); }); @@ -1114,10 +1140,13 @@ describe("Crypto", function() { it("should free PkSigning", () => { const free = jest.fn(); - jest.spyOn(Olm, "PkSigning").mockImplementation(() => ({ - init_with_seed: jest.fn(), - free, - }) as unknown as PkSigning); + jest.spyOn(Olm, "PkSigning").mockImplementation( + () => + ({ + init_with_seed: jest.fn(), + free, + } as unknown as PkSigning), + ); client.client.checkCrossSigningPrivateKey(new Uint8Array(), ""); expect(free).toHaveBeenCalled(); }); @@ -1131,7 +1160,7 @@ describe("Crypto", function() { await client.client.initCrypto(); }); - afterEach(async function() { + afterEach(async function () { await client!.stop(); }); @@ -1140,4 +1169,48 @@ describe("Crypto", function() { await client!.client.crypto!.start(); }); }); + + describe("setRoomEncryption", () => { + let mockClient: MatrixClient; + let mockRoomList: RoomList; + let clientStore: IStore; + let crypto: Crypto; + + beforeEach(async function () { + mockClient = {} as MatrixClient; + const mockStorage = new MockStorageApi() as unknown as Storage; + clientStore = new MemoryStore({ localStorage: mockStorage }) as unknown as IStore; + const cryptoStore = new MemoryCryptoStore(); + + mockRoomList = { + getRoomEncryption: jest.fn().mockReturnValue(null), + setRoomEncryption: jest.fn().mockResolvedValue(undefined), + } as unknown as RoomList; + + crypto = new Crypto( + mockClient, + "@alice:home.server", + "FLIBBLE", + clientStore, + cryptoStore, + mockRoomList, + [], + ); + }); + + it("should set the algorithm if called for a known room", async () => { + const room = new Room("!room:id", mockClient, "@my.user:id"); + await clientStore.storeRoom(room); + await crypto.setRoomEncryption("!room:id", { algorithm: "m.megolm.v1.aes-sha2" } as IRoomEncryption); + expect(mockRoomList!.setRoomEncryption).toHaveBeenCalledTimes(1); + expect(jest.mocked(mockRoomList!.setRoomEncryption).mock.calls[0][0]).toEqual("!room:id"); + }); + + it("should raise if called for an unknown room", async () => { + await expect(async () => { + await crypto.setRoomEncryption("!room:id", { algorithm: "m.megolm.v1.aes-sha2" } as IRoomEncryption); + }).rejects.toThrow(/unknown room/); + expect(mockRoomList!.setRoomEncryption).not.toHaveBeenCalled(); + }); + }); }); diff --git a/spec/unit/crypto/CrossSigningInfo.spec.ts b/spec/unit/crypto/CrossSigningInfo.spec.ts index f6b64cac440..090a2a10ea9 100644 --- a/spec/unit/crypto/CrossSigningInfo.spec.ts +++ b/spec/unit/crypto/CrossSigningInfo.spec.ts @@ -14,28 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -import '../../olm-loader'; -import { - CrossSigningInfo, - createCryptoStoreCacheCallbacks, -} from '../../../src/crypto/CrossSigning'; -import { - IndexedDBCryptoStore, -} from '../../../src/crypto/store/indexeddb-crypto-store'; -import { MemoryCryptoStore } from '../../../src/crypto/store/memory-crypto-store'; -import 'fake-indexeddb/auto'; -import 'jest-localstorage-mock'; +import "../../olm-loader"; +import { CrossSigningInfo, createCryptoStoreCacheCallbacks } from "../../../src/crypto/CrossSigning"; +import { IndexedDBCryptoStore } from "../../../src/crypto/store/indexeddb-crypto-store"; +import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store"; +import "fake-indexeddb/auto"; +import "jest-localstorage-mock"; import { OlmDevice } from "../../../src/crypto/OlmDevice"; -import { logger } from '../../../src/logger'; +import { logger } from "../../../src/logger"; const userId = "@alice:example.com"; // Private key for tests only const testKey = new Uint8Array([ - 0xda, 0x5a, 0x27, 0x60, 0xe3, 0x3a, 0xc5, 0x82, - 0x9d, 0x12, 0xc3, 0xbe, 0xe8, 0xaa, 0xc2, 0xef, - 0xae, 0xb1, 0x05, 0xc1, 0xe7, 0x62, 0x78, 0xa6, - 0xd7, 0x1f, 0xf8, 0x2c, 0x51, 0x85, 0xf0, 0x1d, + 0xda, 0x5a, 0x27, 0x60, 0xe3, 0x3a, 0xc5, 0x82, 0x9d, 0x12, 0xc3, 0xbe, 0xe8, 0xaa, 0xc2, 0xef, 0xae, 0xb1, 0x05, + 0xc1, 0xe7, 0x62, 0x78, 0xa6, 0xd7, 0x1f, 0xf8, 0x2c, 0x51, 0x85, 0xf0, 0x1d, ]); const types = [ @@ -50,13 +43,13 @@ badKey[0] ^= 1; const masterKeyPub = "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"; -describe("CrossSigningInfo.getCrossSigningKey", function() { +describe("CrossSigningInfo.getCrossSigningKey", function () { if (!global.Olm) { - logger.warn('Not running megolm backup unit tests: libolm not present'); + logger.warn("Not running megolm backup unit tests: libolm not present"); return; } - beforeAll(function() { + beforeAll(function () { return global.Olm.init(); }); @@ -65,13 +58,12 @@ describe("CrossSigningInfo.getCrossSigningKey", function() { await expect(info.getCrossSigningKey("master")).rejects.toThrow(); }); - it.each(types)("should throw if the callback returns falsey", - async ({ type, shouldCache }) => { - const info = new CrossSigningInfo(userId, { - getCrossSigningKey: async () => false as unknown as Uint8Array, - }); - await expect(info.getCrossSigningKey(type)).rejects.toThrow("falsey"); + it.each(types)("should throw if the callback returns falsey", async ({ type, shouldCache }) => { + const info = new CrossSigningInfo(userId, { + getCrossSigningKey: async () => false as unknown as Uint8Array, }); + await expect(info.getCrossSigningKey(type)).rejects.toThrow("falsey"); + }); it("should throw if the expected key doesn't come back", async () => { const info = new CrossSigningInfo(userId, { @@ -96,10 +88,49 @@ describe("CrossSigningInfo.getCrossSigningKey", function() { } }); - it.each(types)("should request a key from the cache callback (if set)" + - " and does not call app if one is found" + - " %o", - async ({ type, shouldCache }) => { + it.each(types)( + "should request a key from the cache callback (if set)" + " and does not call app if one is found" + " %o", + async ({ type, shouldCache }) => { + const getCrossSigningKey = jest.fn().mockImplementation(() => { + if (shouldCache) { + return Promise.reject(new Error("Regular callback called")); + } else { + return Promise.resolve(testKey); + } + }); + const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey); + const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { getCrossSigningKeyCache }); + const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); + expect(pubKey).toEqual(masterKeyPub); + expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); + if (shouldCache) { + expect(getCrossSigningKeyCache.mock.calls[0][0]).toBe(type); + } + }, + ); + + it.each(types)("should store a key with the cache callback (if set)", async ({ type, shouldCache }) => { + const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); + const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); + const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { storeCrossSigningKeyCache }); + const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); + expect(pubKey).toEqual(masterKeyPub); + expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0); + if (shouldCache) { + expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type); + expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey); + } + }); + + it.each(types)("does not store a bad key to the cache", async ({ type, shouldCache }) => { + const getCrossSigningKey = jest.fn().mockResolvedValue(badKey); + const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); + const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { storeCrossSigningKeyCache }); + await expect(info.getCrossSigningKey(type, masterKeyPub)).rejects.toThrow(); + expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(0); + }); + + it.each(types)("does not store a value to the cache if it came from the cache", async ({ type, shouldCache }) => { const getCrossSigningKey = jest.fn().mockImplementation(() => { if (shouldCache) { return Promise.reject(new Error("Regular callback called")); @@ -108,110 +139,58 @@ describe("CrossSigningInfo.getCrossSigningKey", function() { } }); const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey); + const storeCrossSigningKeyCache = jest.fn().mockRejectedValue(new Error("Tried to store a value from cache")); const info = new CrossSigningInfo( userId, { getCrossSigningKey }, - { getCrossSigningKeyCache }, + { getCrossSigningKeyCache, storeCrossSigningKeyCache }, ); + expect(storeCrossSigningKeyCache.mock.calls.length).toBe(0); const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); expect(pubKey).toEqual(masterKeyPub); - expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); - if (shouldCache) { - expect(getCrossSigningKeyCache.mock.calls[0][0]).toBe(type); - } }); - it.each(types)("should store a key with the cache callback (if set)", + it.each(types)( + "requests a key from the cache callback (if set) and then calls app" + " if one is not found", async ({ type, shouldCache }) => { const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); - const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); + const getCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); + const storeCrossSigningKeyCache = jest.fn(); const info = new CrossSigningInfo( userId, { getCrossSigningKey }, - { storeCrossSigningKeyCache }, + { getCrossSigningKeyCache, storeCrossSigningKeyCache }, ); const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); expect(pubKey).toEqual(masterKeyPub); - expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0); - if (shouldCache) { - expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type); - expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey); - } - }); + expect(getCrossSigningKey.mock.calls.length).toBe(1); + expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); - it.each(types)("does not store a bad key to the cache", - async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockResolvedValue(badKey); - const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); - const info = new CrossSigningInfo( - userId, - { getCrossSigningKey }, - { storeCrossSigningKeyCache }, - ); - await expect(info.getCrossSigningKey(type, masterKeyPub)).rejects.toThrow(); - expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(0); - }); + /* Also expect that the cache gets updated */ + expect(storeCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); + }, + ); - it.each(types)("does not store a value to the cache if it came from the cache", + it.each(types)( + "requests a key from the cache callback (if set) and then" + " calls app if that key doesn't match", async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockImplementation(() => { - if (shouldCache) { - return Promise.reject(new Error("Regular callback called")); - } else { - return Promise.resolve(testKey); - } - }); - const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey); - const storeCrossSigningKeyCache = jest.fn().mockRejectedValue( - new Error("Tried to store a value from cache"), - ); + const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); + const getCrossSigningKeyCache = jest.fn().mockResolvedValue(badKey); + const storeCrossSigningKeyCache = jest.fn(); const info = new CrossSigningInfo( userId, { getCrossSigningKey }, { getCrossSigningKeyCache, storeCrossSigningKeyCache }, ); - expect(storeCrossSigningKeyCache.mock.calls.length).toBe(0); const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); expect(pubKey).toEqual(masterKeyPub); - }); - - it.each(types)("requests a key from the cache callback (if set) and then calls app" + - " if one is not found", async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); - const getCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); - const storeCrossSigningKeyCache = jest.fn(); - const info = new CrossSigningInfo( - userId, - { getCrossSigningKey }, - { getCrossSigningKeyCache, storeCrossSigningKeyCache }, - ); - const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); - expect(pubKey).toEqual(masterKeyPub); - expect(getCrossSigningKey.mock.calls.length).toBe(1); - expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); - - /* Also expect that the cache gets updated */ - expect(storeCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); - }); - - it.each(types)("requests a key from the cache callback (if set) and then" + - " calls app if that key doesn't match", async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); - const getCrossSigningKeyCache = jest.fn().mockResolvedValue(badKey); - const storeCrossSigningKeyCache = jest.fn(); - const info = new CrossSigningInfo( - userId, - { getCrossSigningKey }, - { getCrossSigningKeyCache, storeCrossSigningKeyCache }, - ); - const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); - expect(pubKey).toEqual(masterKeyPub); - expect(getCrossSigningKey.mock.calls.length).toBe(1); - expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); + expect(getCrossSigningKey.mock.calls.length).toBe(1); + expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); - /* Also expect that the cache gets updated */ - expect(storeCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); - }); + /* Also expect that the cache gets updated */ + expect(storeCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); + }, + ); }); /* @@ -219,20 +198,21 @@ describe("CrossSigningInfo.getCrossSigningKey", function() { * it's not possible to get one in normal execution unless you hack as we do here. */ 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; - }], -])("CrossSigning > createCryptoStoreCacheCallbacks [%s]", function(name, dbFactory) { - let store; + ["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; + }, + ], +])("CrossSigning > createCryptoStoreCacheCallbacks [%s]", function (name, dbFactory) { + let store: IndexedDBCryptoStore; beforeAll(() => { store = dbFactory(); @@ -245,8 +225,10 @@ describe.each([ it("should cache data to the store and retrieve it", async () => { await store.startup(); const olmDevice = new OlmDevice(store); - const { getCrossSigningKeyCache, storeCrossSigningKeyCache } = - createCryptoStoreCacheCallbacks(store, olmDevice); + const { getCrossSigningKeyCache, storeCrossSigningKeyCache } = createCryptoStoreCacheCallbacks( + store, + olmDevice, + ); await storeCrossSigningKeyCache!("self_signing", testKey); // If we've not saved anything, don't expect anything diff --git a/spec/unit/crypto/DeviceList.spec.ts b/spec/unit/crypto/DeviceList.spec.ts index 448c92b28e2..39ba036a252 100644 --- a/spec/unit/crypto/DeviceList.spec.ts +++ b/spec/unit/crypto/DeviceList.spec.ts @@ -22,33 +22,29 @@ import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store import { DeviceList } from "../../../src/crypto/DeviceList"; import { IDownloadKeyResult, MatrixClient } from "../../../src"; import { OlmDevice } from "../../../src/crypto/OlmDevice"; +import { CryptoStore } from "../../../src/crypto/store/base"; const signedDeviceList: IDownloadKeyResult = { - "failures": {}, - "device_keys": { + failures: {}, + device_keys: { "@test1:sw1v.org": { - "HGKAWHRVJQ": { - "signatures": { + HGKAWHRVJQ: { + signatures: { "@test1:sw1v.org": { "ed25519:HGKAWHRVJQ": "8PB450fxKDn5s8IiRZ2N2t6MiueQYVRLHFEzqIi1eLdxx1w" + "XEPC1/1Uz9T4gwnKlMVAKkhB5hXQA/3kjaeLABw", }, }, - "user_id": "@test1:sw1v.org", - "keys": { - "ed25519:HGKAWHRVJQ": - "0gI/T6C+mn1pjtvnnW2yB2l1IIBb/5ULlBXi/LXFSEQ", - "curve25519:HGKAWHRVJQ": - "mbIZED1dBsgIgkgzxDpxKkJmsr4hiWlGzQTvUnQe3RY", + user_id: "@test1:sw1v.org", + keys: { + "ed25519:HGKAWHRVJQ": "0gI/T6C+mn1pjtvnnW2yB2l1IIBb/5ULlBXi/LXFSEQ", + "curve25519:HGKAWHRVJQ": "mbIZED1dBsgIgkgzxDpxKkJmsr4hiWlGzQTvUnQe3RY", }, - "algorithms": [ - "m.olm.v1.curve25519-aes-sha2", - "m.megolm.v1.aes-sha2", - ], - "device_id": "HGKAWHRVJQ", - "unsigned": { - "device_display_name": "", + algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], + device_id: "HGKAWHRVJQ", + unsigned: { + device_display_name: "", }, }, }, @@ -56,50 +52,45 @@ const signedDeviceList: IDownloadKeyResult = { }; const signedDeviceList2: IDownloadKeyResult = { - "failures": {}, - "device_keys": { + failures: {}, + device_keys: { "@test2:sw1v.org": { - "QJVRHWAKGH": { - "signatures": { + QJVRHWAKGH: { + signatures: { "@test2:sw1v.org": { "ed25519:QJVRHWAKGH": "w1xxdLe1iIqzEFHLRVYQeuiM6t2N2ZRiI8s5nDKxf054BP8" + "1CPEX/AQXh5BhkKAVMlKnwg4T9zU1/wBALeajk3", }, }, - "user_id": "@test2:sw1v.org", - "keys": { - "ed25519:QJVRHWAKGH": - "Ig0/C6T+bBII1l2By2Wnnvtjp1nm/iXBlLU5/QESFXL", - "curve25519:QJVRHWAKGH": - "YR3eQnUvTQzGlWih4rsmJkKxpDxzgkgIgsBd1DEZIbm", + user_id: "@test2:sw1v.org", + keys: { + "ed25519:QJVRHWAKGH": "Ig0/C6T+bBII1l2By2Wnnvtjp1nm/iXBlLU5/QESFXL", + "curve25519:QJVRHWAKGH": "YR3eQnUvTQzGlWih4rsmJkKxpDxzgkgIgsBd1DEZIbm", }, - "algorithms": [ - "m.olm.v1.curve25519-aes-sha2", - "m.megolm.v1.aes-sha2", - ], - "device_id": "QJVRHWAKGH", - "unsigned": { - "device_display_name": "", + algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], + device_id: "QJVRHWAKGH", + unsigned: { + device_display_name: "", }, }, }, }, }; -describe('DeviceList', function() { - let downloadSpy; - let cryptoStore; +describe("DeviceList", function () { + let downloadSpy: jest.Mock; + let cryptoStore: CryptoStore; let deviceLists: DeviceList[] = []; - beforeEach(function() { + beforeEach(function () { deviceLists = []; downloadSpy = jest.fn(); cryptoStore = new MemoryCryptoStore(); }); - afterEach(function() { + afterEach(function () { for (const dl of deviceLists) { dl.stop(); } @@ -108,94 +99,96 @@ describe('DeviceList', function() { function createTestDeviceList(keyDownloadChunkSize = 250) { const baseApis = { downloadKeysForUsers: downloadSpy, - getUserId: () => '@test1:sw1v.org', - deviceId: 'HGKAWHRVJQ', + getUserId: () => "@test1:sw1v.org", + deviceId: "HGKAWHRVJQ", } as unknown as MatrixClient; const mockOlm = { - verifySignature: function(key, message, signature) {}, + verifySignature: function (key: string, message: string, signature: string) {}, } as unknown as OlmDevice; const dl = new DeviceList(baseApis, cryptoStore, mockOlm, keyDownloadChunkSize); deviceLists.push(dl); return dl; } - it("should successfully download and store device keys", function() { + it("should successfully download and store device keys", function () { const dl = createTestDeviceList(); - dl.startTrackingDeviceList('@test1:sw1v.org'); + dl.startTrackingDeviceList("@test1:sw1v.org"); const queryDefer1 = utils.defer(); downloadSpy.mockReturnValue(queryDefer1.promise); const prom1 = dl.refreshOutdatedDeviceLists(); - expect(downloadSpy).toHaveBeenCalledWith(['@test1:sw1v.org'], {}); + expect(downloadSpy).toHaveBeenCalledWith(["@test1:sw1v.org"], {}); queryDefer1.resolve(utils.deepCopy(signedDeviceList)); return prom1.then(() => { - const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org'); - expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']); + const storedKeys = dl.getRawStoredDevicesForUser("@test1:sw1v.org"); + expect(Object.keys(storedKeys)).toEqual(["HGKAWHRVJQ"]); dl.stop(); }); }); - it("should have an outdated devicelist on an invalidation while an " + - "update is in progress", function() { + it("should have an outdated devicelist on an invalidation while an " + "update is in progress", function () { const dl = createTestDeviceList(); - dl.startTrackingDeviceList('@test1:sw1v.org'); + dl.startTrackingDeviceList("@test1:sw1v.org"); const queryDefer1 = utils.defer(); downloadSpy.mockReturnValue(queryDefer1.promise); const prom1 = dl.refreshOutdatedDeviceLists(); - expect(downloadSpy).toHaveBeenCalledWith(['@test1:sw1v.org'], {}); + expect(downloadSpy).toHaveBeenCalledWith(["@test1:sw1v.org"], {}); downloadSpy.mockReset(); // outdated notif arrives while the request is in flight. const queryDefer2 = utils.defer(); downloadSpy.mockReturnValue(queryDefer2.promise); - dl.invalidateUserDeviceList('@test1:sw1v.org'); + dl.invalidateUserDeviceList("@test1:sw1v.org"); dl.refreshOutdatedDeviceLists(); - dl.saveIfDirty().then(() => { - // the first request completes - queryDefer1.resolve({ - failures: {}, - device_keys: { - '@test1:sw1v.org': {}, - }, + dl.saveIfDirty() + .then(() => { + // the first request completes + queryDefer1.resolve({ + failures: {}, + device_keys: { + "@test1:sw1v.org": {}, + }, + }); + return prom1; + }) + .then(() => { + // uh-oh; user restarts before second request completes. The new instance + // should know we never got a complete device list. + logger.log("Creating new devicelist to simulate app reload"); + downloadSpy.mockReset(); + const dl2 = createTestDeviceList(); + const queryDefer3 = utils.defer(); + downloadSpy.mockReturnValue(queryDefer3.promise); + + const prom3 = dl2.refreshOutdatedDeviceLists(); + expect(downloadSpy).toHaveBeenCalledWith(["@test1:sw1v.org"], {}); + dl2.stop(); + + queryDefer3.resolve(utils.deepCopy(signedDeviceList)); + + // allow promise chain to complete + return prom3; + }) + .then(() => { + const storedKeys = dl.getRawStoredDevicesForUser("@test1:sw1v.org"); + expect(Object.keys(storedKeys)).toEqual(["HGKAWHRVJQ"]); + dl.stop(); }); - return prom1; - }).then(() => { - // uh-oh; user restarts before second request completes. The new instance - // should know we never got a complete device list. - logger.log("Creating new devicelist to simulate app reload"); - downloadSpy.mockReset(); - const dl2 = createTestDeviceList(); - const queryDefer3 = utils.defer(); - downloadSpy.mockReturnValue(queryDefer3.promise); - - const prom3 = dl2.refreshOutdatedDeviceLists(); - expect(downloadSpy).toHaveBeenCalledWith(['@test1:sw1v.org'], {}); - dl2.stop(); - - queryDefer3.resolve(utils.deepCopy(signedDeviceList)); - - // allow promise chain to complete - return prom3; - }).then(() => { - const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org'); - expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']); - dl.stop(); - }); }); - it("should download device keys in batches", function() { + it("should download device keys in batches", function () { const dl = createTestDeviceList(1); - dl.startTrackingDeviceList('@test1:sw1v.org'); - dl.startTrackingDeviceList('@test2:sw1v.org'); + dl.startTrackingDeviceList("@test1:sw1v.org"); + dl.startTrackingDeviceList("@test2:sw1v.org"); const queryDefer1 = utils.defer(); downloadSpy.mockReturnValueOnce(queryDefer1.promise); @@ -204,16 +197,16 @@ describe('DeviceList', function() { const prom1 = dl.refreshOutdatedDeviceLists(); expect(downloadSpy).toBeCalledTimes(2); - expect(downloadSpy).toHaveBeenNthCalledWith(1, ['@test1:sw1v.org'], {}); - expect(downloadSpy).toHaveBeenNthCalledWith(2, ['@test2:sw1v.org'], {}); + expect(downloadSpy).toHaveBeenNthCalledWith(1, ["@test1:sw1v.org"], {}); + expect(downloadSpy).toHaveBeenNthCalledWith(2, ["@test2:sw1v.org"], {}); queryDefer1.resolve(utils.deepCopy(signedDeviceList)); queryDefer2.resolve(utils.deepCopy(signedDeviceList2)); return prom1.then(() => { - const storedKeys1 = dl.getRawStoredDevicesForUser('@test1:sw1v.org'); - expect(Object.keys(storedKeys1)).toEqual(['HGKAWHRVJQ']); - const storedKeys2 = dl.getRawStoredDevicesForUser('@test2:sw1v.org'); - expect(Object.keys(storedKeys2)).toEqual(['QJVRHWAKGH']); + const storedKeys1 = dl.getRawStoredDevicesForUser("@test1:sw1v.org"); + expect(Object.keys(storedKeys1)).toEqual(["HGKAWHRVJQ"]); + const storedKeys2 = dl.getRawStoredDevicesForUser("@test2:sw1v.org"); + expect(Object.keys(storedKeys2)).toEqual(["QJVRHWAKGH"]); dl.stop(); }); }); diff --git a/spec/unit/crypto/algorithms/megolm.spec.ts b/spec/unit/crypto/algorithms/megolm.spec.ts index a1519d4ab61..2dca6765d1c 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.ts +++ b/spec/unit/crypto/algorithms/megolm.spec.ts @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { mocked, MockedObject } from 'jest-mock'; +import { mocked, MockedObject } from "jest-mock"; -import '../../../olm-loader'; +import "../../../olm-loader"; +import type { OutboundGroupSession } from "@matrix-org/olm"; import * as algorithms from "../../../../src/crypto/algorithms"; import { MemoryCryptoStore } from "../../../../src/crypto/store/memory-crypto-store"; import * as testUtils from "../../../test-utils/test-utils"; @@ -27,25 +28,26 @@ import { MatrixEvent } from "../../../../src/models/event"; import { TestClient } from "../../../TestClient"; import { Room } from "../../../../src/models/room"; import * as olmlib from "../../../../src/crypto/olmlib"; -import { TypedEventEmitter } from '../../../../src/models/typed-event-emitter'; -import { ClientEvent, MatrixClient, RoomMember } from '../../../../src'; -import { DeviceInfo, IDevice } from '../../../../src/crypto/deviceinfo'; -import { DeviceTrustLevel } from '../../../../src/crypto/CrossSigning'; +import { TypedEventEmitter } from "../../../../src/models/typed-event-emitter"; +import { ClientEvent, MatrixClient, RoomMember } from "../../../../src"; +import { DeviceInfo, IDevice } from "../../../../src/crypto/deviceinfo"; +import { DeviceTrustLevel } from "../../../../src/crypto/CrossSigning"; +import { MegolmEncryption as MegolmEncryptionClass } from "../../../../src/crypto/algorithms/megolm"; -const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get('m.megolm.v1.aes-sha2')!; -const MegolmEncryption = algorithms.ENCRYPTION_CLASSES.get('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'; +const ROOM_ID = "!ROOM:ID"; const Olm = global.Olm; -describe("MegolmDecryption", function() { +describe("MegolmDecryption", function () { if (!global.Olm) { - logger.warn('Not running megolm unit tests: libolm not present'); + logger.warn("Not running megolm unit tests: libolm not present"); return; } - beforeAll(function() { + beforeAll(function () { return Olm.init(); }); @@ -54,8 +56,8 @@ describe("MegolmDecryption", function() { let mockCrypto: MockedObject; let mockBaseApis: MockedObject; - beforeEach(async function() { - mockCrypto = testUtils.mock(Crypto, 'Crypto') as MockedObject; + beforeEach(async function () { + mockCrypto = testUtils.mock(Crypto, "Crypto") as MockedObject; mockBaseApis = { claimOneTimeKeys: jest.fn(), sendToDevice: jest.fn(), @@ -67,7 +69,7 @@ describe("MegolmDecryption", function() { const olmDevice = new OlmDevice(cryptoStore); megolmDecryption = new MegolmDecryption({ - userId: '@user:id', + userId: "@user:id", crypto: mockCrypto, olmDevice: olmDevice, baseApis: mockBaseApis, @@ -86,22 +88,22 @@ describe("MegolmDecryption", function() { jest.clearAllMocks(); }); - describe('receives some keys:', function() { - let groupSession; - beforeEach(async function() { + describe("receives some keys:", function () { + let groupSession: OutboundGroupSession; + beforeEach(async function () { groupSession = new global.Olm.OutboundGroupSession(); groupSession.create(); // construct a fake decrypted key event via the use of a mocked // 'crypto' implementation. const event = new MatrixEvent({ - type: 'm.room.encrypted', + type: "m.room.encrypted", }); const decryptedData = { clearEvent: { - type: 'm.room_key', + type: "m.room_key", content: { - algorithm: 'm.megolm.v1.aes-sha2', + algorithm: "m.megolm.v1.aes-sha2", room_id: ROOM_ID, session_id: groupSession.session_id(), session_key: groupSession.session_key(), @@ -118,7 +120,7 @@ describe("MegolmDecryption", function() { }; const mockCrypto = { - decryptEvent: function() { + decryptEvent: function () { return Promise.resolve(decryptedData); }, } as unknown as Crypto; @@ -128,106 +130,113 @@ describe("MegolmDecryption", function() { }); }); - it('can decrypt an event', function() { + it("can decrypt an event", function () { const event = new MatrixEvent({ - type: 'm.room.encrypted', + type: "m.room.encrypted", room_id: ROOM_ID, content: { - algorithm: 'm.megolm.v1.aes-sha2', + algorithm: "m.megolm.v1.aes-sha2", sender_key: "SENDER_CURVE25519", session_id: groupSession.session_id(), - ciphertext: groupSession.encrypt(JSON.stringify({ - room_id: ROOM_ID, - content: 'testytest', - })), + ciphertext: groupSession.encrypt( + JSON.stringify({ + room_id: ROOM_ID, + content: "testytest", + }), + ), }, }); return megolmDecryption.decryptEvent(event).then((res) => { - expect(res.clearEvent.content).toEqual('testytest'); + expect(res.clearEvent.content).toEqual("testytest"); }); }); - it('can respond to a key request event', function() { + it("can respond to a key request event", function () { const keyRequest: IncomingRoomKeyRequest = { - requestId: '123', + requestId: "123", share: jest.fn(), - userId: '@alice:foo', - deviceId: 'alidevice', + userId: "@alice:foo", + deviceId: "alidevice", requestBody: { - algorithm: '', + algorithm: "", room_id: ROOM_ID, sender_key: "SENDER_CURVE25519", session_id: groupSession.session_id(), }, }; - return megolmDecryption.hasKeysForKeyRequest( - keyRequest, - ).then((hasKeys) => { - expect(hasKeys).toBe(true); + return megolmDecryption + .hasKeysForKeyRequest(keyRequest) + .then((hasKeys) => { + expect(hasKeys).toBe(true); - // set up some pre-conditions for the share call - const deviceInfo = {} as DeviceInfo; - mockCrypto.getStoredDevice.mockReturnValue(deviceInfo); + // set up some pre-conditions for the share call + const deviceInfo = {} as DeviceInfo; + mockCrypto.getStoredDevice.mockReturnValue(deviceInfo); - mockOlmLib.ensureOlmSessionsForDevices.mockResolvedValue({ - '@alice:foo': { 'alidevice': { - sessionId: 'alisession', - device: new DeviceInfo('alidevice'), - } }, - }); + mockOlmLib.ensureOlmSessionsForDevices.mockResolvedValue({ + "@alice:foo": { + alidevice: { + sessionId: "alisession", + device: new DeviceInfo("alidevice"), + }, + }, + }); - const awaitEncryptForDevice = new Promise((res, rej) => { - mockOlmLib.encryptMessageForDevice.mockImplementation(() => { - res(); - return Promise.resolve(); + const awaitEncryptForDevice = new Promise((res, rej) => { + mockOlmLib.encryptMessageForDevice.mockImplementation(() => { + res(); + return Promise.resolve(); + }); }); - }); - mockBaseApis.sendToDevice.mockReset(); - mockBaseApis.queueToDevice.mockReset(); + mockBaseApis.sendToDevice.mockReset(); + mockBaseApis.queueToDevice.mockReset(); - // do the share - megolmDecryption.shareKeysWithDevice(keyRequest); + // do the share + megolmDecryption.shareKeysWithDevice(keyRequest); - // it's asynchronous, so we have to wait a bit - return awaitEncryptForDevice; - }).then(() => { - // check that it called encryptMessageForDevice with - // appropriate args. - expect(mockOlmLib.encryptMessageForDevice).toBeCalledTimes(1); + // it's asynchronous, so we have to wait a bit + return awaitEncryptForDevice; + }) + .then(() => { + // check that it called encryptMessageForDevice with + // appropriate args. + expect(mockOlmLib.encryptMessageForDevice).toBeCalledTimes(1); - const call = mockOlmLib.encryptMessageForDevice.mock.calls[0]; - const payload = call[6]; + const call = mockOlmLib.encryptMessageForDevice.mock.calls[0]; + const payload = call[6]; - expect(payload.type).toEqual("m.forwarded_room_key"); - expect(payload.content).toMatchObject({ - sender_key: "SENDER_CURVE25519", - sender_claimed_ed25519_key: "SENDER_ED25519", - session_id: groupSession.session_id(), - chain_index: 0, - forwarding_curve25519_key_chain: [], + expect(payload.type).toEqual("m.forwarded_room_key"); + expect(payload.content).toMatchObject({ + sender_key: "SENDER_CURVE25519", + sender_claimed_ed25519_key: "SENDER_ED25519", + session_id: groupSession.session_id(), + chain_index: 0, + forwarding_curve25519_key_chain: [], + }); + expect(payload.content.session_key).toBeDefined(); }); - expect(payload.content.session_key).toBeDefined(); - }); }); - it("can detect replay attacks", function() { + it("can detect replay attacks", function () { // trying to decrypt two different messages (marked by different // event IDs or timestamps) using the same (sender key, session id, // message index) triple should result in an exception being thrown // as it should be detected as a replay attack. const sessionId = groupSession.session_id(); - const cipherText = groupSession.encrypt(JSON.stringify({ - room_id: ROOM_ID, - content: 'testytest', - })); + const cipherText = groupSession.encrypt( + JSON.stringify({ + room_id: ROOM_ID, + content: "testytest", + }), + ); const event1 = new MatrixEvent({ - type: 'm.room.encrypted', + type: "m.room.encrypted", room_id: ROOM_ID, content: { - algorithm: 'm.megolm.v1.aes-sha2', + algorithm: "m.megolm.v1.aes-sha2", sender_key: "SENDER_CURVE25519", session_id: sessionId, ciphertext: cipherText, @@ -238,49 +247,50 @@ describe("MegolmDecryption", function() { const successHandler = jest.fn(); const failureHandler = jest.fn((err) => { - expect(err.toString()).toMatch( - /Duplicate message index, possible replay attack/, - ); + expect(err.toString()).toMatch(/Duplicate message index, possible replay attack/); }); - return megolmDecryption.decryptEvent(event1).then((res) => { - const event2 = new MatrixEvent({ - type: 'm.room.encrypted', - room_id: ROOM_ID, - content: { - algorithm: 'm.megolm.v1.aes-sha2', - sender_key: "SENDER_CURVE25519", - session_id: sessionId, - ciphertext: cipherText, - }, - event_id: "$event2", - origin_server_ts: 1507754149000, - }); + return megolmDecryption + .decryptEvent(event1) + .then((res) => { + const event2 = new MatrixEvent({ + type: "m.room.encrypted", + room_id: ROOM_ID, + content: { + algorithm: "m.megolm.v1.aes-sha2", + sender_key: "SENDER_CURVE25519", + session_id: sessionId, + ciphertext: cipherText, + }, + event_id: "$event2", + origin_server_ts: 1507754149000, + }); - return megolmDecryption.decryptEvent(event2); - }).then( - successHandler, - failureHandler, - ).then(() => { - expect(successHandler).not.toHaveBeenCalled(); - expect(failureHandler).toHaveBeenCalled(); - }); + return megolmDecryption.decryptEvent(event2); + }) + .then(successHandler, failureHandler) + .then(() => { + expect(successHandler).not.toHaveBeenCalled(); + expect(failureHandler).toHaveBeenCalled(); + }); }); - it("allows re-decryption of the same event", function() { + it("allows re-decryption of the same event", function () { // in contrast with the previous test, if the event ID and // timestamp are the same, then it should not be considered a // replay attack const sessionId = groupSession.session_id(); - const cipherText = groupSession.encrypt(JSON.stringify({ - room_id: ROOM_ID, - content: 'testytest', - })); + const cipherText = groupSession.encrypt( + JSON.stringify({ + room_id: ROOM_ID, + content: "testytest", + }), + ); const event = new MatrixEvent({ - type: 'm.room.encrypted', + type: "m.room.encrypted", room_id: ROOM_ID, content: { - algorithm: 'm.megolm.v1.aes-sha2', + algorithm: "m.megolm.v1.aes-sha2", sender_key: "SENDER_CURVE25519", session_id: sessionId, ciphertext: cipherText, @@ -298,10 +308,10 @@ describe("MegolmDecryption", function() { describe("session reuse and key reshares", () => { const rotationPeriodMs = 999 * 24 * 60 * 60 * 1000; // 999 days, so we don't have to deal with it - let megolmEncryption; - let aliceDeviceInfo; - let mockRoom; - let olmDevice; + let megolmEncryption: MegolmEncryptionClass; + let aliceDeviceInfo: DeviceInfo; + let mockRoom: Room; + let olmDevice: OlmDevice; beforeEach(async () => { // @ts-ignore assigning to readonly prop @@ -317,13 +327,13 @@ describe("MegolmDecryption", function() { mockBaseApis.claimOneTimeKeys.mockResolvedValue({ failures: {}, one_time_keys: { - '@alice:home.server': { + "@alice:home.server": { aliceDevice: { - 'signed_curve25519:flooble': { - key: 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI', + "signed_curve25519:flooble": { + key: "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI", signatures: { - '@alice:home.server': { - 'ed25519:aliceDevice': 'totally valid', + "@alice:home.server": { + "ed25519:aliceDevice": "totally valid", }, }, }, @@ -335,37 +345,37 @@ describe("MegolmDecryption", function() { mockBaseApis.queueToDevice.mockResolvedValue(undefined); aliceDeviceInfo = { - deviceId: 'aliceDevice', + deviceId: "aliceDevice", isBlocked: jest.fn().mockReturnValue(false), isUnverified: jest.fn().mockReturnValue(false), - getIdentityKey: jest.fn().mockReturnValue( - 'YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE', - ), - getFingerprint: jest.fn().mockReturnValue(''), - }; - - mockCrypto.downloadKeys.mockReturnValue(Promise.resolve({ - '@alice:home.server': { - aliceDevice: aliceDeviceInfo, - }, - })); + getIdentityKey: jest.fn().mockReturnValue("YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE"), + getFingerprint: jest.fn().mockReturnValue(""), + } as unknown as DeviceInfo; + + mockCrypto.downloadKeys.mockReturnValue( + Promise.resolve({ + "@alice:home.server": { + aliceDevice: aliceDeviceInfo, + }, + }), + ); mockCrypto.checkDeviceTrust.mockReturnValue({ isVerified: () => false, } as DeviceTrustLevel); megolmEncryption = new MegolmEncryption({ - userId: '@user:id', - deviceId: '12345', + userId: "@user:id", + deviceId: "12345", crypto: mockCrypto, olmDevice: olmDevice, baseApis: mockBaseApis, roomId: ROOM_ID, config: { - algorithm: 'm.megolm.v1.aes-sha2', + algorithm: "m.megolm.v1.aes-sha2", rotation_period_ms: rotationPeriodMs, }, - }); + }) as MegolmEncryptionClass; // Splice the real method onto the mock object as megolm uses this method // on the crypto class in order to encrypt / start sessions @@ -377,11 +387,9 @@ describe("MegolmDecryption", function() { mockCrypto.baseApis = mockBaseApis; mockRoom = { - getEncryptionTargetMembers: jest.fn().mockReturnValue( - [{ userId: "@alice:home.server" }], - ), + getEncryptionTargetMembers: jest.fn().mockReturnValue([{ userId: "@alice:home.server" }]), getBlacklistUnverifiedDevices: jest.fn().mockReturnValue(false), - }; + } as unknown as Room; }); it("should use larger otkTimeout when preparing to encrypt room", async () => { @@ -392,16 +400,21 @@ describe("MegolmDecryption", function() { expect(mockRoom.getEncryptionTargetMembers).toHaveBeenCalled(); expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalledWith( - [['@alice:home.server', 'aliceDevice']], 'signed_curve25519', 10000, + [["@alice:home.server", "aliceDevice"]], + "signed_curve25519", + 10000, ); }); it("should generate a new session if this one needs rotation", async () => { + // @ts-ignore - private method access const session = await megolmEncryption.prepareNewSession(false); session.creationTime -= rotationPeriodMs + 10000; // a smidge over the rotation time // Inject expired session which needs rotation + // @ts-ignore - private field access megolmEncryption.setupPromise = Promise.resolve(session); + // @ts-ignore - private method access const prepareNewSessionSpy = jest.spyOn(megolmEncryption, "prepareNewSession"); await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { body: "Some text", @@ -409,7 +422,7 @@ describe("MegolmDecryption", function() { expect(prepareNewSessionSpy).toHaveBeenCalledTimes(1); }); - it("re-uses sessions for sequential messages", async function() { + it("re-uses sessions for sequential messages", async function () { const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { body: "Some text", }); @@ -417,14 +430,16 @@ describe("MegolmDecryption", function() { // this should have claimed a key for alice as it's starting a new session expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalledWith( - [['@alice:home.server', 'aliceDevice']], 'signed_curve25519', 2000, - ); - expect(mockCrypto.downloadKeys).toHaveBeenCalledWith( - ['@alice:home.server'], false, + [["@alice:home.server", "aliceDevice"]], + "signed_curve25519", + 2000, ); + expect(mockCrypto.downloadKeys).toHaveBeenCalledWith(["@alice:home.server"], false); expect(mockBaseApis.queueToDevice).toHaveBeenCalled(); expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalledWith( - [['@alice:home.server', 'aliceDevice']], 'signed_curve25519', 2000, + [["@alice:home.server", "aliceDevice"]], + "signed_curve25519", + 2000, ); mockBaseApis.claimOneTimeKeys.mockReset(); @@ -440,71 +455,81 @@ describe("MegolmDecryption", function() { expect(ct2.session_id).toEqual(ct1.session_id); }); - it("re-shares keys to devices it's already sent to", async function() { + it("re-shares keys to devices it's already sent to", async function () { const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { body: "Some text", }); mockBaseApis.sendToDevice.mockClear(); - await megolmEncryption.reshareKeyWithDevice( - olmDevice.deviceCurve25519Key, + await megolmEncryption.reshareKeyWithDevice!( + olmDevice.deviceCurve25519Key!, ct1.session_id, - '@alice:home.server', + "@alice:home.server", aliceDeviceInfo, ); expect(mockBaseApis.sendToDevice).toHaveBeenCalled(); }); - it("does not re-share keys to devices whose keys have changed", async function() { + it("does not re-share keys to devices whose keys have changed", async function () { const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { body: "Some text", }); - aliceDeviceInfo.getIdentityKey = jest.fn().mockReturnValue( - 'YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWI', - ); + aliceDeviceInfo.getIdentityKey = jest + .fn() + .mockReturnValue("YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWI"); mockBaseApis.queueToDevice.mockClear(); - await megolmEncryption.reshareKeyWithDevice( - olmDevice.deviceCurve25519Key, + await megolmEncryption.reshareKeyWithDevice!( + olmDevice.deviceCurve25519Key!, ct1.session_id, - '@alice:home.server', + "@alice:home.server", aliceDeviceInfo, ); expect(mockBaseApis.queueToDevice).not.toHaveBeenCalled(); }); + + it("shouldn't wedge the setup promise if sharing a room key fails", async () => { + // @ts-ignore - private field access + const initialSetupPromise = await megolmEncryption.setupPromise; + expect(initialSetupPromise).toBe(null); + + // @ts-ignore - private field access + megolmEncryption.prepareSession = () => { + throw new Error("Can't prepare session"); + }; + + await expect(() => + // @ts-ignore - private field access + megolmEncryption.ensureOutboundSession(mockRoom, {}, {}, true), + ).rejects.toThrow(); + + // @ts-ignore - private field access + const finalSetupPromise = await megolmEncryption.setupPromise; + expect(finalSetupPromise).toBe(null); + }); }); }); - it("notifies devices that have been blocked", async function() { - const aliceClient = (new TestClient( - "@alice:example.com", "alicedevice", - )).client; - const bobClient1 = (new TestClient( - "@bob:example.com", "bobdevice1", - )).client; - const bobClient2 = (new TestClient( - "@bob:example.com", "bobdevice2", - )).client; - await Promise.all([ - aliceClient.initCrypto(), - bobClient1.initCrypto(), - bobClient2.initCrypto(), - ]); + it("notifies devices that have been blocked", async function () { + const aliceClient = new TestClient("@alice:example.com", "alicedevice").client; + const bobClient1 = new TestClient("@bob:example.com", "bobdevice1").client; + const bobClient2 = new TestClient("@bob:example.com", "bobdevice2").client; + await Promise.all([aliceClient.initCrypto(), bobClient1.initCrypto(), bobClient2.initCrypto()]); const aliceDevice = aliceClient.crypto!.olmDevice; const bobDevice1 = bobClient1.crypto!.olmDevice; const bobDevice2 = bobClient2.crypto!.olmDevice; const encryptionCfg = { - "algorithm": "m.megolm.v1.aes-sha2", + algorithm: "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const room = new Room(roomId, aliceClient, "@alice:example.com", {}); const bobMember = new RoomMember(roomId, "@bob:example.com"); - room.getEncryptionTargetMembers = async function() { + room.getEncryptionTargetMembers = async function () { return [bobMember]; }; room.setBlacklistUnverifiedDevices(true); @@ -532,10 +557,8 @@ describe("MegolmDecryption", function() { }, }; - aliceClient.crypto!.deviceList.storeDevicesForUser( - "@bob:example.com", BOB_DEVICES, - ); - aliceClient.crypto!.deviceList.downloadKeys = async function(userIds) { + aliceClient.crypto!.deviceList.storeDevicesForUser("@bob:example.com", BOB_DEVICES); + aliceClient.crypto!.deviceList.downloadKeys = async function (userIds) { // @ts-ignore short-circuiting private method return this.getDevicesFromStore(userIds); }; @@ -558,22 +581,23 @@ describe("MegolmDecryption", function() { const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0]; expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/); delete contentMap["@bob:example.com"].bobdevice1.session_id; + delete contentMap["@bob:example.com"].bobdevice1["org.matrix.msgid"]; delete contentMap["@bob:example.com"].bobdevice2.session_id; + delete contentMap["@bob:example.com"].bobdevice2["org.matrix.msgid"]; expect(contentMap).toStrictEqual({ - '@bob:example.com': { + "@bob:example.com": { bobdevice1: { algorithm: "m.megolm.v1.aes-sha2", room_id: roomId, - code: 'm.unverified', - reason: - 'The sender has disabled encrypting to unverified devices.', + code: "m.unverified", + reason: "The sender has disabled encrypting to unverified devices.", sender_key: aliceDevice.deviceCurve25519Key, }, bobdevice2: { algorithm: "m.megolm.v1.aes-sha2", room_id: roomId, - code: 'm.blacklisted', - reason: 'The sender has blocked you.', + code: "m.blacklisted", + reason: "The sender has blocked you.", sender_key: aliceDevice.deviceCurve25519Key, }, }, @@ -584,27 +608,20 @@ describe("MegolmDecryption", function() { bobClient2.stopClient(); }); - it("does not block unverified devices when sending verification events", async function() { - const aliceClient = (new TestClient( - "@alice:example.com", "alicedevice", - )).client; - const bobClient = (new TestClient( - "@bob:example.com", "bobdevice", - )).client; - await Promise.all([ - aliceClient.initCrypto(), - bobClient.initCrypto(), - ]); + it("does not block unverified devices when sending verification events", async function () { + const aliceClient = new TestClient("@alice:example.com", "alicedevice").client; + const bobClient = new TestClient("@bob:example.com", "bobdevice").client; + await Promise.all([aliceClient.initCrypto(), bobClient.initCrypto()]); const bobDevice = bobClient.crypto!.olmDevice; const encryptionCfg = { - "algorithm": "m.megolm.v1.aes-sha2", + algorithm: "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const room = new Room(roomId, aliceClient, "@alice:example.com", {}); const bobMember = new RoomMember(roomId, "@bob:example.com"); - room.getEncryptionTargetMembers = async function() { + room.getEncryptionTargetMembers = async function () { return [bobMember]; }; room.setBlacklistUnverifiedDevices(true); @@ -623,17 +640,15 @@ describe("MegolmDecryption", function() { }, }; - aliceClient.crypto!.deviceList.storeDevicesForUser( - "@bob:example.com", BOB_DEVICES, - ); - aliceClient.crypto!.deviceList.downloadKeys = async function(userIds) { + aliceClient.crypto!.deviceList.storeDevicesForUser("@bob:example.com", BOB_DEVICES); + aliceClient.crypto!.deviceList.downloadKeys = async function (userIds) { // @ts-ignore private return this.getDevicesFromStore(userIds); }; await bobDevice.generateOneTimeKeys(1); const oneTimeKeys = await bobDevice.getOneTimeKeys(); - const signedOneTimeKeys: Record = {}; + const signedOneTimeKeys: Record = {}; for (const keyId in oneTimeKeys.curve25519) { if (oneTimeKeys.curve25519.hasOwnProperty(keyId)) { const k = { @@ -648,7 +663,7 @@ describe("MegolmDecryption", function() { aliceClient.claimOneTimeKeys = jest.fn().mockResolvedValue({ one_time_keys: { - '@bob:example.com': { + "@bob:example.com": { bobdevice: signedOneTimeKeys, }, }, @@ -678,22 +693,15 @@ describe("MegolmDecryption", function() { bobClient.stopClient(); }); - it("notifies devices when unable to create olm session", async function() { - const aliceClient = (new TestClient( - "@alice:example.com", "alicedevice", - )).client; - const bobClient = (new TestClient( - "@bob:example.com", "bobdevice", - )).client; - await Promise.all([ - aliceClient.initCrypto(), - bobClient.initCrypto(), - ]); + it("notifies devices when unable to create olm session", async function () { + const aliceClient = new TestClient("@alice:example.com", "alicedevice").client; + const bobClient = new TestClient("@bob:example.com", "bobdevice").client; + await Promise.all([aliceClient.initCrypto(), bobClient.initCrypto()]); const aliceDevice = aliceClient.crypto!.olmDevice; const bobDevice = bobClient.crypto!.olmDevice; const encryptionCfg = { - "algorithm": "m.megolm.v1.aes-sha2", + algorithm: "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); @@ -727,10 +735,8 @@ describe("MegolmDecryption", function() { }, }; - aliceClient.crypto!.deviceList.storeDevicesForUser( - "@bob:example.com", BOB_DEVICES, - ); - aliceClient.crypto!.deviceList.downloadKeys = async function(userIds) { + aliceClient.crypto!.deviceList.storeDevicesForUser("@bob:example.com", BOB_DEVICES); + aliceClient.crypto!.deviceList.downloadKeys = async function (userIds) { // @ts-ignore private return this.getDevicesFromStore(userIds); }; @@ -755,12 +761,13 @@ describe("MegolmDecryption", function() { expect(aliceClient.sendToDevice).toHaveBeenCalled(); const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0]; expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/); + delete contentMap["@bob:example.com"]["bobdevice"]["org.matrix.msgid"]; expect(contentMap).toStrictEqual({ - '@bob:example.com': { + "@bob:example.com": { bobdevice: { algorithm: "m.megolm.v1.aes-sha2", - code: 'm.no_olm', - reason: 'Unable to establish a secure channel.', + code: "m.no_olm", + reason: "Unable to establish a secure channel.", sender_key: aliceDevice.deviceCurve25519Key, }, }, @@ -770,17 +777,10 @@ describe("MegolmDecryption", function() { bobClient.stopClient(); }); - it("throws an error describing why it doesn't have a key", async function() { - const aliceClient = (new TestClient( - "@alice:example.com", "alicedevice", - )).client; - const bobClient = (new TestClient( - "@bob:example.com", "bobdevice", - )).client; - await Promise.all([ - aliceClient.initCrypto(), - bobClient.initCrypto(), - ]); + it("throws an error describing why it doesn't have a key", async function () { + const aliceClient = new TestClient("@alice:example.com", "alicedevice").client; + const bobClient = new TestClient("@bob:example.com", "bobdevice").client; + await Promise.all([aliceClient.initCrypto(), bobClient.initCrypto()]); const bobDevice = bobClient.crypto!.olmDevice; const aliceEventEmitter = new TypedEventEmitter(); @@ -788,74 +788,81 @@ describe("MegolmDecryption", function() { const roomId = "!someroom"; - aliceEventEmitter.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({ - type: "m.room_key.withheld", - sender: "@bob:example.com", - content: { - algorithm: "m.megolm.v1.aes-sha2", - room_id: roomId, - session_id: "session_id1", - sender_key: bobDevice.deviceCurve25519Key, - code: "m.blacklisted", - reason: "You have been blocked", - }, - })); - - await expect(aliceClient.crypto!.decryptEvent(new MatrixEvent({ - type: "m.room.encrypted", - sender: "@bob:example.com", - event_id: "$event", - room_id: roomId, - content: { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "blablabla", - device_id: "bobdevice", - sender_key: bobDevice.deviceCurve25519Key, - session_id: "session_id1", - }, - }))).rejects.toThrow("The sender has blocked you."); + aliceEventEmitter.emit( + ClientEvent.ToDeviceEvent, + new MatrixEvent({ + type: "m.room_key.withheld", + sender: "@bob:example.com", + content: { + algorithm: "m.megolm.v1.aes-sha2", + room_id: roomId, + session_id: "session_id1", + sender_key: bobDevice.deviceCurve25519Key, + code: "m.blacklisted", + reason: "You have been blocked", + }, + }), + ); - aliceEventEmitter.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({ - type: "m.room_key.withheld", - sender: "@bob:example.com", - content: { - algorithm: "m.megolm.v1.aes-sha2", - room_id: roomId, - session_id: "session_id2", - sender_key: bobDevice.deviceCurve25519Key, - code: "m.blacklisted", - reason: "You have been blocked", - }, - })); + await expect( + aliceClient.crypto!.decryptEvent( + new MatrixEvent({ + type: "m.room.encrypted", + sender: "@bob:example.com", + event_id: "$event", + room_id: roomId, + content: { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "blablabla", + device_id: "bobdevice", + sender_key: bobDevice.deviceCurve25519Key, + session_id: "session_id1", + }, + }), + ), + ).rejects.toThrow("The sender has blocked you."); + + aliceEventEmitter.emit( + ClientEvent.ToDeviceEvent, + new MatrixEvent({ + type: "m.room_key.withheld", + sender: "@bob:example.com", + content: { + algorithm: "m.megolm.v1.aes-sha2", + room_id: roomId, + session_id: "session_id2", + sender_key: bobDevice.deviceCurve25519Key, + code: "m.blacklisted", + reason: "You have been blocked", + }, + }), + ); - await expect(aliceClient.crypto!.decryptEvent(new MatrixEvent({ - type: "m.room.encrypted", - sender: "@bob:example.com", - event_id: "$event", - room_id: roomId, - content: { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "blablabla", - device_id: "bobdevice", - sender_key: bobDevice.deviceCurve25519Key, - session_id: "session_id2", - }, - }))).rejects.toThrow("The sender has blocked you."); + await expect( + aliceClient.crypto!.decryptEvent( + new MatrixEvent({ + type: "m.room.encrypted", + sender: "@bob:example.com", + event_id: "$event", + room_id: roomId, + content: { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "blablabla", + device_id: "bobdevice", + sender_key: bobDevice.deviceCurve25519Key, + session_id: "session_id2", + }, + }), + ), + ).rejects.toThrow("The sender has blocked you."); aliceClient.stopClient(); bobClient.stopClient(); }); - it("throws an error describing the lack of an olm session", async function() { - const aliceClient = (new TestClient( - "@alice:example.com", "alicedevice", - )).client; - const bobClient = (new TestClient( - "@bob:example.com", "bobdevice", - )).client; - await Promise.all([ - aliceClient.initCrypto(), - bobClient.initCrypto(), - ]); + it("throws an error describing the lack of an olm session", async function () { + const aliceClient = new TestClient("@alice:example.com", "alicedevice").client; + const bobClient = new TestClient("@bob:example.com", "bobdevice").client; + await Promise.all([aliceClient.initCrypto(), bobClient.initCrypto()]); const aliceEventEmitter = new TypedEventEmitter(); aliceClient.crypto!.registerEventHandlers(aliceEventEmitter); @@ -867,84 +874,91 @@ describe("MegolmDecryption", function() { const now = Date.now(); - aliceEventEmitter.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({ - type: "m.room_key.withheld", - sender: "@bob:example.com", - content: { - algorithm: "m.megolm.v1.aes-sha2", - room_id: roomId, - session_id: "session_id1", - sender_key: bobDevice.deviceCurve25519Key, - code: "m.no_olm", - reason: "Unable to establish a secure channel.", - }, - })); + aliceEventEmitter.emit( + ClientEvent.ToDeviceEvent, + new MatrixEvent({ + type: "m.room_key.withheld", + sender: "@bob:example.com", + content: { + algorithm: "m.megolm.v1.aes-sha2", + room_id: roomId, + session_id: "session_id1", + sender_key: bobDevice.deviceCurve25519Key, + code: "m.no_olm", + reason: "Unable to establish a secure channel.", + }, + }), + ); await new Promise((resolve) => { setTimeout(resolve, 100); }); - await expect(aliceClient.crypto!.decryptEvent(new MatrixEvent({ - type: "m.room.encrypted", - sender: "@bob:example.com", - event_id: "$event", - room_id: roomId, - content: { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "blablabla", - device_id: "bobdevice", - sender_key: bobDevice.deviceCurve25519Key, - session_id: "session_id1", - }, - origin_server_ts: now, - }))).rejects.toThrow("The sender was unable to establish a secure channel."); - - aliceEventEmitter.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({ - type: "m.room_key.withheld", - sender: "@bob:example.com", - content: { - algorithm: "m.megolm.v1.aes-sha2", - room_id: roomId, - session_id: "session_id2", - sender_key: bobDevice.deviceCurve25519Key, - code: "m.no_olm", - reason: "Unable to establish a secure channel.", - }, - })); + await expect( + aliceClient.crypto!.decryptEvent( + new MatrixEvent({ + type: "m.room.encrypted", + sender: "@bob:example.com", + event_id: "$event", + room_id: roomId, + content: { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "blablabla", + device_id: "bobdevice", + sender_key: bobDevice.deviceCurve25519Key, + session_id: "session_id1", + }, + origin_server_ts: now, + }), + ), + ).rejects.toThrow("The sender was unable to establish a secure channel."); + + aliceEventEmitter.emit( + ClientEvent.ToDeviceEvent, + new MatrixEvent({ + type: "m.room_key.withheld", + sender: "@bob:example.com", + content: { + algorithm: "m.megolm.v1.aes-sha2", + room_id: roomId, + session_id: "session_id2", + sender_key: bobDevice.deviceCurve25519Key, + code: "m.no_olm", + reason: "Unable to establish a secure channel.", + }, + }), + ); await new Promise((resolve) => { setTimeout(resolve, 100); }); - await expect(aliceClient.crypto!.decryptEvent(new MatrixEvent({ - type: "m.room.encrypted", - sender: "@bob:example.com", - event_id: "$event", - room_id: roomId, - content: { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "blablabla", - device_id: "bobdevice", - sender_key: bobDevice.deviceCurve25519Key, - session_id: "session_id2", - }, - origin_server_ts: now, - }))).rejects.toThrow("The sender was unable to establish a secure channel."); + await expect( + aliceClient.crypto!.decryptEvent( + new MatrixEvent({ + type: "m.room.encrypted", + sender: "@bob:example.com", + event_id: "$event", + room_id: roomId, + content: { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "blablabla", + device_id: "bobdevice", + sender_key: bobDevice.deviceCurve25519Key, + session_id: "session_id2", + }, + origin_server_ts: now, + }), + ), + ).rejects.toThrow("The sender was unable to establish a secure channel."); aliceClient.stopClient(); bobClient.stopClient(); }); - it("throws an error to indicate a wedged olm session", async function() { - const aliceClient = (new TestClient( - "@alice:example.com", "alicedevice", - )).client; - const bobClient = (new TestClient( - "@bob:example.com", "bobdevice", - )).client; - await Promise.all([ - aliceClient.initCrypto(), - bobClient.initCrypto(), - ]); + it("throws an error to indicate a wedged olm session", async function () { + const aliceClient = new TestClient("@alice:example.com", "alicedevice").client; + const bobClient = new TestClient("@bob:example.com", "bobdevice").client; + await Promise.all([aliceClient.initCrypto(), bobClient.initCrypto()]); const aliceEventEmitter = new TypedEventEmitter(); aliceClient.crypto!.registerEventHandlers(aliceEventEmitter); @@ -956,35 +970,42 @@ describe("MegolmDecryption", function() { const now = Date.now(); // pretend we got an event that we can't decrypt - aliceEventEmitter.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({ - type: "m.room.encrypted", - sender: "@bob:example.com", - content: { - msgtype: "m.bad.encrypted", - algorithm: "m.megolm.v1.aes-sha2", - session_id: "session_id", - sender_key: bobDevice.deviceCurve25519Key, - }, - })); + aliceEventEmitter.emit( + ClientEvent.ToDeviceEvent, + new MatrixEvent({ + type: "m.room.encrypted", + sender: "@bob:example.com", + content: { + msgtype: "m.bad.encrypted", + algorithm: "m.megolm.v1.aes-sha2", + session_id: "session_id", + sender_key: bobDevice.deviceCurve25519Key, + }, + }), + ); await new Promise((resolve) => { setTimeout(resolve, 100); }); - await expect(aliceClient.crypto!.decryptEvent(new MatrixEvent({ - type: "m.room.encrypted", - sender: "@bob:example.com", - event_id: "$event", - room_id: roomId, - content: { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "blablabla", - device_id: "bobdevice", - sender_key: bobDevice.deviceCurve25519Key, - session_id: "session_id", - }, - origin_server_ts: now, - }))).rejects.toThrow("The secure channel with the sender was corrupted."); + await expect( + aliceClient.crypto!.decryptEvent( + new MatrixEvent({ + type: "m.room.encrypted", + sender: "@bob:example.com", + event_id: "$event", + room_id: roomId, + content: { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "blablabla", + device_id: "bobdevice", + sender_key: bobDevice.deviceCurve25519Key, + session_id: "session_id", + }, + origin_server_ts: now, + }), + ), + ).rejects.toThrow("The secure channel with the sender was corrupted."); aliceClient.stopClient(); bobClient.stopClient(); }); diff --git a/spec/unit/crypto/algorithms/olm.spec.ts b/spec/unit/crypto/algorithms/olm.spec.ts index b24532091ae..6099ccceb6c 100644 --- a/spec/unit/crypto/algorithms/olm.spec.ts +++ b/spec/unit/crypto/algorithms/olm.spec.ts @@ -15,15 +15,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MockedObject } from 'jest-mock'; +import { MockedObject } from "jest-mock"; -import '../../../olm-loader'; +import "../../../olm-loader"; import { MemoryCryptoStore } from "../../../../src/crypto/store/memory-crypto-store"; import { logger } from "../../../../src/logger"; import { OlmDevice } from "../../../../src/crypto/OlmDevice"; import * as olmlib from "../../../../src/crypto/olmlib"; import { DeviceInfo } from "../../../../src/crypto/deviceinfo"; -import { MatrixClient } from '../../../../src'; +import { MatrixClient } from "../../../../src"; function makeOlmDevice() { const cryptoStore = new MemoryCryptoStore(); @@ -31,73 +31,72 @@ function makeOlmDevice() { return olmDevice; } -async function setupSession(initiator, opponent) { +async function setupSession(initiator: OlmDevice, opponent: OlmDevice) { await opponent.generateOneTimeKeys(1); const keys = await opponent.getOneTimeKeys(); - const firstKey = Object.values(keys['curve25519'])[0]; + const firstKey = Object.values(keys["curve25519"])[0]; - const sid = await initiator.createOutboundSession( - opponent.deviceCurve25519Key, firstKey, - ); + const sid = await initiator.createOutboundSession(opponent.deviceCurve25519Key!, firstKey); return sid; } -describe("OlmDevice", function() { +function alwaysSucceed(promise: Promise): Promise { + // swallow any exception thrown by a promise, so that + // Promise.all doesn't abort + return promise.catch(() => {}); +} + +describe("OlmDevice", function () { if (!global.Olm) { - logger.warn('Not running megolm unit tests: libolm not present'); + logger.warn("Not running megolm unit tests: libolm not present"); return; } - beforeAll(function() { + beforeAll(function () { return global.Olm.init(); }); let aliceOlmDevice: OlmDevice; let bobOlmDevice: OlmDevice; - beforeEach(async function() { + beforeEach(async function () { aliceOlmDevice = makeOlmDevice(); bobOlmDevice = makeOlmDevice(); await aliceOlmDevice.init(); await bobOlmDevice.init(); }); - describe('olm', function() { - it("can decrypt messages", async function() { + describe("olm", function () { + it("can decrypt messages", async function () { const sid = await setupSession(aliceOlmDevice, bobOlmDevice); - const ciphertext = await aliceOlmDevice.encryptMessage( + const ciphertext = (await aliceOlmDevice.encryptMessage( bobOlmDevice.deviceCurve25519Key!, sid, "The olm or proteus is an aquatic salamander in the family Proteidae", - ) as any; // OlmDevice.encryptMessage has incorrect return type + )) as any; // OlmDevice.encryptMessage has incorrect return type const result = await bobOlmDevice.createInboundSession( aliceOlmDevice.deviceCurve25519Key!, ciphertext.type, ciphertext.body, ); - expect(result.payload).toEqual( - "The olm or proteus is an aquatic salamander in the family Proteidae", - ); + expect(result.payload).toEqual("The olm or proteus is an aquatic salamander in the family Proteidae"); }); - it('exports picked account and olm sessions', async function() { + it("exports picked account and olm sessions", async function () { const sessionId = await setupSession(aliceOlmDevice, bobOlmDevice); const exported = await bobOlmDevice.export(); // At this moment only Alice (the “initiator” in setupSession) has a session expect(exported.sessions).toEqual([]); - const MESSAGE = ( - "The olm or proteus is an aquatic salamander" - + " in the family Proteidae" - ); - const ciphertext = await aliceOlmDevice.encryptMessage( + const MESSAGE = "The olm or proteus is an aquatic salamander" + " in the family Proteidae"; + const ciphertext = (await aliceOlmDevice.encryptMessage( bobOlmDevice.deviceCurve25519Key!, sessionId, MESSAGE, - ) as any; // OlmDevice.encryptMessage has incorrect return type + )) as any; // OlmDevice.encryptMessage has incorrect return type const bobRecreatedOlmDevice = makeOlmDevice(); bobRecreatedOlmDevice.init({ fromExportedDevice: exported }); @@ -113,15 +112,12 @@ describe("OlmDevice", function() { // this time we expect Bob to have a session to export expect(exportedAgain.sessions).toHaveLength(1); - const MESSAGE_2 = ( - "In contrast to most amphibians," - + " the olm is entirely aquatic" - ); - const ciphertext2 = await aliceOlmDevice.encryptMessage( + const MESSAGE_2 = "In contrast to most amphibians," + " the olm is entirely aquatic"; + const ciphertext2 = (await aliceOlmDevice.encryptMessage( bobOlmDevice.deviceCurve25519Key!, sessionId, MESSAGE_2, - ) as any; // OlmDevice.encryptMessage has incorrect return type + )) as any; // OlmDevice.encryptMessage has incorrect return type const bobRecreatedAgainOlmDevice = makeOlmDevice(); bobRecreatedAgainOlmDevice.init({ fromExportedDevice: exportedAgain }); @@ -136,7 +132,7 @@ describe("OlmDevice", function() { expect(decrypted2).toEqual(MESSAGE_2); }); - it("creates only one session at a time", async function() { + it("creates only one session at a time", async function () { // if we call ensureOlmSessionsForDevices multiple times, it should // only try to create one session at a time, even if the server is // slow @@ -152,27 +148,21 @@ describe("OlmDevice", function() { } as unknown as MockedObject; const devicesByUser = { "@bob:example.com": [ - DeviceInfo.fromStorage({ - keys: { - "curve25519:ABCDEFG": "akey", + DeviceInfo.fromStorage( + { + keys: { + "curve25519:ABCDEFG": "akey", + }, }, - }, "ABCDEFG"), + "ABCDEFG", + ), ], }; - function alwaysSucceed(promise) { - // swallow any exception thrown by a promise, so that - // Promise.all doesn't abort - return promise.catch(() => {}); - } // start two tasks that try to ensure that there's an olm session const promises = Promise.all([ - alwaysSucceed(olmlib.ensureOlmSessionsForDevices( - aliceOlmDevice, baseApis, devicesByUser, - )), - alwaysSucceed(olmlib.ensureOlmSessionsForDevices( - aliceOlmDevice, baseApis, devicesByUser, - )), + alwaysSucceed(olmlib.ensureOlmSessionsForDevices(aliceOlmDevice, baseApis, devicesByUser)), + alwaysSucceed(olmlib.ensureOlmSessionsForDevices(aliceOlmDevice, baseApis, devicesByUser)), ]); await new Promise((resolve) => { @@ -192,7 +182,7 @@ describe("OlmDevice", function() { expect(count).toBe(2); }); - it("avoids deadlocks when two tasks are ensuring the same devices", async function() { + it("avoids deadlocks when two tasks are ensuring the same devices", async function () { // This test checks whether `ensureOlmSessionsForDevices` properly // handles multiple tasks in flight ensuring some set of devices in // common without deadlocks. @@ -208,60 +198,47 @@ describe("OlmDevice", function() { }, } as unknown as MockedObject; - const deviceBobA = DeviceInfo.fromStorage({ - keys: { - "curve25519:BOB-A": "akey", + const deviceBobA = DeviceInfo.fromStorage( + { + keys: { + "curve25519:BOB-A": "akey", + }, }, - }, "BOB-A"); - const deviceBobB = DeviceInfo.fromStorage({ - keys: { - "curve25519:BOB-B": "bkey", + "BOB-A", + ); + const deviceBobB = DeviceInfo.fromStorage( + { + keys: { + "curve25519:BOB-B": "bkey", + }, }, - }, "BOB-B"); + "BOB-B", + ); // There's no required ordering of devices per user, so here we // create two different orderings so that each task reserves a // device the other task needs before continuing. const devicesByUserAB = { - "@bob:example.com": [ - deviceBobA, - deviceBobB, - ], + "@bob:example.com": [deviceBobA, deviceBobB], }; const devicesByUserBA = { - "@bob:example.com": [ - deviceBobB, - deviceBobA, - ], + "@bob:example.com": [deviceBobB, deviceBobA], }; - function alwaysSucceed(promise) { - // swallow any exception thrown by a promise, so that - // Promise.all doesn't abort - return promise.catch(() => {}); - } - - const task1 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices( - aliceOlmDevice, baseApis, devicesByUserAB, - )); + const task1 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices(aliceOlmDevice, baseApis, devicesByUserAB)); // After a single tick through the first task, it should have // claimed ownership of all devices to avoid deadlocking others. expect(Object.keys(aliceOlmDevice.sessionsInProgress).length).toBe(2); - const task2 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices( - aliceOlmDevice, baseApis, devicesByUserBA, - )); + const task2 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices(aliceOlmDevice, baseApis, devicesByUserBA)); // The second task should not have changed the ownership count, as // it's waiting on the first task. expect(Object.keys(aliceOlmDevice.sessionsInProgress).length).toBe(2); // Track the tasks, but don't await them yet. - const promises = Promise.all([ - task1, - task2, - ]); + const promises = Promise.all([task1, task2]); await new Promise((resolve) => { setTimeout(resolve, 200); diff --git a/spec/unit/crypto/backup.spec.ts b/spec/unit/crypto/backup.spec.ts index 0d0820cd3f8..4c1be8672bd 100644 --- a/spec/unit/crypto/backup.spec.ts +++ b/spec/unit/crypto/backup.spec.ts @@ -15,9 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MockedObject } from "jest-mock"; - -import '../../olm-loader'; +import "../../olm-loader"; import { logger } from "../../../src/logger"; import * as olmlib from "../../../src/crypto/olmlib"; import { MatrixClient } from "../../../src/client"; @@ -30,27 +28,31 @@ import { Crypto } from "../../../src/crypto"; import { resetCrossSigningKeys } from "./crypto-utils"; import { BackupManager } from "../../../src/crypto/backup"; import { StubStore } from "../../../src/store/stub"; -import { MatrixScheduler } from '../../../src'; +import { IndexedDBCryptoStore, MatrixScheduler } from "../../../src"; +import { CryptoStore } from "../../../src/crypto/store/base"; +import { MegolmDecryption as MegolmDecryptionClass } from "../../../src/crypto/algorithms/megolm"; +import { IKeyBackupInfo } from "../../../src/crypto/keybackup"; const Olm = global.Olm; -const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get('m.megolm.v1.aes-sha2')!; +const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get("m.megolm.v1.aes-sha2")!; -const ROOM_ID = '!ROOM:ID'; +const ROOM_ID = "!ROOM:ID"; -const SESSION_ID = 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc'; +const SESSION_ID = "o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc"; const ENCRYPTED_EVENT = new MatrixEvent({ - type: 'm.room.encrypted', - room_id: '!ROOM:ID', + type: "m.room.encrypted", + room_id: "!ROOM:ID", content: { - algorithm: 'm.megolm.v1.aes-sha2', - sender_key: 'SENDER_CURVE25519', + algorithm: "m.megolm.v1.aes-sha2", + sender_key: "SENDER_CURVE25519", session_id: SESSION_ID, - ciphertext: 'AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N' - + 'CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl' - + 'mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs', + ciphertext: + "AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N" + + "CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl" + + "mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs", }, - event_id: '$event1', + event_id: "$event1", origin_server_ts: 1507753886000, }); @@ -59,19 +61,20 @@ const CURVE25519_KEY_BACKUP_DATA = { forwarded_count: 0, is_verified: false, session_data: { - ciphertext: '2z2M7CZ+azAiTHN1oFzZ3smAFFt+LEOYY6h3QO3XXGdw' - + '6YpNn/gpHDO6I/rgj1zNd4FoTmzcQgvKdU8kN20u5BWRHxaHTZ' - + 'Slne5RxE6vUdREsBgZePglBNyG0AogR/PVdcrv/v18Y6rLM5O9' - + 'SELmwbV63uV9Kuu/misMxoqbuqEdG7uujyaEKtjlQsJ5MGPQOy' - + 'Syw7XrnesSwF6XWRMxcPGRV0xZr3s9PI350Wve3EncjRgJ9IGF' - + 'ru1bcptMqfXgPZkOyGvrphHoFfoK7nY3xMEHUiaTRfRIjq8HNV' - + '4o8QY1qmWGnxNBQgOlL8MZlykjg3ULmQ3DtFfQPj/YYGS3jzxv' - + 'C+EBjaafmsg+52CTeK3Rswu72PX450BnSZ1i3If4xWAUKvjTpe' - + 'Ug5aDLqttOv1pITolTJDw5W/SD+b5rjEKg1CFCHGEGE9wwV3Nf' - + 'QHVCQL+dfpd7Or0poy4dqKMAi3g0o3Tg7edIF8d5rREmxaALPy' - + 'iie8PHD8mj/5Y0GLqrac4CD6+Mop7eUTzVovprjg', - mac: '5lxYBHQU80M', - ephemeral: '/Bn0A4UMFwJaDDvh0aEk1XZj3k1IfgCxgFY9P9a0b14', + ciphertext: + "2z2M7CZ+azAiTHN1oFzZ3smAFFt+LEOYY6h3QO3XXGdw" + + "6YpNn/gpHDO6I/rgj1zNd4FoTmzcQgvKdU8kN20u5BWRHxaHTZ" + + "Slne5RxE6vUdREsBgZePglBNyG0AogR/PVdcrv/v18Y6rLM5O9" + + "SELmwbV63uV9Kuu/misMxoqbuqEdG7uujyaEKtjlQsJ5MGPQOy" + + "Syw7XrnesSwF6XWRMxcPGRV0xZr3s9PI350Wve3EncjRgJ9IGF" + + "ru1bcptMqfXgPZkOyGvrphHoFfoK7nY3xMEHUiaTRfRIjq8HNV" + + "4o8QY1qmWGnxNBQgOlL8MZlykjg3ULmQ3DtFfQPj/YYGS3jzxv" + + "C+EBjaafmsg+52CTeK3Rswu72PX450BnSZ1i3If4xWAUKvjTpe" + + "Ug5aDLqttOv1pITolTJDw5W/SD+b5rjEKg1CFCHGEGE9wwV3Nf" + + "QHVCQL+dfpd7Or0poy4dqKMAi3g0o3Tg7edIF8d5rREmxaALPy" + + "iie8PHD8mj/5Y0GLqrac4CD6+Mop7eUTzVovprjg", + mac: "5lxYBHQU80M", + ephemeral: "/Bn0A4UMFwJaDDvh0aEk1XZj3k1IfgCxgFY9P9a0b14", }, }; @@ -80,54 +83,60 @@ const AES256_KEY_BACKUP_DATA = { forwarded_count: 0, is_verified: false, session_data: { - iv: 'b3Jqqvm5S9QdmXrzssspLQ', - ciphertext: 'GOOASO3E9ThogkG0zMjEduGLM3u9jHZTkS7AvNNbNj3q1znwk4OlaVKXce' - + '7ynofiiYIiS865VlOqrKEEXv96XzRyUpgn68e3WsicwYl96EtjIEh/iY003PG2Qd' - + 'EluT899Ax7PydpUHxEktbWckMppYomUR5q8x1KI1SsOQIiJaIGThmIMPANRCFiK0' - + 'WQj+q+dnhzx4lt9AFqU5bKov8qKnw2qGYP7/+6RmJ0Kpvs8tG6lrcNDEHtFc2r0r' - + 'KKubDypo0Vc8EWSwsAHdKa36ewRavpreOuE8Z9RLfY0QIR1ecXrMqW0CdGFr7H3P' - + 'vcjF8sjwvQAavzxEKT1WMGizSMLeKWo2mgZ5cKnwV5HGUAw596JQvKs9laG2U89K' - + 'YrT0sH30vi62HKzcBLcDkWkUSNYPz7UiZ1MM0L380UA+1ZOXSOmtBA9xxzzbc8Xd' - + 'fRimVgklGdxrxjzuNLYhL2BvVH4oPWonD9j0bvRwE6XkimdbGQA8HB7UmXXjE8WA' - + 'RgaDHkfzoA3g3aeQ', - mac: 'uR988UYgGL99jrvLLPX3V1ows+UYbktTmMxPAo2kxnU', + iv: "b3Jqqvm5S9QdmXrzssspLQ", + ciphertext: + "GOOASO3E9ThogkG0zMjEduGLM3u9jHZTkS7AvNNbNj3q1znwk4OlaVKXce" + + "7ynofiiYIiS865VlOqrKEEXv96XzRyUpgn68e3WsicwYl96EtjIEh/iY003PG2Qd" + + "EluT899Ax7PydpUHxEktbWckMppYomUR5q8x1KI1SsOQIiJaIGThmIMPANRCFiK0" + + "WQj+q+dnhzx4lt9AFqU5bKov8qKnw2qGYP7/+6RmJ0Kpvs8tG6lrcNDEHtFc2r0r" + + "KKubDypo0Vc8EWSwsAHdKa36ewRavpreOuE8Z9RLfY0QIR1ecXrMqW0CdGFr7H3P" + + "vcjF8sjwvQAavzxEKT1WMGizSMLeKWo2mgZ5cKnwV5HGUAw596JQvKs9laG2U89K" + + "YrT0sH30vi62HKzcBLcDkWkUSNYPz7UiZ1MM0L380UA+1ZOXSOmtBA9xxzzbc8Xd" + + "fRimVgklGdxrxjzuNLYhL2BvVH4oPWonD9j0bvRwE6XkimdbGQA8HB7UmXXjE8WA" + + "RgaDHkfzoA3g3aeQ", + mac: "uR988UYgGL99jrvLLPX3V1ows+UYbktTmMxPAo2kxnU", }, }; const CURVE25519_BACKUP_INFO = { algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, - version: '1', + version: "1", auth_data: { public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", }, }; -const AES256_BACKUP_INFO = { +const AES256_BACKUP_INFO: IKeyBackupInfo = { algorithm: "org.matrix.msc3270.v1.aes-hmac-sha2", - version: '1', - auth_data: { - // FIXME: add iv and mac - }, + version: "1", + auth_data: {} as IKeyBackupInfo["auth_data"], }; -const keys = {}; +const keys: Record = {}; -function getCrossSigningKey(type) { - return keys[type]; +function getCrossSigningKey(type: string) { + return Promise.resolve(keys[type]); } -function saveCrossSigningKeys(k) { +function saveCrossSigningKeys(k: Record) { Object.assign(keys, k); } -function makeTestClient(cryptoStore) { - const scheduler = [ - "getQueueForEvent", "queueEvent", "removeEventFromQueue", - "setProcessFunction", - ].reduce((r, k) => {r[k] = jest.fn(); return r;}, {}) as MockedObject; +function makeTestScheduler(): MatrixScheduler { + return (["getQueueForEvent", "queueEvent", "removeEventFromQueue", "setProcessFunction"] as const).reduce( + (r, k) => { + r[k] = jest.fn(); + return r; + }, + {} as MatrixScheduler, + ); +} + +function makeTestClient(cryptoStore: CryptoStore) { + const scheduler = makeTestScheduler(); const store = new StubStore(); - return new MatrixClient({ + const client = new MatrixClient({ baseUrl: "https://my.home.server", idBaseUrl: "https://identity.server", accessToken: "my.access.token", @@ -139,80 +148,81 @@ function makeTestClient(cryptoStore) { cryptoStore: cryptoStore, cryptoCallbacks: { getCrossSigningKey, saveCrossSigningKeys }, }); + + // initialising the crypto library will trigger a key upload request, which we can stub out + client.uploadKeysRequest = jest.fn(); + return client; } -describe("MegolmBackup", function() { +describe("MegolmBackup", function () { if (!global.Olm) { - logger.warn('Not running megolm backup unit tests: libolm not present'); + logger.warn("Not running megolm backup unit tests: libolm not present"); return; } - beforeAll(function() { + beforeAll(function () { return Olm.init(); }); - let olmDevice; - let mockOlmLib; - let mockCrypto; - let cryptoStore; - let megolmDecryption; - beforeEach(async function() { - mockCrypto = testUtils.mock(Crypto, 'Crypto'); + let olmDevice: OlmDevice; + let mockOlmLib: typeof olmlib; + let mockCrypto: Crypto; + let cryptoStore: CryptoStore; + let megolmDecryption: MegolmDecryptionClass; + beforeEach(async function () { + mockCrypto = testUtils.mock(Crypto, "Crypto"); + // @ts-ignore making mock mockCrypto.backupManager = testUtils.mock(BackupManager, "BackupManager"); - mockCrypto.backupKey = new Olm.PkEncryption(); - mockCrypto.backupKey.set_recipient_key( - "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", - ); - mockCrypto.backupInfo = CURVE25519_BACKUP_INFO; + mockCrypto.backupManager.backupInfo = CURVE25519_BACKUP_INFO; cryptoStore = new MemoryCryptoStore(); olmDevice = new OlmDevice(cryptoStore); // we stub out the olm encryption bits - mockOlmLib = {}; + mockOlmLib = {} as unknown as typeof olmlib; mockOlmLib.ensureOlmSessionsForDevices = jest.fn(); - mockOlmLib.encryptMessageForDevice = - jest.fn().mockResolvedValue(undefined); + mockOlmLib.encryptMessageForDevice = jest.fn().mockResolvedValue(undefined); }); - describe("backup", function() { - let mockBaseApis; + describe("backup", function () { + let mockBaseApis: MatrixClient; - beforeEach(function() { - mockBaseApis = {}; + beforeEach(function () { + mockBaseApis = {} as unknown as MatrixClient; megolmDecryption = new MegolmDecryption({ - userId: '@user:id', + userId: "@user:id", crypto: mockCrypto, olmDevice: olmDevice, baseApis: mockBaseApis, roomId: ROOM_ID, - }); + }) as MegolmDecryptionClass; + // @ts-ignore private field access megolmDecryption.olmlib = mockOlmLib; // clobber the setTimeout function to run 100x faster. // ideally we would use lolex, but we have no oportunity // to tick the clock between the first try and the retry. const realSetTimeout = global.setTimeout; - jest.spyOn(global, 'setTimeout').mockImplementation(function(f, n) { - return realSetTimeout(f!, n!/100); + jest.spyOn(global, "setTimeout").mockImplementation(function (f, n) { + return realSetTimeout(f!, n! / 100); }); }); - afterEach(function() { - jest.spyOn(global, 'setTimeout').mockRestore(); + afterEach(function () { + jest.spyOn(global, "setTimeout").mockRestore(); }); - it('automatically calls the key back up', function() { + it("automatically calls the key back up", function () { const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); // construct a fake decrypted key event via the use of a mocked // 'crypto' implementation. const event = new MatrixEvent({ - type: 'm.room.encrypted', + type: "m.room.encrypted", }); event.getWireType = () => "m.room.encrypted"; event.getWireContent = () => { @@ -222,9 +232,9 @@ describe("MegolmBackup", function() { }; const decryptedData = { clearEvent: { - type: 'm.room_key', + type: "m.room_key", content: { - algorithm: 'm.megolm.v1.aes-sha2', + algorithm: "m.megolm.v1.aes-sha2", room_id: ROOM_ID, session_id: groupSession.session_id(), session_key: groupSession.session_key(), @@ -234,23 +244,27 @@ describe("MegolmBackup", function() { claimedEd25519Key: "SENDER_ED25519", }; - mockCrypto.decryptEvent = function() { + mockCrypto.decryptEvent = function () { return Promise.resolve(decryptedData); }; - mockCrypto.cancelRoomKeyRequest = function() {}; + mockCrypto.cancelRoomKeyRequest = function () {}; + // @ts-ignore readonly field write mockCrypto.backupManager = { backupGroupSession: jest.fn(), }; - return event.attemptDecryption(mockCrypto).then(() => { - return megolmDecryption.onRoomKeyEvent(event); - }).then(() => { - expect(mockCrypto.backupManager.backupGroupSession).toHaveBeenCalled(); - }); + return event + .attemptDecryption(mockCrypto) + .then(() => { + return megolmDecryption.onRoomKeyEvent(event); + }) + .then(() => { + expect(mockCrypto.backupManager.backupGroupSession).toHaveBeenCalled(); + }); }); - it('sends backups to the server (Curve25519 version)', function() { + it("sends backups to the server (Curve25519 version)", function () { const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); const ibGroupSession = new Olm.InboundGroupSession(); @@ -259,64 +273,62 @@ describe("MegolmBackup", function() { const client = makeTestClient(cryptoStore); megolmDecryption = new MegolmDecryption({ - userId: '@user:id', + userId: "@user:id", crypto: mockCrypto, olmDevice: olmDevice, baseApis: client, roomId: ROOM_ID, - }); + }) as MegolmDecryptionClass; + // @ts-ignore private field access megolmDecryption.olmlib = mockOlmLib; - return client.initCrypto() + return client + .initCrypto() .then(() => { - return cryptoStore.doTxn( - "readwrite", - [cryptoStore.STORE_SESSION], - (txn) => { - cryptoStore.addEndToEndInboundGroupSession( - "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", - groupSession.session_id(), - { - forwardingCurve25519KeyChain: undefined, - keysClaimed: { - ed25519: "SENDER_ED25519", - }, - room_id: ROOM_ID, - session: ibGroupSession.pickle(olmDevice.pickleKey), + return cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { + cryptoStore.addEndToEndInboundGroupSession( + "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", + groupSession.session_id(), + { + forwardingCurve25519KeyChain: undefined!, + keysClaimed: { + ed25519: "SENDER_ED25519", }, - txn); - }); + room_id: ROOM_ID, + session: ibGroupSession.pickle(olmDevice.pickleKey), + }, + txn, + ); + }); }) .then(async () => { await client.enableKeyBackup({ algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, - version: '1', + version: "1", auth_data: { public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", }, }); let numCalls = 0; return new Promise((resolve, reject) => { - client.http.authedRequest = function( - method, path, queryParams, data, opts, - ): Promise { + client.http.authedRequest = function (method, path, queryParams, data, opts): any { ++numCalls; expect(numCalls).toBeLessThanOrEqual(1); if (numCalls >= 2) { // exit out of retry loop if there's something wrong reject(new Error("authedRequest called too many timmes")); - return Promise.resolve({} as T); + return Promise.resolve({}); } expect(method).toBe("PUT"); expect(path).toBe("/room_keys/keys"); - expect(queryParams.version).toBe('1'); + expect(queryParams?.version).toBe("1"); expect((data as Record).rooms[ROOM_ID].sessions).toBeDefined(); expect((data as Record).rooms[ROOM_ID].sessions).toHaveProperty( groupSession.session_id(), ); resolve(); - return Promise.resolve({} as T); + return Promise.resolve({}); }; client.crypto!.backupManager.backupGroupSession( "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", @@ -329,7 +341,7 @@ describe("MegolmBackup", function() { }); }); - it('sends backups to the server (AES-256 version)', function() { + it("sends backups to the server (AES-256 version)", function () { const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); const ibGroupSession = new Olm.InboundGroupSession(); @@ -338,42 +350,42 @@ describe("MegolmBackup", function() { const client = makeTestClient(cryptoStore); megolmDecryption = new MegolmDecryption({ - userId: '@user:id', + userId: "@user:id", crypto: mockCrypto, olmDevice: olmDevice, baseApis: client, roomId: ROOM_ID, - }); + }) as MegolmDecryptionClass; + // @ts-ignore private field access megolmDecryption.olmlib = mockOlmLib; - return client.initCrypto() + return client + .initCrypto() .then(() => { return client.crypto!.storeSessionBackupPrivateKey(new Uint8Array(32)); }) .then(() => { - return cryptoStore.doTxn( - "readwrite", - [cryptoStore.STORE_SESSION], - (txn) => { - cryptoStore.addEndToEndInboundGroupSession( - "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", - groupSession.session_id(), - { - forwardingCurve25519KeyChain: undefined, - keysClaimed: { - ed25519: "SENDER_ED25519", - }, - room_id: ROOM_ID, - session: ibGroupSession.pickle(olmDevice.pickleKey), + return cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { + cryptoStore.addEndToEndInboundGroupSession( + "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", + groupSession.session_id(), + { + forwardingCurve25519KeyChain: undefined!, + keysClaimed: { + ed25519: "SENDER_ED25519", }, - txn); - }); + room_id: ROOM_ID, + session: ibGroupSession.pickle(olmDevice.pickleKey), + }, + txn, + ); + }); }) .then(async () => { await client.enableKeyBackup({ algorithm: "org.matrix.msc3270.v1.aes-hmac-sha2", - version: '1', + version: "1", auth_data: { iv: "PsCAtR7gMc4xBd9YS3A9Ow", mac: "ZSDsTFEZK7QzlauCLMleUcX96GQZZM7UNtk4sripSqQ", @@ -381,25 +393,23 @@ describe("MegolmBackup", function() { }); let numCalls = 0; return new Promise((resolve, reject) => { - client.http.authedRequest = function( - method, path, queryParams, data, opts, - ): Promise { + client.http.authedRequest = function (method, path, queryParams, data, opts): any { ++numCalls; expect(numCalls).toBeLessThanOrEqual(1); if (numCalls >= 2) { // exit out of retry loop if there's something wrong reject(new Error("authedRequest called too many timmes")); - return Promise.resolve({} as T); + return Promise.resolve({}); } expect(method).toBe("PUT"); expect(path).toBe("/room_keys/keys"); - expect(queryParams.version).toBe('1'); + expect(queryParams?.version).toBe("1"); expect((data as Record).rooms[ROOM_ID].sessions).toBeDefined(); expect((data as Record).rooms[ROOM_ID].sessions).toHaveProperty( groupSession.session_id(), ); resolve(); - return Promise.resolve({} as T); + return Promise.resolve({}); }; client.crypto!.backupManager.backupGroupSession( "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", @@ -412,7 +422,7 @@ describe("MegolmBackup", function() { }); }); - it('signs backups with the cross-signing master key', async function() { + it("signs backups with the cross-signing master key", async function () { const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); const ibGroupSession = new Olm.InboundGroupSession(); @@ -421,26 +431,29 @@ describe("MegolmBackup", function() { const client = makeTestClient(cryptoStore); megolmDecryption = new MegolmDecryption({ - userId: '@user:id', + userId: "@user:id", crypto: mockCrypto, olmDevice: olmDevice, baseApis: client, roomId: ROOM_ID, - }); + }) as MegolmDecryptionClass; + // @ts-ignore private field access megolmDecryption.olmlib = mockOlmLib; await client.initCrypto(); - client.uploadDeviceSigningKeys = async function(e) {return {};}; - client.uploadKeySignatures = async function(e) {return { failures: {} };}; + client.uploadDeviceSigningKeys = async function (e) { + return {}; + }; + client.uploadKeySignatures = async function (e) { + return { failures: {} }; + }; await resetCrossSigningKeys(client); let numCalls = 0; await Promise.all([ new Promise((resolve, reject) => { - let backupInfo; - client.http.authedRequest = function( - method, path, queryParams, data, opts, - ) { + let backupInfo: Record | BodyInit | undefined; + client.http.authedRequest = function (method, path, queryParams, data, opts): any { ++numCalls; expect(numCalls).toBeLessThanOrEqual(2); if (numCalls === 1) { @@ -449,7 +462,9 @@ describe("MegolmBackup", function() { try { // make sure auth_data is signed by the master key olmlib.pkVerify( - (data as Record).auth_data, client.getCrossSigningId()!, "@alice:bar", + (data as Record).auth_data, + client.getCrossSigningId()!, + "@alice:bar", ); } catch (e) { reject(e); @@ -480,16 +495,13 @@ describe("MegolmBackup", function() { client.stopClient(); }); - it('retries when a backup fails', async function() { + it("retries when a backup fails", async function () { const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); const ibGroupSession = new Olm.InboundGroupSession(); ibGroupSession.create(groupSession.session_key()); - const scheduler = [ - "getQueueForEvent", "queueEvent", "removeEventFromQueue", - "setProcessFunction", - ].reduce((r, k) => {r[k] = jest.fn(); return r;}, {}) as MockedObject; + const scheduler = makeTestScheduler(); const store = new StubStore(); const client = new MatrixClient({ baseUrl: "https://my.home.server", @@ -502,39 +514,40 @@ describe("MegolmBackup", function() { deviceId: "device", cryptoStore: cryptoStore, }); + // initialising the crypto library will trigger a key upload request, which we can stub out + client.uploadKeysRequest = jest.fn(); megolmDecryption = new MegolmDecryption({ - userId: '@user:id', + userId: "@user:id", crypto: mockCrypto, olmDevice: olmDevice, baseApis: client, roomId: ROOM_ID, - }); + }) as MegolmDecryptionClass; + // @ts-ignore private field access megolmDecryption.olmlib = mockOlmLib; await client.initCrypto(); - await cryptoStore.doTxn( - "readwrite", - [cryptoStore.STORE_SESSION], - (txn) => { - cryptoStore.addEndToEndInboundGroupSession( - "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", - groupSession.session_id(), - { - forwardingCurve25519KeyChain: undefined, - keysClaimed: { - ed25519: "SENDER_ED25519", - }, - room_id: ROOM_ID, - session: ibGroupSession.pickle(olmDevice.pickleKey), + await cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { + cryptoStore.addEndToEndInboundGroupSession( + "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", + groupSession.session_id(), + { + forwardingCurve25519KeyChain: undefined!, + keysClaimed: { + ed25519: "SENDER_ED25519", }, - txn); - }); + room_id: ROOM_ID, + session: ibGroupSession.pickle(olmDevice.pickleKey), + }, + txn, + ); + }); await client.enableKeyBackup({ algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, - version: '1', + version: "1", auth_data: { public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", }, @@ -542,30 +555,26 @@ describe("MegolmBackup", function() { let numCalls = 0; await new Promise((resolve, reject) => { - client.http.authedRequest = function( - method, path, queryParams, data, opts, - ): Promise { + client.http.authedRequest = function (method, path, queryParams, data, opts): any { ++numCalls; expect(numCalls).toBeLessThanOrEqual(2); if (numCalls >= 3) { // exit out of retry loop if there's something wrong reject(new Error("authedRequest called too many timmes")); - return Promise.resolve({} as T); + return Promise.resolve({}); } expect(method).toBe("PUT"); expect(path).toBe("/room_keys/keys"); - expect(queryParams.version).toBe('1'); + expect(queryParams?.version).toBe("1"); expect((data as Record).rooms[ROOM_ID].sessions).toBeDefined(); expect((data as Record).rooms[ROOM_ID].sessions).toHaveProperty( groupSession.session_id(), ); if (numCalls > 1) { resolve(); - return Promise.resolve({} as T); + return Promise.resolve({}); } else { - return Promise.reject( - new Error("this is an expected failure"), - ); + return Promise.reject(new Error("this is an expected failure")); } }; return client.crypto!.backupManager.backupGroupSession( @@ -578,66 +587,73 @@ describe("MegolmBackup", function() { }); }); - describe("restore", function() { - let client; + describe("restore", function () { + let client: MatrixClient; - beforeEach(function() { + beforeEach(function () { client = makeTestClient(cryptoStore); megolmDecryption = new MegolmDecryption({ - userId: '@user:id', + userId: "@user:id", crypto: mockCrypto, olmDevice: olmDevice, baseApis: client, roomId: ROOM_ID, - }); + }) as MegolmDecryptionClass; + // @ts-ignore private field access megolmDecryption.olmlib = mockOlmLib; return client.initCrypto(); }); - afterEach(function() { + afterEach(function () { client.stopClient(); }); - it('can restore from backup (Curve25519 version)', function() { - client.http.authedRequest = function() { - return Promise.resolve(CURVE25519_KEY_BACKUP_DATA); + it("can restore from backup (Curve25519 version)", function () { + client.http.authedRequest = function () { + return Promise.resolve(CURVE25519_KEY_BACKUP_DATA); }; - return client.restoreKeyBackupWithRecoveryKey( - "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", - ROOM_ID, - SESSION_ID, - CURVE25519_BACKUP_INFO, - ).then(() => { - return megolmDecryption.decryptEvent(ENCRYPTED_EVENT); - }).then((res) => { - expect(res.clearEvent.content).toEqual('testytest'); - expect(res.untrusted).toBeTruthy(); // keys from Curve25519 backup are untrusted - }); + return client + .restoreKeyBackupWithRecoveryKey( + "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", + ROOM_ID, + SESSION_ID, + CURVE25519_BACKUP_INFO, + ) + .then(() => { + return megolmDecryption.decryptEvent(ENCRYPTED_EVENT); + }) + .then((res) => { + expect(res.clearEvent.content).toEqual("testytest"); + expect(res.untrusted).toBeTruthy(); // keys from Curve25519 backup are untrusted + }); }); - it('can restore from backup (AES-256 version)', function() { - client.http.authedRequest = function() { - return Promise.resolve(AES256_KEY_BACKUP_DATA); + it("can restore from backup (AES-256 version)", function () { + client.http.authedRequest = function () { + return Promise.resolve(AES256_KEY_BACKUP_DATA); }; - return client.restoreKeyBackupWithRecoveryKey( - "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", - ROOM_ID, - SESSION_ID, - AES256_BACKUP_INFO, - ).then(() => { - return megolmDecryption.decryptEvent(ENCRYPTED_EVENT); - }).then((res) => { - expect(res.clearEvent.content).toEqual('testytest'); - expect(res.untrusted).toBeFalsy(); // keys from AES backup are trusted - }); + return client + .restoreKeyBackupWithRecoveryKey( + "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", + ROOM_ID, + SESSION_ID, + AES256_BACKUP_INFO, + ) + .then(() => { + return megolmDecryption.decryptEvent(ENCRYPTED_EVENT); + }) + .then((res) => { + expect(res.clearEvent.content).toEqual("testytest"); + expect(res.untrusted).toBeFalsy(); // keys from AES backup are trusted + }); }); - it('can restore backup by room (Curve25519 version)', function() { - client.http.authedRequest = function() { - return Promise.resolve({ + it("can restore backup by room (Curve25519 version)", function () { + client.http.authedRequest = function () { + return Promise.resolve({ rooms: { [ROOM_ID]: { sessions: { @@ -647,30 +663,35 @@ describe("MegolmBackup", function() { }, }); }; - return client.restoreKeyBackupWithRecoveryKey( - "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", - null, null, CURVE25519_BACKUP_INFO, - ).then(() => { - return megolmDecryption.decryptEvent(ENCRYPTED_EVENT); - }).then((res) => { - expect(res.clearEvent.content).toEqual('testytest'); - }); + return client + .restoreKeyBackupWithRecoveryKey( + "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", + null!, + null!, + CURVE25519_BACKUP_INFO, + ) + .then(() => { + return megolmDecryption.decryptEvent(ENCRYPTED_EVENT); + }) + .then((res) => { + expect(res.clearEvent.content).toEqual("testytest"); + }); }); - it('has working cache functions', async function() { + it("has working cache functions", async function () { const key = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]); - await client.crypto.storeSessionBackupPrivateKey(key); - const result = await client.crypto.getSessionBackupPrivateKey(); - expect(new Uint8Array(result)).toEqual(key); + await client.crypto!.storeSessionBackupPrivateKey(key); + const result = await client.crypto!.getSessionBackupPrivateKey(); + expect(new Uint8Array(result!)).toEqual(key); }); - it('caches session backup keys as it encounters them', async function() { - const cachedNull = await client.crypto.getSessionBackupPrivateKey(); + it("caches session backup keys as it encounters them", async function () { + const cachedNull = await client.crypto!.getSessionBackupPrivateKey(); expect(cachedNull).toBeNull(); - client.http.authedRequest = function() { - return Promise.resolve(CURVE25519_KEY_BACKUP_DATA); + client.http.authedRequest = function () { + return Promise.resolve(CURVE25519_KEY_BACKUP_DATA); }; - await new Promise((resolve) => { + await new Promise((resolve) => { client.restoreKeyBackupWithRecoveryKey( "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", ROOM_ID, @@ -679,33 +700,32 @@ describe("MegolmBackup", function() { { cacheCompleteCallback: resolve }, ); }); - const cachedKey = await client.crypto.getSessionBackupPrivateKey(); + const cachedKey = await client.crypto!.getSessionBackupPrivateKey(); expect(cachedKey).not.toBeNull(); }); - it("fails if an known algorithm is used", async function() { + it("fails if an known algorithm is used", async function () { const BAD_BACKUP_INFO = Object.assign({}, CURVE25519_BACKUP_INFO, { algorithm: "this.algorithm.does.not.exist", }); - client.http.authedRequest = function() { - return Promise.resolve(CURVE25519_KEY_BACKUP_DATA); + client.http.authedRequest = function () { + return Promise.resolve(CURVE25519_KEY_BACKUP_DATA); }; - await expect(client.restoreKeyBackupWithRecoveryKey( - "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", - ROOM_ID, - SESSION_ID, - BAD_BACKUP_INFO, - )).rejects.toThrow(); + await expect( + client.restoreKeyBackupWithRecoveryKey( + "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", + ROOM_ID, + SESSION_ID, + BAD_BACKUP_INFO, + ), + ).rejects.toThrow(); }); }); describe("flagAllGroupSessionsForBackup", () => { it("should return number of sesions needing backup", async () => { - const scheduler = [ - "getQueueForEvent", "queueEvent", "removeEventFromQueue", - "setProcessFunction", - ].reduce((r, k) => {r[k] = jest.fn(); return r;}, {}) as MockedObject; + const scheduler = makeTestScheduler(); const store = new StubStore(); const client = new MatrixClient({ baseUrl: "https://my.home.server", @@ -718,6 +738,9 @@ describe("MegolmBackup", function() { deviceId: "device", cryptoStore, }); + // initialising the crypto library will trigger a key upload request, which we can stub out + client.uploadKeysRequest = jest.fn(); + await client.initCrypto(); cryptoStore.countSessionsNeedingBackup = jest.fn().mockReturnValue(6); diff --git a/spec/unit/crypto/cross-signing.spec.ts b/spec/unit/crypto/cross-signing.spec.ts index e9c112c5005..eddaf43ba4a 100644 --- a/spec/unit/crypto/cross-signing.spec.ts +++ b/spec/unit/crypto/cross-signing.spec.ts @@ -15,26 +15,27 @@ See the License for the specific language governing permissions and limitations under the License. */ -import '../../olm-loader'; -import anotherjson from 'another-json'; -import { PkSigning } from '@matrix-org/olm'; +import "../../olm-loader"; +import anotherjson from "another-json"; +import { PkSigning } from "@matrix-org/olm"; +import HttpBackend from "matrix-mock-request"; import * as olmlib from "../../../src/crypto/olmlib"; -import { MatrixError } from '../../../src/http-api'; -import { logger } from '../../../src/logger'; -import { ICrossSigningKey, ICreateClientOpts, ISignedKey } from '../../../src/client'; -import { CryptoEvent } from '../../../src/crypto'; -import { IDevice } from '../../../src/crypto/deviceinfo'; -import { TestClient } from '../../TestClient'; +import { MatrixError } from "../../../src/http-api"; +import { logger } from "../../../src/logger"; +import { ICrossSigningKey, ICreateClientOpts, ISignedKey, MatrixClient } from "../../../src/client"; +import { CryptoEvent, IBootstrapCrossSigningOpts } from "../../../src/crypto"; +import { IDevice } from "../../../src/crypto/deviceinfo"; +import { TestClient } from "../../TestClient"; import { resetCrossSigningKeys } from "./crypto-utils"; -const PUSH_RULES_RESPONSE = { +const PUSH_RULES_RESPONSE: Response = { method: "GET", path: "/pushrules/", data: {}, }; -const filterResponse = function(userId) { +const filterResponse = function (userId: string): Response { const filterPath = "/user/" + encodeURIComponent(userId) + "/filter"; return { method: "POST", @@ -43,33 +44,37 @@ const filterResponse = function(userId) { }; }; -function setHttpResponses(httpBackend, responses) { - responses.forEach(response => { - httpBackend - .when(response.method, response.path) - .respond(200, response.data); +interface Response { + method: "GET" | "PUT" | "POST" | "DELETE"; + path: string; + data: object; +} + +function setHttpResponses(httpBackend: HttpBackend, responses: Response[]) { + responses.forEach((response) => { + httpBackend.when(response.method, response.path).respond(200, response.data); }); } async function makeTestClient( - userInfo: { userId: string, deviceId: string}, + userInfo: { userId: string; deviceId: string }, options: Partial = {}, - keys = {}, + keys: Record = {}, ) { - function getCrossSigningKey(type) { - return keys[type]; + function getCrossSigningKey(type: string) { + return keys[type] ?? null; } - function saveCrossSigningKeys(k) { + function saveCrossSigningKeys(k: Record) { Object.assign(keys, k); } options.cryptoCallbacks = Object.assign( - {}, { getCrossSigningKey, saveCrossSigningKeys }, options.cryptoCallbacks || {}, - ); - const testClient = new TestClient( - userInfo.userId, userInfo.deviceId, undefined, undefined, options, + {}, + { getCrossSigningKey, saveCrossSigningKeys }, + options.cryptoCallbacks || {}, ); + const testClient = new TestClient(userInfo.userId, userInfo.deviceId, undefined, undefined, options); const client = testClient.client; await client.initCrypto(); @@ -77,24 +82,25 @@ async function makeTestClient( return { client, httpBackend: testClient.httpBackend }; } -describe("Cross Signing", function() { +describe("Cross Signing", function () { if (!global.Olm) { - logger.warn('Not running megolm backup unit tests: libolm not present'); + logger.warn("Not running megolm backup unit tests: libolm not present"); return; } - beforeAll(function() { + beforeAll(function () { return global.Olm.init(); }); - it("should sign the master key with the device key", async function() { - const { client: alice } = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - ); + it("should sign the master key with the device key", async function () { + const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); alice.uploadDeviceSigningKeys = jest.fn().mockImplementation(async (auth, keys) => { await olmlib.verifySignature( - alice.crypto!.olmDevice, keys.master_key, "@alice:example.com", - "Osborne2", alice.crypto!.olmDevice.deviceEd25519Key!, + alice.crypto!.olmDevice, + keys.master_key, + "@alice:example.com", + "Osborne2", + alice.crypto!.olmDevice.deviceEd25519Key!, ); }); alice.uploadKeySignatures = async () => ({ failures: {} }); @@ -102,24 +108,22 @@ describe("Cross Signing", function() { alice.getAccountDataFromServer = async () => ({} as T); // set Alice's cross-signing key await alice.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async func => { await func({}); }, + authUploadDeviceSigningKeys: async (func) => { + await func({}); + }, }); expect(alice.uploadDeviceSigningKeys).toHaveBeenCalled(); alice.stopClient(); }); - it("should abort bootstrap if device signing auth fails", async function() { - const { client: alice } = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - ); + it("should abort bootstrap if device signing auth fails", async function () { + const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); alice.uploadDeviceSigningKeys = async (auth, keys) => { const errorResponse = { session: "sessionId", flows: [ { - stages: [ - "m.login.password", - ], + stages: ["m.login.password"], }, ], params: {}, @@ -141,8 +145,10 @@ describe("Cross Signing", function() { }; alice.uploadKeySignatures = async () => ({ failures: {} }); alice.setAccountData = async () => ({}); - alice.getAccountDataFromServer = async (): Promise => ({} as T); - const authUploadDeviceSigningKeys = async func => await func({}); + alice.getAccountDataFromServer = async (): Promise => ({} as T); + const authUploadDeviceSigningKeys: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => { + await func({}); + }; // Try bootstrap, expecting `authUploadDeviceSigningKeys` to pass // through failure, stopping before actually applying changes. @@ -160,10 +166,8 @@ describe("Cross Signing", function() { alice.stopClient(); }); - it("should upload a signature when a user is verified", async function() { - const { client: alice } = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - ); + it("should upload a signature when a user is verified", async function () { + const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); alice.uploadDeviceSigningKeys = async () => ({}); alice.uploadKeySignatures = async () => ({ failures: {} }); // set Alice's cross-signing key @@ -195,18 +199,14 @@ describe("Cross Signing", function() { alice.stopClient(); }); - it.skip("should get cross-signing keys from sync", async function() { + it.skip("should get cross-signing keys from sync", async function () { const masterKey = new Uint8Array([ - 0xda, 0x5a, 0x27, 0x60, 0xe3, 0x3a, 0xc5, 0x82, - 0x9d, 0x12, 0xc3, 0xbe, 0xe8, 0xaa, 0xc2, 0xef, - 0xae, 0xb1, 0x05, 0xc1, 0xe7, 0x62, 0x78, 0xa6, - 0xd7, 0x1f, 0xf8, 0x2c, 0x51, 0x85, 0xf0, 0x1d, + 0xda, 0x5a, 0x27, 0x60, 0xe3, 0x3a, 0xc5, 0x82, 0x9d, 0x12, 0xc3, 0xbe, 0xe8, 0xaa, 0xc2, 0xef, 0xae, 0xb1, + 0x05, 0xc1, 0xe7, 0x62, 0x78, 0xa6, 0xd7, 0x1f, 0xf8, 0x2c, 0x51, 0x85, 0xf0, 0x1d, ]); const selfSigningKey = new Uint8Array([ - 0x1e, 0xf4, 0x01, 0x6d, 0x4f, 0xa1, 0x73, 0x66, - 0x6b, 0xf8, 0x93, 0xf5, 0xb0, 0x4d, 0x17, 0xc0, - 0x17, 0xb5, 0xa5, 0xf6, 0x59, 0x11, 0x8b, 0x49, - 0x34, 0xf2, 0x4b, 0x64, 0x9b, 0x52, 0xf8, 0x5f, + 0x1e, 0xf4, 0x01, 0x6d, 0x4f, 0xa1, 0x73, 0x66, 0x6b, 0xf8, 0x93, 0xf5, 0xb0, 0x4d, 0x17, 0xc0, 0x17, 0xb5, + 0xa5, 0xf6, 0x59, 0x11, 0x8b, 0x49, 0x34, 0xf2, 0x4b, 0x64, 0x9b, 0x52, 0xf8, 0x5f, ]); const { client: alice, httpBackend } = await makeTestClient( @@ -214,8 +214,8 @@ describe("Cross Signing", function() { { cryptoCallbacks: { // will be called to sign our own device - getCrossSigningKey: async type => { - if (type === 'master') { + getCrossSigningKey: async (type) => { + if (type === "master") { return masterKey; } else { return selfSigningKey; @@ -239,11 +239,10 @@ describe("Cross Signing", function() { try { await olmlib.verifySignature( alice.crypto!.olmDevice, - content["@alice:example.com"][ - "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk" - ], + content["@alice:example.com"]["nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"], "@alice:example.com", - "Osborne2", alice.crypto!.olmDevice.deviceEd25519Key!, + "Osborne2", + alice.crypto!.olmDevice.deviceEd25519Key!, ); olmlib.pkVerify( content["@alice:example.com"]["Osborne2"], @@ -258,8 +257,7 @@ describe("Cross Signing", function() { }); // @ts-ignore private property - const deviceInfo = alice.crypto!.deviceList.devices["@alice:example.com"] - .Osborne2; + const deviceInfo = alice.crypto!.deviceList.devices["@alice:example.com"].Osborne2; const aliceDevice = { user_id: "@alice:example.com", device_id: "Osborne2", @@ -267,15 +265,10 @@ describe("Cross Signing", function() { algorithms: deviceInfo.algorithms, }; await alice.crypto!.signObject(aliceDevice); - olmlib.pkSign( - aliceDevice as ISignedKey, - selfSigningKey as unknown as PkSigning, - "@alice:example.com", - '', - ); + olmlib.pkSign(aliceDevice as ISignedKey, selfSigningKey as unknown as PkSigning, "@alice:example.com", ""); // feed sync result that includes master key, ssk, device key - const responses = [ + const responses: Response[] = [ PUSH_RULES_RESPONSE, { method: "POST", @@ -294,10 +287,7 @@ describe("Cross Signing", function() { data: { next_batch: "abcdefg", device_lists: { - changed: [ - "@alice:example.com", - "@bob:example.com", - ], + changed: ["@alice:example.com", "@bob:example.com"], }, }, }, @@ -305,35 +295,35 @@ describe("Cross Signing", function() { method: "POST", path: "/keys/query", data: { - "failures": {}, - "device_keys": { + failures: {}, + device_keys: { "@alice:example.com": { - "Osborne2": aliceDevice, + Osborne2: aliceDevice, }, }, - "master_keys": { + master_keys: { "@alice:example.com": { user_id: "@alice:example.com", usage: ["master"], keys: { "ed25519:nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk": - "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk", + "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk", }, }, }, - "self_signing_keys": { + self_signing_keys: { "@alice:example.com": { user_id: "@alice:example.com", usage: ["self-signing"], keys: { "ed25519:EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ": - "EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ", + "EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ", }, signatures: { "@alice:example.com": { "ed25519:nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk": - "Wqx/HXR851KIi8/u/UX+fbAMtq9Uj8sr8FsOcqrLfVYa6lAmbXs" - + "Vhfy4AlZ3dnEtjgZx0U0QDrghEn2eYBeOCA", + "Wqx/HXR851KIi8/u/UX+fbAMtq9Uj8sr8FsOcqrLfVYa6lAmbXs" + + "Vhfy4AlZ3dnEtjgZx0U0QDrghEn2eYBeOCA", }, }, }, @@ -373,10 +363,8 @@ describe("Cross Signing", function() { alice.stopClient(); }); - it("should use trust chain to determine device verification", async function() { - const { client: alice } = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - ); + it("should use trust chain to determine device verification", async function () { + const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); alice.uploadDeviceSigningKeys = async () => ({}); alice.uploadKeySignatures = async () => ({ failures: {} }); // set Alice's cross-signing key @@ -463,8 +451,8 @@ describe("Cross Signing", function() { alice.stopClient(); }); - it.skip("should trust signatures received from other devices", async function() { - const aliceKeys: Record = {}; + it.skip("should trust signatures received from other devices", async function () { + const aliceKeys: Record = {}; const { client: alice, httpBackend } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, undefined, @@ -479,10 +467,8 @@ describe("Cross Signing", function() { await resetCrossSigningKeys(alice); const selfSigningKey = new Uint8Array([ - 0x1e, 0xf4, 0x01, 0x6d, 0x4f, 0xa1, 0x73, 0x66, - 0x6b, 0xf8, 0x93, 0xf5, 0xb0, 0x4d, 0x17, 0xc0, - 0x17, 0xb5, 0xa5, 0xf6, 0x59, 0x11, 0x8b, 0x49, - 0x34, 0xf2, 0x4b, 0x64, 0x9b, 0x52, 0xf8, 0x5f, + 0x1e, 0xf4, 0x01, 0x6d, 0x4f, 0xa1, 0x73, 0x66, 0x6b, 0xf8, 0x93, 0xf5, 0xb0, 0x4d, 0x17, 0xc0, 0x17, 0xb5, + 0xa5, 0xf6, 0x59, 0x11, 0x8b, 0x49, 0x34, 0xf2, 0x4b, 0x64, 0x9b, 0x52, 0xf8, 0x5f, ]); const keyChangePromise = new Promise((resolve, reject) => { @@ -494,8 +480,7 @@ describe("Cross Signing", function() { }); // @ts-ignore private property - const deviceInfo = alice.crypto!.deviceList.devices["@alice:example.com"] - .Osborne2; + const deviceInfo = alice.crypto!.deviceList.devices["@alice:example.com"].Osborne2; const aliceDevice = { user_id: "@alice:example.com", device_id: "Osborne2", @@ -527,29 +512,23 @@ describe("Cross Signing", function() { verified: 0, known: false, }; - olmlib.pkSign( - bobDevice, - selfSigningKey as unknown as PkSigning, - "@bob:example.com", - '', - ); + olmlib.pkSign(bobDevice, selfSigningKey as unknown as PkSigning, "@bob:example.com", ""); const bobMaster: ICrossSigningKey = { user_id: "@bob:example.com", usage: ["master"], keys: { - "ed25519:nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk": - "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk", + "ed25519:nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk": "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk", }, }; - olmlib.pkSign(bobMaster, aliceKeys.user_signing, "@alice:example.com", ''); + olmlib.pkSign(bobMaster, aliceKeys.user_signing, "@alice:example.com", ""); // Alice downloads Bob's keys // - device key // - ssk // - master key signed by her usk (pretend that it was signed by another // of Alice's devices) - const responses = [ + const responses: Response[] = [ PUSH_RULES_RESPONSE, { method: "POST", @@ -568,9 +547,7 @@ describe("Cross Signing", function() { data: { next_batch: "abcdefg", device_lists: { - changed: [ - "@bob:example.com", - ], + changed: ["@bob:example.com"], }, }, }, @@ -578,31 +555,31 @@ describe("Cross Signing", function() { method: "POST", path: "/keys/query", data: { - "failures": {}, - "device_keys": { + failures: {}, + device_keys: { "@alice:example.com": { - "Osborne2": aliceDevice, + Osborne2: aliceDevice, }, "@bob:example.com": { - "Dynabook": bobDevice, + Dynabook: bobDevice, }, }, - "master_keys": { + master_keys: { "@bob:example.com": bobMaster, }, - "self_signing_keys": { + self_signing_keys: { "@bob:example.com": { user_id: "@bob:example.com", usage: ["self-signing"], keys: { "ed25519:EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ": - "EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ", + "EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ", }, signatures: { "@bob:example.com": { "ed25519:nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk": - "2KLiufImvEbfJuAFvsaZD+PsL8ELWl7N1u9yr/9hZvwRghBfQMB" - + "LAI86b1kDV9+Cq1lt85ykReeCEzmTEPY2BQ", + "2KLiufImvEbfJuAFvsaZD+PsL8ELWl7N1u9yr/9hZvwRghBfQMB" + + "LAI86b1kDV9+Cq1lt85ykReeCEzmTEPY2BQ", }, }, }, @@ -638,10 +615,8 @@ describe("Cross Signing", function() { alice.stopClient(); }); - it("should dis-trust an unsigned device", async function() { - const { client: alice } = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - ); + it("should dis-trust an unsigned device", async function () { + const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); alice.uploadDeviceSigningKeys = async () => ({}); alice.uploadKeySignatures = async () => ({ failures: {} }); // set Alice's cross-signing key @@ -708,10 +683,8 @@ describe("Cross Signing", function() { alice.stopClient(); }); - it("should dis-trust a user when their ssk changes", async function() { - const { client: alice } = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - ); + it("should dis-trust a user when their ssk changes", async function () { + const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); alice.uploadDeviceSigningKeys = async () => ({}); alice.uploadKeySignatures = async () => ({ failures: {} }); await resetCrossSigningKeys(alice); @@ -852,8 +825,8 @@ describe("Cross Signing", function() { alice.stopClient(); }); - it("should offer to upgrade device verifications to cross-signing", async function() { - let upgradeResolveFunc; + it("should offer to upgrade device verifications to cross-signing", async function () { + let upgradeResolveFunc: Function; const { client: alice } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, @@ -866,11 +839,8 @@ describe("Cross Signing", function() { }, }, }, - - ); - const { client: bob } = await makeTestClient( - { userId: "@bob:example.com", deviceId: "Dynabook" }, ); + const { client: bob } = await makeTestClient({ userId: "@bob:example.com", deviceId: "Dynabook" }); bob.uploadDeviceSigningKeys = async () => ({}); bob.uploadKeySignatures = async () => ({ failures: {} }); @@ -887,10 +857,7 @@ describe("Cross Signing", function() { known: true, }, }); - alice.crypto!.deviceList.storeCrossSigningForUser( - "@bob:example.com", - bob.crypto!.crossSigningInfo.toStorage(), - ); + alice.crypto!.deviceList.storeCrossSigningForUser("@bob:example.com", bob.crypto!.crossSigningInfo.toStorage()); alice.uploadDeviceSigningKeys = async () => ({}); alice.uploadKeySignatures = async () => ({ failures: {} }); @@ -909,8 +876,9 @@ describe("Cross Signing", function() { expect(bobTrust.isTofu()).toBeTruthy(); // "forget" that Bob is trusted - delete alice.crypto!.deviceList.crossSigningInfo["@bob:example.com"] - .keys.master.signatures!["@alice:example.com"]; + delete alice.crypto!.deviceList.crossSigningInfo["@bob:example.com"].keys.master.signatures![ + "@alice:example.com" + ]; const bobTrust2 = alice.checkUserTrust("@bob:example.com"); expect(bobTrust2.isCrossSigningVerified()).toBeFalsy(); @@ -932,91 +900,83 @@ describe("Cross Signing", function() { bob.stopClient(); }); - it( - "should observe that our own device is cross-signed, even if this device doesn't trust the key", - async function() { - const { client: alice } = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - ); - alice.uploadDeviceSigningKeys = async () => ({}); - alice.uploadKeySignatures = async () => ({ failures: {} }); - - // Generate Alice's SSK etc - const aliceMasterSigning = new global.Olm.PkSigning(); - const aliceMasterPrivkey = aliceMasterSigning.generate_seed(); - const aliceMasterPubkey = aliceMasterSigning.init_with_seed(aliceMasterPrivkey); - const aliceSigning = new global.Olm.PkSigning(); - const alicePrivkey = aliceSigning.generate_seed(); - const alicePubkey = aliceSigning.init_with_seed(alicePrivkey); - const aliceSSK: ICrossSigningKey = { - user_id: "@alice:example.com", - usage: ["self_signing"], - keys: { - ["ed25519:" + alicePubkey]: alicePubkey, - }, - }; - const sskSig = aliceMasterSigning.sign(anotherjson.stringify(aliceSSK)); - aliceSSK.signatures = { - "@alice:example.com": { - ["ed25519:" + aliceMasterPubkey]: sskSig, - }, - }; + it("should observe that our own device is cross-signed, even if this device doesn't trust the key", async function () { + const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); - // Alice's device downloads the keys, but doesn't trust them yet - alice.crypto!.deviceList.storeCrossSigningForUser("@alice:example.com", { - keys: { - master: { - user_id: "@alice:example.com", - usage: ["master"], - keys: { - ["ed25519:" + aliceMasterPubkey]: aliceMasterPubkey, - }, + // Generate Alice's SSK etc + const aliceMasterSigning = new global.Olm.PkSigning(); + const aliceMasterPrivkey = aliceMasterSigning.generate_seed(); + const aliceMasterPubkey = aliceMasterSigning.init_with_seed(aliceMasterPrivkey); + const aliceSigning = new global.Olm.PkSigning(); + const alicePrivkey = aliceSigning.generate_seed(); + const alicePubkey = aliceSigning.init_with_seed(alicePrivkey); + const aliceSSK: ICrossSigningKey = { + user_id: "@alice:example.com", + usage: ["self_signing"], + keys: { + ["ed25519:" + alicePubkey]: alicePubkey, + }, + }; + const sskSig = aliceMasterSigning.sign(anotherjson.stringify(aliceSSK)); + aliceSSK.signatures = { + "@alice:example.com": { + ["ed25519:" + aliceMasterPubkey]: sskSig, + }, + }; + + // Alice's device downloads the keys, but doesn't trust them yet + alice.crypto!.deviceList.storeCrossSigningForUser("@alice:example.com", { + keys: { + master: { + user_id: "@alice:example.com", + usage: ["master"], + keys: { + ["ed25519:" + aliceMasterPubkey]: aliceMasterPubkey, }, - self_signing: aliceSSK, }, - firstUse: true, - crossSigningVerifiedBefore: false, - }); + self_signing: aliceSSK, + }, + firstUse: true, + crossSigningVerifiedBefore: false, + }); - // Alice has a second device that's cross-signed - const aliceDeviceId = 'Dynabook'; - const aliceUnsignedDevice = { - user_id: "@alice:example.com", - device_id: aliceDeviceId, - algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], - keys: { - "curve25519:Dynabook": "somePubkey", - "ed25519:Dynabook": "someOtherPubkey", + // Alice has a second device that's cross-signed + const aliceDeviceId = "Dynabook"; + const aliceUnsignedDevice = { + user_id: "@alice:example.com", + device_id: aliceDeviceId, + algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], + keys: { + "curve25519:Dynabook": "somePubkey", + "ed25519:Dynabook": "someOtherPubkey", + }, + }; + const sig = aliceSigning.sign(anotherjson.stringify(aliceUnsignedDevice)); + const aliceCrossSignedDevice: IDevice = { + ...aliceUnsignedDevice, + verified: 0, + known: false, + signatures: { + "@alice:example.com": { + ["ed25519:" + alicePubkey]: sig, }, - }; - const sig = aliceSigning.sign(anotherjson.stringify(aliceUnsignedDevice)); - const aliceCrossSignedDevice: IDevice = { - ...aliceUnsignedDevice, - verified: 0, - known: false, - signatures: { - "@alice:example.com": { - ["ed25519:" + alicePubkey]: sig, - }, - } }; - alice.crypto!.deviceList.storeDevicesForUser("@alice:example.com", { - [aliceDeviceId]: aliceCrossSignedDevice, - }); + }, + }; + alice.crypto!.deviceList.storeDevicesForUser("@alice:example.com", { + [aliceDeviceId]: aliceCrossSignedDevice, + }); - // We don't trust the cross-signing keys yet... - expect( - alice.checkDeviceTrust("@alice:example.com", aliceDeviceId).isCrossSigningVerified(), - ).toBeFalsy(); - // ... but we do acknowledge that the device is signed by them - expect(alice.checkIfOwnDeviceCrossSigned(aliceDeviceId)).toBeTruthy(); - alice.stopClient(); - }, - ); + // We don't trust the cross-signing keys yet... + expect(alice.checkDeviceTrust("@alice:example.com", aliceDeviceId).isCrossSigningVerified()).toBeFalsy(); + // ... but we do acknowledge that the device is signed by them + expect(alice.checkIfOwnDeviceCrossSigned(aliceDeviceId)).toBeTruthy(); + alice.stopClient(); + }); - it("should observe that our own device isn't cross-signed", async function() { - const { client: alice } = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - ); + it("should observe that our own device isn't cross-signed", async function () { + const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); alice.uploadDeviceSigningKeys = async () => ({}); alice.uploadKeySignatures = async () => ({ failures: {} }); @@ -1076,9 +1036,7 @@ describe("Cross Signing", function() { }); it("checkIfOwnDeviceCrossSigned should sanely handle unknown devices", async () => { - const { client: alice } = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - ); + const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); alice.uploadDeviceSigningKeys = async () => ({}); alice.uploadKeySignatures = async () => ({ failures: {} }); @@ -1129,3 +1087,67 @@ describe("Cross Signing", function() { alice.stopClient(); }); }); + +describe("userHasCrossSigningKeys", function () { + if (!global.Olm) { + return; + } + + beforeAll(() => { + return global.Olm.init(); + }); + + let aliceClient: MatrixClient; + let httpBackend: HttpBackend; + beforeEach(async () => { + const testClient = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); + aliceClient = testClient.client; + httpBackend = testClient.httpBackend; + }); + + afterEach(() => { + aliceClient.stopClient(); + }); + + it("should download devices and return true if one is a cross-signing key", async () => { + httpBackend.when("POST", "/keys/query").respond(200, { + master_keys: { + "@alice:example.com": { + user_id: "@alice:example.com", + usage: ["master"], + keys: { + "ed25519:nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk": + "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk", + }, + }, + }, + }); + + let result: boolean; + await Promise.all([ + httpBackend.flush("/keys/query"), + aliceClient.userHasCrossSigningKeys().then((res) => { + result = res; + }), + ]); + expect(result!).toBeTruthy(); + }); + + it("should download devices and return false if there is no cross-signing key", async () => { + httpBackend.when("POST", "/keys/query").respond(200, {}); + + let result: boolean; + await Promise.all([ + httpBackend.flush("/keys/query"), + aliceClient.userHasCrossSigningKeys().then((res) => { + result = res; + }), + ]); + expect(result!).toBeFalsy(); + }); + + it("throws an error if crypto is disabled", () => { + aliceClient["cryptoBackend"] = undefined; + expect(() => aliceClient.userHasCrossSigningKeys()).toThrowError("encryption disabled"); + }); +}); diff --git a/spec/unit/crypto/crypto-utils.ts b/spec/unit/crypto/crypto-utils.ts index 1391d79f193..1dd0065de5c 100644 --- a/spec/unit/crypto/crypto-utils.ts +++ b/spec/unit/crypto/crypto-utils.ts @@ -1,34 +1,33 @@ -import { IRecoveryKey } from '../../../src/crypto/api'; -import { CrossSigningLevel } from '../../../src/crypto/CrossSigning'; -import { IndexedDBCryptoStore } from '../../../src/crypto/store/indexeddb-crypto-store'; +import { IRecoveryKey } from "../../../src/crypto/api"; +import { CrossSigningLevel } from "../../../src/crypto/CrossSigning"; +import { IndexedDBCryptoStore } from "../../../src/crypto/store/indexeddb-crypto-store"; +import { MatrixClient } from "../../../src"; +import { CryptoEvent } from "../../../src/crypto"; // needs to be phased out and replaced with bootstrapSecretStorage, // but that is doing too much extra stuff for it to be an easy transition. export async function resetCrossSigningKeys( - client, - { level }: { level?: CrossSigningLevel} = {}, + client: MatrixClient, + { level }: { level?: CrossSigningLevel } = {}, ): Promise { - const crypto = client.crypto; + const crypto = client.crypto!; const oldKeys = Object.assign({}, crypto.crossSigningInfo.keys); try { await crypto.crossSigningInfo.resetKeys(level); await crypto.signObject(crypto.crossSigningInfo.keys.master); // write a copy locally so we know these are trusted keys - await crypto.cryptoStore.doTxn( - 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - crypto.cryptoStore.storeCrossSigningKeys( - txn, crypto.crossSigningInfo.keys); - }, - ); + await crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + crypto.cryptoStore.storeCrossSigningKeys(txn, crypto.crossSigningInfo.keys); + }); } catch (e) { // If anything failed here, revert the keys so we know to try again from the start // next time. crypto.crossSigningInfo.keys = oldKeys; throw e; } - crypto.emit("crossSigning.keysChanged", {}); + crypto.emit(CryptoEvent.KeysChanged, {}); + // @ts-ignore await crypto.afterCrossSigningLocalKeyChange(); } diff --git a/spec/unit/crypto/dehydration.spec.ts b/spec/unit/crypto/dehydration.spec.ts index 7d3227117c0..18b4b382806 100644 --- a/spec/unit/crypto/dehydration.spec.ts +++ b/spec/unit/crypto/dehydration.spec.ts @@ -14,33 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -import '../../olm-loader'; -import { TestClient } from '../../TestClient'; -import { logger } from '../../../src/logger'; -import { DEHYDRATION_ALGORITHM } from '../../../src/crypto/dehydration'; +import "../../olm-loader"; +import { TestClient } from "../../TestClient"; +import { logger } from "../../../src/logger"; +import { DEHYDRATION_ALGORITHM } from "../../../src/crypto/dehydration"; const Olm = global.Olm; describe("Dehydration", () => { if (!global.Olm) { - logger.warn('Not running dehydration unit tests: libolm not present'); + logger.warn("Not running dehydration unit tests: libolm not present"); return; } - beforeAll(function() { + beforeAll(function () { return global.Olm.init(); }); it("should rehydrate a dehydrated device", async () => { const key = new Uint8Array([1, 2, 3]); - const alice = new TestClient( - "@alice:example.com", "Osborne2", undefined, undefined, - { - cryptoCallbacks: { - getDehydrationKey: async t => key, - }, + const alice = new TestClient("@alice:example.com", "Osborne2", undefined, undefined, { + cryptoCallbacks: { + getDehydrationKey: async (t) => key, }, - ); + }); const dehydratedDevice = new Olm.Account(); dehydratedDevice.create(); @@ -56,25 +53,20 @@ describe("Dehydration", () => { success: true, }); - expect((await Promise.all([ - alice.client.rehydrateDevice(), - alice.httpBackend.flushAllExpected(), - ]))[0]) - .toEqual("ABCDEFG"); + expect((await Promise.all([alice.client.rehydrateDevice(), alice.httpBackend.flushAllExpected()]))[0]).toEqual( + "ABCDEFG", + ); expect(alice.client.getDeviceId()).toEqual("ABCDEFG"); }); it("should dehydrate a device", async () => { const key = new Uint8Array([1, 2, 3]); - const alice = new TestClient( - "@alice:example.com", "Osborne2", undefined, undefined, - { - cryptoCallbacks: { - getDehydrationKey: async t => key, - }, + const alice = new TestClient("@alice:example.com", "Osborne2", undefined, undefined, { + cryptoCallbacks: { + getDehydrationKey: async (t) => key, }, - ); + }); await alice.client.initCrypto(); @@ -84,7 +76,8 @@ describe("Dehydration", () => { let pickledAccount = ""; - alice.httpBackend.when("PUT", "/dehydrated_device") + alice.httpBackend + .when("PUT", "/dehydrated_device") .check((req) => { expect(req.data.device_data).toMatchObject({ algorithm: DEHYDRATION_ALGORITHM, @@ -95,7 +88,8 @@ describe("Dehydration", () => { .respond(200, { device_id: "ABCDEFG", }); - alice.httpBackend.when("POST", "/keys/upload/ABCDEFG") + alice.httpBackend + .when("POST", "/keys/upload/ABCDEFG") .check((req) => { expect(req.data).toMatchObject({ "device_keys": expect.objectContaining({ @@ -119,11 +113,12 @@ describe("Dehydration", () => { .respond(200, {}); try { - const deviceId = - (await Promise.all([ + const deviceId = ( + await Promise.all([ alice.client.createDehydratedDevice(new Uint8Array(key), {}), alice.httpBackend.flushAllExpected(), - ]))[0]; + ]) + )[0]; expect(deviceId).toEqual("ABCDEFG"); expect(deviceId).not.toEqual(""); diff --git a/spec/unit/crypto/outgoing-room-key-requests.spec.ts b/spec/unit/crypto/outgoing-room-key-requests.spec.ts index 049b7b7d365..f160efc0c9f 100644 --- a/spec/unit/crypto/outgoing-room-key-requests.spec.ts +++ b/spec/unit/crypto/outgoing-room-key-requests.spec.ts @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -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'; +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"; -import 'fake-indexeddb/auto'; -import 'jest-localstorage-mock'; +import "fake-indexeddb/auto"; +import "jest-localstorage-mock"; const requests = [ { @@ -46,54 +46,46 @@ const requests = [ requestId: "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" }, - ], + recipients: [{ userId: "@becca:example.com", deviceId: "foobarbaz" }], }, ]; describe.each([ - ["IndexedDBCryptoStore", - () => new IndexedDBCryptoStore(global.indexedDB, "tests")], + ["IndexedDBCryptoStore", () => new IndexedDBCryptoStore(global.indexedDB, "tests")], ["LocalStorageCryptoStore", () => new LocalStorageCryptoStore(localStorage)], ["MemoryCryptoStore", () => new MemoryCryptoStore()], -])("Outgoing room key requests [%s]", function(name, dbFactory) { +])("Outgoing room key requests [%s]", function (name, dbFactory) { let store: CryptoStore; beforeAll(async () => { store = dbFactory(); await store.startup(); - await Promise.all(requests.map((request) => - store.getOrAddOutgoingRoomKeyRequest(request), - )); + await Promise.all(requests.map((request) => store.getOrAddOutgoingRoomKeyRequest(request))); }); - it("getAllOutgoingRoomKeyRequestsByState retrieves all entries in a given state", - async () => { - const r = await - store.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); - expect(r).toHaveLength(2); - requests.filter((e) => e.state === RoomKeyRequestState.Sent).forEach((e) => { + it("getAllOutgoingRoomKeyRequestsByState retrieves all entries in a given state", async () => { + const r = await store.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); + expect(r).toHaveLength(2); + requests + .filter((e) => e.state === RoomKeyRequestState.Sent) + .forEach((e) => { expect(r).toContainEqual(e); }); - }); + }); - 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]); - }); + 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 = - await store.getOutgoingRoomKeyRequestByState([RoomKeyRequestState.Sent]); - expect(r).not.toBeNull(); - expect(r).not.toBeUndefined(); - expect(r!.state).toEqual(RoomKeyRequestState.Sent); - expect(requests).toContainEqual(r); - }); + test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state", async () => { + const r = await store.getOutgoingRoomKeyRequestByState([RoomKeyRequestState.Sent]); + expect(r).not.toBeNull(); + expect(r).not.toBeUndefined(); + expect(r!.state).toEqual(RoomKeyRequestState.Sent); + expect(requests).toContainEqual(r); + }); }); diff --git a/spec/unit/crypto/secrets.spec.ts b/spec/unit/crypto/secrets.spec.ts index 386df0d22b0..dc86642578d 100644 --- a/spec/unit/crypto/secrets.spec.ts +++ b/spec/unit/crypto/secrets.spec.ts @@ -14,56 +14,70 @@ See the License for the specific language governing permissions and limitations under the License. */ -import '../../olm-loader'; +import "../../olm-loader"; import * as olmlib from "../../../src/crypto/olmlib"; +import { IObject } from "../../../src/crypto/olmlib"; import { SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/crypto/SecretStorage"; import { MatrixEvent } from "../../../src/models/event"; -import { TestClient } from '../../TestClient'; -import { makeTestClients } from './verification/util'; +import { TestClient } from "../../TestClient"; +import { makeTestClients } from "./verification/util"; import { encryptAES } from "../../../src/crypto/aes"; import { createSecretStorageKey, resetCrossSigningKeys } from "./crypto-utils"; -import { logger } from '../../../src/logger'; -import { ClientEvent, ICreateClientOpts } from '../../../src/client'; -import { ISecretStorageKeyInfo } from '../../../src/crypto/api'; -import { DeviceInfo } from '../../../src/crypto/deviceinfo'; - -async function makeTestClient(userInfo: { userId: string, deviceId: string}, options: Partial = {}) { - const client = (new TestClient( - userInfo.userId, userInfo.deviceId, undefined, undefined, options, - )).client; +import { logger } from "../../../src/logger"; +import { ClientEvent, ICreateClientOpts, ICrossSigningKey, MatrixClient } from "../../../src/client"; +import { ISecretStorageKeyInfo } from "../../../src/crypto/api"; +import { DeviceInfo } from "../../../src/crypto/deviceinfo"; +import { ISignatures } from "../../../src/@types/signed"; +import { ICurve25519AuthData } from "../../../src/crypto/keybackup"; + +async function makeTestClient( + userInfo: { userId: string; deviceId: string }, + options: Partial = {}, +) { + const client = new TestClient(userInfo.userId, userInfo.deviceId, undefined, undefined, options).client; // Make it seem as if we've synced and thus the store can be trusted to // contain valid account data. - client.isInitialSyncComplete = function() { + client.isInitialSyncComplete = function () { return true; }; await client.initCrypto(); // No need to download keys for these tests - jest.spyOn(client.crypto!, 'downloadKeys').mockResolvedValue({}); + jest.spyOn(client.crypto!, "downloadKeys").mockResolvedValue({}); return client; } // Wrapper around pkSign to return a signed object. pkSign returns the // signature, rather than the signed object. -function sign(obj, key, userId) { - olmlib.pkSign(obj, key, userId, ''); - return obj; +function sign( + obj: T, + key: Uint8Array, + userId: string, +): T & { + signatures: ISignatures; + unsigned?: object; +} { + olmlib.pkSign(obj, key, userId, ""); + return obj as T & { + signatures: ISignatures; + unsigned?: object; + }; } -describe("Secrets", function() { +describe("Secrets", function () { if (!global.Olm) { - logger.warn('Not running megolm backup unit tests: libolm not present'); + logger.warn("Not running megolm backup unit tests: libolm not present"); return; } - beforeAll(function() { + beforeAll(function () { return global.Olm.init(); }); - it("should store and retrieve a secret", async function() { + it("should store and retrieve a secret", async function () { const key = new Uint8Array(16); for (let i = 0; i < 16; i++) key[i] = i; @@ -73,22 +87,22 @@ describe("Secrets", function() { const signingkeyInfo = { user_id: "@alice:example.com", - usage: ['master'], + usage: ["master"], keys: { - ['ed25519:' + signingPubKey]: signingPubKey, + ["ed25519:" + signingPubKey]: signingPubKey, }, }; - const getKey = jest.fn().mockImplementation(async e => { + const getKey = jest.fn().mockImplementation(async (e) => { expect(Object.keys(e.keys)).toEqual(["abc"]); - return ['abc', key]; + return ["abc", key]; }); const alice = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, { cryptoCallbacks: { - getCrossSigningKey: async t => signingKey, + getCrossSigningKey: async (t) => signingKey, getSecretStorageKey: getKey, }, }, @@ -99,21 +113,20 @@ describe("Secrets", function() { const secretStorage = alice.crypto!.secretStorage; - jest.spyOn(alice, 'setAccountData').mockImplementation( - async function(eventType, contents) { - alice.store.storeAccountDataEvents([ - new MatrixEvent({ - type: eventType, - content: contents, - }), - ]); - return {}; - }); + jest.spyOn(alice, "setAccountData").mockImplementation(async function (eventType, contents) { + alice.store.storeAccountDataEvents([ + new MatrixEvent({ + type: eventType, + content: contents, + }), + ]); + return {}; + }); const keyAccountData = { algorithm: SECRET_STORAGE_ALGORITHM_V1_AES, }; - await alice.crypto!.crossSigningInfo.signObject(keyAccountData, 'master'); + await alice.crypto!.crossSigningInfo.signObject(keyAccountData, "master"); alice.store.storeAccountDataEvents([ new MatrixEvent({ @@ -133,54 +146,48 @@ describe("Secrets", function() { alice.stopClient(); }); - it("should throw if given a key that doesn't exist", async function() { - const alice = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - ); + it("should throw if given a key that doesn't exist", async function () { + const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); try { await alice.storeSecret("foo", "bar", ["this secret does not exist"]); // should be able to use expect(...).toThrow() but mocha still fails // the test even when it throws for reasons I have no inclination to debug expect(true).toBeFalsy(); - } catch (e) { - } + } catch (e) {} alice.stopClient(); }); - it("should refuse to encrypt with zero keys", async function() { - const alice = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - ); + it("should refuse to encrypt with zero keys", async function () { + const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); try { await alice.storeSecret("foo", "bar", []); expect(true).toBeFalsy(); - } catch (e) { - } + } catch (e) {} alice.stopClient(); }); - it("should encrypt with default key if keys is null", async function() { + it("should encrypt with default key if keys is null", async function () { const key = new Uint8Array(16); for (let i = 0; i < 16; i++) key[i] = i; - const getKey = jest.fn().mockImplementation(async e => { + const getKey = jest.fn().mockImplementation(async (e) => { expect(Object.keys(e.keys)).toEqual([newKeyId]); return [newKeyId, key]; }); - let keys = {}; + let keys: Record = {}; const alice = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, { cryptoCallbacks: { - getCrossSigningKey: t => keys[t], - saveCrossSigningKeys: k => keys = k, + getCrossSigningKey: (t) => Promise.resolve(keys[t]), + saveCrossSigningKeys: (k) => (keys = k), getSecretStorageKey: getKey, }, }, ); - alice.setAccountData = async function(eventType, contents) { + alice.setAccountData = async function (eventType, contents) { alice.store.storeAccountDataEvents([ new MatrixEvent({ type: eventType, @@ -191,33 +198,31 @@ describe("Secrets", function() { }; resetCrossSigningKeys(alice); - const { keyId: newKeyId } = await alice.addSecretStorageKey( - SECRET_STORAGE_ALGORITHM_V1_AES, { pubkey: undefined, key: undefined }, - ); + const { keyId: newKeyId } = await alice.addSecretStorageKey(SECRET_STORAGE_ALGORITHM_V1_AES, { + pubkey: undefined, + key: undefined, + }); // we don't await on this because it waits for the event to come down the sync // which won't happen in the test setup alice.setDefaultSecretStorageKeyId(newKeyId); await alice.storeSecret("foo", "bar"); - const accountData = alice.getAccountData('foo'); + const accountData = alice.getAccountData("foo"); expect(accountData!.getContent().encrypted).toBeTruthy(); alice.stopClient(); }); - it("should refuse to encrypt if no keys given and no default key", async function() { - const alice = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - ); + it("should refuse to encrypt if no keys given and no default key", async function () { + const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); try { await alice.storeSecret("foo", "bar"); expect(true).toBeFalsy(); - } catch (e) { - } + } catch (e) {} alice.stopClient(); }); - it("should request secrets from other clients", async function() { + it("should request secrets from other clients", async function () { const [[osborne2, vax], clearTestClientTimeouts] = await makeTestClients( [ { userId: "@alice:example.com", deviceId: "Osborne2" }, @@ -227,7 +232,7 @@ describe("Secrets", function() { cryptoCallbacks: { onSecretRequested: (userId, deviceId, requestId, secretName, deviceTrust) => { expect(secretName).toBe("foo"); - return "bar"; + return Promise.resolve("bar"); }, }, }, @@ -238,7 +243,7 @@ describe("Secrets", function() { const secretStorage = osborne2.client.crypto!.secretStorage; osborne2.client.crypto!.deviceList.storeDevicesForUser("@alice:example.com", { - "VAX": { + VAX: { known: false, algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], keys: { @@ -249,7 +254,7 @@ describe("Secrets", function() { }, }); vax.client.crypto!.deviceList.storeDevicesForUser("@alice:example.com", { - "Osborne2": { + Osborne2: { algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], verified: 0, known: false, @@ -280,30 +285,20 @@ describe("Secrets", function() { clearTestClientTimeouts(); }); - describe("bootstrap", function() { + describe("bootstrap", function () { // keys used in some of the tests - const XSK = new Uint8Array( - olmlib.decodeBase64("3lo2YdJugHjfE+Or7KJ47NuKbhE7AAGLgQ/dc19913Q="), - ); + const XSK = new Uint8Array(olmlib.decodeBase64("3lo2YdJugHjfE+Or7KJ47NuKbhE7AAGLgQ/dc19913Q=")); const XSPubKey = "DRb8pFVJyEJ9OWvXeUoM0jq/C2Wt+NxzBZVuk2nRb+0"; - const USK = new Uint8Array( - olmlib.decodeBase64("lKWi3hJGUie5xxHgySoz8PHFnZv6wvNaud/p2shN9VU="), - ); + const USK = new Uint8Array(olmlib.decodeBase64("lKWi3hJGUie5xxHgySoz8PHFnZv6wvNaud/p2shN9VU=")); const USPubKey = "CUpoiTtHiyXpUmd+3ohb7JVxAlUaOG1NYs9Jlx8soQU"; - const SSK = new Uint8Array( - olmlib.decodeBase64("1R6JVlXX99UcfUZzKuCDGQgJTw8ur1/ofgPD8pp+96M="), - ); + const SSK = new Uint8Array(olmlib.decodeBase64("1R6JVlXX99UcfUZzKuCDGQgJTw8ur1/ofgPD8pp+96M=")); const SSPubKey = "0DfNsRDzEvkCLA0gD3m7VAGJ5VClhjEsewI35xq873Q"; - const SSSSKey = new Uint8Array( - olmlib.decodeBase64( - "XrmITOOdBhw6yY5Bh7trb/bgp1FRdIGyCUxxMP873R0=", - ), - ); + const SSSSKey = new Uint8Array(olmlib.decodeBase64("XrmITOOdBhw6yY5Bh7trb/bgp1FRdIGyCUxxMP873R0=")); - it("bootstraps when no storage or cross-signing keys locally", async function() { + it("bootstraps when no storage or cross-signing keys locally", async function () { const key = new Uint8Array(16); for (let i = 0; i < 16; i++) key[i] = i; - const getKey = jest.fn().mockImplementation(async e => { + const getKey = jest.fn().mockImplementation(async (e) => { return [Object.keys(e.keys)[0], key]; }); @@ -320,20 +315,20 @@ describe("Secrets", function() { ); bob.uploadDeviceSigningKeys = async () => ({}); bob.uploadKeySignatures = jest.fn().mockResolvedValue(undefined); - bob.setAccountData = async function(eventType, contents) { + bob.setAccountData = async function (eventType, contents) { const event = new MatrixEvent({ type: eventType, content: contents, }); - this.store.storeAccountDataEvents([ - event, - ]); + this.store.storeAccountDataEvents([event]); this.emit(ClientEvent.AccountData, event); return {}; }; await bob.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async func => { await func({}); }, + authUploadDeviceSigningKeys: async (func) => { + await func({}); + }, }); await bob.bootstrapSecretStorage({ createSecretStorageKey, @@ -343,53 +338,53 @@ describe("Secrets", function() { const secretStorage = bob.crypto!.secretStorage; expect(crossSigning.getId()).toBeTruthy(); - expect(await crossSigning.isStoredInSecretStorage(secretStorage)) - .toBeTruthy(); + expect(await crossSigning.isStoredInSecretStorage(secretStorage)).toBeTruthy(); expect(await secretStorage.hasKey()).toBeTruthy(); bob.stopClient(); }); - it("bootstraps when cross-signing keys in secret storage", async function() { + it("bootstraps when cross-signing keys in secret storage", async function () { const decryption = new global.Olm.PkDecryption(); const storagePublicKey = decryption.generate_key(); const storagePrivateKey = decryption.get_private_key(); - const bob = await makeTestClient( + const bob: MatrixClient = await makeTestClient( { userId: "@bob:example.com", deviceId: "bob1", }, { cryptoCallbacks: { - getSecretStorageKey: async request => { + getSecretStorageKey: async (request) => { const defaultKeyId = await bob.getDefaultSecretStorageKeyId(); expect(Object.keys(request.keys)).toEqual([defaultKeyId]); - return [defaultKeyId, storagePrivateKey]; + return [defaultKeyId!, storagePrivateKey]; }, }, }, ); - bob.uploadDeviceSigningKeys = async () => {}; - bob.uploadKeySignatures = async () => {}; - bob.setAccountData = async function(eventType, contents, callback) { + bob.uploadDeviceSigningKeys = async () => ({}); + bob.uploadKeySignatures = async () => ({ failures: {} }); + bob.setAccountData = async function (eventType, contents) { const event = new MatrixEvent({ type: eventType, content: contents, }); - this.store.storeAccountDataEvents([ - event, - ]); - this.emit("accountData", event); + this.store.storeAccountDataEvents([event]); + this.emit(ClientEvent.AccountData, event); + return {}; }; - bob.crypto.backupManager.checkKeyBackup = async () => {}; + bob.crypto!.backupManager.checkKeyBackup = async () => null; - const crossSigning = bob.crypto.crossSigningInfo; - const secretStorage = bob.crypto.secretStorage; + const crossSigning = bob.crypto!.crossSigningInfo; + const secretStorage = bob.crypto!.secretStorage; // Set up cross-signing keys from scratch with specific storage key await bob.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async func => await func({}), + authUploadDeviceSigningKeys: async (func) => { + await func({}); + }, }); await bob.bootstrapSecretStorage({ createSecretStorageKey: async () => ({ @@ -400,37 +395,35 @@ describe("Secrets", function() { }); // Clear local cross-signing keys and read from secret storage - bob.crypto.deviceList.storeCrossSigningForUser( - "@bob:example.com", - crossSigning.toStorage(), - ); + bob.crypto!.deviceList.storeCrossSigningForUser("@bob:example.com", crossSigning.toStorage()); crossSigning.keys = {}; await bob.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async func => await func({}), + authUploadDeviceSigningKeys: async (func) => { + await func({}); + }, }); expect(crossSigning.getId()).toBeTruthy(); - expect(await crossSigning.isStoredInSecretStorage(secretStorage)) - .toBeTruthy(); + expect(await crossSigning.isStoredInSecretStorage(secretStorage)).toBeTruthy(); expect(await secretStorage.hasKey()).toBeTruthy(); bob.stopClient(); }); - it("adds passphrase checking if it's lacking", async function() { + it("adds passphrase checking if it's lacking", async function () { let crossSigningKeys: Record = { master: XSK, user_signing: USK, self_signing: SSK, }; - const secretStorageKeys = { + const secretStorageKeys: Record = { key_id: SSSSKey, }; const alice = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, { cryptoCallbacks: { - getCrossSigningKey: async t => crossSigningKeys[t], - saveCrossSigningKeys: k => crossSigningKeys = k, + getCrossSigningKey: async (t) => crossSigningKeys[t], + saveCrossSigningKeys: (k) => (crossSigningKeys = k), getSecretStorageKey: async ({ keys }, name) => { for (const keyId of Object.keys(keys)) { if (secretStorageKeys[keyId]) { @@ -498,32 +491,44 @@ describe("Secrets", function() { [`ed25519:${XSPubKey}`]: XSPubKey, }, }, - self_signing: sign({ - user_id: "@alice:example.com", - usage: ["self_signing"], - keys: { - [`ed25519:${SSPubKey}`]: SSPubKey, + self_signing: sign( + { + user_id: "@alice:example.com", + usage: ["self_signing"], + keys: { + [`ed25519:${SSPubKey}`]: SSPubKey, + }, }, - }, XSK, "@alice:example.com"), - user_signing: sign({ - user_id: "@alice:example.com", - usage: ["user_signing"], - keys: { - [`ed25519:${USPubKey}`]: USPubKey, + XSK, + "@alice:example.com", + ), + user_signing: sign( + { + user_id: "@alice:example.com", + usage: ["user_signing"], + keys: { + [`ed25519:${USPubKey}`]: USPubKey, + }, }, - }, XSK, "@alice:example.com"), + XSK, + "@alice:example.com", + ), }, }); alice.getKeyBackupVersion = async () => { return { version: "1", algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", - auth_data: sign({ - public_key: "pxEXhg+4vdMf/kFwP4bVawFWdb0EmytL3eFJx++zQ0A", - }, XSK, "@alice:example.com"), + auth_data: sign( + { + public_key: "pxEXhg+4vdMf/kFwP4bVawFWdb0EmytL3eFJx++zQ0A", + }, + XSK, + "@alice:example.com", + ), }; }; - alice.setAccountData = async function(name, data) { + alice.setAccountData = async function (name, data) { const event = new MatrixEvent({ type: name, content: data, @@ -535,11 +540,9 @@ describe("Secrets", function() { await alice.bootstrapSecretStorage({}); - expect(alice.getAccountData("m.secret_storage.default_key")!.getContent()) - .toEqual({ key: "key_id" }); + expect(alice.getAccountData("m.secret_storage.default_key")!.getContent()).toEqual({ key: "key_id" }); const keyInfo = alice.getAccountData("m.secret_storage.key.key_id")!.getContent(); - expect(keyInfo.algorithm) - .toEqual("m.secret_storage.v1.aes-hmac-sha2"); + expect(keyInfo.algorithm).toEqual("m.secret_storage.v1.aes-hmac-sha2"); expect(keyInfo.passphrase).toEqual({ algorithm: "m.pbkdf2", iterations: 500000, @@ -547,25 +550,24 @@ describe("Secrets", function() { }); expect(keyInfo).toHaveProperty("iv"); expect(keyInfo).toHaveProperty("mac"); - expect(alice.checkSecretStorageKey(secretStorageKeys.key_id, keyInfo)) - .toBeTruthy(); + expect(alice.checkSecretStorageKey(secretStorageKeys.key_id, keyInfo)).toBeTruthy(); alice.stopClient(); }); - it("fixes backup keys in the wrong format", async function() { + it("fixes backup keys in the wrong format", async function () { let crossSigningKeys: Record = { master: XSK, user_signing: USK, self_signing: SSK, }; - const secretStorageKeys = { + const secretStorageKeys: Record = { key_id: SSSSKey, }; const alice = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, { cryptoCallbacks: { - getCrossSigningKey: async t => crossSigningKeys[t], - saveCrossSigningKeys: k => crossSigningKeys = k, + getCrossSigningKey: async (t) => crossSigningKeys[t], + saveCrossSigningKeys: (k) => (crossSigningKeys = k), getSecretStorageKey: async ({ keys }, name) => { for (const keyId of Object.keys(keys)) { if (secretStorageKeys[keyId]) { @@ -625,7 +627,8 @@ describe("Secrets", function() { encrypted: { key_id: await encryptAES( "123,45,6,7,89,1,234,56,78,90,12,34,5,67,8,90", - secretStorageKeys.key_id, "m.megolm_backup.v1", + secretStorageKeys.key_id, + "m.megolm_backup.v1", ), }, }, @@ -642,32 +645,44 @@ describe("Secrets", function() { [`ed25519:${XSPubKey}`]: XSPubKey, }, }, - self_signing: sign({ - user_id: "@alice:example.com", - usage: ["self_signing"], - keys: { - [`ed25519:${SSPubKey}`]: SSPubKey, + self_signing: sign( + { + user_id: "@alice:example.com", + usage: ["self_signing"], + keys: { + [`ed25519:${SSPubKey}`]: SSPubKey, + }, }, - }, XSK, "@alice:example.com"), - user_signing: sign({ - user_id: "@alice:example.com", - usage: ["user_signing"], - keys: { - [`ed25519:${USPubKey}`]: USPubKey, + XSK, + "@alice:example.com", + ), + user_signing: sign( + { + user_id: "@alice:example.com", + usage: ["user_signing"], + keys: { + [`ed25519:${USPubKey}`]: USPubKey, + }, }, - }, XSK, "@alice:example.com"), + XSK, + "@alice:example.com", + ), }, }); alice.getKeyBackupVersion = async () => { return { version: "1", algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", - auth_data: sign({ - public_key: "pxEXhg+4vdMf/kFwP4bVawFWdb0EmytL3eFJx++zQ0A", - }, XSK, "@alice:example.com"), + auth_data: sign( + { + public_key: "pxEXhg+4vdMf/kFwP4bVawFWdb0EmytL3eFJx++zQ0A", + }, + XSK, + "@alice:example.com", + ), }; }; - alice.setAccountData = async function(name, data) { + alice.setAccountData = async function (name, data) { const event = new MatrixEvent({ type: name, content: data, @@ -681,8 +696,7 @@ describe("Secrets", function() { const backupKey = alice.getAccountData("m.megolm_backup.v1")!.getContent(); expect(backupKey.encrypted).toHaveProperty("key_id"); - expect(await alice.getSecret("m.megolm_backup.v1")) - .toEqual("ey0GB1kB6jhOWgwiBUMIWg=="); + expect(await alice.getSecret("m.megolm_backup.v1")).toEqual("ey0GB1kB6jhOWgwiBUMIWg=="); alice.stopClient(); }); }); diff --git a/spec/unit/crypto/verification/InRoomChannel.spec.ts b/spec/unit/crypto/verification/InRoomChannel.spec.ts index c6f9db33997..562c83b04a3 100644 --- a/spec/unit/crypto/verification/InRoomChannel.spec.ts +++ b/spec/unit/crypto/verification/InRoomChannel.spec.ts @@ -17,15 +17,17 @@ import { MatrixClient } from "../../../../src/client"; import { InRoomChannel } from "../../../../src/crypto/verification/request/InRoomChannel"; import { MatrixEvent } from "../../../../src/models/event"; -describe("InRoomChannel tests", function() { +describe("InRoomChannel tests", function () { const ALICE = "@alice:hs.tld"; const BOB = "@bob:hs.tld"; const MALORY = "@malory:hs.tld"; const client = { - getUserId() { return ALICE; }, + getUserId() { + return ALICE; + }, } as unknown as MatrixClient; - it("getEventType only returns .request for a message with a msgtype", function() { + it("getEventType only returns .request for a message with a msgtype", function () { const invalidEvent = new MatrixEvent({ type: "m.key.verification.request", }); @@ -34,34 +36,29 @@ describe("InRoomChannel tests", function() { type: "m.room.message", content: { msgtype: "m.key.verification.request" }, }); - expect(InRoomChannel.getEventType(validEvent)). - toStrictEqual("m.key.verification.request"); + expect(InRoomChannel.getEventType(validEvent)).toStrictEqual("m.key.verification.request"); const validFooEvent = new MatrixEvent({ type: "m.foo" }); - expect(InRoomChannel.getEventType(validFooEvent)). - toStrictEqual("m.foo"); + expect(InRoomChannel.getEventType(validFooEvent)).toStrictEqual("m.foo"); }); - it("getEventType should return m.room.message for messages", function() { + it("getEventType should return m.room.message for messages", function () { const messageEvent = new MatrixEvent({ type: "m.room.message", content: { msgtype: "m.text" }, }); // XXX: The event type doesn't matter too much, just as long as it's not a verification event - expect(InRoomChannel.getEventType(messageEvent)). - toStrictEqual("m.room.message"); + expect(InRoomChannel.getEventType(messageEvent)).toStrictEqual("m.room.message"); }); - it("getEventType should return actual type for non-message events", function() { + it("getEventType should return actual type for non-message events", function () { const event = new MatrixEvent({ type: "m.room.member", - content: { }, + content: {}, }); - expect(InRoomChannel.getEventType(event)). - toStrictEqual("m.room.member"); + expect(InRoomChannel.getEventType(event)).toStrictEqual("m.room.member"); }); - it("getOtherPartyUserId should not return anything for a request not " + - "directed at me", function() { + it("getOtherPartyUserId should not return anything for a request not " + "directed at me", function () { const event = new MatrixEvent({ sender: BOB, type: "m.room.message", @@ -70,29 +67,25 @@ describe("InRoomChannel tests", function() { expect(InRoomChannel.getOtherPartyUserId(event, client)).toStrictEqual(undefined); }); - it("getOtherPartyUserId should not return anything an event that is not of a valid " + - "request type", function() { + it("getOtherPartyUserId should not return anything an event that is not of a valid " + "request type", function () { // invalid because this should be a room message with msgtype const invalidRequest = new MatrixEvent({ sender: BOB, type: "m.key.verification.request", content: { to: ALICE }, }); - expect(InRoomChannel.getOtherPartyUserId(invalidRequest, client)) - .toStrictEqual(undefined); + expect(InRoomChannel.getOtherPartyUserId(invalidRequest, client)).toStrictEqual(undefined); const startEvent = new MatrixEvent({ sender: BOB, type: "m.key.verification.start", content: { to: ALICE }, }); - expect(InRoomChannel.getOtherPartyUserId(startEvent, client)) - .toStrictEqual(undefined); + expect(InRoomChannel.getOtherPartyUserId(startEvent, client)).toStrictEqual(undefined); const fooEvent = new MatrixEvent({ sender: BOB, type: "m.foo", content: { to: ALICE }, }); - expect(InRoomChannel.getOtherPartyUserId(fooEvent, client)) - .toStrictEqual(undefined); + expect(InRoomChannel.getOtherPartyUserId(fooEvent, client)).toStrictEqual(undefined); }); }); diff --git a/spec/unit/crypto/verification/qr_code.spec.ts b/spec/unit/crypto/verification/qr_code.spec.ts index 33fa2405c1d..7d2fc9171d1 100644 --- a/spec/unit/crypto/verification/qr_code.spec.ts +++ b/spec/unit/crypto/verification/qr_code.spec.ts @@ -19,13 +19,13 @@ import { logger } from "../../../../src/logger"; const Olm = global.Olm; -describe("QR code verification", function() { +describe("QR code verification", function () { if (!global.Olm) { - logger.warn('Not running device verification tests: libolm not present'); + logger.warn("Not running device verification tests: libolm not present"); return; } - beforeAll(function() { + beforeAll(function () { return Olm.init(); }); diff --git a/spec/unit/crypto/verification/request.spec.ts b/spec/unit/crypto/verification/request.spec.ts index be957989a6d..63989c77c40 100644 --- a/spec/unit/crypto/verification/request.spec.ts +++ b/spec/unit/crypto/verification/request.spec.ts @@ -18,23 +18,23 @@ import "../../../olm-loader"; import { CryptoEvent, verificationMethods } from "../../../../src/crypto"; import { logger } from "../../../../src/logger"; import { SAS } from "../../../../src/crypto/verification/SAS"; -import { makeTestClients } from './util'; +import { makeTestClients } from "./util"; const Olm = global.Olm; jest.useFakeTimers(); -describe("verification request integration tests with crypto layer", function() { +describe("verification request integration tests with crypto layer", function () { if (!global.Olm) { - logger.warn('Not running device verification unit tests: libolm not present'); + logger.warn("Not running device verification unit tests: libolm not present"); return; } - beforeAll(function() { + beforeAll(function () { return Olm.init(); }); - it("should request and accept a verification", async function() { + it("should request and accept a verification", async function () { const [[alice, bob], clearTestClientTimeouts] = await makeTestClients( [ { userId: "@alice:example.com", deviceId: "Osborne2" }, @@ -44,7 +44,7 @@ describe("verification request integration tests with crypto layer", function() verificationMethods: [verificationMethods.SAS], }, ); - alice.client.crypto!.deviceList.getRawStoredDevicesForUser = function() { + alice.client.crypto!.deviceList.getRawStoredDevicesForUser = function () { return { Dynabook: { algorithms: [], @@ -66,7 +66,7 @@ describe("verification request integration tests with crypto layer", function() bobVerifier.endTimer(); }); const aliceRequest = await alice.client.requestVerification("@bob:example.com"); - await aliceRequest.waitFor(r => r.started); + await aliceRequest.waitFor((r) => r.started); const aliceVerifier = aliceRequest.verifier; expect(aliceVerifier).toBeInstanceOf(SAS); diff --git a/spec/unit/crypto/verification/sas.spec.ts b/spec/unit/crypto/verification/sas.spec.ts index ee058c7f0a0..5309c0d80f7 100644 --- a/spec/unit/crypto/verification/sas.spec.ts +++ b/spec/unit/crypto/verification/sas.spec.ts @@ -15,15 +15,15 @@ See the License for the specific language governing permissions and limitations under the License. */ import "../../../olm-loader"; -import { makeTestClients } from './util'; +import { makeTestClients } from "./util"; import { MatrixEvent } from "../../../../src/models/event"; import { ISasEvent, SAS, SasEvent } from "../../../../src/crypto/verification/SAS"; -import { DeviceInfo } from "../../../../src/crypto/deviceinfo"; +import { DeviceInfo, IDevice } from "../../../../src/crypto/deviceinfo"; 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 as Verification, VerificationBase } from "../../../../src/crypto/verification/Base"; +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"; @@ -31,43 +31,45 @@ import { TestClient } from "../../../TestClient"; const Olm = global.Olm; -let ALICE_DEVICES; -let BOB_DEVICES; +let ALICE_DEVICES: Record; +let BOB_DEVICES: Record; -describe("SAS verification", function() { +describe("SAS verification", function () { if (!global.Olm) { - logger.warn('Not running device verification unit tests: libolm not present'); + logger.warn("Not running device verification unit tests: libolm not present"); return; } - beforeAll(function() { + beforeAll(function () { return Olm.init(); }); - it("should error on an unexpected event", async function() { + it("should error on an unexpected event", async function () { //channel, baseApis, userId, deviceId, startEvent, request const request = { - onVerifierCancelled: function() {}, + onVerifierCancelled: function () {}, } as VerificationRequest; const channel = { - send: function() { + send: function () { return Promise.resolve(); }, } as unknown as IVerificationChannel; const mockClient = {} as unknown as MatrixClient; - const event = new MatrixEvent({ type: 'test' }); + 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", - content: {}, - })); + sas.handleEvent( + new MatrixEvent({ + sender: "@alice:example.com", + type: "es.inquisition", + content: {}, + }), + ); const spy = jest.fn(); await sas.verify().catch(spy); expect(spy).toHaveBeenCalled(); // Cancel the SAS for cleanup (we started a verification, so abort) - sas.cancel(new Error('error')); + sas.cancel(new Error("error")); }); describe("verification", () => { @@ -75,7 +77,7 @@ describe("SAS verification", function() { let bob: TestClient; let aliceSasEvent: ISasEvent | null; let bobSasEvent: ISasEvent | null; - let aliceVerifier: Verification; + let aliceVerifier: SAS; let bobPromise: Promise>; let clearTestClientTimeouts: () => void; @@ -95,38 +97,34 @@ describe("SAS verification", function() { ALICE_DEVICES = { Osborne2: { - user_id: "@alice:example.com", - device_id: "Osborne2", algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], keys: { - "ed25519:Osborne2": aliceDevice.deviceEd25519Key, - "curve25519:Osborne2": aliceDevice.deviceCurve25519Key, + "ed25519:Osborne2": aliceDevice.deviceEd25519Key!, + "curve25519:Osborne2": aliceDevice.deviceCurve25519Key!, }, + verified: DeviceInfo.DeviceVerification.UNVERIFIED, + known: false, }, }; BOB_DEVICES = { Dynabook: { - user_id: "@bob:example.com", - device_id: "Dynabook", algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], keys: { - "ed25519:Dynabook": bobDevice.deviceEd25519Key, - "curve25519:Dynabook": bobDevice.deviceCurve25519Key, + "ed25519:Dynabook": bobDevice.deviceEd25519Key!, + "curve25519:Dynabook": bobDevice.deviceCurve25519Key!, }, + verified: DeviceInfo.DeviceVerification.UNVERIFIED, + known: false, }, }; - alice.client.crypto!.deviceList.storeDevicesForUser( - "@bob:example.com", BOB_DEVICES, - ); + alice.client.crypto!.deviceList.storeDevicesForUser("@bob:example.com", BOB_DEVICES); alice.client.downloadKeys = () => { return Promise.resolve({}); }; - bob.client.crypto!.deviceList.storeDevicesForUser( - "@alice:example.com", ALICE_DEVICES, - ); + bob.client.crypto!.deviceList.storeDevicesForUser("@alice:example.com", ALICE_DEVICES); bob.client.downloadKeys = () => { return Promise.resolve({}); }; @@ -135,8 +133,8 @@ describe("SAS verification", function() { bobSasEvent = null; bobPromise = new Promise>((resolve, reject) => { - bob.client.on(CryptoEvent.VerificationRequest, request => { - request.verifier!.on("show_sas", (e) => { + bob.client.on(CryptoEvent.VerificationRequest, (request) => { + (request.verifier!).on(SasEvent.ShowSas, (e) => { if (!e.sas.emoji || !e.sas.decimal) { e.cancel(); } else if (!aliceSasEvent) { @@ -157,8 +155,10 @@ describe("SAS verification", function() { }); aliceVerifier = alice.client.beginKeyVerification( - verificationMethods.SAS, bob.client.getUserId()!, bob.deviceId!, - ); + verificationMethods.SAS, + bob.client.getUserId()!, + bob.deviceId!, + ) as SAS; aliceVerifier.on(SasEvent.ShowSas, (e) => { if (!e.sas.emoji || !e.sas.decimal) { e.cancel(); @@ -177,10 +177,7 @@ describe("SAS verification", function() { }); }); afterEach(async () => { - await Promise.all([ - alice.stop(), - bob.stop(), - ]); + await Promise.all([alice.stop(), bob.stop()]); clearTestClientTimeouts(); }); @@ -189,23 +186,21 @@ describe("SAS verification", function() { let macMethod; let keyAgreement; const origSendToDevice = bob.client.sendToDevice.bind(bob.client); - bob.client.sendToDevice = function(type, map) { + bob.client.sendToDevice = function (type, map) { if (type === "m.key.verification.accept") { - macMethod = map[alice.client.getUserId()!][alice.client.deviceId!] - .message_authentication_code; - keyAgreement = map[alice.client.getUserId()!][alice.client.deviceId!] - .key_agreement_protocol; + macMethod = map[alice.client.getUserId()!][alice.client.deviceId!].message_authentication_code; + keyAgreement = map[alice.client.getUserId()!][alice.client.deviceId!].key_agreement_protocol; } return origSendToDevice(type, map); }; - alice.httpBackend.when('POST', '/keys/query').respond(200, { + alice.httpBackend.when("POST", "/keys/query").respond(200, { failures: {}, device_keys: { "@bob:example.com": BOB_DEVICES, }, }); - bob.httpBackend.when('POST', '/keys/query').respond(200, { + bob.httpBackend.when("POST", "/keys/query").respond(200, { failures: {}, device_keys: { "@alice:example.com": ALICE_DEVICES, @@ -224,11 +219,9 @@ describe("SAS verification", function() { expect(keyAgreement).toBe("curve25519-hkdf-sha256"); // make sure Alice and Bob verified each other - const bobDevice - = await alice.client.getStoredDevice("@bob:example.com", "Dynabook"); + const bobDevice = await alice.client.getStoredDevice("@bob:example.com", "Dynabook"); expect(bobDevice?.isVerified()).toBeTruthy(); - const aliceDevice - = await bob.client.getStoredDevice("@alice:example.com", "Osborne2"); + const aliceDevice = await bob.client.getStoredDevice("@alice:example.com", "Osborne2"); expect(aliceDevice?.isVerified()).toBeTruthy(); }); @@ -244,27 +237,27 @@ describe("SAS verification", function() { // has, since it is the same object. If this does not // happen, the verification will fail due to a hash // commitment mismatch. - map[bob.client.getUserId()!][bob.client.deviceId!] - .message_authentication_codes = ['hkdf-hmac-sha256']; + map[bob.client.getUserId()!][bob.client.deviceId!].message_authentication_codes = [ + "hkdf-hmac-sha256", + ]; } return aliceOrigSendToDevice(type, map); }; const bobOrigSendToDevice = bob.client.sendToDevice.bind(bob.client); bob.client.sendToDevice = (type, map) => { if (type === "m.key.verification.accept") { - macMethod = map[alice.client.getUserId()!][alice.client.deviceId!] - .message_authentication_code; + macMethod = map[alice.client.getUserId()!][alice.client.deviceId!].message_authentication_code; } return bobOrigSendToDevice(type, map); }; - alice.httpBackend.when('POST', '/keys/query').respond(200, { + alice.httpBackend.when("POST", "/keys/query").respond(200, { failures: {}, device_keys: { "@bob:example.com": BOB_DEVICES, }, }); - bob.httpBackend.when('POST', '/keys/query').respond(200, { + bob.httpBackend.when("POST", "/keys/query").respond(200, { failures: {}, device_keys: { "@alice:example.com": ALICE_DEVICES, @@ -280,11 +273,9 @@ describe("SAS verification", function() { expect(macMethod).toBe("hkdf-hmac-sha256"); - const bobDevice - = await alice.client.getStoredDevice("@bob:example.com", "Dynabook"); + const bobDevice = await alice.client.getStoredDevice("@bob:example.com", "Dynabook"); expect(bobDevice!.isVerified()).toBeTruthy(); - const aliceDevice - = await bob.client.getStoredDevice("@alice:example.com", "Osborne2"); + const aliceDevice = await bob.client.getStoredDevice("@alice:example.com", "Osborne2"); expect(aliceDevice!.isVerified()).toBeTruthy(); }); @@ -300,27 +291,25 @@ describe("SAS verification", function() { // has, since it is the same object. If this does not // happen, the verification will fail due to a hash // commitment mismatch. - map[bob.client.getUserId()!][bob.client.deviceId!] - .message_authentication_codes = ['hmac-sha256']; + map[bob.client.getUserId()!][bob.client.deviceId!].message_authentication_codes = ["hmac-sha256"]; } return aliceOrigSendToDevice(type, map); }; const bobOrigSendToDevice = bob.client.sendToDevice.bind(bob.client); bob.client.sendToDevice = (type, map) => { if (type === "m.key.verification.accept") { - macMethod = map[alice.client.getUserId()!][alice.client.deviceId!] - .message_authentication_code; + macMethod = map[alice.client.getUserId()!][alice.client.deviceId!].message_authentication_code; } return bobOrigSendToDevice(type, map); }; - alice.httpBackend.when('POST', '/keys/query').respond(200, { + alice.httpBackend.when("POST", "/keys/query").respond(200, { failures: {}, device_keys: { "@bob:example.com": BOB_DEVICES, }, }); - bob.httpBackend.when('POST', '/keys/query').respond(200, { + bob.httpBackend.when("POST", "/keys/query").respond(200, { failures: {}, device_keys: { "@alice:example.com": ALICE_DEVICES, @@ -336,41 +325,33 @@ describe("SAS verification", function() { expect(macMethod).toBe("hmac-sha256"); - const bobDevice - = await alice.client.getStoredDevice("@bob:example.com", "Dynabook"); + const bobDevice = await alice.client.getStoredDevice("@bob:example.com", "Dynabook"); expect(bobDevice?.isVerified()).toBeTruthy(); - const aliceDevice - = await bob.client.getStoredDevice("@alice:example.com", "Osborne2"); + const aliceDevice = await bob.client.getStoredDevice("@alice:example.com", "Osborne2"); expect(aliceDevice?.isVerified()).toBeTruthy(); }); it("should verify a cross-signing key", async () => { - alice.httpBackend.when('POST', '/keys/device_signing/upload').respond( - 200, {}, - ); - alice.httpBackend.when('POST', '/keys/signatures/upload').respond(200, {}); + alice.httpBackend.when("POST", "/keys/device_signing/upload").respond(200, {}); + alice.httpBackend.when("POST", "/keys/signatures/upload").respond(200, {}); alice.httpBackend.flush(undefined, 2); await resetCrossSigningKeys(alice.client); - bob.httpBackend.when('POST', '/keys/device_signing/upload').respond(200, {}); - bob.httpBackend.when('POST', '/keys/signatures/upload').respond(200, {}); + bob.httpBackend.when("POST", "/keys/device_signing/upload").respond(200, {}); + bob.httpBackend.when("POST", "/keys/signatures/upload").respond(200, {}); bob.httpBackend.flush(undefined, 2); await resetCrossSigningKeys(bob.client); - bob.client.crypto!.deviceList.storeCrossSigningForUser( - "@alice:example.com", { - keys: alice.client.crypto!.crossSigningInfo.keys, - crossSigningVerifiedBefore: false, - firstUse: true, - }, - ); + bob.client.crypto!.deviceList.storeCrossSigningForUser("@alice:example.com", { + keys: alice.client.crypto!.crossSigningInfo.keys, + crossSigningVerifiedBefore: false, + firstUse: true, + }); const verifyProm = Promise.all([ aliceVerifier.verify(), bobPromise.then((verifier) => { - bob.httpBackend.when( - 'POST', '/keys/signatures/upload', - ).respond(200, {}); + bob.httpBackend.when("POST", "/keys/signatures/upload").respond(200, {}); bob.httpBackend.flush(undefined, 1, 2000); return verifier.verify(); }), @@ -378,9 +359,7 @@ describe("SAS verification", function() { await verifyProm; - const bobDeviceTrust = alice.client.checkDeviceTrust( - "@bob:example.com", "Dynabook", - ); + const bobDeviceTrust = alice.client.checkDeviceTrust("@bob:example.com", "Dynabook"); expect(bobDeviceTrust.isLocallyVerified()).toBeTruthy(); expect(bobDeviceTrust.isCrossSigningVerified()).toBeFalsy(); @@ -388,15 +367,13 @@ describe("SAS verification", function() { expect(aliceTrust.isCrossSigningVerified()).toBeTruthy(); expect(aliceTrust.isTofu()).toBeTruthy(); - const aliceDeviceTrust = bob.client.checkDeviceTrust( - "@alice:example.com", "Osborne2", - ); + const aliceDeviceTrust = bob.client.checkDeviceTrust("@alice:example.com", "Osborne2"); expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy(); expect(aliceDeviceTrust.isCrossSigningVerified()).toBeFalsy(); }); }); - it("should send a cancellation message on error", async function() { + it("should send a cancellation message on error", async function () { const [[alice, bob], clearTestClientTimeouts] = await makeTestClients( [ { userId: "@alice:example.com", deviceId: "Osborne2" }, @@ -412,8 +389,8 @@ describe("SAS verification", function() { bob.client.downloadKeys = jest.fn().mockResolvedValue({}); const bobPromise = new Promise>((resolve, reject) => { - bob.client.on(CryptoEvent.VerificationRequest, request => { - request.verifier!.on("show_sas", (e) => { + bob.client.on(CryptoEvent.VerificationRequest, (request) => { + (request.verifier!).on(SasEvent.ShowSas, (e) => { e.mismatch(); }); resolve(request.verifier!); @@ -421,7 +398,9 @@ 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(); @@ -432,26 +411,24 @@ describe("SAS verification", function() { ]); expect(aliceSpy).toHaveBeenCalled(); expect(bobSpy).toHaveBeenCalled(); - expect(alice.client.setDeviceVerified) - .not.toHaveBeenCalled(); - expect(bob.client.setDeviceVerified) - .not.toHaveBeenCalled(); + expect(alice.client.setDeviceVerified).not.toHaveBeenCalled(); + expect(bob.client.setDeviceVerified).not.toHaveBeenCalled(); alice.stop(); bob.stop(); clearTestClientTimeouts(); }); - describe("verification in DM", function() { - let alice; - let bob; - let aliceSasEvent; - let bobSasEvent; - let aliceVerifier; - let bobPromise; - let clearTestClientTimeouts; + describe("verification in DM", function () { + let alice: TestClient; + let bob: TestClient; + let aliceSasEvent: ISasEvent | null; + let bobSasEvent: ISasEvent | null; + let aliceVerifier: SAS; + let bobPromise: Promise; + let clearTestClientTimeouts: Function; - beforeEach(async function() { + beforeEach(async function () { [[alice, bob], clearTestClientTimeouts] = await makeTestClients( [ { userId: "@alice:example.com", deviceId: "Osborne2" }, @@ -477,7 +454,7 @@ describe("SAS verification", function() { ); }; alice.client.downloadKeys = () => { - return Promise.resolve(); + return Promise.resolve({}); }; bob.client.crypto!.setDeviceVerification = jest.fn(); @@ -495,16 +472,16 @@ describe("SAS verification", function() { return "bob+base64+ed25519+key"; }; bob.client.downloadKeys = () => { - return Promise.resolve(); + return Promise.resolve({}); }; aliceSasEvent = null; bobSasEvent = null; bobPromise = new Promise((resolve, reject) => { - bob.client.on("crypto.verification.request", async (request) => { - const verifier = request.beginKeyVerification(SAS.NAME); - verifier.on("show_sas", (e) => { + bob.client.on(CryptoEvent.VerificationRequest, async (request) => { + const verifier = request.beginKeyVerification(SAS.NAME) as SAS; + verifier.on(SasEvent.ShowSas, (e) => { if (!e.sas.emoji || !e.sas.decimal) { e.cancel(); } else if (!aliceSasEvent) { @@ -525,12 +502,10 @@ describe("SAS verification", function() { }); }); - const aliceRequest = await alice.client.requestVerificationDM( - bob.client.getUserId(), "!room_id", - ); - await aliceRequest.waitFor(r => r.started); - aliceVerifier = aliceRequest.verifier; - aliceVerifier.on("show_sas", (e) => { + const aliceRequest = await alice.client.requestVerificationDM(bob.client.getUserId()!, "!room_id"); + await aliceRequest.waitFor((r) => r.started); + aliceVerifier = aliceRequest.verifier! as SAS; + aliceVerifier.on(SasEvent.ShowSas, (e) => { if (!e.sas.emoji || !e.sas.decimal) { e.cancel(); } else if (!bobSasEvent) { @@ -547,40 +522,32 @@ describe("SAS verification", function() { } }); }); - afterEach(async function() { - await Promise.all([ - alice.stop(), - bob.stop(), - ]); + afterEach(async function () { + await Promise.all([alice.stop(), bob.stop()]); clearTestClientTimeouts(); }); - it("should verify a key", async function() { - await Promise.all([ - aliceVerifier.verify(), - bobPromise, - ]); + it("should verify a key", async function () { + await Promise.all([aliceVerifier.verify(), bobPromise]); // make sure Alice and Bob verified each other - 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" }, - ); + 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.ts b/spec/unit/crypto/verification/secret_request.spec.ts index 2f9fafb5dfc..ff8b15cc629 100644 --- a/spec/unit/crypto/verification/secret_request.spec.ts +++ b/spec/unit/crypto/verification/secret_request.spec.ts @@ -14,28 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -import '../../../olm-loader'; -import { MatrixClient, MatrixEvent } from '../../../../src/matrix'; +import "../../../olm-loader"; +import { MatrixClient, MatrixEvent } from "../../../../src/matrix"; import { encodeBase64 } from "../../../../src/crypto/olmlib"; import "../../../../src/crypto"; // import this to cycle-break -import { CrossSigningInfo } from '../../../../src/crypto/CrossSigning'; -import { VerificationRequest } from '../../../../src/crypto/verification/request/VerificationRequest'; -import { IVerificationChannel } from '../../../../src/crypto/verification/request/Channel'; -import { VerificationBase } from '../../../../src/crypto/verification/Base'; +import { CrossSigningInfo } from "../../../../src/crypto/CrossSigning"; +import { VerificationRequest } from "../../../../src/crypto/verification/request/VerificationRequest"; +import { IVerificationChannel } from "../../../../src/crypto/verification/request/Channel"; +import { VerificationBase } from "../../../../src/crypto/verification/Base"; jest.useFakeTimers(); // Private key for tests only const testKey = new Uint8Array([ - 0xda, 0x5a, 0x27, 0x60, 0xe3, 0x3a, 0xc5, 0x82, - 0x9d, 0x12, 0xc3, 0xbe, 0xe8, 0xaa, 0xc2, 0xef, - 0xae, 0xb1, 0x05, 0xc1, 0xe7, 0x62, 0x78, 0xa6, - 0xd7, 0x1f, 0xf8, 0x2c, 0x51, 0x85, 0xf0, 0x1d, + 0xda, 0x5a, 0x27, 0x60, 0xe3, 0x3a, 0xc5, 0x82, 0x9d, 0x12, 0xc3, 0xbe, 0xe8, 0xaa, 0xc2, 0xef, 0xae, 0xb1, 0x05, + 0xc1, 0xe7, 0x62, 0x78, 0xa6, 0xd7, 0x1f, 0xf8, 0x2c, 0x51, 0x85, 0xf0, 0x1d, ]); const testKeyPub = "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"; describe("self-verifications", () => { - beforeAll(function() { + beforeAll(function () { return global.Olm.init(); }); @@ -47,26 +45,22 @@ describe("self-verifications", () => { storeCrossSigningKeyCache: jest.fn(), }; - const crossSigningInfo = new CrossSigningInfo( - userId, - {}, - cacheCallbacks, - ); + const crossSigningInfo = new CrossSigningInfo(userId, {}, cacheCallbacks); crossSigningInfo.keys = { master: { keys: { X: testKeyPub }, usage: [], - user_id: 'user-id', + user_id: "user-id", }, self_signing: { keys: { X: testKeyPub }, usage: [], - user_id: 'user-id', + user_id: "user-id", }, user_signing: { keys: { X: testKeyPub }, usage: [], - user_id: 'user-id', + user_id: "user-id", }, }; @@ -114,18 +108,15 @@ describe("self-verifications", () => { expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls.length).toBe(3); expect(secretStorage.request.mock.calls.length).toBe(4); - expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[0][1]) - .toEqual(testKey); - expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[1][1]) - .toEqual(testKey); + expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[0][1]).toEqual(testKey); + expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[1][1]).toEqual(testKey); - expect(storeSessionBackupPrivateKey.mock.calls[0][0]) - .toEqual(testKey); + expect(storeSessionBackupPrivateKey.mock.calls[0][0]).toEqual(testKey); expect(restoreKeyBackupWithCache).toHaveBeenCalled(); expect(result).toBeInstanceOf(Array); - expect(result[0][0]).toBe(testKeyPub); - expect(result[1][0]).toBe(testKeyPub); + expect(result![0][0]).toBe(testKeyPub); + expect(result![1][0]).toBe(testKeyPub); }); }); diff --git a/spec/unit/crypto/verification/setDeviceVerification.spec.ts b/spec/unit/crypto/verification/setDeviceVerification.spec.ts new file mode 100644 index 00000000000..e1c07222663 --- /dev/null +++ b/spec/unit/crypto/verification/setDeviceVerification.spec.ts @@ -0,0 +1,56 @@ +/* +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 "../../../olm-loader"; + +import { CRYPTO_ENABLED, MatrixClient } from "../../../../src/client"; +import { TestClient } from "../../../TestClient"; + +const Olm = global.Olm; + +describe("crypto.setDeviceVerification", () => { + const userId = "@alice:example.com"; + const deviceId1 = "device1"; + let client: MatrixClient; + + if (!CRYPTO_ENABLED) { + return; + } + + beforeAll(async () => { + await Olm.init(); + }); + + beforeEach(async () => { + client = new TestClient(userId, deviceId1).client; + await client.initCrypto(); + }); + + it("client should provide crypto", () => { + expect(client.crypto).not.toBeUndefined(); + }); + + describe("when setting an own device as verified", () => { + beforeEach(async () => { + jest.spyOn(client.crypto!, "cancelAndResendAllOutgoingKeyRequests"); + await client.crypto!.setDeviceVerification(userId, deviceId1, true); + }); + + it("cancelAndResendAllOutgoingKeyRequests should be called", () => { + expect(client.crypto!.cancelAndResendAllOutgoingKeyRequests).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/unit/crypto/verification/util.ts b/spec/unit/crypto/verification/util.ts index 5efbe4ed5bd..8b478660c1f 100644 --- a/spec/unit/crypto/verification/util.ts +++ b/spec/unit/crypto/verification/util.ts @@ -15,43 +15,51 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { TestClient } from '../../../TestClient'; -import { MatrixEvent } from "../../../../src/models/event"; +import { TestClient } from "../../../TestClient"; +import { IContent, 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'; +import { logger } from "../../../../src/logger"; +import { MatrixClient, ClientEvent, ICreateClientOpts } from "../../../../src/client"; -export async function makeTestClients(userInfos, options): Promise<[TestClient[], () => void]> { +interface UserInfo { + userId: string; + deviceId: string; +} + +export async function makeTestClients( + userInfos: UserInfo[], + options: Partial, +): 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: matrixClient.getUserId()!, - type: type, - content: msg, - }); - const client = clientMap[userId][deviceId]; - const decryptionPromise = event.isEncrypted() ? - event.attemptDecryption(client.crypto!) : - Promise.resolve(); + 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: matrixClient.getUserId()!, + type: type, + content: msg, + }); + const client = clientMap[userId][deviceId]; + const decryptionPromise = event.isEncrypted() + ? event.attemptDecryption(client.crypto!) + : Promise.resolve(); - decryptionPromise.then( - () => client.emit(ClientEvent.ToDeviceEvent, event), - ); + decryptionPromise.then(() => client.emit(ClientEvent.ToDeviceEvent, event)); + } } } } - } - return {}; - }; - const makeSendEvent = (matrixClient: MatrixClient) => (room, type, content) => { + return {}; + }; + const makeSendEvent = (matrixClient: MatrixClient) => (room: string, type: string, content: IContent) => { // make up a unique ID as the event ID const eventId = "$" + matrixClient.makeTxnId(); const rawEvent = { @@ -63,15 +71,17 @@ export async function makeTestClients(userInfos, options): Promise<[TestClient[] origin_server_ts: Date.now(), }; const event = new MatrixEvent(rawEvent); - const remoteEcho = new MatrixEvent(Object.assign({}, rawEvent, { - unsigned: { - transaction_id: matrixClient.makeTxnId(), - }, - })); + const remoteEcho = new MatrixEvent( + Object.assign({}, rawEvent, { + unsigned: { + transaction_id: matrixClient.makeTxnId(), + }, + }), + ); const timeout = setTimeout(() => { for (const tc of clients) { - const room = new Room('test', tc.client, tc.client.getUserId()!); + 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!!"); @@ -88,22 +98,23 @@ export async function makeTestClients(userInfos, options): Promise<[TestClient[] }; for (const userInfo of userInfos) { - let keys = {}; + let keys: Record = {}; if (!options) options = {}; if (!options.cryptoCallbacks) options.cryptoCallbacks = {}; if (!options.cryptoCallbacks.saveCrossSigningKeys) { - options.cryptoCallbacks.saveCrossSigningKeys = k => { keys = k; }; - options.cryptoCallbacks.getCrossSigningKey = typ => keys[typ]; + options.cryptoCallbacks.saveCrossSigningKeys = (k) => { + keys = k; + }; + // @ts-ignore tsc getting confused by overloads + options.cryptoCallbacks.getCrossSigningKey = (typ) => keys[typ]; } - const testClient = new TestClient( - userInfo.userId, userInfo.deviceId, undefined, undefined, - options, - ); + const testClient = new TestClient(userInfo.userId, userInfo.deviceId, undefined, undefined, options); if (!(userInfo.userId in clientMap)) { clientMap[userInfo.userId] = {}; } clientMap[userInfo.userId][userInfo.deviceId] = testClient.client; testClient.client.sendToDevice = makeSendToDevice(testClient.client); + // @ts-ignore tsc getting confused by overloads testClient.client.sendEvent = makeSendEvent(testClient.client); clients.push(testClient); } diff --git a/spec/unit/crypto/verification/verification_request.spec.ts b/spec/unit/crypto/verification/verification_request.spec.ts index 6549c3af78a..ea6919216a3 100644 --- a/spec/unit/crypto/verification/verification_request.spec.ts +++ b/spec/unit/crypto/verification/verification_request.spec.ts @@ -13,12 +13,15 @@ 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 { VerificationRequest, READY_TYPE, START_TYPE, DONE_TYPE } from - "../../../../src/crypto/verification/request/VerificationRequest"; +import { + VerificationRequest, + READY_TYPE, + START_TYPE, + DONE_TYPE, +} from "../../../../src/crypto/verification/request/VerificationRequest"; import { InRoomChannel } from "../../../../src/crypto/verification/request/InRoomChannel"; -import { ToDeviceChannel } from - "../../../../src/crypto/verification/request/ToDeviceChannel"; -import { MatrixEvent } from "../../../../src/models/event"; +import { ToDeviceChannel } from "../../../../src/crypto/verification/request/ToDeviceChannel"; +import { IContent, MatrixEvent } from "../../../../src/models/event"; import { MatrixClient } from "../../../../src/client"; import { IVerificationChannel } from "../../../../src/crypto/verification/request/Channel"; import { VerificationBase } from "../../../../src/crypto/verification/Base"; @@ -30,26 +33,32 @@ type MockClient = MatrixClient & { function makeMockClient(userId: string, deviceId: string): MockClient { let counter = 1; let events: MatrixEvent[] = []; - const deviceEvents = {}; + const deviceEvents: Record> = {}; return { - getUserId() { return userId; }, - getDeviceId() { return deviceId; }, + getUserId() { + return userId; + }, + getDeviceId() { + return deviceId; + }, - sendEvent(roomId, type, content) { + sendEvent(roomId: string, type: string, content: IContent) { counter = counter + 1; const eventId = `$${userId}-${deviceId}-${counter}`; - events.push(new MatrixEvent({ - sender: userId, - event_id: eventId, - room_id: roomId, - type, - content, - origin_server_ts: Date.now(), - })); + events.push( + new MatrixEvent({ + sender: userId, + event_id: eventId, + room_id: roomId, + type, + content, + origin_server_ts: Date.now(), + }), + ); return Promise.resolve({ event_id: eventId }); }, - sendToDevice(type, msgMap) { + sendToDevice(type: string, msgMap: Record>) { for (const userId of Object.keys(msgMap)) { const deviceMap = msgMap[userId]; for (const deviceId of Object.keys(deviceMap)) { @@ -84,7 +93,7 @@ function makeMockClient(userId: string, deviceId: string): MockClient { } const MOCK_METHOD = "mock-verify"; -class MockVerifier extends VerificationBase<'', any> { +class MockVerifier extends VerificationBase<"", any> { public _channel; public _startEvent; constructor( @@ -111,7 +120,7 @@ class MockVerifier extends VerificationBase<'', any> { } } - async handleEvent(event) { + async handleEvent(event: MatrixEvent) { if (event.getType() === DONE_TYPE && !this._startEvent) { await this._channel.send(DONE_TYPE, {}); } @@ -122,12 +131,14 @@ class MockVerifier extends VerificationBase<'', any> { } } -function makeRemoteEcho(event) { - return new MatrixEvent(Object.assign({}, event.event, { - unsigned: { - transaction_id: "abc", - }, - })); +function makeRemoteEcho(event: MatrixEvent) { + return new MatrixEvent( + Object.assign({}, event.event, { + unsigned: { + transaction_id: "abc", + }, + }), + ); } async function distributeEvent( @@ -135,33 +146,26 @@ async function distributeEvent( theirRequest: VerificationRequest, event: MatrixEvent, ): Promise { - await ownRequest.channel.handleEvent( - makeRemoteEcho(event), - ownRequest, - true, - ); + await ownRequest.channel.handleEvent(makeRemoteEcho(event), ownRequest, true); await theirRequest.channel.handleEvent(event, theirRequest, true); } jest.useFakeTimers(); -describe("verification request unit tests", function() { - it("transition from UNSENT to DONE through happy path", async function() { +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 verificationMethods = new Map([[MOCK_METHOD, MockVerifier]]) as unknown as Map< + string, + typeof VerificationBase + >; const aliceRequest = new VerificationRequest( new InRoomChannel(alice, "!room", bob.getUserId()!), verificationMethods, alice, ); - const bobRequest = new VerificationRequest( - new InRoomChannel(bob, "!room"), - verificationMethods, - bob, - ); + const bobRequest = new VerificationRequest(new InRoomChannel(bob, "!room"), verificationMethods, bob); expect(aliceRequest.invalid).toBe(true); expect(bobRequest.invalid).toBe(true); @@ -199,23 +203,23 @@ describe("verification request unit tests", function() { expect(bobRequest.done).toBe(true); }); - it("methods only contains common methods", async 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 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()!), - aliceVerificationMethods, alice); - const bobRequest = new VerificationRequest( - new InRoomChannel(bob, "!room"), - bobVerificationMethods, - bob, + aliceVerificationMethods, + alice, ); + const bobRequest = new VerificationRequest(new InRoomChannel(bob, "!room"), bobVerificationMethods, bob); await aliceRequest.sendRequest(); const [requestEvent] = alice.popEvents(); await distributeEvent(aliceRequest, bobRequest, requestEvent); @@ -226,7 +230,7 @@ describe("verification request unit tests", function() { expect(bobRequest.methods).toStrictEqual(["c"]); }); - it("other client accepting request puts it in observeOnly mode", async function() { + it("other client accepting request puts it in observeOnly mode", async function () { const alice = makeMockClient("@alice:matrix.tld", "device1"); const bob1 = makeMockClient("@bob:matrix.tld", "device1"); const bob2 = makeMockClient("@bob:matrix.tld", "device2"); @@ -237,16 +241,8 @@ describe("verification request unit tests", function() { ); await aliceRequest.sendRequest(); const [requestEvent] = alice.popEvents(); - const bob1Request = new VerificationRequest( - new InRoomChannel(bob1, "!room"), - new Map(), - bob1, - ); - const bob2Request = new VerificationRequest( - new InRoomChannel(bob2, "!room"), - new Map(), - bob2, - ); + const bob1Request = new VerificationRequest(new InRoomChannel(bob1, "!room"), new Map(), bob1); + const bob2Request = new VerificationRequest(new InRoomChannel(bob2, "!room"), new Map(), bob2); await bob1Request.channel.handleEvent(requestEvent, bob1Request, true); await bob2Request.channel.handleEvent(requestEvent, bob2Request, true); @@ -258,12 +254,13 @@ describe("verification request unit tests", function() { expect(bob2Request.observeOnly).toBe(true); }); - it("verify own device with to_device messages", async 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 verificationMethods = new Map([[MOCK_METHOD, MockVerifier]]) as unknown as Map< + string, + typeof VerificationBase + >; const bob1Request = new VerificationRequest( new ToDeviceChannel( bob1, @@ -300,7 +297,7 @@ describe("verification request unit tests", function() { expect(bob2Request.done).toBe(true); }); - it("request times out after 10 minutes", async function() { + it("request times out after 10 minutes", async function () { const alice = makeMockClient("@alice:matrix.tld", "device1"); const bob = makeMockClient("@bob:matrix.tld", "device1"); const aliceRequest = new VerificationRequest( @@ -318,7 +315,7 @@ describe("verification request unit tests", function() { expect(aliceRequest._cancellingUserId).toBe(alice.getUserId()); }); - it("request times out 2 minutes after receipt", async function() { + it("request times out 2 minutes after receipt", async function () { const alice = makeMockClient("@alice:matrix.tld", "device1"); const bob = makeMockClient("@bob:matrix.tld", "device1"); const aliceRequest = new VerificationRequest( @@ -328,11 +325,7 @@ describe("verification request unit tests", function() { ); await aliceRequest.sendRequest(); const [requestEvent] = alice.popEvents(); - const bobRequest = new VerificationRequest( - new InRoomChannel(bob, "!room"), - new Map(), - bob, - ); + const bobRequest = new VerificationRequest(new InRoomChannel(bob, "!room"), new Map(), bob); await bobRequest.channel.handleEvent(requestEvent, bobRequest, true); diff --git a/spec/unit/embedded.spec.ts b/spec/unit/embedded.spec.ts index 3e0ead87169..ef5215e20f0 100644 --- a/spec/unit/embedded.spec.ts +++ b/spec/unit/embedded.spec.ts @@ -23,13 +23,7 @@ limitations under the License. // eslint-disable-next-line no-restricted-imports import { EventEmitter } from "events"; import { MockedObject } from "jest-mock"; -import { - WidgetApi, - WidgetApiToWidgetAction, - MatrixCapabilities, - ITurnServer, - IRoomEvent, -} from "matrix-widget-api"; +import { WidgetApi, WidgetApiToWidgetAction, MatrixCapabilities, ITurnServer, IRoomEvent } from "matrix-widget-api"; import { createRoomWidgetClient, MsgType } from "../../src/matrix"; import { MatrixClient, ClientEvent, ITurnServer as IClientTurnServer } from "../../src/client"; @@ -88,7 +82,9 @@ describe("RoomWidgetClient", () => { expect(widgetApi.requestCapabilityToSendEvent).toHaveBeenCalledWith("org.matrix.rageshake_request"); await client.sendEvent("!1:example.org", "org.matrix.rageshake_request", { request_id: 123 }); expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith( - "org.matrix.rageshake_request", { request_id: 123 }, "!1:example.org", + "org.matrix.rageshake_request", + { request_id: 123 }, + "!1:example.org", ); }); @@ -105,8 +101,8 @@ describe("RoomWidgetClient", () => { expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org"); expect(widgetApi.requestCapabilityToReceiveEvent).toHaveBeenCalledWith("org.matrix.rageshake_request"); - const emittedEvent = new Promise(resolve => client.once(ClientEvent.Event, resolve)); - const emittedSync = new Promise(resolve => client.once(ClientEvent.Sync, resolve)); + const emittedEvent = new Promise((resolve) => client.once(ClientEvent.Event, resolve)); + const emittedSync = new Promise((resolve) => client.once(ClientEvent.Sync, resolve)); widgetApi.emit( `action:${WidgetApiToWidgetAction.SendEvent}`, new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }), @@ -118,7 +114,12 @@ describe("RoomWidgetClient", () => { // It should've also inserted the event into the room object const room = client.getRoom("!1:example.org"); expect(room).not.toBeNull(); - expect(room!.getLiveTimeline().getEvents().map(e => e.getEffectiveEvent())).toEqual([event]); + expect( + room! + .getLiveTimeline() + .getEvents() + .map((e) => e.getEffectiveEvent()), + ).toEqual([event]); }); }); @@ -157,7 +158,10 @@ describe("RoomWidgetClient", () => { expect(widgetApi.requestCapabilityToSendState).toHaveBeenCalledWith("org.example.foo", "bar"); await client.sendStateEvent("!1:example.org", "org.example.foo", { hello: "world" }, "bar"); expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( - "org.example.foo", "bar", { hello: "world" }, "!1:example.org", + "org.example.foo", + "bar", + { hello: "world" }, + "!1:example.org", ); }); @@ -166,8 +170,8 @@ describe("RoomWidgetClient", () => { expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org"); expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar"); - const emittedEvent = new Promise(resolve => client.once(ClientEvent.Event, resolve)); - const emittedSync = new Promise(resolve => client.once(ClientEvent.Sync, resolve)); + const emittedEvent = new Promise((resolve) => client.once(ClientEvent.Event, resolve)); + const emittedSync = new Promise((resolve) => client.once(ClientEvent.Sync, resolve)); widgetApi.emit( `action:${WidgetApiToWidgetAction.SendEvent}`, new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }), @@ -260,8 +264,8 @@ describe("RoomWidgetClient", () => { content: { hello: "world" }, }; - const emittedEvent = new Promise(resolve => client.once(ClientEvent.ToDeviceEvent, resolve)); - const emittedSync = new Promise(resolve => client.once(ClientEvent.Sync, resolve)); + const emittedEvent = new Promise((resolve) => client.once(ClientEvent.ToDeviceEvent, resolve)); + const emittedSync = new Promise((resolve) => client.once(ClientEvent.Sync, resolve)); widgetApi.emit( `action:${WidgetApiToWidgetAction.SendToDevice}`, new CustomEvent(`action:${WidgetApiToWidgetAction.SendToDevice}`, { detail: { data: event } }), @@ -308,7 +312,7 @@ describe("RoomWidgetClient", () => { }; let emitServer2: () => void; - const getServer2 = new Promise(resolve => emitServer2 = () => resolve(server2)); + const getServer2 = new Promise((resolve) => (emitServer2 = () => resolve(server2))); widgetApi.getTurnServers.mockImplementation(async function* () { yield server1; yield await getServer2; @@ -321,7 +325,7 @@ describe("RoomWidgetClient", () => { expect(client.getTurnServers()).toEqual([clientServer1]); // Subsequent servers arrive asynchronously and should emit an event - const emittedServer = new Promise(resolve => + const emittedServer = new Promise((resolve) => client.once(ClientEvent.TurnServers, resolve), ); emitServer2!(); diff --git a/spec/unit/event-mapper.spec.ts b/spec/unit/event-mapper.spec.ts index 0dc43fc7a8b..4b1f4cbaeea 100644 --- a/spec/unit/event-mapper.spec.ts +++ b/spec/unit/event-mapper.spec.ts @@ -18,7 +18,7 @@ import { MatrixClient, MatrixEvent, MatrixEventEvent, MatrixScheduler, Room } fr import { eventMapperFor } from "../../src/event-mapper"; import { IStore } from "../../src/store"; -describe("eventMapperFor", function() { +describe("eventMapperFor", function () { let rooms: Room[] = []; const userId = "@test:example.org"; @@ -29,10 +29,10 @@ describe("eventMapperFor", function() { client = new MatrixClient({ baseUrl: "https://my.home.server", accessToken: "my.access.token", - fetchFn: function() {} as any, // NOP + fetchFn: function () {} as any, // NOP store: { getRoom(roomId: string): Room | null { - return rooms.find(r => r.roomId === roomId) ?? null; + return rooms.find((r) => r.roomId === roomId) ?? null; }, } as IStore, scheduler: { diff --git a/spec/unit/event-timeline-set.spec.ts b/spec/unit/event-timeline-set.spec.ts index 2bc068af660..2831b6ca608 100644 --- a/spec/unit/event-timeline-set.spec.ts +++ b/spec/unit/event-timeline-set.spec.ts @@ -25,12 +25,12 @@ import { MatrixEvent, MatrixEventEvent, Room, -} from '../../src'; +} from "../../src"; import { Thread } from "../../src/models/thread"; import { ReEmitter } from "../../src/ReEmitter"; -describe('EventTimelineSet', () => { - const roomId = '!foo:bar'; +describe("EventTimelineSet", () => { + const roomId = "!foo:bar"; const userA = "@alice:bar"; let room: Room; @@ -42,7 +42,7 @@ describe('EventTimelineSet', () => { let replyEvent: MatrixEvent; const itShouldReturnTheRelatedEvents = () => { - it('should return the related events', () => { + it("should return the related events", () => { eventTimelineSet.relations.aggregateChildEvent(messageEvent); const relations = eventTimelineSet.relations.getChildEventsForEvent( messageEvent.getId()!, @@ -55,45 +55,49 @@ describe('EventTimelineSet', () => { }); }; - const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ - event: true, - type: EventType.RoomMessage, - user: userA, - room: roomId, - content: { - "body": "Thread response :: " + Math.random(), - "m.relates_to": { - "event_id": root.getId(), - "m.in_reply_to": { - "event_id": root.getId(), + const mkThreadResponse = (root: MatrixEvent) => + utils.mkEvent( + { + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "Thread response :: " + Math.random(), + "m.relates_to": { + "event_id": root.getId(), + "m.in_reply_to": { + event_id: root.getId(), + }, + "rel_type": "m.thread", + }, }, - "rel_type": "m.thread", }, - }, - }, room.client); + room.client, + ); beforeEach(() => { - client = utils.mock(MatrixClient, 'MatrixClient'); - client.reEmitter = utils.mock(ReEmitter, 'ReEmitter'); + client = utils.mock(MatrixClient, "MatrixClient"); + client.reEmitter = utils.mock(ReEmitter, "ReEmitter"); room = new Room(roomId, client, userA); eventTimelineSet = new EventTimelineSet(room); eventTimeline = new EventTimeline(eventTimelineSet); messageEvent = utils.mkMessage({ room: roomId, user: userA, - msg: 'Hi!', + msg: "Hi!", event: true, }); replyEvent = utils.mkReplyMessage({ room: roomId, user: userA, - msg: 'Hoo!', + msg: "Hoo!", event: true, replyToMessage: messageEvent, }); }); - describe('addLiveEvent', () => { + describe("addLiveEvent", () => { it("Adds event to the live timeline in the timeline set", () => { const liveTimeline = eventTimelineSet.getLiveTimeline(); expect(liveTimeline.getEvents().length).toStrictEqual(0); @@ -111,7 +115,10 @@ describe('EventTimelineSet', () => { // make a duplicate const duplicateMessageEvent = utils.mkMessage({ - room: roomId, user: userA, msg: "dupe", event: true, + room: roomId, + user: userA, + msg: "dupe", + event: true, }); duplicateMessageEvent.event.event_id = messageEvent.getId(); @@ -133,7 +140,7 @@ describe('EventTimelineSet', () => { }); }); - describe('addEventToTimeline', () => { + describe("addEventToTimeline", () => { let thread: Thread; beforeEach(() => { @@ -153,19 +160,10 @@ describe('EventTimelineSet', () => { it("Make sure legacy overload passing options directly as parameters still works", () => { const liveTimeline = eventTimelineSet.getLiveTimeline(); expect(() => { - eventTimelineSet.addEventToTimeline( - messageEvent, - liveTimeline, - true, - ); + eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, true); }).not.toThrow(); expect(() => { - eventTimelineSet.addEventToTimeline( - messageEvent, - liveTimeline, - true, - false, - ); + eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, true, false); }).not.toThrow(); }); @@ -204,8 +202,8 @@ describe('EventTimelineSet', () => { expect(liveTimeline.getEvents().length).toStrictEqual(0); }); - describe('non-room timeline', () => { - it('Adds event to timeline', () => { + describe("non-room timeline", () => { + it("Adds event to timeline", () => { const nonRoomEventTimelineSet = new EventTimelineSet( // This is what we're specifically testing against, a timeline // without a `room` defined @@ -222,24 +220,16 @@ describe('EventTimelineSet', () => { }); }); - describe('aggregateRelations', () => { - describe('with unencrypted events', () => { + describe("aggregateRelations", () => { + describe("with unencrypted events", () => { beforeEach(() => { - eventTimelineSet.addEventsToTimeline( - [ - messageEvent, - replyEvent, - ], - true, - eventTimeline, - 'foo', - ); + eventTimelineSet.addEventsToTimeline([messageEvent, replyEvent], true, eventTimeline, "foo"); }); itShouldReturnTheRelatedEvents(); }); - describe('with events to be decrypted', () => { + describe("with events to be decrypted", () => { let messageEventShouldAttemptDecryptionSpy: jest.SpyInstance; let messageEventIsDecryptionFailureSpy: jest.SpyInstance; @@ -247,26 +237,18 @@ describe('EventTimelineSet', () => { let replyEventIsDecryptionFailureSpy: jest.SpyInstance; beforeEach(() => { - messageEventShouldAttemptDecryptionSpy = jest.spyOn(messageEvent, 'shouldAttemptDecryption'); + messageEventShouldAttemptDecryptionSpy = jest.spyOn(messageEvent, "shouldAttemptDecryption"); messageEventShouldAttemptDecryptionSpy.mockReturnValue(true); - messageEventIsDecryptionFailureSpy = jest.spyOn(messageEvent, 'isDecryptionFailure'); + messageEventIsDecryptionFailureSpy = jest.spyOn(messageEvent, "isDecryptionFailure"); - replyEventShouldAttemptDecryptionSpy = jest.spyOn(replyEvent, 'shouldAttemptDecryption'); + replyEventShouldAttemptDecryptionSpy = jest.spyOn(replyEvent, "shouldAttemptDecryption"); replyEventShouldAttemptDecryptionSpy.mockReturnValue(true); - replyEventIsDecryptionFailureSpy = jest.spyOn(messageEvent, 'isDecryptionFailure'); - - eventTimelineSet.addEventsToTimeline( - [ - messageEvent, - replyEvent, - ], - true, - eventTimeline, - 'foo', - ); + replyEventIsDecryptionFailureSpy = jest.spyOn(messageEvent, "isDecryptionFailure"); + + eventTimelineSet.addEventsToTimeline([messageEvent, replyEvent], true, eventTimeline, "foo"); }); - it('should not return the related events', () => { + it("should not return the related events", () => { eventTimelineSet.relations.aggregateChildEvent(messageEvent); const relations = eventTimelineSet.relations.getChildEventsForEvent( messageEvent.getId()!, @@ -276,7 +258,7 @@ describe('EventTimelineSet', () => { expect(relations).toBeUndefined(); }); - describe('after decryption', () => { + describe("after decryption", () => { beforeEach(() => { // simulate decryption failure once messageEventIsDecryptionFailureSpy.mockReturnValue(true); @@ -302,22 +284,26 @@ describe('EventTimelineSet', () => { }); describe("canContain", () => { - const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ - event: true, - type: EventType.RoomMessage, - user: userA, - room: roomId, - content: { - "body": "Thread response :: " + Math.random(), - "m.relates_to": { - "event_id": root.getId(), - "m.in_reply_to": { - "event_id": root.getId()!, + const mkThreadResponse = (root: MatrixEvent) => + utils.mkEvent( + { + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "Thread response :: " + Math.random(), + "m.relates_to": { + "event_id": root.getId(), + "m.in_reply_to": { + event_id: root.getId()!, + }, + "rel_type": "m.thread", + }, }, - "rel_type": "m.thread", }, - }, - }, room.client); + room.client, + ); let thread: Thread; diff --git a/spec/unit/event-timeline.spec.ts b/spec/unit/event-timeline.spec.ts index 052b035a9be..2ac49c49f39 100644 --- a/spec/unit/event-timeline.spec.ts +++ b/spec/unit/event-timeline.spec.ts @@ -1,4 +1,4 @@ -import { mocked } from 'jest-mock'; +import { mocked } from "jest-mock"; import * as utils from "../test-utils/test-utils"; import { Direction, EventTimeline } from "../../src/models/event-timeline"; @@ -8,7 +8,7 @@ import { Room } from "../../src/models/room"; import { RoomMember } from "../../src/models/room-member"; import { EventTimelineSet } from "../../src/models/event-timeline-set"; -describe("EventTimeline", function() { +describe("EventTimeline", function () { const roomId = "!foo:bar"; const userA = "@alice:bar"; const userB = "@bertha:bar"; @@ -19,7 +19,7 @@ describe("EventTimeline", function() { const getTimeline = (): EventTimeline => { const room = new Room(roomId, mockClient, userA); const timelineSet = new EventTimelineSet(room); - jest.spyOn(room, 'getUnfilteredTimelineSet').mockReturnValue(timelineSet); + jest.spyOn(room, "getUnfilteredTimelineSet").mockReturnValue(timelineSet); const timeline = new EventTimeline(timelineSet); // We manually stub the methods we'll be mocking out later instead of mocking the whole module @@ -31,29 +31,34 @@ describe("EventTimeline", function() { return timeline; }; - beforeEach(function() { + beforeEach(function () { // reset any RoomState mocks jest.resetAllMocks(); timeline = getTimeline(); }); - describe("construction", function() { - it("getRoomId should get room id", function() { + describe("construction", function () { + it("getRoomId should get room id", function () { const v = timeline.getRoomId(); expect(v).toEqual(roomId); }); }); - describe("initialiseState", function() { - it("should copy state events to start and end state", function() { + describe("initialiseState", function () { + it("should copy state events to start and end state", function () { const events = [ utils.mkMembership({ - room: roomId, mship: "invite", user: userB, skey: userA, + room: roomId, + mship: "invite", + user: userB, + skey: userA, event: true, }), utils.mkEvent({ - type: "m.room.name", room: roomId, user: userB, + type: "m.room.name", + room: roomId, + user: userB, event: true, content: { name: "New room" }, }), @@ -61,49 +66,51 @@ describe("EventTimeline", function() { timeline.initialiseState(events); // @ts-ignore private prop const timelineStartState = timeline.startState!; - expect(mocked(timelineStartState).setStateEvents).toHaveBeenCalledWith( - events, - { timelineWasEmpty: undefined }, - ); + expect(mocked(timelineStartState).setStateEvents).toHaveBeenCalledWith(events, { + timelineWasEmpty: undefined, + }); // @ts-ignore private prop const timelineEndState = timeline.endState!; - expect(mocked(timelineEndState).setStateEvents).toHaveBeenCalledWith( - events, - { timelineWasEmpty: undefined }, - ); + expect(mocked(timelineEndState).setStateEvents).toHaveBeenCalledWith(events, { + timelineWasEmpty: undefined, + }); }); - it("should raise an exception if called after events are added", function() { - const event = - utils.mkMessage({ - room: roomId, user: userA, msg: "Adam stole the plushies", - event: true, - }); + it("should raise an exception if called after events are added", function () { + const event = utils.mkMessage({ + room: roomId, + user: userA, + msg: "Adam stole the plushies", + event: true, + }); const state = [ utils.mkMembership({ - room: roomId, mship: "invite", user: userB, skey: userA, + room: roomId, + mship: "invite", + user: userB, + skey: userA, event: true, }), ]; - expect(function() { + expect(function () { timeline.initialiseState(state); }).not.toThrow(); timeline.addEvent(event, { toStartOfTimeline: false }); - expect(function() { + expect(function () { timeline.initialiseState(state); }).toThrow(); }); }); - describe("paginationTokens", function() { - it("pagination tokens should start null", function() { + describe("paginationTokens", function () { + it("pagination tokens should start null", function () { expect(timeline.getPaginationToken(EventTimeline.BACKWARDS)).toBe(null); expect(timeline.getPaginationToken(EventTimeline.FORWARDS)).toBe(null); }); - it("setPaginationToken should set token", function() { + it("setPaginationToken should set token", function () { timeline.setPaginationToken("back", EventTimeline.BACKWARDS); timeline.setPaginationToken("fwd", EventTimeline.FORWARDS); expect(timeline.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("back"); @@ -121,13 +128,13 @@ describe("EventTimeline", function() { }); }); - describe("neighbouringTimelines", function() { - it("neighbouring timelines should start null", function() { + describe("neighbouringTimelines", function () { + it("neighbouring timelines should start null", function () { expect(timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)).toBe(null); expect(timeline.getNeighbouringTimeline(EventTimeline.FORWARDS)).toBe(null); }); - it("setNeighbouringTimeline should set neighbour", function() { + it("setNeighbouringTimeline should set neighbour", function () { const prev = getTimeline(); const next = getTimeline(); timeline.setNeighbouringTimeline(prev, EventTimeline.BACKWARDS); @@ -136,42 +143,44 @@ describe("EventTimeline", function() { expect(timeline.getNeighbouringTimeline(EventTimeline.FORWARDS)).toBe(next); }); - it("setNeighbouringTimeline should throw if called twice", function() { + it("setNeighbouringTimeline should throw if called twice", function () { const prev = getTimeline(); const next = getTimeline(); - expect(function() { + expect(function () { timeline.setNeighbouringTimeline(prev, EventTimeline.BACKWARDS); }).not.toThrow(); - expect(timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)) - .toBe(prev); - expect(function() { + expect(timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)).toBe(prev); + expect(function () { timeline.setNeighbouringTimeline(prev, EventTimeline.BACKWARDS); }).toThrow(); - expect(function() { + expect(function () { timeline.setNeighbouringTimeline(next, EventTimeline.FORWARDS); }).not.toThrow(); - expect(timeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) - .toBe(next); - expect(function() { + expect(timeline.getNeighbouringTimeline(EventTimeline.FORWARDS)).toBe(next); + expect(function () { timeline.setNeighbouringTimeline(next, EventTimeline.FORWARDS); }).toThrow(); }); }); - describe("addEvent", function() { + describe("addEvent", function () { const events = [ utils.mkMessage({ - room: roomId, user: userA, msg: "hungry hungry hungry", + room: roomId, + user: userA, + msg: "hungry hungry hungry", event: true, }), utils.mkMessage({ - room: roomId, user: userB, msg: "nom nom nom", + room: roomId, + user: userB, + msg: "nom nom nom", event: true, }), ]; - it("should be able to add events to the end", function() { + it("should be able to add events to the end", function () { timeline.addEvent(events[0], { toStartOfTimeline: false }); const initialIndex = timeline.getBaseIndex(); timeline.addEvent(events[1], { toStartOfTimeline: false }); @@ -181,7 +190,7 @@ describe("EventTimeline", function() { expect(timeline.getEvents()[1]).toEqual(events[1]); }); - it("should be able to add events to the start", function() { + it("should be able to add events to the start", function () { timeline.addEvent(events[0], { toStartOfTimeline: true }); const initialIndex = timeline.getBaseIndex(); timeline.addEvent(events[1], { toStartOfTimeline: true }); @@ -191,7 +200,7 @@ describe("EventTimeline", function() { expect(timeline.getEvents()[1]).toEqual(events[0]); }); - it("should set event.sender for new and old events", function() { + it("should set event.sender for new and old events", function () { const sentinel = new RoomMember(roomId, userA); sentinel.name = "Alice"; sentinel.membership = "join"; @@ -200,27 +209,31 @@ describe("EventTimeline", function() { sentinel.name = "Old Alice"; sentinel.membership = "join"; - mocked(timeline.getState(EventTimeline.FORWARDS)!).getSentinelMember - .mockImplementation(function(uid) { - if (uid === userA) { - return sentinel; - } - return null; - }); - mocked(timeline.getState(EventTimeline.BACKWARDS)!).getSentinelMember - .mockImplementation(function(uid) { - if (uid === userA) { - return oldSentinel; - } - return null; - }); + mocked(timeline.getState(EventTimeline.FORWARDS)!).getSentinelMember.mockImplementation(function (uid) { + if (uid === userA) { + return sentinel; + } + return null; + }); + mocked(timeline.getState(EventTimeline.BACKWARDS)!).getSentinelMember.mockImplementation(function (uid) { + if (uid === userA) { + return oldSentinel; + } + return null; + }); const newEv = utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, event: true, + type: "m.room.name", + room: roomId, + user: userA, + event: true, content: { name: "New Room Name" }, }); const oldEv = utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, event: true, + type: "m.room.name", + room: roomId, + user: userA, + event: true, content: { name: "Old Room Name" }, }); @@ -230,128 +243,159 @@ describe("EventTimeline", function() { expect(oldEv.sender).toEqual(oldSentinel); }); - it("should set event.target for new and old m.room.member events", - function() { - const sentinel = new RoomMember(roomId, userA); - sentinel.name = "Alice"; - sentinel.membership = "join"; - - const oldSentinel = new RoomMember(roomId, userA); - sentinel.name = "Old Alice"; - sentinel.membership = "join"; - - mocked(timeline.getState(EventTimeline.FORWARDS)!).getSentinelMember - .mockImplementation(function(uid) { - if (uid === userA) { - return sentinel; - } - return null; - }); - mocked(timeline.getState(EventTimeline.BACKWARDS)!).getSentinelMember - .mockImplementation(function(uid) { - if (uid === userA) { - return oldSentinel; - } - return null; - }); - - const newEv = utils.mkMembership({ - room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }); - const oldEv = utils.mkMembership({ - room: roomId, mship: "ban", user: userB, skey: userA, event: true, - }); - timeline.addEvent(newEv, { toStartOfTimeline: false }); - expect(newEv.target).toEqual(sentinel); - timeline.addEvent(oldEv, { toStartOfTimeline: true }); - expect(oldEv.target).toEqual(oldSentinel); - }); + it("should set event.target for new and old m.room.member events", function () { + const sentinel = new RoomMember(roomId, userA); + sentinel.name = "Alice"; + sentinel.membership = "join"; - it("should call setStateEvents on the right RoomState with the right " + - "forwardLooking value for new events", function() { - const events = [ - utils.mkMembership({ - room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }), - utils.mkEvent({ - type: "m.room.name", room: roomId, user: userB, event: true, - content: { - name: "New room", - }, - }), - ]; + const oldSentinel = new RoomMember(roomId, userA); + sentinel.name = "Old Alice"; + sentinel.membership = "join"; - timeline.addEvent(events[0], { toStartOfTimeline: false }); - timeline.addEvent(events[1], { toStartOfTimeline: false }); + mocked(timeline.getState(EventTimeline.FORWARDS)!).getSentinelMember.mockImplementation(function (uid) { + if (uid === userA) { + return sentinel; + } + return null; + }); + mocked(timeline.getState(EventTimeline.BACKWARDS)!).getSentinelMember.mockImplementation(function (uid) { + if (uid === userA) { + return oldSentinel; + } + return null; + }); - expect(timeline.getState(EventTimeline.FORWARDS)!.setStateEvents). - toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined }); - expect(timeline.getState(EventTimeline.FORWARDS)!.setStateEvents). - toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined }); + const newEv = utils.mkMembership({ + room: roomId, + mship: "invite", + user: userB, + skey: userA, + event: true, + }); + const oldEv = utils.mkMembership({ + room: roomId, + mship: "ban", + user: userB, + skey: userA, + event: true, + }); + timeline.addEvent(newEv, { toStartOfTimeline: false }); + expect(newEv.target).toEqual(sentinel); + timeline.addEvent(oldEv, { toStartOfTimeline: true }); + expect(oldEv.target).toEqual(oldSentinel); + }); - expect(events[0].forwardLooking).toBe(true); - expect(events[1].forwardLooking).toBe(true); + it( + "should call setStateEvents on the right RoomState with the right " + "forwardLooking value for new events", + function () { + const events = [ + utils.mkMembership({ + room: roomId, + mship: "invite", + user: userB, + skey: userA, + event: true, + }), + utils.mkEvent({ + type: "m.room.name", + room: roomId, + user: userB, + event: true, + content: { + name: "New room", + }, + }), + ]; + + timeline.addEvent(events[0], { toStartOfTimeline: false }); + timeline.addEvent(events[1], { toStartOfTimeline: false }); - expect(timeline.getState(EventTimeline.BACKWARDS)!.setStateEvents). - not.toHaveBeenCalled(); - }); + expect(timeline.getState(EventTimeline.FORWARDS)!.setStateEvents).toHaveBeenCalledWith([events[0]], { + timelineWasEmpty: undefined, + }); + expect(timeline.getState(EventTimeline.FORWARDS)!.setStateEvents).toHaveBeenCalledWith([events[1]], { + timelineWasEmpty: undefined, + }); - it("should call setStateEvents on the right RoomState with the right " + - "forwardLooking value for old events", function() { - const events = [ - utils.mkMembership({ - room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }), - utils.mkEvent({ - type: "m.room.name", room: roomId, user: userB, event: true, - content: { - name: "New room", - }, - }), - ]; + expect(events[0].forwardLooking).toBe(true); + expect(events[1].forwardLooking).toBe(true); + + expect(timeline.getState(EventTimeline.BACKWARDS)!.setStateEvents).not.toHaveBeenCalled(); + }, + ); + + it( + "should call setStateEvents on the right RoomState with the right " + "forwardLooking value for old events", + function () { + const events = [ + utils.mkMembership({ + room: roomId, + mship: "invite", + user: userB, + skey: userA, + event: true, + }), + utils.mkEvent({ + type: "m.room.name", + room: roomId, + user: userB, + event: true, + content: { + name: "New room", + }, + }), + ]; - timeline.addEvent(events[0], { toStartOfTimeline: true }); - timeline.addEvent(events[1], { toStartOfTimeline: true }); + timeline.addEvent(events[0], { toStartOfTimeline: true }); + timeline.addEvent(events[1], { toStartOfTimeline: true }); - expect(timeline.getState(EventTimeline.BACKWARDS)!.setStateEvents). - toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined }); - expect(timeline.getState(EventTimeline.BACKWARDS)!.setStateEvents). - toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined }); + expect(timeline.getState(EventTimeline.BACKWARDS)!.setStateEvents).toHaveBeenCalledWith([events[0]], { + timelineWasEmpty: undefined, + }); + expect(timeline.getState(EventTimeline.BACKWARDS)!.setStateEvents).toHaveBeenCalledWith([events[1]], { + timelineWasEmpty: undefined, + }); - expect(events[0].forwardLooking).toBe(false); - expect(events[1].forwardLooking).toBe(false); + expect(events[0].forwardLooking).toBe(false); + expect(events[1].forwardLooking).toBe(false); - expect(timeline.getState(EventTimeline.FORWARDS)!.setStateEvents). - not.toHaveBeenCalled(); - }); + expect(timeline.getState(EventTimeline.FORWARDS)!.setStateEvents).not.toHaveBeenCalled(); + }, + ); it("Make sure legacy overload passing options directly as parameters still works", () => { expect(() => timeline.addEvent(events[0], { toStartOfTimeline: true })).not.toThrow(); // @ts-ignore stateContext is not a valid param expect(() => timeline.addEvent(events[0], { stateContext: new RoomState(roomId) })).not.toThrow(); - expect(() => timeline.addEvent(events[0], - { toStartOfTimeline: false, roomState: new RoomState(roomId) }, - )).not.toThrow(); + expect(() => + timeline.addEvent(events[0], { toStartOfTimeline: false, roomState: new RoomState(roomId) }), + ).not.toThrow(); }); }); - describe("removeEvent", function() { + describe("removeEvent", function () { const events = [ utils.mkMessage({ - room: roomId, user: userA, msg: "hungry hungry hungry", + room: roomId, + user: userA, + msg: "hungry hungry hungry", event: true, }), utils.mkMessage({ - room: roomId, user: userB, msg: "nom nom nom", + room: roomId, + user: userB, + msg: "nom nom nom", event: true, }), utils.mkMessage({ - room: roomId, user: userB, msg: "piiie", + room: roomId, + user: userB, + msg: "piiie", event: true, }), ]; - it("should remove events", function() { + it("should remove events", function () { timeline.addEvent(events[0], { toStartOfTimeline: false }); timeline.addEvent(events[1], { toStartOfTimeline: false }); expect(timeline.getEvents().length).toEqual(2); @@ -365,7 +409,7 @@ describe("EventTimeline", function() { expect(timeline.getEvents().length).toEqual(0); }); - it("should update baseIndex", function() { + it("should update baseIndex", function () { timeline.addEvent(events[0], { toStartOfTimeline: false }); timeline.addEvent(events[1], { toStartOfTimeline: true }); timeline.addEvent(events[2], { toStartOfTimeline: false }); @@ -384,15 +428,14 @@ describe("EventTimeline", function() { // this is basically https://github.com/vector-im/vector-web/issues/937 // - removing the last event got baseIndex into such a state that // further addEvent(ev, false) calls made the index increase. - it("should not make baseIndex assplode when removing the last event", - function() { - timeline.addEvent(events[0], { toStartOfTimeline: true }); - timeline.removeEvent(events[0].getId()!); - const initialIndex = timeline.getBaseIndex(); - timeline.addEvent(events[1], { toStartOfTimeline: false }); - timeline.addEvent(events[2], { toStartOfTimeline: false }); - expect(timeline.getBaseIndex()).toEqual(initialIndex); - expect(timeline.getEvents().length).toEqual(2); - }); + it("should not make baseIndex assplode when removing the last event", function () { + timeline.addEvent(events[0], { toStartOfTimeline: true }); + timeline.removeEvent(events[0].getId()!); + const initialIndex = timeline.getBaseIndex(); + timeline.addEvent(events[1], { toStartOfTimeline: false }); + timeline.addEvent(events[2], { toStartOfTimeline: false }); + expect(timeline.getBaseIndex()).toEqual(initialIndex); + expect(timeline.getEvents().length).toEqual(2); + }); }); }); diff --git a/spec/unit/filter-component.spec.ts b/spec/unit/filter-component.spec.ts index a0a337cd17d..ad7cecde26a 100644 --- a/spec/unit/filter-component.spec.ts +++ b/spec/unit/filter-component.spec.ts @@ -1,15 +1,15 @@ import { RelationType } from "../../src"; import { FilterComponent } from "../../src/filter-component"; -import { mkEvent } from '../test-utils/test-utils'; +import { mkEvent } from "../test-utils/test-utils"; -describe("Filter Component", function() { - describe("types", function() { - it("should filter out events with other types", function() { - const filter = new FilterComponent({ types: ['m.room.message'] }); +describe("Filter Component", function () { + describe("types", function () { + it("should filter out events with other types", function () { + const filter = new FilterComponent({ types: ["m.room.message"] }); const event = mkEvent({ - type: 'm.room.member', - content: { }, - room: 'roomId', + type: "m.room.member", + content: {}, + room: "roomId", event: true, }); @@ -18,12 +18,12 @@ describe("Filter Component", function() { expect(checkResult).toBe(false); }); - it("should validate events with the same type", function() { - const filter = new FilterComponent({ types: ['m.room.message'] }); + it("should validate events with the same type", function () { + const filter = new FilterComponent({ types: ["m.room.message"] }); const event = mkEvent({ - type: 'm.room.message', - content: { }, - room: 'roomId', + type: "m.room.message", + content: {}, + room: "roomId", event: true, }); @@ -32,17 +32,20 @@ describe("Filter Component", function() { expect(checkResult).toBe(true); }); - it("should filter out events by relation participation", function() { - const currentUserId = '@me:server.org'; - const filter = new FilterComponent({ - related_by_senders: [currentUserId], - }, currentUserId); + it("should filter out events by relation participation", function () { + const currentUserId = "@me:server.org"; + const filter = new FilterComponent( + { + related_by_senders: [currentUserId], + }, + currentUserId, + ); const threadRootNotParticipated = mkEvent({ - type: 'm.room.message', + type: "m.room.message", content: {}, - room: 'roomId', - user: '@someone-else:server.org', + room: "roomId", + user: "@someone-else:server.org", event: true, unsigned: { "m.relations": { @@ -57,14 +60,17 @@ describe("Filter Component", function() { expect(filter.check(threadRootNotParticipated)).toBe(false); }); - it("should keep events by relation participation", function() { - const currentUserId = '@me:server.org'; - const filter = new FilterComponent({ - related_by_senders: [currentUserId], - }, currentUserId); + it("should keep events by relation participation", function () { + const currentUserId = "@me:server.org"; + const filter = new FilterComponent( + { + related_by_senders: [currentUserId], + }, + currentUserId, + ); const threadRootParticipated = mkEvent({ - type: 'm.room.message', + type: "m.room.message", content: {}, unsigned: { "m.relations": { @@ -74,23 +80,23 @@ describe("Filter Component", function() { }, }, }, - user: '@someone-else:server.org', - room: 'roomId', + user: "@someone-else:server.org", + room: "roomId", event: true, }); expect(filter.check(threadRootParticipated)).toBe(true); }); - it("should filter out events by relation type", function() { + it("should filter out events by relation type", function () { const filter = new FilterComponent({ related_by_rel_types: ["m.thread"], }); const referenceRelationEvent = mkEvent({ - type: 'm.room.message', + type: "m.room.message", content: {}, - room: 'roomId', + room: "roomId", event: true, unsigned: { "m.relations": { @@ -102,13 +108,13 @@ describe("Filter Component", function() { expect(filter.check(referenceRelationEvent)).toBe(false); }); - it("should keep events by relation type", function() { + it("should keep events by relation type", function () { const filter = new FilterComponent({ related_by_rel_types: ["m.thread"], }); const threadRootEvent = mkEvent({ - type: 'm.room.message', + type: "m.room.message", content: {}, unsigned: { "m.relations": { @@ -118,22 +124,22 @@ describe("Filter Component", function() { }, }, }, - room: 'roomId', + room: "roomId", event: true, }); const eventWithMultipleRelations = mkEvent({ - "type": "m.room.message", - "content": {}, - "unsigned": { + type: "m.room.message", + content: {}, + unsigned: { "m.relations": { "testtesttest": {}, "m.annotation": { - "chunk": [ + chunk: [ { - "type": "m.reaction", - "key": "🤫", - "count": 1, + type: "m.reaction", + key: "🤫", + count: 1, }, ], }, @@ -143,20 +149,20 @@ describe("Filter Component", function() { }, }, }, - "room": 'roomId', - "event": true, + room: "roomId", + event: true, }); const noMatchEvent = mkEvent({ - "type": "m.room.message", - "content": {}, - "unsigned": { + type: "m.room.message", + content: {}, + unsigned: { "m.relations": { - "testtesttest": {}, + testtesttest: {}, }, }, - "room": 'roomId', - "event": true, + room: "roomId", + event: true, }); expect(filter.check(threadRootEvent)).toBe(true); diff --git a/spec/unit/filter.spec.ts b/spec/unit/filter.spec.ts index ef8d67e5140..6a31f691b62 100644 --- a/spec/unit/filter.spec.ts +++ b/spec/unit/filter.spec.ts @@ -19,17 +19,17 @@ import { Filter, IFilterDefinition } from "../../src/filter"; import { mkEvent } from "../test-utils/test-utils"; import { EventType } from "../../src"; -describe("Filter", function() { +describe("Filter", function () { const filterId = "f1lt3ring15g00d4ursoul"; const userId = "@sir_arthur_david:humming.tiger"; let filter: Filter; - beforeEach(function() { + beforeEach(function () { filter = new Filter(userId); }); - describe("fromJson", function() { - it("create a new Filter from the provided values", function() { + describe("fromJson", function () { + it("create a new Filter from the provided values", function () { const definition = { event_fields: ["type", "content"], }; @@ -40,8 +40,8 @@ describe("Filter", function() { }); }); - describe("setTimelineLimit", function() { - it("should set room.timeline.limit of the filter definition", function() { + describe("setTimelineLimit", function () { + it("should set room.timeline.limit of the filter definition", function () { filter.setTimelineLimit(10); expect(filter.getDefinition()).toEqual({ room: { @@ -53,18 +53,18 @@ describe("Filter", function() { }); }); - describe("setDefinition/getDefinition", function() { - it("should set and get the filter body", function() { + describe("setDefinition/getDefinition", function () { + it("should set and get the filter body", function () { const definition = { - event_format: "client" as IFilterDefinition['event_format'], + event_format: "client" as IFilterDefinition["event_format"], }; filter.setDefinition(definition); expect(filter.getDefinition()).toEqual(definition); }); }); - describe("setUnreadThreadNotifications", function() { - it("setUnreadThreadNotifications", function() { + describe("setUnreadThreadNotifications", function () { + it("setUnreadThreadNotifications", function () { filter.setUnreadThreadNotifications(true); expect(filter.getDefinition()).toEqual({ room: { diff --git a/spec/unit/http-api/fetch.spec.ts b/spec/unit/http-api/fetch.spec.ts index 1af230211aa..9fc0642c571 100644 --- a/spec/unit/http-api/fetch.spec.ts +++ b/spec/unit/http-api/fetch.spec.ts @@ -65,8 +65,9 @@ describe("FetchHttpApi", () => { describe("idServerRequest", () => { it("should throw if no idBaseUrl", () => { const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); - expect(() => api.idServerRequest(Method.Get, "/test", {}, IdentityPrefix.V2)) - .toThrow("No identity server base URL set"); + expect(() => api.idServerRequest(Method.Get, "/test", {}, IdentityPrefix.V2)).toThrow( + "No identity server base URL set", + ); }); it("should send params as query string for GET requests", () => { @@ -105,9 +106,11 @@ describe("FetchHttpApi", () => { const text = "418 I'm a teapot"; const fetchFn = jest.fn().mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue(text) }); const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, prefix, fetchFn, onlyData: true }); - await expect(api.requestOtherUrl(Method.Get, "http://url", undefined, { - json: false, - })).resolves.toBe(text); + await expect( + api.requestOtherUrl(Method.Get, "http://url", undefined, { + json: false, + }), + ).resolves.toBe(text); }); it("should send token via query params if useAuthorizationHeader=false", () => { @@ -207,10 +210,12 @@ describe("FetchHttpApi", () => { return name === "Content-Type" ? "application/json" : null; }, }, - text: jest.fn().mockResolvedValue(JSON.stringify({ - errcode: "M_CONSENT_NOT_GIVEN", - error: "Ye shall ask for consent", - })), + text: jest.fn().mockResolvedValue( + JSON.stringify({ + errcode: "M_CONSENT_NOT_GIVEN", + error: "Ye shall ask for consent", + }), + ), }); const emitter = new TypedEventEmitter(); const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn }); diff --git a/spec/unit/http-api/index.spec.ts b/spec/unit/http-api/index.spec.ts index 55a4d49df1b..cef31ffea96 100644 --- a/spec/unit/http-api/index.spec.ts +++ b/spec/unit/http-api/index.spec.ts @@ -85,8 +85,10 @@ describe("MatrixHttpApi", () => { useAuthorizationHeader: false, }); upload = api.uploadContent({} as File); - expect(xhr.open) - .toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/r0/upload?access_token=token"); + expect(xhr.open).toHaveBeenCalledWith( + Method.Post, + baseUrl.toLowerCase() + "/_matrix/media/r0/upload?access_token=token", + ); expect(xhr.setRequestHeader).not.toHaveBeenCalledWith("Authorization"); }); @@ -104,8 +106,10 @@ describe("MatrixHttpApi", () => { it("should include filename by default", () => { const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); upload = api.uploadContent({} as File, { name: "name" }); - expect(xhr.open) - .toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/r0/upload?filename=name"); + expect(xhr.open).toHaveBeenCalledWith( + Method.Post, + baseUrl.toLowerCase() + "/_matrix/media/r0/upload?filename=name", + ); }); it("should allow not sending the filename", () => { @@ -216,9 +220,9 @@ describe("MatrixHttpApi", () => { it("should return active uploads in `getCurrentUploads`", () => { const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); upload = api.uploadContent({} as File); - expect(api.getCurrentUploads().find(u => u.promise === upload)).toBeTruthy(); + expect(api.getCurrentUploads().find((u) => u.promise === upload)).toBeTruthy(); api.cancelUpload(upload); - expect(api.getCurrentUploads().find(u => u.promise === upload)).toBeFalsy(); + expect(api.getCurrentUploads().find((u) => u.promise === upload)).toBeFalsy(); }); it("should return expected object from `getContentUri`", () => { diff --git a/spec/unit/http-api/utils.spec.ts b/spec/unit/http-api/utils.spec.ts index 108ba306fba..9d74f79dc7d 100644 --- a/spec/unit/http-api/utils.spec.ts +++ b/spec/unit/http-api/utils.spec.ts @@ -51,10 +51,7 @@ describe("anySignal", () => { jest.useFakeTimers(); it("should fire when any signal fires", () => { - const { signal } = anySignal([ - timeoutSignal(3000), - timeoutSignal(2000), - ]); + const { signal } = anySignal([timeoutSignal(3000), timeoutSignal(2000)]); const onabort = jest.fn(); signal.onabort = onabort; @@ -67,10 +64,7 @@ describe("anySignal", () => { }); it("should cleanup when instructed", () => { - const { signal, cleanup } = anySignal([ - timeoutSignal(3000), - timeoutSignal(2000), - ]); + const { signal, cleanup } = anySignal([timeoutSignal(3000), timeoutSignal(2000)]); const onabort = jest.fn(); signal.onabort = onabort; @@ -93,53 +87,95 @@ describe("anySignal", () => { describe("parseErrorResponse", () => { it("should resolve Matrix Errors from XHR", () => { - expect(parseErrorResponse({ - getResponseHeader(name: string): string | null { - return name === "Content-Type" ? "application/json" : null; - }, - status: 500, - } as XMLHttpRequest, '{"errcode": "TEST"}')).toStrictEqual(new MatrixError({ - errcode: "TEST", - }, 500)); + expect( + parseErrorResponse( + { + getResponseHeader(name: string): string | null { + return name === "Content-Type" ? "application/json" : null; + }, + status: 500, + } as XMLHttpRequest, + '{"errcode": "TEST"}', + ), + ).toStrictEqual( + new MatrixError( + { + errcode: "TEST", + }, + 500, + ), + ); }); it("should resolve Matrix Errors from fetch", () => { - expect(parseErrorResponse({ - headers: { - get(name: string): string | null { - return name === "Content-Type" ? "application/json" : null; + expect( + parseErrorResponse( + { + headers: { + get(name: string): string | null { + return name === "Content-Type" ? "application/json" : null; + }, + }, + status: 500, + } as Response, + '{"errcode": "TEST"}', + ), + ).toStrictEqual( + new MatrixError( + { + errcode: "TEST", }, - }, - status: 500, - } as Response, '{"errcode": "TEST"}')).toStrictEqual(new MatrixError({ - errcode: "TEST", - }, 500)); + 500, + ), + ); }); it("should resolve Matrix Errors from XHR with urls", () => { - expect(parseErrorResponse({ - responseURL: "https://example.com", - getResponseHeader(name: string): string | null { - return name === "Content-Type" ? "application/json" : null; - }, - status: 500, - } as XMLHttpRequest, '{"errcode": "TEST"}')).toStrictEqual(new MatrixError({ - errcode: "TEST", - }, 500, "https://example.com")); + expect( + parseErrorResponse( + { + responseURL: "https://example.com", + getResponseHeader(name: string): string | null { + return name === "Content-Type" ? "application/json" : null; + }, + status: 500, + } as XMLHttpRequest, + '{"errcode": "TEST"}', + ), + ).toStrictEqual( + new MatrixError( + { + errcode: "TEST", + }, + 500, + "https://example.com", + ), + ); }); it("should resolve Matrix Errors from fetch with urls", () => { - expect(parseErrorResponse({ - url: "https://example.com", - headers: { - get(name: string): string | null { - return name === "Content-Type" ? "application/json" : null; + expect( + parseErrorResponse( + { + url: "https://example.com", + headers: { + get(name: string): string | null { + return name === "Content-Type" ? "application/json" : null; + }, + }, + status: 500, + } as Response, + '{"errcode": "TEST"}', + ), + ).toStrictEqual( + new MatrixError( + { + errcode: "TEST", }, - }, - status: 500, - } as Response, '{"errcode": "TEST"}')).toStrictEqual(new MatrixError({ - errcode: "TEST", - }, 500, "https://example.com")); + 500, + "https://example.com", + ), + ); }); it("should set a sensible default error message on MatrixError", () => { @@ -152,37 +188,51 @@ describe("parseErrorResponse", () => { }); it("should handle no type gracefully", () => { - expect(parseErrorResponse({ - headers: { - get(name: string): string | null { - return null; - }, - }, - status: 500, - } as Response, '{"errcode": "TEST"}')).toStrictEqual(new HTTPError("Server returned 500 error", 500)); + expect( + parseErrorResponse( + { + headers: { + get(name: string): string | null { + return null; + }, + }, + status: 500, + } as Response, + '{"errcode": "TEST"}', + ), + ).toStrictEqual(new HTTPError("Server returned 500 error", 500)); }); it("should handle invalid type gracefully", () => { - expect(parseErrorResponse({ - headers: { - get(name: string): string | null { - return name === "Content-Type" ? " " : null; - }, - }, - status: 500, - } as Response, '{"errcode": "TEST"}')) - .toStrictEqual(new Error("Error parsing Content-Type ' ': TypeError: invalid media type")); + expect( + parseErrorResponse( + { + headers: { + get(name: string): string | null { + return name === "Content-Type" ? " " : null; + }, + }, + status: 500, + } as Response, + '{"errcode": "TEST"}', + ), + ).toStrictEqual(new Error("Error parsing Content-Type ' ': TypeError: invalid media type")); }); it("should handle plaintext errors", () => { - expect(parseErrorResponse({ - headers: { - get(name: string): string | null { - return name === "Content-Type" ? "text/plain" : null; - }, - }, - status: 418, - } as Response, "I'm a teapot")).toStrictEqual(new HTTPError("Server returned 418 error: I'm a teapot", 418)); + expect( + parseErrorResponse( + { + headers: { + get(name: string): string | null { + return name === "Content-Type" ? "text/plain" : null; + }, + }, + status: 418, + } as Response, + "I'm a teapot", + ), + ).toStrictEqual(new HTTPError("Server returned 418 error: I'm a teapot", 418)); }); }); diff --git a/spec/unit/interactive-auth.spec.ts b/spec/unit/interactive-auth.spec.ts index 098cadad5f0..366a9a710c8 100644 --- a/spec/unit/interactive-auth.spec.ts +++ b/spec/unit/interactive-auth.spec.ts @@ -44,9 +44,7 @@ describe("InteractiveAuth", () => { requestEmailToken: jest.fn(), authData: { session: "sessionId", - flows: [ - { stages: [AuthType.Password] }, - ], + flows: [{ stages: [AuthType.Password] }], params: { [AuthType.Password]: { param: "aa" }, }, @@ -60,7 +58,7 @@ describe("InteractiveAuth", () => { // first we expect a call here stateUpdated.mockImplementation((stage) => { - logger.log('aaaa'); + logger.log("aaaa"); expect(stage).toEqual(AuthType.Password); ia.submitAuthDict({ type: AuthType.Password, @@ -68,9 +66,9 @@ describe("InteractiveAuth", () => { }); // .. which should trigger a call here - const requestRes = { "a": "b" }; + const requestRes = { a: "b" }; doRequest.mockImplementation(async (authData) => { - logger.log('cccc'); + logger.log("cccc"); expect(authData).toEqual({ session: "sessionId", type: AuthType.Password, @@ -95,9 +93,7 @@ describe("InteractiveAuth", () => { requestEmailToken: jest.fn(), authData: { session: "sessionId", - flows: [ - { stages: [AuthType.Password] }, - ], + flows: [{ stages: [AuthType.Password] }], errcode: "MockError0", params: { [AuthType.Password]: { param: "aa" }, @@ -112,7 +108,7 @@ describe("InteractiveAuth", () => { // first we expect a call here stateUpdated.mockImplementation((stage) => { - logger.log('aaaa'); + logger.log("aaaa"); expect(stage).toEqual(AuthType.Password); ia.submitAuthDict({ type: AuthType.Password, @@ -120,9 +116,9 @@ describe("InteractiveAuth", () => { }); // .. which should trigger a call here - const requestRes = { "a": "b" }; + const requestRes = { a: "b" }; doRequest.mockImplementation(async (authData) => { - logger.log('cccc'); + logger.log("cccc"); expect(authData).toEqual({ session: "sessionId", type: AuthType.Password, @@ -146,12 +142,10 @@ describe("InteractiveAuth", () => { stateUpdated, requestEmailToken, matrixClient: getFakeClient(), - emailSid: 'myEmailSid', + emailSid: "myEmailSid", authData: { session: "sessionId", - flows: [ - { stages: [AuthType.Email, AuthType.Password] }, - ], + flows: [{ stages: [AuthType.Email, AuthType.Password] }], params: { [AuthType.Email]: { param: "aa" }, [AuthType.Password]: { param: "bb" }, @@ -166,7 +160,7 @@ describe("InteractiveAuth", () => { // first we expect a call here stateUpdated.mockImplementation((stage) => { - logger.log('husky'); + logger.log("husky"); expect(stage).toEqual(AuthType.Email); ia.submitAuthDict({ type: AuthType.Email, @@ -174,9 +168,9 @@ describe("InteractiveAuth", () => { }); // .. which should trigger a call here - const requestRes = { "a": "b" }; + const requestRes = { a: "b" }; doRequest.mockImplementation(async (authData) => { - logger.log('barfoo'); + logger.log("barfoo"); expect(authData).toEqual({ session: "sessionId", type: AuthType.Email, @@ -211,20 +205,21 @@ describe("InteractiveAuth", () => { doRequest.mockImplementation((authData) => { logger.log("request1", authData); expect(authData).toEqual(null); // first request should be null - const err = new MatrixError({ - session: "sessionId", - flows: [ - { stages: [AuthType.Password] }, - ], - params: { - [AuthType.Password]: { param: "aa" }, + const err = new MatrixError( + { + session: "sessionId", + flows: [{ stages: [AuthType.Password] }], + params: { + [AuthType.Password]: { param: "aa" }, + }, }, - }, 401); + 401, + ); throw err; }); // .. which should be followed by a call to stateUpdated - const requestRes = { "a": "b" }; + const requestRes = { a: "b" }; stateUpdated.mockImplementation((stage) => { expect(stage).toEqual(AuthType.Password); expect(ia.getSessionId()).toEqual("sessionId"); @@ -272,20 +267,21 @@ describe("InteractiveAuth", () => { doRequest.mockImplementation((authData) => { logger.log("request1", authData); expect(authData).toEqual(null); // first request should be null - const err = new MatrixError({ - session: "sessionId", - flows: [ - { stages: [AuthType.Password] }, - ], - params: { - [AuthType.Password]: { param: "aa" }, + const err = new MatrixError( + { + session: "sessionId", + flows: [{ stages: [AuthType.Password] }], + params: { + [AuthType.Password]: { param: "aa" }, + }, }, - }, 401); + 401, + ); throw err; }); // .. which should be followed by a call to stateUpdated - const requestRes = { "a": "b" }; + const requestRes = { a: "b" }; stateUpdated.mockImplementation((stage) => { expect(stage).toEqual(AuthType.Password); expect(ia.getSessionId()).toEqual("sessionId"); @@ -329,19 +325,20 @@ describe("InteractiveAuth", () => { doRequest.mockImplementation((authData) => { logger.log("request1", authData); expect(authData).toEqual(null); // first request should be null - const err = new MatrixError({ - session: "sessionId", - flows: [], - params: { - [AuthType.Password]: { param: "aa" }, + const err = new MatrixError( + { + session: "sessionId", + flows: [], + params: { + [AuthType.Password]: { param: "aa" }, + }, }, - }, 401); + 401, + ); throw err; }); - await expect(ia.attemptAuth.bind(ia)).rejects.toThrow( - new Error('No appropriate authentication flow found'), - ); + await expect(ia.attemptAuth.bind(ia)).rejects.toThrow(new Error("No appropriate authentication flow found")); }); it("should start an auth stage and reject if no auth flow but has session", async () => { @@ -354,29 +351,29 @@ describe("InteractiveAuth", () => { doRequest, stateUpdated, requestEmailToken, - authData: { - }, + authData: {}, sessionId: "sessionId", }); doRequest.mockImplementation((authData) => { logger.log("request1", authData); - expect(authData).toEqual({ "session": "sessionId" }); // has existing sessionId - const err = new MatrixError({ - session: "sessionId", - flows: [], - params: { - [AuthType.Password]: { param: "aa" }, + expect(authData).toEqual({ session: "sessionId" }); // has existing sessionId + const err = new MatrixError( + { + session: "sessionId", + flows: [], + params: { + [AuthType.Password]: { param: "aa" }, + }, + error: "Mock Error 1", + errcode: "MOCKERR1", }, - error: "Mock Error 1", - errcode: "MOCKERR1", - }, 401); + 401, + ); throw err; }); - await expect(ia.attemptAuth.bind(ia)).rejects.toThrow( - new Error('No appropriate authentication flow found'), - ); + await expect(ia.attemptAuth.bind(ia)).rejects.toThrow(new Error("No appropriate authentication flow found")); }); it("should handle unexpected error types without data propery set", async () => { @@ -396,14 +393,12 @@ describe("InteractiveAuth", () => { doRequest.mockImplementation((authData) => { logger.log("request1", authData); - expect(authData).toEqual({ "session": "sessionId" }); // has existing sessionId - const err = new HTTPError('myerror', 401); + expect(authData).toEqual({ session: "sessionId" }); // has existing sessionId + const err = new HTTPError("myerror", 401); throw err; }); - await expect(ia.attemptAuth.bind(ia)).rejects.toThrow( - new Error("myerror"), - ); + await expect(ia.attemptAuth.bind(ia)).rejects.toThrow(new Error("myerror")); }); it("should allow dummy auth", async () => { @@ -417,15 +412,13 @@ describe("InteractiveAuth", () => { stateUpdated, requestEmailToken, authData: { - session: 'sessionId', - flows: [ - { stages: [AuthType.Dummy] }, - ], + session: "sessionId", + flows: [{ stages: [AuthType.Dummy] }], params: {}, }, }); - const requestRes = { "a": "b" }; + const requestRes = { a: "b" }; doRequest.mockImplementation((authData) => { logger.log("request1", authData); expect(authData).toEqual({ @@ -450,7 +443,9 @@ describe("InteractiveAuth", () => { const ia = new InteractiveAuth({ matrixClient: getFakeClient(), - doRequest, stateUpdated, requestEmailToken, + doRequest, + stateUpdated, + requestEmailToken, }); await ia.requestEmailToken(); @@ -477,7 +472,9 @@ describe("InteractiveAuth", () => { const ia = new InteractiveAuth({ matrixClient: getFakeClient(), - doRequest, stateUpdated, requestEmailToken, + doRequest, + stateUpdated, + requestEmailToken, }); await ia.requestEmailToken(); @@ -506,7 +503,9 @@ describe("InteractiveAuth", () => { const ia = new InteractiveAuth({ matrixClient: getFakeClient(), - doRequest, stateUpdated, requestEmailToken, + doRequest, + stateUpdated, + requestEmailToken, }); await expect(ia.requestEmailToken.bind(ia)).rejects.toThrowError("unspecific network error"); @@ -520,7 +519,9 @@ describe("InteractiveAuth", () => { const ia = new InteractiveAuth({ matrixClient: getFakeClient(), - doRequest, stateUpdated, requestEmailToken, + doRequest, + stateUpdated, + requestEmailToken, }); await Promise.all([ia.requestEmailToken(), ia.requestEmailToken(), ia.requestEmailToken()]); @@ -536,7 +537,9 @@ describe("InteractiveAuth", () => { const ia = new InteractiveAuth({ matrixClient: getFakeClient(), - doRequest, stateUpdated, requestEmailToken, + doRequest, + stateUpdated, + requestEmailToken, }); await ia.requestEmailToken(); diff --git a/spec/unit/local_notifications.spec.ts b/spec/unit/local_notifications.spec.ts index 4f6f0c32a3c..e46d462ad96 100644 --- a/spec/unit/local_notifications.spec.ts +++ b/spec/unit/local_notifications.spec.ts @@ -16,15 +16,13 @@ limitations under the License. import { LocalNotificationSettings } from "../../src/@types/local_notifications"; import { LOCAL_NOTIFICATION_SETTINGS_PREFIX, MatrixClient } from "../../src/matrix"; -import { TestClient } from '../TestClient'; +import { TestClient } from "../TestClient"; let client: MatrixClient; describe("Local notification settings", () => { beforeEach(() => { - client = (new TestClient( - "@alice:matrix.org", "123", undefined, undefined, undefined, - )).client; + client = new TestClient("@alice:matrix.org", "123", undefined, undefined, undefined).client; client.setAccountData = jest.fn(); }); diff --git a/spec/unit/location.spec.ts b/spec/unit/location.spec.ts index d7bdf407fa5..953d3b611b1 100644 --- a/spec/unit/location.spec.ts +++ b/spec/unit/location.spec.ts @@ -25,13 +25,13 @@ import { import { TEXT_NODE_TYPE } from "../../src/@types/extensible_events"; import { MsgType } from "../../src/@types/event"; -describe("Location", function() { +describe("Location", function () { const defaultContent = { - "body": "Location geo:-36.24484561954707,175.46884959563613;u=10 at 2022-03-09T11:01:52.443Z", - "msgtype": "m.location", - "geo_uri": "geo:-36.24484561954707,175.46884959563613;u=10", - [M_LOCATION.name]: { "uri": "geo:-36.24484561954707,175.46884959563613;u=10", "description": null }, - [M_ASSET.name]: { "type": "m.self" }, + body: "Location geo:-36.24484561954707,175.46884959563613;u=10 at 2022-03-09T11:01:52.443Z", + msgtype: "m.location", + geo_uri: "geo:-36.24484561954707,175.46884959563613;u=10", + [M_LOCATION.name]: { uri: "geo:-36.24484561954707,175.46884959563613;u=10", description: null }, + [M_ASSET.name]: { type: "m.self" }, [TEXT_NODE_TYPE.name]: "Location geo:-36.24484561954707,175.46884959563613;u=10 at 2022-03-09T11:01:52.443Z", [M_TIMESTAMP.name]: 1646823712443, } as any; @@ -43,13 +43,15 @@ describe("Location", function() { const modernEventContent = { ...modernProperties }; const legacyEventContent = { + body, + msgtype, // eslint-disable-next-line camelcase - body, msgtype, geo_uri, + geo_uri, } as LocationEventWireContent; - it("should create a valid location with defaults", function() { + it("should create a valid location with defaults", function () { const loc = makeLocationContent(undefined, "geo:foo", 134235435); - expect(loc.body).toEqual('User Location geo:foo at 1970-01-02T13:17:15.435Z'); + expect(loc.body).toEqual("User Location geo:foo at 1970-01-02T13:17:15.435Z"); expect(loc.msgtype).toEqual(MsgType.Location); expect(loc.geo_uri).toEqual("geo:foo"); expect(M_LOCATION.findIn(loc)).toEqual({ @@ -57,13 +59,12 @@ describe("Location", function() { description: undefined, }); expect(M_ASSET.findIn(loc)).toEqual({ type: LocationAssetType.Self }); - expect(TEXT_NODE_TYPE.findIn(loc)).toEqual('User Location geo:foo at 1970-01-02T13:17:15.435Z'); + expect(TEXT_NODE_TYPE.findIn(loc)).toEqual("User Location geo:foo at 1970-01-02T13:17:15.435Z"); expect(M_TIMESTAMP.findIn(loc)).toEqual(134235435); }); - it("should create a valid location with explicit properties", function() { - const loc = makeLocationContent( - undefined, "geo:bar", 134235436, "desc", LocationAssetType.Pin); + it("should create a valid location with explicit properties", function () { + const loc = makeLocationContent(undefined, "geo:bar", 134235436, "desc", LocationAssetType.Pin); expect(loc.body).toEqual('Location "desc" geo:bar at 1970-01-02T13:17:15.436Z'); expect(loc.msgtype).toEqual(MsgType.Location); @@ -77,19 +78,19 @@ describe("Location", function() { expect(M_TIMESTAMP.findIn(loc)).toEqual(134235436); }); - it('parses backwards compatible event correctly', () => { + it("parses backwards compatible event correctly", () => { const eventContent = parseLocationEvent(backwardsCompatibleEventContent); expect(eventContent).toEqual(backwardsCompatibleEventContent); }); - it('parses modern correctly', () => { + it("parses modern correctly", () => { const eventContent = parseLocationEvent(modernEventContent); expect(eventContent).toEqual(backwardsCompatibleEventContent); }); - it('parses legacy event correctly', () => { + it("parses legacy event correctly", () => { const eventContent = parseLocationEvent(legacyEventContent); const { diff --git a/spec/unit/login.spec.ts b/spec/unit/login.spec.ts index 6b3ae47fe97..a1951d18ca3 100644 --- a/spec/unit/login.spec.ts +++ b/spec/unit/login.spec.ts @@ -1,59 +1,59 @@ -import { SSOAction } from '../../src/@types/auth'; -import { TestClient } from '../TestClient'; +import { SSOAction } from "../../src/@types/auth"; +import { TestClient } from "../TestClient"; -describe('Login request', function() { +describe("Login request", function () { let client: TestClient; - beforeEach(function() { + beforeEach(function () { client = new TestClient(); }); - afterEach(function() { + afterEach(function () { client.stop(); }); - it('should store "access_token" and "user_id" if in response', async function() { + it('should store "access_token" and "user_id" if in response', async function () { const response = { user_id: 1, access_token: Date.now().toString(16) }; - client.httpBackend.when('POST', '/login').respond(200, response); - client.httpBackend.flush('/login', 1, 100); - await client.client.login('m.login.any', { user: 'test', password: '12312za' }); + client.httpBackend.when("POST", "/login").respond(200, response); + client.httpBackend.flush("/login", 1, 100); + await client.client.login("m.login.any", { user: "test", password: "12312za" }); expect(client.client.getAccessToken()).toBe(response.access_token); expect(client.client.getUserId()).toBe(response.user_id); }); }); -describe('SSO login URL', function() { +describe("SSO login URL", function () { let client: TestClient; - beforeEach(function() { + beforeEach(function () { client = new TestClient(); }); - afterEach(function() { + afterEach(function () { client.stop(); }); - describe('SSOAction', function() { + describe("SSOAction", function () { const redirectUri = "https://test.com/foo"; - it('No action', function() { + 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); + expect(url.searchParams.has("org.matrix.msc3824.action")).toBe(false); }); - it('register', function() { + 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'); + expect(url.searchParams.get("org.matrix.msc3824.action")).toEqual("register"); }); - it('login', function() { + 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'); + 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 ba4be9f9b20..b13dfc125be 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -17,7 +17,7 @@ limitations under the License. import { mocked } from "jest-mock"; import { logger } from "../../src/logger"; -import { MatrixClient, ClientEvent } from "../../src/client"; +import { ClientEvent, ITurnServerResponse, MatrixClient, Store } from "../../src/client"; import { Filter } from "../../src/filter"; import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace"; import { @@ -36,7 +36,17 @@ 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, EventTimeline, MatrixError, Room } from "../../src"; +import { + ContentHelpers, + EventTimeline, + ICreateRoomOpts, + IRequestOpts, + MatrixError, + MatrixHttpApi, + MatrixScheduler, + Method, + Room, +} from "../../src"; import { supportsMatrixCall } from "../../src/webrtc/call"; import { makeBeaconEvent } from "../test-utils/beacon"; import { @@ -44,6 +54,9 @@ import { POLICIES_ACCOUNT_EVENT_TYPE, PolicyScope, } from "../../src/models/invites-ignorer"; +import { IOlmDevice } from "../../src/crypto/algorithms/megolm"; +import { QueryDict } from "../../src/utils"; +import { SyncState } from "../../src/sync"; jest.useFakeTimers(); @@ -52,17 +65,36 @@ jest.mock("../../src/webrtc/call", () => ({ supportsMatrixCall: jest.fn(() => false), })); -describe("MatrixClient", function() { +type HttpLookup = { + method: string; + path: string; + data?: Record; + error?: object; + expectBody?: Record; + expectQueryParams?: QueryDict; + thenCall?: Function; +}; + +interface Options extends ICreateRoomOpts { + _roomId?: string; +} + +type WrappedRoom = Room & { + _options: Options; + _state: Map; +}; + +describe("MatrixClient", function () { const userId = "@alice:bar"; const identityServerUrl = "https://identity.server"; const identityServerDomain = "identity.server"; - let client; - let store; - let scheduler; + let client: MatrixClient; + let store: Store; + let scheduler: MatrixScheduler; const KEEP_ALIVE_PATH = "/_matrix/client/versions"; - const PUSH_RULES_RESPONSE = { + const PUSH_RULES_RESPONSE: HttpLookup = { method: "GET", path: "/pushrules/", data: {}, @@ -70,7 +102,7 @@ describe("MatrixClient", function() { const FILTER_PATH = "/user/" + encodeURIComponent(userId) + "/filter"; - const FILTER_RESPONSE = { + const FILTER_RESPONSE: HttpLookup = { method: "POST", path: FILTER_PATH, data: { filter_id: "f1lt3r" }, @@ -82,29 +114,21 @@ describe("MatrixClient", function() { rooms: {}, }; - const SYNC_RESPONSE = { + const SYNC_RESPONSE: HttpLookup = { method: "GET", path: "/sync", data: SYNC_DATA, }; // items are popped off when processed and block if no items left. - let httpLookups: { - method: string; - path: string; - data?: object; - error?: object; - expectBody?: object; - expectQueryParams?: object; - thenCall?: Function; - }[] = []; + let httpLookups: HttpLookup[] = []; let acceptKeepalives: boolean; let pendingLookup: { promise: Promise; method: string; path: string; } | null = null; - function httpReq(method, path, qp, data, prefix) { + function httpReq(method: Method, path: string, qp?: QueryDict, data?: BodyInit, opts?: IRequestOpts) { if (path === KEEP_ALIVE_PATH && acceptKeepalives) { return Promise.resolve({ unstable_features: { @@ -114,13 +138,20 @@ describe("MatrixClient", function() { }); } const next = httpLookups.shift(); - const logLine = ( - "MatrixClient[UT] RECV " + method + " " + path + " " + - "EXPECT " + (next ? next.method : next) + " " + (next ? next.path : next) - ); + const logLine = + "MatrixClient[UT] RECV " + + method + + " " + + path + + " " + + "EXPECT " + + (next ? next.method : next) + + " " + + (next ? next.path : next); logger.log(logLine); - if (!next) { // no more things to return + if (!next) { + // no more things to return if (pendingLookup) { if (pendingLookup.method === method && pendingLookup.path === path) { return pendingLookup.promise; @@ -136,16 +167,13 @@ describe("MatrixClient", function() { return pendingLookup.promise; } if (next.path === path && next.method === method) { - logger.log( - "MatrixClient[UT] Matched. Returning " + - (next.error ? "BAD" : "GOOD") + " response", - ); + logger.log("MatrixClient[UT] Matched. Returning " + (next.error ? "BAD" : "GOOD") + " response"); if (next.expectBody) { expect(data).toEqual(next.expectBody); } if (next.expectQueryParams) { - Object.keys(next.expectQueryParams).forEach(function(k) { - expect(qp[k]).toEqual(next.expectQueryParams![k]); + Object.keys(next.expectQueryParams).forEach(function (k) { + expect(qp?.[k]).toEqual(next.expectQueryParams![k]); }); } @@ -178,30 +206,52 @@ describe("MatrixClient", function() { baseUrl: "https://my.home.server", idBaseUrl: identityServerUrl, accessToken: "my.access.token", - fetchFn: function() {} as any, // NOP + fetchFn: function () {} as any, // NOP store: store, scheduler: scheduler, userId: userId, }); // FIXME: We shouldn't be yanking http like this. - client.http = [ - "authedRequest", "getContentUri", "request", "uploadContent", - ].reduce((r, k) => { r[k] = jest.fn(); return r; }, {}); - client.http.authedRequest.mockImplementation(httpReq); - client.http.request.mockImplementation(httpReq); + client.http = (["authedRequest", "getContentUri", "request", "uploadContent"] as const).reduce((r, k) => { + r[k] = jest.fn(); + return r; + }, {} as MatrixHttpApi); + mocked(client.http.authedRequest).mockImplementation(httpReq); + mocked(client.http.request).mockImplementation(httpReq); } - beforeEach(function() { - scheduler = [ - "getQueueForEvent", "queueEvent", "removeEventFromQueue", - "setProcessFunction", - ].reduce((r, k) => { r[k] = jest.fn(); return r; }, {}); - store = [ - "getRoom", "getRooms", "getUser", "getSyncToken", "scrollback", - "save", "wantsSave", "setSyncToken", "storeEvents", "storeRoom", "storeUser", - "getFilterIdByName", "setFilterIdByName", "getFilter", "storeFilter", - "getSyncAccumulator", "startup", "deleteAllData", - ].reduce((r, k) => { r[k] = jest.fn(); return r; }, {}); + beforeEach(function () { + scheduler = (["getQueueForEvent", "queueEvent", "removeEventFromQueue", "setProcessFunction"] as const).reduce( + (r, k) => { + r[k] = jest.fn(); + return r; + }, + {} as MatrixScheduler, + ); + store = ( + [ + "getRoom", + "getRooms", + "getUser", + "getSyncToken", + "scrollback", + "save", + "wantsSave", + "setSyncToken", + "storeEvents", + "storeRoom", + "storeUser", + "getFilterIdByName", + "setFilterIdByName", + "getFilter", + "storeFilter", + "startup", + "deleteAllData", + ] as const + ).reduce((r, k) => { + r[k] = jest.fn(); + return r; + }, {} as Store); store.getSavedSync = jest.fn().mockReturnValue(Promise.resolve(null)); store.getSavedSyncToken = jest.fn().mockReturnValue(Promise.resolve(null)); store.setSyncData = jest.fn().mockReturnValue(Promise.resolve(null)); @@ -219,13 +269,13 @@ describe("MatrixClient", function() { httpLookups.push(SYNC_RESPONSE); }); - afterEach(function() { + afterEach(function () { // need to re-stub the requests with NOPs because there are no guarantees // clients from previous tests will be GC'd before the next test. This // means they may call /events and then fail an expect() which will fail // a DIFFERENT test (pollution between tests!) - we return unresolved // promises to stop the client from continuing to run. - client.http.authedRequest.mockImplementation(function() { + mocked(client.http.authedRequest).mockImplementation(function () { return new Promise(() => {}); }); client.stopClient(); @@ -239,12 +289,14 @@ describe("MatrixClient", function() { it("overload without threadId works", async () => { const eventId = "$eventId:example.org"; const txnId = client.makeTxnId(); - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, - data: { event_id: eventId }, - expectBody: content, - }]; + httpLookups = [ + { + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: content, + }, + ]; await client.sendEvent(roomId, EventType.RoomMessage, { ...content }, txnId); }); @@ -252,12 +304,14 @@ describe("MatrixClient", function() { it("overload with null threadId works", async () => { const eventId = "$eventId:example.org"; const txnId = client.makeTxnId(); - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, - data: { event_id: eventId }, - expectBody: content, - }]; + httpLookups = [ + { + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: content, + }, + ]; await client.sendEvent(roomId, null, EventType.RoomMessage, { ...content }, txnId); }); @@ -266,19 +320,21 @@ describe("MatrixClient", function() { const eventId = "$eventId:example.org"; const txnId = client.makeTxnId(); const threadId = "$threadId:server"; - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, - data: { event_id: eventId }, - expectBody: { - ...content, - "m.relates_to": { - "event_id": threadId, - "is_falling_back": true, - "rel_type": "m.thread", + httpLookups = [ + { + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: { + ...content, + "m.relates_to": { + event_id: threadId, + is_falling_back: true, + rel_type: "m.thread", + }, }, }, - }]; + ]; await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); }); @@ -289,27 +345,29 @@ describe("MatrixClient", function() { const txnId = client.makeTxnId(); const room = new Room(roomId, client, userId); - store.getRoom.mockReturnValue(room); + mocked(store.getRoom).mockReturnValue(room); const rootEvent = new MatrixEvent({ event_id: threadId }); room.createThread(threadId, rootEvent, [rootEvent], false); - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, - data: { event_id: eventId }, - expectBody: { - ...content, - "m.relates_to": { - "m.in_reply_to": { - event_id: threadId, + httpLookups = [ + { + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: { + ...content, + "m.relates_to": { + "m.in_reply_to": { + event_id: threadId, + }, + "event_id": threadId, + "is_falling_back": true, + "rel_type": "m.thread", }, - "event_id": threadId, - "is_falling_back": true, - "rel_type": "m.thread", }, }, - }]; + ]; await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); }); @@ -329,27 +387,29 @@ describe("MatrixClient", function() { }; const room = new Room(roomId, client, userId); - store.getRoom.mockReturnValue(room); + mocked(store.getRoom).mockReturnValue(room); const rootEvent = new MatrixEvent({ event_id: threadId }); room.createThread(threadId, rootEvent, [rootEvent], false); - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, - data: { event_id: eventId }, - expectBody: { - ...content, - "m.relates_to": { - "m.in_reply_to": { - event_id: "$other:event", + httpLookups = [ + { + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: { + ...content, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$other:event", + }, + "event_id": threadId, + "is_falling_back": false, + "rel_type": "m.thread", }, - "event_id": threadId, - "is_falling_back": false, - "rel_type": "m.thread", }, }, - }]; + ]; await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); }); @@ -359,7 +419,7 @@ describe("MatrixClient", function() { const userId = "@test:example.org"; const roomId = "!room:example.org"; const roomName = "Test Tree"; - const mockRoom = {}; + const mockRoom = {} as unknown as Room; const fn = jest.fn().mockImplementation((opts) => { expect(opts).toMatchObject({ name: roomName, @@ -431,23 +491,23 @@ describe("MatrixClient", function() { throw new Error("Unexpected event type or state key"); } }, - }, - }; + } as Room["currentState"], + } as unknown as Room; client.getRoom = (getRoomId) => { expect(getRoomId).toEqual(roomId); return mockRoom; }; const tree = client.unstableGetFileTreeSpace(roomId); expect(tree).toBeDefined(); - expect(tree.roomId).toEqual(roomId); - expect(tree.room).toBe(mockRoom); + expect(tree!.roomId).toEqual(roomId); + expect(tree!.room).toBe(mockRoom); }); it("should not get (unstable) file trees if not joined", async () => { const roomId = "!room:example.org"; const mockRoom = { getMyMembership: () => "leave", // "not join" - }; + } as unknown as Room; client.getRoom = (getRoomId) => { expect(getRoomId).toEqual(roomId); return mockRoom; @@ -491,8 +551,8 @@ describe("MatrixClient", function() { throw new Error("Unexpected event type or state key"); } }, - }, - }; + } as Room["currentState"], + } as unknown as Room; client.getRoom = (getRoomId) => { expect(getRoomId).toEqual(roomId); return mockRoom; @@ -525,8 +585,8 @@ describe("MatrixClient", function() { throw new Error("Unexpected event type or state key"); } }, - }, - }; + } as Room["currentState"], + } as unknown as Room; client.getRoom = (getRoomId) => { expect(getRoomId).toEqual(roomId); return mockRoom; @@ -535,21 +595,18 @@ describe("MatrixClient", function() { expect(tree).toBeFalsy(); }); - it("should not POST /filter if a matching filter already exists", async function() { - httpLookups = [ - PUSH_RULES_RESPONSE, - SYNC_RESPONSE, - ]; + it("should not POST /filter if a matching filter already exists", async function () { + httpLookups = [PUSH_RULES_RESPONSE, SYNC_RESPONSE]; const filterId = "ehfewf"; - store.getFilterIdByName.mockReturnValue(filterId); + mocked(store.getFilterIdByName).mockReturnValue(filterId); const filter = new Filter("0", filterId); - filter.setDefinition({ "room": { "timeline": { "limit": 8 } } }); - store.getFilter.mockReturnValue(filter); + filter.setDefinition({ room: { timeline: { limit: 8 } } }); + mocked(store.getFilter).mockReturnValue(filter); const syncPromise = new Promise((resolve, reject) => { - client.on("sync", function syncListener(state) { + client.on(ClientEvent.Sync, function syncListener(state) { if (state === "SYNCING") { expect(httpLookups.length).toEqual(0); - client.removeListener("sync", syncListener); + client.removeListener(ClientEvent.Sync, syncListener); resolve(); } else if (state === "ERROR") { reject(new Error("sync error")); @@ -560,17 +617,17 @@ describe("MatrixClient", function() { await syncPromise; }); - describe("getSyncState", function() { - it("should return null if the client isn't started", function() { + describe("getSyncState", function () { + it("should return null if the client isn't started", function () { expect(client.getSyncState()).toBe(null); }); - it("should return the same sync state as emitted sync events", async function() { + it("should return the same sync state as emitted sync events", async function () { const syncingPromise = new Promise((resolve) => { - client.on("sync", function syncListener(state) { + client.on(ClientEvent.Sync, function syncListener(state) { expect(state).toEqual(client.getSyncState()); if (state === "SYNCING") { - client.removeListener("sync", syncListener); + client.removeListener(ClientEvent.Sync, syncListener); resolve(); } }); @@ -580,22 +637,20 @@ describe("MatrixClient", function() { }); }); - describe("getOrCreateFilter", function() { - it("should POST createFilter if no id is present in localStorage", function() { - }); - it("should use an existing filter if id is present in localStorage", function() { - }); - it("should handle localStorage filterId missing from the server", function(done) { - function getFilterName(userId, suffix?: string) { + describe("getOrCreateFilter", function () { + it("should POST createFilter if no id is present in localStorage", function () {}); + it("should use an existing filter if id is present in localStorage", function () {}); + it("should handle localStorage filterId missing from the server", function (done) { + function getFilterName(userId: string, suffix?: 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 : ""); } - const invalidFilterId = 'invalidF1lt3r'; + const invalidFilterId = "invalidF1lt3r"; httpLookups = []; httpLookups.push({ method: "GET", - path: FILTER_PATH + '/' + invalidFilterId, + path: FILTER_PATH + "/" + invalidFilterId, error: { errcode: "M_UNKNOWN", name: "M_UNKNOWN", @@ -605,42 +660,44 @@ describe("MatrixClient", function() { }, }); httpLookups.push(FILTER_RESPONSE); - store.getFilterIdByName.mockReturnValue(invalidFilterId); + mocked(store.getFilterIdByName).mockReturnValue(invalidFilterId); - const filterName = getFilterName(client.credentials.userId); + const filterName = getFilterName(client.credentials.userId!); client.store.setFilterIdByName(filterName, invalidFilterId); const filter = new Filter(client.credentials.userId); - client.getOrCreateFilter(filterName, filter).then(function(filterId) { - expect(filterId).toEqual(FILTER_RESPONSE.data.filter_id); + client.getOrCreateFilter(filterName, filter).then(function (filterId) { + expect(filterId).toEqual(FILTER_RESPONSE.data?.filter_id); done(); }); }); }); - describe("retryImmediately", function() { - it("should return false if there is no request waiting", async function() { + describe("retryImmediately", function () { + it("should return false if there is no request waiting", async function () { httpLookups = []; await client.startClient(); expect(client.retryImmediately()).toBe(false); }); - it("should work on /filter", function(done) { + it("should work on /filter", function (done) { httpLookups = []; httpLookups.push(PUSH_RULES_RESPONSE); httpLookups.push({ - method: "POST", path: FILTER_PATH, error: { errcode: "NOPE_NOPE_NOPE" }, + method: "POST", + path: FILTER_PATH, + error: { errcode: "NOPE_NOPE_NOPE" }, }); httpLookups.push(FILTER_RESPONSE); httpLookups.push(SYNC_RESPONSE); - client.on("sync", function syncListener(state) { + client.on(ClientEvent.Sync, function syncListener(state) { if (state === "ERROR" && httpLookups.length > 0) { expect(httpLookups.length).toEqual(2); expect(client.retryImmediately()).toBe(true); jest.advanceTimersByTime(1); } else if (state === "PREPARED" && httpLookups.length === 0) { - client.removeListener("sync", syncListener); + client.removeListener(ClientEvent.Sync, syncListener); done(); } else { // unexpected state transition! @@ -650,47 +707,51 @@ describe("MatrixClient", function() { client.startClient(); }); - it("should work on /sync", function(done) { + it("should work on /sync", function (done) { httpLookups.push({ - method: "GET", path: "/sync", error: { errcode: "NOPE_NOPE_NOPE" }, + method: "GET", + path: "/sync", + error: { errcode: "NOPE_NOPE_NOPE" }, }); httpLookups.push({ - method: "GET", path: "/sync", data: SYNC_DATA, + method: "GET", + path: "/sync", + data: SYNC_DATA, }); - client.on("sync", function syncListener(state) { + client.on(ClientEvent.Sync, function syncListener(state) { if (state === "ERROR" && httpLookups.length > 0) { expect(httpLookups.length).toEqual(1); - expect(client.retryImmediately()).toBe( - true, - ); + expect(client.retryImmediately()).toBe(true); jest.advanceTimersByTime(1); } else if (state === "RECONNECTING" && httpLookups.length > 0) { jest.advanceTimersByTime(10000); } else if (state === "SYNCING" && httpLookups.length === 0) { - client.removeListener("sync", syncListener); + client.removeListener(ClientEvent.Sync, syncListener); done(); } }); client.startClient(); }); - it("should work on /pushrules", function(done) { + it("should work on /pushrules", function (done) { httpLookups = []; httpLookups.push({ - method: "GET", path: "/pushrules/", error: { errcode: "NOPE_NOPE_NOPE" }, + method: "GET", + path: "/pushrules/", + error: { errcode: "NOPE_NOPE_NOPE" }, }); httpLookups.push(PUSH_RULES_RESPONSE); httpLookups.push(FILTER_RESPONSE); httpLookups.push(SYNC_RESPONSE); - client.on("sync", function syncListener(state) { + client.on(ClientEvent.Sync, function syncListener(state) { if (state === "ERROR" && httpLookups.length > 0) { expect(httpLookups.length).toEqual(3); expect(client.retryImmediately()).toBe(true); jest.advanceTimersByTime(1); } else if (state === "PREPARED" && httpLookups.length === 0) { - client.removeListener("sync", syncListener); + client.removeListener(ClientEvent.Sync, syncListener); done(); } else { // unexpected state transition! @@ -701,13 +762,11 @@ describe("MatrixClient", function() { }); }); - describe("emitted sync events", function() { - function syncChecker(expectedStates, done) { - return function syncListener(state, old) { + describe("emitted sync events", function () { + function syncChecker(expectedStates: [string, string | null][], done: Function) { + return function syncListener(state: SyncState, old: SyncState | null) { const expected = expectedStates.shift(); - logger.log( - "'sync' curr=%s old=%s EXPECT=%s", state, old, expected, - ); + logger.log("'sync' curr=%s old=%s EXPECT=%s", state, old, expected); if (!expected) { done(); return; @@ -715,7 +774,7 @@ describe("MatrixClient", function() { expect(state).toEqual(expected[0]); expect(old).toEqual(expected[1]); if (expectedStates.length === 0) { - client.removeListener("sync", syncListener); + client.removeListener(ClientEvent.Sync, syncListener); done(); } // standard retry time is 5 to 10 seconds @@ -723,71 +782,83 @@ describe("MatrixClient", function() { }; } - it("should transition null -> PREPARED after the first /sync", function(done) { + it("should transition null -> PREPARED after the first /sync", function (done) { const expectedStates: [string, string | null][] = []; expectedStates.push(["PREPARED", null]); - client.on("sync", syncChecker(expectedStates, done)); + client.on(ClientEvent.Sync, syncChecker(expectedStates, done)); client.startClient(); }); - it("should transition null -> ERROR after a failed /filter", function(done) { + it("should transition null -> ERROR after a failed /filter", function (done) { const expectedStates: [string, string | null][] = []; httpLookups = []; httpLookups.push(PUSH_RULES_RESPONSE); httpLookups.push({ - method: "POST", path: FILTER_PATH, error: { errcode: "NOPE_NOPE_NOPE" }, + method: "POST", + path: FILTER_PATH, + error: { errcode: "NOPE_NOPE_NOPE" }, }); expectedStates.push(["ERROR", null]); - client.on("sync", syncChecker(expectedStates, done)); + client.on(ClientEvent.Sync, syncChecker(expectedStates, done)); client.startClient(); }); // Disabled because now `startClient` makes a legit call to `/versions` // And those tests are really unhappy about it... Not possible to figure // out what a good resolution would look like - xit("should transition ERROR -> CATCHUP after /sync if prev failed", function(done) { + xit("should transition ERROR -> CATCHUP after /sync if prev failed", function (done) { const expectedStates: [string, string | null][] = []; acceptKeepalives = false; httpLookups = []; httpLookups.push(PUSH_RULES_RESPONSE); httpLookups.push(FILTER_RESPONSE); httpLookups.push({ - method: "GET", path: "/sync", error: { errcode: "NOPE_NOPE_NOPE" }, + method: "GET", + path: "/sync", + error: { errcode: "NOPE_NOPE_NOPE" }, }); httpLookups.push({ - method: "GET", path: KEEP_ALIVE_PATH, + method: "GET", + path: KEEP_ALIVE_PATH, error: { errcode: "KEEPALIVE_FAIL" }, }); httpLookups.push({ - method: "GET", path: KEEP_ALIVE_PATH, data: {}, + method: "GET", + path: KEEP_ALIVE_PATH, + data: {}, }); httpLookups.push({ - method: "GET", path: "/sync", data: SYNC_DATA, + method: "GET", + path: "/sync", + data: SYNC_DATA, }); expectedStates.push(["RECONNECTING", null]); expectedStates.push(["ERROR", "RECONNECTING"]); expectedStates.push(["CATCHUP", "ERROR"]); - client.on("sync", syncChecker(expectedStates, done)); + client.on(ClientEvent.Sync, syncChecker(expectedStates, done)); client.startClient(); }); - it("should transition PREPARED -> SYNCING after /sync", function(done) { + it("should transition PREPARED -> SYNCING after /sync", function (done) { const expectedStates: [string, string | null][] = []; expectedStates.push(["PREPARED", null]); expectedStates.push(["SYNCING", "PREPARED"]); - client.on("sync", syncChecker(expectedStates, done)); + client.on(ClientEvent.Sync, syncChecker(expectedStates, done)); client.startClient(); }); - xit("should transition SYNCING -> ERROR after a failed /sync", function(done) { + xit("should transition SYNCING -> ERROR after a failed /sync", function (done) { acceptKeepalives = false; const expectedStates: [string, string | null][] = []; httpLookups.push({ - method: "GET", path: "/sync", error: { errcode: "NONONONONO" }, + method: "GET", + path: "/sync", + error: { errcode: "NONONONONO" }, }); httpLookups.push({ - method: "GET", path: KEEP_ALIVE_PATH, + method: "GET", + path: KEEP_ALIVE_PATH, error: { errcode: "KEEPALIVE_FAIL" }, }); @@ -795,25 +866,27 @@ describe("MatrixClient", function() { expectedStates.push(["SYNCING", "PREPARED"]); expectedStates.push(["RECONNECTING", "SYNCING"]); expectedStates.push(["ERROR", "RECONNECTING"]); - client.on("sync", syncChecker(expectedStates, done)); + client.on(ClientEvent.Sync, syncChecker(expectedStates, done)); client.startClient(); }); - xit("should transition ERROR -> SYNCING after /sync if prev failed", function(done) { + xit("should transition ERROR -> SYNCING after /sync if prev failed", function (done) { const expectedStates: [string, string | null][] = []; httpLookups.push({ - method: "GET", path: "/sync", error: { errcode: "NONONONONO" }, + method: "GET", + path: "/sync", + error: { errcode: "NONONONONO" }, }); httpLookups.push(SYNC_RESPONSE); expectedStates.push(["PREPARED", null]); expectedStates.push(["SYNCING", "PREPARED"]); expectedStates.push(["ERROR", "SYNCING"]); - client.on("sync", syncChecker(expectedStates, done)); + client.on(ClientEvent.Sync, syncChecker(expectedStates, done)); client.startClient(); }); - it("should transition SYNCING -> SYNCING on subsequent /sync successes", function(done) { + it("should transition SYNCING -> SYNCING on subsequent /sync successes", function (done) { const expectedStates: [string, string | null][] = []; httpLookups.push(SYNC_RESPONSE); httpLookups.push(SYNC_RESPONSE); @@ -821,22 +894,26 @@ describe("MatrixClient", function() { expectedStates.push(["PREPARED", null]); expectedStates.push(["SYNCING", "PREPARED"]); expectedStates.push(["SYNCING", "SYNCING"]); - client.on("sync", syncChecker(expectedStates, done)); + client.on(ClientEvent.Sync, syncChecker(expectedStates, done)); client.startClient(); }); - xit("should transition ERROR -> ERROR if keepalive keeps failing", function(done) { + xit("should transition ERROR -> ERROR if keepalive keeps failing", function (done) { acceptKeepalives = false; const expectedStates: [string, string | null][] = []; httpLookups.push({ - method: "GET", path: "/sync", error: { errcode: "NONONONONO" }, + method: "GET", + path: "/sync", + error: { errcode: "NONONONONO" }, }); httpLookups.push({ - method: "GET", path: KEEP_ALIVE_PATH, + method: "GET", + path: KEEP_ALIVE_PATH, error: { errcode: "KEEPALIVE_FAIL" }, }); httpLookups.push({ - method: "GET", path: KEEP_ALIVE_PATH, + method: "GET", + path: KEEP_ALIVE_PATH, error: { errcode: "KEEPALIVE_FAIL" }, }); @@ -845,32 +922,34 @@ describe("MatrixClient", function() { expectedStates.push(["RECONNECTING", "SYNCING"]); expectedStates.push(["ERROR", "RECONNECTING"]); expectedStates.push(["ERROR", "ERROR"]); - client.on("sync", syncChecker(expectedStates, done)); + client.on(ClientEvent.Sync, syncChecker(expectedStates, done)); client.startClient(); }); }); - describe("inviteByEmail", function() { + describe("inviteByEmail", function () { const roomId = "!foo:bar"; - it("should send an invite HTTP POST", function() { - httpLookups = [{ - method: "POST", - path: "/rooms/!foo%3Abar/invite", - data: {}, - expectBody: { - id_server: identityServerDomain, - medium: "email", - address: "alice@gmail.com", + it("should send an invite HTTP POST", function () { + httpLookups = [ + { + method: "POST", + path: "/rooms/!foo%3Abar/invite", + data: {}, + expectBody: { + id_server: identityServerDomain, + medium: "email", + address: "alice@gmail.com", + }, }, - }]; + ]; client.inviteByEmail(roomId, "alice@gmail.com"); expect(httpLookups.length).toEqual(0); }); }); - describe("guest rooms", function() { - it("should only do /sync calls (without filter/pushrules)", async function() { + describe("guest rooms", function () { + it("should only do /sync calls (without filter/pushrules)", async function () { httpLookups = []; // no /pushrules or /filter httpLookups.push({ method: "GET", @@ -882,20 +961,21 @@ describe("MatrixClient", function() { expect(httpLookups.length).toBe(0); }); - xit("should be able to peek into a room using peekInRoom", function(done) { - }); + xit("should be able to peek into a room using peekInRoom", function (done) {}); }); - describe("getPresence", function() { - it("should send a presence HTTP GET", function() { - httpLookups = [{ - method: "GET", - path: `/presence/${encodeURIComponent(userId)}/status`, - data: { - "presence": "unavailable", - "last_active_ago": 420845, + describe("getPresence", function () { + it("should send a presence HTTP GET", function () { + httpLookups = [ + { + method: "GET", + path: `/presence/${encodeURIComponent(userId)}/status`, + data: { + presence: "unavailable", + last_active_ago: 420845, + }, }, - }]; + ]; client.getPresence(userId); expect(httpLookups.length).toEqual(0); }); @@ -914,14 +994,14 @@ describe("MatrixClient", function() { throw new Error("Unexpected event type or state key"); } }, - }, + } as Room["currentState"], getThread: jest.fn(), addPendingEvent: jest.fn(), updatePendingEvent: jest.fn(), reEmitter: { reEmit: jest.fn(), }, - }; + } as unknown as Room; beforeEach(() => { client.getRoom = (getRoomId) => { @@ -933,11 +1013,13 @@ describe("MatrixClient", function() { it("overload without threadId works", async () => { const eventId = "$eventId:example.org"; const txnId = client.makeTxnId(); - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}/${txnId}`, - data: { event_id: eventId }, - }]; + httpLookups = [ + { + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}/${txnId}`, + data: { event_id: eventId }, + }, + ]; await client.redactEvent(roomId, eventId, txnId); }); @@ -945,11 +1027,13 @@ describe("MatrixClient", function() { it("overload with null threadId works", async () => { const eventId = "$eventId:example.org"; const txnId = client.makeTxnId(); - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}/${txnId}`, - data: { event_id: eventId }, - }]; + httpLookups = [ + { + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}/${txnId}`, + data: { event_id: eventId }, + }, + ]; await client.redactEvent(roomId, null, eventId, txnId); }); @@ -957,11 +1041,13 @@ describe("MatrixClient", function() { it("overload with threadId works", async () => { const eventId = "$eventId:example.org"; const txnId = client.makeTxnId(); - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}/${txnId}`, - data: { event_id: eventId }, - }]; + httpLookups = [ + { + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}/${txnId}`, + data: { event_id: eventId }, + }, + ]; await client.redactEvent(roomId, "$threadId:server", eventId, txnId); }); @@ -970,12 +1056,14 @@ describe("MatrixClient", function() { const eventId = "$eventId:example.org"; const txnId = client.makeTxnId(); const reason = "This is the redaction reason"; - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}/${txnId}`, - expectBody: { reason }, // NOT ENCRYPTED - data: { event_id: eventId }, - }]; + httpLookups = [ + { + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}/${txnId}`, + expectBody: { reason }, // NOT ENCRYPTED + data: { event_id: eventId }, + }, + ]; await client.redactEvent(roomId, eventId, txnId, { reason }); }); @@ -987,7 +1075,7 @@ describe("MatrixClient", function() { const mockRoom = { getMyMembership: () => "join", - updatePendingEvent: (event, status) => event.setStatus(status), + updatePendingEvent: (event: MatrixEvent, status: EventStatus) => event.setStatus(status), currentState: { getStateEvents: (eventType, stateKey) => { if (eventType === EventType.RoomCreate) { @@ -1004,15 +1092,14 @@ describe("MatrixClient", function() { throw new Error("Unexpected event type or state key"); } }, - }, - }; + } as Room["currentState"], + } as unknown as Room; - let event; + let event: MatrixEvent; beforeEach(async () => { event = new MatrixEvent({ event_id: "~" + roomId + ":" + txnId, - user_id: client.credentials.userId, - sender: client.credentials.userId, + sender: client.credentials.userId!, room_id: roomId, origin_server_ts: new Date().getTime(), }); @@ -1022,26 +1109,28 @@ describe("MatrixClient", function() { expect(getRoomId).toEqual(roomId); return mockRoom; }; - client.crypto = { // mock crypto - encryptEvent: (event, room) => new Promise(() => {}), + client.crypto = { + // mock crypto + encryptEvent: () => new Promise(() => {}), stop: jest.fn(), - }; + } as unknown as Crypto; }); function assertCancelled() { expect(event.status).toBe(EventStatus.CANCELLED); - expect(client.scheduler.removeEventFromQueue(event)).toBeFalsy(); - expect(httpLookups.filter(h => h.path.includes("/send/")).length).toBe(0); + expect(client.scheduler?.removeEventFromQueue(event)).toBeFalsy(); + expect(httpLookups.filter((h) => h.path.includes("/send/")).length).toBe(0); } it("should cancel an event which is queued", () => { event.setStatus(EventStatus.QUEUED); - client.scheduler.queueEvent(event); + client.scheduler?.queueEvent(event); client.cancelPendingEvent(event); assertCancelled(); }); it("should cancel an event which is encrypting", async () => { + // @ts-ignore protected method access client.encryptAndSendEvent(null, event); await testUtils.emitPromise(event, "Event.status"); client.cancelPendingEvent(event); @@ -1068,22 +1157,22 @@ describe("MatrixClient", function() { const room = new Room("!room1:matrix.org", client, userId); const rootEvent = new MatrixEvent({ - "content": {}, - "origin_server_ts": 1, - "room_id": "!room1:matrix.org", - "sender": "@alice:matrix.org", - "type": "m.room.message", - "unsigned": { + content: {}, + origin_server_ts: 1, + room_id: "!room1:matrix.org", + sender: "@alice:matrix.org", + type: "m.room.message", + unsigned: { "m.relations": { "m.thread": { - "latest_event": {}, - "count": 33, - "current_user_participated": false, + latest_event: {}, + count: 33, + current_user_participated: false, }, }, }, - "event_id": "$ev1", - "user_id": "@alice:matrix.org", + event_id: "$ev1", + user_id: "@alice:matrix.org", }); expect(rootEvent.isThreadRoot).toBe(true); @@ -1103,17 +1192,12 @@ describe("MatrixClient", function() { const room = { hasPendingEvent: jest.fn().mockReturnValue(false), addLocalEchoReceipt: jest.fn(), - }; + } as unknown as Room; const rrEvent = new MatrixEvent({ event_id: "read_event_id" }); const rpEvent = new MatrixEvent({ event_id: "read_private_event_id" }); client.getRoom = () => room; - client.setRoomReadMarkers( - "room_id", - "read_marker_event_id", - rrEvent, - rpEvent, - ); + client.setRoomReadMarkers("room_id", "read_marker_event_id", rrEvent, rpEvent); expect(client.setRoomReadMarkersHttpRequest).toHaveBeenCalledWith( "room_id", @@ -1138,11 +1222,11 @@ describe("MatrixClient", function() { }); describe("beacons", () => { - const roomId = '!room:server.org'; + const roomId = "!room:server.org"; const content = makeBeaconInfoContent(100, true); beforeEach(() => { - client.http.authedRequest.mockClear().mockResolvedValue({}); + mocked(client.http.authedRequest).mockClear().mockResolvedValue({}); }); it("creates new beacon info", async () => { @@ -1150,11 +1234,11 @@ describe("MatrixClient", function() { // event type combined const expectedEventType = M_BEACON_INFO.name; - const [method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0]; - expect(method).toBe('PUT'); + const [method, path, queryParams, requestContent] = mocked(client.http.authedRequest).mock.calls[0]; + expect(method).toBe("PUT"); expect(path).toEqual( `/rooms/${encodeURIComponent(roomId)}/state/` + - `${encodeURIComponent(expectedEventType)}/${encodeURIComponent(userId)}`, + `${encodeURIComponent(expectedEventType)}/${encodeURIComponent(userId)}`, ); expect(queryParams).toBeFalsy(); expect(requestContent).toEqual(content); @@ -1164,34 +1248,34 @@ describe("MatrixClient", function() { await client.unstable_setLiveBeacon(roomId, content); // event type combined - const [, path, , requestContent] = client.http.authedRequest.mock.calls[0]; + const [, path, , requestContent] = mocked(client.http.authedRequest).mock.calls[0]; expect(path).toEqual( `/rooms/${encodeURIComponent(roomId)}/state/` + - `${encodeURIComponent(M_BEACON_INFO.name)}/${encodeURIComponent(userId)}`, + `${encodeURIComponent(M_BEACON_INFO.name)}/${encodeURIComponent(userId)}`, ); expect(requestContent).toEqual(content); }); - describe('processBeaconEvents()', () => { - it('does nothing when events is falsy', () => { + describe("processBeaconEvents()", () => { + it("does nothing when events is falsy", () => { const room = new Room(roomId, client, userId); - const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents'); + const roomStateProcessSpy = jest.spyOn(room.currentState, "processBeaconEvents"); client.processBeaconEvents(room, undefined); expect(roomStateProcessSpy).not.toHaveBeenCalled(); }); - it('does nothing when events is of length 0', () => { + it("does nothing when events is of length 0", () => { const room = new Room(roomId, client, userId); - const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents'); + const roomStateProcessSpy = jest.spyOn(room.currentState, "processBeaconEvents"); client.processBeaconEvents(room, []); expect(roomStateProcessSpy).not.toHaveBeenCalled(); }); - it('calls room states processBeaconEvents with events', () => { + it("calls room states processBeaconEvents with events", () => { const room = new Room(roomId, client, userId); - const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents'); + const roomStateProcessSpy = jest.spyOn(room.currentState, "processBeaconEvents"); const messageEvent = testUtils.mkMessage({ room: roomId, user: userId, event: true }); const beaconEvent = makeBeaconEvent(userId); @@ -1205,14 +1289,13 @@ describe("MatrixClient", function() { describe("setRoomTopic", () => { const roomId = "!foofoofoofoofoofoo:matrix.org"; const createSendStateEventMock = (topic: string, htmlTopic?: string) => { - return jest.fn() - .mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { - expect(roomId).toEqual(roomId); - expect(eventType).toEqual(EventType.RoomTopic); - expect(content).toMatchObject(ContentHelpers.makeTopicContent(topic, htmlTopic)); - expect(stateKey).toBeUndefined(); - return Promise.resolve(); - }); + return jest.fn().mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { + expect(roomId).toEqual(roomId); + expect(eventType).toEqual(EventType.RoomTopic); + expect(content).toMatchObject(ContentHelpers.makeTopicContent(topic, htmlTopic)); + expect(stateKey).toBeUndefined(); + return Promise.resolve(); + }); }; it("is called with plain text topic and sends state event", async () => { @@ -1238,19 +1321,19 @@ describe("MatrixClient", function() { }); describe("setPassword", () => { - const auth = { session: 'abcdef', type: 'foo' }; - const newPassword = 'newpassword'; + const auth = { session: "abcdef", type: "foo" }; + const newPassword = "newpassword"; const passwordTest = (expectedRequestContent: any) => { - const [method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0]; - expect(method).toBe('POST'); - expect(path).toEqual('/account/password'); + const [method, path, queryParams, requestContent] = mocked(client.http.authedRequest).mock.calls[0]; + expect(method).toBe("POST"); + expect(path).toEqual("/account/password"); expect(queryParams).toBeFalsy(); expect(requestContent).toEqual(expectedRequestContent); }; beforeEach(() => { - client.http.authedRequest.mockClear().mockResolvedValue({}); + mocked(client.http.authedRequest).mockClear().mockResolvedValue({}); }); it("no logout_devices specified", async () => { @@ -1289,15 +1372,15 @@ describe("MatrixClient", function() { const response = { aliases: ["#woop:example.org", "#another:example.org"], }; - client.http.authedRequest.mockClear().mockResolvedValue(response); + mocked(client.http.authedRequest).mockClear().mockResolvedValue(response); const roomId = "!whatever:example.org"; const result = await client.getLocalAliases(roomId); // Current version of the endpoint we support is v3 - const [method, path, queryParams, data, opts] = client.http.authedRequest.mock.calls[0]; + const [method, path, queryParams, data, opts] = mocked(client.http.authedRequest).mock.calls[0]; expect(data).toBeFalsy(); - expect(method).toBe('GET'); + expect(method).toBe("GET"); expect(path).toEqual(`/rooms/${encodeURIComponent(roomId)}/aliases`); expect(opts).toMatchObject({ prefix: "/_matrix/client/v3" }); expect(queryParams).toBeFalsy(); @@ -1352,19 +1435,25 @@ describe("MatrixClient", function() { ], username: "1443779631:@user:example.com", password: "JlKfBy1QwLrO20385QyAtEyIv0=", - }; + } as unknown as ITurnServerResponse; jest.spyOn(client, "turnServer").mockResolvedValue(turnServer); const events: any[][] = []; - const onTurnServers = (...args) => events.push(args); + const onTurnServers = (...args: any[]) => events.push(args); client.on(ClientEvent.TurnServers, onTurnServers); expect(await client.checkTurnServers()).toBe(true); client.off(ClientEvent.TurnServers, onTurnServers); - expect(events).toEqual([[[{ - urls: turnServer.uris, - username: turnServer.username, - credential: turnServer.password, - }]]]); + expect(events).toEqual([ + [ + [ + { + urls: turnServer.uris, + username: turnServer.username, + credential: turnServer.password, + }, + ], + ], + ]); }); it("emits an event when an error occurs", async () => { @@ -1372,7 +1461,7 @@ describe("MatrixClient", function() { jest.spyOn(client, "turnServer").mockRejectedValue(error); const events: any[][] = []; - const onTurnServersError = (...args) => events.push(args); + const onTurnServersError = (...args: any[]) => events.push(args); client.on(ClientEvent.TurnServersError, onTurnServersError); expect(await client.checkTurnServers()).toBe(false); client.off(ClientEvent.TurnServersError, onTurnServersError); @@ -1384,7 +1473,7 @@ describe("MatrixClient", function() { jest.spyOn(client, "turnServer").mockRejectedValue(error); const events: any[][] = []; - const onTurnServersError = (...args) => events.push(args); + const onTurnServersError = (...args: any[]) => events.push(args); client.on(ClientEvent.TurnServersError, onTurnServersError); expect(await client.checkTurnServers()).toBe(false); client.off(ClientEvent.TurnServersError, onTurnServersError); @@ -1400,7 +1489,7 @@ describe("MatrixClient", function() { it("is an alias for the crypto method", async () => { client.crypto = testUtils.mock(Crypto, "Crypto"); - const deviceInfos = []; + const deviceInfos: IOlmDevice[] = []; const payload = {}; await client.encryptAndSendToDevices(deviceInfos, payload); expect(client.crypto.encryptAndSendToDevices).toHaveBeenLastCalledWith(deviceInfos, payload); @@ -1411,11 +1500,11 @@ describe("MatrixClient", function() { beforeEach(() => { // Mockup `getAccountData`/`setAccountData`. const dataStore = new Map(); - client.setAccountData = function(eventType, content) { + client.setAccountData = function (eventType, content) { dataStore.set(eventType, content); - return Promise.resolve(); + return Promise.resolve({}); }; - client.getAccountData = function(eventType) { + client.getAccountData = function (eventType) { const data = dataStore.get(eventType); return new MatrixEvent({ content: data, @@ -1424,44 +1513,44 @@ describe("MatrixClient", function() { // Mockup `createRoom`/`getRoom`/`joinRoom`, including state. const rooms = new Map(); - client.createRoom = function(options = {}) { + client.createRoom = function (options: Options = {}) { const roomId = options["_roomId"] || `!room-${rooms.size}:example.org`; - const state = new Map(); + const state = new Map(); const room = { roomId, _options: options, _state: state, - getUnfilteredTimelineSet: function() { + getUnfilteredTimelineSet: function () { return { - getLiveTimeline: function() { + getLiveTimeline: function () { return { - getState: function(direction) { + getState: function (direction) { expect(direction).toBe(EventTimeline.FORWARDS); return { - getStateEvents: function(type) { + getStateEvents: function (type) { const store = state.get(type) || {}; - return Object.keys(store).map(key => store[key]); + return Object.keys(store).map((key) => store[key]); }, }; }, - }; + } as EventTimeline; }, }; }, - }; + } as unknown as WrappedRoom; rooms.set(roomId, room); return Promise.resolve({ room_id: roomId }); }; - client.getRoom = function(roomId) { + client.getRoom = function (roomId) { return rooms.get(roomId); }; - client.joinRoom = function(roomId) { - return this.getRoom(roomId) || this.createRoom({ _roomId: roomId }); + client.joinRoom = async function (roomId) { + return this.getRoom(roomId)! || this.createRoom({ _roomId: roomId } as ICreateRoomOpts); }; // Mockup state events - client.sendStateEvent = function(roomId, type, content) { - const room = this.getRoom(roomId); + client.sendStateEvent = function (roomId, type, content) { + const room = this.getRoom(roomId) as WrappedRoom; const state: Map = room._state; let store = state.get(type); if (!store) { @@ -1470,24 +1559,25 @@ describe("MatrixClient", function() { } const eventId = `$event-${Math.random()}:example.org`; store[eventId] = { - getId: function() { + getId: function () { return eventId; }, - getRoomId: function() { + getRoomId: function () { return roomId; }, - getContent: function() { + getContent: function () { return content; }, }; - return { event_id: eventId }; + return Promise.resolve({ event_id: eventId }); }; - client.redactEvent = function(roomId, eventId) { - const room = this.getRoom(roomId); + client.redactEvent = function (roomId, eventId) { + const room = this.getRoom(roomId) as WrappedRoom; const state: Map = room._state; for (const store of state.values()) { - delete store[eventId]; + delete store[eventId!]; } + return Promise.resolve({ event_id: "$" + eventId + "-" + Math.random() }); }; }); @@ -1523,7 +1613,7 @@ describe("MatrixClient", function() { roomId: "!snafu:somewhere.org", }); expect(ruleMatch).toBeTruthy(); - expect(ruleMatch.getContent()).toMatchObject({ + expect(ruleMatch!.getContent()).toMatchObject({ recommendation: "m.ban", reason: "just a test", }); @@ -1552,7 +1642,7 @@ describe("MatrixClient", function() { roomId: "!snafu:somewhere.org", }); expect(ruleSenderMatch).toBeTruthy(); - expect(ruleSenderMatch.getContent()).toMatchObject({ + expect(ruleSenderMatch!.getContent()).toMatchObject({ recommendation: "m.ban", reason: REASON, }); @@ -1562,7 +1652,7 @@ describe("MatrixClient", function() { roomId: "!snafu:example.org", }); expect(ruleRoomMatch).toBeTruthy(); - expect(ruleRoomMatch.getContent()).toMatchObject({ + expect(ruleRoomMatch!.getContent()).toMatchObject({ recommendation: "m.ban", reason: REASON, }); @@ -1587,7 +1677,7 @@ describe("MatrixClient", function() { roomId: BAD_ROOM_ID, }); expect(ruleSenderMatch).toBeTruthy(); - expect(ruleSenderMatch.getContent()).toMatchObject({ + expect(ruleSenderMatch!.getContent()).toMatchObject({ recommendation: "m.ban", reason: REASON, }); @@ -1621,7 +1711,7 @@ describe("MatrixClient", function() { roomId: "!snafu:somewhere.org", }); expect(ruleMatch).toBeTruthy(); - expect(ruleMatch.getContent()).toMatchObject({ + expect(ruleMatch!.getContent()).toMatchObject({ recommendation: "m.ban", reason: "just a test", }); @@ -1649,13 +1739,13 @@ describe("MatrixClient", function() { roomId: "!snafu:somewhere.org", }); expect(ruleMatch).toBeTruthy(); - expect(ruleMatch.getContent()).toMatchObject({ + 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); + await client.ignoredInvites.removeRule(ruleMatch as MatrixEvent); const ruleMatch2 = await client.ignoredInvites.getRuleForInvite({ sender: "@foobar:example.org", roomId: "!snafu:somewhere.org", @@ -1669,10 +1759,10 @@ describe("MatrixClient", function() { // 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); + const newSourceRoom = client.getRoom(NEW_SOURCE_ROOM_ID) as WrappedRoom; // 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(); + 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(); @@ -1686,7 +1776,7 @@ describe("MatrixClient", function() { 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(); + 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(); @@ -1698,7 +1788,7 @@ describe("MatrixClient", function() { // Check where it shows up. const targetRoomId = ignoreInvites2.target; - const targetRoom = client.getRoom(targetRoomId); + const targetRoom = client.getRoom(targetRoomId) as WrappedRoom; expect(targetRoom._state.get(PolicyScope.User)[eventId]).toBeTruthy(); expect(newSourceRoom._state.get(PolicyScope.User)?.[eventId]).toBeFalsy(); }); diff --git a/spec/unit/models/MSC3089Branch.spec.ts b/spec/unit/models/MSC3089Branch.spec.ts index 6f8fdbe4e56..33bc21ddcc8 100644 --- a/spec/unit/models/MSC3089Branch.spec.ts +++ b/spec/unit/models/MSC3089Branch.spec.ts @@ -50,23 +50,27 @@ describe("MSC3089Branch", () => { } }, }; - indexEvent = ({ + indexEvent = { getRoomId: () => branchRoomId, getStateKey: () => fileEventId, - }); + }; directory = new MSC3089TreeSpace(client, branchRoomId); branch = new MSC3089Branch(client, indexEvent, directory); - branch2 = new MSC3089Branch(client, { - getRoomId: () => branchRoomId, - getStateKey: () => fileEventId2, - } as MatrixEvent, directory); + branch2 = new MSC3089Branch( + client, + { + getRoomId: () => branchRoomId, + getStateKey: () => fileEventId2, + } as MatrixEvent, + directory, + ); }); - it('should know the file event ID', () => { + it("should know the file event ID", () => { expect(branch.id).toEqual(fileEventId); }); - it('should know if the file is active or not', () => { + it("should know if the file is active or not", () => { indexEvent.getContent = () => ({}); expect(branch.isActive).toBe(false); indexEvent.getContent = () => ({ active: false }); @@ -77,15 +81,16 @@ describe("MSC3089Branch", () => { expect(branch.isActive).toBe(false); }); - it('should be able to delete the file', async () => { + it("should be able to delete the file", async () => { const eventIdOrder = [fileEventId, fileEventId2]; - const stateFn = jest.fn() + const stateFn = jest + .fn() .mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { expect(roomId).toEqual(branchRoomId); expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value expect(content).toMatchObject({}); - expect(content['active']).toBeUndefined(); + expect(content["active"]).toBeUndefined(); expect(stateKey).toEqual(eventIdOrder[stateFn.mock.calls.length - 1]); return Promise.resolve(); // return value not used @@ -109,7 +114,7 @@ describe("MSC3089Branch", () => { expect(redactFn).toHaveBeenCalledTimes(2); }); - it('should know its name', async () => { + it("should know its name", async () => { const name = "My File.txt"; indexEvent.getContent = () => ({ active: true, name: name }); @@ -118,10 +123,11 @@ describe("MSC3089Branch", () => { expect(res).toEqual(name); }); - it('should be able to change its name', async () => { + it("should be able to change its name", async () => { const name = "My File.txt"; indexEvent.getContent = () => ({ active: true, retained: true }); - const stateFn = jest.fn() + const stateFn = jest + .fn() .mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { expect(roomId).toEqual(branchRoomId); expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value @@ -141,7 +147,7 @@ describe("MSC3089Branch", () => { expect(stateFn).toHaveBeenCalledTimes(1); }); - it('should be v1 by default', () => { + it("should be v1 by default", () => { indexEvent.getContent = () => ({ active: true }); const res = branch.version; @@ -149,7 +155,7 @@ describe("MSC3089Branch", () => { expect(res).toEqual(1); }); - it('should be vN when set', () => { + it("should be vN when set", () => { indexEvent.getContent = () => ({ active: true, version: 3 }); const res = branch.version; @@ -157,7 +163,7 @@ describe("MSC3089Branch", () => { expect(res).toEqual(3); }); - it('should be unlocked by default', async () => { + it("should be unlocked by default", async () => { indexEvent.getContent = () => ({ active: true }); const res = branch.isLocked(); @@ -165,7 +171,7 @@ describe("MSC3089Branch", () => { expect(res).toEqual(false); }); - it('should use lock status from index event', async () => { + it("should use lock status from index event", async () => { indexEvent.getContent = () => ({ active: true, locked: true }); const res = branch.isLocked(); @@ -173,10 +179,11 @@ describe("MSC3089Branch", () => { expect(res).toEqual(true); }); - it('should be able to change its locked status', async () => { + it("should be able to change its locked status", async () => { const locked = true; indexEvent.getContent = () => ({ active: true, retained: true }); - const stateFn = jest.fn() + const stateFn = jest + .fn() .mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { expect(roomId).toEqual(branchRoomId); expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value @@ -196,16 +203,17 @@ describe("MSC3089Branch", () => { expect(stateFn).toHaveBeenCalledTimes(1); }); - it('should be able to return event information', async () => { + it("should be able to return event information", async () => { const mxcLatter = "example.org/file"; const fileContent = { isFile: "not quite", url: "mxc://" + mxcLatter }; const fileEvent = { getId: () => fileEventId, getOriginalContent: () => ({ file: fileContent }) }; - staticRoom.getUnfilteredTimelineSet = () => ({ - findEventById: (eventId) => { - expect(eventId).toEqual(fileEventId); - return fileEvent; - }, - }) as EventTimelineSet; + staticRoom.getUnfilteredTimelineSet = () => + ({ + findEventById: (eventId) => { + expect(eventId).toEqual(fileEventId); + return fileEvent; + }, + } as EventTimelineSet); client.mxcUrlToHttp = (mxc: string) => { expect(mxc).toEqual("mxc://" + mxcLatter); return `https://example.org/_matrix/media/v1/download/${mxcLatter}`; @@ -217,20 +225,21 @@ describe("MSC3089Branch", () => { expect(res).toMatchObject({ info: fileContent, // Escape regex from MDN guides: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions - httpUrl: expect.stringMatching(`.+${mxcLatter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`), + httpUrl: expect.stringMatching(`.+${mxcLatter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`), }); }); - it('should be able to return the event object', async () => { + it("should be able to return the event object", async () => { const mxcLatter = "example.org/file"; const fileContent = { isFile: "not quite", url: "mxc://" + mxcLatter }; const fileEvent = { getId: () => fileEventId, getOriginalContent: () => ({ file: fileContent }) }; - staticRoom.getUnfilteredTimelineSet = () => ({ - findEventById: (eventId) => { - expect(eventId).toEqual(fileEventId); - return fileEvent; - }, - }) as EventTimelineSet; + staticRoom.getUnfilteredTimelineSet = () => + ({ + findEventById: (eventId) => { + expect(eventId).toEqual(fileEventId); + return fileEvent; + }, + } as EventTimelineSet); client.mxcUrlToHttp = (mxc: string) => { expect(mxc).toEqual("mxc://" + mxcLatter); return `https://example.org/_matrix/media/v1/download/${mxcLatter}`; @@ -242,14 +251,15 @@ describe("MSC3089Branch", () => { expect(res).toBe(fileEvent); }); - it('should create new versions of itself', async () => { + it("should create new versions of itself", async () => { const canaryName = "canary"; const canaryContents = "contents go here"; const canaryFile = {} as IEncryptedFile; const canaryAddl = { canary: true }; indexEvent.getContent = () => ({ active: true, retained: true }); const stateKeyOrder = [fileEventId2, fileEventId]; - const stateFn = jest.fn() + const stateFn = jest + .fn() .mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { expect(roomId).toEqual(branchRoomId); expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value @@ -273,26 +283,25 @@ describe("MSC3089Branch", () => { }); client.sendStateEvent = stateFn; - const createFn = jest.fn().mockImplementation(( - name: string, - contents: ArrayBuffer, - info: Partial, - addl: IContent, - ) => { - expect(name).toEqual(canaryName); - expect(contents).toBe(canaryContents); - expect(info).toBe(canaryFile); - expect(addl).toMatchObject({ - ...canaryAddl, - "m.new_content": true, - "m.relates_to": { - "rel_type": RelationType.Replace, - "event_id": fileEventId, - }, - }); + const createFn = jest + .fn() + .mockImplementation( + (name: string, contents: ArrayBuffer, info: Partial, addl: IContent) => { + expect(name).toEqual(canaryName); + expect(contents).toBe(canaryContents); + expect(info).toBe(canaryFile); + expect(addl).toMatchObject({ + ...canaryAddl, + "m.new_content": true, + "m.relates_to": { + rel_type: RelationType.Replace, + event_id: fileEventId, + }, + }); - return Promise.resolve({ event_id: fileEventId2 }); - }); + return Promise.resolve({ event_id: fileEventId2 }); + }, + ); directory.createFile = createFn; await branch.createNewVersion(canaryName, canaryContents, canaryFile, canaryAddl); @@ -301,21 +310,27 @@ describe("MSC3089Branch", () => { expect(createFn).toHaveBeenCalledTimes(1); }); - it('should fetch file history', async () => { - branch2.getFileEvent = () => Promise.resolve({ - replacingEventId: () => undefined, - getId: () => fileEventId2, - } as MatrixEvent); - branch.getFileEvent = () => Promise.resolve({ - replacingEventId: () => fileEventId2, - getId: () => fileEventId, - } as MatrixEvent); - - const events = [await branch.getFileEvent(), await branch2.getFileEvent(), { - replacingEventId: (): string | undefined => undefined, - getId: () => "$unknown", - }]; - staticRoom.getLiveTimeline = () => ({ getEvents: () => events }) as EventTimeline; + it("should fetch file history", async () => { + branch2.getFileEvent = () => + Promise.resolve({ + replacingEventId: () => undefined, + getId: () => fileEventId2, + } as MatrixEvent); + branch.getFileEvent = () => + Promise.resolve({ + replacingEventId: () => fileEventId2, + getId: () => fileEventId, + } as MatrixEvent); + + const events = [ + await branch.getFileEvent(), + await branch2.getFileEvent(), + { + replacingEventId: (): string | undefined => undefined, + getId: () => "$unknown", + }, + ]; + staticRoom.getLiveTimeline = () => ({ getEvents: () => events } as EventTimeline); directory.getFile = (evId: string) => { expect(evId).toEqual(fileEventId); @@ -323,9 +338,6 @@ describe("MSC3089Branch", () => { }; const results = await branch2.getVersionHistory(); - expect(results).toMatchObject([ - branch2, - branch, - ]); + expect(results).toMatchObject([branch2, branch]); }); }); diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index 4158ea8b9fe..9cb82f7870a 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient } from "../../../src"; +import { IContent, MatrixClient } from "../../../src"; import { Room } from "../../../src/models/room"; import { MatrixEvent } from "../../../src/models/event"; import { EventType, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../../../src/@types/event"; @@ -33,7 +33,7 @@ describe("MSC3089TreeSpace", () => { const roomId = "!tree:localhost"; const targetUser = "@target:example.org"; - let powerLevels; + let powerLevels: MatrixEvent; beforeEach(() => { // TODO: Use utility functions to create test rooms and clients @@ -72,18 +72,19 @@ describe("MSC3089TreeSpace", () => { }); } - it('should populate the room reference', () => { + it("should populate the room reference", () => { expect(tree.room).toBe(room); }); - it('should proxy the ID member to room ID', () => { + it("should proxy the ID member to room ID", () => { expect(tree.id).toEqual(tree.roomId); expect(tree.id).toEqual(roomId); }); - it('should support setting the name of the space', async () => { + it("should support setting the name of the space", async () => { const newName = "NEW NAME"; - const fn = jest.fn() + const fn = jest + .fn() .mockImplementation((stateRoomId: string, eventType: EventType, content: any, stateKey: string) => { expect(stateRoomId).toEqual(roomId); expect(eventType).toEqual(EventType.RoomName); @@ -96,7 +97,7 @@ describe("MSC3089TreeSpace", () => { expect(fn).toHaveBeenCalledTimes(1); }); - it('should support inviting users to the space', async () => { + it("should support inviting users to the space", async () => { const target = targetUser; const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => { expect(inviteRoomId).toEqual(roomId); @@ -108,7 +109,7 @@ describe("MSC3089TreeSpace", () => { expect(fn).toHaveBeenCalledTimes(1); }); - it('should retry invites to the space', async () => { + it("should retry invites to the space", async () => { const target = targetUser; const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => { expect(inviteRoomId).toEqual(roomId); @@ -121,7 +122,7 @@ describe("MSC3089TreeSpace", () => { expect(fn).toHaveBeenCalledTimes(2); }); - it('should not retry invite permission errors', async () => { + it("should not retry invite permission errors", async () => { const target = targetUser; const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => { expect(inviteRoomId).toEqual(roomId); @@ -141,7 +142,7 @@ describe("MSC3089TreeSpace", () => { expect(fn).toHaveBeenCalledTimes(1); }); - it('should invite to subspaces', async () => { + it("should invite to subspaces", async () => { const target = targetUser; const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => { expect(inviteRoomId).toEqual(roomId); @@ -162,7 +163,7 @@ describe("MSC3089TreeSpace", () => { expect(fn).toHaveBeenCalledTimes(4); }); - it('should share keys with invitees', async () => { + it("should share keys with invitees", async () => { const target = targetUser; const sendKeysFn = jest.fn().mockImplementation((inviteRoomId: string, userIds: string[]) => { expect(inviteRoomId).toEqual(roomId); @@ -190,7 +191,7 @@ describe("MSC3089TreeSpace", () => { expect(historyFn).toHaveBeenCalledTimes(1); }); - it('should not share keys with invitees if inappropriate history visibility', async () => { + it("should not share keys with invitees if inappropriate history visibility", async () => { const target = targetUser; const sendKeysFn = jest.fn().mockImplementation((inviteRoomId: string, userIds: string[]) => { expect(inviteRoomId).toEqual(roomId); @@ -215,7 +216,8 @@ describe("MSC3089TreeSpace", () => { async function evaluatePowerLevels(pls: any, role: TreePermissions, expectedPl: number) { makePowerLevels(pls); - const fn = jest.fn() + const fn = jest + .fn() .mockImplementation((stateRoomId: string, eventType: EventType, content: any, stateKey: string) => { expect(stateRoomId).toEqual(roomId); expect(eventType).toEqual(EventType.RoomPowerLevels); @@ -240,80 +242,100 @@ describe("MSC3089TreeSpace", () => { expect(finalPermissions).toEqual(role); } - it('should support setting Viewer permissions', () => { - return evaluatePowerLevels({ - ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, - users_default: 1024, - events_default: 1025, - events: { - [EventType.RoomPowerLevels]: 1026, + it("should support setting Viewer permissions", () => { + return evaluatePowerLevels( + { + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users_default: 1024, + events_default: 1025, + events: { + [EventType.RoomPowerLevels]: 1026, + }, }, - }, TreePermissions.Viewer, 1024); + TreePermissions.Viewer, + 1024, + ); }); - it('should support setting Editor permissions', () => { - return evaluatePowerLevels({ - ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, - users_default: 1024, - events_default: 1025, - events: { - [EventType.RoomPowerLevels]: 1026, + it("should support setting Editor permissions", () => { + return evaluatePowerLevels( + { + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users_default: 1024, + events_default: 1025, + events: { + [EventType.RoomPowerLevels]: 1026, + }, }, - }, TreePermissions.Editor, 1025); + TreePermissions.Editor, + 1025, + ); }); - it('should support setting Owner permissions', () => { - return evaluatePowerLevels({ - ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, - users_default: 1024, - events_default: 1025, - events: { - [EventType.RoomPowerLevels]: 1026, + it("should support setting Owner permissions", () => { + return evaluatePowerLevels( + { + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users_default: 1024, + events_default: 1025, + events: { + [EventType.RoomPowerLevels]: 1026, + }, }, - }, TreePermissions.Owner, 1026); + TreePermissions.Owner, + 1026, + ); }); - it('should support demoting permissions', () => { - return evaluatePowerLevels({ - ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, - users_default: 1024, - events_default: 1025, - events: { - [EventType.RoomPowerLevels]: 1026, - }, - users: { - [targetUser]: 2222, + it("should support demoting permissions", () => { + return evaluatePowerLevels( + { + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users_default: 1024, + events_default: 1025, + events: { + [EventType.RoomPowerLevels]: 1026, + }, + users: { + [targetUser]: 2222, + }, }, - }, TreePermissions.Viewer, 1024); + TreePermissions.Viewer, + 1024, + ); }); - it('should support promoting permissions', () => { - return evaluatePowerLevels({ - ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, - users_default: 1024, - events_default: 1025, - events: { - [EventType.RoomPowerLevels]: 1026, - }, - users: { - [targetUser]: 5, + it("should support promoting permissions", () => { + return evaluatePowerLevels( + { + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users_default: 1024, + events_default: 1025, + events: { + [EventType.RoomPowerLevels]: 1026, + }, + users: { + [targetUser]: 5, + }, }, - }, TreePermissions.Editor, 1025); + TreePermissions.Editor, + 1025, + ); }); - it('should support defaults: Viewer', () => { + it("should support defaults: Viewer", () => { return evaluatePowerLevels({}, TreePermissions.Viewer, 0); }); - it('should support defaults: Editor', () => { + it("should support defaults: Editor", () => { return evaluatePowerLevels({}, TreePermissions.Editor, 50); }); - it('should support defaults: Owner', () => { + it("should support defaults: Owner", () => { return evaluatePowerLevels({}, TreePermissions.Owner, 100); }); - it('should create subdirectories', async () => { + it("should create subdirectories", async () => { const subspaceName = "subdirectory"; const subspaceId = "!subspace:localhost"; const domain = "domain.example.com"; @@ -331,7 +353,8 @@ describe("MSC3089TreeSpace", () => { expect(name).toEqual(subspaceName); return new MSC3089TreeSpace(client, subspaceId); }); - const sendStateFn = jest.fn() + const sendStateFn = jest + .fn() .mockImplementation(async (roomId: string, eventType: EventType, content: any, stateKey: string) => { expect([tree.roomId, subspaceId]).toContain(roomId); if (roomId === subspaceId) { @@ -361,7 +384,7 @@ describe("MSC3089TreeSpace", () => { expect(sendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, content, subspaceId); }); - it('should find subdirectories', () => { + it("should find subdirectories", () => { const firstChildRoom = "!one:example.org"; const secondChildRoom = "!two:example.org"; const thirdChildRoom = "!three:example.org"; // to ensure it doesn't end up in the subdirectories @@ -399,7 +422,7 @@ describe("MSC3089TreeSpace", () => { expect(getFn).toHaveBeenCalledWith(thirdChildRoom); // check to make sure it tried }); - it('should find specific directories', () => { + it("should find specific directories", () => { client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor // Only mocking used API @@ -415,7 +438,7 @@ describe("MSC3089TreeSpace", () => { expect(result).toBeFalsy(); }); - it('should be able to delete itself', async () => { + it("should be able to delete itself", async () => { const delete1 = jest.fn().mockImplementation(() => Promise.resolve()); const subdir1 = { delete: delete1 } as any as MSC3089TreeSpace; // mock tested bits @@ -466,7 +489,7 @@ describe("MSC3089TreeSpace", () => { expect(leaveFn).toHaveBeenCalledTimes(1); }); - describe('get and set order', () => { + describe("get and set order", () => { // Danger: these are partial implementations for testing purposes only // @ts-ignore - "MatrixEvent is a value but used as a type", which is true but not important @@ -480,10 +503,10 @@ describe("MSC3089TreeSpace", () => { const staticDomain = "static.example.org"; function addSubspace(roomId: string, createTs?: number, order?: string) { - const content = { + const content: IContent = { via: [staticDomain], }; - if (order) content['order'] = order; + if (order) content["order"] = order; parentState.push({ getType: () => EventType.SpaceChild, getStateKey: () => roomId, @@ -511,7 +534,7 @@ describe("MSC3089TreeSpace", () => { } function expectOrder(childRoomId: string, order: number) { - const child = childTrees.find(c => c.roomId === childRoomId); + const child = childTrees.find((c) => c.roomId === childRoomId); expect(child).toBeDefined(); expect(child!.getOrder()).toEqual(order); } @@ -523,10 +546,10 @@ describe("MSC3089TreeSpace", () => { expect([EventType.SpaceParent, EventType.RoomCreate]).toContain(eventType); if (eventType === EventType.RoomCreate) { expect(stateKey).toEqual(""); - return childState[roomId].find(e => e.getType() === EventType.RoomCreate); + return childState[roomId].find((e) => e.getType() === EventType.RoomCreate); } else { expect(stateKey).toBeUndefined(); - return childState[roomId].filter(e => e.getType() === eventType); + return childState[roomId].filter((e) => e.getType() === eventType); } }, }, @@ -541,22 +564,22 @@ describe("MSC3089TreeSpace", () => { roomId: tree.roomId, currentState: { getStateEvents: (eventType: EventType, stateKey?: string) => { - expect([ - EventType.SpaceChild, - EventType.RoomCreate, - EventType.SpaceParent, - ]).toContain(eventType); + expect([EventType.SpaceChild, EventType.RoomCreate, EventType.SpaceParent]).toContain( + eventType, + ); if (eventType === EventType.RoomCreate) { expect(stateKey).toEqual(""); - return parentState.filter(e => e.getType() === EventType.RoomCreate)[0]; + return parentState.filter((e) => e.getType() === EventType.RoomCreate)[0]; } else { if (stateKey !== undefined) { expect(Object.keys(rooms)).toContain(stateKey); expect(stateKey).not.toEqual(tree.roomId); - return parentState.find(e => e.getType() === eventType && e.getStateKey() === stateKey); + return parentState.find( + (e) => e.getType() === eventType && e.getStateKey() === stateKey, + ); } // else fine - return parentState.filter(e => e.getType() === eventType); + return parentState.filter((e) => e.getType() === eventType); } }, }, @@ -567,18 +590,23 @@ describe("MSC3089TreeSpace", () => { (tree).room = parentRoom; // override readonly client.getRoom = (r) => rooms[r ?? ""]; - clientSendStateFn = jest.fn() + clientSendStateFn = jest + .fn() .mockImplementation((roomId: string, eventType: EventType, content: any, stateKey: string) => { expect(roomId).toEqual(tree.roomId); expect(eventType).toEqual(EventType.SpaceChild); - expect(content).toMatchObject(expect.objectContaining({ - via: expect.any(Array), - order: expect.any(String), - })); + expect(content).toMatchObject( + expect.objectContaining({ + via: expect.any(Array), + order: expect.any(String), + }), + ); expect(Object.keys(rooms)).toContain(stateKey); expect(stateKey).not.toEqual(tree.roomId); - const stateEvent = parentState.find(e => e.getType() === eventType && e.getStateKey() === stateKey); + const stateEvent = parentState.find( + (e) => e.getType() === eventType && e.getStateKey() === stateKey, + ); expect(stateEvent).toBeDefined(); stateEvent.getContent = () => content; @@ -587,7 +615,7 @@ describe("MSC3089TreeSpace", () => { client.sendStateEvent = clientSendStateFn; }); - it('should know when something is top level', () => { + it("should know when something is top level", () => { const a = "!a:example.org"; addSubspace(a); @@ -595,12 +623,12 @@ describe("MSC3089TreeSpace", () => { expect(childTrees[0].isTopLevel).toBe(false); // a bit of a hack to get at this, but it's fine }); - it('should return -1 for top level spaces', () => { + it("should return -1 for top level spaces", () => { // The tree is what we've defined as top level, so it should work expect(tree.getOrder()).toEqual(-1); }); - it('should throw when setting an order at the top level space', async () => { + it("should throw when setting an order at the top level space", async () => { try { // The tree is what we've defined as top level, so it should work await tree.setOrder(2); @@ -612,7 +640,7 @@ describe("MSC3089TreeSpace", () => { } }); - it('should return a stable order for unordered children', () => { + it("should return a stable order for unordered children", () => { const a = "!a:example.org"; const b = "!b:example.org"; const c = "!c:example.org"; @@ -627,7 +655,7 @@ describe("MSC3089TreeSpace", () => { expectOrder(c, 2); }); - it('should return a stable order for ordered children', () => { + it("should return a stable order for ordered children", () => { const a = "!a:example.org"; const b = "!b:example.org"; const c = "!c:example.org"; @@ -642,7 +670,7 @@ describe("MSC3089TreeSpace", () => { expectOrder(a, 2); }); - it('should return a stable order for partially ordered children', () => { + it("should return a stable order for partially ordered children", () => { const a = "!a:example.org"; const b = "!b:example.org"; const c = "!c:example.org"; @@ -660,7 +688,7 @@ describe("MSC3089TreeSpace", () => { expectOrder(a, 2); }); - it('should return a stable order if the create event timestamps are the same', () => { + it("should return a stable order if the create event timestamps are the same", () => { const a = "!a:example.org"; const b = "!b:example.org"; const c = "!c:example.org"; @@ -675,7 +703,7 @@ describe("MSC3089TreeSpace", () => { expectOrder(c, 2); }); - it('should return a stable order if there are no known create events', () => { + it("should return a stable order if there are no known create events", () => { const a = "!a:example.org"; const b = "!b:example.org"; const c = "!c:example.org"; @@ -692,7 +720,7 @@ describe("MSC3089TreeSpace", () => { // XXX: These tests rely on `getOrder()` re-calculating and not caching values. - it('should allow reordering within unordered children', async () => { + it("should allow reordering within unordered children", async () => { const a = "!a:example.org"; const b = "!b:example.org"; const c = "!c:example.org"; @@ -704,32 +732,47 @@ describe("MSC3089TreeSpace", () => { // Order of this state is validated by other tests. - const treeA = childTrees.find(c => c.roomId === a); + const treeA = childTrees.find((c) => c.roomId === a); expect(treeA).toBeDefined(); await treeA!.setOrder(1); expect(clientSendStateFn).toHaveBeenCalledTimes(3); - expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ - via: [staticDomain], // should retain domain independent of client.getDomain() - - // Because of how the reordering works (maintain stable ordering before moving), we end up calling this - // function twice for the same room. - order: DEFAULT_ALPHABET[0], - }), a); - expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ - via: [staticDomain], // should retain domain independent of client.getDomain() - order: DEFAULT_ALPHABET[1], - }), b); - expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ - via: [staticDomain], // should retain domain independent of client.getDomain() - order: DEFAULT_ALPHABET[2], - }), a); + expect(clientSendStateFn).toHaveBeenCalledWith( + tree.roomId, + EventType.SpaceChild, + expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + + // Because of how the reordering works (maintain stable ordering before moving), we end up calling this + // function twice for the same room. + order: DEFAULT_ALPHABET[0], + }), + a, + ); + expect(clientSendStateFn).toHaveBeenCalledWith( + tree.roomId, + EventType.SpaceChild, + expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: DEFAULT_ALPHABET[1], + }), + b, + ); + expect(clientSendStateFn).toHaveBeenCalledWith( + tree.roomId, + EventType.SpaceChild, + expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: DEFAULT_ALPHABET[2], + }), + a, + ); expectOrder(a, 1); expectOrder(b, 0); expectOrder(c, 2); }); - it('should allow reordering within ordered children', async () => { + it("should allow reordering within ordered children", async () => { const a = "!a:example.org"; const b = "!b:example.org"; const c = "!c:example.org"; @@ -741,21 +784,26 @@ describe("MSC3089TreeSpace", () => { // Order of this state is validated by other tests. - const treeA = childTrees.find(c => c.roomId === a); + const treeA = childTrees.find((c) => c.roomId === a); expect(treeA).toBeDefined(); await treeA!.setOrder(1); expect(clientSendStateFn).toHaveBeenCalledTimes(1); - expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ - via: [staticDomain], // should retain domain independent of client.getDomain() - order: 'Y', - }), a); + expect(clientSendStateFn).toHaveBeenCalledWith( + tree.roomId, + EventType.SpaceChild, + expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: "Y", + }), + a, + ); expectOrder(a, 1); expectOrder(b, 0); expectOrder(c, 2); }); - it('should allow reordering within partially ordered children', async () => { + it("should allow reordering within partially ordered children", async () => { const a = "!a:example.org"; const b = "!b:example.org"; const c = "!c:example.org"; @@ -769,22 +817,27 @@ describe("MSC3089TreeSpace", () => { // Order of this state is validated by other tests. - const treeA = childTrees.find(c => c.roomId === a); + const treeA = childTrees.find((c) => c.roomId === a); expect(treeA).toBeDefined(); await treeA!.setOrder(2); expect(clientSendStateFn).toHaveBeenCalledTimes(1); - expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ - via: [staticDomain], // should retain domain independent of client.getDomain() - order: 'Z', - }), a); + expect(clientSendStateFn).toHaveBeenCalledWith( + tree.roomId, + EventType.SpaceChild, + expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: "Z", + }), + a, + ); expectOrder(a, 2); expectOrder(b, 3); expectOrder(c, 1); expectOrder(d, 0); }); - it('should support moving upwards', async () => { + it("should support moving upwards", async () => { const a = "!a:example.org"; const b = "!b:example.org"; const c = "!c:example.org"; @@ -798,22 +851,27 @@ describe("MSC3089TreeSpace", () => { // Order of this state is validated by other tests. - const treeB = childTrees.find(c => c.roomId === b); + const treeB = childTrees.find((c) => c.roomId === b); expect(treeB).toBeDefined(); await treeB!.setOrder(2); expect(clientSendStateFn).toHaveBeenCalledTimes(1); - expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ - via: [staticDomain], // should retain domain independent of client.getDomain() - order: 'Y', - }), b); + expect(clientSendStateFn).toHaveBeenCalledWith( + tree.roomId, + EventType.SpaceChild, + expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: "Y", + }), + b, + ); expectOrder(a, 0); expectOrder(b, 2); expectOrder(c, 1); expectOrder(d, 3); }); - it('should support moving downwards', async () => { + it("should support moving downwards", async () => { const a = "!a:example.org"; const b = "!b:example.org"; const c = "!c:example.org"; @@ -827,22 +885,27 @@ describe("MSC3089TreeSpace", () => { // Order of this state is validated by other tests. - const treeC = childTrees.find(ch => ch.roomId === c); + const treeC = childTrees.find((ch) => ch.roomId === c); expect(treeC).toBeDefined(); await treeC!.setOrder(1); expect(clientSendStateFn).toHaveBeenCalledTimes(1); - expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ - via: [staticDomain], // should retain domain independent of client.getDomain() - order: 'U', - }), c); + expect(clientSendStateFn).toHaveBeenCalledWith( + tree.roomId, + EventType.SpaceChild, + expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: "U", + }), + c, + ); expectOrder(a, 0); expectOrder(b, 2); expectOrder(c, 1); expectOrder(d, 3); }); - it('should support moving over the partial ordering boundary', async () => { + it("should support moving over the partial ordering boundary", async () => { const a = "!a:example.org"; const b = "!b:example.org"; const c = "!c:example.org"; @@ -856,19 +919,29 @@ describe("MSC3089TreeSpace", () => { // Order of this state is validated by other tests. - const treeB = childTrees.find(ch => ch.roomId === b); + const treeB = childTrees.find((ch) => ch.roomId === b); expect(treeB).toBeDefined(); await treeB!.setOrder(2); expect(clientSendStateFn).toHaveBeenCalledTimes(2); - expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ - via: [staticDomain], // should retain domain independent of client.getDomain() - order: 'W', - }), c); - expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ - via: [staticDomain], // should retain domain independent of client.getDomain() - order: 'X', - }), b); + expect(clientSendStateFn).toHaveBeenCalledWith( + tree.roomId, + EventType.SpaceChild, + expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: "W", + }), + c, + ); + expect(clientSendStateFn).toHaveBeenCalledWith( + tree.roomId, + EventType.SpaceChild, + expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: "X", + }), + b, + ); expectOrder(a, 0); expectOrder(b, 2); expectOrder(c, 1); @@ -876,7 +949,7 @@ describe("MSC3089TreeSpace", () => { }); }); - it('should upload files', async () => { + it("should upload files", async () => { const mxc = "mxc://example.org/file"; const fileInfo = { mimetype: "text/plain", @@ -910,7 +983,8 @@ describe("MSC3089TreeSpace", () => { }); client.sendMessage = sendMsgFn; - const sendStateFn = jest.fn() + const sendStateFn = jest + .fn() .mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { expect(roomId).toEqual(tree.roomId); expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable @@ -935,7 +1009,7 @@ describe("MSC3089TreeSpace", () => { expect(sendStateFn).toHaveBeenCalledTimes(1); }); - it('should upload file versions', async () => { + it("should upload file versions", async () => { const mxc = "mxc://example.org/file"; const fileInfo = { mimetype: "text/plain", @@ -972,7 +1046,8 @@ describe("MSC3089TreeSpace", () => { }); client.sendMessage = sendMsgFn; - const sendStateFn = jest.fn() + const sendStateFn = jest + .fn() .mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { expect(roomId).toEqual(tree.roomId); expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable @@ -997,7 +1072,7 @@ describe("MSC3089TreeSpace", () => { expect(sendStateFn).toHaveBeenCalledTimes(1); }); - it('should support getting files', () => { + it("should support getting files", () => { const fileEventId = "$file"; const fileEvent = { forTest: true }; // MatrixEvent mock room.currentState = { @@ -1013,7 +1088,7 @@ describe("MSC3089TreeSpace", () => { expect(file!.indexEvent).toBe(fileEvent); }); - it('should return falsy for unknown files', () => { + it("should return falsy for unknown files", () => { const fileEventId = "$file"; room.currentState = { getStateEvents: (eventType: string, stateKey?: string): MatrixEvent[] | MatrixEvent | null => { @@ -1027,7 +1102,7 @@ describe("MSC3089TreeSpace", () => { expect(file).toBeFalsy(); }); - it('should list files', () => { + it("should list files", () => { const firstFile = { getContent: () => ({ active: true }) }; const secondFile = { getContent: () => ({ active: false }) }; // deliberately inactive room.currentState = { @@ -1044,7 +1119,7 @@ describe("MSC3089TreeSpace", () => { expect(files[0].indexEvent).toBe(firstFile); }); - it('should list all files', () => { + it("should list all files", () => { const firstFile = { getContent: () => ({ active: true }) }; const secondFile = { getContent: () => ({ active: false }) }; // deliberately inactive room.currentState = { diff --git a/spec/unit/models/beacon.spec.ts b/spec/unit/models/beacon.spec.ts index 2806ea26cb2..620e4a8fda1 100644 --- a/spec/unit/models/beacon.spec.ts +++ b/spec/unit/models/beacon.spec.ts @@ -18,50 +18,46 @@ import { REFERENCE_RELATION } from "matrix-events-sdk"; import { MatrixEvent } from "../../../src"; import { M_BEACON_INFO } from "../../../src/@types/beacon"; -import { - isTimestampInDuration, - Beacon, - BeaconEvent, -} from "../../../src/models/beacon"; +import { isTimestampInDuration, Beacon, BeaconEvent } from "../../../src/models/beacon"; import { makeBeaconEvent, makeBeaconInfoEvent } from "../../test-utils/beacon"; jest.useFakeTimers(); -describe('Beacon', () => { - describe('isTimestampInDuration()', () => { - const startTs = new Date('2022-03-11T12:07:47.592Z').getTime(); +describe("Beacon", () => { + describe("isTimestampInDuration()", () => { + const startTs = new Date("2022-03-11T12:07:47.592Z").getTime(); const HOUR_MS = 3600000; - it('returns false when timestamp is before start time', () => { + it("returns false when timestamp is before start time", () => { // day before - const timestamp = new Date('2022-03-10T12:07:47.592Z').getTime(); + const timestamp = new Date("2022-03-10T12:07:47.592Z").getTime(); expect(isTimestampInDuration(startTs, HOUR_MS, timestamp)).toBe(false); }); - it('returns false when timestamp is after start time + duration', () => { + it("returns false when timestamp is after start time + duration", () => { // 1 second later - const timestamp = new Date('2022-03-10T12:07:48.592Z').getTime(); + const timestamp = new Date("2022-03-10T12:07:48.592Z").getTime(); expect(isTimestampInDuration(startTs, HOUR_MS, timestamp)).toBe(false); }); - it('returns true when timestamp is exactly start time', () => { + it("returns true when timestamp is exactly start time", () => { expect(isTimestampInDuration(startTs, HOUR_MS, startTs)).toBe(true); }); - it('returns true when timestamp is exactly the end of the duration', () => { + it("returns true when timestamp is exactly the end of the duration", () => { expect(isTimestampInDuration(startTs, HOUR_MS, startTs + HOUR_MS)).toBe(true); }); - it('returns true when timestamp is within the duration', () => { + it("returns true when timestamp is within the duration", () => { const twoHourDuration = HOUR_MS * 2; const now = startTs + HOUR_MS; expect(isTimestampInDuration(startTs, twoHourDuration, now)).toBe(true); }); }); - describe('Beacon', () => { - const userId = '@user:server.org'; - const userId2 = '@user2:server.org'; - const roomId = '$room:server.org'; + describe("Beacon", () => { + const userId = "@user:server.org"; + const userId2 = "@user2:server.org"; + const roomId = "$room:server.org"; // 14.03.2022 16:15 const now = 1647270879403; const HOUR_MS = 3600000; @@ -75,7 +71,7 @@ describe('Beacon', () => { const advanceDateAndTime = (ms: number) => { // bc liveness check uses Date.now we have to advance this mock - jest.spyOn(global.Date, 'now').mockReturnValue(Date.now() + ms); + jest.spyOn(global.Date, "now").mockReturnValue(Date.now() + ms); // then advance time for the interval by the same amount jest.advanceTimersByTime(ms); }; @@ -89,7 +85,7 @@ describe('Beacon', () => { isLive: true, timestamp: now - HOUR_MS, }, - '$live123', + "$live123", ); notLiveBeaconEvent = makeBeaconInfoEvent( userId, @@ -99,7 +95,7 @@ describe('Beacon', () => { isLive: false, timestamp: now - HOUR_MS, }, - '$dead123', + "$dead123", ); user2BeaconEvent = makeBeaconInfoEvent( userId2, @@ -109,18 +105,18 @@ describe('Beacon', () => { isLive: true, timestamp: now - HOUR_MS, }, - '$user2live123', + "$user2live123", ); // back to 'now' - jest.spyOn(global.Date, 'now').mockReturnValue(now); + jest.spyOn(global.Date, "now").mockReturnValue(now); }); afterAll(() => { - jest.spyOn(global.Date, 'now').mockRestore(); + jest.spyOn(global.Date, "now").mockRestore(); }); - it('creates beacon from event', () => { + it("creates beacon from event", () => { const beacon = new Beacon(liveBeaconEvent); expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId()); @@ -132,7 +128,7 @@ describe('Beacon', () => { expect(beacon.beaconInfo).toBeTruthy(); }); - it('creates beacon without error from a malformed event', () => { + it("creates beacon without error from a malformed event", () => { const event = new MatrixEvent({ type: M_BEACON_INFO.name, room_id: roomId, @@ -150,13 +146,13 @@ describe('Beacon', () => { expect(beacon.beaconInfo).toBeTruthy(); }); - describe('isLive()', () => { - it('returns false when beacon is explicitly set to not live', () => { + describe("isLive()", () => { + it("returns false when beacon is explicitly set to not live", () => { const beacon = new Beacon(notLiveBeaconEvent); expect(beacon.isLive).toEqual(false); }); - it('returns false when beacon is expired', () => { + it("returns false when beacon is expired", () => { const expiredBeaconEvent = makeBeaconInfoEvent( userId2, roomId, @@ -165,13 +161,13 @@ describe('Beacon', () => { isLive: true, timestamp: now - HOUR_MS * 2, }, - '$user2live123', + "$user2live123", ); const beacon = new Beacon(expiredBeaconEvent); expect(beacon.isLive).toEqual(false); }); - it('returns false when beacon timestamp is in future by an hour', () => { + it("returns false when beacon timestamp is in future by an hour", () => { const beaconStartsInHour = makeBeaconInfoEvent( userId2, roomId, @@ -180,13 +176,13 @@ describe('Beacon', () => { isLive: true, timestamp: now + HOUR_MS, }, - '$user2live123', + "$user2live123", ); const beacon = new Beacon(beaconStartsInHour); expect(beacon.isLive).toEqual(false); }); - it('returns true when beacon timestamp is one minute in the future', () => { + it("returns true when beacon timestamp is one minute in the future", () => { const beaconStartsInOneMin = makeBeaconInfoEvent( userId2, roomId, @@ -195,13 +191,13 @@ describe('Beacon', () => { isLive: true, timestamp: now + 60000, }, - '$user2live123', + "$user2live123", ); const beacon = new Beacon(beaconStartsInOneMin); expect(beacon.isLive).toEqual(true); }); - it('returns true when beacon timestamp is one minute before expiry', () => { + it("returns true when beacon timestamp is one minute before expiry", () => { // this test case is to check the start time leniency doesn't affect // strict expiry time checks const expiresInOneMin = makeBeaconInfoEvent( @@ -212,13 +208,13 @@ describe('Beacon', () => { isLive: true, timestamp: now - HOUR_MS + 60000, }, - '$user2live123', + "$user2live123", ); const beacon = new Beacon(expiresInOneMin); expect(beacon.isLive).toEqual(true); }); - it('returns false when beacon timestamp is one minute after expiry', () => { + it("returns false when beacon timestamp is one minute after expiry", () => { // this test case is to check the start time leniency doesn't affect // strict expiry time checks const expiredOneMinAgo = makeBeaconInfoEvent( @@ -229,21 +225,21 @@ describe('Beacon', () => { isLive: true, timestamp: now - HOUR_MS - 60000, }, - '$user2live123', + "$user2live123", ); const beacon = new Beacon(expiredOneMinAgo); expect(beacon.isLive).toEqual(false); }); - it('returns true when beacon was created in past and not yet expired', () => { + it("returns true when beacon was created in past and not yet expired", () => { // liveBeaconEvent was created 1 hour ago const beacon = new Beacon(liveBeaconEvent); expect(beacon.isLive).toEqual(true); }); }); - describe('update()', () => { - it('does not update with different event', () => { + describe("update()", () => { + it("does not update with different event", () => { const beacon = new Beacon(liveBeaconEvent); expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId()); @@ -253,15 +249,12 @@ describe('Beacon', () => { expect(beacon.identifier).toEqual(`${roomId}_${userId}`); }); - it('does not update with an older event', () => { + it("does not update with an older event", () => { const beacon = new Beacon(liveBeaconEvent); - const emitSpy = jest.spyOn(beacon, 'emit').mockClear(); + const emitSpy = jest.spyOn(beacon, "emit").mockClear(); expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId()); - const oldUpdateEvent = makeBeaconInfoEvent( - userId, - roomId, - ); + const oldUpdateEvent = makeBeaconInfoEvent(userId, roomId); // less than the original event oldUpdateEvent.event.origin_server_ts = liveBeaconEvent.event.origin_server_ts! - 1000; @@ -271,23 +264,27 @@ describe('Beacon', () => { expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId()); }); - it('updates event', () => { + it("updates event", () => { const beacon = new Beacon(liveBeaconEvent); - const emitSpy = jest.spyOn(beacon, 'emit'); + const emitSpy = jest.spyOn(beacon, "emit"); expect(beacon.isLive).toEqual(true); const updatedBeaconEvent = makeBeaconInfoEvent( - userId, roomId, { timeout: HOUR_MS * 3, isLive: false }, '$live123'); + userId, + roomId, + { timeout: HOUR_MS * 3, isLive: false }, + "$live123", + ); beacon.update(updatedBeaconEvent); expect(beacon.isLive).toEqual(false); expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.Update, updatedBeaconEvent, beacon); }); - it('emits livenesschange event when beacon liveness changes', () => { + it("emits livenesschange event when beacon liveness changes", () => { const beacon = new Beacon(liveBeaconEvent); - const emitSpy = jest.spyOn(beacon, 'emit'); + const emitSpy = jest.spyOn(beacon, "emit"); expect(beacon.isLive).toEqual(true); @@ -304,12 +301,12 @@ describe('Beacon', () => { }); }); - describe('monitorLiveness()', () => { - it('does not set a monitor interval when beacon is not live', () => { + describe("monitorLiveness()", () => { + it("does not set a monitor interval when beacon is not live", () => { // beacon was created an hour ago // and has a 3hr duration const beacon = new Beacon(notLiveBeaconEvent); - const emitSpy = jest.spyOn(beacon, 'emit'); + const emitSpy = jest.spyOn(beacon, "emit"); beacon.monitorLiveness(); @@ -321,7 +318,7 @@ describe('Beacon', () => { expect(emitSpy).not.toHaveBeenCalled(); }); - it('checks liveness of beacon at expected start time', () => { + it("checks liveness of beacon at expected start time", () => { const futureBeaconEvent = makeBeaconInfoEvent( userId, roomId, @@ -331,12 +328,12 @@ describe('Beacon', () => { // start timestamp hour in future timestamp: now + HOUR_MS, }, - '$live123', + "$live123", ); const beacon = new Beacon(futureBeaconEvent); expect(beacon.isLive).toBeFalsy(); - const emitSpy = jest.spyOn(beacon, 'emit'); + const emitSpy = jest.spyOn(beacon, "emit"); beacon.monitorLiveness(); @@ -355,12 +352,12 @@ describe('Beacon', () => { expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LivenessChange, false, beacon); }); - it('checks liveness of beacon at expected expiry time', () => { + it("checks liveness of beacon at expected expiry time", () => { // live beacon was created an hour ago // and has a 3hr duration const beacon = new Beacon(liveBeaconEvent); expect(beacon.isLive).toBeTruthy(); - const emitSpy = jest.spyOn(beacon, 'emit'); + const emitSpy = jest.spyOn(beacon, "emit"); beacon.monitorLiveness(); advanceDateAndTime(HOUR_MS * 2 + 1); @@ -369,7 +366,7 @@ describe('Beacon', () => { expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LivenessChange, false, beacon); }); - it('clears monitor interval when re-monitoring liveness', () => { + it("clears monitor interval when re-monitoring liveness", () => { // live beacon was created an hour ago // and has a 3hr duration const beacon = new Beacon(liveBeaconEvent); @@ -385,12 +382,12 @@ describe('Beacon', () => { expect(beacon.livenessWatchTimeout).not.toEqual(oldMonitor); }); - it('destroy kills liveness monitor and emits', () => { + it("destroy kills liveness monitor and emits", () => { // live beacon was created an hour ago // and has a 3hr duration const beacon = new Beacon(liveBeaconEvent); expect(beacon.isLive).toBeTruthy(); - const emitSpy = jest.spyOn(beacon, 'emit'); + const emitSpy = jest.spyOn(beacon, "emit"); beacon.monitorLiveness(); @@ -407,10 +404,10 @@ describe('Beacon', () => { }); }); - describe('addLocations', () => { - it('ignores locations when beacon is not live', () => { + describe("addLocations", () => { + it("ignores locations when beacon is not live", () => { const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: false })); - const emitSpy = jest.spyOn(beacon, 'emit'); + const emitSpy = jest.spyOn(beacon, "emit"); beacon.addLocations([ makeBeaconEvent(userId, { beaconInfoId: beacon.beaconInfoId, timestamp: now + 1 }), @@ -420,9 +417,9 @@ describe('Beacon', () => { expect(emitSpy).not.toHaveBeenCalled(); }); - it('ignores locations outside the beacon live duration', () => { + it("ignores locations outside the beacon live duration", () => { const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 })); - const emitSpy = jest.spyOn(beacon, 'emit'); + const emitSpy = jest.spyOn(beacon, "emit"); beacon.addLocations([ // beacon has now + 60000 live period @@ -435,7 +432,7 @@ describe('Beacon', () => { it("should ignore invalid beacon events", () => { const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 })); - const emitSpy = jest.spyOn(beacon, 'emit'); + const emitSpy = jest.spyOn(beacon, "emit"); const ev = new MatrixEvent({ type: M_BEACON_INFO.name, @@ -454,50 +451,48 @@ describe('Beacon', () => { 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', () => { + 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; - const beacon = new Beacon(makeBeaconInfoEvent( - userId, - roomId, - { isLive: true, timeout: 60000, timestamp: startTimestamp }, - )); - const emitSpy = jest.spyOn(beacon, 'emit'); + const beacon = new Beacon( + makeBeaconInfoEvent(userId, roomId, { + isLive: true, + timeout: 60000, + timestamp: startTimestamp, + }), + ); + const emitSpy = jest.spyOn(beacon, "emit"); beacon.addLocations([ // beacon has now + 60000 live period - makeBeaconEvent( - userId, - { - beaconInfoId: beacon.beaconInfoId, - // now < location timestamp < beacon timestamp - timestamp: now + 10, - }, - ), + makeBeaconEvent(userId, { + beaconInfoId: beacon.beaconInfoId, + // now < location timestamp < beacon timestamp + timestamp: now + 10, + }), ]); expect(beacon.latestLocationState).toBeFalsy(); expect(emitSpy).not.toHaveBeenCalled(); }); - it('sets latest location when location timestamp is after startTimestamp', () => { + it("sets latest location when location timestamp is after startTimestamp", () => { const startTimestamp = now + 60000; - const beacon = new Beacon(makeBeaconInfoEvent( - userId, - roomId, - { isLive: true, timeout: 600000, timestamp: startTimestamp }, - )); - const emitSpy = jest.spyOn(beacon, 'emit'); + const beacon = new Beacon( + makeBeaconInfoEvent(userId, roomId, { + isLive: true, + timeout: 600000, + timestamp: startTimestamp, + }), + ); + const emitSpy = jest.spyOn(beacon, "emit"); beacon.addLocations([ // beacon has now + 600000 live period - makeBeaconEvent( - userId, - { - beaconInfoId: beacon.beaconInfoId, - // now < beacon timestamp < location timestamp - timestamp: startTimestamp + 10, - }, - ), + makeBeaconEvent(userId, { + beaconInfoId: beacon.beaconInfoId, + // now < beacon timestamp < location timestamp + timestamp: startTimestamp + 10, + }), ]); expect(beacon.latestLocationState).toBeTruthy(); @@ -505,23 +500,21 @@ describe('Beacon', () => { }); }); - it('sets latest location state to most recent location', () => { + it("sets latest location state to most recent location", () => { const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 })); - const emitSpy = jest.spyOn(beacon, 'emit'); + const emitSpy = jest.spyOn(beacon, "emit"); const locations = [ // older - makeBeaconEvent( - userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:foo', timestamp: now + 1 }, - ), + makeBeaconEvent(userId, { beaconInfoId: beacon.beaconInfoId, uri: "geo:foo", timestamp: now + 1 }), // newer - makeBeaconEvent( - userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:bar', timestamp: now + 10000 }, - ), + makeBeaconEvent(userId, { + beaconInfoId: beacon.beaconInfoId, + uri: "geo:bar", + timestamp: now + 10000, + }), // not valid - makeBeaconEvent( - userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:baz', timestamp: now - 5 }, - ), + makeBeaconEvent(userId, { beaconInfoId: beacon.beaconInfoId, uri: "geo:baz", timestamp: now - 5 }), ]; beacon.addLocations(locations); @@ -529,7 +522,7 @@ describe('Beacon', () => { const expectedLatestLocation = { description: undefined, timestamp: now + 10000, - uri: 'geo:bar', + uri: "geo:bar", }; // the newest valid location @@ -538,32 +531,40 @@ describe('Beacon', () => { expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LocationUpdate, expectedLatestLocation); }); - it('ignores locations that are less recent that the current latest location', () => { + it("ignores locations that are less recent that the current latest location", () => { const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 })); - const olderLocation = makeBeaconEvent( - userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:foo', timestamp: now + 1 }, - ); - const newerLocation = makeBeaconEvent( - userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:bar', timestamp: now + 10000 }, - ); + const olderLocation = makeBeaconEvent(userId, { + beaconInfoId: beacon.beaconInfoId, + uri: "geo:foo", + timestamp: now + 1, + }); + const newerLocation = makeBeaconEvent(userId, { + beaconInfoId: beacon.beaconInfoId, + uri: "geo:bar", + timestamp: now + 10000, + }); beacon.addLocations([newerLocation]); // latest location set to newerLocation - expect(beacon.latestLocationState).toEqual(expect.objectContaining({ - uri: 'geo:bar', - })); + expect(beacon.latestLocationState).toEqual( + expect.objectContaining({ + uri: "geo:bar", + }), + ); expect(beacon.latestLocationEvent).toEqual(newerLocation); - const emitSpy = jest.spyOn(beacon, 'emit').mockClear(); + const emitSpy = jest.spyOn(beacon, "emit").mockClear(); // add older location beacon.addLocations([olderLocation]); // no change - expect(beacon.latestLocationState).toEqual(expect.objectContaining({ - uri: 'geo:bar', - })); + expect(beacon.latestLocationState).toEqual( + expect.objectContaining({ + uri: "geo:bar", + }), + ); // no emit expect(emitSpy).not.toHaveBeenCalled(); }); diff --git a/spec/unit/models/event.spec.ts b/spec/unit/models/event.spec.ts index 535e0f12db6..da492b54225 100644 --- a/spec/unit/models/event.spec.ts +++ b/spec/unit/models/event.spec.ts @@ -19,8 +19,8 @@ import { emitPromise } from "../../test-utils/test-utils"; import { EventType } from "../../../src"; import { Crypto } from "../../../src/crypto"; -describe('MatrixEvent', () => { - it('should create copies of itself', () => { +describe("MatrixEvent", () => { + it("should create copies of itself", () => { const a = new MatrixEvent({ type: "com.example.test", content: { @@ -38,7 +38,7 @@ describe('MatrixEvent', () => { // The other properties we're not super interested in, honestly. }); - it('should compare itself to other events using json', () => { + it("should compare itself to other events using json", () => { const a = new MatrixEvent({ type: "com.example.test", content: { @@ -121,47 +121,48 @@ describe('MatrixEvent', () => { }); describe(".attemptDecryption", () => { - let encryptedEvent; - const eventId = 'test_encrypted_event'; + let encryptedEvent: MatrixEvent; + const eventId = "test_encrypted_event"; beforeEach(() => { encryptedEvent = new MatrixEvent({ event_id: eventId, - type: 'm.room.encrypted', + type: "m.room.encrypted", content: { - ciphertext: 'secrets', + ciphertext: "secrets", }, }); }); - it('should retry decryption if a retry is queued', async () => { - const eventAttemptDecryptionSpy = jest.spyOn(encryptedEvent, 'attemptDecryption'); + it("should retry decryption if a retry is queued", async () => { + const eventAttemptDecryptionSpy = jest.spyOn(encryptedEvent, "attemptDecryption"); const crypto = { - decryptEvent: jest.fn() + decryptEvent: jest + .fn() .mockImplementationOnce(() => { // schedule a second decryption attempt while // the first one is still running. encryptedEvent.attemptDecryption(crypto); const error = new Error("nope"); - error.name = 'DecryptionError'; + error.name = "DecryptionError"; return Promise.reject(error); }) .mockImplementationOnce(() => { return Promise.resolve({ clearEvent: { - type: 'm.room.message', + type: "m.room.message", }, }); }), - }; + } as unknown as Crypto; await encryptedEvent.attemptDecryption(crypto); expect(eventAttemptDecryptionSpy).toHaveBeenCalledTimes(2); expect(crypto.decryptEvent).toHaveBeenCalledTimes(2); - expect(encryptedEvent.getType()).toEqual('m.room.message'); + expect(encryptedEvent.getType()).toEqual("m.room.message"); }); }); }); diff --git a/spec/unit/models/thread.spec.ts b/spec/unit/models/thread.spec.ts index 37e77954795..42b976cd6ac 100644 --- a/spec/unit/models/thread.spec.ts +++ b/spec/unit/models/thread.spec.ts @@ -14,13 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient } from "../../../src/client"; +import { MatrixClient, PendingEventOrdering } from "../../../src/client"; import { Room } from "../../../src/models/room"; -import { Thread } from "../../../src/models/thread"; +import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread"; import { mkThread } from "../../test-utils/thread"; import { TestClient } from "../../TestClient"; +import { emitPromise, mkMessage } from "../../test-utils/test-utils"; +import { EventStatus } from "../../../src"; -describe('Thread', () => { +describe("Thread", () => { describe("constructor", () => { it("should explode for element-web#22141 logging", () => { // Logging/debugging for https://github.com/vector-im/element-web/issues/22141 @@ -30,19 +32,53 @@ describe('Thread', () => { }); }); + it("includes pending events in replyCount", async () => { + const myUserId = "@bob:example.org"; + const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, { timelineSupport: false }); + const client = testClient.client; + const room = new Room("123", client, myUserId, { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + jest.spyOn(client, "getRoom").mockReturnValue(room); + + const { thread } = mkThread({ + room, + client, + authorId: myUserId, + participantUserIds: ["@alice:example.org"], + length: 3, + }); + await emitPromise(thread, ThreadEvent.Update); + expect(thread.length).toBe(2); + + const event = mkMessage({ + room: room.roomId, + user: myUserId, + msg: "thread reply", + relatesTo: { + rel_type: THREAD_RELATION_TYPE.name, + event_id: thread.id, + }, + event: true, + }); + await thread.processEvent(event); + event.setStatus(EventStatus.SENDING); + room.addPendingEvent(event, "txn01"); + + await emitPromise(thread, ThreadEvent.Update); + expect(thread.length).toBe(3); + }); + describe("hasUserReadEvent", () => { const myUserId = "@bob:example.org"; let client: MatrixClient; let room: Room; beforeEach(() => { - const testClient = new TestClient( - myUserId, - "DEVICE", - "ACCESS_TOKEN", - undefined, - { timelineSupport: false }, - ); + const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, { + timelineSupport: false, + }); client = testClient.client; room = new Room("123", client, myUserId); diff --git a/spec/unit/notifications.spec.ts b/spec/unit/notifications.spec.ts index e7e637d4acc..144afb70f12 100644 --- a/spec/unit/notifications.spec.ts +++ b/spec/unit/notifications.spec.ts @@ -39,7 +39,7 @@ let threadEvent: MatrixEvent; const ROOM_ID = "!roomId:example.org"; let THREAD_ID: string; -function mkPushAction(notify, highlight): IActionsObject { +function mkPushAction(notify: boolean, highlight: boolean): IActionsObject { return { notify, tweaks: { @@ -57,9 +57,9 @@ describe("fixNotificationCountOnDecryption", () => { decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0), supportsExperimentalThreads: jest.fn().mockReturnValue(true), }); - mockClient.reEmitter = mock(ReEmitter, 'ReEmitter'); + mockClient.reEmitter = mock(ReEmitter, "ReEmitter"); mockClient.canSupport = new Map(); - Object.keys(Feature).forEach(feature => { + Object.keys(Feature).forEach((feature) => { mockClient.canSupport.set(feature as Feature, ServerSupport.Stable); }); @@ -67,14 +67,17 @@ describe("fixNotificationCountOnDecryption", () => { room.setUnreadNotificationCount(NotificationCountType.Total, 1); room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); - event = mkEvent({ - type: EventType.RoomMessage, - content: { - msgtype: MsgType.Text, - body: "Hello world!", + event = mkEvent( + { + type: EventType.RoomMessage, + content: { + msgtype: MsgType.Text, + body: "Hello world!", + }, + event: true, }, - event: true, - }, mockClient); + mockClient, + ); THREAD_ID = event.getId()!; threadEvent = mkEvent({ @@ -138,6 +141,29 @@ describe("fixNotificationCountOnDecryption", () => { expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(0); }); + it("does not calculate for threads unknown to the room", () => { + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0); + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0); + + const unknownThreadEvent = mkEvent({ + type: EventType.RoomMessage, + content: { + "m.relates_to": { + rel_type: RelationType.Thread, + event_id: "$unknownthread", + }, + "msgtype": MsgType.Text, + "body": "Thread reply", + }, + event: true, + }); + + fixNotificationCountOnDecryption(mockClient, unknownThreadEvent); + + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(0); + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(0); + }); + it("emits events", () => { const cb = jest.fn(); room.on(RoomEvent.UnreadNotifications, cb); diff --git a/spec/unit/pusher.spec.ts b/spec/unit/pusher.spec.ts index dd46770a4aa..994ef767587 100644 --- a/spec/unit/pusher.spec.ts +++ b/spec/unit/pusher.spec.ts @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MockHttpBackend from 'matrix-mock-request'; +import MockHttpBackend from "matrix-mock-request"; import { MatrixClient, PUSHER_ENABLED } from "../../src/matrix"; -import { mkPusher } from '../test-utils/test-utils'; +import { mkPusher } from "../test-utils/test-utils"; const realSetTimeout = setTimeout; function flushPromises() { - return new Promise(r => { + return new Promise((r) => { realSetTimeout(r, 1); }); } diff --git a/spec/unit/pushprocessor.spec.ts b/spec/unit/pushprocessor.spec.ts index a2b61255389..dd84f03820a 100644 --- a/spec/unit/pushprocessor.spec.ts +++ b/spec/unit/pushprocessor.spec.ts @@ -2,7 +2,7 @@ import * as utils from "../test-utils/test-utils"; import { IActionsObject, PushProcessor } from "../../src/pushprocessor"; import { EventType, IContent, MatrixClient, MatrixEvent } from "../../src"; -describe('NotificationService', function() { +describe("NotificationService", function () { const testUserId = "@ali:matrix.org"; const testDisplayName = "Alice M"; const testRoomId = "!fl1bb13:localhost"; @@ -13,15 +13,15 @@ describe('NotificationService', function() { // These would be better if individual rules were configured in the tests themselves. const matrixClient = { - getRoom: function() { + getRoom: function () { return { currentState: { - getMember: function() { + getMember: function () { return { name: testDisplayName, }; }, - getJoinedMemberCount: function() { + getJoinedMemberCount: function () { return 0; }, members: {}, @@ -32,136 +32,136 @@ describe('NotificationService', function() { userId: testUserId, }, pushRules: { - "device": {}, - "global": { - "content": [ + device: {}, + global: { + content: [ { - "actions": [ + actions: [ "notify", { - "set_tweak": "sound", - "value": "default", + set_tweak: "sound", + value: "default", }, { - "set_tweak": "highlight", + set_tweak: "highlight", }, ], - "enabled": true, - "pattern": "ali", - "rule_id": ".m.rule.contains_user_name", + enabled: true, + pattern: "ali", + rule_id: ".m.rule.contains_user_name", }, { - "actions": [ + actions: [ "notify", { - "set_tweak": "sound", - "value": "default", + set_tweak: "sound", + value: "default", }, { - "set_tweak": "highlight", + set_tweak: "highlight", }, ], - "enabled": true, - "pattern": "coffee", - "rule_id": "coffee", + enabled: true, + pattern: "coffee", + rule_id: "coffee", }, { - "actions": [ + actions: [ "notify", { - "set_tweak": "sound", - "value": "default", + set_tweak: "sound", + value: "default", }, { - "set_tweak": "highlight", + set_tweak: "highlight", }, ], - "enabled": true, - "pattern": "foo*bar", - "rule_id": "foobar", + enabled: true, + pattern: "foo*bar", + rule_id: "foobar", }, { - "actions": [ + actions: [ "notify", { - "set_tweak": "sound", - "value": "default", + set_tweak: "sound", + value: "default", }, { - "set_tweak": "highlight", + set_tweak: "highlight", }, ], - "enabled": true, - "pattern": "p[io]ng", - "rule_id": "pingpong", + enabled: true, + pattern: "p[io]ng", + rule_id: "pingpong", }, { - "actions": [ + actions: [ "notify", { - "set_tweak": "sound", - "value": "default", + set_tweak: "sound", + value: "default", }, { - "set_tweak": "highlight", + set_tweak: "highlight", }, ], - "enabled": true, - "pattern": "I ate [0-9] pies", - "rule_id": "pies", + enabled: true, + pattern: "I ate [0-9] pies", + rule_id: "pies", }, { - "actions": [ + actions: [ "notify", { - "set_tweak": "sound", - "value": "default", + set_tweak: "sound", + value: "default", }, { - "set_tweak": "highlight", + set_tweak: "highlight", }, ], - "enabled": true, - "pattern": "b[!ai]ke", - "rule_id": "bakebike", + enabled: true, + pattern: "b[!ai]ke", + rule_id: "bakebike", }, ], - "override": [ + override: [ { - "actions": [ + actions: [ "notify", { - "set_tweak": "sound", - "value": "default", + set_tweak: "sound", + value: "default", }, { - "set_tweak": "highlight", + set_tweak: "highlight", }, ], - "conditions": [ + conditions: [ { - "kind": "contains_display_name", + kind: "contains_display_name", }, ], - "enabled": true, - "rule_id": ".m.rule.contains_display_name", + enabled: true, + rule_id: ".m.rule.contains_display_name", }, { - "actions": [ + actions: [ "notify", { - "set_tweak": "sound", - "value": "default", + set_tweak: "sound", + value: "default", }, ], - "conditions": [ + conditions: [ { - "is": "2", - "kind": "room_member_count", + is: "2", + kind: "room_member_count", }, ], - "enabled": true, - "rule_id": ".m.rule.room_one_to_one", + enabled: true, + rule_id: ".m.rule.room_one_to_one", }, { rule_id: ".org.matrix.msc3914.rule.room.call", @@ -180,41 +180,39 @@ describe('NotificationService', function() { actions: ["notify", { set_tweak: "sound", value: "default" }], }, ], - "room": [], - "sender": [], - "underride": [ + room: [], + sender: [], + underride: [ { - "actions": [ - "dont-notify", - ], - "conditions": [ + actions: ["dont-notify"], + conditions: [ { - "key": "content.msgtype", - "kind": "event_match", - "pattern": "m.notice", + key: "content.msgtype", + kind: "event_match", + pattern: "m.notice", }, ], - "enabled": true, - "rule_id": ".m.rule.suppress_notices", + enabled: true, + rule_id: ".m.rule.suppress_notices", }, { - "actions": [ + actions: [ "notify", { - "set_tweak": "highlight", - "value": false, + set_tweak: "highlight", + value: false, }, ], - "conditions": [], - "enabled": true, - "rule_id": ".m.rule.fallback", + conditions: [], + enabled: true, + rule_id: ".m.rule.fallback", }, ], }, }, } as unknown as MatrixClient; - beforeEach(function() { + beforeEach(function () { testEvent = utils.mkEvent({ type: "m.room.message", room: testRoomId, @@ -231,25 +229,25 @@ describe('NotificationService', function() { // User IDs - it('should bing on a user ID.', function() { + it("should bing on a user ID.", function () { testEvent.event.content!.body = "Hello @ali:matrix.org, how are you?"; const actions = pushProcessor.actionsForEvent(testEvent); expect(actions.tweaks.highlight).toEqual(true); }); - it('should bing on a partial user ID with an @.', function() { + it("should bing on a partial user ID with an @.", function () { testEvent.event.content!.body = "Hello @ali, how are you?"; const actions = pushProcessor.actionsForEvent(testEvent); expect(actions.tweaks.highlight).toEqual(true); }); - it('should bing on a partial user ID without @.', function() { + it("should bing on a partial user ID without @.", function () { testEvent.event.content!.body = "Hello ali, how are you?"; const actions = pushProcessor.actionsForEvent(testEvent); expect(actions.tweaks.highlight).toEqual(true); }); - it('should bing on a case-insensitive user ID.', function() { + it("should bing on a case-insensitive user ID.", function () { testEvent.event.content!.body = "Hello @AlI:matrix.org, how are you?"; const actions = pushProcessor.actionsForEvent(testEvent); expect(actions.tweaks.highlight).toEqual(true); @@ -257,13 +255,13 @@ describe('NotificationService', function() { // Display names - it('should bing on a display name.', function() { + it("should bing on a display name.", function () { testEvent.event.content!.body = "Hello Alice M, how are you?"; const actions = pushProcessor.actionsForEvent(testEvent); expect(actions.tweaks.highlight).toEqual(true); }); - it('should bing on a case-insensitive display name.', function() { + it("should bing on a case-insensitive display name.", function () { testEvent.event.content!.body = "Hello ALICE M, how are you?"; const actions = pushProcessor.actionsForEvent(testEvent); expect(actions.tweaks.highlight).toEqual(true); @@ -271,25 +269,25 @@ describe('NotificationService', function() { // Bing words - it('should bing on a bing word.', function() { + it("should bing on a bing word.", function () { testEvent.event.content!.body = "I really like coffee"; const actions = pushProcessor.actionsForEvent(testEvent); expect(actions.tweaks.highlight).toEqual(true); }); - it('should bing on case-insensitive bing words.', function() { + it("should bing on case-insensitive bing words.", function () { testEvent.event.content!.body = "Coffee is great"; const actions = pushProcessor.actionsForEvent(testEvent); expect(actions.tweaks.highlight).toEqual(true); }); - it('should bing on wildcard (.*) bing words.', function() { + it("should bing on wildcard (.*) bing words.", function () { testEvent.event.content!.body = "It was foomahbar I think."; const actions = pushProcessor.actionsForEvent(testEvent); expect(actions.tweaks.highlight).toEqual(true); }); - it('should bing on character group ([abc]) bing words.', function() { + it("should bing on character group ([abc]) bing words.", function () { testEvent.event.content!.body = "Ping!"; let actions = pushProcessor.actionsForEvent(testEvent); expect(actions.tweaks.highlight).toEqual(true); @@ -298,13 +296,13 @@ describe('NotificationService', function() { expect(actions.tweaks.highlight).toEqual(true); }); - it('should bing on character range ([a-z]) bing words.', function() { + it("should bing on character range ([a-z]) bing words.", function () { testEvent.event.content!.body = "I ate 6 pies"; const actions = pushProcessor.actionsForEvent(testEvent); expect(actions.tweaks.highlight).toEqual(true); }); - it('should bing on character negation ([!a]) bing words.', function() { + it("should bing on character negation ([!a]) bing words.", function () { testEvent.event.content!.body = "boke"; let actions = pushProcessor.actionsForEvent(testEvent); expect(actions.tweaks.highlight).toEqual(true); @@ -313,7 +311,7 @@ describe('NotificationService', function() { expect(actions.tweaks.highlight).toEqual(false); }); - it('should not bing on room server ACL changes', function() { + it("should not bing on room server ACL changes", function () { testEvent = utils.mkEvent({ type: EventType.RoomServerAcl, room: testRoomId, @@ -331,31 +329,41 @@ describe('NotificationService', function() { // invalid - it('should gracefully handle bad input.', function() { - testEvent.event.content!.body = { "foo": "bar" }; + it("should gracefully handle bad input.", function () { + testEvent.event.content!.body = { foo: "bar" }; const actions = pushProcessor.actionsForEvent(testEvent); expect(actions.tweaks.highlight).toEqual(false); }); - it("a rule with no conditions matches every event.", function() { - expect(pushProcessor.ruleMatchesEvent({ - rule_id: "rule1", - actions: [], - conditions: [], - default: false, - enabled: true, - }, testEvent)).toBe(true); - expect(pushProcessor.ruleMatchesEvent({ - rule_id: "rule1", - actions: [], - default: false, - enabled: true, - }, testEvent)).toBe(true); + it("a rule with no conditions matches every event.", function () { + expect( + pushProcessor.ruleMatchesEvent( + { + rule_id: "rule1", + actions: [], + conditions: [], + default: false, + enabled: true, + }, + testEvent, + ), + ).toBe(true); + expect( + pushProcessor.ruleMatchesEvent( + { + rule_id: "rule1", + actions: [], + default: false, + enabled: true, + }, + testEvent, + ), + ).toBe(true); }); describe("group call started push rule", () => { beforeEach(() => { - matrixClient.pushRules!.global!.underride!.find(r => r.rule_id === ".m.rule.fallback")!.enabled = false; + matrixClient.pushRules!.global!.underride!.find((r) => r.rule_id === ".m.rule.fallback")!.enabled = false; }); const getActionsForEvent = (prevContent: IContent, content: IContent): IActionsObject => { @@ -384,73 +392,104 @@ describe('NotificationService', function() { 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.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", - })); + 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", - })); + 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", - })); + 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", - })); + 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", - })); + 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/queueToDevice.spec.ts b/spec/unit/queueToDevice.spec.ts index 298f5341ca9..c09d6968703 100644 --- a/spec/unit/queueToDevice.spec.ts +++ b/spec/unit/queueToDevice.spec.ts @@ -14,21 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MockHttpBackend from 'matrix-mock-request'; -import { indexedDB as fakeIndexedDB } from 'fake-indexeddb'; +import MockHttpBackend from "matrix-mock-request"; +import { indexedDB as fakeIndexedDB } from "fake-indexeddb"; import { IndexedDBStore, MatrixEvent, MemoryStore, Room } from "../../src"; import { MatrixClient } from "../../src/client"; -import { ToDeviceBatch } from '../../src/models/ToDeviceMessage'; -import { logger } from '../../src/logger'; -import { IStore } from '../../src/store'; -import { flushPromises } from '../test-utils/flushPromises'; +import { ToDeviceBatch } from "../../src/models/ToDeviceMessage"; +import { logger } from "../../src/logger"; +import { IStore } from "../../src/store"; +import { flushPromises } from "../test-utils/flushPromises"; import { removeElement } from "../../src/utils"; const FAKE_USER = "@alice:example.org"; const FAKE_DEVICE_ID = "AAAAAAAA"; const FAKE_PAYLOAD = { - "foo": 42, + foo: 42, }; const EXPECTED_BODY = { messages: { @@ -45,8 +45,8 @@ const FAKE_MSG = { }; enum StoreType { - Memory = 'Memory', - IndexedDB = 'IndexedDB', + Memory = "Memory", + IndexedDB = "IndexedDB", } async function flushAndRunTimersUntil(cond: () => boolean) { @@ -57,13 +57,11 @@ async function flushAndRunTimersUntil(cond: () => boolean) { } } -describe.each([ - [StoreType.Memory], [StoreType.IndexedDB], -])("queueToDevice (%s store)", function(storeType) { +describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s store)", function (storeType) { let httpBackend: MockHttpBackend; let client: MatrixClient; - beforeEach(async function() { + beforeEach(async function () { jest.runOnlyPendingTimers(); jest.useRealTimers(); httpBackend = new MockHttpBackend(); @@ -85,23 +83,22 @@ describe.each([ }); }); - afterEach(function() { + afterEach(function () { jest.useRealTimers(); client.stopClient(); }); - it("sends a to-device message", async function() { - httpBackend.when( - "PUT", "/sendToDevice/org.example.foo/", - ).check((request) => { - expect(request.data).toEqual(EXPECTED_BODY); - }).respond(200, {}); + it("sends a to-device message", async function () { + httpBackend + .when("PUT", "/sendToDevice/org.example.foo/") + .check((request) => { + expect(request.data).toEqual(EXPECTED_BODY); + }) + .respond(200, {}); await client.queueToDevice({ eventType: "org.example.foo", - batch: [ - FAKE_MSG, - ], + batch: [FAKE_MSG], }); await httpBackend.flushAllExpected(); @@ -111,24 +108,21 @@ describe.each([ await flushPromises(); }); - it("retries on error", async function() { + it("retries on error", async function () { jest.useFakeTimers(); - httpBackend.when( - "PUT", "/sendToDevice/org.example.foo/", - ).respond(500); + httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500); - httpBackend.when( - "PUT", "/sendToDevice/org.example.foo/", - ).check((request) => { - expect(request.data).toEqual(EXPECTED_BODY); - }).respond(200, {}); + httpBackend + .when("PUT", "/sendToDevice/org.example.foo/") + .check((request) => { + expect(request.data).toEqual(EXPECTED_BODY); + }) + .respond(200, {}); await client.queueToDevice({ eventType: "org.example.foo", - batch: [ - FAKE_MSG, - ], + batch: [FAKE_MSG], }); await flushAndRunTimersUntil(() => httpBackend.requests.length > 0); expect(httpBackend.flushSync(undefined, 1)).toEqual(1); @@ -141,18 +135,14 @@ describe.each([ await flushPromises(); }); - it("stops retrying on 4xx errors", async function() { + it("stops retrying on 4xx errors", async function () { jest.useFakeTimers(); - httpBackend.when( - "PUT", "/sendToDevice/org.example.foo/", - ).respond(400); + httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(400); await client.queueToDevice({ eventType: "org.example.foo", - batch: [ - FAKE_MSG, - ], + batch: [FAKE_MSG], }); await flushAndRunTimersUntil(() => httpBackend.requests.length > 0); expect(httpBackend.flushSync(undefined, 1)).toEqual(1); @@ -166,29 +156,23 @@ describe.each([ expect(httpBackend.requests.length).toEqual(0); }); - it("honours ratelimiting", async function() { + it("honours ratelimiting", async function () { jest.useFakeTimers(); // pick something obscure enough it's unlikley to clash with a // retry delay the algorithm uses anyway const retryDelay = 279 * 1000; - httpBackend.when( - "PUT", "/sendToDevice/org.example.foo/", - ).respond(429, { + httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(429, { errcode: "M_LIMIT_EXCEEDED", retry_after_ms: retryDelay, }); - httpBackend.when( - "PUT", "/sendToDevice/org.example.foo/", - ).respond(200, {}); + httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {}); await client.queueToDevice({ eventType: "org.example.foo", - batch: [ - FAKE_MSG, - ], + batch: [FAKE_MSG], }); await flushAndRunTimersUntil(() => httpBackend.requests.length > 0); expect(httpBackend.flushSync(undefined, 1)).toEqual(1); @@ -209,26 +193,20 @@ describe.each([ expect(httpBackend.flushSync(undefined, 1)).toEqual(1); }); - it("retries on retryImmediately()", async function() { + it("retries on retryImmediately()", async function () { httpBackend.when("GET", "/_matrix/client/versions").respond(200, { versions: ["r0.0.1"], }); await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]); - httpBackend.when( - "PUT", "/sendToDevice/org.example.foo/", - ).respond(500); + httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500); - httpBackend.when( - "PUT", "/sendToDevice/org.example.foo/", - ).respond(200, {}); + httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {}); await client.queueToDevice({ eventType: "org.example.foo", - batch: [ - FAKE_MSG, - ], + batch: [FAKE_MSG], }); expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1); await flushPromises(); @@ -239,26 +217,20 @@ describe.each([ expect(await httpBackend.flush(undefined, 1, 3000)).toEqual(1); }); - it("retries on when client is started", async function() { + it("retries on when client is started", async function () { httpBackend.when("GET", "/_matrix/client/versions").respond(200, { versions: ["r0.0.1"], }); await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]); - httpBackend.when( - "PUT", "/sendToDevice/org.example.foo/", - ).respond(500); + httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500); - httpBackend.when( - "PUT", "/sendToDevice/org.example.foo/", - ).respond(200, {}); + httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {}); await client.queueToDevice({ eventType: "org.example.foo", - batch: [ - FAKE_MSG, - ], + batch: [FAKE_MSG], }); expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1); await flushPromises(); @@ -269,26 +241,20 @@ describe.each([ expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1); }); - it("retries when a message is retried", async function() { + it("retries when a message is retried", async function () { httpBackend.when("GET", "/_matrix/client/versions").respond(200, { versions: ["r0.0.1"], }); await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]); - httpBackend.when( - "PUT", "/sendToDevice/org.example.foo/", - ).respond(500); + httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500); - httpBackend.when( - "PUT", "/sendToDevice/org.example.foo/", - ).respond(200, {}); + httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {}); await client.queueToDevice({ eventType: "org.example.foo", - batch: [ - FAKE_MSG, - ], + batch: [FAKE_MSG], }); expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1); @@ -305,7 +271,7 @@ describe.each([ expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1); }); - it("splits many messages into multiple HTTP requests", async function() { + it("splits many messages into multiple HTTP requests", async function () { const batch: ToDeviceBatch = { eventType: "org.example.foo", batch: [], @@ -320,16 +286,20 @@ describe.each([ } const expectedCounts = [20, 1]; - httpBackend.when( - "PUT", "/sendToDevice/org.example.foo/", - ).check((request) => { - expect(removeElement(expectedCounts, c => c === Object.keys(request.data.messages).length)).toBeTruthy(); - }).respond(200, {}); - httpBackend.when( - "PUT", "/sendToDevice/org.example.foo/", - ).check((request) => { - expect(Object.keys(request.data.messages).length).toEqual(1); - }).respond(200, {}); + httpBackend + .when("PUT", "/sendToDevice/org.example.foo/") + .check((request) => { + expect( + removeElement(expectedCounts, (c) => c === Object.keys(request.data.messages).length), + ).toBeTruthy(); + }) + .respond(200, {}); + httpBackend + .when("PUT", "/sendToDevice/org.example.foo/") + .check((request) => { + expect(Object.keys(request.data.messages).length).toEqual(1); + }) + .respond(200, {}); await client.queueToDevice(batch); await httpBackend.flushAllExpected(); diff --git a/spec/unit/read-receipt.spec.ts b/spec/unit/read-receipt.spec.ts index 4443c25befc..46e46b1b356 100644 --- a/spec/unit/read-receipt.spec.ts +++ b/spec/unit/read-receipt.spec.ts @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MockHttpBackend from 'matrix-mock-request'; +import MockHttpBackend from "matrix-mock-request"; -import { MAIN_ROOM_TIMELINE, ReceiptType } from '../../src/@types/read_receipts'; +import { MAIN_ROOM_TIMELINE, ReceiptType } from "../../src/@types/read_receipts"; import { MatrixClient } from "../../src/client"; -import { Feature, ServerSupport } from '../../src/feature'; -import { EventType } from '../../src/matrix'; -import { synthesizeReceipt } from '../../src/models/read-receipt'; -import { encodeUri } from '../../src/utils'; +import { Feature, ServerSupport } from "../../src/feature"; +import { EventType } from "../../src/matrix"; +import { synthesizeReceipt } 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 @@ -32,7 +32,7 @@ import * as utils from "../test-utils/test-utils"; // and avoids assuming anything about the app's behaviour. const realSetTimeout = setTimeout; function flushPromises() { - return new Promise(r => { + return new Promise((r) => { realSetTimeout(r, 1); }); } @@ -53,7 +53,7 @@ const threadEvent = utils.mkEvent({ "m.relates_to": { "event_id": THREAD_ID, "m.in_reply_to": { - "event_id": THREAD_ID, + event_id: THREAD_ID, }, "rel_type": "m.thread", }, @@ -66,11 +66,11 @@ const roomEvent = utils.mkEvent({ user: "@bob:matrix.org", room: ROOM_ID, content: { - "body": "Hello from a room", + body: "Hello from a room", }, }); -function mockServerSideSupport(client, serverSideSupport: ServerSupport) { +function mockServerSideSupport(client: MatrixClient, serverSideSupport: ServerSupport) { client.canSupport.set(Feature.ThreadUnreadNotifications, serverSideSupport); } @@ -87,15 +87,19 @@ describe("Read receipt", () => { 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, {}); + 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, ServerSupport.Stable); client.sendReceipt(threadEvent, ReceiptType.Read, {}); @@ -105,15 +109,19 @@ describe("Read receipt", () => { }); it("sends an unthreaded 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).toBeUndefined(); - }).respond(200, {}); + 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, ServerSupport.Stable); client.sendReadReceipt(threadEvent, ReceiptType.Read, true); @@ -123,15 +131,19 @@ describe("Read receipt", () => { }); 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, {}); + 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, ServerSupport.Stable); client.sendReceipt(roomEvent, ReceiptType.Read, {}); @@ -141,15 +153,19 @@ describe("Read receipt", () => { }); 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, {}); + 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, ServerSupport.Unsupported); client.sendReceipt(threadEvent, ReceiptType.Read, {}); @@ -159,15 +175,19 @@ describe("Read receipt", () => { }); it("sends a valid room read receipt even when body omitted", async () => { - httpBackend.when( - "POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", { - $roomId: ROOM_ID, - $receiptType: ReceiptType.Read, - $eventId: threadEvent.getId()!, - }), - ).check((request) => { - expect(request.data).toEqual({}); - }).respond(200, {}); + httpBackend + .when( + "POST", + encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", { + $roomId: ROOM_ID, + $receiptType: ReceiptType.Read, + $eventId: threadEvent.getId()!, + }), + ) + .check((request) => { + expect(request.data).toEqual({}); + }) + .respond(200, {}); mockServerSideSupport(client, ServerSupport.Unsupported); client.sendReceipt(threadEvent, ReceiptType.Read, undefined); diff --git a/spec/unit/realtime-callbacks.spec.ts b/spec/unit/realtime-callbacks.spec.ts index dd0d605a0cc..7cb1806a39c 100644 --- a/spec/unit/realtime-callbacks.spec.ts +++ b/spec/unit/realtime-callbacks.spec.ts @@ -19,14 +19,14 @@ import * as callbacks from "../../src/realtime-callbacks"; let wallTime = 1234567890; jest.useFakeTimers().setSystemTime(wallTime); -describe("realtime-callbacks", function() { - function tick(millis) { +describe("realtime-callbacks", function () { + function tick(millis: number): void { wallTime += millis; jest.advanceTimersByTime(millis); } - describe("setTimeout", function() { - it("should call the callback after the timeout", function() { + describe("setTimeout", function () { + it("should call the callback after the timeout", function () { const callback = jest.fn(); callbacks.setTimeout(callback, 100); @@ -35,7 +35,7 @@ describe("realtime-callbacks", function() { expect(callback).toHaveBeenCalled(); }); - it("should default to a zero timeout", function() { + it("should default to a zero timeout", function () { const callback = jest.fn(); callbacks.setTimeout(callback, 0); @@ -44,16 +44,16 @@ describe("realtime-callbacks", function() { expect(callback).toHaveBeenCalled(); }); - it("should pass any parameters to the callback", function() { + it("should pass any parameters to the callback", function () { const callback = jest.fn(); callbacks.setTimeout(callback, 0, "a", "b", "c"); tick(0); expect(callback).toHaveBeenCalledWith("a", "b", "c"); }); - it("should set 'this' to the global object", function() { + it("should set 'this' to the global object", function () { let passed = false; - const callback = function(this: typeof global) { + const callback = function (this: typeof global) { expect(this).toBe(global); // eslint-disable-line @typescript-eslint/no-invalid-this expect(this.console).toBeTruthy(); // eslint-disable-line @typescript-eslint/no-invalid-this passed = true; @@ -63,7 +63,7 @@ describe("realtime-callbacks", function() { expect(passed).toBe(true); }); - it("should handle timeouts of several seconds", function() { + it("should handle timeouts of several seconds", function () { const callback = jest.fn(); callbacks.setTimeout(callback, 2000); @@ -74,7 +74,7 @@ describe("realtime-callbacks", function() { expect(callback).toHaveBeenCalled(); }); - it("should call multiple callbacks in the right order", function() { + it("should call multiple callbacks in the right order", function () { const callback1 = jest.fn(); const callback2 = jest.fn(); const callback3 = jest.fn(); @@ -99,12 +99,12 @@ describe("realtime-callbacks", function() { expect(callback3).toHaveBeenCalled(); }); - it("should treat -ve timeouts the same as a zero timeout", function() { + it("should treat -ve timeouts the same as a zero timeout", function () { const callback1 = jest.fn(); const callback2 = jest.fn(); // check that cb1 is called before cb2 - callback1.mockImplementation(function() { + callback1.mockImplementation(function () { expect(callback2).not.toHaveBeenCalled(); }); @@ -118,9 +118,9 @@ describe("realtime-callbacks", function() { expect(callback2).toHaveBeenCalled(); }); - it("should not get confused by chained calls", function() { + it("should not get confused by chained calls", function () { const callback2 = jest.fn(); - const callback1 = jest.fn(function() { + const callback1 = jest.fn(function () { callbacks.setTimeout(callback2, 0); expect(callback2).not.toHaveBeenCalled(); }); @@ -136,8 +136,8 @@ describe("realtime-callbacks", function() { expect(callback2).toHaveBeenCalled(); }); - it("should be immune to exceptions", function() { - const callback1 = jest.fn(function() { + it("should be immune to exceptions", function () { + const callback1 = jest.fn(function () { throw new Error("prepare to die"); }); const callback2 = jest.fn(); @@ -152,8 +152,8 @@ describe("realtime-callbacks", function() { }); }); - describe("cancelTimeout", function() { - it("should cancel a pending timeout", function() { + describe("cancelTimeout", function () { + it("should cancel a pending timeout", function () { const callback = jest.fn(); const k = callbacks.setTimeout(callback, 10); callbacks.clearTimeout(k); @@ -161,7 +161,7 @@ describe("realtime-callbacks", function() { expect(callback).not.toHaveBeenCalled(); }); - it("should not affect sooner timeouts", function() { + it("should not affect sooner timeouts", function () { const callback1 = jest.fn(); const callback2 = jest.fn(); diff --git a/spec/unit/relations.spec.ts b/spec/unit/relations.spec.ts index 022f8c0c503..91b77dd1212 100644 --- a/spec/unit/relations.spec.ts +++ b/spec/unit/relations.spec.ts @@ -20,22 +20,22 @@ import { Room } from "../../src/models/room"; import { Relations } from "../../src/models/relations"; import { TestClient } from "../TestClient"; -describe("Relations", function() { - it("should deduplicate annotations", function() { +describe("Relations", function () { + it("should deduplicate annotations", function () { const room = new Room("room123", null!, null!); const relations = new Relations("m.annotation", "m.reaction", room); // Create an instance of an annotation const eventData = { - "sender": "@bob:example.com", - "type": "m.reaction", - "event_id": "$cZ1biX33ENJqIm00ks0W_hgiO_6CHrsAc3ZQrnLeNTw", - "room_id": "!pzVjCQSoQPpXQeHpmK:example.com", - "content": { + sender: "@bob:example.com", + type: "m.reaction", + event_id: "$cZ1biX33ENJqIm00ks0W_hgiO_6CHrsAc3ZQrnLeNTw", + room_id: "!pzVjCQSoQPpXQeHpmK:example.com", + content: { "m.relates_to": { - "event_id": "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o", - "key": "👍️", - "rel_type": "m.annotation", + event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o", + key: "👍️", + rel_type: "m.annotation", }, }, }; @@ -75,24 +75,24 @@ describe("Relations", function() { } }); - it("should emit created regardless of ordering", async function() { + it("should emit created regardless of ordering", async function () { const targetEvent = new MatrixEvent({ - "sender": "@bob:example.com", - "type": "m.room.message", - "event_id": "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o", - "room_id": "!pzVjCQSoQPpXQeHpmK:example.com", - "content": {}, + sender: "@bob:example.com", + type: "m.room.message", + event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o", + room_id: "!pzVjCQSoQPpXQeHpmK:example.com", + content: {}, }); const relationEvent = new MatrixEvent({ - "sender": "@bob:example.com", - "type": "m.reaction", - "event_id": "$cZ1biX33ENJqIm00ks0W_hgiO_6CHrsAc3ZQrnLeNTw", - "room_id": "!pzVjCQSoQPpXQeHpmK:example.com", - "content": { + sender: "@bob:example.com", + type: "m.reaction", + event_id: "$cZ1biX33ENJqIm00ks0W_hgiO_6CHrsAc3ZQrnLeNTw", + room_id: "!pzVjCQSoQPpXQeHpmK:example.com", + content: { "m.relates_to": { - "event_id": "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o", - "key": "👍️", - "rel_type": "m.annotation", + event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o", + key: "👍️", + rel_type: "m.annotation", }, }, }); @@ -100,7 +100,7 @@ describe("Relations", function() { // Add the target event first, then the relation event { const room = new Room("room123", null!, null!); - const relationsCreated = new Promise(resolve => { + const relationsCreated = new Promise((resolve) => { targetEvent.once(MatrixEventEvent.RelationsCreated, resolve); }); @@ -114,7 +114,7 @@ describe("Relations", function() { // Add the relation event first, then the target event { const room = new Room("room123", null!, null!); - const relationsCreated = new Promise(resolve => { + const relationsCreated = new Promise((resolve) => { targetEvent.once(MatrixEventEvent.RelationsCreated, resolve); }); @@ -141,31 +141,31 @@ describe("Relations", function() { // Create an instance of a state event with rel_type m.replace const originalTopic = new MatrixEvent({ - "sender": userId, - "type": "m.room.topic", - "event_id": "$orig", - "room_id": room.roomId, - "content": { - "topic": "orig", + sender: userId, + type: "m.room.topic", + event_id: "$orig", + room_id: room.roomId, + content: { + topic: "orig", }, - "state_key": "", + state_key: "", }); const badlyEditedTopic = new MatrixEvent({ - "sender": userId, - "type": "m.room.topic", - "event_id": "$orig", - "room_id": room.roomId, - "content": { + sender: userId, + type: "m.room.topic", + event_id: "$orig", + room_id: room.roomId, + content: { "topic": "topic", "m.new_content": { - "topic": "edit", + topic: "edit", }, "m.relates_to": { - "event_id": "$orig", - "rel_type": "m.replace", + event_id: "$orig", + rel_type: "m.replace", }, }, - "state_key": "", + state_key: "", }); await relations.setTargetEvent(originalTopic); @@ -188,14 +188,14 @@ describe("Relations", function() { // Create an instance of an annotation const eventData = { - "sender": "@bob:example.com", - "type": "m.room.message", - "event_id": "$cZ1biX33ENJqIm00ks0W_hgiO_6CHrsAc3ZQrnLeNTw", - "room_id": "!pzVjCQSoQPpXQeHpmK:example.com", - "content": { + sender: "@bob:example.com", + type: "m.room.message", + event_id: "$cZ1biX33ENJqIm00ks0W_hgiO_6CHrsAc3ZQrnLeNTw", + room_id: "!pzVjCQSoQPpXQeHpmK:example.com", + content: { "m.relates_to": { - "event_id": "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o", - "rel_type": "m.replace", + event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o", + rel_type: "m.replace", }, }, }; diff --git a/spec/unit/rendezvous/DummyTransport.ts b/spec/unit/rendezvous/DummyTransport.ts index 617b0380c71..caa8eaa0d01 100644 --- a/spec/unit/rendezvous/DummyTransport.ts +++ b/spec/unit/rendezvous/DummyTransport.ts @@ -21,7 +21,7 @@ import { RendezvousTransport, RendezvousTransportDetails, } from "../../../src/rendezvous"; -import { sleep } from '../../../src/utils'; +import { sleep } from "../../../src/utils"; export class DummyTransport implements RendezvousTransport { otherParty?: DummyTransport; @@ -41,8 +41,9 @@ export class DummyTransport implements async send(data: T): Promise { logger.info( - `[${this.name}] => [${this.otherParty?.name}] Attempting to send data: ${ - JSON.stringify(data)} where etag matches ${this.etag}`, + `[${this.name}] => [${this.otherParty?.name}] Attempting to send data: ${JSON.stringify( + data, + )} where etag matches ${this.etag}`, ); // eslint-disable-next-line no-constant-condition while (!this.cancelled) { @@ -68,12 +69,13 @@ export class DummyTransport implements this.lastEtagReceived = this.etag; logger.info( `[${this.otherParty?.name}] => [${this.name}] Received data: ` + - `${JSON.stringify(this.data)} with etag ${this.etag}`, + `${JSON.stringify(this.data)} with etag ${this.etag}`, ); return this.data; } - logger.info(`[${this.name}] Sleeping to retry receive after etag ${ - this.lastEtagReceived} as remote is ${this.etag}`); + logger.info( + `[${this.name}] Sleeping to retry receive after etag ${this.lastEtagReceived} as remote is ${this.etag}`, + ); await sleep(250); } diff --git a/spec/unit/rendezvous/ecdh.spec.ts b/spec/unit/rendezvous/ecdh.spec.ts index b8f40b52c3b..f0b2daa1da3 100644 --- a/spec/unit/rendezvous/ecdh.spec.ts +++ b/spec/unit/rendezvous/ecdh.spec.ts @@ -14,25 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import '../../olm-loader'; +import "../../olm-loader"; import { RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous"; -import { MSC3903ECDHPayload, MSC3903ECDHv1RendezvousChannel } from '../../../src/rendezvous/channels'; -import { decodeBase64 } from '../../../src/crypto/olmlib'; -import { DummyTransport } from './DummyTransport'; +import { MSC3903ECDHPayload, MSC3903ECDHv1RendezvousChannel } from "../../../src/rendezvous/channels"; +import { decodeBase64 } from "../../../src/crypto/olmlib"; +import { DummyTransport } from "./DummyTransport"; function makeTransport(name: string) { - return new DummyTransport(name, { type: 'dummy' }); + return new DummyTransport(name, { type: "dummy" }); } -describe('ECDHv1', function() { - beforeAll(async function() { +describe("ECDHv1", function () { + beforeAll(async function () { await global.Olm.init(); }); - describe('with crypto', () => { - it("initiator wants to sign in", async function() { - const aliceTransport = makeTransport('Alice'); - const bobTransport = makeTransport('Bob'); + describe("with crypto", () => { + it("initiator wants to sign in", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob"); aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; @@ -55,9 +55,9 @@ describe('ECDHv1', function() { await bob.cancel(RendezvousFailureReason.Unknown); }); - it("initiator wants to reciprocate", async function() { - const aliceTransport = makeTransport('Alice'); - const bobTransport = makeTransport('Bob'); + it("initiator wants to reciprocate", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob"); aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; @@ -80,9 +80,9 @@ describe('ECDHv1', function() { await bob.cancel(RendezvousFailureReason.Unknown); }); - it("double connect", async function() { - const aliceTransport = makeTransport('Alice'); - const bobTransport = makeTransport('Bob'); + it("double connect", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob"); aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; @@ -102,9 +102,9 @@ describe('ECDHv1', function() { await bob.cancel(RendezvousFailureReason.Unknown); }); - it("closed", async function() { - const aliceTransport = makeTransport('Alice'); - const bobTransport = makeTransport('Bob'); + it("closed", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob"); aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; @@ -128,9 +128,9 @@ describe('ECDHv1', function() { await bob.cancel(RendezvousFailureReason.Unknown); }); - it("require ciphertext", async function() { - const aliceTransport = makeTransport('Alice'); - const bobTransport = makeTransport('Bob'); + it("require ciphertext", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob"); aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; @@ -152,9 +152,9 @@ describe('ECDHv1', function() { await bob.cancel(RendezvousFailureReason.Unknown); }); - it("ciphertext before set up", async function() { - const aliceTransport = makeTransport('Alice'); - const bobTransport = makeTransport('Bob'); + it("ciphertext before set up", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob"); aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; diff --git a/spec/unit/rendezvous/rendezvous.spec.ts b/spec/unit/rendezvous/rendezvous.spec.ts index 2e0f492c046..8586040a626 100644 --- a/spec/unit/rendezvous/rendezvous.spec.ts +++ b/spec/unit/rendezvous/rendezvous.spec.ts @@ -16,13 +16,8 @@ limitations under the License. import MockHttpBackend from "matrix-mock-request"; -import '../../olm-loader'; -import { - MSC3906Rendezvous, - RendezvousCode, - RendezvousFailureReason, - RendezvousIntent, -} from "../../../src/rendezvous"; +import "../../olm-loader"; +import { MSC3906Rendezvous, RendezvousCode, RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous"; import { ECDHv1RendezvousCode, MSC3903ECDHPayload, @@ -46,7 +41,11 @@ function makeMockClient(opts: { msc3886Enabled: boolean; devices?: Record>; verificationFunction?: ( - userId: string, deviceId: string, verified: boolean, blocked: boolean, known: boolean, + userId: string, + deviceId: string, + verified: boolean, + blocked: boolean, + known: boolean, ) => void; crossSigningIds?: Record; }): MatrixClient { @@ -59,9 +58,15 @@ function makeMockClient(opts: { }, }; }, - getUserId() { return opts.userId; }, - getDeviceId() { return opts.deviceId; }, - getDeviceEd25519Key() { return opts.deviceKey; }, + getUserId() { + return opts.userId; + }, + getDeviceId() { + return opts.deviceId; + }, + getDeviceEd25519Key() { + return opts.deviceKey; + }, baseUrl: "https://example.com", crypto: { getStoredDevice(userId: string, deviceId: string) { @@ -77,30 +82,30 @@ function makeMockClient(opts: { } as unknown as MatrixClient; } -function makeTransport(name: string, uri = 'https://test.rz/123456') { - return new DummyTransport(name, { type: 'http.v1', uri }); +function makeTransport(name: string, uri = "https://test.rz/123456") { + return new DummyTransport(name, { type: "http.v1", uri }); } -describe("Rendezvous", function() { - beforeAll(async function() { +describe("Rendezvous", function () { + beforeAll(async function () { await global.Olm.init(); }); let httpBackend: MockHttpBackend; - let fetchFn: typeof global.fetchFn; + let fetchFn: typeof global.fetch; let transports: DummyTransport[]; - beforeEach(function() { + beforeEach(function () { httpBackend = new MockHttpBackend(); fetchFn = httpBackend.fetchFn as typeof global.fetch; transports = []; }); - afterEach(function() { - transports.forEach(x => x.cleanup()); + afterEach(function () { + transports.forEach((x) => x.cleanup()); }); - it("generate and cancel", async function() { + it("generate and cancel", async function () { const alice = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", @@ -127,11 +132,11 @@ describe("Rendezvous", function() { expect(aliceRz.code).toBeUndefined(); const codePromise = aliceRz.generateCode(); - await httpBackend.flush(''); + await httpBackend.flush(""); await aliceRz.generateCode(); - expect(typeof aliceRz.code).toBe('string'); + expect(typeof aliceRz.code).toBe("string"); await codePromise; @@ -140,8 +145,9 @@ describe("Rendezvous", function() { expect(code.intent).toEqual(RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE); expect(code.rendezvous?.algorithm).toEqual("org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256"); expect(code.rendezvous?.transport.type).toEqual("org.matrix.msc3886.http.v1"); - expect((code.rendezvous?.transport as MSC3886SimpleHttpRendezvousTransportDetails).uri) - .toEqual("https://fallbackserver/rz/123"); + expect((code.rendezvous?.transport as MSC3886SimpleHttpRendezvousTransportDetails).uri).toEqual( + "https://fallbackserver/rz/123", + ); httpBackend.when("DELETE", "https://fallbackserver/rz").response = { body: null, @@ -152,7 +158,7 @@ describe("Rendezvous", function() { }; const cancelPromise = aliceRz.cancel(RendezvousFailureReason.UserDeclined); - await httpBackend.flush(''); + await httpBackend.flush(""); expect(cancelPromise).resolves.toBeUndefined(); httpBackend.verifyNoOutstandingExpectation(); httpBackend.verifyNoOutstandingRequests(); @@ -160,9 +166,9 @@ describe("Rendezvous", function() { await aliceRz.close(); }); - it("no protocols", async function() { - const aliceTransport = makeTransport('Alice'); - const bobTransport = makeTransport('Bob', 'https://test.rz/999999'); + it("no protocols", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); transports.push(aliceTransport, bobTransport); aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; @@ -199,14 +205,14 @@ describe("Rendezvous", function() { // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); // wait for protocols - logger.info('Bob waiting for protocols'); + logger.info("Bob waiting for protocols"); const protocols = await bobEcdh.receive(); logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: 'm.login.finish', - outcome: 'unsupported', + type: "m.login.finish", + outcome: "unsupported", }); })(); @@ -214,9 +220,9 @@ describe("Rendezvous", function() { await bobStartPromise; }); - it("new device declines protocol", async function() { - const aliceTransport = makeTransport('Alice', 'https://test.rz/123456'); - const bobTransport = makeTransport('Bob', 'https://test.rz/999999'); + it("new device declines protocol", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); transports.push(aliceTransport, bobTransport); aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; @@ -253,17 +259,17 @@ describe("Rendezvous", function() { // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); // wait for protocols - logger.info('Bob waiting for protocols'); + logger.info("Bob waiting for protocols"); const protocols = await bobEcdh.receive(); logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: 'm.login.progress', - protocols: ['org.matrix.msc3906.login_token'], + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: 'm.login.finish', outcome: 'unsupported' }); + await bobEcdh.send({ type: "m.login.finish", outcome: "unsupported" }); })(); await aliceStartProm; @@ -272,9 +278,9 @@ describe("Rendezvous", function() { expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.UnsupportedAlgorithm); }); - it("new device declines protocol", async function() { - const aliceTransport = makeTransport('Alice', 'https://test.rz/123456'); - const bobTransport = makeTransport('Bob', 'https://test.rz/999999'); + it("new device declines protocol", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); transports.push(aliceTransport, bobTransport); aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; @@ -311,17 +317,17 @@ describe("Rendezvous", function() { // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); // wait for protocols - logger.info('Bob waiting for protocols'); + logger.info("Bob waiting for protocols"); const protocols = await bobEcdh.receive(); logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: 'm.login.progress', - protocols: ['org.matrix.msc3906.login_token'], + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: 'm.login.progress', protocol: 'bad protocol' }); + await bobEcdh.send({ type: "m.login.progress", protocol: "bad protocol" }); })(); await aliceStartProm; @@ -330,9 +336,9 @@ describe("Rendezvous", function() { expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.UnsupportedAlgorithm); }); - it("decline on existing device", async function() { - const aliceTransport = makeTransport('Alice', 'https://test.rz/123456'); - const bobTransport = makeTransport('Bob', 'https://test.rz/999999'); + it("decline on existing device", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); transports.push(aliceTransport, bobTransport); aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; @@ -369,17 +375,17 @@ describe("Rendezvous", function() { // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); // wait for protocols - logger.info('Bob waiting for protocols'); + logger.info("Bob waiting for protocols"); const protocols = await bobEcdh.receive(); logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: 'm.login.progress', - protocols: ['org.matrix.msc3906.login_token'], + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: 'm.login.progress', protocol: 'org.matrix.msc3906.login_token' }); + await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); })(); await aliceStartProm; @@ -387,12 +393,12 @@ describe("Rendezvous", function() { await aliceRz.declineLoginOnExistingDevice(); const loginToken = await bobEcdh.receive(); - expect(loginToken).toEqual({ type: 'm.login.finish', outcome: 'declined' }); + expect(loginToken).toEqual({ type: "m.login.finish", outcome: "declined" }); }); - it("approve on existing device + no verification", async function() { - const aliceTransport = makeTransport('Alice', 'https://test.rz/123456'); - const bobTransport = makeTransport('Bob', 'https://test.rz/999999'); + it("approve on existing device + no verification", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); transports.push(aliceTransport, bobTransport); aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; @@ -429,17 +435,17 @@ describe("Rendezvous", function() { // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); // wait for protocols - logger.info('Bob waiting for protocols'); + logger.info("Bob waiting for protocols"); const protocols = await bobEcdh.receive(); logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: 'm.login.progress', - protocols: ['org.matrix.msc3906.login_token'], + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: 'm.login.progress', protocol: 'org.matrix.msc3906.login_token' }); + await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); })(); await aliceStartProm; @@ -449,8 +455,8 @@ describe("Rendezvous", function() { const bobCompleteProm = (async () => { const loginToken = await bobEcdh.receive(); - expect(loginToken).toEqual({ type: 'm.login.progress', login_token: 'token', homeserver: alice.baseUrl }); - await bobEcdh.send({ type: 'm.login.finish', outcome: 'success' }); + expect(loginToken).toEqual({ type: "m.login.progress", login_token: "token", homeserver: alice.baseUrl }); + await bobEcdh.send({ type: "m.login.finish", outcome: "success" }); })(); await confirmProm; @@ -458,8 +464,8 @@ describe("Rendezvous", function() { }); async function completeLogin(devices: Record>) { - const aliceTransport = makeTransport('Alice', 'https://test.rz/123456'); - const bobTransport = makeTransport('Bob', 'https://test.rz/999999'); + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); transports.push(aliceTransport, bobTransport); aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; @@ -473,10 +479,10 @@ describe("Rendezvous", function() { msc3882Enabled: true, msc3886Enabled: false, devices, - deviceKey: 'aaaa', + deviceKey: "aaaa", verificationFunction: aliceVerification, crossSigningIds: { - master: 'mmmmm', + master: "mmmmm", }, }); const aliceEcdh = new MSC3903ECDHv1RendezvousChannel(aliceTransport, undefined, aliceOnFailure); @@ -503,17 +509,17 @@ describe("Rendezvous", function() { // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); // wait for protocols - logger.info('Bob waiting for protocols'); + logger.info("Bob waiting for protocols"); const protocols = await bobEcdh.receive(); logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: 'm.login.progress', - protocols: ['org.matrix.msc3906.login_token'], + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: 'm.login.progress', protocol: 'org.matrix.msc3906.login_token' }); + await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); })(); await aliceStartProm; @@ -523,11 +529,11 @@ describe("Rendezvous", function() { const bobLoginProm = (async () => { const loginToken = await bobEcdh.receive(); - expect(loginToken).toEqual({ type: 'm.login.progress', login_token: 'token', homeserver: alice.baseUrl }); - await bobEcdh.send({ type: 'm.login.finish', outcome: 'success', device_id: 'BOB', device_key: 'bbbb' }); + expect(loginToken).toEqual({ type: "m.login.progress", login_token: "token", homeserver: alice.baseUrl }); + await bobEcdh.send({ type: "m.login.finish", outcome: "success", device_id: "BOB", device_key: "bbbb" }); })(); - expect(await confirmProm).toEqual('BOB'); + expect(await confirmProm).toEqual("BOB"); await bobLoginProm; return { @@ -539,7 +545,7 @@ describe("Rendezvous", function() { }; } - it("approve on existing device + verification", async function() { + it("approve on existing device + verification", async function () { const { bobEcdh, aliceRz } = await completeLogin({ BOB: { getFingerprint: () => "bbbb", @@ -550,11 +556,11 @@ describe("Rendezvous", function() { const bobVerifyProm = (async () => { const verified = await bobEcdh.receive(); expect(verified).toEqual({ - type: 'm.login.finish', - outcome: 'verified', - verifying_device_id: 'ALICE', - verifying_device_key: 'aaaa', - master_key: 'mmmmm', + type: "m.login.finish", + outcome: "verified", + verifying_device_id: "ALICE", + verifying_device_key: "aaaa", + master_key: "mmmmm", }); })(); @@ -562,12 +568,12 @@ describe("Rendezvous", function() { await bobVerifyProm; }); - it("device not online within timeout", async function() { + it("device not online within timeout", async function () { const { aliceRz } = await completeLogin({}); expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrowError(); }); - it("device appears online within timeout", async function() { + it("device appears online within timeout", async function () { const devices: Record> = {}; const { aliceRz } = await completeLogin(devices); // device appears after 1 second @@ -579,7 +585,7 @@ describe("Rendezvous", function() { await aliceRz.verifyNewDeviceOnExistingDevice(2000); }); - it("device appears online after timeout", async function() { + it("device appears online after timeout", async function () { const devices: Record> = {}; const { aliceRz } = await completeLogin(devices); // device appears after 1 second @@ -591,7 +597,7 @@ describe("Rendezvous", function() { expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrowError(); }); - it("mismatched device key", async function() { + it("mismatched device key", async function () { const { aliceRz } = await completeLogin({ BOB: { getFingerprint: () => "XXXX", diff --git a/spec/unit/rendezvous/simpleHttpTransport.spec.ts b/spec/unit/rendezvous/simpleHttpTransport.spec.ts index 9542cb1d1cd..69a38cbdeb5 100644 --- a/spec/unit/rendezvous/simpleHttpTransport.spec.ts +++ b/spec/unit/rendezvous/simpleHttpTransport.spec.ts @@ -20,13 +20,17 @@ import type { MatrixClient } from "../../../src"; import { RendezvousFailureReason } from "../../../src/rendezvous"; import { MSC3886SimpleHttpRendezvousTransport } from "../../../src/rendezvous/transports"; -function makeMockClient(opts: { userId: string, deviceId: string, msc3886Enabled: boolean}): MatrixClient { +function makeMockClient(opts: { userId: string; deviceId: string; msc3886Enabled: boolean }): MatrixClient { return { doesServerSupportUnstableFeature(feature: string) { return Promise.resolve(opts.msc3886Enabled && feature === "org.matrix.msc3886"); }, - getUserId() { return opts.userId; }, - getDeviceId() { return opts.deviceId; }, + getUserId() { + return opts.userId; + }, + getDeviceId() { + return opts.deviceId; + }, requestLoginToken() { return Promise.resolve({ login_token: "token" }); }, @@ -34,11 +38,11 @@ function makeMockClient(opts: { userId: string, deviceId: string, msc3886Enabled } as unknown as MatrixClient; } -describe("SimpleHttpRendezvousTransport", function() { +describe("SimpleHttpRendezvousTransport", function () { let httpBackend: MockHttpBackend; let fetchFn: typeof global.fetch; - beforeEach(function() { + beforeEach(function () { httpBackend = new MockHttpBackend(); fetchFn = httpBackend.fetchFn as typeof global.fetch; }); @@ -51,10 +55,11 @@ describe("SimpleHttpRendezvousTransport", function() { ) { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled }); const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, fallbackRzServer, fetchFn }); - { // initial POST - const expectedPostLocation = msc3886Enabled ? - `${client.baseUrl}/_matrix/client/unstable/org.matrix.msc3886/rendezvous` : - fallbackRzServer; + { + // initial POST + const expectedPostLocation = msc3886Enabled + ? `${client.baseUrl}/_matrix/client/unstable/org.matrix.msc3886/rendezvous` + : fallbackRzServer; const prom = simpleHttpTransport.send({}); httpBackend.when("POST", expectedPostLocation).response = { @@ -66,13 +71,14 @@ describe("SimpleHttpRendezvousTransport", function() { }, }, }; - await httpBackend.flush(''); + await httpBackend.flush(""); await prom; } const details = await simpleHttpTransport.details(); expect(details.uri).toBe(expectedFinalLocation); - { // first GET without etag + { + // first GET without etag const prom = simpleHttpTransport.receive(); httpBackend.when("GET", expectedFinalLocation).response = { body: {}, @@ -83,19 +89,19 @@ describe("SimpleHttpRendezvousTransport", function() { }, }, }; - await httpBackend.flush(''); + await httpBackend.flush(""); expect(await prom).toEqual({}); httpBackend.verifyNoOutstandingRequests(); httpBackend.verifyNoOutstandingExpectation(); } } - it("should throw an error when no server available", function() { + it("should throw an error when no server available", function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, fetchFn }); expect(simpleHttpTransport.send({})).rejects.toThrowError("Invalid rendezvous URI"); }); - it("POST to fallback server", async function() { + it("POST to fallback server", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, @@ -112,11 +118,11 @@ describe("SimpleHttpRendezvousTransport", function() { }, }, }; - await httpBackend.flush(''); + await httpBackend.flush(""); expect(await prom).toStrictEqual(undefined); }); - it("POST with no location", async function() { + it("POST with no location", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, @@ -132,19 +138,14 @@ describe("SimpleHttpRendezvousTransport", function() { headers: {}, }, }; - await httpBackend.flush(''); + await httpBackend.flush(""); }); - it("POST with absolute path response", async function() { - await postAndCheckLocation( - false, - "https://fallbackserver/rz", - "/123", - "https://fallbackserver/123", - ); + it("POST with absolute path response", async function () { + await postAndCheckLocation(false, "https://fallbackserver/rz", "/123", "https://fallbackserver/123"); }); - it("POST to built-in MSC3886 implementation", async function() { + it("POST to built-in MSC3886 implementation", async function () { await postAndCheckLocation( true, "https://fallbackserver/rz", @@ -153,7 +154,7 @@ describe("SimpleHttpRendezvousTransport", function() { ); }); - it("POST with relative path response including parent", async function() { + it("POST with relative path response including parent", async function () { await postAndCheckLocation( false, "https://fallbackserver/rz/abc", @@ -162,7 +163,7 @@ describe("SimpleHttpRendezvousTransport", function() { ); }); - it("POST with relative path response including parent", async function() { + it("POST with relative path response including parent", async function () { await postAndCheckLocation( false, "https://fallbackserver/rz/abc", @@ -171,7 +172,7 @@ describe("SimpleHttpRendezvousTransport", function() { ); }); - it("POST to follow 307 to other server", async function() { + it("POST to follow 307 to other server", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, @@ -198,18 +199,19 @@ describe("SimpleHttpRendezvousTransport", function() { }, }, }; - await httpBackend.flush(''); + await httpBackend.flush(""); expect(await prom).toStrictEqual(undefined); }); - it("POST and GET", async function() { + it("POST and GET", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, fallbackRzServer: "https://fallbackserver/rz", fetchFn, }); - { // initial POST + { + // initial POST const prom = simpleHttpTransport.send({ foo: "baa" }); httpBackend.when("POST", "https://fallbackserver/rz").check(({ headers, data }) => { expect(headers["content-type"]).toEqual("application/json"); @@ -223,10 +225,11 @@ describe("SimpleHttpRendezvousTransport", function() { }, }, }; - await httpBackend.flush(''); + await httpBackend.flush(""); expect(await prom).toStrictEqual(undefined); } - { // first GET without etag + { + // first GET without etag const prom = simpleHttpTransport.receive(); httpBackend.when("GET", "https://fallbackserver/rz/123").response = { body: { foo: "baa" }, @@ -238,10 +241,11 @@ describe("SimpleHttpRendezvousTransport", function() { }, }, }; - await httpBackend.flush(''); + await httpBackend.flush(""); expect(await prom).toEqual({ foo: "baa" }); } - { // subsequent GET which should have etag from previous request + { + // subsequent GET which should have etag from previous request const prom = simpleHttpTransport.receive(); httpBackend.when("GET", "https://fallbackserver/rz/123").check(({ headers }) => { expect(headers["if-none-match"]).toEqual("aaa"); @@ -255,19 +259,20 @@ describe("SimpleHttpRendezvousTransport", function() { }, }, }; - await httpBackend.flush(''); + await httpBackend.flush(""); expect(await prom).toEqual({ foo: "baa" }); } }); - it("POST and PUTs", async function() { + it("POST and PUTs", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, fallbackRzServer: "https://fallbackserver/rz", fetchFn, }); - { // initial POST + { + // initial POST const prom = simpleHttpTransport.send({ foo: "baa" }); httpBackend.when("POST", "https://fallbackserver/rz").check(({ headers, data }) => { expect(headers["content-type"]).toEqual("application/json"); @@ -281,10 +286,11 @@ describe("SimpleHttpRendezvousTransport", function() { }, }, }; - await httpBackend.flush('', 1); + await httpBackend.flush("", 1); await prom; } - { // first PUT without etag + { + // first PUT without etag const prom = simpleHttpTransport.send({ a: "b" }); httpBackend.when("PUT", "https://fallbackserver/rz/123").check(({ headers, data }) => { expect(headers["if-match"]).toBeUndefined(); @@ -294,14 +300,15 @@ describe("SimpleHttpRendezvousTransport", function() { response: { statusCode: 202, headers: { - "etag": "aaa", + etag: "aaa", }, }, }; - await httpBackend.flush('', 1); + await httpBackend.flush("", 1); await prom; } - { // subsequent PUT which should have etag from previous request + { + // subsequent PUT which should have etag from previous request const prom = simpleHttpTransport.send({ c: "d" }); httpBackend.when("PUT", "https://fallbackserver/rz/123").check(({ headers }) => { expect(headers["if-match"]).toEqual("aaa"); @@ -310,23 +317,24 @@ describe("SimpleHttpRendezvousTransport", function() { response: { statusCode: 202, headers: { - "etag": "bbb", + etag: "bbb", }, }, }; - await httpBackend.flush('', 1); + await httpBackend.flush("", 1); await prom; } }); - it("POST and DELETE", async function() { + it("POST and DELETE", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, fallbackRzServer: "https://fallbackserver/rz", fetchFn, }); - { // Create + { + // Create const prom = simpleHttpTransport.send({ foo: "baa" }); httpBackend.when("POST", "https://fallbackserver/rz").check(({ headers, data }) => { expect(headers["content-type"]).toEqual("application/json"); @@ -340,10 +348,11 @@ describe("SimpleHttpRendezvousTransport", function() { }, }, }; - await httpBackend.flush(''); + await httpBackend.flush(""); expect(await prom).toStrictEqual(undefined); } - { // Cancel + { + // Cancel const prom = simpleHttpTransport.cancel(RendezvousFailureReason.UserDeclined); httpBackend.when("DELETE", "https://fallbackserver/rz/123").response = { body: null, @@ -352,12 +361,12 @@ describe("SimpleHttpRendezvousTransport", function() { headers: {}, }, }; - await httpBackend.flush(''); + await httpBackend.flush(""); await prom; } }); - it("details before ready", async function() { + it("details before ready", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, @@ -367,7 +376,7 @@ describe("SimpleHttpRendezvousTransport", function() { expect(simpleHttpTransport.details()).rejects.toThrowError(); }); - it("send after cancelled", async function() { + it("send after cancelled", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, @@ -378,7 +387,7 @@ describe("SimpleHttpRendezvousTransport", function() { expect(simpleHttpTransport.send({})).resolves.toBeUndefined(); }); - it("receive before ready", async function() { + it("receive before ready", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, @@ -388,7 +397,7 @@ describe("SimpleHttpRendezvousTransport", function() { expect(simpleHttpTransport.receive()).rejects.toThrowError(); }); - it("404 failure callback", async function() { + it("404 failure callback", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); const onFailure = jest.fn(); const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ @@ -406,11 +415,11 @@ describe("SimpleHttpRendezvousTransport", function() { headers: {}, }, }; - await httpBackend.flush('', 1); + await httpBackend.flush("", 1); expect(onFailure).toBeCalledWith(RendezvousFailureReason.Unknown); }); - it("404 failure callback mapped to expired", async function() { + it("404 failure callback mapped to expired", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); const onFailure = jest.fn(); const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ @@ -420,7 +429,8 @@ describe("SimpleHttpRendezvousTransport", function() { onFailure, }); - { // initial POST + { + // initial POST const prom = simpleHttpTransport.send({ foo: "baa" }); httpBackend.when("POST", "https://fallbackserver/rz").response = { body: null, @@ -432,10 +442,11 @@ describe("SimpleHttpRendezvousTransport", function() { }, }, }; - await httpBackend.flush(''); + await httpBackend.flush(""); await prom; } - { // GET with 404 to simulate expiry + { + // GET with 404 to simulate expiry expect(simpleHttpTransport.receive()).resolves.toBeUndefined(); httpBackend.when("GET", "https://fallbackserver/rz/123").response = { body: { foo: "baa" }, @@ -444,7 +455,7 @@ describe("SimpleHttpRendezvousTransport", function() { headers: {}, }, }; - await httpBackend.flush(''); + await httpBackend.flush(""); expect(onFailure).toBeCalledWith(RendezvousFailureReason.Expired); } }); diff --git a/spec/unit/room-member.spec.ts b/spec/unit/room-member.spec.ts index 8eb3096b60e..bc8a27344b2 100644 --- a/spec/unit/room-member.spec.ts +++ b/spec/unit/room-member.spec.ts @@ -18,21 +18,21 @@ import * as utils from "../test-utils/test-utils"; import { RoomMember, RoomMemberEvent } from "../../src/models/room-member"; import { EventType, RoomState } from "../../src"; -describe("RoomMember", function() { +describe("RoomMember", function () { const roomId = "!foo:bar"; const userA = "@alice:bar"; const userB = "@bertha:bar"; const userC = "@clarissa:bar"; let member = new RoomMember(roomId, userA); - beforeEach(function() { + beforeEach(function () { member = new RoomMember(roomId, userA); }); - describe("getAvatarUrl", function() { + describe("getAvatarUrl", function () { const hsUrl = "https://my.home.server"; - it("should return the URL from m.room.member preferentially", function() { + it("should return the URL from m.room.member preferentially", function () { member.events.member = utils.mkEvent({ event: true, type: "m.room.member", @@ -44,21 +44,20 @@ describe("RoomMember", function() { avatar_url: "mxc://flibble/wibble", }, }); - const url = member.getAvatarUrl(hsUrl, 1, 1, '', false, false); + 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); }); - it("should return nothing if there is no m.room.member and allowDefault=false", - function() { - const url = member.getAvatarUrl(hsUrl, 64, 64, "crop", false, false); - expect(url).toEqual(null); - }); + it("should return nothing if there is no m.room.member and allowDefault=false", function () { + const url = member.getAvatarUrl(hsUrl, 64, 64, "crop", false, false); + expect(url).toEqual(null); + }); }); - describe("setPowerLevelEvent", function() { - it("should set 'powerLevel' and 'powerLevelNorm'.", function() { + describe("setPowerLevelEvent", function () { + it("should set 'powerLevel' and 'powerLevelNorm'.", function () { const event = utils.mkEvent({ type: "m.room.power_levels", room: roomId, @@ -67,7 +66,7 @@ describe("RoomMember", function() { users_default: 20, users: { "@bertha:bar": 200, - "@invalid:user": 10, // shouldn't barf on this. + "@invalid:user": 10, // shouldn't barf on this. }, }, event: true, @@ -82,67 +81,35 @@ describe("RoomMember", function() { expect(memberB.powerLevelNorm).toEqual(100); }); - 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. - }, + 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. }, - event: true, - }); - let emitCount = 0; - - 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); + }, + event: true, }); + let emitCount = 0; - 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, - }, - }, - 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(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.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) { + emitCount += 1; + expect(emitMember).toEqual(member); + expect(emitEvent).toEqual(event); }); - it("should not honor string power levels.", function() { + 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, @@ -150,30 +117,31 @@ describe("RoomMember", function() { content: { users_default: 20, users: { - "@alice:bar": "5", + "@alice:bar": 0, }, }, event: true, }); let emitCount = 0; - member.on(RoomMemberEvent.PowerLevel, function(emitEvent, emitMember) { + // 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(20); + expect(emitMember.userId).toEqual("@alice:bar"); + expect(emitMember.powerLevel).toEqual(0); expect(emitEvent).toEqual(event); }); member.setPowerLevelEvent(event); - expect(member.powerLevel).toEqual(20); + expect(member.powerLevel).toEqual(0); expect(emitCount).toEqual(1); }); - it("should no-op if given a non-state or unrelated event", () => { - const fn = jest.spyOn(member, "emit"); - expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel); - member.setPowerLevelEvent(utils.mkEvent({ - type: EventType.RoomPowerLevels, + it("should not honor string power levels.", function () { + const event = utils.mkEvent({ + type: "m.room.power_levels", room: roomId, user: userA, content: { @@ -182,9 +150,40 @@ describe("RoomMember", function() { "@alice:bar": "5", }, }, - skey: "invalid", event: true, - })); + }); + let emitCount = 0; + + 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); + }); + + it("should no-op if given a non-state or unrelated event", () => { + const fn = jest.spyOn(member, "emit"); + expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel); + member.setPowerLevelEvent( + utils.mkEvent({ + type: EventType.RoomPowerLevels, + room: roomId, + user: userA, + content: { + users_default: 20, + users: { + "@alice:bar": "5", + }, + }, + skey: "invalid", + event: true, + }), + ); const nonStateEv = utils.mkEvent({ type: EventType.RoomPowerLevels, room: roomId, @@ -199,19 +198,21 @@ describe("RoomMember", function() { }); delete nonStateEv.event.state_key; member.setPowerLevelEvent(nonStateEv); - member.setPowerLevelEvent(utils.mkEvent({ - type: EventType.Sticker, - room: roomId, - user: userA, - content: {}, - event: true, - })); + member.setPowerLevelEvent( + utils.mkEvent({ + type: EventType.Sticker, + room: roomId, + user: userA, + content: {}, + event: true, + }), + ); expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel); }); }); - describe("setTypingEvent", function() { - it("should set 'typing'", function() { + describe("setTypingEvent", function () { + it("should set 'typing'", function () { member.typing = false; const memberB = new RoomMember(roomId, userB); memberB.typing = true; @@ -223,9 +224,7 @@ describe("RoomMember", function() { user: userA, room: roomId, content: { - user_ids: [ - userA, userC, - ], + user_ids: [userA, userC], }, event: true, }); @@ -238,34 +237,31 @@ describe("RoomMember", function() { expect(memberC.typing).toEqual(true); }); - 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, - }); - 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); + 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, }); + 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); + }); }); - describe("isOutOfBand", function() { - it("should be set by markOutOfBand", function() { + describe("isOutOfBand", function () { + it("should be set by markOutOfBand", function () { const member = new RoomMember(roomId, userA); expect(member.isOutOfBand()).toEqual(false); member.markOutOfBand(); @@ -346,7 +342,7 @@ describe("RoomMember", function() { }); }); - describe("setMembershipEvent", function() { + describe("setMembershipEvent", function () { const joinEvent = utils.mkMembership({ event: true, mship: "join", @@ -363,51 +359,54 @@ describe("RoomMember", function() { room: roomId, }); - 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); - }); + 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); + }); - 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]; - }, - } 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 set 'name' based on user_id, displayname and room state", function () { + const roomState = { + getStateEvents: function (type: string) { + 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: string) { + 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() { + it("should emit 'RoomMember.membership' if the membership changes", function () { let emitCount = 0; - member.on(RoomMemberEvent.Membership, function(ev, mem) { + member.on(RoomMemberEvent.Membership, function (ev, mem) { emitCount += 1; expect(mem).toEqual(member); expect(ev).toEqual(inviteEvent); @@ -418,9 +417,9 @@ describe("RoomMember", function() { expect(emitCount).toEqual(1); }); - it("should emit 'RoomMember.name' if the name changes", function() { + it("should emit 'RoomMember.name' if the name changes", function () { let emitCount = 0; - member.on(RoomMemberEvent.Name, function(ev, mem) { + member.on(RoomMemberEvent.Name, function (ev, mem) { emitCount += 1; expect(mem).toEqual(member); expect(ev).toEqual(joinEvent); @@ -431,7 +430,7 @@ describe("RoomMember", function() { expect(emitCount).toEqual(1); }); - it("should set 'name' to user_id if it is just whitespace", function() { + it("should set 'name' to user_id if it is just whitespace", function () { const joinEvent = utils.mkMembership({ event: true, mship: "join", @@ -445,7 +444,7 @@ describe("RoomMember", function() { expect(member.name).toEqual(userA); // it should fallback because all whitespace }); - it("should disambiguate users on a fuzzy displayname match", function() { + it("should disambiguate users on a fuzzy displayname match", function () { const joinEvent = utils.mkMembership({ event: true, mship: "join", @@ -455,19 +454,22 @@ describe("RoomMember", function() { }); const roomState = { - getStateEvents: function(type) { + getStateEvents: function (type: string) { if (type !== "m.room.member") { return []; } return [ utils.mkMembership({ - event: true, mship: "join", room: roomId, - user: userC, name: "Alice", + event: true, + mship: "join", + room: roomId, + user: userC, + name: "Alice", }), joinEvent, ]; }, - getUserIdsWithDisplayName: function(displayName) { + getUserIdsWithDisplayName: function (displayName: string) { return [userA, userC]; }, } as unknown as RoomState; diff --git a/spec/unit/room-state.spec.ts b/spec/unit/room-state.spec.ts index 1ac3721a121..0b8a5abf0b3 100644 --- a/spec/unit/room-state.spec.ts +++ b/spec/unit/room-state.spec.ts @@ -14,26 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MockedObject } from 'jest-mock'; +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 { - Beacon, - 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 { MatrixEvent, MatrixEventEvent } from "../../src/models/event"; import { M_BEACON } from "../../src/@types/beacon"; import { MatrixClient } from "../../src/client"; -describe("RoomState", function() { +describe("RoomState", function () { const roomId = "!foo:bar"; const userA = "@alice:bar"; const userB = "@bob:bar"; @@ -42,35 +35,53 @@ describe("RoomState", function() { let state = new RoomState(roomId); - beforeEach(function() { + beforeEach(function () { state = new RoomState(roomId); state.setStateEvents([ - utils.mkMembership({ // userA joined - event: true, mship: "join", user: userA, room: roomId, + utils.mkMembership({ + // userA joined + event: true, + mship: "join", + user: userA, + room: roomId, }), - utils.mkMembership({ // userB joined - event: true, mship: "join", user: userB, room: roomId, + utils.mkMembership({ + // userB joined + event: true, + mship: "join", + user: userB, + room: roomId, }), - utils.mkEvent({ // Room name is "Room name goes here" - type: "m.room.name", user: userA, room: roomId, event: true, content: { + utils.mkEvent({ + // Room name is "Room name goes here" + type: "m.room.name", + user: userA, + room: roomId, + event: true, + content: { name: "Room name goes here", }, }), - utils.mkEvent({ // Room creation - type: "m.room.create", user: userA, room: roomId, event: true, content: { + utils.mkEvent({ + // Room creation + type: "m.room.create", + user: userA, + room: roomId, + event: true, + content: { creator: userA, }, }), ]); }); - describe("getMembers", function() { - it("should return an empty list if there are no members", function() { + describe("getMembers", function () { + it("should return an empty list if there are no members", function () { state = new RoomState(roomId); expect(state.getMembers().length).toEqual(0); }); - it("should return a member for each m.room.member event", function() { + it("should return a member for each m.room.member event", function () { const members = state.getMembers(); expect(members.length).toEqual(2); // ordering unimportant @@ -79,23 +90,26 @@ describe("RoomState", function() { }); }); - describe("getMember", function() { - it("should return null if there is no member", function() { + describe("getMember", function () { + it("should return null if there is no member", function () { expect(state.getMember("@no-one:here")).toEqual(null); }); - it("should return a member if they exist", function() { + it("should return a member if they exist", function () { expect(state.getMember(userB)).toBeTruthy(); }); - it("should return a member which changes as state changes", 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); state.setStateEvents([ utils.mkMembership({ - room: roomId, user: userB, mship: "leave", event: true, + room: roomId, + user: userB, + mship: "leave", + event: true, name: "BobGone", }), ]); @@ -105,71 +119,75 @@ describe("RoomState", function() { }); }); - describe("getSentinelMember", function() { - it("should return a member with the user id as name", function() { + 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"); }); - 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); - - expect(preLeaveUser?.membership).toEqual("join"); - expect(preLeaveUser?.name).toEqual(userA); - - expect(postLeaveUser?.membership).toEqual("leave"); - expect(postLeaveUser?.name).toEqual("AliceIsGone"); - }); + 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); + + expect(preLeaveUser?.membership).toEqual("join"); + expect(preLeaveUser?.name).toEqual(userA); + + 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); - }); + 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); + }); - it("should return an empty list if a state_key was not specified and there" + - " was no match", function() { + it("should return an empty list if a state_key was not specified and there" + " was no match", function () { expect(state.getStateEvents("foo.bar.baz")).toEqual([]); }); - 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() as string)).not.toEqual(-1); - expect([userA, userB].indexOf(events[1].getStateKey() as string)).not.toEqual(-1); - }); + 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() 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", - }); + 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", }); + }); }); - describe("setStateEvents", function() { - it("should emit 'RoomState.members' for each m.room.member event", function() { + describe("setStateEvents", function () { + it("should emit 'RoomState.members' for each m.room.member event", function () { const memberEvents = [ utils.mkMembership({ - user: "@cleo:bar", mship: "invite", room: roomId, event: true, + user: "@cleo:bar", + mship: "invite", + room: roomId, + event: true, }), utils.mkMembership({ - user: "@daisy:bar", mship: "join", room: roomId, event: true, + user: "@daisy:bar", + mship: "join", + room: roomId, + event: true, }), ]; let emitCount = 0; - state.on(RoomStateEvent.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()!)); @@ -179,43 +197,58 @@ describe("RoomState", function() { expect(emitCount).toEqual(2); }); - it("should emit 'RoomState.newMember' for each new member added", function() { + it("should emit 'RoomState.newMember' for each new member added", function () { const memberEvents = [ utils.mkMembership({ - user: "@cleo:bar", mship: "invite", room: roomId, event: true, + user: "@cleo:bar", + mship: "invite", + room: roomId, + event: true, }), utils.mkMembership({ - user: "@daisy:bar", mship: "join", room: roomId, event: true, + user: "@daisy:bar", + mship: "join", + room: roomId, + event: true, }), ]; let emitCount = 0; - state.on(RoomStateEvent.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 + expect(mem.membership).toBeFalsy(); // not defined yet emitCount += 1; }); state.setStateEvents(memberEvents); expect(emitCount).toEqual(2); }); - it("should emit 'RoomState.events' for each state event", function() { + it("should emit 'RoomState.events' for each state event", function () { const events = [ utils.mkMembership({ - user: "@cleo:bar", mship: "invite", room: roomId, event: true, + user: "@cleo:bar", + mship: "invite", + room: roomId, + event: true, }), utils.mkEvent({ - user: userB, room: roomId, type: "m.room.topic", event: true, + user: userB, + room: roomId, + type: "m.room.topic", + event: true, content: { topic: "boo!", }, }), - utils.mkMessage({ // Not a state event - user: userA, room: roomId, event: true, + utils.mkMessage({ + // Not a state event + user: userA, + room: roomId, + event: true, }), ]; let emitCount = 0; - state.on(RoomStateEvent.Events, function(ev, st) { + state.on(RoomStateEvent.Events, function (ev, st) { expect(ev).toEqual(events[emitCount]); expect(st).toEqual(state); emitCount += 1; @@ -224,9 +257,12 @@ describe("RoomState", function() { expect(emitCount).toEqual(2); }); - it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", function() { + it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", function () { const powerLevelEvent = utils.mkEvent({ - type: "m.room.power_levels", room: roomId, user: userA, event: true, + type: "m.room.power_levels", + room: roomId, + user: userA, + event: true, content: { users_default: 10, state_default: 50, @@ -243,12 +279,18 @@ describe("RoomState", function() { expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent); }); - it("should call setPowerLevelEvent on a new RoomMember if power levels exist", function() { + it("should call setPowerLevelEvent on a new RoomMember if power levels exist", function () { const memberEvent = utils.mkMembership({ - mship: "join", user: userC, room: roomId, event: true, + mship: "join", + user: userC, + room: roomId, + event: true, }); const powerLevelEvent = utils.mkEvent({ - type: "m.room.power_levels", room: roomId, user: userA, event: true, + type: "m.room.power_levels", + room: roomId, + user: userA, + event: true, content: { users_default: 10, state_default: 50, @@ -266,9 +308,12 @@ describe("RoomState", function() { expect(state.members[userC].powerLevel).toEqual(10); }); - it("should call setMembershipEvent on the right RoomMember", function() { + it("should call setMembershipEvent on the right RoomMember", function () { const memberEvent = utils.mkMembership({ - user: userB, mship: "leave", room: roomId, event: true, + user: userB, + mship: "leave", + room: roomId, + event: true, }); // spy on the room members jest.spyOn(state.members[userA], "setMembershipEvent"); @@ -276,12 +321,10 @@ describe("RoomState", function() { state.setStateEvents([memberEvent]); expect(state.members[userA].setMembershipEvent).not.toHaveBeenCalled(); - expect(state.members[userB].setMembershipEvent).toHaveBeenCalledWith( - memberEvent, state, - ); + expect(state.members[userB].setMembershipEvent).toHaveBeenCalledWith(memberEvent, state); }); - it("should emit `RoomStateEvent.Marker` for each marker event", function() { + it("should emit `RoomStateEvent.Marker` for each marker event", function () { const events = [ utils.mkEvent({ event: true, @@ -295,7 +338,7 @@ describe("RoomState", function() { }), ]; let emitCount = 0; - state.on(RoomStateEvent.Marker, function(markerEvent, markerFoundOptions) { + state.on(RoomStateEvent.Marker, function (markerEvent, markerFoundOptions) { expect(markerEvent).toEqual(events[emitCount]); expect(markerFoundOptions).toEqual({ timelineWasEmpty: true }); emitCount += 1; @@ -305,10 +348,10 @@ describe("RoomState", function() { }); }); - describe('beacon events', () => { - it('adds new beacon info events to state and emits', () => { + describe("beacon events", () => { + it("adds new beacon info events to state and emits", () => { const beaconEvent = makeBeaconInfoEvent(userA, roomId); - const emitSpy = jest.spyOn(state, 'emit'); + const emitSpy = jest.spyOn(state, "emit"); state.setStateEvents([beaconEvent]); @@ -318,11 +361,11 @@ describe("RoomState", function() { expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.New, beaconEvent, beaconInstance); }); - it('does not add redacted beacon info events to state', () => { + it("does not add redacted beacon info events to state", () => { const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId); - const redactionEvent = new MatrixEvent({ type: 'm.room.redaction' }); + const redactionEvent = new MatrixEvent({ type: "m.room.redaction" }); redactedBeaconEvent.makeRedacted(redactionEvent); - const emitSpy = jest.spyOn(state, 'emit'); + const emitSpy = jest.spyOn(state, "emit"); state.setStateEvents([redactedBeaconEvent]); @@ -333,8 +376,8 @@ describe("RoomState", function() { expect(filterEmitCallsByEventType(BeaconEvent.New, emitSpy).length).toBeFalsy(); }); - it('updates existing beacon info events in state', () => { - const beaconId = '$beacon1'; + it("updates existing beacon info events in state", () => { + const beaconId = "$beacon1"; const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId); const updatedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, beaconId); @@ -350,16 +393,16 @@ describe("RoomState", function() { expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))?.isLive).toEqual(false); }); - it('destroys and removes redacted beacon events', () => { - const beaconId = '$beacon1'; + 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 = new MatrixEvent({ 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 as Beacon, 'destroy'); + const destroySpy = jest.spyOn(beaconInstance as Beacon, "destroy"); expect(beaconInstance?.isLive).toEqual(true); state.setStateEvents([redactedBeaconEvent]); @@ -368,11 +411,11 @@ describe("RoomState", function() { expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(undefined); }); - it('updates live beacon ids once after setting state events', () => { - const liveBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, '$beacon1'); - const deadBeaconEvent = makeBeaconInfoEvent(userB, roomId, { isLive: false }, '$beacon2'); + it("updates live beacon ids once after setting state events", () => { + const liveBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, "$beacon1"); + const deadBeaconEvent = makeBeaconInfoEvent(userB, roomId, { isLive: false }, "$beacon2"); - const emitSpy = jest.spyOn(state, 'emit'); + const emitSpy = jest.spyOn(state, "emit"); state.setStateEvents([liveBeaconEvent, deadBeaconEvent]); @@ -381,7 +424,10 @@ describe("RoomState", function() { // live beacon is now not live const updatedLiveBeaconEvent = makeBeaconInfoEvent( - userA, roomId, { isLive: false }, liveBeaconEvent.getId(), + userA, + roomId, + { isLive: false }, + liveBeaconEvent.getId(), ); state.setStateEvents([updatedLiveBeaconEvent]); @@ -392,10 +438,13 @@ describe("RoomState", function() { }); }); - describe("setOutOfBandMembers", function() { - it("should add a new member", function() { + describe("setOutOfBandMembers", function () { + it("should add a new member", function () { const oobMemberEvent = utils.mkMembership({ - user: userLazy, mship: "join", room: roomId, event: true, + user: userLazy, + mship: "join", + room: roomId, + event: true, }); state.markOutOfBandMembersStarted(); state.setOutOfBandMembers([oobMemberEvent]); @@ -404,17 +453,25 @@ describe("RoomState", function() { expect(member?.isOutOfBand()).toEqual(true); }); - it("should have no effect when not in correct status", function() { - state.setOutOfBandMembers([utils.mkMembership({ - user: userLazy, mship: "join", room: roomId, event: true, - })]); + it("should have no effect when not in correct status", function () { + state.setOutOfBandMembers([ + utils.mkMembership({ + user: userLazy, + mship: "join", + room: roomId, + event: true, + }), + ]); expect(state.getMember(userLazy)).toBeFalsy(); }); - it("should emit newMember when adding a member", function() { + it("should emit newMember when adding a member", function () { const userLazy = "@oob:hs"; const oobMemberEvent = utils.mkMembership({ - user: userLazy, mship: "join", room: roomId, event: true, + user: userLazy, + mship: "join", + room: roomId, + event: true, }); let eventReceived = false; state.once(RoomStateEvent.NewMember, (_event, _state, member) => { @@ -426,9 +483,12 @@ describe("RoomState", function() { expect(eventReceived).toEqual(true); }); - it("should never overwrite existing members", function() { + it("should never overwrite existing members", function () { const oobMemberEvent = utils.mkMembership({ - user: userA, mship: "join", room: roomId, event: true, + user: userA, + mship: "join", + room: roomId, + event: true, }); state.markOutOfBandMembersStarted(); state.setOutOfBandMembers([oobMemberEvent]); @@ -437,10 +497,13 @@ describe("RoomState", function() { expect(memberA?.isOutOfBand()).toEqual(false); }); - it("should emit members when updating a member", function() { + it("should emit members when updating a member", function () { const doesntExistYetUserId = "@doesntexistyet:hs"; const oobMemberEvent = utils.mkMembership({ - user: doesntExistYetUserId, mship: "join", room: roomId, event: true, + user: doesntExistYetUserId, + mship: "join", + room: roomId, + event: true, }); let eventReceived = false; state.once(RoomStateEvent.Members, (_event, _state, member) => { @@ -454,13 +517,18 @@ describe("RoomState", function() { }); }); - describe("clone", function() { - it("should contain same information as original", function() { + describe("clone", function () { + it("should contain same information as original", function () { // include OOB members in copy state.markOutOfBandMembersStarted(); - state.setOutOfBandMembers([utils.mkMembership({ - user: userLazy, mship: "join", room: roomId, event: true, - })]); + state.setOutOfBandMembers([ + utils.mkMembership({ + user: userLazy, + mship: "join", + room: roomId, + event: true, + }), + ]); const copy = state.clone(); // check individual members [userA, userB, userLazy].forEach((userId) => { @@ -475,24 +543,39 @@ describe("RoomState", function() { expect(state.getJoinedMemberCount()).toEqual(copy.getJoinedMemberCount()); }); - it("should mark old copy as not waiting for out of band anymore", function() { + it("should mark old copy as not waiting for out of band anymore", function () { state.markOutOfBandMembersStarted(); const copy = state.clone(); - copy.setOutOfBandMembers([utils.mkMembership({ - user: userA, mship: "join", room: roomId, event: true, - })]); + copy.setOutOfBandMembers([ + utils.mkMembership({ + user: userA, + mship: "join", + room: roomId, + event: true, + }), + ]); // should have no effect as it should be marked in status finished just like copy - state.setOutOfBandMembers([utils.mkMembership({ - user: userLazy, mship: "join", room: roomId, event: true, - })]); + state.setOutOfBandMembers([ + utils.mkMembership({ + user: userLazy, + mship: "join", + room: roomId, + event: true, + }), + ]); expect(state.getMember(userLazy)).toBeFalsy(); }); - it("should return copy independent of original", function() { + it("should return copy independent of original", function () { const copy = state.clone(); - copy.setStateEvents([utils.mkMembership({ - user: userLazy, mship: "join", room: roomId, event: true, - })]); + copy.setStateEvents([ + utils.mkMembership({ + user: userLazy, + mship: "join", + room: roomId, + event: true, + }), + ]); expect(state.getMember(userLazy)).toBeFalsy(); expect(state.getJoinedMemberCount()).toEqual(2); @@ -500,10 +583,13 @@ describe("RoomState", function() { }); }); - describe("setTypingEvent", function() { - it("should call setTypingEvent on each RoomMember", function() { + describe("setTypingEvent", function () { + it("should call setTypingEvent on each RoomMember", function () { const typingEvent = utils.mkEvent({ - type: "m.typing", room: roomId, event: true, content: { + type: "m.typing", + room: roomId, + event: true, + content: { user_ids: [userA], }, }); @@ -517,261 +603,229 @@ 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); - }); - - it("should say members with power >=50 may send state with power level event " + - "but no state default", - function() { - 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, - }, - }, - }); - - state.setStateEvents([powerLevelEvent]); - - expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); - expect(state.maySendStateEvent('m.room.name', userB)).toEqual(false); + 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); }); - it("should obey state_default", - function() { + it( + "should say members with power >=50 may send state with power level event " + "but no state default", + function () { const powerLevelEvent = new MatrixEvent({ - type: "m.room.power_levels", room_id: roomId, sender: userA, + type: "m.room.power_levels", + room_id: roomId, + sender: userA, state_key: "", content: { users_default: 10, - state_default: 30, + // state_default: 50, "intentionally left blank" events_default: 25, users: { - [userA]: 30, - [userB]: 29, + [userA]: 50, }, }, }); 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 = 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, - }, + it("should obey state_default", 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, }, - }); + }, + }); - state.setStateEvents([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(false); + }); - expect(state.maySendStateEvent('m.room.other_thing', userA)).toEqual(true); - expect(state.maySendStateEvent('m.room.other_thing', userB)).toEqual(false); + it("should honour explicit event power levels in the power_levels event", 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, + }, + }, }); + + 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.other_thing", userA)).toEqual(true); + expect(state.maySendStateEvent("m.room.other_thing", userB)).toEqual(false); + }); }); - describe("getJoinedMemberCount", function() { + describe("getJoinedMemberCount", function () { beforeEach(() => { state = new RoomState(roomId); }); - it("should update after adding joined member", function() { - state.setStateEvents([ - utils.mkMembership({ event: true, mship: "join", - user: userA, room: roomId }), - ]); + it("should update after adding joined member", function () { + state.setStateEvents([utils.mkMembership({ event: true, mship: "join", user: userA, room: roomId })]); expect(state.getJoinedMemberCount()).toEqual(1); - state.setStateEvents([ - utils.mkMembership({ event: true, mship: "join", - user: userC, room: roomId }), - ]); + state.setStateEvents([utils.mkMembership({ event: true, mship: "join", user: userC, room: roomId })]); expect(state.getJoinedMemberCount()).toEqual(2); }); }); - describe("getInvitedMemberCount", function() { + describe("getInvitedMemberCount", function () { beforeEach(() => { state = new RoomState(roomId); }); - it("should update after adding invited member", function() { - state.setStateEvents([ - utils.mkMembership({ event: true, mship: "invite", - user: userA, room: roomId }), - ]); + it("should update after adding invited member", function () { + state.setStateEvents([utils.mkMembership({ event: true, mship: "invite", user: userA, room: roomId })]); expect(state.getInvitedMemberCount()).toEqual(1); - state.setStateEvents([ - utils.mkMembership({ event: true, mship: "invite", - user: userC, room: roomId }), - ]); + state.setStateEvents([utils.mkMembership({ event: true, mship: "invite", user: userC, room: roomId })]); expect(state.getInvitedMemberCount()).toEqual(2); }); }); - describe("setJoinedMemberCount", function() { + describe("setJoinedMemberCount", function () { beforeEach(() => { state = new RoomState(roomId); }); - it("should, once used, override counting members from state", function() { - state.setStateEvents([ - utils.mkMembership({ event: true, mship: "join", - user: userA, room: roomId }), - ]); + it("should, once used, override counting members from state", function () { + state.setStateEvents([utils.mkMembership({ event: true, mship: "join", user: userA, room: roomId })]); expect(state.getJoinedMemberCount()).toEqual(1); state.setJoinedMemberCount(100); expect(state.getJoinedMemberCount()).toEqual(100); - state.setStateEvents([ - utils.mkMembership({ event: true, mship: "join", - user: userC, room: roomId }), - ]); + state.setStateEvents([utils.mkMembership({ event: true, mship: "join", user: userC, room: roomId })]); expect(state.getJoinedMemberCount()).toEqual(100); }); - it("should, once used, override counting members from state, " + - "also after clone", function() { - state.setStateEvents([ - utils.mkMembership({ event: true, mship: "join", - user: userA, room: roomId }), - ]); + it("should, once used, override counting members from state, " + "also after clone", function () { + state.setStateEvents([utils.mkMembership({ event: true, mship: "join", user: userA, room: roomId })]); state.setJoinedMemberCount(100); const copy = state.clone(); - copy.setStateEvents([ - utils.mkMembership({ event: true, mship: "join", - user: userC, room: roomId }), - ]); + copy.setStateEvents([utils.mkMembership({ event: true, mship: "join", user: userC, room: roomId })]); expect(state.getJoinedMemberCount()).toEqual(100); }); }); - describe("setInvitedMemberCount", function() { + describe("setInvitedMemberCount", function () { beforeEach(() => { state = new RoomState(roomId); }); - it("should, once used, override counting members from state", function() { - state.setStateEvents([ - utils.mkMembership({ event: true, mship: "invite", - user: userB, room: roomId }), - ]); + it("should, once used, override counting members from state", function () { + state.setStateEvents([utils.mkMembership({ event: true, mship: "invite", user: userB, room: roomId })]); expect(state.getInvitedMemberCount()).toEqual(1); state.setInvitedMemberCount(100); expect(state.getInvitedMemberCount()).toEqual(100); - state.setStateEvents([ - utils.mkMembership({ event: true, mship: "invite", - user: userC, room: roomId }), - ]); + state.setStateEvents([utils.mkMembership({ event: true, mship: "invite", user: userC, room: roomId })]); expect(state.getInvitedMemberCount()).toEqual(100); }); - it("should, once used, override counting members from state, " + - "also after clone", function() { - state.setStateEvents([ - utils.mkMembership({ event: true, mship: "invite", - user: userB, room: roomId }), - ]); + it("should, once used, override counting members from state, " + "also after clone", function () { + state.setStateEvents([utils.mkMembership({ event: true, mship: "invite", user: userB, room: roomId })]); state.setInvitedMemberCount(100); const copy = state.clone(); - copy.setStateEvents([ - utils.mkMembership({ event: true, mship: "invite", - user: userC, room: roomId }), - ]); + copy.setStateEvents([utils.mkMembership({ event: true, mship: "invite", user: userC, room: roomId })]); expect(state.getInvitedMemberCount()).toEqual(100); }); }); - 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); - }); + 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); + }); - it("should obey events_default", - 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, - }, + it("should obey events_default", 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, }, - }); + }, + }); - state.setStateEvents([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 = 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, - }, + it("should honour explicit event power levels in the power_levels event", 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, + }, + }, + }); - state.setStateEvents([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'); - const beacon2 = makeBeaconInfoEvent(userB, roomId, {}, '$beacon2'); + describe("processBeaconEvents", () => { + const beacon1 = makeBeaconInfoEvent(userA, roomId, {}, "$beacon1"); + const beacon2 = makeBeaconInfoEvent(userB, roomId, {}, "$beacon2"); const mockClient = { decryptEventIfNeeded: jest.fn() } as unknown as MockedObject; @@ -779,68 +833,70 @@ describe("RoomState", function() { mockClient.decryptEventIfNeeded.mockClear(); }); - it('does nothing when state has no beacons', () => { - const emitSpy = jest.spyOn(state, 'emit'); - state.processBeaconEvents([makeBeaconEvent(userA, { beaconInfoId: '$beacon1' })], mockClient); + it("does nothing when state has no beacons", () => { + const emitSpy = jest.spyOn(state, "emit"); + state.processBeaconEvents([makeBeaconEvent(userA, { beaconInfoId: "$beacon1" })], mockClient); expect(emitSpy).not.toHaveBeenCalled(); expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled(); }); - it('does nothing when there are no events', () => { + it("does nothing when there are no events", () => { state.setStateEvents([beacon1, beacon2]); - const emitSpy = jest.spyOn(state, 'emit').mockClear(); + const emitSpy = jest.spyOn(state, "emit").mockClear(); state.processBeaconEvents([], mockClient); expect(emitSpy).not.toHaveBeenCalled(); expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled(); }); - describe('without encryption', () => { - it('discards events for beacons that are not in state', () => { + describe("without encryption", () => { + it("discards events for beacons that are not in state", () => { const location = makeBeaconEvent(userA, { - beaconInfoId: 'some-other-beacon', + beaconInfoId: "some-other-beacon", }); const otherRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessage, content: { - ['m.relates_to']: { - event_id: 'whatever', + ["m.relates_to"]: { + event_id: "whatever", }, }, }); state.setStateEvents([beacon1, beacon2]); - const emitSpy = jest.spyOn(state, 'emit').mockClear(); + const emitSpy = jest.spyOn(state, "emit").mockClear(); state.processBeaconEvents([location, otherRelatedEvent], mockClient); expect(emitSpy).not.toHaveBeenCalled(); }); - it('discards events that are not beacon type', () => { + it("discards events that are not beacon type", () => { // related to beacon1 const otherRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessage, content: { - ['m.relates_to']: { + ["m.relates_to"]: { rel_type: RelationType.Reference, event_id: beacon1.getId(), }, }, }); state.setStateEvents([beacon1, beacon2]); - const emitSpy = jest.spyOn(state, 'emit').mockClear(); + const emitSpy = jest.spyOn(state, "emit").mockClear(); state.processBeaconEvents([otherRelatedEvent], mockClient); expect(emitSpy).not.toHaveBeenCalled(); }); - it('adds locations to beacons', () => { + it("adds locations to beacons", () => { const location1 = makeBeaconEvent(userA, { - beaconInfoId: '$beacon1', timestamp: Date.now() + 1, + beaconInfoId: "$beacon1", + timestamp: Date.now() + 1, }); const location2 = makeBeaconEvent(userA, { - beaconInfoId: '$beacon1', timestamp: Date.now() + 2, + beaconInfoId: "$beacon1", + timestamp: Date.now() + 2, }); const location3 = makeBeaconEvent(userB, { - beaconInfoId: 'some-other-beacon', + beaconInfoId: "some-other-beacon", }); state.setStateEvents([beacon1, beacon2]); @@ -848,7 +904,7 @@ describe("RoomState", function() { expect(state.beacons.size).toEqual(2); const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon; - const addLocationsSpy = jest.spyOn(beaconInstance, 'addLocations'); + const addLocationsSpy = jest.spyOn(beaconInstance, "addLocations"); state.processBeaconEvents([location1, location2, location3], mockClient); @@ -859,11 +915,13 @@ describe("RoomState", function() { }); }); - describe('with encryption', () => { - const beacon1RelationContent = { ['m.relates_to']: { - rel_type: RelationType.Reference, - event_id: beacon1.getId(), - } }; + describe("with encryption", () => { + const beacon1RelationContent = { + ["m.relates_to"]: { + rel_type: RelationType.Reference, + event_id: beacon1.getId(), + }, + }; const relatedEncryptedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, @@ -874,53 +932,53 @@ describe("RoomState", function() { type: EventType.RoomMessageEncrypted, content: beacon1RelationContent, }); - jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true); + jest.spyOn(decryptingRelatedEvent, "isBeingDecrypted").mockReturnValue(true); const failedDecryptionRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: beacon1RelationContent, }); - jest.spyOn(failedDecryptionRelatedEvent, 'isDecryptionFailure').mockReturnValue(true); + jest.spyOn(failedDecryptionRelatedEvent, "isDecryptionFailure").mockReturnValue(true); - it('discards events without relations', () => { + it("discards events without relations", () => { const unrelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, }); state.setStateEvents([beacon1, beacon2]); - const emitSpy = jest.spyOn(state, 'emit').mockClear(); + const emitSpy = jest.spyOn(state, "emit").mockClear(); state.processBeaconEvents([unrelatedEvent], mockClient); expect(emitSpy).not.toHaveBeenCalled(); // discard unrelated events early expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled(); }); - it('discards events for beacons that are not in state', () => { + it("discards events for beacons that are not in state", () => { const location = makeBeaconEvent(userA, { - beaconInfoId: 'some-other-beacon', + beaconInfoId: "some-other-beacon", }); const otherRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: { - ['m.relates_to']: { + ["m.relates_to"]: { rel_type: RelationType.Reference, - event_id: 'whatever', + event_id: "whatever", }, }, }); state.setStateEvents([beacon1, beacon2]); const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon; - const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear(); + const addLocationsSpy = jest.spyOn(beacon, "addLocations").mockClear(); state.processBeaconEvents([location, otherRelatedEvent], mockClient); expect(addLocationsSpy).not.toHaveBeenCalled(); // discard unrelated events early expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled(); }); - it('decrypts related events if needed', () => { + it("decrypts related events if needed", () => { const location = makeBeaconEvent(userA, { beaconInfoId: beacon1.getId(), }); @@ -930,15 +988,15 @@ describe("RoomState", function() { expect(mockClient.decryptEventIfNeeded).toHaveBeenCalledTimes(2); }); - it('listens for decryption on events that are being decrypted', () => { + it("listens for decryption on events that are being decrypted", () => { const decryptingRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: beacon1RelationContent, }); - jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true); + jest.spyOn(decryptingRelatedEvent, "isBeingDecrypted").mockReturnValue(true); // spy on event.once - const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, 'once'); + const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, "once"); state.setStateEvents([beacon1, beacon2]); state.processBeaconEvents([decryptingRelatedEvent], mockClient); @@ -947,15 +1005,15 @@ describe("RoomState", function() { expect(eventOnceSpy).toHaveBeenCalled(); }); - it('listens for decryption on events that have decryption failure', () => { + it("listens for decryption on events that have decryption failure", () => { const failedDecryptionRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: beacon1RelationContent, }); - jest.spyOn(failedDecryptionRelatedEvent, 'isDecryptionFailure').mockReturnValue(true); + jest.spyOn(failedDecryptionRelatedEvent, "isDecryptionFailure").mockReturnValue(true); // spy on event.once - const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, 'once'); + const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, "once"); state.setStateEvents([beacon1, beacon2]); state.processBeaconEvents([decryptingRelatedEvent], mockClient); @@ -964,16 +1022,16 @@ describe("RoomState", function() { expect(eventOnceSpy).toHaveBeenCalled(); }); - it('discard events that are not m.beacon type after decryption', () => { + it("discard events that are not m.beacon type after decryption", () => { const decryptingRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: beacon1RelationContent, }); - jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true); + jest.spyOn(decryptingRelatedEvent, "isBeingDecrypted").mockReturnValue(true); state.setStateEvents([beacon1, beacon2]); const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon; - const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear(); + const addLocationsSpy = jest.spyOn(beacon, "addLocations").mockClear(); state.processBeaconEvents([decryptingRelatedEvent], mockClient); // this event is a message after decryption @@ -983,19 +1041,20 @@ describe("RoomState", function() { expect(addLocationsSpy).not.toHaveBeenCalled(); }); - it('adds locations to beacons after decryption', () => { + it("adds locations to beacons after decryption", () => { const decryptingRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: beacon1RelationContent, }); const locationEvent = makeBeaconEvent(userA, { - beaconInfoId: '$beacon1', timestamp: Date.now() + 1, + beaconInfoId: "$beacon1", + timestamp: Date.now() + 1, }); - jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true); + jest.spyOn(decryptingRelatedEvent, "isBeingDecrypted").mockReturnValue(true); state.setStateEvents([beacon1, beacon2]); const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon; - const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear(); + const addLocationsSpy = jest.spyOn(beacon, "addLocations").mockClear(); state.processBeaconEvents([decryptingRelatedEvent], mockClient); // update type after '''decryption''' @@ -1010,17 +1069,21 @@ describe("RoomState", function() { describe("mayClientSendStateEvent", () => { it("should return false if the user isn't authenticated", () => { - expect(state.mayClientSendStateEvent("m.room.message", { - isGuest: jest.fn().mockReturnValue(false), - credentials: {}, - } as unknown as MatrixClient)).toBeFalsy(); + expect( + state.mayClientSendStateEvent("m.room.message", { + isGuest: jest.fn().mockReturnValue(false), + credentials: {}, + } as unknown as MatrixClient), + ).toBeFalsy(); }); it("should return false if the user is a guest", () => { - expect(state.mayClientSendStateEvent("m.room.message", { - isGuest: jest.fn().mockReturnValue(true), - credentials: { userId: userA }, - } as unknown as MatrixClient)).toBeFalsy(); + expect( + state.mayClientSendStateEvent("m.room.message", { + isGuest: jest.fn().mockReturnValue(true), + credentials: { userId: userA }, + } as unknown as MatrixClient), + ).toBeFalsy(); }); }); }); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index f6199570325..81f6602e784 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -16,128 +16,163 @@ limitations under the License. /** * This is an internal module. See {@link MatrixClient} for the public class. - * @module client */ +import { mocked } from "jest-mock"; + import * as utils from "../test-utils/test-utils"; +import { emitPromise } from "../test-utils/test-utils"; import { + Direction, DuplicateStrategy, EventStatus, EventTimelineSet, - EventType, IStateEventWithRoomId, + EventType, + IContent, + IEvent, + IRelationsRequestOpts, + IStateEventWithRoomId, JoinRule, MatrixEvent, MatrixEventEvent, PendingEventOrdering, RelationType, RoomEvent, + RoomMember, } from "../../src"; import { EventTimeline } from "../../src/models/event-timeline"; 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, WrappedReceipt } from "../../src/@types/read_receipts"; -import { FeatureSupport, Thread, ThreadEvent, THREAD_RELATION_TYPE } from "../../src/models/thread"; +import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread"; import { Crypto } from "../../src/crypto"; +import { mkThread } from "../test-utils/thread"; -describe("Room", function() { +describe("Room", function () { const roomId = "!foo:bar"; const userA = "@alice:bar"; const userB = "@bertha:bar"; const userC = "@clarissa:bar"; const userD = "@dorothy:bar"; - let room; - - const mkMessage = () => utils.mkMessage({ - event: true, - user: userA, - room: roomId, - }, room.client); - - const mkReply = (target: MatrixEvent) => utils.mkEvent({ - event: true, - type: EventType.RoomMessage, - user: userA, - room: roomId, - content: { - "body": "Reply :: " + Math.random(), - "m.relates_to": { - "m.in_reply_to": { - "event_id": target.getId()!, + let room: Room; + + const mkMessage = () => + utils.mkMessage( + { + event: true, + user: userA, + room: roomId, + }, + room.client, + ); + + const mkReply = (target: MatrixEvent) => + utils.mkEvent( + { + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "Reply :: " + Math.random(), + "m.relates_to": { + "m.in_reply_to": { + event_id: target.getId()!, + }, + }, }, }, - }, - }, room.client); - - const mkEdit = (target: MatrixEvent, salt = Math.random()) => utils.mkEvent({ - event: true, - type: EventType.RoomMessage, - user: userA, - room: roomId, - content: { - "body": "* Edit of :: " + target.getId() + " :: " + salt, - "m.new_content": { - body: "Edit of :: " + target.getId() + " :: " + salt, + room.client, + ); + + const mkEdit = (target: MatrixEvent, salt = Math.random()) => + utils.mkEvent( + { + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "* Edit of :: " + target.getId() + " :: " + salt, + "m.new_content": { + body: "Edit of :: " + target.getId() + " :: " + salt, + }, + "m.relates_to": { + rel_type: RelationType.Replace, + event_id: target.getId()!, + }, + }, }, - "m.relates_to": { - rel_type: RelationType.Replace, - event_id: target.getId()!, + room.client, + ); + + const mkThreadResponse = (root: MatrixEvent) => + utils.mkEvent( + { + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "Thread response :: " + Math.random(), + "m.relates_to": { + "event_id": root.getId()!, + "m.in_reply_to": { + event_id: root.getId()!, + }, + "rel_type": "m.thread", + }, + }, }, - }, - }, room.client); - - const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ - event: true, - type: EventType.RoomMessage, - user: userA, - room: roomId, - content: { - "body": "Thread response :: " + Math.random(), - "m.relates_to": { - "event_id": root.getId()!, - "m.in_reply_to": { - "event_id": root.getId()!, + room.client, + ); + + const mkReaction = (target: MatrixEvent) => + utils.mkEvent( + { + event: true, + type: EventType.Reaction, + user: userA, + room: roomId, + content: { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: target.getId()!, + key: Math.random().toString(), + }, }, - "rel_type": "m.thread", }, - }, - }, room.client); - - const mkReaction = (target: MatrixEvent) => utils.mkEvent({ - event: true, - type: EventType.Reaction, - user: userA, - room: roomId, - content: { - "m.relates_to": { - "rel_type": RelationType.Annotation, - "event_id": target.getId()!, - "key": Math.random().toString(), + room.client, + ); + + const mkRedaction = (target: MatrixEvent) => + utils.mkEvent( + { + event: true, + type: EventType.RoomRedaction, + user: userA, + room: roomId, + redacts: target.getId()!, + content: {}, }, - }, - }, room.client); - - const mkRedaction = (target: MatrixEvent) => utils.mkEvent({ - event: true, - type: EventType.RoomRedaction, - user: userA, - room: roomId, - redacts: target.getId()!, - content: {}, - }, room.client); - - beforeEach(function() { + room.client, + ); + + beforeEach(function () { room = new Room(roomId, new TestClient(userA, "device").client, userA); // mock RoomStates + // @ts-ignore room.oldState = room.getLiveTimeline().startState = utils.mock(RoomState, "oldState"); + // @ts-ignore room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState"); }); - describe('getCreator', () => { - it("should return the creator from m.room.create", function() { - room.currentState.getStateEvents.mockImplementation(function(type, key) { + describe("getCreator", () => { + it("should return the creator from m.room.create", function () { + // @ts-ignore - mocked doesn't handle overloads sanely + mocked(room.currentState.getStateEvents).mockImplementation(function (type, key) { if (type === EventType.RoomCreate && key === "") { return utils.mkEvent({ event: true, @@ -156,11 +191,12 @@ describe("Room", function() { }); }); - describe("getAvatarUrl", function() { + describe("getAvatarUrl", function () { const hsUrl = "https://my.home.server"; - it("should return the URL from m.room.avatar preferentially", function() { - room.currentState.getStateEvents.mockImplementation(function(type, key) { + it("should return the URL from m.room.avatar preferentially", function () { + // @ts-ignore - mocked doesn't handle overloads sanely + mocked(room.currentState.getStateEvents).mockImplementation(function (type, key) { if (type === EventType.RoomAvatar && key === "") { return utils.mkEvent({ event: true, @@ -174,47 +210,54 @@ describe("Room", function() { }); } }); - const url = room.getAvatarUrl(hsUrl); + const url = room.getAvatarUrl(hsUrl, 100, 100, "scale"); // 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.avatar and allowDefault=false", - function() { - const url = room.getAvatarUrl(hsUrl, 64, 64, "crop", false); - expect(url).toEqual(null); - }); + it("should return nothing if there is no m.room.avatar and allowDefault=false", function () { + const url = room.getAvatarUrl(hsUrl, 64, 64, "crop", false); + expect(url).toEqual(null); + }); }); - describe("getMember", function() { - beforeEach(function() { - room.currentState.getMember.mockImplementation(function(userId) { - return { - "@alice:bar": { - userId: userA, - roomId: roomId, - }, - }[userId] || null; + describe("getMember", function () { + beforeEach(function () { + mocked(room.currentState.getMember).mockImplementation(function (userId) { + return ( + { + "@alice:bar": { + userId: userA, + roomId: roomId, + } as unknown as RoomMember, + }[userId] || null + ); }); }); - it("should return null if the member isn't in current state", function() { + it("should return null if the member isn't in current state", function () { expect(room.getMember("@bar:foo")).toEqual(null); }); - it("should return the member from current state", function() { + it("should return the member from current state", function () { expect(room.getMember(userA)).not.toEqual(null); }); }); - describe("addLiveEvents", function() { + describe("addLiveEvents", function () { const events: MatrixEvent[] = [ utils.mkMessage({ - room: roomId, user: userA, msg: "changing room name", event: true, + room: roomId, + user: userA, + msg: "changing room name", + event: true, }), utils.mkEvent({ - type: EventType.RoomName, room: roomId, user: userA, event: true, + type: EventType.RoomName, + room: roomId, + user: userA, + event: true, content: { name: "New Room Name" }, }), ]; @@ -222,21 +265,26 @@ describe("Room", function() { it("Make sure legacy overload passing options directly as parameters still works", () => { expect(() => room.addLiveEvents(events, DuplicateStrategy.Replace, false)).not.toThrow(); expect(() => room.addLiveEvents(events, DuplicateStrategy.Ignore, true)).not.toThrow(); + // @ts-ignore expect(() => room.addLiveEvents(events, "shouldfailbecauseinvalidduplicatestrategy", false)).toThrow(); }); - it("should throw if duplicateStrategy isn't 'replace' or 'ignore'", function() { - expect(function() { + it("should throw if duplicateStrategy isn't 'replace' or 'ignore'", function () { + expect(function () { + // @ts-ignore room.addLiveEvents(events, { duplicateStrategy: "foo", }); }).toThrow(); }); - it("should replace a timeline event if dupe strategy is 'replace'", function() { + it("should replace a timeline event if dupe strategy is 'replace'", function () { // make a duplicate const dupe = utils.mkMessage({ - room: roomId, user: userA, msg: "dupe", event: true, + room: roomId, + user: userA, + msg: "dupe", + event: true, }); dupe.event.event_id = events[0].getId(); room.addLiveEvents(events); @@ -247,23 +295,27 @@ describe("Room", function() { expect(room.timeline[0]).toEqual(dupe); }); - it("should ignore a given dupe event if dupe strategy is 'ignore'", function() { + it("should ignore a given dupe event if dupe strategy is 'ignore'", function () { // make a duplicate const dupe = utils.mkMessage({ - room: roomId, user: userA, msg: "dupe", event: true, + room: roomId, + user: userA, + msg: "dupe", + event: true, }); dupe.event.event_id = events[0].getId(); room.addLiveEvents(events); expect(room.timeline[0]).toEqual(events[0]); + // @ts-ignore room.addLiveEvents([dupe], { duplicateStrategy: "ignore", }); expect(room.timeline[0]).toEqual(events[0]); }); - it("should emit 'Room.timeline' events", function() { + it("should emit 'Room.timeline' events", function () { let callCount = 0; - room.on("Room.timeline", function(event, emitRoom, toStart) { + room.on(RoomEvent.Timeline, function (event, emitRoom, toStart) { callCount += 1; expect(room.timeline.length).toEqual(callCount); expect(event).toEqual(events[callCount - 1]); @@ -274,40 +326,40 @@ describe("Room", function() { expect(callCount).toEqual(2); }); - it("should call setStateEvents on the right RoomState with the right forwardLooking value for new events", - function() { - const events: MatrixEvent[] = [ - utils.mkMembership({ - room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }), - utils.mkEvent({ - type: EventType.RoomName, room: roomId, user: userB, event: true, - content: { - name: "New room", - }, - }), - ]; - room.addLiveEvents(events); - expect(room.currentState.setStateEvents).toHaveBeenCalledWith( - [events[0]], - { timelineWasEmpty: false }, - ); - expect(room.currentState.setStateEvents).toHaveBeenCalledWith( - [events[1]], - { timelineWasEmpty: false }, - ); - expect(events[0].forwardLooking).toBe(true); - expect(events[1].forwardLooking).toBe(true); - expect(room.oldState.setStateEvents).not.toHaveBeenCalled(); - }); + it("should call setStateEvents on the right RoomState with the right forwardLooking value for new events", function () { + const events: MatrixEvent[] = [ + utils.mkMembership({ + room: roomId, + mship: "invite", + user: userB, + skey: userA, + event: true, + }), + utils.mkEvent({ + type: EventType.RoomName, + room: roomId, + user: userB, + event: true, + content: { + name: "New room", + }, + }), + ]; + room.addLiveEvents(events); + expect(room.currentState.setStateEvents).toHaveBeenCalledWith([events[0]], { timelineWasEmpty: false }); + expect(room.currentState.setStateEvents).toHaveBeenCalledWith([events[1]], { timelineWasEmpty: false }); + expect(events[0].forwardLooking).toBe(true); + expect(events[1].forwardLooking).toBe(true); + expect(room.oldState.setStateEvents).not.toHaveBeenCalled(); + }); - it("should synthesize read receipts for the senders of events", function() { + it("should synthesize read receipts for the senders of events", function () { const sentinel = { userId: userA, membership: "join", name: "Alice", - }; - room.currentState.getSentinelMember.mockImplementation(function(uid) { + } as unknown as RoomMember; + mocked(room.currentState.getSentinelMember).mockImplementation(function (uid) { if (uid === userA) { return sentinel; } @@ -317,41 +369,43 @@ describe("Room", function() { expect(room.getEventReadUpTo(userA)).toEqual(events[1].getId()); }); - it("should emit Room.localEchoUpdated when a local echo is updated", function() { + it("should emit Room.localEchoUpdated when a local echo is updated", function () { const localEvent = utils.mkMessage({ - room: roomId, user: userA, event: true, + room: roomId, + user: userA, + event: true, }); localEvent.status = EventStatus.SENDING; const localEventId = localEvent.getId(); const remoteEvent = utils.mkMessage({ - room: roomId, user: userA, event: true, + room: roomId, + user: userA, + event: true, }); remoteEvent.event.unsigned = { transaction_id: "TXN_ID" }; const remoteEventId = remoteEvent.getId(); let callCount = 0; - room.on("Room.localEchoUpdated", - function(event, emitRoom, oldEventId, oldStatus) { - switch (callCount) { - case 0: - expect(event.getId()).toEqual(localEventId); - expect(event.status).toEqual(EventStatus.SENDING); - expect(emitRoom).toEqual(room); - expect(oldEventId).toBeUndefined(); - expect(oldStatus).toBeUndefined(); - break; - case 1: - expect(event.getId()).toEqual(remoteEventId); - expect(event.status).toBeNull(); - expect(emitRoom).toEqual(room); - expect(oldEventId).toEqual(localEventId); - expect(oldStatus).toBe(EventStatus.SENDING); - break; - } - callCount += 1; - }, - ); + room.on(RoomEvent.LocalEchoUpdated, (event, emitRoom, oldEventId, oldStatus) => { + switch (callCount) { + case 0: + expect(event.getId()).toEqual(localEventId); + expect(event.status).toEqual(EventStatus.SENDING); + expect(emitRoom).toEqual(room); + expect(oldEventId).toBeUndefined(); + expect(oldStatus).toBeUndefined(); + break; + case 1: + expect(event.getId()).toEqual(remoteEventId); + expect(event.status).toBeNull(); + expect(emitRoom).toEqual(room); + expect(oldEventId).toEqual(localEventId); + expect(oldStatus).toBe(EventStatus.SENDING); + break; + } + callCount += 1; + }); // first add the local echo room.addPendingEvent(localEvent, "TXN_ID"); @@ -364,10 +418,12 @@ describe("Room", function() { expect(callCount).toEqual(2); }); - it("should be able to update local echo without a txn ID (/send then /sync)", function() { + it("should be able to update local echo without a txn ID (/send then /sync)", function () { const eventJson = utils.mkMessage({ - room: roomId, user: userA, event: false, - }) as object; + room: roomId, + user: userA, + event: false, + }); delete eventJson["txn_id"]; delete eventJson["event_id"]; const localEvent = new MatrixEvent(Object.assign({ event_id: "$temp" }, eventJson)); @@ -395,10 +451,12 @@ describe("Room", function() { expect(room.getEventForTxnId(txnId)).toBeUndefined(); }); - it("should be able to update local echo without a txn ID (/sync then /send)", function() { + it("should be able to update local echo without a txn ID (/sync then /send)", function () { const eventJson = utils.mkMessage({ - room: roomId, user: userA, event: false, - }) as object; + room: roomId, + user: userA, + event: false, + }); delete eventJson["txn_id"]; delete eventJson["event_id"]; const txnId = "My_txn_id"; @@ -431,7 +489,9 @@ describe("Room", function() { it("should correctly handle remote echoes from other devices", () => { const remoteEvent = utils.mkMessage({ - room: roomId, user: userA, event: true, + room: roomId, + user: userA, + event: true, }); remoteEvent.event.unsigned = { transaction_id: "TXN_ID" }; @@ -441,8 +501,8 @@ describe("Room", function() { }); }); - describe('addEphemeralEvents', () => { - it("should call RoomState.setTypingEvent on m.typing events", function() { + describe("addEphemeralEvents", () => { + it("should call RoomState.setTypingEvent on m.typing events", function () { const typing = utils.mkEvent({ room: roomId, type: EventType.Typing, @@ -456,64 +516,69 @@ describe("Room", function() { }); }); - describe("addEventsToTimeline", function() { + describe("addEventsToTimeline", function () { const events = [ utils.mkMessage({ - room: roomId, user: userA, msg: "changing room name", event: true, + room: roomId, + user: userA, + msg: "changing room name", + event: true, }), utils.mkEvent({ - type: EventType.RoomName, room: roomId, user: userA, event: true, + type: EventType.RoomName, + room: roomId, + user: userA, + event: true, content: { name: "New Room Name" }, }), ]; - it("should not be able to add events to the end", function() { - expect(function() { + it("should not be able to add events to the end", function () { + expect(function () { room.addEventsToTimeline(events, false, room.getLiveTimeline()); }).toThrow(); }); - it("should be able to add events to the start", function() { + it("should be able to add events to the start", function () { room.addEventsToTimeline(events, true, room.getLiveTimeline()); expect(room.timeline.length).toEqual(2); expect(room.timeline[0]).toEqual(events[1]); expect(room.timeline[1]).toEqual(events[0]); }); - it("should emit 'Room.timeline' events when added to the start", - function() { - let callCount = 0; - room.on("Room.timeline", function(event, emitRoom, toStart) { - callCount += 1; - expect(room.timeline.length).toEqual(callCount); - expect(event).toEqual(events[callCount - 1]); - expect(emitRoom).toEqual(room); - expect(toStart).toBe(true); - }); - room.addEventsToTimeline(events, true, room.getLiveTimeline()); - expect(callCount).toEqual(2); + it("should emit 'Room.timeline' events when added to the start", function () { + let callCount = 0; + room.on(RoomEvent.Timeline, function (event, emitRoom, toStart) { + callCount += 1; + expect(room.timeline.length).toEqual(callCount); + expect(event).toEqual(events[callCount - 1]); + expect(emitRoom).toEqual(room); + expect(toStart).toBe(true); }); + room.addEventsToTimeline(events, true, room.getLiveTimeline()); + expect(callCount).toEqual(2); + }); }); - describe("event metadata handling", function() { - it("should set event.sender for new and old events", function() { + describe("event metadata handling", function () { + it("should set event.sender for new and old events", function () { const sentinel = { userId: userA, membership: "join", name: "Alice", - }; + } as unknown as RoomMember; const oldSentinel = { userId: userA, membership: "join", name: "Old Alice", - }; - room.currentState.getSentinelMember.mockImplementation(function(uid) { + } as unknown as RoomMember; + mocked(room.currentState.getSentinelMember).mockImplementation(function (uid) { if (uid === userA) { return sentinel; } return null; }); - room.oldState.getSentinelMember.mockImplementation(function(uid) { + mocked(room.oldState.getSentinelMember).mockImplementation(function (uid) { if (uid === userA) { return oldSentinel; } @@ -521,11 +586,17 @@ describe("Room", function() { }); const newEv = utils.mkEvent({ - type: EventType.RoomName, room: roomId, user: userA, event: true, + type: EventType.RoomName, + room: roomId, + user: userA, + event: true, content: { name: "New Room Name" }, }); const oldEv = utils.mkEvent({ - type: EventType.RoomName, room: roomId, user: userA, event: true, + type: EventType.RoomName, + room: roomId, + user: userA, + event: true, content: { name: "Old Room Name" }, }); room.addLiveEvents([newEv]); @@ -534,24 +605,24 @@ describe("Room", function() { expect(oldEv.sender).toEqual(oldSentinel); }); - it("should set event.target for new and old m.room.member events", function() { + it("should set event.target for new and old m.room.member events", function () { const sentinel = { userId: userA, membership: "join", name: "Alice", - }; + } as unknown as RoomMember; const oldSentinel = { userId: userA, membership: "join", name: "Old Alice", - }; - room.currentState.getSentinelMember.mockImplementation(function(uid) { + } as unknown as RoomMember; + mocked(room.currentState.getSentinelMember).mockImplementation(function (uid) { if (uid === userA) { return sentinel; } return null; }); - room.oldState.getSentinelMember.mockImplementation(function(uid) { + mocked(room.oldState.getSentinelMember).mockImplementation(function (uid) { if (uid === userA) { return oldSentinel; } @@ -559,10 +630,18 @@ describe("Room", function() { }); const newEv = utils.mkMembership({ - room: roomId, mship: "invite", user: userB, skey: userA, event: true, + room: roomId, + mship: "invite", + user: userB, + skey: userA, + event: true, }); const oldEv = utils.mkMembership({ - room: roomId, mship: "ban", user: userB, skey: userA, event: true, + room: roomId, + mship: "ban", + user: userB, + skey: userA, + event: true, }); room.addLiveEvents([newEv]); expect(newEv.target).toEqual(sentinel); @@ -570,77 +649,89 @@ describe("Room", function() { expect(oldEv.target).toEqual(oldSentinel); }); - it("should call setStateEvents on the right RoomState with the right " + - "forwardLooking value for old events", function() { - const events: MatrixEvent[] = [ - utils.mkMembership({ - room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }), - utils.mkEvent({ - type: EventType.RoomName, room: roomId, user: userB, event: true, - content: { - name: "New room", - }, - }), - ]; + it( + "should call setStateEvents on the right RoomState with the right " + "forwardLooking value for old events", + function () { + const events: MatrixEvent[] = [ + utils.mkMembership({ + room: roomId, + mship: "invite", + user: userB, + skey: userA, + event: true, + }), + utils.mkEvent({ + type: EventType.RoomName, + room: roomId, + user: userB, + event: true, + content: { + name: "New room", + }, + }), + ]; - room.addEventsToTimeline(events, true, room.getLiveTimeline()); - expect(room.oldState.setStateEvents).toHaveBeenCalledWith( - [events[0]], - { timelineWasEmpty: undefined }, - ); - expect(room.oldState.setStateEvents).toHaveBeenCalledWith( - [events[1]], - { timelineWasEmpty: undefined }, - ); - expect(events[0].forwardLooking).toBe(false); - expect(events[1].forwardLooking).toBe(false); - expect(room.currentState.setStateEvents).not.toHaveBeenCalled(); - }); + room.addEventsToTimeline(events, true, room.getLiveTimeline()); + expect(room.oldState.setStateEvents).toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined }); + expect(room.oldState.setStateEvents).toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined }); + expect(events[0].forwardLooking).toBe(false); + expect(events[1].forwardLooking).toBe(false); + expect(room.currentState.setStateEvents).not.toHaveBeenCalled(); + }, + ); }); - const resetTimelineTests = function(timelineSupport) { + const resetTimelineTests = function (timelineSupport: boolean) { let events: MatrixEvent[]; - beforeEach(function() { + beforeEach(function () { room = new Room(roomId, new TestClient(userA).client, userA, { timelineSupport: timelineSupport }); // set events each time to avoid resusing Event objects (which // doesn't work because they get frozen) events = [ utils.mkMessage({ - room: roomId, user: userA, msg: "A message", event: true, + room: roomId, + user: userA, + msg: "A message", + event: true, }), utils.mkEvent({ - type: EventType.RoomName, room: roomId, user: userA, event: true, + type: EventType.RoomName, + room: roomId, + user: userA, + event: true, content: { name: "New Room Name" }, }), utils.mkEvent({ - type: EventType.RoomName, room: roomId, user: userA, event: true, + type: EventType.RoomName, + room: roomId, + user: userA, + event: true, content: { name: "Another New Name" }, }), ]; }); - it("should copy state from previous timeline", function() { + it("should copy state from previous timeline", function () { room.addLiveEvents([events[0], events[1]]); expect(room.getLiveTimeline().getEvents().length).toEqual(2); - room.resetLiveTimeline('sometoken', 'someothertoken'); + room.resetLiveTimeline("sometoken", "someothertoken"); room.addLiveEvents([events[2]]); const oldState = room.getLiveTimeline().getState(EventTimeline.BACKWARDS); const newState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); expect(room.getLiveTimeline().getEvents().length).toEqual(1); - expect(oldState.getStateEvents(EventType.RoomName, "")).toEqual(events[1]); - expect(newState.getStateEvents(EventType.RoomName, "")).toEqual(events[2]); + expect(oldState?.getStateEvents(EventType.RoomName, "")).toEqual(events[1]); + expect(newState?.getStateEvents(EventType.RoomName, "")).toEqual(events[2]); }); - it("should reset the legacy timeline fields", function() { + it("should reset the legacy timeline fields", function () { room.addLiveEvents([events[0], events[1]]); expect(room.timeline.length).toEqual(2); const oldStateBeforeRunningReset = room.oldState; let oldStateUpdateEmitCount = 0; - room.on(RoomEvent.OldStateUpdated, function(room, previousOldState, oldState) { + room.on(RoomEvent.OldStateUpdated, function (room, previousOldState, oldState) { expect(previousOldState).toBe(oldStateBeforeRunningReset); expect(oldState).toBe(room.oldState); oldStateUpdateEmitCount += 1; @@ -648,38 +739,33 @@ describe("Room", function() { const currentStateBeforeRunningReset = room.currentState; let currentStateUpdateEmitCount = 0; - room.on(RoomEvent.CurrentStateUpdated, function(room, previousCurrentState, currentState) { + room.on(RoomEvent.CurrentStateUpdated, function (room, previousCurrentState, currentState) { expect(previousCurrentState).toBe(currentStateBeforeRunningReset); expect(currentState).toBe(room.currentState); currentStateUpdateEmitCount += 1; }); - room.resetLiveTimeline('sometoken', 'someothertoken'); + room.resetLiveTimeline("sometoken", "someothertoken"); room.addLiveEvents([events[2]]); const newLiveTimeline = room.getLiveTimeline(); expect(room.timeline).toEqual(newLiveTimeline.getEvents()); - expect(room.oldState).toEqual( - newLiveTimeline.getState(EventTimeline.BACKWARDS)); - expect(room.currentState).toEqual( - newLiveTimeline.getState(EventTimeline.FORWARDS)); + expect(room.oldState).toEqual(newLiveTimeline.getState(EventTimeline.BACKWARDS)); + expect(room.currentState).toEqual(newLiveTimeline.getState(EventTimeline.FORWARDS)); // Make sure `RoomEvent.OldStateUpdated` was emitted expect(oldStateUpdateEmitCount).toEqual(1); // Make sure `RoomEvent.OldStateUpdated` was emitted if necessary expect(currentStateUpdateEmitCount).toEqual(timelineSupport ? 1 : 0); }); - it("should emit Room.timelineReset event and set the correct " + - "pagination token", function() { + it("should emit Room.timelineReset event and set the correct pagination token", function () { let callCount = 0; - room.on("Room.timelineReset", function(emitRoom) { + room.on(RoomEvent.TimelineReset, function (emitRoom) { callCount += 1; expect(emitRoom).toEqual(room); - // make sure that the pagination token has been set before the - // event is emitted. - const tok = emitRoom.getLiveTimeline() - .getPaginationToken(EventTimeline.BACKWARDS); + // make sure that the pagination token has been set before the event is emitted. + const tok = emitRoom?.getLiveTimeline().getPaginationToken(EventTimeline.BACKWARDS); expect(tok).toEqual("pagToken"); }); @@ -687,13 +773,13 @@ describe("Room", function() { expect(callCount).toEqual(1); }); - it("should " + (timelineSupport ? "remember" : "forget") + " old timelines", function() { + it("should " + (timelineSupport ? "remember" : "forget") + " old timelines", function () { room.addLiveEvents([events[0]]); expect(room.timeline.length).toEqual(1); const firstLiveTimeline = room.getLiveTimeline(); - room.resetLiveTimeline('sometoken', 'someothertoken'); + room.resetLiveTimeline("sometoken", "someothertoken"); - const tl = room.getTimelineForEvent(events[0].getId()); + const tl = room.getTimelineForEvent(events[0].getId()!); expect(tl).toBe(timelineSupport ? firstLiveTimeline : null); }); }; @@ -701,89 +787,94 @@ describe("Room", function() { describe("resetLiveTimeline with timeline support enabled", resetTimelineTests.bind(null, true)); describe("resetLiveTimeline with timeline support disabled", resetTimelineTests.bind(null, false)); - describe("compareEventOrdering", function() { - beforeEach(function() { + describe("compareEventOrdering", function () { + beforeEach(function () { room = new Room(roomId, new TestClient(userA).client, userA, { timelineSupport: true }); }); const events: MatrixEvent[] = [ utils.mkMessage({ - room: roomId, user: userA, msg: "1111", event: true, + room: roomId, + user: userA, + msg: "1111", + event: true, }), utils.mkMessage({ - room: roomId, user: userA, msg: "2222", event: true, + room: roomId, + user: userA, + msg: "2222", + event: true, }), utils.mkMessage({ - room: roomId, user: userA, msg: "3333", event: true, + room: roomId, + user: userA, + msg: "3333", + event: true, }), ]; - it("should handle events in the same timeline", function() { + it("should handle events in the same timeline", function () { room.addLiveEvents(events); - expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, - events[1].getId())) - .toBeLessThan(0); - expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[2].getId()!, - events[1].getId())) - .toBeGreaterThan(0); - expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId()!, - events[1].getId())) - .toEqual(0); + expect( + room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, events[1].getId()!), + ).toBeLessThan(0); + expect( + room.getUnfilteredTimelineSet().compareEventOrdering(events[2].getId()!, events[1].getId()!), + ).toBeGreaterThan(0); + expect( + room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId()!, events[1].getId()!), + ).toEqual(0); }); - it("should handle events in adjacent timelines", function() { + it("should handle events in adjacent timelines", function () { const oldTimeline = room.addTimeline(); - oldTimeline.setNeighbouringTimeline(room.getLiveTimeline(), 'f'); - room.getLiveTimeline().setNeighbouringTimeline(oldTimeline, 'b'); + oldTimeline.setNeighbouringTimeline(room.getLiveTimeline(), Direction.Forward); + room.getLiveTimeline().setNeighbouringTimeline(oldTimeline, Direction.Backward); room.addEventsToTimeline([events[0]], false, oldTimeline); room.addLiveEvents([events[1]]); - expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, - events[1].getId())) - .toBeLessThan(0); - expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId()!, - events[0].getId())) - .toBeGreaterThan(0); + expect( + room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, events[1].getId()!), + ).toBeLessThan(0); + expect( + room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId()!, events[0].getId()!), + ).toBeGreaterThan(0); }); - it("should return null for events in non-adjacent timelines", function() { + it("should return null for events in non-adjacent timelines", function () { const oldTimeline = room.addTimeline(); room.addEventsToTimeline([events[0]], false, oldTimeline); room.addLiveEvents([events[1]]); - expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, - events[1].getId())) - .toBe(null); - expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId()!, - events[0].getId())) - .toBe(null); + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, events[1].getId()!)).toBe( + null, + ); + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId()!, events[0].getId()!)).toBe( + null, + ); }); - it("should return null for unknown events", function() { + it("should return null for unknown events", function () { room.addLiveEvents(events); - expect(room.getUnfilteredTimelineSet() - .compareEventOrdering(events[0].getId()!, "xxx")) - .toBe(null); - expect(room.getUnfilteredTimelineSet() - .compareEventOrdering("xxx", events[0].getId())) - .toBe(null); - expect(room.getUnfilteredTimelineSet() - .compareEventOrdering(events[0].getId()!, events[0].getId())) - .toBe(0); + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, "xxx")).toBe(null); + expect(room.getUnfilteredTimelineSet().compareEventOrdering("xxx", events[0].getId()!)).toBe(null); + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, events[0].getId()!)).toBe( + 0, + ); }); }); - describe("getJoinedMembers", function() { - it("should return members whose membership is 'join'", function() { - room.currentState.getMembers.mockImplementation(function() { + describe("getJoinedMembers", function () { + it("should return members whose membership is 'join'", function () { + mocked(room.currentState.getMembers).mockImplementation(function () { return [ - { userId: "@alice:bar", membership: "join" }, - { userId: "@bob:bar", membership: "invite" }, - { userId: "@cleo:bar", membership: "leave" }, + { userId: "@alice:bar", membership: "join" } as unknown as RoomMember, + { userId: "@bob:bar", membership: "invite" } as unknown as RoomMember, + { userId: "@cleo:bar", membership: "leave" } as unknown as RoomMember, ]; }); const res = room.getJoinedMembers(); @@ -791,93 +882,110 @@ describe("Room", function() { expect(res[0].userId).toEqual("@alice:bar"); }); - it("should return an empty list if no membership is 'join'", function() { - room.currentState.getMembers.mockImplementation(function() { - return [ - { userId: "@bob:bar", membership: "invite" }, - ]; + it("should return an empty list if no membership is 'join'", function () { + mocked(room.currentState.getMembers).mockImplementation(function () { + return [{ userId: "@bob:bar", membership: "invite" } as unknown as RoomMember]; }); const res = room.getJoinedMembers(); expect(res.length).toEqual(0); }); }); - describe("hasMembershipState", function() { - it("should return true for a matching userId and membership", - function() { - room.currentState.getMember.mockImplementation(function(userId) { - return { - "@alice:bar": { userId: "@alice:bar", membership: "join" }, - "@bob:bar": { userId: "@bob:bar", membership: "invite" }, - }[userId]; - }); - expect(room.hasMembershipState("@bob:bar", "invite")).toBe(true); + describe("hasMembershipState", function () { + it("should return true for a matching userId and membership", function () { + mocked(room.currentState.getMember).mockImplementation(function (userId) { + return { + "@alice:bar": { userId: "@alice:bar", membership: "join" }, + "@bob:bar": { userId: "@bob:bar", membership: "invite" }, + }[userId] as unknown as RoomMember; }); + expect(room.hasMembershipState("@bob:bar", "invite")).toBe(true); + }); - it("should return false if match membership but no match userId", - function() { - room.currentState.getMember.mockImplementation(function(userId) { - return { - "@alice:bar": { userId: "@alice:bar", membership: "join" }, - }[userId]; - }); - expect(room.hasMembershipState("@bob:bar", "join")).toBe(false); + it("should return false if match membership but no match userId", function () { + mocked(room.currentState.getMember).mockImplementation(function (userId) { + return { + "@alice:bar": { userId: "@alice:bar", membership: "join" }, + }[userId] as unknown as RoomMember; }); + expect(room.hasMembershipState("@bob:bar", "join")).toBe(false); + }); - it("should return false if match userId but no match membership", - function() { - room.currentState.getMember.mockImplementation(function(userId) { - return { - "@alice:bar": { userId: "@alice:bar", membership: "join" }, - }[userId]; - }); - expect(room.hasMembershipState("@alice:bar", "ban")).toBe(false); + it("should return false if match userId but no match membership", function () { + mocked(room.currentState.getMember).mockImplementation(function (userId) { + return { + "@alice:bar": { userId: "@alice:bar", membership: "join" }, + }[userId] as unknown as RoomMember; }); + expect(room.hasMembershipState("@alice:bar", "ban")).toBe(false); + }); - it("should return false if no match membership or userId", - function() { - room.currentState.getMember.mockImplementation(function(userId) { - return { - "@alice:bar": { userId: "@alice:bar", membership: "join" }, - }[userId]; - }); - expect(room.hasMembershipState("@bob:bar", "invite")).toBe(false); + it("should return false if no match membership or userId", function () { + mocked(room.currentState.getMember).mockImplementation(function (userId) { + return { + "@alice:bar": { userId: "@alice:bar", membership: "join" }, + }[userId] as unknown as RoomMember; }); + expect(room.hasMembershipState("@bob:bar", "invite")).toBe(false); + }); - it("should return false if no members exist", - function() { - expect(room.hasMembershipState("@foo:bar", "join")).toBe(false); - }); + it("should return false if no members exist", function () { + expect(room.hasMembershipState("@foo:bar", "join")).toBe(false); + }); }); - describe("recalculate", function() { - const setJoinRule = function(rule: JoinRule) { - room.addLiveEvents([utils.mkEvent({ - type: EventType.RoomJoinRules, room: roomId, user: userA, content: { - join_rule: rule, - }, event: true, - })]); + describe("recalculate", function () { + const setJoinRule = function (rule: JoinRule) { + room.addLiveEvents([ + utils.mkEvent({ + type: EventType.RoomJoinRules, + room: roomId, + user: userA, + content: { + join_rule: rule, + }, + event: true, + }), + ]); }; - const setAltAliases = function(aliases: string[]) { - room.addLiveEvents([utils.mkEvent({ - type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: { - alt_aliases: aliases, - }, event: true, - })]); + const setAltAliases = function (aliases: string[]) { + room.addLiveEvents([ + utils.mkEvent({ + type: EventType.RoomCanonicalAlias, + room: roomId, + skey: "", + content: { + alt_aliases: aliases, + }, + event: true, + }), + ]); }; - const setAlias = function(alias: string) { - room.addLiveEvents([utils.mkEvent({ - type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: { alias }, event: true, - })]); + const setAlias = function (alias: string) { + room.addLiveEvents([ + utils.mkEvent({ + type: EventType.RoomCanonicalAlias, + room: roomId, + skey: "", + content: { alias }, + event: true, + }), + ]); }; - const setRoomName = function(name: string) { - room.addLiveEvents([utils.mkEvent({ - type: EventType.RoomName, room: roomId, user: userA, content: { - name: name, - }, event: true, - })]); + const setRoomName = function (name: string) { + room.addLiveEvents([ + utils.mkEvent({ + type: EventType.RoomName, + room: roomId, + user: userA, + content: { + name: name, + }, + event: true, + }), + ]); }; - const addMember = function(userId: string, state = "join", opts: any = {}) { + const addMember = function (userId: string, state = "join", opts: any = {}) { opts.room = roomId; opts.mship = state; opts.user = opts.user || userId; @@ -888,53 +996,59 @@ describe("Room", function() { return event; }; - beforeEach(function() { + beforeEach(function () { // no mocking room = new Room(roomId, new TestClient(userA).client, userA); }); - describe("Room.recalculate => Stripped State Events", function() { - it("should set stripped state events as actual state events if the " + - "room is an invite room", function() { - const roomName = "flibble"; - - const event = addMember(userA, "invite"); - event.event.unsigned = {}; - event.event.unsigned.invite_room_state = [{ - type: EventType.RoomName, - state_key: "", - content: { - name: roomName, - }, - sender: "@bob:foobar", - }]; + describe("Room.recalculate => Stripped State Events", function () { + it( + "should set stripped state events as actual state events if the " + "room is an invite room", + function () { + const roomName = "flibble"; + + const event = addMember(userA, "invite"); + event.event.unsigned = {}; + event.event.unsigned.invite_room_state = [ + { + type: EventType.RoomName, + state_key: "", + content: { + name: roomName, + }, + sender: "@bob:foobar", + }, + ]; - room.recalculate(); - expect(room.name).toEqual(roomName); - }); + room.recalculate(); + expect(room.name).toEqual(roomName); + }, + ); - it("should not clobber state events if it isn't an invite room", function() { + it("should not clobber state events if it isn't an invite room", function () { const event = addMember(userA, "join"); const roomName = "flibble"; setRoomName(roomName); const roomNameToIgnore = "ignoreme"; event.event.unsigned = {}; - event.event.unsigned.invite_room_state = [{ - type: EventType.RoomName, - state_key: "", - content: { - name: roomNameToIgnore, + event.event.unsigned.invite_room_state = [ + { + type: EventType.RoomName, + state_key: "", + content: { + name: roomNameToIgnore, + }, + sender: "@bob:foobar", }, - sender: "@bob:foobar", - }]; + ]; room.recalculate(); expect(room.name).toEqual(roomName); }); }); - describe("Room.recalculate => Room Name using room summary", function() { - it("should use room heroes if available", function() { + describe("Room.recalculate => Room Name using room summary", function () { + it("should use room heroes if available", function () { addMember(userA, "invite"); addMember(userB); addMember(userC); @@ -947,7 +1061,7 @@ describe("Room", function() { expect(room.name).toEqual(`${userB} and 2 others`); }); - it("missing hero member state reverts to mxid", function() { + it("missing hero member state reverts to mxid", function () { room.setSummary({ "m.heroes": [userB], "m.joined_member_count": 2, @@ -957,7 +1071,7 @@ describe("Room", function() { expect(room.name).toEqual(userB); }); - it("uses hero name from state", function() { + it("uses hero name from state", function () { const name = "Mr B"; addMember(userA, "invite"); addMember(userB, "join", { name }); @@ -969,7 +1083,7 @@ describe("Room", function() { expect(room.name).toEqual(name); }); - it("uses counts from summary", function() { + it("uses counts from summary", function () { const name = "Mr B"; addMember(userB, "join", { name }); room.setSummary({ @@ -981,7 +1095,7 @@ describe("Room", function() { expect(room.name).toEqual(`${name} and 98 others`); }); - it("relies on heroes in case of absent counts", function() { + it("relies on heroes in case of absent counts", function () { const nameB = "Mr Bean"; const nameC = "Mel C"; addMember(userB, "join", { name: nameB }); @@ -993,7 +1107,7 @@ describe("Room", function() { expect(room.name).toEqual(`${nameB} and ${nameC}`); }); - it("uses only heroes", function() { + it("uses only heroes", function () { const nameB = "Mr Bean"; addMember(userB, "join", { name: nameB }); addMember(userC, "join"); @@ -1004,7 +1118,7 @@ describe("Room", function() { expect(room.name).toEqual(nameB); }); - it("reverts to empty room in case of self chat", function() { + it("reverts to empty room in case of self chat", function () { room.setSummary({ "m.heroes": [], "m.invited_member_count": 1, @@ -1014,103 +1128,126 @@ describe("Room", function() { }); }); - describe("Room.recalculate => Room Name", function() { - it("should return the names of members in a private (invite join_rules)" + - " room if a room name and alias don't exist and there are >3 members.", - function() { - setJoinRule(JoinRule.Invite); - addMember(userA); - addMember(userB); - addMember(userC); - addMember(userD); - room.recalculate(); - const name = room.name; - // we expect at least 1 member to be mentioned - const others = [userB, userC, userD]; - let found = false; - for (let i = 0; i < others.length; i++) { - if (name.indexOf(others[i]) !== -1) { - found = true; - break; + describe("Room.recalculate => Room Name", function () { + it( + "should return the names of members in a private (invite join_rules)" + + " room if a room name and alias don't exist and there are >3 members.", + function () { + setJoinRule(JoinRule.Invite); + addMember(userA); + addMember(userB); + addMember(userC); + addMember(userD); + room.recalculate(); + const name = room.name; + // we expect at least 1 member to be mentioned + const others = [userB, userC, userD]; + let found = false; + for (let i = 0; i < others.length; i++) { + if (name.indexOf(others[i]) !== -1) { + found = true; + break; + } } - } - expect(found).toEqual(true); - }); + expect(found).toEqual(true); + }, + ); - it("should return the names of members in a private (invite join_rules)" + - " room if a room name and alias don't exist and there are >2 members.", function() { - setJoinRule(JoinRule.Invite); - addMember(userA); - addMember(userB); - addMember(userC); - room.recalculate(); - const name = room.name; - expect(name.indexOf(userB)).not.toEqual(-1); - expect(name.indexOf(userC)).not.toEqual(-1); - }); + it( + "should return the names of members in a private (invite join_rules)" + + " room if a room name and alias don't exist and there are >2 members.", + function () { + setJoinRule(JoinRule.Invite); + addMember(userA); + addMember(userB); + addMember(userC); + room.recalculate(); + const name = room.name; + expect(name.indexOf(userB)).not.toEqual(-1); + expect(name.indexOf(userC)).not.toEqual(-1); + }, + ); - it("should return the names of members in a public (public join_rules)" + - " room if a room name and alias don't exist and there are >2 members.", function() { - setJoinRule(JoinRule.Public); - addMember(userA); - addMember(userB); - addMember(userC); - room.recalculate(); - const name = room.name; - expect(name.indexOf(userB)).not.toEqual(-1); - expect(name.indexOf(userC)).not.toEqual(-1); - }); + it( + "should return the names of members in a public (public join_rules)" + + " room if a room name and alias don't exist and there are >2 members.", + function () { + setJoinRule(JoinRule.Public); + addMember(userA); + addMember(userB); + addMember(userC); + room.recalculate(); + const name = room.name; + expect(name.indexOf(userB)).not.toEqual(-1); + expect(name.indexOf(userC)).not.toEqual(-1); + }, + ); - it("should show the other user's name for public (public join_rules)" + - " rooms if a room name and alias don't exist and it is a 1:1-chat.", function() { - setJoinRule(JoinRule.Public); - addMember(userA); - addMember(userB); - room.recalculate(); - const name = room.name; - expect(name.indexOf(userB)).not.toEqual(-1); - }); + it( + "should show the other user's name for public (public join_rules)" + + " rooms if a room name and alias don't exist and it is a 1:1-chat.", + function () { + setJoinRule(JoinRule.Public); + addMember(userA); + addMember(userB); + room.recalculate(); + const name = room.name; + expect(name.indexOf(userB)).not.toEqual(-1); + }, + ); - it("should show the other user's name for private " + - "(invite join_rules) rooms if a room name and alias don't exist and it" + - " is a 1:1-chat.", function() { - setJoinRule(JoinRule.Invite); - addMember(userA); - addMember(userB); - room.recalculate(); - const name = room.name; - expect(name.indexOf(userB)).not.toEqual(-1); - }); + it( + "should show the other user's name for private " + + "(invite join_rules) rooms if a room name and alias don't exist and it" + + " is a 1:1-chat.", + function () { + setJoinRule(JoinRule.Invite); + addMember(userA); + addMember(userB); + room.recalculate(); + const name = room.name; + expect(name.indexOf(userB)).not.toEqual(-1); + }, + ); - it("should show the other user's name for private" + - " (invite join_rules) rooms if you are invited to it.", function() { - setJoinRule(JoinRule.Invite); - addMember(userA, "invite", { user: userB }); - addMember(userB); - room.recalculate(); - const name = room.name; - expect(name.indexOf(userB)).not.toEqual(-1); - }); + it( + "should show the other user's name for private" + + " (invite join_rules) rooms if you are invited to it.", + function () { + setJoinRule(JoinRule.Invite); + addMember(userA, "invite", { user: userB }); + addMember(userB); + room.recalculate(); + const name = room.name; + expect(name.indexOf(userB)).not.toEqual(-1); + }, + ); - it("should show the room alias if one exists for private " + - "(invite join_rules) rooms if a room name doesn't exist.", function() { - const alias = "#room_alias:here"; - setJoinRule(JoinRule.Invite); - setAlias(alias); - room.recalculate(); - const name = room.name; - expect(name).toEqual(alias); - }); + it( + "should show the room alias if one exists for private " + + "(invite join_rules) rooms if a room name doesn't exist.", + function () { + const alias = "#room_alias:here"; + setJoinRule(JoinRule.Invite); + setAlias(alias); + room.recalculate(); + const name = room.name; + expect(name).toEqual(alias); + }, + ); - it("should show the room alias if one exists for public " + - "(public join_rules) rooms if a room name doesn't exist.", function() { - const alias = "#room_alias:here"; - setJoinRule(JoinRule.Public); - setAlias(alias); - room.recalculate(); - const name = room.name; - expect(name).toEqual(alias); - }); + it( + "should show the room alias if one exists for public " + + "(public join_rules) rooms if a room name doesn't exist.", + function () { + const alias = "#room_alias:here"; + setJoinRule(JoinRule.Public); + setAlias(alias); + room.recalculate(); + const name = room.name; + expect(name).toEqual(alias); + }, + ); it("should not show alt aliases if a room name does not exist", () => { const alias = "#room_alias:here"; @@ -1120,8 +1257,7 @@ describe("Room", function() { expect(name).not.toEqual(alias); }); - it("should show the room name if one exists for private " + - "(invite join_rules) rooms.", function() { + it("should show the room name if one exists for private " + "(invite join_rules) rooms.", function () { const roomName = "A mighty name indeed"; setJoinRule(JoinRule.Invite); setRoomName(roomName); @@ -1130,8 +1266,7 @@ describe("Room", function() { expect(name).toEqual(roomName); }); - it("should show the room name if one exists for public " + - "(public join_rules) rooms.", function() { + it("should show the room name if one exists for public " + "(public join_rules) rooms.", function () { const roomName = "A mighty name indeed"; setJoinRule(JoinRule.Public); setRoomName(roomName); @@ -1139,63 +1274,66 @@ describe("Room", function() { expect(room.name).toEqual(roomName); }); - it("should return 'Empty room' for private (invite join_rules) rooms if" + - " a room name and alias don't exist and it is a self-chat.", function() { - setJoinRule(JoinRule.Invite); - addMember(userA); - room.recalculate(); - expect(room.name).toEqual("Empty room"); - }); + it( + "should return 'Empty room' for private (invite join_rules) rooms if" + + " a room name and alias don't exist and it is a self-chat.", + function () { + setJoinRule(JoinRule.Invite); + addMember(userA); + room.recalculate(); + expect(room.name).toEqual("Empty room"); + }, + ); - it("should return 'Empty room' for public (public join_rules) rooms if a" + - " room name and alias don't exist and it is a self-chat.", function() { - setJoinRule(JoinRule.Public); - addMember(userA); + it( + "should return 'Empty room' for public (public join_rules) rooms if a" + + " room name and alias don't exist and it is a self-chat.", + function () { + setJoinRule(JoinRule.Public); + addMember(userA); + room.recalculate(); + const name = room.name; + expect(name).toEqual("Empty room"); + }, + ); + + it("should return 'Empty room' if there is no name, " + "alias or members in the room.", function () { room.recalculate(); const name = room.name; expect(name).toEqual("Empty room"); }); - it("should return 'Empty room' if there is no name, " + - "alias or members in the room.", - function() { + it("should return '[inviter display name] if state event " + "available", function () { + setJoinRule(JoinRule.Invite); + addMember(userB, "join", { name: "Alice" }); + addMember(userA, "invite", { user: userA }); room.recalculate(); const name = room.name; - expect(name).toEqual("Empty room"); + expect(name).toEqual("Alice"); }); - it("should return '[inviter display name] if state event " + - "available", - function() { + it("should return inviter mxid if display name not available", function () { setJoinRule(JoinRule.Invite); - addMember(userB, 'join', { name: "Alice" }); + addMember(userB); addMember(userA, "invite", { user: userA }); room.recalculate(); const name = room.name; - expect(name).toEqual("Alice"); + expect(name).toEqual(userB); }); - - it("should return inviter mxid if display name not available", - function() { - setJoinRule(JoinRule.Invite); - addMember(userB); - addMember(userA, "invite", { user: userA }); - room.recalculate(); - const name = room.name; - expect(name).toEqual(userB); - }); }); }); - describe("receipts", function() { + describe("receipts", function () { const eventToAck = utils.mkMessage({ - room: roomId, user: userA, msg: "PLEASE ACKNOWLEDGE MY EXISTENCE", + room: roomId, + user: userA, + msg: "PLEASE ACKNOWLEDGE MY EXISTENCE", event: true, }); - function mkReceipt(roomId: string, records) { - const content = {}; - records.forEach(function(r) { + function mkReceipt(roomId: string, records: Array>) { + const content: IContent = {}; + records.forEach(function (r) { if (!content[r.eventId]) { content[r.eventId] = {}; } @@ -1223,97 +1361,102 @@ describe("Room", function() { }; } - describe("addReceipt", function() { - it("should store the receipt so it can be obtained via getReceiptsForEvent", function() { + describe("addReceipt", function () { + it("should store the receipt so it can be obtained via getReceiptsForEvent", function () { const ts = 13787898424; - room.addReceipt(mkReceipt(roomId, [ - mkRecord(eventToAck.getId()!, "m.read", userB, ts), - ])); - expect(room.getReceiptsForEvent(eventToAck)).toEqual([{ - type: "m.read", - userId: userB, - data: { - ts: ts, + room.addReceipt(mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)])); + expect(room.getReceiptsForEvent(eventToAck)).toEqual([ + { + type: "m.read", + userId: userB, + data: { + ts: ts, + }, }, - }]); + ]); }); - it("should emit an event when a receipt is added", - function() { - const listener = jest.fn(); - room.on("Room.receipt", listener); + it("should emit an event when a receipt is added", function () { + const listener = jest.fn(); + room.on(RoomEvent.Receipt, listener); - const ts = 13787898424; + const ts = 13787898424; - const receiptEvent = mkReceipt(roomId, [ - mkRecord(eventToAck.getId()!, "m.read", userB, ts), - ]); + const receiptEvent = mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)]); - room.addReceipt(receiptEvent); - expect(listener).toHaveBeenCalledWith(receiptEvent, room); - }); + room.addReceipt(receiptEvent); + expect(listener).toHaveBeenCalledWith(receiptEvent, room); + }); - it("should clobber receipts based on type and user ID", function() { + it("should clobber receipts based on type and user ID", function () { const nextEventToAck = utils.mkMessage({ - room: roomId, user: userA, msg: "I AM HERE YOU KNOW", + room: roomId, + user: userA, + msg: "I AM HERE YOU KNOW", event: true, }); const ts = 13787898424; - room.addReceipt(mkReceipt(roomId, [ - mkRecord(eventToAck.getId()!, "m.read", userB, ts), - ])); + room.addReceipt(mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)])); const ts2 = 13787899999; - room.addReceipt(mkReceipt(roomId, [ - mkRecord(nextEventToAck.getId()!, "m.read", userB, ts2), - ])); + room.addReceipt(mkReceipt(roomId, [mkRecord(nextEventToAck.getId()!, "m.read", userB, ts2)])); expect(room.getReceiptsForEvent(eventToAck)).toEqual([]); - expect(room.getReceiptsForEvent(nextEventToAck)).toEqual([{ - type: "m.read", - userId: userB, - data: { - ts: ts2, + expect(room.getReceiptsForEvent(nextEventToAck)).toEqual([ + { + type: "m.read", + userId: userB, + data: { + ts: ts2, + }, }, - }]); + ]); }); - it("should persist multiple receipts for a single event ID", function() { + it("should persist multiple receipts for a single event ID", function () { const ts = 13787898424; - room.addReceipt(mkReceipt(roomId, [ - mkRecord(eventToAck.getId()!, "m.read", userB, ts), - mkRecord(eventToAck.getId()!, "m.read", userC, ts), - mkRecord(eventToAck.getId()!, "m.read", userD, ts), - ])); - expect(room.getUsersReadUpTo(eventToAck)).toEqual( - [userB, userC, userD], + room.addReceipt( + mkReceipt(roomId, [ + mkRecord(eventToAck.getId()!, "m.read", userB, ts), + mkRecord(eventToAck.getId()!, "m.read", userC, ts), + mkRecord(eventToAck.getId()!, "m.read", userD, ts), + ]), ); + expect(room.getUsersReadUpTo(eventToAck)).toEqual([userB, userC, userD]); }); - it("should persist multiple receipts for a single receipt type", function() { + it("should persist multiple receipts for a single receipt type", function () { const eventTwo = utils.mkMessage({ - room: roomId, user: userA, msg: "2222", + room: roomId, + user: userA, + msg: "2222", event: true, }); const eventThree = utils.mkMessage({ - room: roomId, user: userA, msg: "3333", + room: roomId, + user: userA, + msg: "3333", event: true, }); const ts = 13787898424; - room.addReceipt(mkReceipt(roomId, [ - mkRecord(eventToAck.getId()!, "m.read", userB, ts), - mkRecord(eventTwo.getId()!, "m.read", userC, ts), - mkRecord(eventThree.getId()!, "m.read", userD, ts), - ])); + room.addReceipt( + mkReceipt(roomId, [ + mkRecord(eventToAck.getId()!, "m.read", userB, ts), + mkRecord(eventTwo.getId()!, "m.read", userC, ts), + mkRecord(eventThree.getId()!, "m.read", userD, ts), + ]), + ); expect(room.getUsersReadUpTo(eventToAck)).toEqual([userB]); expect(room.getUsersReadUpTo(eventTwo)).toEqual([userC]); expect(room.getUsersReadUpTo(eventThree)).toEqual([userD]); }); - it("should persist multiple receipts for a single user ID", function() { - room.addReceipt(mkReceipt(roomId, [ - mkRecord(eventToAck.getId()!, "m.delivered", userB, 13787898424), - mkRecord(eventToAck.getId()!, "m.read", userB, 22222222), - mkRecord(eventToAck.getId()!, "m.seen", userB, 33333333), - ])); + it("should persist multiple receipts for a single user ID", function () { + room.addReceipt( + mkReceipt(roomId, [ + mkRecord(eventToAck.getId()!, "m.delivered", userB, 13787898424), + mkRecord(eventToAck.getId()!, "m.read", userB, 22222222), + mkRecord(eventToAck.getId()!, "m.seen", userB, 33333333), + ]), + ); expect(room.getReceiptsForEvent(eventToAck)).toEqual([ { type: "m.delivered", @@ -1339,18 +1482,24 @@ describe("Room", function() { ]); }); - it("should prioritise the most recent event", function() { + it("should prioritise the most recent event", function () { const events: MatrixEvent[] = [ utils.mkMessage({ - room: roomId, user: userA, msg: "1111", + room: roomId, + user: userA, + msg: "1111", event: true, }), utils.mkMessage({ - room: roomId, user: userA, msg: "2222", + room: roomId, + user: userA, + msg: "2222", event: true, }), utils.mkMessage({ - room: roomId, user: userA, msg: "3333", + room: roomId, + user: userA, + msg: "3333", event: true, }), ]; @@ -1359,36 +1508,36 @@ describe("Room", function() { const ts = 13787898424; // check it initialises correctly - room.addReceipt(mkReceipt(roomId, [ - mkRecord(events[0].getId()!, "m.read", userB, ts), - ])); + room.addReceipt(mkReceipt(roomId, [mkRecord(events[0].getId()!, "m.read", userB, ts)])); expect(room.getEventReadUpTo(userB)).toEqual(events[0].getId()); // 2>0, so it should move forward - room.addReceipt(mkReceipt(roomId, [ - mkRecord(events[2].getId()!, "m.read", userB, ts), - ])); + room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, ts)])); expect(room.getEventReadUpTo(userB)).toEqual(events[2].getId()); // 1<2, so it should stay put - room.addReceipt(mkReceipt(roomId, [ - mkRecord(events[1].getId()!, "m.read", userB, ts), - ])); + room.addReceipt(mkReceipt(roomId, [mkRecord(events[1].getId()!, "m.read", userB, ts)])); expect(room.getEventReadUpTo(userB)).toEqual(events[2].getId()); }); it("should prioritise the most recent event even if it is synthetic", () => { const events: MatrixEvent[] = [ utils.mkMessage({ - room: roomId, user: userA, msg: "1111", + room: roomId, + user: userA, + msg: "1111", event: true, }), utils.mkMessage({ - room: roomId, user: userA, msg: "2222", + room: roomId, + user: userA, + msg: "2222", event: true, }), utils.mkMessage({ - room: roomId, user: userA, msg: "3333", + room: roomId, + user: userA, + msg: "3333", event: true, }), ]; @@ -1397,59 +1546,45 @@ describe("Room", function() { const ts = 13787898424; // check it initialises correctly - room.addReceipt(mkReceipt(roomId, [ - mkRecord(events[0].getId()!, "m.read", userB, ts), - ])); + room.addReceipt(mkReceipt(roomId, [mkRecord(events[0].getId()!, "m.read", userB, ts)])); expect(room.getEventReadUpTo(userB)).toEqual(events[0].getId()); // 2>0, so it should move forward - room.addReceipt(mkReceipt(roomId, [ - mkRecord(events[2].getId()!, "m.read", userB, ts), - ]), true); + room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, ts)]), true); expect(room.getEventReadUpTo(userB)).toEqual(events[2].getId()); - expect(room.getReceiptsForEvent(events[2])).toEqual([ - { data: { ts }, type: "m.read", userId: userB }, - ]); + expect(room.getReceiptsForEvent(events[2])).toEqual([{ data: { ts }, type: "m.read", userId: userB }]); // 1<2, so it should stay put - room.addReceipt(mkReceipt(roomId, [ - mkRecord(events[1].getId()!, "m.read", userB, ts), - ])); + room.addReceipt(mkReceipt(roomId, [mkRecord(events[1].getId()!, "m.read", userB, ts)])); expect(room.getEventReadUpTo(userB)).toEqual(events[2].getId()); expect(room.getEventReadUpTo(userB, true)).toEqual(events[1].getId()); - expect(room.getReceiptsForEvent(events[2])).toEqual([ - { data: { ts }, type: "m.read", userId: userB }, - ]); + expect(room.getReceiptsForEvent(events[2])).toEqual([{ data: { ts }, type: "m.read", userId: userB }]); }); }); - describe("getUsersReadUpTo", function() { - it("should return user IDs read up to the given event", function() { + describe("getUsersReadUpTo", function () { + it("should return user IDs read up to the given event", function () { const ts = 13787898424; - room.addReceipt(mkReceipt(roomId, [ - mkRecord(eventToAck.getId()!, "m.read", userB, ts), - ])); + room.addReceipt(mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)])); expect(room.getUsersReadUpTo(eventToAck)).toEqual([userB]); }); }); - describe("hasUserReadUpTo", function() { - it("should acknowledge if an event has been read", function() { + 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), - ])); + 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() { + it("return false for an unknown event", function () { expect(room.hasUserReadEvent(userB, "unknown_event")).toEqual(false); }); }); }); - describe("tags", function() { - function mkTags(roomId, tags) { - const content = { "tags": tags }; + describe("tags", function () { + function mkTags(roomId: string, tags: object) { + const content = { tags: tags }; return new MatrixEvent({ content: content, room_id: roomId, @@ -1457,22 +1592,21 @@ describe("Room", function() { }); } - describe("addTag", function() { - it("should set tags on rooms from event stream so " + - "they can be obtained by the tags property", - function() { - const tags = { "m.foo": { "order": 0.5 } }; - room.addTags(mkTags(roomId, tags)); - expect(room.tags).toEqual(tags); - }); + describe("addTag", function () { + it( + "should set tags on rooms from event stream so " + "they can be obtained by the tags property", + function () { + const tags = { "m.foo": { order: 0.5 } }; + room.addTags(mkTags(roomId, tags)); + expect(room.tags).toEqual(tags); + }, + ); - it("should emit Room.tags event when new tags are " + - "received on the event stream", - function() { + it("should emit Room.tags event when new tags are " + "received on the event stream", function () { const listener = jest.fn(); - room.on("Room.tags", listener); + room.on(RoomEvent.Tags, listener); - const tags = { "m.foo": { "order": 0.5 } }; + const tags = { "m.foo": { order: 0.5 } }; const event = mkTags(roomId, tags); room.addTags(event); expect(listener).toHaveBeenCalledWith(event, room); @@ -1483,62 +1617,70 @@ describe("Room", function() { }); }); - describe("addPendingEvent", function() { - it("should add pending events to the pendingEventList if " + - "pendingEventOrdering == 'detached'", function() { - const client = (new TestClient( - "@alice:example.com", "alicedevice", - )).client; + describe("addPendingEvent", function () { + it("should add pending events to the pendingEventList if " + "pendingEventOrdering == 'detached'", function () { + const client = new TestClient("@alice:example.com", "alicedevice").client; client.supportsExperimentalThreads = () => true; const room = new Room(roomId, client, userA, { pendingEventOrdering: PendingEventOrdering.Detached, }); const eventA = utils.mkMessage({ - room: roomId, user: userA, msg: "remote 1", event: true, + room: roomId, + user: userA, + msg: "remote 1", + event: true, }); const eventB = utils.mkMessage({ - room: roomId, user: userA, msg: "local 1", event: true, + room: roomId, + user: userA, + msg: "local 1", + event: true, }); eventB.status = EventStatus.SENDING; const eventC = utils.mkMessage({ - room: roomId, user: userA, msg: "remote 2", event: true, + room: roomId, + user: userA, + msg: "remote 2", + event: true, }); room.addLiveEvents([eventA]); room.addPendingEvent(eventB, "TXN1"); room.addLiveEvents([eventC]); - expect(room.timeline).toEqual( - [eventA, eventC], - ); - expect(room.getPendingEvents()).toEqual( - [eventB], - ); + expect(room.timeline).toEqual([eventA, eventC]); + expect(room.getPendingEvents()).toEqual([eventB]); }); - it("should add pending events to the timeline if " + - "pendingEventOrdering == 'chronological'", function() { + it("should add pending events to the timeline if " + "pendingEventOrdering == 'chronological'", function () { const room = new Room(roomId, new TestClient(userA).client, userA, { pendingEventOrdering: PendingEventOrdering.Chronological, }); const eventA = utils.mkMessage({ - room: roomId, user: userA, msg: "remote 1", event: true, + room: roomId, + user: userA, + msg: "remote 1", + event: true, }); const eventB = utils.mkMessage({ - room: roomId, user: userA, msg: "local 1", event: true, + room: roomId, + user: userA, + msg: "local 1", + event: true, }); eventB.status = EventStatus.SENDING; const eventC = utils.mkMessage({ - room: roomId, user: userA, msg: "remote 2", event: true, + room: roomId, + user: userA, + msg: "remote 2", + event: true, }); room.addLiveEvents([eventA]); room.addPendingEvent(eventB, "TXN1"); room.addLiveEvents([eventC]); - expect(room.timeline).toEqual( - [eventA, eventB, eventC], - ); + expect(room.timeline).toEqual([eventA, eventB, eventC]); }); it("should apply redactions eagerly in the pending event list", () => { - const client = (new TestClient("@alice:example.com", "alicedevice")).client; + const client = new TestClient("@alice:example.com", "alicedevice").client; const room = new Room(roomId, client, userA, { pendingEventOrdering: PendingEventOrdering.Detached, }); @@ -1568,72 +1710,68 @@ describe("Room", function() { }); }); - describe("updatePendingEvent", function() { - it("should remove cancelled events from the pending list", function() { - const client = (new TestClient( - "@alice:example.com", "alicedevice", - )).client; + describe("updatePendingEvent", function () { + it("should remove cancelled events from the pending list", function () { + const client = new TestClient("@alice:example.com", "alicedevice").client; const room = new Room(roomId, client, userA, { pendingEventOrdering: PendingEventOrdering.Detached, }); const eventA = utils.mkMessage({ - room: roomId, user: userA, event: true, + room: roomId, + user: userA, + event: true, }); eventA.status = EventStatus.SENDING; const eventId = eventA.getId(); room.addPendingEvent(eventA, "TXN1"); - expect(room.getPendingEvents()).toEqual( - [eventA], - ); + expect(room.getPendingEvents()).toEqual([eventA]); // the event has to have been failed or queued before it can be // cancelled room.updatePendingEvent(eventA, EventStatus.NOT_SENT); let callCount = 0; - room.on(RoomEvent.LocalEchoUpdated, - function(event, emitRoom, oldEventId, oldStatus) { - expect(event).toEqual(eventA); - expect(event.status).toEqual(EventStatus.CANCELLED); - expect(emitRoom).toEqual(room); - expect(oldEventId).toEqual(eventId); - expect(oldStatus).toEqual(EventStatus.NOT_SENT); - callCount++; - }); + room.on(RoomEvent.LocalEchoUpdated, function (event, emitRoom, oldEventId, oldStatus) { + expect(event).toEqual(eventA); + expect(event.status).toEqual(EventStatus.CANCELLED); + expect(emitRoom).toEqual(room); + expect(oldEventId).toEqual(eventId); + expect(oldStatus).toEqual(EventStatus.NOT_SENT); + callCount++; + }); room.updatePendingEvent(eventA, EventStatus.CANCELLED); expect(room.getPendingEvents()).toEqual([]); expect(callCount).toEqual(1); }); - it("should remove cancelled events from the timeline", function() { + it("should remove cancelled events from the timeline", function () { const room = new Room(roomId, null!, userA); const eventA = utils.mkMessage({ - room: roomId, user: userA, event: true, + room: roomId, + user: userA, + event: true, }); eventA.status = EventStatus.SENDING; const eventId = eventA.getId(); room.addPendingEvent(eventA, "TXN1"); - expect(room.getLiveTimeline().getEvents()).toEqual( - [eventA], - ); + expect(room.getLiveTimeline().getEvents()).toEqual([eventA]); // the event has to have been failed or queued before it can be // cancelled room.updatePendingEvent(eventA, EventStatus.NOT_SENT); let callCount = 0; - room.on(RoomEvent.LocalEchoUpdated, - function(event, emitRoom, oldEventId, oldStatus) { - expect(event).toEqual(eventA); - expect(event.status).toEqual(EventStatus.CANCELLED); - expect(emitRoom).toEqual(room); - expect(oldEventId).toEqual(eventId); - expect(oldStatus).toEqual(EventStatus.NOT_SENT); - callCount++; - }); + room.on(RoomEvent.LocalEchoUpdated, function (event, emitRoom, oldEventId, oldStatus) { + expect(event).toEqual(eventA); + expect(event.status).toEqual(EventStatus.CANCELLED); + expect(emitRoom).toEqual(room); + expect(oldEventId).toEqual(eventId); + expect(oldStatus).toEqual(EventStatus.NOT_SENT); + callCount++; + }); room.updatePendingEvent(eventA, EventStatus.CANCELLED); expect(room.getLiveTimeline().getEvents()).toEqual([]); @@ -1641,17 +1779,22 @@ describe("Room", function() { }); }); - describe("loadMembersIfNeeded", function() { - function createClientMock(serverResponse, storageResponse: MatrixEvent[] | Error | null = null) { + describe("loadMembersIfNeeded", function () { + function createClientMock( + serverResponse: Error | MatrixEvent[], + storageResponse: MatrixEvent[] | Error | null = null, + ) { return { - getEventMapper: function() { + getEventMapper: function () { // events should already be MatrixEvents - return function(event) {return event;}; + return function (event: MatrixEvent) { + return event; + }; }, isCryptoEnabled() { return true; }, - isRoomEncrypted: function() { + isRoomEncrypted: function () { return false; }, members: jest.fn().mockImplementation(() => { @@ -1664,14 +1807,14 @@ describe("Room", function() { store: { storageResponse, storedMembers: [] as IStateEventWithRoomId[] | null, - getOutOfBandMembers: function() { + getOutOfBandMembers: function () { if (this.storageResponse instanceof Error) { return Promise.reject(this.storageResponse); } else { return Promise.resolve(this.storageResponse); } }, - setOutOfBandMembers: function(roomId, memberEvents) { + setOutOfBandMembers: function (roomId: string, memberEvents: IStateEventWithRoomId[]) { this.storedMembers = memberEvents; return Promise.resolve(); }, @@ -1690,7 +1833,7 @@ describe("Room", function() { name: "User A", }); - it("should load members from server on first call", async function() { + it("should load members from server on first call", async function () { const client = createClientMock([memberEvent]); const room = new Room(roomId, client as any, null!, { lazyLoadMembers: true }); await room.loadMembersIfNeeded(); @@ -1701,7 +1844,7 @@ describe("Room", function() { expect(storedMembers[0].event_id).toEqual(memberEvent.getId()); }); - it("should take members from storage if available", async function() { + it("should take members from storage if available", async function () { const memberEvent2 = utils.mkMembership({ user: "@user_a:bar", mship: "join", @@ -1718,7 +1861,7 @@ describe("Room", function() { expect(memberA.name).toEqual("User A"); }); - it("should allow retry on error", async function() { + it("should allow retry on error", async function () { const client = createClientMock(new Error("server says no")); const room = new Room(roomId, client as any, null!, { lazyLoadMembers: true }); let hasThrown = false; @@ -1736,14 +1879,13 @@ describe("Room", function() { }); }); - describe("getMyMembership", function() { - it("should return synced membership if membership isn't available yet", - function() { - const room = new Room(roomId, null!, userA); - room.updateMyMembership(JoinRule.Invite); - expect(room.getMyMembership()).toEqual(JoinRule.Invite); - }); - it("should emit a Room.myMembership event on a change", function() { + describe("getMyMembership", function () { + it("should return synced membership if membership isn't available yet", function () { + const room = new Room(roomId, null!, userA); + room.updateMyMembership(JoinRule.Invite); + expect(room.getMyMembership()).toEqual(JoinRule.Invite); + }); + it("should emit a Room.myMembership event on a change", function () { const room = new Room(roomId, null!, userA); const events: { membership: string; @@ -1755,7 +1897,7 @@ describe("Room", function() { room.updateMyMembership(JoinRule.Invite); expect(room.getMyMembership()).toEqual(JoinRule.Invite); expect(events[0]).toEqual({ membership: "invite", oldMembership: undefined }); - events.splice(0); //clear + events.splice(0); //clear room.updateMyMembership(JoinRule.Invite); expect(events.length).toEqual(0); room.updateMyMembership("join"); @@ -1803,27 +1945,29 @@ describe("Room", function() { }); }); - describe("guessDMUserId", function() { - it("should return first hero id", function() { + describe("guessDMUserId", function () { + it("should return first hero id", function () { const room = new Room(roomId, new TestClient(userA).client, userA); room.setSummary({ - 'm.heroes': [userB], - 'm.joined_member_count': 1, - 'm.invited_member_count': 1, + "m.heroes": [userB], + "m.joined_member_count": 1, + "m.invited_member_count": 1, }); expect(room.guessDMUserId()).toEqual(userB); }); - it("should return first member that isn't self", function() { + it("should return first member that isn't self", function () { const room = new Room(roomId, new TestClient(userA).client, userA); - room.addLiveEvents([utils.mkMembership({ - user: userB, - mship: "join", - room: roomId, - event: true, - })]); + room.addLiveEvents([ + utils.mkMembership({ + user: userB, + mship: "join", + room: roomId, + event: true, + }), + ]); expect(room.guessDMUserId()).toEqual(userB); }); - it("should return self if only member present", function() { + it("should return self if only member present", function () { const room = new Room(roomId, new TestClient(userA).client, userA); expect(room.guessDMUserId()).toEqual(userA); }); @@ -1859,8 +2003,8 @@ describe("Room", function() { }); }); - describe("maySendMessage", function() { - it("should return false if synced membership not join", function() { + describe("maySendMessage", function () { + it("should return false if synced membership not join", function () { const room = new Room(roomId, { isRoomEncrypted: () => false } as any, userA); room.updateMyMembership(JoinRule.Invite); expect(room.maySendMessage()).toEqual(false); @@ -1871,130 +2015,183 @@ describe("Room", function() { }); }); - describe("getDefaultRoomName", function() { - it("should return 'Empty room' if a user is the only member", function() { + describe("getDefaultRoomName", function () { + it("should return 'Empty room' if a user is the only member", function () { const room = new Room(roomId, new TestClient(userA).client, userA); expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); }); - it("should return a display name if one other member is in the room", function() { + it("should return a display name if one other member is in the room", function () { const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", + user: userA, + mship: "join", + room: roomId, + event: true, + name: "User A", }), utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", + user: userB, + mship: "join", + room: roomId, + event: true, + name: "User B", }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); - it("should return a display name if one other member is banned", function() { + it("should return a display name if one other member is banned", function () { const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", + user: userA, + mship: "join", + room: roomId, + event: true, + name: "User A", }), utils.mkMembership({ - user: userB, mship: "ban", - room: roomId, event: true, name: "User B", + user: userB, + mship: "ban", + room: roomId, + event: true, + name: "User B", }), ]); expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); }); - it("should return a display name if one other member is invited", function() { + it("should return a display name if one other member is invited", function () { const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", + user: userA, + mship: "join", + room: roomId, + event: true, + name: "User A", }), utils.mkMembership({ - user: userB, mship: "invite", - room: roomId, event: true, name: "User B", + user: userB, + mship: "invite", + room: roomId, + event: true, + name: "User B", }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); - it("should return 'Empty room (was User B)' if User B left the room", function() { + it("should return 'Empty room (was User B)' if User B left the room", function () { const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", + user: userA, + mship: "join", + room: roomId, + event: true, + name: "User A", }), utils.mkMembership({ - user: userB, mship: "leave", - room: roomId, event: true, name: "User B", + user: userB, + mship: "leave", + room: roomId, + event: true, + name: "User B", }), ]); expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); }); - it("should return 'User B and User C' if in a room with two other users", function() { + it("should return 'User B and User C' if in a room with two other users", function () { const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", + user: userA, + mship: "join", + room: roomId, + event: true, + name: "User A", }), utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", + user: userB, + mship: "join", + room: roomId, + event: true, + name: "User B", }), utils.mkMembership({ - user: userC, mship: "join", - room: roomId, event: true, name: "User C", + user: userC, + mship: "join", + room: roomId, + event: true, + name: "User C", }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B and User C"); }); - it("should return 'User B and 2 others' if in a room with three other users", function() { + it("should return 'User B and 2 others' if in a room with three other users", function () { const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", + user: userA, + mship: "join", + room: roomId, + event: true, + name: "User A", }), utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", + user: userB, + mship: "join", + room: roomId, + event: true, + name: "User B", }), utils.mkMembership({ - user: userC, mship: "join", - room: roomId, event: true, name: "User C", + user: userC, + mship: "join", + room: roomId, + event: true, + name: "User C", }), utils.mkMembership({ - user: userD, mship: "join", - room: roomId, event: true, name: "User D", + user: userD, + mship: "join", + room: roomId, + event: true, + name: "User D", }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B and 2 others"); }); }); - describe("io.element.functional_users", function() { - it("should return a display name (default behaviour) if no one is marked as a functional member", function() { + describe("io.element.functional_users", function () { + it("should return a display name (default behaviour) if no one is marked as a functional member", function () { const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", + user: userA, + mship: "join", + room: roomId, + event: true, + name: "User A", }), utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", + user: userB, + mship: "join", + room: roomId, + event: true, + name: "User B", }), utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + skey: "", + room: roomId, + event: true, content: { service_members: [], }, @@ -2003,16 +2200,22 @@ describe("Room", function() { expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); - it("should return a display name (default behaviour) if service members is a number (invalid)", function() { + it("should return a display name (default behaviour) if service members is a number (invalid)", function () { const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", + user: userA, + mship: "join", + room: roomId, + event: true, + name: "User A", }), utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", + user: userB, + mship: "join", + room: roomId, + event: true, + name: "User B", }), utils.mkEvent({ type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, @@ -2027,20 +2230,28 @@ describe("Room", function() { expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); - it("should return a display name (default behaviour) if service members is a string (invalid)", function() { + it("should return a display name (default behaviour) if service members is a string (invalid)", function () { const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", + user: userA, + mship: "join", + room: roomId, + event: true, + name: "User A", }), utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", + user: userB, + mship: "join", + room: roomId, + event: true, + name: "User B", }), utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + skey: "", + room: roomId, + event: true, content: { service_members: userB, }, @@ -2049,20 +2260,28 @@ describe("Room", function() { expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); - it("should return 'Empty room' if the only other member is a functional member", function() { + it("should return 'Empty room' if the only other member is a functional member", function () { const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", + user: userA, + mship: "join", + room: roomId, + event: true, + name: "User A", }), utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", + user: userB, + mship: "join", + room: roomId, + event: true, + name: "User B", }), utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + skey: "", + room: roomId, + event: true, content: { service_members: [userB], }, @@ -2071,24 +2290,36 @@ describe("Room", function() { expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); }); - it("should return 'User B' if User B is the only other member who isn't a functional member", function() { + it("should return 'User B' if User B is the only other member who isn't a functional member", function () { const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", + user: userA, + mship: "join", + room: roomId, + event: true, + name: "User A", }), utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", + user: userB, + mship: "join", + room: roomId, + event: true, + name: "User B", }), utils.mkMembership({ - user: userC, mship: "join", - room: roomId, event: true, name: "User C", + user: userC, + mship: "join", + room: roomId, + event: true, + name: "User C", }), utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, user: userA, + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + skey: "", + room: roomId, + event: true, + user: userA, content: { service_members: [userC], }, @@ -2097,24 +2328,36 @@ describe("Room", function() { expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); - it("should return 'Empty room' if all other members are functional members", function() { + it("should return 'Empty room' if all other members are functional members", function () { const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", + user: userA, + mship: "join", + room: roomId, + event: true, + name: "User A", }), utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", + user: userB, + mship: "join", + room: roomId, + event: true, + name: "User B", }), utils.mkMembership({ - user: userC, mship: "join", - room: roomId, event: true, name: "User C", + user: userC, + mship: "join", + room: roomId, + event: true, + name: "User C", }), utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, user: userA, + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + skey: "", + room: roomId, + event: true, + user: userA, content: { service_members: [userB, userC], }, @@ -2123,20 +2366,29 @@ describe("Room", function() { expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); }); - it("should not break if an unjoined user is marked as a service user", function() { + it("should not break if an unjoined user is marked as a service user", function () { const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", + user: userA, + mship: "join", + room: roomId, + event: true, + name: "User A", }), utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", + user: userB, + mship: "join", + room: roomId, + event: true, + name: "User B", }), utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, user: userA, + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + skey: "", + room: roomId, + event: true, + user: userA, content: { service_members: [userC], }, @@ -2146,31 +2398,29 @@ describe("Room", function() { }); }); - describe("threads", function() { + describe("threads", function () { beforeEach(() => { - const client = (new TestClient( - "@alice:example.com", "alicedevice", - )).client; + const client = new TestClient("@alice:example.com", "alicedevice").client; room = new Room(roomId, client, userA); client.getRoom = () => room; }); - it("allow create threads without a root event", function() { + it("allow create threads without a root event", function () { const eventWithoutARootEvent = new MatrixEvent({ event_id: "$123", room_id: roomId, content: { "m.relates_to": { - "rel_type": "m.thread", - "event_id": "$000", + rel_type: "m.thread", + event_id: "$000", }, }, unsigned: { - "age": 1, + age: 1, }, }); - room.createThread("$000", undefined, [eventWithoutARootEvent]); + room.createThread("$000", undefined, [eventWithoutARootEvent], false); const rootEvent = new MatrixEvent({ event_id: "$666", @@ -2188,7 +2438,22 @@ describe("Room", function() { }, }); - expect(() => room.createThread(rootEvent.getId()!, rootEvent, [])).not.toThrow(); + expect(() => room.createThread(rootEvent.getId()!, rootEvent, [], false)).not.toThrow(); + }); + + it("returns the same model when creating a thread twice", () => { + const { thread, rootEvent } = mkThread({ + room, + client: new TestClient().client, + authorId: "@bob:example.org", + participantUserIds: ["@bob:example.org"], + }); + + expect(thread).toBeInstanceOf(Thread); + + const duplicateThread = room.createThread(rootEvent.getId()!, rootEvent, [], false); + + expect(duplicateThread).toBe(thread); }); it("creating thread from edited event should not conflate old versions of the event", () => { @@ -2211,49 +2476,64 @@ describe("Room", function() { const threadResponseEdit = mkEdit(threadResponse); threadResponseEdit.localTimestamp += 2000; - room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ - ...threadRoot.event, - unsigned: { - "age": 123, - "m.relations": { - [THREAD_RELATION_TYPE.name]: { - latest_event: threadResponse.event, - count: 2, - current_user_participated: true, + room.client.fetchRoomEvent = (eventId: string) => + Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + [THREAD_RELATION_TYPE.name]: { + latest_event: threadResponse.event, + count: 2, + current_user_participated: true, + }, }, }, - }, - }); + }); + + room.client.fetchRelations = ( + roomId: string, + eventId: string, + relationType?: RelationType | string | null, + eventType?: EventType | string | null, + opts: IRelationsRequestOpts = { dir: Direction.Backward }, + ) => + Promise.resolve({ + chunk: [threadResponse.event] as IEvent[], + next_batch: "start_token", + }); let prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([randomMessage, threadRoot, threadResponse]); - const thread = await prom; + const thread: Thread = await prom; await emitPromise(room, ThreadEvent.Update); - expect(thread.replyToEvent.event).toEqual(threadResponse.event); - expect(thread.replyToEvent.getContent().body).toBe(threadResponse.getContent().body); - - room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ - ...threadRoot.event, - unsigned: { - "age": 123, - "m.relations": { - [THREAD_RELATION_TYPE.name]: { - latest_event: { - ...threadResponse.event, - content: threadResponseEdit.event.content, + expect(thread.initialEventsFetched).toBeTruthy(); + expect(thread.replyToEvent!.event).toEqual(threadResponse.event); + expect(thread.replyToEvent!.getContent().body).toBe(threadResponse.getContent().body); + + room.client.fetchRoomEvent = (eventId: string) => + Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + [THREAD_RELATION_TYPE.name]: { + latest_event: { + ...threadResponse.event, + content: threadResponseEdit.getContent()["m.new_content"], + }, + count: 2, + current_user_participated: true, }, - count: 2, - current_user_participated: true, }, }, - }, - }); + }); prom = emitPromise(room, ThreadEvent.Update); room.addLiveEvents([threadResponseEdit]); await prom; - expect(thread.replyToEvent.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body); + expect(thread.replyToEvent!.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body); }); it("Redactions to thread responses decrement the length", async () => { @@ -2266,19 +2546,20 @@ describe("Room", function() { const threadResponse2 = mkThreadResponse(threadRoot); threadResponse2.localTimestamp += 2000; - room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ - ...threadRoot.event, - unsigned: { - "age": 123, - "m.relations": { - [THREAD_RELATION_TYPE.name]: { - latest_event: threadResponse2.event, - count: 2, - current_user_participated: true, + room.client.fetchRoomEvent = (eventId: string) => + Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + [THREAD_RELATION_TYPE.name]: { + latest_event: threadResponse2.event, + count: 2, + current_user_participated: true, + }, }, }, - }, - }); + }); let prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); @@ -2288,30 +2569,31 @@ describe("Room", function() { expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); - thread.timelineSet.addEventToTimeline( - threadResponse1, - thread.liveTimeline, - { toStartOfTimeline: true, fromCache: false, roomState: thread.roomState }, - ); - thread.timelineSet.addEventToTimeline( - threadResponse2, - thread.liveTimeline, - { toStartOfTimeline: true, fromCache: false, roomState: thread.roomState }, - ); - - room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ - ...threadRoot.event, - unsigned: { - "age": 123, - "m.relations": { - [THREAD_RELATION_TYPE.name]: { - latest_event: threadResponse2.event, - count: 1, - current_user_participated: true, + thread.timelineSet.addEventToTimeline(threadResponse1, thread.liveTimeline, { + toStartOfTimeline: true, + fromCache: false, + roomState: thread.roomState, + }); + thread.timelineSet.addEventToTimeline(threadResponse2, thread.liveTimeline, { + toStartOfTimeline: true, + fromCache: false, + roomState: thread.roomState, + }); + + room.client.fetchRoomEvent = (eventId: string) => + Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + [THREAD_RELATION_TYPE.name]: { + latest_event: threadResponse2.event, + count: 1, + current_user_participated: true, + }, }, }, - }, - }); + }); prom = emitPromise(thread, ThreadEvent.Update); const threadResponse1Redaction = mkRedaction(threadResponse1); @@ -2332,19 +2614,20 @@ describe("Room", function() { threadResponse2.localTimestamp += 2000; const threadResponse2Reaction = mkReaction(threadResponse2); - room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ - ...threadRoot.event, - unsigned: { - "age": 123, - "m.relations": { - [THREAD_RELATION_TYPE.name]: { - latest_event: threadResponse2.event, - count: 2, - current_user_participated: true, + room.client.fetchRoomEvent = (eventId: string) => + Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + [THREAD_RELATION_TYPE.name]: { + latest_event: threadResponse2.event, + count: 2, + current_user_participated: true, + }, }, }, - }, - }); + }); const prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); @@ -2371,19 +2654,20 @@ describe("Room", function() { threadResponse2.localTimestamp += 2000; const threadResponse2Reaction = mkReaction(threadResponse2); - room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ - ...threadRoot.event, - unsigned: { - "age": 123, - "m.relations": { - [THREAD_RELATION_TYPE.name]: { - latest_event: threadResponse2.event, - count: 2, - current_user_participated: true, + room.client.fetchRoomEvent = (eventId: string) => + Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + [THREAD_RELATION_TYPE.name]: { + latest_event: threadResponse2.event, + count: 2, + current_user_participated: true, + }, }, }, - }, - }); + }); let prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); @@ -2405,12 +2689,11 @@ describe("Room", function() { Thread.setServerSideSupport(FeatureSupport.Stable); Thread.setServerSideListSupport(FeatureSupport.Stable); - room.client.createThreadListMessagesRequest = () => Promise.resolve({ - start: null, - end: null, - chunk: [], - state: [], - }); + room.client.createThreadListMessagesRequest = () => + Promise.resolve({ + chunk: [], + state: [], + }); await room.createThreadsTimelineSets(); await room.fetchRoomThreads(); @@ -2421,41 +2704,57 @@ describe("Room", function() { const threadResponse2 = mkThreadResponse(threadRoot); threadResponse2.localTimestamp += 2000; - room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ - ...threadRoot.event, - unsigned: { - "age": 123, - "m.relations": { - "m.thread": { - latest_event: threadResponse2.event, - count: 2, - current_user_participated: true, + room.client.fetchRoomEvent = (eventId: string) => + Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + "m.thread": { + latest_event: threadResponse2.event, + count: 2, + current_user_participated: true, + }, }, }, - }, - }); + }); + + room.client.fetchRelations = ( + roomId: string, + eventId: string, + relationType?: RelationType | string | null, + eventType?: EventType | string | null, + opts: IRelationsRequestOpts = { dir: Direction.Backward }, + ) => + Promise.resolve({ + chunk: [threadResponse1.event] as IEvent[], + next_batch: "start_token", + }); let prom = emitPromise(room, ThreadEvent.New); - room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); - const thread = await prom; + room.addLiveEvents([threadRoot, threadResponse1]); + const thread: Thread = await prom; await emitPromise(room, ThreadEvent.Update); + expect(thread.initialEventsFetched).toBeTruthy(); + room.addLiveEvents([threadResponse2]); expect(thread).toHaveLength(2); - expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); - - room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ - ...threadRoot.event, - unsigned: { - "age": 123, - "m.relations": { - "m.thread": { - latest_event: threadResponse1.event, - count: 1, - current_user_participated: true, + expect(thread.replyToEvent!.getId()).toBe(threadResponse2.getId()); + + room.client.fetchRoomEvent = (eventId: string) => + Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + "m.thread": { + latest_event: threadResponse1.event, + count: 1, + current_user_participated: true, + }, }, }, - }, - }); + }); prom = emitPromise(room, ThreadEvent.Update); const threadResponse2Redaction = mkRedaction(threadResponse2); @@ -2463,21 +2762,22 @@ describe("Room", function() { await prom; await emitPromise(room, ThreadEvent.Update); expect(thread).toHaveLength(1); - expect(thread.replyToEvent.getId()).toBe(threadResponse1.getId()); - - room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ - ...threadRoot.event, - unsigned: { - "age": 123, - "m.relations": { - "m.thread": { - latest_event: threadRoot.event, - count: 0, - current_user_participated: true, + expect(thread.replyToEvent!.getId()).toBe(threadResponse1.getId()); + + room.client.fetchRoomEvent = (eventId: string) => + Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + "m.thread": { + latest_event: threadRoot.event, + count: 0, + current_user_participated: true, + }, }, }, - }, - }); + }); prom = emitPromise(room, ThreadEvent.Delete); const prom2 = emitPromise(room, RoomEvent.Timeline); @@ -2486,7 +2786,7 @@ describe("Room", function() { await prom; await prom2; expect(thread).toHaveLength(0); - expect(thread.replyToEvent.getId()).toBe(threadRoot.getId()); + expect(thread.replyToEvent!.getId()).toBe(threadRoot.getId()); }); }); @@ -2565,14 +2865,7 @@ describe("Room", function() { const reaction2Redaction = mkRedaction(reply1); const roots = new Set([threadRoot.getId()!]); - const events = [ - threadRoot, - threadResponse1, - reply1, - reaction1, - reaction2, - reaction2Redaction, - ]; + const events = [threadRoot, threadResponse1, reply1, reaction1, reaction2, reaction2Redaction]; expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInRoom).toBeTruthy(); expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInThread).toBeFalsy(); @@ -2591,12 +2884,7 @@ describe("Room", function() { const reply2 = mkReply(reply1); const roots = new Set([threadRoot.getId()!]); - const events = [ - threadRoot, - threadResponse1, - reply1, - reply2, - ]; + const events = [threadRoot, threadResponse1, reply1, reply2]; expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInRoom).toBeTruthy(); expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInThread).toBeFalsy(); @@ -2604,40 +2892,32 @@ describe("Room", function() { expect(room.eventShouldLiveIn(reply2, events, roots).shouldLiveInThread).toBeFalsy(); }); - it("should aggregate relations in thread event timeline set", () => { + it("should aggregate relations in thread event timeline set", async () => { Thread.setServerSideSupport(FeatureSupport.Stable); const threadRoot = mkMessage(); const rootReaction = mkReaction(threadRoot); const threadResponse = mkThreadResponse(threadRoot); const threadReaction = mkReaction(threadResponse); - const events = [ - threadRoot, - rootReaction, - threadResponse, - threadReaction, - ]; + const events = [threadRoot, rootReaction, threadResponse, threadReaction]; + const prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents(events); - - const thread = threadRoot.getThread()!; + const thread = await prom; + expect(thread).toBe(threadRoot.getThread()); expect(thread.rootEvent).toBe(threadRoot); - const rootRelations = thread.timelineSet.relations.getChildEventsForEvent( - threadRoot.getId()!, - RelationType.Annotation, - EventType.Reaction, - )!.getSortedAnnotationsByKey(); + const rootRelations = thread.timelineSet.relations + .getChildEventsForEvent(threadRoot.getId()!, RelationType.Annotation, EventType.Reaction)! + .getSortedAnnotationsByKey(); expect(rootRelations).toHaveLength(1); expect(rootRelations![0][0]).toEqual(rootReaction.getRelation()!.key); expect(rootRelations![0][1].size).toEqual(1); expect(rootRelations![0][1].has(rootReaction)).toBeTruthy(); - const responseRelations = thread.timelineSet.relations.getChildEventsForEvent( - threadResponse.getId()!, - RelationType.Annotation, - EventType.Reaction, - )!.getSortedAnnotationsByKey(); + const responseRelations = thread.timelineSet.relations + .getChildEventsForEvent(threadResponse.getId()!, RelationType.Annotation, EventType.Reaction)! + .getSortedAnnotationsByKey(); expect(responseRelations).toHaveLength(1); expect(responseRelations![0][0]).toEqual(threadReaction.getRelation()!.key); expect(responseRelations![0][1].size).toEqual(1); @@ -2651,7 +2931,7 @@ describe("Room", function() { it("handles missing receipt type", () => { room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => { - return receiptType === ReceiptType.ReadPrivate ? { eventId: "eventId" } as WrappedReceipt : null; + return receiptType === ReceiptType.ReadPrivate ? ({ eventId: "eventId" } as WrappedReceipt) : null; }; expect(room.getEventReadUpTo(userA)).toEqual("eventId"); @@ -2670,9 +2950,12 @@ describe("Room", function() { }; for (let i = 1; i <= 2; i++) { - room.getUnfilteredTimelineSet = () => ({ compareEventOrdering: (event1, event2) => { - return (event1 === `eventId${i}`) ? 1 : -1; - } } as EventTimelineSet); + room.getUnfilteredTimelineSet = () => + ({ + compareEventOrdering: (event1, event2) => { + return event1 === `eventId${i}` ? 1 : -1; + }, + } as EventTimelineSet); expect(room.getEventReadUpTo(userA)).toEqual(`eventId${i}`); } @@ -2681,9 +2964,10 @@ describe("Room", function() { describe("correctly compares by timestamp", () => { it("should correctly compare, if we have all receipts", () => { for (let i = 1; i <= 2; i++) { - room.getUnfilteredTimelineSet = () => ({ - compareEventOrdering: (_1, _2) => null, - } as EventTimelineSet); + room.getUnfilteredTimelineSet = () => + ({ + compareEventOrdering: (_1, _2) => null, + } as EventTimelineSet); room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => { if (receiptType === ReceiptType.ReadPrivate) { return { eventId: "eventId1", data: { ts: i === 1 ? 2 : 1 } } as WrappedReceipt; @@ -2699,9 +2983,10 @@ describe("Room", function() { }); it("should correctly compare, if private read receipt is missing", () => { - room.getUnfilteredTimelineSet = () => ({ - compareEventOrdering: (_1, _2) => null, - } as EventTimelineSet); + room.getUnfilteredTimelineSet = () => + ({ + compareEventOrdering: (_1, _2) => null, + } as EventTimelineSet); room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => { if (receiptType === ReceiptType.Read) { return { eventId: "eventId2", data: { ts: 1 } } as WrappedReceipt; @@ -2715,9 +3000,10 @@ describe("Room", function() { describe("fallback precedence", () => { beforeAll(() => { - room.getUnfilteredTimelineSet = () => ({ - compareEventOrdering: (_1, _2) => null, - } as EventTimelineSet); + room.getUnfilteredTimelineSet = () => + ({ + compareEventOrdering: (_1, _2) => null, + } as EventTimelineSet); }); it("should give precedence to m.read.private", () => { @@ -2761,7 +3047,7 @@ describe("Room", function() { }); describe("thread notifications", () => { - let room; + let room: Room; beforeEach(() => { const client = new TestClient(userA).client; @@ -2833,11 +3119,11 @@ describe("Room", function() { }); describe("hasThreadUnreadNotification", () => { - it('has no notifications by default', () => { + it("has no notifications by default", () => { expect(room.hasThreadUnreadNotification()).toBe(false); }); - it('main timeline notification does not affect this', () => { + it("main timeline notification does not affect this", () => { room.setUnreadNotificationCount(NotificationCountType.Highlight, 1); expect(room.hasThreadUnreadNotification()).toBe(false); room.setUnreadNotificationCount(NotificationCountType.Total, 1); @@ -2847,7 +3133,7 @@ describe("Room", function() { expect(room.hasThreadUnreadNotification()).toBe(true); }); - it('lets you reset', () => { + it("lets you reset", () => { room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 1); expect(room.hasThreadUnreadNotification()).toBe(true); @@ -2890,26 +3176,29 @@ describe("Room", function() { it("should load pending events from from the store and decrypt if needed", async () => { const client = new TestClient(userA).client; - client.crypto = { + client.crypto = client["cryptoBackend"] = { decryptEvent: jest.fn().mockResolvedValue({ clearEvent: { body: "enc" } }), } as unknown as Crypto; - client.store.getPendingEvents = jest.fn(async roomId => [{ - event_id: "$1:server", - type: "m.room.message", - content: { body: "1" }, - sender: "@1:server", - room_id: roomId, - origin_server_ts: 1, - txn_id: "txn1", - }, { - event_id: "$2:server", - type: "m.room.encrypted", - content: { body: "2" }, - sender: "@2:server", - room_id: roomId, - origin_server_ts: 2, - txn_id: "txn2", - }]); + client.store.getPendingEvents = jest.fn(async (roomId) => [ + { + event_id: "$1:server", + type: "m.room.message", + content: { body: "1" }, + sender: "@1:server", + room_id: roomId, + origin_server_ts: 1, + txn_id: "txn1", + }, + { + event_id: "$2:server", + type: "m.room.encrypted", + content: { body: "2" }, + sender: "@2:server", + room_id: roomId, + origin_server_ts: 2, + txn_id: "txn2", + }, + ]); const room = new Room(roomId, client, userA, { pendingEventOrdering: PendingEventOrdering.Detached, }); diff --git a/spec/unit/scheduler.spec.ts b/spec/unit/scheduler.spec.ts index 59c2d0a1d45..122b71fde1d 100644 --- a/spec/unit/scheduler.spec.ts +++ b/spec/unit/scheduler.spec.ts @@ -1,54 +1,62 @@ // This file had a function whose name is all caps, which displeases eslint /* eslint new-cap: "off" */ -import { defer } from '../../src/utils'; +import { defer, IDeferred } from "../../src/utils"; import { MatrixError } from "../../src/http-api"; import { MatrixScheduler } from "../../src/scheduler"; import * as utils from "../test-utils/test-utils"; +import { MatrixEvent } from "../../src"; jest.useFakeTimers(); -describe("MatrixScheduler", function() { - let scheduler; - let retryFn; - let queueFn; - let deferred; +describe("MatrixScheduler", function () { + let scheduler: MatrixScheduler>; + let retryFn: Function | null; + let queueFn: ((event: MatrixEvent) => string | null) | null; + let deferred: IDeferred>; const roomId = "!foo:bar"; const eventA = utils.mkMessage({ - user: "@alice:bar", room: roomId, event: true, + user: "@alice:bar", + room: roomId, + event: true, }); const eventB = utils.mkMessage({ - user: "@alice:bar", room: roomId, event: true, + user: "@alice:bar", + room: roomId, + event: true, }); - beforeEach(function() { - scheduler = new MatrixScheduler(function(ev, attempts, err) { - if (retryFn) { - return retryFn(ev, attempts, err); - } - return -1; - }, function(event) { - if (queueFn) { - return queueFn(event); - } - return null; - }); + beforeEach(function () { + scheduler = new MatrixScheduler( + function (ev, attempts, err) { + if (retryFn) { + return retryFn(ev, attempts, err); + } + return -1; + }, + function (event) { + if (queueFn) { + return queueFn(event); + } + return null; + }, + ); retryFn = null; queueFn = null; deferred = defer(); }); - it("should process events in a queue in a FIFO manner", async function() { - retryFn = function() { + it("should process events in a queue in a FIFO manner", async function () { + retryFn = function () { return 0; }; - queueFn = function() { + queueFn = function () { return "one_big_queue"; }; const deferA = defer>(); const deferB = defer>(); let yieldedA = false; - scheduler.setProcessFunction(function(event) { + scheduler.setProcessFunction(function (event) { if (yieldedA) { expect(event).toEqual(eventB); return deferB.promise; @@ -58,98 +66,95 @@ describe("MatrixScheduler", function() { return deferA.promise; } }); - const abPromise = Promise.all([ - scheduler.queueEvent(eventA), - scheduler.queueEvent(eventB), - ]); + const abPromise = Promise.all([scheduler.queueEvent(eventA), scheduler.queueEvent(eventB)]); deferB.resolve({ b: true }); deferA.resolve({ a: true }); const [a, b] = await abPromise; - expect(a.a).toEqual(true); - expect(b.b).toEqual(true); + expect(a!.a).toEqual(true); + expect(b!.b).toEqual(true); }); - it("should invoke the retryFn on failure and wait the amount of time specified", - async function() { - const waitTimeMs = 1500; - const retryDefer = defer(); - retryFn = function() { - retryDefer.resolve(); - return waitTimeMs; - }; - queueFn = function() { - return "yep"; - }; + it("should invoke the retryFn on failure and wait the amount of time specified", async function () { + const waitTimeMs = 1500; + const retryDefer = defer(); + retryFn = function () { + retryDefer.resolve(); + return waitTimeMs; + }; + queueFn = function () { + return "yep"; + }; - let procCount = 0; - scheduler.setProcessFunction(function(ev) { - procCount += 1; - if (procCount === 1) { - expect(ev).toEqual(eventA); - return deferred.promise; - } else if (procCount === 2) { + let procCount = 0; + scheduler.setProcessFunction(function (ev) { + procCount += 1; + if (procCount === 1) { + expect(ev).toEqual(eventA); + return deferred.promise; + } else if (procCount === 2) { // don't care about this deferred - return new Promise(() => {}); - } - expect(procCount).toBeLessThan(3); - }); - - scheduler.queueEvent(eventA); - // as queueing doesn't start processing synchronously anymore (see commit bbdb5ac) - // wait just long enough before it does - await Promise.resolve(); - expect(procCount).toEqual(1); - deferred.reject({}); - await retryDefer.promise; - expect(procCount).toEqual(1); - jest.advanceTimersByTime(waitTimeMs); - await Promise.resolve(); - expect(procCount).toEqual(2); + return new Promise(() => {}); + } + expect(procCount).toBeLessThan(3); + return new Promise(() => {}); }); - it("should give up if the retryFn on failure returns -1 and try the next event", - async function() { + scheduler.queueEvent(eventA); + // as queueing doesn't start processing synchronously anymore (see commit bbdb5ac) + // wait just long enough before it does + await Promise.resolve(); + expect(procCount).toEqual(1); + deferred.reject({}); + await retryDefer.promise; + expect(procCount).toEqual(1); + jest.advanceTimersByTime(waitTimeMs); + await Promise.resolve(); + expect(procCount).toEqual(2); + }); + + it("should give up if the retryFn on failure returns -1 and try the next event", async function () { // Queue A & B. // Reject A and return -1 on retry. // Expect B to be tried next and the promise for A to be rejected. - retryFn = function() { - return -1; - }; - queueFn = function() { - return "yep"; - }; - - const deferA = defer(); - const deferB = defer(); - let procCount = 0; - scheduler.setProcessFunction(function(ev) { - procCount += 1; - if (procCount === 1) { - expect(ev).toEqual(eventA); - return deferA.promise; - } else if (procCount === 2) { - expect(ev).toEqual(eventB); - return deferB.promise; - } - expect(procCount).toBeLessThan(3); - }); + retryFn = function () { + return -1; + }; + queueFn = function () { + return "yep"; + }; - const globalA = scheduler.queueEvent(eventA); - scheduler.queueEvent(eventB); - // as queueing doesn't start processing synchronously anymore (see commit bbdb5ac) - // wait just long enough before it does - await Promise.resolve(); - expect(procCount).toEqual(1); - deferA.reject({}); - try { - await globalA; - } catch (err) { - await Promise.resolve(); - expect(procCount).toEqual(2); + const deferA = defer>(); + const deferB = defer>(); + let procCount = 0; + scheduler.setProcessFunction(function (ev) { + procCount += 1; + if (procCount === 1) { + expect(ev).toEqual(eventA); + return deferA.promise; + } else if (procCount === 2) { + expect(ev).toEqual(eventB); + return deferB.promise; } + expect(procCount).toBeLessThan(3); + return new Promise>(() => {}); }); - it("should treat each queue separately", function(done) { + const globalA = scheduler.queueEvent(eventA); + scheduler.queueEvent(eventB); + // as queueing doesn't start processing synchronously anymore (see commit bbdb5ac) + // wait just long enough before it does + await Promise.resolve(); + expect(procCount).toEqual(1); + deferA.reject({}); + try { + await globalA; + } catch (err) { + await Promise.resolve(); + expect(procCount).toEqual(2); + } + }); + + it("should treat each queue separately", function (done) { // Queue messages A B C D. // Bucket A&D into queue_A // Bucket B&C into queue_B @@ -159,24 +164,22 @@ describe("MatrixScheduler", function() { const eventC = utils.mkMessage({ user: "@a:bar", room: roomId, event: true }); const eventD = utils.mkMessage({ user: "@b:bar", room: roomId, event: true }); - const buckets = {}; + const buckets: Record = {}; buckets[eventA.getId()!] = "queue_A"; buckets[eventD.getId()!] = "queue_A"; buckets[eventB.getId()!] = "queue_B"; buckets[eventC.getId()!] = "queue_B"; - retryFn = function() { + retryFn = function () { return 0; }; - queueFn = function(event) { - return buckets[event.getId()]; + queueFn = function (event) { + return buckets[event.getId()!]; }; - const expectOrder = [ - eventA.getId(), eventB.getId(), eventD.getId(), - ]; - const deferA = defer(); - scheduler.setProcessFunction(function(event) { + const expectOrder = [eventA.getId(), eventB.getId(), eventD.getId()]; + const deferA = defer>(); + scheduler.setProcessFunction(function (event) { const id = expectOrder.shift(); expect(id).toEqual(event.getId()); if (expectOrder.length === 0) { @@ -190,95 +193,99 @@ describe("MatrixScheduler", function() { scheduler.queueEvent(eventD); // wait a bit then resolve A and we should get D (not C) next. - setTimeout(function() { - deferA.resolve(); + setTimeout(function () { + deferA.resolve({}); }, 1000); jest.advanceTimersByTime(1000); }); - describe("queueEvent", function() { - it("should return null if the event shouldn't be queued", function() { - queueFn = function() { + describe("queueEvent", function () { + it("should return null if the event shouldn't be queued", function () { + queueFn = function () { return null; }; expect(scheduler.queueEvent(eventA)).toEqual(null); }); - it("should return a Promise if the event is queued", function() { - queueFn = function() { + it("should return a Promise if the event is queued", function () { + queueFn = function () { return "yep"; }; const prom = scheduler.queueEvent(eventA); expect(prom).toBeTruthy(); - expect(prom.then).toBeTruthy(); + expect(prom!.then).toBeTruthy(); }); }); - describe("getQueueForEvent", function() { - it("should return null if the event doesn't map to a queue name", function() { - queueFn = function() { + describe("getQueueForEvent", function () { + it("should return null if the event doesn't map to a queue name", function () { + queueFn = function () { return null; }; expect(scheduler.getQueueForEvent(eventA)).toBe(null); }); - it("should return null if the mapped queue doesn't exist", function() { - queueFn = function() { + it("should return null if the mapped queue doesn't exist", function () { + queueFn = function () { return "yep"; }; expect(scheduler.getQueueForEvent(eventA)).toBe(null); }); - it("should return a list of events in the queue and modifications to" + - " the list should not affect the underlying queue.", function() { - queueFn = function() { - return "yep"; - }; - scheduler.queueEvent(eventA); - scheduler.queueEvent(eventB); - const queue = scheduler.getQueueForEvent(eventA); - expect(queue.length).toEqual(2); - expect(queue).toEqual([eventA, eventB]); - // modify the queue - const eventC = utils.mkMessage( - { user: "@a:bar", room: roomId, event: true }, - ); - queue.push(eventC); - const queueAgain = scheduler.getQueueForEvent(eventA); - expect(queueAgain.length).toEqual(2); - }); + it( + "should return a list of events in the queue and modifications to" + + " the list should not affect the underlying queue.", + function () { + queueFn = function () { + return "yep"; + }; + scheduler.queueEvent(eventA); + scheduler.queueEvent(eventB); + const queue = scheduler.getQueueForEvent(eventA); + expect(queue).toHaveLength(2); + expect(queue).toEqual([eventA, eventB]); + // modify the queue + const eventC = utils.mkMessage({ user: "@a:bar", room: roomId, event: true }); + queue!.push(eventC); + const queueAgain = scheduler.getQueueForEvent(eventA); + expect(queueAgain).toHaveLength(2); + }, + ); - it("should return a list of events in the queue and modifications to" + - " an event in the queue should affect the underlying queue.", function() { - queueFn = function() { - return "yep"; - }; - scheduler.queueEvent(eventA); - scheduler.queueEvent(eventB); - const queue = scheduler.getQueueForEvent(eventA); - queue[1].event.content.body = "foo"; - const queueAgain = scheduler.getQueueForEvent(eventA); - expect(queueAgain[1].event.content.body).toEqual("foo"); - }); + it( + "should return a list of events in the queue and modifications to" + + " an event in the queue should affect the underlying queue.", + function () { + queueFn = function () { + return "yep"; + }; + scheduler.queueEvent(eventA); + scheduler.queueEvent(eventB); + const queue = scheduler.getQueueForEvent(eventA)!; + queue[1].event.content!.body = "foo"; + const queueAgain = scheduler.getQueueForEvent(eventA)!; + expect(queueAgain[1].event.content?.body).toEqual("foo"); + }, + ); }); - describe("removeEventFromQueue", function() { - it("should return false if the event doesn't map to a queue name", function() { - queueFn = function() { + describe("removeEventFromQueue", function () { + it("should return false if the event doesn't map to a queue name", function () { + queueFn = function () { return null; }; expect(scheduler.removeEventFromQueue(eventA)).toBe(false); }); - it("should return false if the event isn't in the queue", function() { - queueFn = function() { + it("should return false if the event isn't in the queue", function () { + queueFn = function () { return "yep"; }; expect(scheduler.removeEventFromQueue(eventA)).toBe(false); }); - it("should return true if the event was removed", function() { - queueFn = function() { + it("should return true if the event was removed", function () { + queueFn = function () { return "yep"; }; scheduler.queueEvent(eventA); @@ -286,14 +293,14 @@ describe("MatrixScheduler", function() { }); }); - describe("setProcessFunction", function() { - it("should call the processFn if there are queued events", function() { - queueFn = function() { + describe("setProcessFunction", function () { + it("should call the processFn if there are queued events", function () { + queueFn = function () { return "yep"; }; let procCount = 0; scheduler.queueEvent(eventA); - scheduler.setProcessFunction(function(ev) { + scheduler.setProcessFunction(function (ev) { procCount += 1; expect(ev).toEqual(eventA); return deferred.promise; @@ -305,12 +312,12 @@ describe("MatrixScheduler", function() { }); }); - it("should not call the processFn if there are no queued events", function() { - queueFn = function() { + it("should not call the processFn if there are no queued events", function () { + queueFn = function () { return "yep"; }; let procCount = 0; - scheduler.setProcessFunction(function(ev) { + scheduler.setProcessFunction(function (ev) { procCount += 1; return deferred.promise; }); @@ -318,48 +325,46 @@ describe("MatrixScheduler", function() { }); }); - describe("QUEUE_MESSAGES", function() { - it("should queue m.room.message events only", function() { + describe("QUEUE_MESSAGES", function () { + it("should queue m.room.message events only", function () { expect(MatrixScheduler.QUEUE_MESSAGES(eventA)).toEqual("message"); - expect(MatrixScheduler.QUEUE_MESSAGES( - utils.mkMembership({ - user: "@alice:bar", room: roomId, mship: "join", event: true, - }), - )).toEqual(null); + expect( + MatrixScheduler.QUEUE_MESSAGES( + utils.mkMembership({ + user: "@alice:bar", + room: roomId, + mship: "join", + event: true, + }), + ), + ).toEqual(null); }); }); - describe("RETRY_BACKOFF_RATELIMIT", function() { - it("should wait at least the time given on M_LIMIT_EXCEEDED", function() { + describe("RETRY_BACKOFF_RATELIMIT", function () { + it("should wait at least the time given on M_LIMIT_EXCEEDED", function () { const res = MatrixScheduler.RETRY_BACKOFF_RATELIMIT( - eventA, 1, new MatrixError({ - errcode: "M_LIMIT_EXCEEDED", retry_after_ms: 5000, + eventA, + 1, + new MatrixError({ + errcode: "M_LIMIT_EXCEEDED", + retry_after_ms: 5000, }), ); expect(res >= 500).toBe(true); }); - it("should give up after 5 attempts", function() { - const res = MatrixScheduler.RETRY_BACKOFF_RATELIMIT( - eventA, 5, new MatrixError({}), - ); + it("should give up after 5 attempts", function () { + const res = MatrixScheduler.RETRY_BACKOFF_RATELIMIT(eventA, 5, new MatrixError({})); expect(res).toBe(-1); }); - it("should do exponential backoff", function() { + it("should do exponential backoff", function () { const error = new MatrixError({}); - expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT( - eventA, 1, error, - )).toEqual(2000); - expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT( - eventA, 2, error, - )).toEqual(4000); - expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT( - eventA, 3, error, - )).toEqual(8000); - expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT( - eventA, 4, error, - )).toEqual(16000); + expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT(eventA, 1, error)).toEqual(2000); + expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT(eventA, 2, error)).toEqual(4000); + expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT(eventA, 3, error)).toEqual(8000); + expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT(eventA, 4, error)).toEqual(16000); }); }); }); diff --git a/spec/unit/stores/indexeddb.spec.ts b/spec/unit/stores/indexeddb.spec.ts index abd71274bea..f2ce29e47e6 100644 --- a/spec/unit/stores/indexeddb.spec.ts +++ b/spec/unit/stores/indexeddb.spec.ts @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import 'fake-indexeddb/auto'; -import 'jest-localstorage-mock'; +import "fake-indexeddb/auto"; +import "jest-localstorage-mock"; import { IndexedDBStore, IStateEventWithRoomId, MemoryStore } from "../../../src"; import { emitPromise } from "../../test-utils/test-utils"; @@ -61,8 +61,9 @@ describe("IndexedDBStore", () => { // Simulate a broken IDB (store.backend as LocalIndexedDBStoreBackend)["db"]!.transaction = (): IDBTransaction => { - const err = new Error("Failed to execute 'transaction' on 'IDBDatabase': " + - "The database connection is closing."); + const err = new Error( + "Failed to execute 'transaction' on 'IDBDatabase': " + "The database connection is closing.", + ); err.name = "InvalidStateError"; throw err; }; diff --git a/spec/unit/sync-accumulator.spec.ts b/spec/unit/sync-accumulator.spec.ts index 2e1ec58b60c..9255d7ec89c 100644 --- a/spec/unit/sync-accumulator.spec.ts +++ b/spec/unit/sync-accumulator.spec.ts @@ -16,7 +16,8 @@ limitations under the License. */ import { ReceiptType } from "../../src/@types/read_receipts"; -import { SyncAccumulator } from "../../src/sync-accumulator"; +import { IJoinedRoom, ISyncResponse, SyncAccumulator } from "../../src/sync-accumulator"; +import { IRoomSummary } from "../../src"; // The event body & unsigned object get frozen to assert that they don't get altered // by the impl @@ -55,12 +56,12 @@ const RES_WITH_AGE = { }, }, }, -}; +} as unknown as ISyncResponse; -describe("SyncAccumulator", function() { - let sa; +describe("SyncAccumulator", function () { + let sa: SyncAccumulator; - beforeEach(function() { + beforeEach(function () { sa = new SyncAccumulator({ maxTimelineEntries: 10, }); @@ -81,10 +82,7 @@ describe("SyncAccumulator", function() { ephemeral: { events: [] }, unread_notifications: {}, state: { - events: [ - member("alice", "join"), - member("bob", "join"), - ], + events: [member("alice", "join"), member("bob", "join")], }, summary: { "m.heroes": undefined, @@ -98,7 +96,7 @@ describe("SyncAccumulator", function() { }, }, }, - }; + } as unknown as ISyncResponse; sa.accumulate(res); const output = sa.getJSON(); expect(output.nextBatch).toEqual(res.next_batch); @@ -108,46 +106,47 @@ describe("SyncAccumulator", function() { it("should prune the timeline to the oldest prev_batch within the limit", () => { // maxTimelineEntries is 10 so we should get back all // 10 timeline messages with a prev_batch of "pinned_to_1" - sa.accumulate(syncSkeleton({ - state: { events: [member("alice", "join")] }, - timeline: { - events: [ - msg("alice", "1"), - msg("alice", "2"), - msg("alice", "3"), - msg("alice", "4"), - msg("alice", "5"), - msg("alice", "6"), - msg("alice", "7"), - ], - prev_batch: "pinned_to_1", - }, - })); - sa.accumulate(syncSkeleton({ - state: { events: [] }, - timeline: { - events: [ - msg("alice", "8"), - ], - prev_batch: "pinned_to_8", - }, - })); - sa.accumulate(syncSkeleton({ - state: { events: [] }, - timeline: { - events: [ - msg("alice", "9"), - msg("alice", "10"), - ], - prev_batch: "pinned_to_10", - }, - })); + sa.accumulate( + syncSkeleton({ + state: { events: [member("alice", "join")] }, + timeline: { + events: [ + msg("alice", "1"), + msg("alice", "2"), + msg("alice", "3"), + msg("alice", "4"), + msg("alice", "5"), + msg("alice", "6"), + msg("alice", "7"), + ], + prev_batch: "pinned_to_1", + }, + }), + ); + sa.accumulate( + syncSkeleton({ + state: { events: [] }, + timeline: { + events: [msg("alice", "8")], + prev_batch: "pinned_to_8", + }, + }), + ); + sa.accumulate( + syncSkeleton({ + state: { events: [] }, + timeline: { + events: [msg("alice", "9"), msg("alice", "10")], + prev_batch: "pinned_to_10", + }, + }), + ); let output = sa.getJSON().roomsData.join["!foo:bar"]; expect(output.timeline.events.length).toEqual(10); output.timeline.events.forEach((e, i) => { - expect(e.content.body).toEqual(""+(i+1)); + expect(e.content.body).toEqual("" + (i + 1)); }); expect(output.timeline.prev_batch).toEqual("pinned_to_1"); @@ -155,62 +154,60 @@ describe("SyncAccumulator", function() { // AND give us <= 10 messages without losing messages in-between. // It should try to find the oldest prev_batch which still fits into 10 // messages, which is "pinned to 8". - sa.accumulate(syncSkeleton({ - state: { events: [] }, - timeline: { - events: [ - msg("alice", "11"), - msg("alice", "12"), - msg("alice", "13"), - msg("alice", "14"), - msg("alice", "15"), - msg("alice", "16"), - msg("alice", "17"), - ], - prev_batch: "pinned_to_11", - }, - })); + sa.accumulate( + syncSkeleton({ + state: { events: [] }, + timeline: { + events: [ + msg("alice", "11"), + msg("alice", "12"), + msg("alice", "13"), + msg("alice", "14"), + msg("alice", "15"), + msg("alice", "16"), + msg("alice", "17"), + ], + prev_batch: "pinned_to_11", + }, + }), + ); output = sa.getJSON().roomsData.join["!foo:bar"]; expect(output.timeline.events.length).toEqual(10); output.timeline.events.forEach((e, i) => { - expect(e.content.body).toEqual(""+(i+8)); + expect(e.content.body).toEqual("" + (i + 8)); }); expect(output.timeline.prev_batch).toEqual("pinned_to_8"); }); it("should remove the stored timeline on limited syncs", () => { - sa.accumulate(syncSkeleton({ - state: { events: [member("alice", "join")] }, - timeline: { - events: [ - msg("alice", "1"), - msg("alice", "2"), - msg("alice", "3"), - ], - prev_batch: "pinned_to_1", - }, - })); + sa.accumulate( + syncSkeleton({ + state: { events: [member("alice", "join")] }, + timeline: { + events: [msg("alice", "1"), msg("alice", "2"), msg("alice", "3")], + prev_batch: "pinned_to_1", + }, + }), + ); // some time passes and now we get a limited sync - sa.accumulate(syncSkeleton({ - state: { events: [] }, - timeline: { - limited: true, - events: [ - msg("alice", "51"), - msg("alice", "52"), - msg("alice", "53"), - ], - prev_batch: "pinned_to_51", - }, - })); + sa.accumulate( + syncSkeleton({ + state: { events: [] }, + timeline: { + limited: true, + events: [msg("alice", "51"), msg("alice", "52"), msg("alice", "53")], + prev_batch: "pinned_to_51", + }, + }), + ); const output = sa.getJSON().roomsData.join["!foo:bar"]; expect(output.timeline.events.length).toEqual(3); output.timeline.events.forEach((e, i) => { - expect(e.content.body).toEqual(""+(i+51)); + expect(e.content.body).toEqual("" + (i + 51)); }); expect(output.timeline.prev_batch).toEqual("pinned_to_51"); }); @@ -218,19 +215,18 @@ describe("SyncAccumulator", function() { it("should drop typing notifications", () => { const res = syncSkeleton({ ephemeral: { - events: [{ - type: "m.typing", - content: { - user_ids: ["@alice:localhost"], + events: [ + { + type: "m.typing", + content: { + user_ids: ["@alice:localhost"], + }, }, - room_id: "!foo:bar", - }], + ], }, }); sa.accumulate(res); - expect( - sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events.length, - ).toEqual(0); + expect(sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events.length).toEqual(0); }); it("should clobber account data based on event type", () => { @@ -246,22 +242,22 @@ describe("SyncAccumulator", function() { food: "apple", }, }; - sa.accumulate(syncSkeleton({ - account_data: { - events: [acc1], - }, - })); - sa.accumulate(syncSkeleton({ - account_data: { - events: [acc2], - }, - })); - expect( - sa.getJSON().roomsData.join["!foo:bar"].account_data.events.length, - ).toEqual(1); - expect( - sa.getJSON().roomsData.join["!foo:bar"].account_data.events[0], - ).toEqual(acc2); + sa.accumulate( + syncSkeleton({ + account_data: { + events: [acc1], + }, + }), + ); + sa.accumulate( + syncSkeleton({ + account_data: { + events: [acc2], + }, + }), + ); + expect(sa.getJSON().roomsData.join["!foo:bar"].account_data.events.length).toEqual(1); + expect(sa.getJSON().roomsData.join["!foo:bar"].account_data.events[0]).toEqual(acc2); }); it("should clobber global account data based on event type", () => { @@ -281,18 +277,14 @@ describe("SyncAccumulator", function() { account_data: { events: [acc1], }, - }); + } as unknown as ISyncResponse); sa.accumulate({ account_data: { events: [acc2], }, - }); - expect( - sa.getJSON().accountData.length, - ).toEqual(1); - expect( - sa.getJSON().accountData[0], - ).toEqual(acc2); + } as unknown as ISyncResponse); + expect(sa.getJSON().accountData.length).toEqual(1); + expect(sa.getJSON().accountData[0]).toEqual(acc2); }); it("should accumulate read receipts", () => { @@ -326,23 +318,23 @@ describe("SyncAccumulator", function() { }, }, }; - sa.accumulate(syncSkeleton({ - ephemeral: { - events: [receipt1], - }, - })); - sa.accumulate(syncSkeleton({ - ephemeral: { - events: [receipt2], - }, - })); - - expect( - sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events.length, - ).toEqual(1); - expect( - sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events[0], - ).toEqual({ + sa.accumulate( + syncSkeleton({ + ephemeral: { + events: [receipt1], + }, + }), + ); + sa.accumulate( + syncSkeleton({ + ephemeral: { + events: [receipt2], + }, + }), + ); + + expect(sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events.length).toEqual(1); + expect(sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events[0]).toEqual({ type: "m.receipt", room_id: "!foo:bar", content: { @@ -387,23 +379,23 @@ describe("SyncAccumulator", function() { }, }, }; - sa.accumulate(syncSkeleton({ - ephemeral: { - events: [receipt1], - }, - })); - sa.accumulate(syncSkeleton({ - ephemeral: { - events: [receipt2], - }, - })); - - expect( - sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events.length, - ).toEqual(1); - expect( - sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events[0], - ).toEqual({ + sa.accumulate( + syncSkeleton({ + ephemeral: { + events: [receipt1], + }, + }), + ); + sa.accumulate( + syncSkeleton({ + ephemeral: { + events: [receipt2], + }, + }), + ); + + expect(sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events.length).toEqual(1); + expect(sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events[0]).toEqual({ type: "m.receipt", room_id: "!foo:bar", content: { @@ -421,8 +413,8 @@ describe("SyncAccumulator", function() { }); }); - describe("summary field", function() { - function createSyncResponseWithSummary(summary) { + describe("summary field", function () { + function createSyncResponseWithSummary(summary: IRoomSummary): ISyncResponse { return { next_batch: "abc", rooms: { @@ -444,32 +436,38 @@ describe("SyncAccumulator", function() { }, }, }, - }; + } as unknown as ISyncResponse; } afterEach(() => { - jest.spyOn(global.Date, 'now').mockRestore(); + jest.spyOn(global.Date, "now").mockRestore(); }); - it("should copy summary properties", function() { - sa.accumulate(createSyncResponseWithSummary({ - "m.heroes": ["@alice:bar"], - "m.invited_member_count": 2, - })); + it("should copy summary properties", function () { + sa.accumulate( + createSyncResponseWithSummary({ + "m.heroes": ["@alice:bar"], + "m.invited_member_count": 2, + }), + ); const summary = sa.getJSON().roomsData.join["!foo:bar"].summary; expect(summary["m.invited_member_count"]).toEqual(2); expect(summary["m.heroes"]).toEqual(["@alice:bar"]); }); - it("should accumulate summary properties", function() { - sa.accumulate(createSyncResponseWithSummary({ - "m.heroes": ["@alice:bar"], - "m.invited_member_count": 2, - })); - sa.accumulate(createSyncResponseWithSummary({ - "m.heroes": ["@bob:bar"], - "m.joined_member_count": 5, - })); + it("should accumulate summary properties", function () { + sa.accumulate( + createSyncResponseWithSummary({ + "m.heroes": ["@alice:bar"], + "m.invited_member_count": 2, + }), + ); + sa.accumulate( + createSyncResponseWithSummary({ + "m.heroes": ["@bob:bar"], + "m.joined_member_count": 5, + }), + ); const summary = sa.getJSON().roomsData.join["!foo:bar"].summary; expect(summary["m.invited_member_count"]).toEqual(2); expect(summary["m.joined_member_count"]).toEqual(5); @@ -480,15 +478,15 @@ describe("SyncAccumulator", function() { const delta = 1000; const startingTs = 1000; - jest.spyOn(global.Date, 'now').mockReturnValue(startingTs); + jest.spyOn(global.Date, "now").mockReturnValue(startingTs); sa.accumulate(RES_WITH_AGE); - jest.spyOn(global.Date, 'now').mockReturnValue(startingTs + delta); + jest.spyOn(global.Date, "now").mockReturnValue(startingTs + delta); const output = sa.getJSON(); - expect(output.roomsData.join["!foo:bar"].timeline.events[0].unsigned.age).toEqual( - RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0].unsigned.age + delta, + expect(output.roomsData.join["!foo:bar"].timeline.events[0].unsigned?.age).toEqual( + RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0].unsigned!.age! + delta, ); expect(Object.keys(output.roomsData.join["!foo:bar"].timeline.events[0])).toEqual( Object.keys(RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0]), @@ -506,13 +504,14 @@ describe("SyncAccumulator", function() { 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(); + expect( + output.roomsData.join["!foo:bar"].unread_thread_notifications!["$143273582443PhrSn:example.org"], + ).not.toBeUndefined(); }); }); }); -function syncSkeleton(joinObj) { +function syncSkeleton(joinObj: Partial): ISyncResponse { joinObj = joinObj || {}; return { next_batch: "abc", @@ -521,11 +520,12 @@ function syncSkeleton(joinObj) { "!foo:bar": joinObj, }, }, - }; + } as unknown as ISyncResponse; } -function msg(localpart, text) { +function msg(localpart: string, text: string) { return { + event_id: "$" + Math.random(), content: { body: text, }, @@ -535,8 +535,9 @@ function msg(localpart, text) { }; } -function member(localpart, membership) { +function member(localpart: string, membership: string) { return { + event_id: "$" + Math.random(), content: { membership: membership, }, diff --git a/spec/unit/timeline-window.spec.ts b/spec/unit/timeline-window.spec.ts index f6dec04e786..f786a513d64 100644 --- a/spec/unit/timeline-window.spec.ts +++ b/spec/unit/timeline-window.spec.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MockedObject } from 'jest-mock'; +import { MockedObject } from "jest-mock"; import { MatrixClient } from "../../src/client"; import { EventTimelineSet } from "../../src/models/event-timeline-set"; @@ -37,7 +37,7 @@ const mockClient = { function createTimeline(numEvents = 3, baseIndex = 1): EventTimeline { const room = new Room(ROOM_ID, mockClient, USER_ID); const timelineSet = new EventTimelineSet(room); - jest.spyOn(room, 'getUnfilteredTimelineSet').mockReturnValue(timelineSet); + jest.spyOn(room, "getUnfilteredTimelineSet").mockReturnValue(timelineSet); const timeline = new EventTimeline(timelineSet); @@ -55,7 +55,8 @@ function addEventsToTimeline(timeline: EventTimeline, numEvents: number, toStart for (let i = 0; i < numEvents; i++) { timeline.addEvent( mkMessage({ - room: ROOM_ID, user: USER_ID, + room: ROOM_ID, + user: USER_ID, event: true, }), { toStartOfTimeline }, @@ -74,42 +75,42 @@ function createLinkedTimelines(): [EventTimeline, EventTimeline] { return [tl1, tl2]; } -describe("TimelineIndex", function() { +describe("TimelineIndex", function () { beforeEach(() => { jest.clearAllMocks(); mockClient.getEventTimeline.mockResolvedValue(undefined); }); - describe("minIndex", function() { - it("should return the min index relative to BaseIndex", function() { + describe("minIndex", function () { + it("should return the min index relative to BaseIndex", function () { const timelineIndex = new TimelineIndex(createTimeline(), 0); expect(timelineIndex.minIndex()).toEqual(-1); }); }); - describe("maxIndex", function() { - it("should return the max index relative to BaseIndex", function() { + describe("maxIndex", function () { + it("should return the max index relative to BaseIndex", function () { const timelineIndex = new TimelineIndex(createTimeline(), 0); expect(timelineIndex.maxIndex()).toEqual(2); }); }); - describe("advance", function() { - it("should advance up to the end of the timeline", function() { + describe("advance", function () { + it("should advance up to the end of the timeline", function () { const timelineIndex = new TimelineIndex(createTimeline(), 0); const result = timelineIndex.advance(3); expect(result).toEqual(2); expect(timelineIndex.index).toEqual(2); }); - it("should retreat back to the start of the timeline", function() { + it("should retreat back to the start of the timeline", function () { const timelineIndex = new TimelineIndex(createTimeline(), 0); const result = timelineIndex.advance(-2); expect(result).toEqual(-1); expect(timelineIndex.index).toEqual(-1); }); - it("should advance into the next timeline", function() { + it("should advance into the next timeline", function () { const timelines = createLinkedTimelines(); const tl1 = timelines[0]; const tl2 = timelines[1]; @@ -127,7 +128,7 @@ describe("TimelineIndex", function() { expect(timelineIndex.index).toEqual(0); }); - it("should retreat into the previous timeline", function() { + it("should retreat into the previous timeline", function () { const timelines = createLinkedTimelines(); const tl1 = timelines[0]; const tl2 = timelines[1]; @@ -143,8 +144,8 @@ describe("TimelineIndex", function() { }); }); - describe("retreat", function() { - it("should retreat up to the start of the timeline", function() { + describe("retreat", function () { + it("should retreat up to the start of the timeline", function () { const timelineIndex = new TimelineIndex(createTimeline(), 0); const result = timelineIndex.retreat(2); expect(result).toEqual(1); @@ -153,14 +154,17 @@ describe("TimelineIndex", function() { }); }); -describe("TimelineWindow", function() { +describe("TimelineWindow", function () { /** * create a dummy eventTimelineSet and client, and a TimelineWindow * attached to them. */ - function createWindow(timeline: EventTimeline, opts?: { - windowLimit?: number; - }): [TimelineWindow, EventTimelineSet] { + function createWindow( + timeline: EventTimeline, + opts?: { + windowLimit?: number; + }, + ): [TimelineWindow, EventTimelineSet] { const timelineSet = { getTimelineForEvent: () => null } as unknown as EventTimelineSet; mockClient.getEventTimeline.mockResolvedValue(timeline); @@ -173,12 +177,12 @@ describe("TimelineWindow", function() { mockClient.paginateEventTimeline.mockResolvedValue(false); }); - describe("load", function() { - it("should initialise from the live timeline", async function() { + describe("load", function () { + it("should initialise from the live timeline", async function () { const liveTimeline = createTimeline(); const room = new Room(ROOM_ID, mockClient, USER_ID); const timelineSet = new EventTimelineSet(room); - jest.spyOn(timelineSet, 'getLiveTimeline').mockReturnValue(liveTimeline); + jest.spyOn(timelineSet, "getLiveTimeline").mockReturnValue(liveTimeline); const timelineWindow = new TimelineWindow(mockClient, timelineSet); await timelineWindow.load(undefined, 2); @@ -189,7 +193,7 @@ describe("TimelineWindow", function() { expect(timelineWindow.getEvents()).toEqual(expectedEvents); }); - it("should initialise from a specific event", async function() { + it("should initialise from a specific event", async function () { const timeline = createTimeline(); const eventId = timeline.getEvents()[1].getId(); @@ -203,7 +207,7 @@ describe("TimelineWindow", function() { expect(timelineWindow.getEvents()).toEqual(expectedEvents); }); - it("canPaginate should return false until load has returned", async function() { + it("canPaginate should return false until load has returned", async function () { const timeline = createTimeline(); timeline.setPaginationToken("toktok1", EventTimeline.BACKWARDS); timeline.setPaginationToken("toktok2", EventTimeline.FORWARDS); @@ -227,15 +231,13 @@ describe("TimelineWindow", function() { expect(timelineWindow.getEvents()).toEqual(expectedEvents); // can paginate now - expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) - .toBe(true); - expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) - .toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(true); }); }); - describe("pagination", function() { - it("should be able to advance across the initial timeline", async function() { + describe("pagination", function () { + it("should be able to advance across the initial timeline", async function () { const timeline = createTimeline(); const eventId = timeline.getEvents()[1].getId(); const [timelineWindow] = createWindow(timeline); @@ -245,19 +247,15 @@ describe("TimelineWindow", function() { const expectedEvents = [timeline.getEvents()[1]]; expect(timelineWindow.getEvents()).toEqual(expectedEvents); - expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) - .toBe(true); - expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) - .toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(true); expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(true); const expectedEventsAfterPagination = timeline.getEvents().slice(1); expect(timelineWindow.getEvents()).toEqual(expectedEventsAfterPagination); - expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) - .toBe(true); - expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) - .toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(false); // cant paginate forward anymore expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(false); @@ -268,14 +266,12 @@ describe("TimelineWindow", function() { const expectedEvents3 = timeline.getEvents(); expect(timelineWindow.getEvents()).toEqual(expectedEvents3); - expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) - .toBe(false); - expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) - .toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(false); expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(false); }); - it("should advance into next timeline", async function() { + it("should advance into next timeline", async function () { const tls = createLinkedTimelines(); const eventId = tls[0].getEvents()[1].getId(); const [timelineWindow] = createWindow(tls[0], { windowLimit: 5 }); @@ -284,37 +280,29 @@ describe("TimelineWindow", function() { const expectedEvents = tls[0].getEvents(); expect(timelineWindow.getEvents()).toEqual(expectedEvents); - expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) - .toBe(false); - expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) - .toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(true); expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(true); - const expectedEvents2 = tls[0].getEvents() - .concat(tls[1].getEvents().slice(0, 2)); + const expectedEvents2 = tls[0].getEvents().concat(tls[1].getEvents().slice(0, 2)); expect(timelineWindow.getEvents()).toEqual(expectedEvents2); - expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) - .toBe(false); - expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) - .toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(true); expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(true); // the windowLimit should have made us drop an event from // tls[0] - const expectedEvents3 = tls[0].getEvents().slice(1) - .concat(tls[1].getEvents()); + const expectedEvents3 = tls[0].getEvents().slice(1).concat(tls[1].getEvents()); expect(timelineWindow.getEvents()).toEqual(expectedEvents3); - expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) - .toBe(true); - expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) - .toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(false); expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(false); }); - it("should retreat into previous timeline", async function() { + it("should retreat into previous timeline", async function () { const tls = createLinkedTimelines(); const eventId = tls[1].getEvents()[1].getId(); const [timelineWindow] = createWindow(tls[1], { windowLimit: 5 }); @@ -324,36 +312,28 @@ describe("TimelineWindow", function() { const expectedEvents = tls[1].getEvents(); expect(timelineWindow.getEvents()).toEqual(expectedEvents); - expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) - .toBe(true); - expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) - .toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(false); expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(true); - const expectedEvents2 = tls[0].getEvents().slice(1, 3) - .concat(tls[1].getEvents()); + const expectedEvents2 = tls[0].getEvents().slice(1, 3).concat(tls[1].getEvents()); expect(timelineWindow.getEvents()).toEqual(expectedEvents2); - expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) - .toBe(true); - expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) - .toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(false); expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(true); // the windowLimit should have made us drop an event from // tls[1] - const expectedEvents3 = tls[0].getEvents() - .concat(tls[1].getEvents().slice(0, 2)); + const expectedEvents3 = tls[0].getEvents().concat(tls[1].getEvents().slice(0, 2)); expect(timelineWindow.getEvents()).toEqual(expectedEvents3); - expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) - .toBe(false); - expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) - .toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(true); expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(false); }); - it("should make forward pagination requests", async function() { + it("should make forward pagination requests", async function () { const timeline = createTimeline(); timeline.setPaginationToken("toktok", EventTimeline.FORWARDS); @@ -377,7 +357,7 @@ describe("TimelineWindow", function() { expect(timelineWindow.getEvents()).toEqual(expectedEvents2); }); - it("should make backward pagination requests", async function() { + it("should make backward pagination requests", async function () { const timeline = createTimeline(); timeline.setPaginationToken("toktok", EventTimeline.BACKWARDS); @@ -403,7 +383,7 @@ describe("TimelineWindow", function() { expect(timelineWindow.getEvents()).toEqual(expectedEvents2); }); - it("should limit the number of unsuccessful pagination requests", async function() { + it("should limit the number of unsuccessful pagination requests", async function () { const timeline = createTimeline(); timeline.setPaginationToken("toktok", EventTimeline.FORWARDS); @@ -418,10 +398,8 @@ describe("TimelineWindow", function() { const expectedEvents = timeline.getEvents(); expect(timelineWindow.getEvents()).toEqual(expectedEvents); - expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) - .toBe(false); - expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) - .toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(true); expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2, true, 3)).toBe(false); expect(mockClient.paginateEventTimeline).toHaveBeenCalledWith(timeline, { backwards: false, limit: 2 }); @@ -430,10 +408,8 @@ describe("TimelineWindow", function() { const expectedEvents2 = timeline.getEvents().slice(0, 3); expect(timelineWindow.getEvents()).toEqual(expectedEvents2); - expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) - .toBe(false); - expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) - .toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(true); }); }); }); diff --git a/spec/unit/user.spec.ts b/spec/unit/user.spec.ts index 42cf42150f7..089fcc91e64 100644 --- a/spec/unit/user.spec.ts +++ b/spec/unit/user.spec.ts @@ -17,28 +17,30 @@ limitations under the License. import { User, UserEvent } from "../../src/models/user"; import { mkEvent } from "../test-utils/test-utils"; -describe("User", function() { +describe("User", function () { const userId = "@alice:bar"; let user: User; - beforeEach(function() { + beforeEach(function () { user = new User(userId); }); - describe("setPresenceEvent", function() { + describe("setPresenceEvent", function () { const event = mkEvent({ - type: "m.presence", content: { + type: "m.presence", + content: { presence: "online", user_id: userId, displayname: "Alice", last_active_ago: 1085, avatar_url: "mxc://foo/bar", - }, event: true, + }, + event: true, }); - it("should emit 'User.displayName' if the display name changes", function() { + it("should emit 'User.displayName' if the display name changes", function () { let emitCount = 0; - user.on(UserEvent.DisplayName, function(ev, usr) { + user.on(UserEvent.DisplayName, function (ev, usr) { emitCount += 1; }); user.setPresenceEvent(event); @@ -47,9 +49,9 @@ describe("User", function() { expect(emitCount).toEqual(1); }); - it("should emit 'User.avatarUrl' if the avatar URL changes", function() { + it("should emit 'User.avatarUrl' if the avatar URL changes", function () { let emitCount = 0; - user.on(UserEvent.AvatarUrl, function(ev, usr) { + user.on(UserEvent.AvatarUrl, function (ev, usr) { emitCount += 1; }); user.setPresenceEvent(event); @@ -58,9 +60,9 @@ describe("User", function() { expect(emitCount).toEqual(1); }); - it("should emit 'User.presence' if the presence changes", function() { + it("should emit 'User.presence' if the presence changes", function () { let emitCount = 0; - user.on(UserEvent.Presence, function(ev, usr) { + user.on(UserEvent.Presence, function (ev, usr) { emitCount += 1; }); user.setPresenceEvent(event); @@ -69,27 +71,27 @@ describe("User", function() { expect(emitCount).toEqual(1); }); - it("should set User.displayName", function() { + it("should set User.displayName", function () { user.setPresenceEvent(event); expect(user.displayName).toEqual("Alice"); }); - it("should set User.avatarUrl", function() { + it("should set User.avatarUrl", function () { user.setPresenceEvent(event); expect(user.avatarUrl).toEqual("mxc://foo/bar"); }); - it("should set User.presence", function() { + it("should set User.presence", function () { user.setPresenceEvent(event); expect(user.presence).toEqual("online"); }); - it("should set User.lastActiveAgo", function() { + it("should set User.lastActiveAgo", function () { user.setPresenceEvent(event); expect(user.lastActiveAgo).toEqual(1085); }); - it("should set User.events.presence", function() { + it("should set User.events.presence", function () { user.setPresenceEvent(event); expect(user.events.presence).toEqual(event); }); diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts index 1b11f2a7cd3..8104cba0815 100644 --- a/spec/unit/utils.spec.ts +++ b/spec/unit/utils.spec.ts @@ -19,9 +19,9 @@ import { ReceiptType } from "../../src/@types/read_receipts"; // TODO: Fix types throughout -describe("utils", function() { - describe("encodeParams", function() { - it("should url encode and concat with &s", function() { +describe("utils", function () { + describe("encodeParams", function () { + it("should url encode and concat with &s", function () { const params = { foo: "bar", baz: "beer@", @@ -29,7 +29,7 @@ describe("utils", function() { expect(utils.encodeParams(params).toString()).toEqual("foo=bar&baz=beer%40"); }); - it("should handle boolean and numeric values", function() { + it("should handle boolean and numeric values", function () { const params = { string: "foobar", number: 12345, @@ -56,38 +56,36 @@ describe("utils", function() { }); }); - describe("encodeUri", function() { - it("should replace based on object keys and url encode", function() { + describe("encodeUri", function () { + it("should replace based on object keys and url encode", function () { const path = "foo/bar/%something/%here"; const vals = { "%something": "baz", "%here": "beer@", }; - expect(utils.encodeUri(path, vals)).toEqual( - "foo/bar/baz/beer%40", - ); + expect(utils.encodeUri(path, vals)).toEqual("foo/bar/baz/beer%40"); }); }); - describe("removeElement", function() { - it("should remove only 1 element if there is a match", function() { - const matchFn = function() { + describe("removeElement", function () { + it("should remove only 1 element if there is a match", function () { + const matchFn = function () { return true; }; const arr = [55, 66, 77]; utils.removeElement(arr, matchFn); expect(arr).toEqual([66, 77]); }); - it("should be able to remove in reverse order", function() { - const matchFn = function() { + it("should be able to remove in reverse order", function () { + const matchFn = function () { return true; }; const arr = [55, 66, 77]; utils.removeElement(arr, matchFn, true); expect(arr).toEqual([55, 66]); }); - it("should remove nothing if the function never returns true", function() { - const matchFn = function() { + it("should remove nothing if the function never returns true", function () { + const matchFn = function () { return false; }; const arr = [55, 66, 77]; @@ -96,8 +94,8 @@ describe("utils", function() { }); }); - describe("isFunction", function() { - it("should return true for functions", function() { + describe("isFunction", function () { + it("should return true for functions", function () { expect(utils.isFunction([])).toBe(false); expect(utils.isFunction([5, 3, 7])).toBe(false); expect(utils.isFunction(undefined)).toBe(false); @@ -106,36 +104,39 @@ describe("utils", function() { expect(utils.isFunction("foo")).toBe(false); expect(utils.isFunction(555)).toBe(false); - expect(utils.isFunction(function() {})).toBe(true); - const s = { foo: function() {} }; + expect(utils.isFunction(function () {})).toBe(true); + const s = { foo: function () {} }; expect(utils.isFunction(s.foo)).toBe(true); }); }); - describe("checkObjectHasKeys", function() { - it("should throw for missing keys", function() { - expect(function() { + describe("checkObjectHasKeys", function () { + it("should throw for missing keys", function () { + expect(function () { utils.checkObjectHasKeys({}, ["foo"]); }).toThrow(); - expect(function() { - utils.checkObjectHasKeys({ - foo: "bar", - }, ["foo"]); + expect(function () { + utils.checkObjectHasKeys( + { + foo: "bar", + }, + ["foo"], + ); }).not.toThrow(); }); }); - describe("deepCompare", function() { + describe("deepCompare", function () { const assert = { - isTrue: function(x: any) { + isTrue: function (x: any) { expect(x).toBe(true); }, - isFalse: function(x: any) { + isFalse: function (x: any) { expect(x).toBe(false); }, }; - it("should handle primitives", function() { + it("should handle primitives", function () { assert.isTrue(utils.deepCompare(null, null)); assert.isFalse(utils.deepCompare(null, undefined)); assert.isTrue(utils.deepCompare("hi", "hi")); @@ -143,26 +144,26 @@ describe("utils", function() { assert.isFalse(utils.deepCompare(5, 10)); }); - it("should handle regexps", function() { + it("should handle regexps", function () { assert.isTrue(utils.deepCompare(/abc/, /abc/)); assert.isFalse(utils.deepCompare(/abc/, /123/)); const r = /abc/; assert.isTrue(utils.deepCompare(r, r)); }); - it("should handle dates", function() { + it("should handle dates", function () { assert.isTrue(utils.deepCompare(new Date("2011-03-31"), new Date("2011-03-31"))); assert.isFalse(utils.deepCompare(new Date("2011-03-31"), new Date("1970-01-01"))); }); - it("should handle arrays", function() { + it("should handle arrays", function () { assert.isTrue(utils.deepCompare([], [])); assert.isTrue(utils.deepCompare([1, 2], [1, 2])); assert.isFalse(utils.deepCompare([1, 2], [2, 1])); assert.isFalse(utils.deepCompare([1, 2], [1, 2, 3])); }); - it("should handle simple objects", function() { + it("should handle simple objects", function () { assert.isTrue(utils.deepCompare({}, {})); assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 2 })); assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { b: 2, a: 1 })); @@ -171,34 +172,44 @@ describe("utils", function() { 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 }, - 2: { name: "arb", age: 26 }, - }, { - 1: { name: "mhc", age: 28 }, - 2: { name: "arb", age: 26 }, - })); - - assert.isFalse(utils.deepCompare({ - 1: { name: "mhc", age: 28 }, - 2: { name: "arb", age: 26 }, - }, { - 1: { name: "mhc", age: 28 }, - 2: { name: "arb", age: 27 }, - })); + assert.isTrue( + utils.deepCompare( + { + 1: { name: "mhc", age: 28 }, + 2: { name: "arb", age: 26 }, + }, + { + 1: { name: "mhc", age: 28 }, + 2: { name: "arb", age: 26 }, + }, + ), + ); + + assert.isFalse( + utils.deepCompare( + { + 1: { name: "mhc", age: 28 }, + 2: { name: "arb", age: 26 }, + }, + { + 1: { name: "mhc", age: 28 }, + 2: { name: "arb", age: 27 }, + }, + ), + ); assert.isFalse(utils.deepCompare({}, null)); assert.isFalse(utils.deepCompare({}, undefined)); }); - it("should handle functions", function() { + it("should handle functions", function () { // no two different function is equal really, they capture their // context variables so even if they have same toString(), they // won't have same functionality - const func = function() { + const func = function () { return true; }; - const func2 = function() { + const func2 = function () { return true; }; assert.isTrue(utils.deepCompare(func, func)); @@ -208,8 +219,8 @@ describe("utils", function() { }); }); - describe("chunkPromises", function() { - it("should execute promises in chunks", async function() { + describe("chunkPromises", function () { + it("should execute promises in chunks", async function () { let promiseCount = 0; async function fn1() { @@ -228,8 +239,8 @@ describe("utils", function() { }); }); - describe('simpleRetryOperation', () => { - it('should retry', async () => { + describe("simpleRetryOperation", () => { + it("should retry", async () => { let count = 0; const val = {}; const fn = (attempt: any) => { @@ -256,24 +267,24 @@ describe("utils", function() { // all that concerns us. }); - describe('DEFAULT_ALPHABET', () => { - it('should be usefully printable ASCII in order', () => { + describe("DEFAULT_ALPHABET", () => { + it("should be usefully printable ASCII in order", () => { expect(DEFAULT_ALPHABET).toEqual( " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~", ); }); }); - describe('alphabetPad', () => { - it('should pad to the alphabet length', () => { + describe("alphabetPad", () => { + it("should pad to the alphabet length", () => { const len = 12; - expect(alphabetPad("a", len)).toEqual("a" + ("".padEnd(len - 1, DEFAULT_ALPHABET[0]))); - expect(alphabetPad("a", len, "123")).toEqual("a" + ("".padEnd(len - 1, '1'))); + expect(alphabetPad("a", len)).toEqual("a" + "".padEnd(len - 1, DEFAULT_ALPHABET[0])); + expect(alphabetPad("a", len, "123")).toEqual("a" + "".padEnd(len - 1, "1")); }); }); - describe('baseToString', () => { - it('should calculate the appropriate string from numbers', () => { + describe("baseToString", () => { + it("should calculate the appropriate string from numbers", () => { // Verify the whole alphabet for (let i = BigInt(1); i <= DEFAULT_ALPHABET.length; i++) { logger.log({ i }); // for debugging @@ -290,14 +301,14 @@ describe("utils", function() { expect(baseToString(BigInt(7820126496))).toEqual(DEFAULT_ALPHABET[0].repeat(6)); expect(baseToString(BigInt(10))).toEqual(DEFAULT_ALPHABET[9]); - expect(baseToString(BigInt(10), "abcdefghijklmnopqrstuvwxyz")).toEqual('j'); + expect(baseToString(BigInt(10), "abcdefghijklmnopqrstuvwxyz")).toEqual("j"); expect(baseToString(BigInt(6337))).toEqual("ab"); - expect(baseToString(BigInt(80), "abcdefghijklmnopqrstuvwxyz")).toEqual('cb'); + expect(baseToString(BigInt(80), "abcdefghijklmnopqrstuvwxyz")).toEqual("cb"); }); }); - describe('stringToBase', () => { - it('should calculate the appropriate number for a string', () => { + describe("stringToBase", () => { + it("should calculate the appropriate number for a string", () => { expect(stringToBase(DEFAULT_ALPHABET[0].repeat(1))).toEqual(BigInt(1)); expect(stringToBase(DEFAULT_ALPHABET[0].repeat(2))).toEqual(BigInt(96)); expect(stringToBase(DEFAULT_ALPHABET[0].repeat(3))).toEqual(BigInt(9121)); @@ -312,42 +323,42 @@ describe("utils", function() { }); }); - describe('averageBetweenStrings', () => { - it('should average appropriately', () => { + describe("averageBetweenStrings", () => { + it("should average appropriately", () => { expect(averageBetweenStrings(" ", "!!")).toEqual(" P"); expect(averageBetweenStrings(" ", "!")).toEqual(" "); - expect(averageBetweenStrings('A', 'B')).toEqual('A '); - expect(averageBetweenStrings('AA', 'BB')).toEqual('Aq'); - expect(averageBetweenStrings('A', 'z')).toEqual(']'); - expect(averageBetweenStrings('a', 'z', "abcdefghijklmnopqrstuvwxyz")).toEqual('m'); - expect(averageBetweenStrings('AA', 'zz')).toEqual('^.'); - expect(averageBetweenStrings('aa', 'zz', "abcdefghijklmnopqrstuvwxyz")).toEqual('mz'); - expect(averageBetweenStrings('cat', 'doggo')).toEqual("d9>Cw"); - expect(averageBetweenStrings('cat', 'doggo', "abcdefghijklmnopqrstuvwxyz")).toEqual("cumqh"); + expect(averageBetweenStrings("A", "B")).toEqual("A "); + expect(averageBetweenStrings("AA", "BB")).toEqual("Aq"); + expect(averageBetweenStrings("A", "z")).toEqual("]"); + expect(averageBetweenStrings("a", "z", "abcdefghijklmnopqrstuvwxyz")).toEqual("m"); + expect(averageBetweenStrings("AA", "zz")).toEqual("^."); + expect(averageBetweenStrings("aa", "zz", "abcdefghijklmnopqrstuvwxyz")).toEqual("mz"); + expect(averageBetweenStrings("cat", "doggo")).toEqual("d9>Cw"); + expect(averageBetweenStrings("cat", "doggo", "abcdefghijklmnopqrstuvwxyz")).toEqual("cumqh"); }); }); - describe('nextString', () => { - it('should find the next string appropriately', () => { - expect(nextString('A')).toEqual('B'); - expect(nextString('b', 'abcdefghijklmnopqrstuvwxyz')).toEqual('c'); - expect(nextString('cat')).toEqual('cau'); - expect(nextString('cat', 'abcdefghijklmnopqrstuvwxyz')).toEqual('cau'); + describe("nextString", () => { + it("should find the next string appropriately", () => { + expect(nextString("A")).toEqual("B"); + expect(nextString("b", "abcdefghijklmnopqrstuvwxyz")).toEqual("c"); + expect(nextString("cat")).toEqual("cau"); + expect(nextString("cat", "abcdefghijklmnopqrstuvwxyz")).toEqual("cau"); }); }); - describe('prevString', () => { - it('should find the next string appropriately', () => { - expect(prevString('B')).toEqual('A'); - expect(prevString('c', 'abcdefghijklmnopqrstuvwxyz')).toEqual('b'); - expect(prevString('cau')).toEqual('cat'); - expect(prevString('cau', 'abcdefghijklmnopqrstuvwxyz')).toEqual('cat'); + describe("prevString", () => { + it("should find the next string appropriately", () => { + expect(prevString("B")).toEqual("A"); + expect(prevString("c", "abcdefghijklmnopqrstuvwxyz")).toEqual("b"); + expect(prevString("cau")).toEqual("cat"); + expect(prevString("cau", "abcdefghijklmnopqrstuvwxyz")).toEqual("cat"); }); }); // Let's just ensure the ordering is sensible for lexicographic ordering - describe('string averaging unified', () => { - it('should be truly previous and next', () => { + describe("string averaging unified", () => { + it("should be truly previous and next", () => { let midpoint = "cat"; // We run this test 100 times to ensure we end up with a sane sequence. @@ -364,7 +375,7 @@ describe("utils", function() { } }); - it('should roll over', () => { + it("should roll over", () => { const lastAlpha = DEFAULT_ALPHABET[DEFAULT_ALPHABET.length - 1]; const firstAlpha = DEFAULT_ALPHABET[0]; @@ -375,14 +386,14 @@ describe("utils", function() { expect(prevString(highRoll)).toEqual(lowRoll); }); - it('should be reversible on small strings', () => { + it("should be reversible on small strings", () => { // Large scale reversibility is tested for max space order value const input = "cats"; expect(prevString(nextString(input))).toEqual(input); }); // We want to explicitly make sure that Space order values are supported and roll appropriately - it('should properly handle rolling over at 50 characters', () => { + it("should properly handle rolling over at 50 characters", () => { // Note: we also test reversibility of large strings here. const maxSpaceValue = DEFAULT_ALPHABET[DEFAULT_ALPHABET.length - 1].repeat(50); @@ -397,30 +408,30 @@ describe("utils", function() { }); }); - describe('lexicographicCompare', () => { - it('should work', () => { + describe("lexicographicCompare", () => { + it("should work", () => { // Simple tests - expect(lexicographicCompare('a', 'b') < 0).toBe(true); - expect(lexicographicCompare('ab', 'b') < 0).toBe(true); - expect(lexicographicCompare('cat', 'dog') < 0).toBe(true); + expect(lexicographicCompare("a", "b") < 0).toBe(true); + expect(lexicographicCompare("ab", "b") < 0).toBe(true); + expect(lexicographicCompare("cat", "dog") < 0).toBe(true); // Simple tests (reversed) - expect(lexicographicCompare('b', 'a') > 0).toBe(true); - expect(lexicographicCompare('b', 'ab') > 0).toBe(true); - expect(lexicographicCompare('dog', 'cat') > 0).toBe(true); + expect(lexicographicCompare("b", "a") > 0).toBe(true); + expect(lexicographicCompare("b", "ab") > 0).toBe(true); + expect(lexicographicCompare("dog", "cat") > 0).toBe(true); // Simple equality tests - expect(lexicographicCompare('a', 'a') === 0).toBe(true); - expect(lexicographicCompare('A', 'A') === 0).toBe(true); + expect(lexicographicCompare("a", "a") === 0).toBe(true); + expect(lexicographicCompare("A", "A") === 0).toBe(true); // ASCII rule testing - expect(lexicographicCompare('A', 'a') < 0).toBe(true); - expect(lexicographicCompare('a', 'A') > 0).toBe(true); + expect(lexicographicCompare("A", "a") < 0).toBe(true); + expect(lexicographicCompare("a", "A") > 0).toBe(true); }); }); - describe('deepSortedObjectEntries', () => { - it('should auto-return non-objects', () => { + describe("deepSortedObjectEntries", () => { + it("should auto-return non-objects", () => { expect(deepSortedObjectEntries(42)).toEqual(42); expect(deepSortedObjectEntries("not object")).toEqual("not object"); expect(deepSortedObjectEntries(true)).toEqual(true); @@ -429,7 +440,7 @@ describe("utils", function() { expect(deepSortedObjectEntries(undefined)).toEqual(undefined); }); - it('should sort objects appropriately', () => { + it("should sort objects appropriately", () => { const input = { a: 42, b: { @@ -442,11 +453,14 @@ describe("utils", function() { const output: any = [ ["72", "test"], ["a", 42], - ["b", [ - ["a", "test"], - ["b", "alpha"], - ["d", []], - ]], + [ + "b", + [ + ["a", "test"], + ["b", "alpha"], + ["d", []], + ], + ], ]; expect(deepSortedObjectEntries(input)).toMatchObject(output); @@ -455,12 +469,20 @@ describe("utils", function() { describe("recursivelyAssign", () => { it("doesn't override with null/undefined", () => { - const result = utils.recursivelyAssign( + const result = utils.recursivelyAssign< + { + string: string; + object: object; + float: number; + }, + {} + >( { string: "Hello world", object: {}, float: 0.1, - }, { + }, + { string: null, object: undefined, }, @@ -475,7 +497,14 @@ describe("utils", function() { }); it("assigns recursively", () => { - const result = utils.recursivelyAssign( + const result = utils.recursivelyAssign< + { + number: number; + object: object; + thing: string | object; + }, + {} + >( { number: 42, object: { @@ -486,7 +515,8 @@ describe("utils", function() { }, }, thing: "string", - }, { + }, + { number: 2, object: { message: "How are you", @@ -517,9 +547,9 @@ describe("utils", function() { }); }); - describe('sortEventsByLatestContentTimestamp', () => { - const roomId = '!room:server'; - const userId = '@user:server'; + describe("sortEventsByLatestContentTimestamp", () => { + const roomId = "!room:server"; + const userId = "@user:server"; const eventWithoutContentTimestamp = mkMessage({ room: roomId, user: userId, event: true }); // m.beacon events have timestamp in content const beaconEvent1 = makeBeaconEvent(userId, { timestamp: 1648804528557 }); @@ -527,32 +557,33 @@ describe("utils", function() { const beaconEvent3 = makeBeaconEvent(userId, { timestamp: 1648804528000 }); const beaconEvent4 = makeBeaconEvent(userId, { timestamp: 0 }); - it('sorts events with timestamps as later than events without', () => { + it("sorts events with timestamps as later than events without", () => { expect( - [beaconEvent4, eventWithoutContentTimestamp, beaconEvent1] - .sort(utils.sortEventsByLatestContentTimestamp), - ).toEqual([ - beaconEvent1, beaconEvent4, eventWithoutContentTimestamp, - ]); + [beaconEvent4, eventWithoutContentTimestamp, beaconEvent1].sort( + utils.sortEventsByLatestContentTimestamp, + ), + ).toEqual([beaconEvent1, beaconEvent4, eventWithoutContentTimestamp]); }); - it('sorts by content timestamps correctly', () => { - expect( - [beaconEvent1, beaconEvent2, beaconEvent3].sort(sortEventsByLatestContentTimestamp), - ).toEqual([beaconEvent2, beaconEvent1, beaconEvent3]); + it("sorts by content timestamps correctly", () => { + expect([beaconEvent1, beaconEvent2, beaconEvent3].sort(sortEventsByLatestContentTimestamp)).toEqual([ + beaconEvent2, + beaconEvent1, + beaconEvent3, + ]); }); }); - describe('isSupportedReceiptType', () => { - it('should support m.read', () => { + describe("isSupportedReceiptType", () => { + it("should support m.read", () => { expect(utils.isSupportedReceiptType(ReceiptType.Read)).toBeTruthy(); }); - it('should support m.read.private', () => { + it("should support m.read.private", () => { expect(utils.isSupportedReceiptType(ReceiptType.ReadPrivate)).toBeTruthy(); }); - it('should not support other receipt types', () => { + it("should not support other receipt types", () => { expect(utils.isSupportedReceiptType("this is a receipt type")).toBeFalsy(); }); }); diff --git a/spec/unit/webrtc/call.spec.ts b/spec/unit/webrtc/call.spec.ts index ee52a518a36..9ddf880aba9 100644 --- a/spec/unit/webrtc/call.spec.ts +++ b/spec/unit/webrtc/call.spec.ts @@ -16,7 +16,7 @@ limitations under the License. import { mocked } from "jest-mock"; -import { TestClient } from '../../TestClient'; +import { TestClient } from "../../TestClient"; import { MatrixCall, CallErrorCode, @@ -25,14 +25,14 @@ import { CallType, CallState, CallParty, -} from '../../../src/webrtc/call'; +} from "../../../src/webrtc/call"; import { MCallAnswer, MCallHangupReject, SDPStreamMetadata, SDPStreamMetadataKey, SDPStreamMetadataPurpose, -} from '../../../src/webrtc/callEventTypes'; +} from "../../../src/webrtc/callEventTypes"; import { DUMMY_SDP, MockMediaHandler, @@ -78,17 +78,19 @@ const fakeIncomingCall = async (client: TestClient, call: MatrixCall, version: s getSender: () => "@test:foo", getLocalAge: () => 1, } as unknown as MatrixEvent); - call.getFeeds().push(new CallFeed({ - client: client.client, - userId: "remote_user_id", - deviceId: undefined, - stream: new MockMediaStream( - "remote_stream_id", [new MockMediaStreamTrack("remote_tack_id", "audio")], - ) as unknown as MediaStream, - purpose: SDPStreamMetadataPurpose.Usermedia, - audioMuted: false, - videoMuted: false, - })); + call.getFeeds().push( + new CallFeed({ + client: client.client, + userId: "remote_user_id", + deviceId: undefined, + stream: new MockMediaStream("remote_stream_id", [ + new MockMediaStreamTrack("remote_tack_id", "audio"), + ]) as unknown as MediaStream, + purpose: SDPStreamMetadataPurpose.Usermedia, + audioMuted: false, + videoMuted: false, + }), + ); await callPromise; }; @@ -101,7 +103,7 @@ function makeMockEvent(sender: string, content: Record): MatrixEven } as MatrixEvent; } -describe('Call', function() { +describe("Call", function () { let client: TestClient; let call: MatrixCall; let prevNavigator: Navigator; @@ -112,7 +114,7 @@ describe('Call', function() { const errorListener = () => {}; - beforeEach(function() { + beforeEach(function () { prevNavigator = global.navigator; prevDocument = global.document; prevWindow = global.window; @@ -125,8 +127,8 @@ describe('Call', function() { client.client.sendEvent = mockSendEvent = jest.fn(); { // in which we do naughty assignments to private members - const untypedClient = (client.client as any); - untypedClient.mediaHandler = new MockMediaHandler; + const untypedClient = client.client as any; + untypedClient.mediaHandler = new MockMediaHandler(); untypedClient.turnServersExpiry = Date.now() + 60 * 60 * 1000; } @@ -148,7 +150,7 @@ describe('Call', function() { call.on(CallEvent.Error, errorListener); }); - afterEach(function() { + afterEach(function () { // Hangup to stop timers call.hangup(CallErrorCode.UserHangup, true); @@ -160,115 +162,131 @@ describe('Call', function() { jest.useRealTimers(); }); - it('should ignore candidate events from non-matching party ID', async function() { + it("should ignore candidate events from non-matching party ID", async function () { await startVoiceCall(client, call); - await call.onAnswerReceived(makeMockEvent("@test:foo", { - version: 1, - call_id: call.callId, - party_id: 'the_correct_party_id', - answer: { - sdp: DUMMY_SDP, - }, - })); - - const mockAddIceCandidate = call.peerConn!.addIceCandidate = jest.fn(); - call.onRemoteIceCandidatesReceived(makeMockEvent("@test:foo", { - version: 1, - call_id: call.callId, - party_id: 'the_correct_party_id', - candidates: [ - { - candidate: '', - sdpMid: '', + await call.onAnswerReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "the_correct_party_id", + answer: { + sdp: DUMMY_SDP, }, - ], - })); + }), + ); + + const mockAddIceCandidate = (call.peerConn!.addIceCandidate = jest.fn()); + call.onRemoteIceCandidatesReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "the_correct_party_id", + candidates: [ + { + candidate: "", + sdpMid: "", + }, + ], + }), + ); expect(mockAddIceCandidate).toHaveBeenCalled(); - call.onRemoteIceCandidatesReceived(makeMockEvent("@test:foo", { - version: 1, - call_id: call.callId, - party_id: 'some_other_party_id', - candidates: [ - { - candidate: '', - sdpMid: '', - }, - ], - })); + call.onRemoteIceCandidatesReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "some_other_party_id", + candidates: [ + { + candidate: "", + sdpMid: "", + }, + ], + }), + ); expect(mockAddIceCandidate).toHaveBeenCalled(); }); - it('should add candidates received before answer if party ID is correct', async function() { + it("should add candidates received before answer if party ID is correct", async function () { await startVoiceCall(client, call); - const mockAddIceCandidate = call.peerConn!.addIceCandidate = jest.fn(); - - call.onRemoteIceCandidatesReceived(makeMockEvent("@test:foo", { - version: 1, - call_id: call.callId, - party_id: 'the_correct_party_id', - candidates: [ - { - candidate: 'the_correct_candidate', - sdpMid: '', - }, - ], - })); - - call.onRemoteIceCandidatesReceived(makeMockEvent("@test:foo", { - version: 1, - call_id: call.callId, - party_id: 'some_other_party_id', - candidates: [ - { - candidate: 'the_wrong_candidate', - sdpMid: '', - }, - ], - })); + const mockAddIceCandidate = (call.peerConn!.addIceCandidate = jest.fn()); + + call.onRemoteIceCandidatesReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "the_correct_party_id", + candidates: [ + { + candidate: "the_correct_candidate", + sdpMid: "", + }, + ], + }), + ); + + call.onRemoteIceCandidatesReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "some_other_party_id", + candidates: [ + { + candidate: "the_wrong_candidate", + sdpMid: "", + }, + ], + }), + ); expect(mockAddIceCandidate).not.toHaveBeenCalled(); - await call.onAnswerReceived(makeMockEvent("@test:foo", { - version: 1, - call_id: call.callId, - party_id: 'the_correct_party_id', - answer: { - sdp: DUMMY_SDP, - }, - })); + await call.onAnswerReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "the_correct_party_id", + answer: { + sdp: DUMMY_SDP, + }, + }), + ); expect(mockAddIceCandidate).toHaveBeenCalled(); expect(mockAddIceCandidate).toHaveBeenCalledWith({ - candidate: 'the_correct_candidate', - sdpMid: '', + candidate: "the_correct_candidate", + sdpMid: "", }); }); - it('should map asserted identity messages to remoteAssertedIdentity', async function() { + it("should map asserted identity messages to remoteAssertedIdentity", async function () { await startVoiceCall(client, call); - await call.onAnswerReceived(makeMockEvent("@test:foo", { - version: 1, - call_id: call.callId, - party_id: 'party_id', - answer: { - sdp: DUMMY_SDP, - }, - })); + await call.onAnswerReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "party_id", + answer: { + sdp: DUMMY_SDP, + }, + }), + ); const identChangedCallback = jest.fn(); call.on(CallEvent.AssertedIdentityChanged, identChangedCallback); - await call.onAssertedIdentityReceived(makeMockEvent("@test:foo", { - version: 1, - call_id: call.callId, - party_id: 'party_id', - asserted_identity: { - id: "@steve:example.com", - display_name: "Steve Gibbons", - }, - })); + await call.onAssertedIdentityReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "party_id", + asserted_identity: { + id: "@steve:example.com", + display_name: "Steve Gibbons", + }, + }), + ); expect(identChangedCallback).toHaveBeenCalled(); @@ -280,30 +298,29 @@ describe('Call', function() { it("should map SDPStreamMetadata to feeds", async () => { await startVoiceCall(client, call); - await call.onAnswerReceived(makeMockEvent("@test:foo", { - version: 1, - call_id: call.callId, - party_id: 'party_id', - answer: { - sdp: DUMMY_SDP, - }, - [SDPStreamMetadataKey]: { - "remote_stream": { - purpose: SDPStreamMetadataPurpose.Usermedia, - audio_muted: true, - video_muted: false, + await call.onAnswerReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "party_id", + answer: { + sdp: DUMMY_SDP, }, - }, - })); + [SDPStreamMetadataKey]: { + remote_stream: { + purpose: SDPStreamMetadataPurpose.Usermedia, + audio_muted: true, + video_muted: false, + }, + }, + }), + ); (call as any).pushRemoteFeed( - new MockMediaStream( - "remote_stream", - [ - new MockMediaStreamTrack("remote_audio_track", "audio"), - new MockMediaStreamTrack("remote_video_track", "video"), - ], - ), + new MockMediaStream("remote_stream", [ + new MockMediaStreamTrack("remote_audio_track", "audio"), + new MockMediaStreamTrack("remote_video_track", "video"), + ]), ); const feed = call.getFeeds().find((feed) => feed.stream.id === "remote_stream"); expect(feed?.purpose).toBe(SDPStreamMetadataPurpose.Usermedia); @@ -314,16 +331,18 @@ describe('Call', function() { it("should fallback to replaceTrack() if the other side doesn't support SPDStreamMetadata", async () => { await startVoiceCall(client, call); - await call.onAnswerReceived(makeMockEvent("@test:foo", { - version: 1, - call_id: call.callId, - party_id: 'party_id', - answer: { - sdp: DUMMY_SDP, - }, - })); + await call.onAnswerReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "party_id", + answer: { + sdp: DUMMY_SDP, + }, + }), + ); - const mockScreenshareNoMetadata = (call as any).setScreensharingEnabledWithoutMetadataSupport = jest.fn(); + const mockScreenshareNoMetadata = ((call as any).setScreensharingEnabledWithoutMetadataSupport = jest.fn()); call.setScreensharingEnabled(true); expect(mockScreenshareNoMetadata).toHaveBeenCalled(); @@ -342,34 +361,33 @@ describe('Call', function() { }); it("should handle mid-call device changes", async () => { - client.client.getMediaHandler().getUserMediaStream = jest.fn().mockReturnValue( - new MockMediaStream( - "stream", [ + client.client.getMediaHandler().getUserMediaStream = jest + .fn() + .mockReturnValue( + new MockMediaStream("stream", [ new MockMediaStreamTrack("audio_track", "audio"), new MockMediaStreamTrack("video_track", "video"), - ], - ), - ); + ]), + ); await startVoiceCall(client, call); - await call.onAnswerReceived(makeMockEvent("@test:foo", { - version: 1, - call_id: call.callId, - party_id: 'party_id', - answer: { - sdp: DUMMY_SDP, - }, - })); + await call.onAnswerReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "party_id", + answer: { + sdp: DUMMY_SDP, + }, + }), + ); await call.updateLocalUsermediaStream( - new MockMediaStream( - "replacement_stream", - [ - new MockMediaStreamTrack("new_audio_track", "audio"), - new MockMediaStreamTrack("video_track", "video"), - ], - ).typed(), + new MockMediaStream("replacement_stream", [ + new MockMediaStreamTrack("new_audio_track", "audio"), + new MockMediaStreamTrack("video_track", "video"), + ]).typed(), ); // XXX: Lots of inspecting the prvate state of the call object here @@ -386,15 +404,17 @@ describe('Call', function() { it("should handle upgrade to video call", async () => { await startVoiceCall(client, call); - await call.onAnswerReceived(makeMockEvent("@test:foo", { - version: 1, - call_id: call.callId, - party_id: 'party_id', - answer: { - sdp: DUMMY_SDP, - }, - [SDPStreamMetadataKey]: {}, - })); + await call.onAnswerReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "party_id", + answer: { + sdp: DUMMY_SDP, + }, + [SDPStreamMetadataKey]: {}, + }), + ); // XXX Should probably test using the public interfaces, ie. // setLocalVideoMuted probably? @@ -413,7 +433,7 @@ describe('Call', function() { await startVoiceCall(client, call); (call as any).updateRemoteSDPStreamMetadata({ - "remote_stream": { + remote_stream: { purpose: SDPStreamMetadataPurpose.Usermedia, audio_muted: false, video_muted: false, @@ -422,16 +442,18 @@ describe('Call', function() { (call as any).pushRemoteFeed(new MockMediaStream("remote_stream", [])); const feed = call.getFeeds().find((feed) => feed.stream.id === "remote_stream"); - call.onSDPStreamMetadataChangedReceived(makeMockEvent("@test:foo", { - [SDPStreamMetadataKey]: { - "remote_stream": { - purpose: SDPStreamMetadataPurpose.Screenshare, - audio_muted: true, - video_muted: true, - id: "feed_id2", + call.onSDPStreamMetadataChangedReceived( + makeMockEvent("@test:foo", { + [SDPStreamMetadataKey]: { + remote_stream: { + purpose: SDPStreamMetadataPurpose.Screenshare, + audio_muted: true, + video_muted: true, + id: "feed_id2", + }, }, - }, - })); + }), + ); expect(feed?.purpose).toBe(SDPStreamMetadataPurpose.Screenshare); expect(feed?.isAudioMuted()).toBe(true); @@ -462,11 +484,13 @@ describe('Call', function() { "m.call.transferee": true, "m.call.dtmf": false, }; - (call as any).chooseOpponent(makeMockEvent(opponentMember.userId, { - version: 1, - party_id: "party_id", - capabilities: opponentCaps, - })); + (call as any).chooseOpponent( + makeMockEvent(opponentMember.userId, { + version: 1, + party_id: "party_id", + capabilities: opponentCaps, + }), + ); expect(call.getOpponentMember()).toBe(opponentMember); expect((call as any).opponentPartyId).toBe("party_id"); @@ -522,40 +546,42 @@ describe('Call', function() { }); it("should correctly generate local SDPStreamMetadata", async () => { - const callPromise = call.placeCallWithCallFeeds([new CallFeed({ - client: client.client, - stream: new MockMediaStream( - "local_stream1", [new MockMediaStreamTrack("track_id", "audio")], - ) as unknown as MediaStream, - roomId: call.roomId, - userId: client.getUserId(), - deviceId: undefined, - purpose: SDPStreamMetadataPurpose.Usermedia, - audioMuted: false, - videoMuted: false, - })]); + const callPromise = call.placeCallWithCallFeeds([ + new CallFeed({ + client: client.client, + stream: new MockMediaStream("local_stream1", [ + new MockMediaStreamTrack("track_id", "audio"), + ]) as unknown as MediaStream, + roomId: call.roomId, + userId: client.getUserId(), + deviceId: undefined, + purpose: SDPStreamMetadataPurpose.Usermedia, + audioMuted: false, + videoMuted: false, + }), + ]); await client.httpBackend!.flush(""); await callPromise; call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" }); (call as any).pushNewLocalFeed( - new MockMediaStream( - "local_stream2", [new MockMediaStreamTrack("track_id", "video")], - ) as unknown as MediaStream, + new MockMediaStream("local_stream2", [ + new MockMediaStreamTrack("track_id", "video"), + ]) as unknown as MediaStream, SDPStreamMetadataPurpose.Screenshare, ); await call.setMicrophoneMuted(true); expect((call as any).getLocalSDPStreamMetadata()).toStrictEqual({ - "local_stream1": { - "purpose": SDPStreamMetadataPurpose.Usermedia, - "audio_muted": true, - "video_muted": true, + local_stream1: { + purpose: SDPStreamMetadataPurpose.Usermedia, + audio_muted: true, + video_muted: true, }, - "local_stream2": { - "purpose": SDPStreamMetadataPurpose.Screenshare, - "audio_muted": true, - "video_muted": false, + local_stream2: { + purpose: SDPStreamMetadataPurpose.Screenshare, + audio_muted: true, + video_muted: false, }, }); }); @@ -591,12 +617,12 @@ describe('Call', function() { call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" }); (call as any).updateRemoteSDPStreamMetadata({ - "remote_usermedia_stream_id": { + remote_usermedia_stream_id: { purpose: SDPStreamMetadataPurpose.Usermedia, audio_muted: false, video_muted: false, }, - "remote_screensharing_stream_id": { + remote_screensharing_stream_id: { purpose: SDPStreamMetadataPurpose.Screenshare, id: "remote_screensharing_feed_id", audio_muted: false, @@ -623,12 +649,14 @@ describe('Call', function() { const callHangupCallback = jest.fn(); call.on(CallEvent.Hangup, callHangupCallback); - await call.onSelectAnswerReceived(makeMockEvent("@test:foo.bar", { - version: 1, - call_id: call.callId, - party_id: 'party_id', - selected_party_id: "different_party_id", - })); + await call.onSelectAnswerReceived( + makeMockEvent("@test:foo.bar", { + version: 1, + call_id: call.callId, + party_id: "party_id", + selected_party_id: "different_party_id", + }), + ); expect(callHangupCallback).toHaveBeenCalled(); }); @@ -638,7 +666,7 @@ describe('Call', function() { client.client.isFallbackICEServerAllowed = () => true; const localCall = new MatrixCall({ client: client.client, - roomId: '!room_id', + roomId: "!room_id", }); expect((localCall as any).turnServers).toStrictEqual([{ urls: ["stun:turn.matrix.org"] }]); @@ -648,7 +676,7 @@ describe('Call', function() { client.client.isFallbackICEServerAllowed = () => false; const localCall = new MatrixCall({ client: client.client, - roomId: '!room_id', + roomId: "!room_id", }); expect((localCall as any).turnServers).toStrictEqual([]); @@ -659,7 +687,7 @@ describe('Call', function() { const turnServers = [{ urls: ["turn.server.org"] }]; const localCall = new MatrixCall({ client: client.client, - roomId: '!room_id', + roomId: "!room_id", turnServers, }); @@ -704,16 +732,18 @@ describe('Call', function() { expect(supportsMatrixCall()).toBe(false); }); - it("should return false if RTCPeerConnection & RTCSessionDescription " + - "& RTCIceCandidate & mediaDevices are unavailable", - () => { - global.window.RTCPeerConnection = undefined!; - global.window.RTCSessionDescription = undefined!; - global.window.RTCIceCandidate = undefined!; - // @ts-ignore - writing to a read-only property as we are simulating faulty browsers - global.navigator.mediaDevices = undefined; - expect(supportsMatrixCall()).toBe(false); - }); + it( + "should return false if RTCPeerConnection & RTCSessionDescription " + + "& RTCIceCandidate & mediaDevices are unavailable", + () => { + global.window.RTCPeerConnection = undefined!; + global.window.RTCSessionDescription = undefined!; + global.window.RTCIceCandidate = undefined!; + // @ts-ignore - writing to a read-only property as we are simulating faulty browsers + global.navigator.mediaDevices = undefined; + expect(supportsMatrixCall()).toBe(false); + }, + ); }); describe("ignoring streams with ids for which we already have a feed", () => { @@ -733,19 +763,21 @@ describe('Call', function() { }); it("should ignore stream passed to pushRemoteFeed()", async () => { - await call.onAnswerReceived(makeMockEvent("@test:foo", { - version: 1, - call_id: call.callId, - party_id: 'party_id', - answer: { - sdp: DUMMY_SDP, - }, - [SDPStreamMetadataKey]: { - [STREAM_ID]: { - purpose: SDPStreamMetadataPurpose.Usermedia, + await call.onAnswerReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "party_id", + answer: { + sdp: DUMMY_SDP, }, - }, - })); + [SDPStreamMetadataKey]: { + [STREAM_ID]: { + purpose: SDPStreamMetadataPurpose.Usermedia, + }, + }, + }), + ); (call as any).pushRemoteFeed(new MockMediaStream(STREAM_ID)); (call as any).pushRemoteFeed(new MockMediaStream(STREAM_ID)); @@ -780,9 +812,13 @@ describe('Call', function() { await call.transferToCall(targetCall); const newCallId = (sendEvent.mock.calls[0][2] as any)!.await_call; - expect(sendEvent).toHaveBeenCalledWith(call.roomId, EventType.CallReplaces, expect.objectContaining({ - create_call: newCallId, - })); + expect(sendEvent).toHaveBeenCalledWith( + call.roomId, + EventType.CallReplaces, + expect.objectContaining({ + create_call: newCallId, + }), + ); }); }); @@ -830,10 +866,12 @@ describe('Call', function() { video_muted: video, }, }; - (call as any).pushRemoteFeed(new MockMediaStream("stream", [ - new MockMediaStreamTrack("track1", "audio"), - new MockMediaStreamTrack("track1", "video"), - ])); + (call as any).pushRemoteFeed( + new MockMediaStream("stream", [ + new MockMediaStreamTrack("track1", "audio"), + new MockMediaStreamTrack("track1", "video"), + ]), + ); call.onSDPStreamMetadataChangedReceived({ getContent: () => ({ [SDPStreamMetadataKey]: metadata, @@ -933,12 +971,12 @@ describe('Call', function() { await fakeIncomingCall(client, call, "1"); }); - const untilEventSent = async (...args) => { + const untilEventSent = async (...args: any[]) => { const maxTries = 20; for (let tries = 0; tries < maxTries; ++tries) { if (tries) { - await new Promise(resolve => { + await new Promise((resolve) => { realSetTimeout(resolve, 100); }); } @@ -964,31 +1002,27 @@ describe('Call', function() { expect.objectContaining({ call_id: call.callId, answer: expect.objectContaining({ - type: 'offer', + type: "offer", }), }), ); }); describe("ICE candidate sending", () => { - let mockPeerConn; + let mockPeerConn: MockRTCPeerConnection; const fakeCandidateString = "here is a fake candidate!"; const fakeCandidateEvent = { candidate: { candidate: fakeCandidateString, sdpMLineIndex: 0, - sdpMid: '0', + sdpMid: "0", toJSON: jest.fn().mockReturnValue(fakeCandidateString), }, } as unknown as RTCPeerConnectionIceEvent; beforeEach(async () => { await call.answer(); - await untilEventSent( - FAKE_ROOM_ID, - EventType.CallAnswer, - expect.objectContaining({}), - ); + await untilEventSent(FAKE_ROOM_ID, EventType.CallAnswer, expect.objectContaining({})); mockPeerConn = call.peerConn as unknown as MockRTCPeerConnection; }); @@ -1003,9 +1037,7 @@ describe('Call', function() { FAKE_ROOM_ID, EventType.CallCandidates, expect.objectContaining({ - candidates: [ - fakeCandidateString, - ], + candidates: [fakeCandidateString], }), ); }); @@ -1021,9 +1053,7 @@ describe('Call', function() { FAKE_ROOM_ID, EventType.CallCandidates, expect.objectContaining({ - candidates: [ - fakeCandidateString, - ], + candidates: [fakeCandidateString], }), ); @@ -1033,9 +1063,7 @@ describe('Call', function() { FAKE_ROOM_ID, EventType.CallCandidates, expect.objectContaining({ - candidates: [ - fakeCandidateString, - ], + candidates: [fakeCandidateString], }), ); }); @@ -1059,9 +1087,7 @@ describe('Call', function() { FAKE_ROOM_ID, EventType.CallCandidates, expect.objectContaining({ - candidates: [ - fakeCandidateString, - ], + candidates: [fakeCandidateString], }), ); if (!call.callHasEnded) { @@ -1086,7 +1112,7 @@ describe('Call', function() { }); describe("Screen sharing", () => { - const waitNegotiateFunc = resolve => { + const waitNegotiateFunc = (resolve: Function): void => { mockSendEvent.mockImplementationOnce(() => { // Note that the peer connection here is a dummy one and always returns // dummy SDP, so there's not much point returning the content: the SDP will @@ -1104,21 +1130,23 @@ describe('Call', function() { MockRTCPeerConnection.triggerAllNegotiations(); await sendNegotiatePromise; - await call.onAnswerReceived(makeMockEvent("@test:foo", { - "version": 1, - "call_id": call.callId, - "party_id": 'party_id', - "answer": { - sdp: DUMMY_SDP, - }, - "org.matrix.msc3077.sdp_stream_metadata": { - "foo": { - "purpose": "m.usermedia", - "audio_muted": false, - "video_muted": false, + await call.onAnswerReceived( + makeMockEvent("@test:foo", { + "version": 1, + "call_id": call.callId, + "party_id": "party_id", + "answer": { + sdp: DUMMY_SDP, }, - }, - })); + "org.matrix.msc3077.sdp_stream_metadata": { + foo: { + purpose: "m.usermedia", + audio_muted: false, + video_muted: false, + }, + }, + }), + ); }); afterEach(() => { @@ -1129,9 +1157,9 @@ describe('Call', function() { it("enables and disables screensharing", async () => { await call.setScreensharingEnabled(true); - expect( - call.getLocalFeeds().filter(f => f.purpose == SDPStreamMetadataPurpose.Screenshare), - ).toHaveLength(1); + expect(call.getLocalFeeds().filter((f) => f.purpose == SDPStreamMetadataPurpose.Screenshare)).toHaveLength( + 1, + ); mockSendEvent.mockReset(); const sendNegotiatePromise = new Promise(waitNegotiateFunc); @@ -1155,9 +1183,9 @@ describe('Call', function() { await call.setScreensharingEnabled(false); - expect( - call.getLocalFeeds().filter(f => f.purpose == SDPStreamMetadataPurpose.Screenshare), - ).toHaveLength(0); + expect(call.getLocalFeeds().filter((f) => f.purpose == SDPStreamMetadataPurpose.Screenshare)).toHaveLength( + 0, + ); }); it("removes RTX codec from screen sharing transcievers", async () => { @@ -1221,47 +1249,49 @@ describe('Call', function() { it("falls back to replaceTrack for opponents that don't support stream metadata", async () => { await startVideoCall(client, call); - await call.onAnswerReceived(makeMockEvent("@test:foo", { - "version": 1, - "call_id": call.callId, - "party_id": 'party_id', - "answer": { - sdp: DUMMY_SDP, - }, - })); + await call.onAnswerReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "party_id", + answer: { + sdp: DUMMY_SDP, + }, + }), + ); MockRTCPeerConnection.triggerAllNegotiations(); - const mockVideoSender = call.peerConn!.getSenders().find(s => s.track!.kind === "video"); - const mockReplaceTrack = mockVideoSender!.replaceTrack = jest.fn(); + const mockVideoSender = call.peerConn!.getSenders().find((s) => s.track!.kind === "video"); + const mockReplaceTrack = (mockVideoSender!.replaceTrack = jest.fn()); await call.setScreensharingEnabled(true); // our local feed should still reflect the purpose of the feed (ie. screenshare) - expect( - call.getLocalFeeds().filter(f => f.purpose == SDPStreamMetadataPurpose.Screenshare).length, - ).toEqual(1); + expect(call.getLocalFeeds().filter((f) => f.purpose == SDPStreamMetadataPurpose.Screenshare).length).toEqual(1); // but we should not have re-negotiated expect(MockRTCPeerConnection.hasAnyPendingNegotiations()).toEqual(false); - expect(mockReplaceTrack).toHaveBeenCalledWith(expect.objectContaining({ - id: "screenshare_video_track", - })); + expect(mockReplaceTrack).toHaveBeenCalledWith( + expect.objectContaining({ + id: "screenshare_video_track", + }), + ); mockReplaceTrack.mockClear(); await call.setScreensharingEnabled(false); - expect( - call.getLocalFeeds().filter(f => f.purpose == SDPStreamMetadataPurpose.Screenshare), - ).toHaveLength(0); + expect(call.getLocalFeeds().filter((f) => f.purpose == SDPStreamMetadataPurpose.Screenshare)).toHaveLength(0); expect(call.getLocalFeeds()).toHaveLength(1); expect(MockRTCPeerConnection.hasAnyPendingNegotiations()).toEqual(false); - expect(mockReplaceTrack).toHaveBeenCalledWith(expect.objectContaining({ - id: "usermedia_video_track", - })); + expect(mockReplaceTrack).toHaveBeenCalledWith( + expect.objectContaining({ + id: "usermedia_video_track", + }), + ); }); describe("call transfers", () => { @@ -1301,7 +1331,7 @@ describe('Call', function() { const newCallHangupListener = jest.fn(); call.on(CallEvent.Hangup, callHangupListener); - newCall.on(CallEvent.Error, () => { }); + newCall.on(CallEvent.Error, () => {}); newCall.on(CallEvent.Hangup, newCallHangupListener); await startVoiceCall(client, call, ALICE_USER_ID); @@ -1309,20 +1339,28 @@ describe('Call', function() { await call.transferToCall(newCall); - expect(mockSendEvent).toHaveBeenCalledWith(FAKE_ROOM_ID, EventType.CallReplaces, expect.objectContaining({ - target_user: { - id: ALICE_USER_ID, - display_name: ALICE_DISPLAY_NAME, - avatar_url: ALICE_AVATAR_URL, - }, - })); - expect(mockSendEvent).toHaveBeenCalledWith(FAKE_ROOM_ID, EventType.CallReplaces, expect.objectContaining({ - target_user: { - id: BOB_USER_ID, - display_name: BOB_DISPLAY_NAME, - avatar_url: BOB_AVATAR_URL, - }, - })); + expect(mockSendEvent).toHaveBeenCalledWith( + FAKE_ROOM_ID, + EventType.CallReplaces, + expect.objectContaining({ + target_user: { + id: ALICE_USER_ID, + display_name: ALICE_DISPLAY_NAME, + avatar_url: ALICE_AVATAR_URL, + }, + }), + ); + expect(mockSendEvent).toHaveBeenCalledWith( + FAKE_ROOM_ID, + EventType.CallReplaces, + expect.objectContaining({ + target_user: { + id: BOB_USER_ID, + display_name: BOB_DISPLAY_NAME, + avatar_url: BOB_AVATAR_URL, + }, + }), + ); expect(callHangupListener).toHaveBeenCalledWith(call); expect(newCallHangupListener).toHaveBeenCalledWith(newCall); @@ -1335,13 +1373,17 @@ describe('Call', function() { await startVoiceCall(client, call, ALICE_USER_ID); await call.transfer(BOB_USER_ID); - expect(mockSendEvent).toHaveBeenCalledWith(FAKE_ROOM_ID, EventType.CallReplaces, expect.objectContaining({ - target_user: { - id: BOB_USER_ID, - display_name: BOB_DISPLAY_NAME, - avatar_url: BOB_AVATAR_URL, - }, - })); + expect(mockSendEvent).toHaveBeenCalledWith( + FAKE_ROOM_ID, + EventType.CallReplaces, + expect.objectContaining({ + target_user: { + id: BOB_USER_ID, + display_name: BOB_DISPLAY_NAME, + avatar_url: BOB_AVATAR_URL, + }, + }), + ); // @ts-ignore Mock expect(call.terminate).toHaveBeenCalledWith(CallParty.Local, CallErrorCode.Transfered, true); }); @@ -1354,9 +1396,10 @@ describe('Call', function() { await call.placeVoiceCall(); - (call.peerConn as unknown as MockRTCPeerConnection).onTrackListener!( - { streams: [], track: new MockMediaStreamTrack("track_ev", "audio") } as unknown as RTCTrackEvent, - ); + (call.peerConn as unknown as MockRTCPeerConnection).onTrackListener!({ + streams: [], + track: new MockMediaStreamTrack("track_ev", "audio"), + } as unknown as RTCTrackEvent); // @ts-ignore Mock pushRemoteFeed() is private expect(call.pushRemoteFeed).not.toHaveBeenCalled(); @@ -1367,19 +1410,22 @@ describe('Call', function() { jest.spyOn(call, "pushRemoteFeed"); await call.placeVoiceCall(); - await call.onAnswerReceived(makeMockEvent("@test:foo", { - version: 1, - call_id: call.callId, - party_id: 'the_correct_party_id', - answer: { - sdp: DUMMY_SDP, - }, - })); + await call.onAnswerReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "the_correct_party_id", + answer: { + sdp: DUMMY_SDP, + }, + }), + ); const stream = new MockMediaStream("stream_ev", [new MockMediaStreamTrack("track_ev", "audio")]); - (call.peerConn as unknown as MockRTCPeerConnection).onTrackListener!( - { streams: [stream], track: stream.getAudioTracks()[0] } as unknown as RTCTrackEvent, - ); + (call.peerConn as unknown as MockRTCPeerConnection).onTrackListener!({ + streams: [stream], + track: stream.getAudioTracks()[0], + } as unknown as RTCTrackEvent); // @ts-ignore Mock pushRemoteFeed() is private expect(call.pushRemoteFeed).toHaveBeenCalledWith(stream); @@ -1392,7 +1438,7 @@ describe('Call', function() { it("ends call on onHangupReceived() if state is ringing", async () => { expect(call.callHasEnded()).toBe(false); - call.state = CallState.Ringing; + (call as any).state = CallState.Ringing; call.onHangupReceived({} as MCallHangupReject); expect(call.callHasEnded()).toBe(true); @@ -1419,18 +1465,19 @@ describe('Call', function() { }); }); - it.each( - Object.values(CallState), - )("ends call on onRejectReceived() if in correct state (state=%s)", async (state: CallState) => { - expect(call.callHasEnded()).toBe(false); + it.each(Object.values(CallState))( + "ends call on onRejectReceived() if in correct state (state=%s)", + async (state: CallState) => { + expect(call.callHasEnded()).toBe(false); - call.state = state; - call.onRejectReceived({} as MCallHangupReject); + (call as any).state = state; + call.onRejectReceived({} as MCallHangupReject); - expect(call.callHasEnded()).toBe( - [CallState.InviteSent, CallState.Ringing, CallState.Ended].includes(state), - ); - }); + expect(call.callHasEnded()).toBe( + [CallState.InviteSent, CallState.Ringing, CallState.Ended].includes(state), + ); + }, + ); it("terminates call when answered elsewhere", async () => { await call.placeVoiceCall(); @@ -1458,4 +1505,50 @@ describe('Call', function() { expect(call.hasPeerConnection).toBe(true); }); }); + + it("should correctly emit LengthChanged", async () => { + const advanceByArray = [2, 3, 5]; + const lengthChangedListener = jest.fn(); + + jest.useFakeTimers(); + call.addListener(CallEvent.LengthChanged, lengthChangedListener); + await fakeIncomingCall(client, call, "1"); + (call.peerConn as unknown as MockRTCPeerConnection).iceConnectionStateChangeListener!(); + + let hasAdvancedBy = 0; + for (const advanceBy of advanceByArray) { + jest.advanceTimersByTime(advanceBy * 1000); + hasAdvancedBy += advanceBy; + + expect(lengthChangedListener).toHaveBeenCalledTimes(hasAdvancedBy); + expect(lengthChangedListener).toBeCalledWith(hasAdvancedBy); + } + }); + + describe("ICE disconnected timeout", () => { + let mockPeerConn: MockRTCPeerConnection; + + beforeEach(async () => { + jest.useFakeTimers(); + jest.spyOn(call, "hangup"); + + await fakeIncomingCall(client, call, "1"); + + mockPeerConn = call.peerConn as unknown as MockRTCPeerConnection; + mockPeerConn.iceConnectionState = "disconnected"; + mockPeerConn.iceConnectionStateChangeListener!(); + }); + + it("should hang up after being disconnected for 30 seconds", () => { + jest.advanceTimersByTime(31 * 1000); + expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false); + }); + + it("should not hangup if we've managed to re-connect", () => { + mockPeerConn.iceConnectionState = "connected"; + mockPeerConn.iceConnectionStateChangeListener!(); + jest.advanceTimersByTime(31 * 1000); + expect(call.hangup).not.toHaveBeenCalled(); + }); + }); }); diff --git a/spec/unit/webrtc/callEventHandler.spec.ts b/spec/unit/webrtc/callEventHandler.spec.ts index b7687cdc2fc..82826363b83 100644 --- a/spec/unit/webrtc/callEventHandler.spec.ts +++ b/spec/unit/webrtc/callEventHandler.spec.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { TestClient } from '../../TestClient'; +import { TestClient } from "../../TestClient"; import { ClientEvent, EventTimeline, @@ -166,14 +166,20 @@ describe("CallEventHandler", () => { const room = new Room("!room:id", client, "@user:id"); const timelineData: IRoomTimelineData = { timeline: new EventTimeline(new EventTimelineSet(room, {})) }; - client.emit(RoomEvent.Timeline, new MatrixEvent({ - type: EventType.RoomMessage, - room_id: "!room:id", - content: { - text: "hello", - - }, - }), room, false, false, timelineData); + client.emit( + RoomEvent.Timeline, + new MatrixEvent({ + type: EventType.RoomMessage, + room_id: "!room:id", + content: { + text: "hello", + }, + }), + room, + false, + false, + timelineData, + ); await sync(); // @ts-ignore Mock handleCallEvent is private @@ -202,13 +208,20 @@ describe("CallEventHandler", () => { }); it("should create a call when receiving an invite", async () => { - client.emit(RoomEvent.Timeline, new MatrixEvent({ - type: EventType.CallInvite, - room_id: "!room:id", - content: { - call_id: "123", - }, - }), room, false, false, timelineData); + client.emit( + RoomEvent.Timeline, + new MatrixEvent({ + type: EventType.CallInvite, + room_id: "!room:id", + content: { + call_id: "123", + }, + }), + room, + false, + false, + timelineData, + ); await sync(); expect(incomingCallListener).toHaveBeenCalled(); @@ -226,23 +239,30 @@ describe("CallEventHandler", () => { const GROUP_CALL_ID = "group_call_id"; const DEVICE_ID = "device_id"; - incomingCallListener.mockImplementation((c) => call = c); + incomingCallListener.mockImplementation((c) => (call = c)); jest.spyOn(client.groupCallEventHandler!, "getGroupCallById").mockReturnValue(groupCall); // @ts-ignore Mock onIncomingCall is private jest.spyOn(groupCall, "onIncomingCall"); await groupCall.enter(); - client.emit(RoomEvent.Timeline, new MatrixEvent({ - type: EventType.CallInvite, - room_id: "!room:id", - content: { - call_id: "123", - conf_id: GROUP_CALL_ID, - device_id: DEVICE_ID, - sender_session_id: SESSION_ID, - dest_session_id: client.getSessionId(), - }, - }), room, false, false, timelineData); + client.emit( + RoomEvent.Timeline, + new MatrixEvent({ + type: EventType.CallInvite, + room_id: "!room:id", + content: { + call_id: "123", + conf_id: GROUP_CALL_ID, + device_id: DEVICE_ID, + sender_session_id: SESSION_ID, + dest_session_id: client.getSessionId(), + }, + }), + room, + false, + false, + timelineData, + ); await sync(); expect(incomingCallListener).toHaveBeenCalled(); @@ -257,14 +277,21 @@ describe("CallEventHandler", () => { }); it("ignores a call with a different invitee than us", async () => { - client.emit(RoomEvent.Timeline, new MatrixEvent({ - type: EventType.CallInvite, - room_id: "!room:id", - content: { - call_id: "123", - invitee: "@bob:bar", - }, - }), room, false, false, timelineData); + client.emit( + RoomEvent.Timeline, + new MatrixEvent({ + type: EventType.CallInvite, + room_id: "!room:id", + content: { + call_id: "123", + invitee: "@bob:bar", + }, + }), + room, + false, + false, + timelineData, + ); await sync(); expect(incomingCallListener).not.toHaveBeenCalled(); diff --git a/spec/unit/webrtc/callFeed.spec.ts b/spec/unit/webrtc/callFeed.spec.ts index 635fa14fd8f..e14a1a0c56b 100644 --- a/spec/unit/webrtc/callFeed.spec.ts +++ b/spec/unit/webrtc/callFeed.spec.ts @@ -17,13 +17,30 @@ limitations under the License. import { SDPStreamMetadataPurpose } from "../../../src/webrtc/callEventTypes"; import { CallFeed } from "../../../src/webrtc/callFeed"; import { TestClient } from "../../TestClient"; -import { MockMediaStream, MockMediaStreamTrack } from "../../test-utils/webrtc"; +import { MockMatrixCall, MockMediaStream, MockMediaStreamTrack } from "../../test-utils/webrtc"; +import { CallEvent, CallState } from "../../../src/webrtc/call"; describe("CallFeed", () => { - let client; + const roomId = "room1"; + let client: TestClient; + let call: MockMatrixCall; + let feed: CallFeed; beforeEach(() => { client = new TestClient("@alice:foo", "somedevice", "token", undefined, {}); + call = new MockMatrixCall(roomId); + + feed = new CallFeed({ + client: client.client, + call: call.typed(), + roomId, + userId: "user1", + // @ts-ignore Mock + stream: new MockMediaStream("stream1"), + purpose: SDPStreamMetadataPurpose.Usermedia, + audioMuted: false, + videoMuted: false, + }); }); afterEach(() => { @@ -31,21 +48,6 @@ describe("CallFeed", () => { }); describe("muting", () => { - let feed: CallFeed; - - beforeEach(() => { - feed = new CallFeed({ - client, - roomId: "room1", - userId: "user1", - // @ts-ignore Mock - stream: new MockMediaStream("stream1"), - purpose: SDPStreamMetadataPurpose.Usermedia, - audioMuted: false, - videoMuted: false, - }); - }); - describe("muting by default", () => { it("should mute audio by default", () => { expect(feed.isAudioMuted()).toBeTruthy(); @@ -86,4 +88,23 @@ describe("CallFeed", () => { }); }); }); + + describe("connected", () => { + it.each([true, false])("should always be connected, if isLocal()", (val: boolean) => { + // @ts-ignore + feed._connected = val; + jest.spyOn(feed, "isLocal").mockReturnValue(true); + + expect(feed.connected).toBeTruthy(); + }); + + it.each([ + [CallState.Connected, true], + [CallState.Connecting, false], + ])("should react to call state, when !isLocal()", (state: CallState, expected: Boolean) => { + call.emit(CallEvent.State, state); + + expect(feed.connected).toBe(expected); + }); + }); }); diff --git a/spec/unit/webrtc/groupCall.spec.ts b/spec/unit/webrtc/groupCall.spec.ts index 3c9266b98c8..59cdbac129d 100644 --- a/spec/unit/webrtc/groupCall.spec.ts +++ b/spec/unit/webrtc/groupCall.spec.ts @@ -14,15 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { - EventType, - GroupCallIntent, - GroupCallType, - MatrixCall, - MatrixEvent, - Room, - RoomMember, -} from '../../../src'; +import { EventType, GroupCallIntent, GroupCallType, MatrixCall, MatrixEvent, Room, RoomMember } from "../../../src"; import { RoomStateEvent } from "../../../src/models/room-state"; import { GroupCall, GroupCallEvent, GroupCallState } from "../../../src/webrtc/groupCall"; import { IMyDevice, MatrixClient } from "../../../src/client"; @@ -33,24 +25,24 @@ import { MockMediaStream, MockMediaStreamTrack, MockRTCPeerConnection, -} from '../../test-utils/webrtc'; + MockMatrixCall, + FAKE_ROOM_ID, + FAKE_USER_ID_1, + FAKE_CONF_ID, + FAKE_DEVICE_ID_2, + FAKE_SESSION_ID_2, + FAKE_USER_ID_2, + FAKE_DEVICE_ID_1, + FAKE_SESSION_ID_1, + FAKE_USER_ID_3, +} from "../../test-utils/webrtc"; import { SDPStreamMetadataKey, SDPStreamMetadataPurpose } from "../../../src/webrtc/callEventTypes"; import { sleep } from "../../../src/utils"; -import { CallEventHandlerEvent } from '../../../src/webrtc/callEventHandler'; -import { CallFeed } from '../../../src/webrtc/callFeed'; -import { CallEvent, CallState } from '../../../src/webrtc/call'; -import { flushPromises } from '../../test-utils/flushPromises'; - -const FAKE_ROOM_ID = "!fake:test.dummy"; -const FAKE_CONF_ID = "fakegroupcallid"; - -const FAKE_USER_ID_1 = "@alice:test.dummy"; -const FAKE_DEVICE_ID_1 = "@AAAAAA"; -const FAKE_SESSION_ID_1 = "alice1"; -const FAKE_USER_ID_2 = "@bob:test.dummy"; -const FAKE_DEVICE_ID_2 = "@BBBBBB"; -const FAKE_SESSION_ID_2 = "bob1"; -const FAKE_USER_ID_3 = "@charlie:test.dummy"; +import { CallEventHandlerEvent } from "../../../src/webrtc/callEventHandler"; +import { CallFeed } from "../../../src/webrtc/callFeed"; +import { CallEvent, CallState } from "../../../src/webrtc/call"; +import { flushPromises } from "../../test-utils/flushPromises"; + const FAKE_STATE_EVENTS = [ { getContent: () => ({ @@ -62,31 +54,40 @@ const FAKE_STATE_EVENTS = [ }, { getContent: () => ({ - "m.calls": [{ - "m.call_id": FAKE_CONF_ID, - "m.devices": [{ - device_id: FAKE_DEVICE_ID_2, - session_id: FAKE_SESSION_ID_2, - expires_ts: Date.now() + ONE_HOUR, - feeds: [], - }], - }], + "m.calls": [ + { + "m.call_id": FAKE_CONF_ID, + "m.devices": [ + { + device_id: FAKE_DEVICE_ID_2, + session_id: FAKE_SESSION_ID_2, + expires_ts: Date.now() + ONE_HOUR, + feeds: [], + }, + ], + }, + ], }), getStateKey: () => FAKE_USER_ID_2, getRoomId: () => FAKE_ROOM_ID, getTs: () => 0, - }, { + }, + { getContent: () => ({ "m.expires_ts": Date.now() + ONE_HOUR, - "m.calls": [{ - "m.call_id": FAKE_CONF_ID, - "m.devices": [{ - device_id: "user3_device", - session_id: "user3_session", - expires_ts: Date.now() + ONE_HOUR, - feeds: [], - }], - }], + "m.calls": [ + { + "m.call_id": FAKE_CONF_ID, + "m.devices": [ + { + device_id: "user3_device", + session_id: "user3_session", + expires_ts: Date.now() + ONE_HOUR, + feeds: [], + }, + ], + }, + ], }), getStateKey: () => "user3", getRoomId: () => FAKE_ROOM_ID, @@ -97,8 +98,8 @@ const FAKE_STATE_EVENTS = [ const mockGetStateEvents = (type: EventType, userId?: string): MatrixEvent[] | MatrixEvent | null => { if (type === EventType.GroupCallMemberPrefix) { return userId === undefined - ? FAKE_STATE_EVENTS as MatrixEvent[] - : FAKE_STATE_EVENTS.find(e => e.getStateKey() === userId) as MatrixEvent; + ? (FAKE_STATE_EVENTS as MatrixEvent[]) + : (FAKE_STATE_EVENTS.find((e) => e.getStateKey() === userId) as MatrixEvent); } else { const fakeEvent = { getContent: () => ({}), getTs: () => 0 } as MatrixEvent; return userId === undefined ? [fakeEvent] : fakeEvent; @@ -108,14 +109,7 @@ const mockGetStateEvents = (type: EventType, userId?: string): MatrixEvent[] | M const ONE_HOUR = 1000 * 60 * 60; const createAndEnterGroupCall = async (cli: MatrixClient, room: Room): Promise => { - const groupCall = new GroupCall( - cli, - room, - GroupCallType.Video, - false, - GroupCallIntent.Prompt, - FAKE_CONF_ID, - ); + const groupCall = new GroupCall(cli, room, GroupCallType.Video, false, GroupCallIntent.Prompt, FAKE_CONF_ID); await groupCall.create(); await groupCall.enter(); @@ -123,57 +117,19 @@ const createAndEnterGroupCall = async (cli: MatrixClient, room: Room): Promise(), - stream: new MockMediaStream("stream"), - }; - public remoteUsermediaFeed?: CallFeed; - public remoteScreensharingFeed?: CallFeed; - - public reject = jest.fn(); - public answerWithCallFeeds = jest.fn(); - public hangup = jest.fn(); - - public sendMetadataUpdate = jest.fn(); - - public on = jest.fn(); - public removeListener = jest.fn(); - - public getOpponentMember(): Partial { - return this.opponentMember; - } - - public getOpponentDeviceId(): string { - return this.opponentDeviceId; - } - - public typed(): MatrixCall { return this as unknown as MatrixCall; } -} - -describe('Group Call', function() { - beforeEach(function() { +describe("Group Call", function () { + beforeEach(function () { installWebRTCMocks(); }); - describe('Basic functionality', function() { + describe("Basic functionality", function () { let mockSendState: jest.Mock; let mockClient: MatrixClient; let room: Room; let groupCall: GroupCall; - beforeEach(function() { - const typedMockClient = new MockCallMatrixClient( - FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1, - ); + beforeEach(function () { + const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1); mockSendState = typedMockClient.sendStateEvent; mockClient = typedMockClient as unknown as MatrixClient; @@ -207,7 +163,9 @@ describe('Group Call', function() { await groupCall.create(); expect(mockSendState).toHaveBeenCalledWith( - FAKE_ROOM_ID, EventType.GroupCallPrefix, expect.objectContaining({ + FAKE_ROOM_ID, + EventType.GroupCallPrefix, + expect.objectContaining({ "m.type": GroupCallType.Video, "m.intent": GroupCallIntent.Prompt, }), @@ -260,9 +218,9 @@ describe('Group Call', function() { }); it("includes local device in participants when entered via another session", async () => { - const hasLocalParticipant = () => groupCall.participants.get( - room.getMember(mockClient.getUserId()!)!, - )?.has(mockClient.getDeviceId()!) ?? false; + const hasLocalParticipant = () => + groupCall.participants.get(room.getMember(mockClient.getUserId()!)!)?.has(mockClient.getDeviceId()!) ?? + false; expect(groupCall.enteredViaAnotherSession).toBe(false); expect(hasLocalParticipant()).toBe(false); @@ -351,7 +309,7 @@ describe('Group Call', function() { }); describe("call feeds changing", () => { - let call: MockCall; + let call: MockMatrixCall; const currentFeed = new MockCallFeed(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, new MockMediaStream("current")); const newFeed = new MockCallFeed(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, new MockMediaStream("new")); @@ -361,13 +319,13 @@ describe('Group Call', function() { jest.spyOn(groupCall, "emit"); - call = new MockCall(room.roomId, groupCall.groupCallId); + call = new MockMatrixCall(room.roomId, groupCall.groupCallId); await groupCall.create(); }); it("ignores changes, if we can't get user id of opponent", async () => { - const call = new MockCall(room.roomId, groupCall.groupCallId); + const call = new MockMatrixCall(room.roomId, groupCall.groupCallId); jest.spyOn(call, "getOpponentMember").mockReturnValue({ userId: undefined }); // @ts-ignore Mock @@ -514,14 +472,15 @@ describe('Group Call', function() { }); it("sends metadata updates before unmuting in PTT mode", async () => { - const mockCall = new MockCall(FAKE_ROOM_ID, groupCall.groupCallId); + const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId); + // @ts-ignore groupCall.calls.set( mockCall.getOpponentMember() as RoomMember, - new Map([[mockCall.getOpponentDeviceId(), mockCall.typed()]]), + new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]), ); let metadataUpdateResolve: () => void; - const metadataUpdatePromise = new Promise(resolve => { + const metadataUpdatePromise = new Promise((resolve) => { metadataUpdateResolve = resolve; }); mockCall.sendMetadataUpdate = jest.fn().mockReturnValue(metadataUpdatePromise); @@ -539,10 +498,11 @@ describe('Group Call', function() { }); it("sends metadata updates after muting in PTT mode", async () => { - const mockCall = new MockCall(FAKE_ROOM_ID, groupCall.groupCallId); + const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId); + // @ts-ignore groupCall.calls.set( mockCall.getOpponentMember() as RoomMember, - new Map([[mockCall.getOpponentDeviceId(), mockCall.typed()]]), + new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]), ); // the call starts muted, so unmute to get in the right state to test @@ -550,7 +510,7 @@ describe('Group Call', function() { mockCall.localUsermediaFeed.setAudioVideoMuted.mockReset(); let metadataUpdateResolve: () => void; - const metadataUpdatePromise = new Promise(resolve => { + const metadataUpdatePromise = new Promise((resolve) => { metadataUpdateResolve = resolve; }); mockCall.sendMetadataUpdate = jest.fn().mockReturnValue(metadataUpdatePromise); @@ -568,27 +528,21 @@ describe('Group Call', function() { }); }); - describe('Placing calls', function() { + describe("Placing calls", function () { let groupCall1: GroupCall; let groupCall2: GroupCall; let client1: MockCallMatrixClient; let client2: MockCallMatrixClient; - beforeEach(function() { + beforeEach(function () { MockRTCPeerConnection.resetInstances(); - client1 = new MockCallMatrixClient( - FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1, - ); + client1 = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1); - client2 = new MockCallMatrixClient( - FAKE_USER_ID_2, FAKE_DEVICE_ID_2, FAKE_SESSION_ID_2, - ); + client2 = new MockCallMatrixClient(FAKE_USER_ID_2, FAKE_DEVICE_ID_2, FAKE_SESSION_ID_2); // Inject the state events directly into each client when sent - const fakeSendStateEvents = ( - roomId: string, eventType: EventType, content: any, statekey: string, - ) => { + const fakeSendStateEvents = (roomId: string, eventType: EventType, content: any, statekey: string) => { if (eventType === EventType.GroupCallMemberPrefix) { const fakeEvent = { getContent: () => content, @@ -609,7 +563,7 @@ describe('Group Call', function() { client1Room.currentState.emit(RoomStateEvent.Update, client1Room.currentState); client2Room.currentState.emit(RoomStateEvent.Update, client2Room.currentState); } - return Promise.resolve({ "event_id": "foo" }); + return Promise.resolve({ event_id: "foo" }); }; client1.sendStateEvent.mockImplementation(fakeSendStateEvents); @@ -628,15 +582,25 @@ describe('Group Call', function() { } as unknown as RoomMember; groupCall1 = new GroupCall( - client1.typed(), client1Room, GroupCallType.Video, false, GroupCallIntent.Prompt, FAKE_CONF_ID, + client1.typed(), + client1Room, + GroupCallType.Video, + false, + GroupCallIntent.Prompt, + FAKE_CONF_ID, ); groupCall2 = new GroupCall( - client2.typed(), client2Room, GroupCallType.Video, false, GroupCallIntent.Prompt, FAKE_CONF_ID, + client2.typed(), + client2Room, + GroupCallType.Video, + false, + GroupCallIntent.Prompt, + FAKE_CONF_ID, ); }); - afterEach(function() { + afterEach(function () { groupCall1.leave(); groupCall2.leave(); jest.useRealTimers(); @@ -644,11 +608,11 @@ describe('Group Call', function() { MockRTCPeerConnection.resetInstances(); }); - it("Places a call to a peer", async function() { + it("Places a call to a peer", async function () { await groupCall1.create(); try { - const toDeviceProm = new Promise(resolve => { + const toDeviceProm = new Promise((resolve) => { client1.sendToDevice.mockImplementation(() => { resolve(); return Promise.resolve({}); @@ -678,12 +642,12 @@ describe('Group Call', function() { } }); - it("Retries calls", async function() { + it("Retries calls", async function () { jest.useFakeTimers(); await groupCall1.create(); try { - const toDeviceProm = new Promise(resolve => { + const toDeviceProm = new Promise((resolve) => { client1.sendToDevice.mockImplementation(() => { resolve(); return Promise.resolve({}); @@ -698,14 +662,15 @@ describe('Group Call', function() { expect(client1.sendToDevice).toHaveBeenCalled(); - const oldCall = groupCall1.calls.get( - groupCall1.room.getMember(client2.userId)!, - )!.get(client2.deviceId)!; + // @ts-ignore + const oldCall = groupCall1.calls + .get(groupCall1.room.getMember(client2.userId)!)! + .get(client2.deviceId)!; oldCall.emit(CallEvent.Hangup, oldCall!); client1.sendToDevice.mockClear(); - const toDeviceProm2 = new Promise(resolve => { + const toDeviceProm2 = new Promise((resolve) => { client1.sendToDevice.mockImplementation(() => { resolve(); return Promise.resolve({}); @@ -719,11 +684,12 @@ describe('Group Call', function() { // to even be created... let newCall: MatrixCall | undefined; while ( - (newCall = groupCall1.calls.get( - groupCall1.room.getMember(client2.userId)!, - )?.get(client2.deviceId)) === undefined - || newCall.peerConn === undefined - || newCall.callId == oldCall.callId + // @ts-ignore + (newCall = groupCall1.calls + .get(groupCall1.room.getMember(client2.userId)!) + ?.get(client2.deviceId)) === undefined || + newCall.peerConn === undefined || + newCall.callId == oldCall.callId ) { await flushPromises(); } @@ -743,11 +709,11 @@ describe('Group Call', function() { } }); - it("Updates call mute status correctly on call state change", async function() { + it("Updates call mute status correctly on call state change", async function () { await groupCall1.create(); try { - const toDeviceProm = new Promise(resolve => { + const toDeviceProm = new Promise((resolve) => { client1.sendToDevice.mockImplementation(() => { resolve(); return Promise.resolve({}); @@ -763,9 +729,8 @@ describe('Group Call', function() { groupCall1.setMicrophoneMuted(false); groupCall1.setLocalVideoMuted(false); - const call = groupCall1.calls.get( - groupCall1.room.getMember(client2.userId)!, - )!.get(client2.deviceId)!; + // @ts-ignore + const call = groupCall1.calls.get(groupCall1.room.getMember(client2.userId)!)!.get(client2.deviceId)!; call.isMicrophoneMuted = jest.fn().mockReturnValue(true); call.setMicrophoneMuted = jest.fn(); call.isLocalVideoMuted = jest.fn().mockReturnValue(true); @@ -786,9 +751,7 @@ describe('Group Call', function() { let room: Room; beforeEach(() => { - const typedMockClient = new MockCallMatrixClient( - FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1, - ); + const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1); mockClient = typedMockClient as unknown as MatrixClient; room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1); @@ -811,19 +774,19 @@ describe('Group Call', function() { const setAVMutedArray: ((audioMuted: boolean | null, videoMuted: boolean | null) => void)[] = []; const tracksArray: MediaStreamTrack[] = []; const sendMetadataUpdateArray: (() => Promise)[] = []; - groupCall.forEachCall(call => { - setAVMutedArray.push(call.localUsermediaFeed!.setAudioVideoMuted = jest.fn()); + groupCall.forEachCall((call) => { + setAVMutedArray.push((call.localUsermediaFeed!.setAudioVideoMuted = jest.fn())); tracksArray.push(...call.localUsermediaStream!.getAudioTracks()); - sendMetadataUpdateArray.push(call.sendMetadataUpdate = jest.fn()); + sendMetadataUpdateArray.push((call.sendMetadataUpdate = jest.fn())); }); await groupCall.setMicrophoneMuted(true); - groupCall.localCallFeed!.stream.getAudioTracks().forEach(track => expect(track.enabled).toBe(false)); + groupCall.localCallFeed!.stream.getAudioTracks().forEach((track) => expect(track.enabled).toBe(false)); expect(groupCall.localCallFeed!.setAudioVideoMuted).toHaveBeenCalledWith(true, null); - setAVMutedArray.forEach(f => expect(f).toHaveBeenCalledWith(true, null)); - tracksArray.forEach(track => expect(track.enabled).toBe(false)); - sendMetadataUpdateArray.forEach(f => expect(f).toHaveBeenCalled()); + setAVMutedArray.forEach((f) => expect(f).toHaveBeenCalledWith(true, null)); + tracksArray.forEach((track) => expect(track.enabled).toBe(false)); + sendMetadataUpdateArray.forEach((f) => expect(f).toHaveBeenCalled()); groupCall.terminate(); }); @@ -835,37 +798,38 @@ describe('Group Call', function() { const setAVMutedArray: ((audioMuted: boolean | null, videoMuted: boolean | null) => void)[] = []; const tracksArray: MediaStreamTrack[] = []; const sendMetadataUpdateArray: (() => Promise)[] = []; - groupCall.forEachCall(call => { + groupCall.forEachCall((call) => { call.localUsermediaFeed!.isVideoMuted = jest.fn().mockReturnValue(true); - setAVMutedArray.push(call.localUsermediaFeed!.setAudioVideoMuted = jest.fn()); + setAVMutedArray.push((call.localUsermediaFeed!.setAudioVideoMuted = jest.fn())); tracksArray.push(...call.localUsermediaStream!.getVideoTracks()); - sendMetadataUpdateArray.push(call.sendMetadataUpdate = jest.fn()); + sendMetadataUpdateArray.push((call.sendMetadataUpdate = jest.fn())); }); await groupCall.setLocalVideoMuted(true); - groupCall.localCallFeed!.stream.getVideoTracks().forEach(track => expect(track.enabled).toBe(false)); + groupCall.localCallFeed!.stream.getVideoTracks().forEach((track) => expect(track.enabled).toBe(false)); expect(groupCall.localCallFeed!.setAudioVideoMuted).toHaveBeenCalledWith(null, true); - setAVMutedArray.forEach(f => expect(f).toHaveBeenCalledWith(null, true)); - tracksArray.forEach(track => expect(track.enabled).toBe(false)); - sendMetadataUpdateArray.forEach(f => expect(f).toHaveBeenCalled()); + setAVMutedArray.forEach((f) => expect(f).toHaveBeenCalledWith(null, true)); + tracksArray.forEach((track) => expect(track.enabled).toBe(false)); + sendMetadataUpdateArray.forEach((f) => expect(f).toHaveBeenCalled()); groupCall.terminate(); }); }); describe("remote muting", () => { - const getMetadataEvent = (audio: boolean, video: boolean): MatrixEvent => ({ - getContent: () => ({ - [SDPStreamMetadataKey]: { - stream: { - purpose: SDPStreamMetadataPurpose.Usermedia, - audio_muted: audio, - video_muted: video, + const getMetadataEvent = (audio: boolean, video: boolean): MatrixEvent => + ({ + getContent: () => ({ + [SDPStreamMetadataKey]: { + stream: { + purpose: SDPStreamMetadataPurpose.Usermedia, + audio_muted: audio, + video_muted: video, + }, }, - }, - }), - } as MatrixEvent); + }), + } as MatrixEvent); it("should mute remote feed's audio after receiving metadata with video audio", async () => { const metadataEvent = getMetadataEvent(true, false); @@ -874,13 +838,17 @@ describe('Group Call', function() { // It takes a bit of time for the calls to get created await sleep(10); + // @ts-ignore const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!; - call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember; + call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember); // @ts-ignore Mock - call.pushRemoteFeed(new MockMediaStream("stream", [ - new MockMediaStreamTrack("audio_track", "audio"), - new MockMediaStreamTrack("video_track", "video"), - ])); + call.pushRemoteFeed( + // @ts-ignore Mock + new MockMediaStream("stream", [ + new MockMediaStreamTrack("audio_track", "audio"), + new MockMediaStreamTrack("video_track", "video"), + ]), + ); call.onSDPStreamMetadataChangedReceived(metadataEvent); const feed = groupCall.getUserMediaFeed(call.invitee!, call.getOpponentDeviceId()!); @@ -897,13 +865,17 @@ describe('Group Call', function() { // It takes a bit of time for the calls to get created await sleep(10); + // @ts-ignore const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!; - call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember; + call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember); // @ts-ignore Mock - call.pushRemoteFeed(new MockMediaStream("stream", [ - new MockMediaStreamTrack("audio_track", "audio"), - new MockMediaStreamTrack("video_track", "video"), - ])); + call.pushRemoteFeed( + // @ts-ignore Mock + new MockMediaStream("stream", [ + new MockMediaStreamTrack("audio_track", "audio"), + new MockMediaStreamTrack("video_track", "video"), + ]), + ); call.onSDPStreamMetadataChangedReceived(metadataEvent); const feed = groupCall.getUserMediaFeed(call.invitee!, call.getOpponentDeviceId()!); @@ -923,9 +895,7 @@ describe('Group Call', function() { beforeEach(async () => { // we are bob here because we're testing incoming calls, and since alice's user id // is lexicographically before Bob's, the spec requires that she calls Bob. - const typedMockClient = new MockCallMatrixClient( - FAKE_USER_ID_2, FAKE_DEVICE_ID_2, FAKE_SESSION_ID_2, - ); + const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_2, FAKE_DEVICE_ID_2, FAKE_SESSION_ID_2); mockClient = typedMockClient as unknown as MatrixClient; room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_2); @@ -939,7 +909,7 @@ describe('Group Call', function() { }); it("ignores incoming calls for other rooms", async () => { - const mockCall = new MockCall("!someotherroom.fake.dummy", groupCall.groupCallId); + const mockCall = new MockMatrixCall("!someotherroom.fake.dummy", groupCall.groupCallId); mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall); @@ -948,7 +918,7 @@ describe('Group Call', function() { }); it("rejects incoming calls for the wrong group call", async () => { - const mockCall = new MockCall(room.roomId, "not " + groupCall.groupCallId); + const mockCall = new MockMatrixCall(room.roomId, "not " + groupCall.groupCallId); mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall); @@ -956,7 +926,7 @@ describe('Group Call', function() { }); it("ignores incoming calls not in the ringing state", async () => { - const mockCall = new MockCall(room.roomId, groupCall.groupCallId); + const mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId); mockCall.state = CallState.Connected; mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall); @@ -966,21 +936,21 @@ describe('Group Call', function() { }); it("answers calls for the right room & group call ID", async () => { - const mockCall = new MockCall(room.roomId, groupCall.groupCallId); + const mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId); mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall); expect(mockCall.reject).not.toHaveBeenCalled(); expect(mockCall.answerWithCallFeeds).toHaveBeenCalled(); - expect(groupCall.calls).toEqual(new Map([[ - groupCall.room.getMember(FAKE_USER_ID_1)!, - new Map([[FAKE_DEVICE_ID_1, mockCall]]), - ]])); + // @ts-ignore + expect(groupCall.calls).toEqual( + new Map([[groupCall.room.getMember(FAKE_USER_ID_1)!, new Map([[FAKE_DEVICE_ID_1, mockCall]])]]), + ); }); it("replaces calls if it already has one with the same user", async () => { - const oldMockCall = new MockCall(room.roomId, groupCall.groupCallId); - const newMockCall = new MockCall(room.roomId, groupCall.groupCallId); + const oldMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId); + const newMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId); newMockCall.opponentMember = oldMockCall.opponentMember; // Ensure referential equality newMockCall.callId = "not " + oldMockCall.callId; @@ -989,20 +959,18 @@ describe('Group Call', function() { expect(oldMockCall.hangup).toHaveBeenCalled(); expect(newMockCall.answerWithCallFeeds).toHaveBeenCalled(); - expect(groupCall.calls).toEqual(new Map([[ - groupCall.room.getMember(FAKE_USER_ID_1)!, - new Map([[FAKE_DEVICE_ID_1, newMockCall]]), - ]])); + // @ts-ignore + expect(groupCall.calls).toEqual( + new Map([[groupCall.room.getMember(FAKE_USER_ID_1)!, new Map([[FAKE_DEVICE_ID_1, newMockCall]])]]), + ); }); it("starts to process incoming calls when we've entered", async () => { // First we leave the call since we have already entered groupCall.leave(); - const call = new MockCall(room.roomId, groupCall.groupCallId); - mockClient.callEventHandler!.calls = new Map([ - [call.callId, call.typed()], - ]); + const call = new MockMatrixCall(room.roomId, groupCall.groupCallId); + mockClient.callEventHandler!.calls = new Map([[call.callId, call.typed()]]); await groupCall.enter(); expect(call.answerWithCallFeeds).toHaveBeenCalled(); @@ -1016,9 +984,7 @@ describe('Group Call', function() { let groupCall: GroupCall; beforeEach(async () => { - typedMockClient = new MockCallMatrixClient( - FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1, - ); + typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1); mockClient = typedMockClient.typed(); room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1); @@ -1037,9 +1003,9 @@ describe('Group Call', function() { it("sending screensharing stream", async () => { const onNegotiationNeededArray: (() => Promise)[] = []; - groupCall.forEachCall(call => { + groupCall.forEachCall((call) => { // @ts-ignore Mock - onNegotiationNeededArray.push(call.gotLocalOffer = jest.fn()); + onNegotiationNeededArray.push((call.gotLocalOffer = jest.fn())); }); let enabledResult: boolean; @@ -1049,10 +1015,10 @@ describe('Group Call', function() { MockRTCPeerConnection.triggerAllNegotiations(); expect(groupCall.screenshareFeeds).toHaveLength(1); - groupCall.forEachCall(c => { - expect(c.getLocalFeeds().find(f => f.purpose === SDPStreamMetadataPurpose.Screenshare)).toBeDefined(); + groupCall.forEachCall((c) => { + expect(c.getLocalFeeds().find((f) => f.purpose === SDPStreamMetadataPurpose.Screenshare)).toBeDefined(); }); - onNegotiationNeededArray.forEach(f => expect(f).toHaveBeenCalled()); + onNegotiationNeededArray.forEach((f) => expect(f).toHaveBeenCalled()); // Enabling it again should do nothing typedMockClient.mediaHandler.getScreensharingStream.mockClear(); @@ -1072,12 +1038,13 @@ describe('Group Call', function() { // It takes a bit of time for the calls to get created await sleep(10); + // @ts-ignore const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!; - call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember; + call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember); call.onNegotiateReceived({ getContent: () => ({ [SDPStreamMetadataKey]: { - "screensharing_stream": { + screensharing_stream: { purpose: SDPStreamMetadataPurpose.Screenshare, }, }, @@ -1088,9 +1055,10 @@ describe('Group Call', function() { }), } as MatrixEvent); // @ts-ignore Mock - call.pushRemoteFeed(new MockMediaStream("screensharing_stream", [ - new MockMediaStreamTrack("video_track", "video"), - ])); + call.pushRemoteFeed( + // @ts-ignore Mock + new MockMediaStream("screensharing_stream", [new MockMediaStreamTrack("video_track", "video")]), + ); expect(groupCall.screenshareFeeds).toHaveLength(1); expect(groupCall.getScreenshareFeed(call.invitee!, call.getOpponentDeviceId()!)).toBeDefined(); @@ -1128,9 +1096,7 @@ describe('Group Call', function() { beforeEach(async () => { jest.useFakeTimers(); - const mockClient = new MockCallMatrixClient( - FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1, - ); + const mockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1); room = new Room(FAKE_ROOM_ID, mockClient.typed(), FAKE_USER_ID_1); room.currentState.members[FAKE_USER_ID_1] = { @@ -1143,7 +1109,7 @@ describe('Group Call', function() { roomId: FAKE_ROOM_ID, userId: FAKE_USER_ID_2, deviceId: FAKE_DEVICE_ID_1, - stream: (new MockMediaStream("foo", [])).typed(), + stream: new MockMediaStream("foo", []).typed(), purpose: SDPStreamMetadataPurpose.Usermedia, audioMuted: false, videoMuted: true, @@ -1155,7 +1121,7 @@ describe('Group Call', function() { roomId: FAKE_ROOM_ID, userId: FAKE_USER_ID_3, deviceId: FAKE_DEVICE_ID_1, - stream: (new MockMediaStream("foo", [])).typed(), + stream: new MockMediaStream("foo", []).typed(), purpose: SDPStreamMetadataPurpose.Usermedia, audioMuted: false, videoMuted: true, @@ -1205,28 +1171,17 @@ describe('Group Call', function() { it("throws when there already is a call", async () => { jest.spyOn(client, "getRoom").mockReturnValue(new Room("room_id", client, "my_user_id")); - await client.createGroupCall( - "room_id", - GroupCallType.Video, - false, - GroupCallIntent.Prompt, - ); + await client.createGroupCall("room_id", GroupCallType.Video, false, GroupCallIntent.Prompt); - await expect(client.createGroupCall( - "room_id", - GroupCallType.Video, - false, - GroupCallIntent.Prompt, - )).rejects.toThrow("room_id already has an existing group call"); + await expect( + client.createGroupCall("room_id", GroupCallType.Video, false, GroupCallIntent.Prompt), + ).rejects.toThrow("room_id already has an existing group call"); }); it("throws if the room doesn't exist", async () => { - await expect(client.createGroupCall( - "room_id", - GroupCallType.Video, - false, - GroupCallIntent.Prompt, - )).rejects.toThrow("Cannot find room room_id"); + await expect( + client.createGroupCall("room_id", GroupCallType.Video, false, GroupCallIntent.Prompt), + ).rejects.toThrow("Cannot find room room_id"); }); describe("correctly passes parameters", () => { @@ -1293,24 +1248,35 @@ describe('Group Call', function() { }; const mkContent = (devices: IMyDevice[]) => ({ - "m.calls": [{ - "m.call_id": groupCall.groupCallId, - "m.devices": devices.map(d => ({ - device_id: d.device_id, session_id: "1", feeds: [], expires_ts: 1000 * 60 * 10, - })), - }], + "m.calls": [ + { + "m.call_id": groupCall.groupCallId, + "m.devices": devices.map((d) => ({ + device_id: d.device_id, + session_id: "1", + feeds: [], + expires_ts: 1000 * 60 * 10, + })), + }, + ], }); - const expectDevices = (devices: IMyDevice[]) => expect( - room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, FAKE_USER_ID_2)?.getContent(), - ).toEqual({ - "m.calls": [{ - "m.call_id": groupCall.groupCallId, - "m.devices": devices.map(d => ({ - device_id: d.device_id, session_id: "1", feeds: [], expires_ts: expect.any(Number), - })), - }], - }); + const expectDevices = (devices: IMyDevice[]) => + expect( + room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, FAKE_USER_ID_2)?.getContent(), + ).toEqual({ + "m.calls": [ + { + "m.call_id": groupCall.groupCallId, + "m.devices": devices.map((d) => ({ + device_id: d.device_id, + session_id: "1", + feeds: [], + expires_ts: expect.any(Number), + })), + }, + ], + }); let mockClient: MatrixClient; let room: Room; @@ -1324,21 +1290,21 @@ describe('Group Call', function() { afterAll(() => jest.useRealTimers()); beforeEach(async () => { - const typedMockClient = new MockCallMatrixClient( - FAKE_USER_ID_2, bobWeb.device_id, FAKE_SESSION_ID_2, - ); + const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_2, bobWeb.device_id, FAKE_SESSION_ID_2); jest.spyOn(typedMockClient, "sendStateEvent").mockImplementation( async (roomId, eventType, content, stateKey) => { const eventId = `$${Math.random()}`; if (roomId === room.roomId) { - room.addLiveEvents([new MatrixEvent({ - event_id: eventId, - type: eventType, - room_id: roomId, - sender: FAKE_USER_ID_2, - content, - state_key: stateKey, - })]); + room.addLiveEvents([ + new MatrixEvent({ + event_id: eventId, + type: eventType, + room_id: roomId, + sender: FAKE_USER_ID_2, + content, + state_key: stateKey, + }), + ]); } return { event_id: eventId }; }, @@ -1359,12 +1325,7 @@ describe('Group Call', function() { await groupCall.create(); mockClient.getDevices = async () => ({ - devices: [ - bobWeb, - bobDesktop, - bobDesktopOffline, - bobDesktopNeverOnline, - ], + devices: [bobWeb, bobDesktop, bobDesktopOffline, bobDesktopNeverOnline], }); }); diff --git a/spec/unit/webrtc/groupCallEventHandler.spec.ts b/spec/unit/webrtc/groupCallEventHandler.spec.ts index de70e42085b..6c97988bf46 100644 --- a/spec/unit/webrtc/groupCallEventHandler.spec.ts +++ b/spec/unit/webrtc/groupCallEventHandler.spec.ts @@ -38,16 +38,14 @@ const FAKE_SESSION_ID = "session1"; const FAKE_ROOM_ID = "!roomid:test.dummy"; const FAKE_GROUP_CALL_ID = "fakegroupcallid"; -describe('Group Call Event Handler', function() { +describe("Group Call Event Handler", function () { let groupCallEventHandler: GroupCallEventHandler; let mockClient: MockCallMatrixClient; let mockRoom: Room; let mockMember: RoomMember; beforeEach(() => { - mockClient = new MockCallMatrixClient( - FAKE_USER_ID, FAKE_DEVICE_ID, FAKE_SESSION_ID, - ); + mockClient = new MockCallMatrixClient(FAKE_USER_ID, FAKE_DEVICE_ID, FAKE_SESSION_ID); groupCallEventHandler = new GroupCallEventHandler(mockClient.typed()); mockMember = { @@ -70,7 +68,7 @@ describe('Group Call Event Handler', function() { } }), }, - getMember: (userId: string) => userId === FAKE_USER_ID ? mockMember : null, + getMember: (userId: string) => (userId === FAKE_USER_ID ? mockMember : null), } as unknown as Room; (mockClient as any).getRoom = jest.fn().mockReturnValue(mockRoom); @@ -79,23 +77,20 @@ describe('Group Call Event Handler', function() { describe("reacts to state changes", () => { it("terminates call", async () => { await groupCallEventHandler.start(); - mockClient.emitRoomState( - makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID), - { roomId: FAKE_ROOM_ID } as unknown as RoomState, - ); + mockClient.emitRoomState(makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID), { + roomId: FAKE_ROOM_ID, + } as unknown as RoomState); const groupCall = groupCallEventHandler.groupCalls.get(FAKE_ROOM_ID)!; expect(groupCall.state).toBe(GroupCallState.LocalCallFeedUninitialized); mockClient.emitRoomState( - makeMockGroupCallStateEvent( - FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, { - "m.type": GroupCallType.Video, - "m.intent": GroupCallIntent.Prompt, - "m.terminated": GroupCallTerminationReason.CallEnded, - }, - ), + makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, { + "m.type": GroupCallType.Video, + "m.intent": GroupCallIntent.Prompt, + "m.terminated": GroupCallTerminationReason.CallEnded, + }), { roomId: FAKE_ROOM_ID, } as unknown as RoomState, @@ -131,7 +126,7 @@ describe('Group Call Event Handler', function() { }); it("finds existing group calls when started", async () => { - const mockClientEmit = mockClient.emit = jest.fn(); + const mockClientEmit = (mockClient.emit = jest.fn()); mockClient.getRooms.mockReturnValue([mockRoom]); await groupCallEventHandler.start(); @@ -171,18 +166,15 @@ describe('Group Call Event Handler', function() { mockClient.on(GroupCallEventHandlerEvent.Incoming, onIncomingGroupCall); await groupCallEventHandler.start(); - mockClient.emitRoomState( - makeMockGroupCallStateEvent( - FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, - ), - { - roomId: FAKE_ROOM_ID, - } as unknown as RoomState, - ); + mockClient.emitRoomState(makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID), { + roomId: FAKE_ROOM_ID, + } as unknown as RoomState); - expect(onIncomingGroupCall).toHaveBeenCalledWith(expect.objectContaining({ - groupCallId: FAKE_GROUP_CALL_ID, - })); + expect(onIncomingGroupCall).toHaveBeenCalledWith( + expect.objectContaining({ + groupCallId: FAKE_GROUP_CALL_ID, + }), + ); mockClient.off(GroupCallEventHandlerEvent.Incoming, onIncomingGroupCall); }); @@ -191,23 +183,19 @@ describe('Group Call Event Handler', function() { await groupCallEventHandler.start(); const dataChannelOptions = { - "maxPacketLifeTime": "life_time", - "maxRetransmits": "retransmits", - "ordered": "ordered", - "protocol": "protocol", + maxPacketLifeTime: "life_time", + maxRetransmits: "retransmits", + ordered: "ordered", + protocol: "protocol", }; mockClient.emitRoomState( - makeMockGroupCallStateEvent( - FAKE_ROOM_ID, - FAKE_GROUP_CALL_ID, - { - "m.type": GroupCallType.Video, - "m.intent": GroupCallIntent.Prompt, - "dataChannelsEnabled": true, - dataChannelOptions, - }, - ), + makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, { + "m.type": GroupCallType.Video, + "m.intent": GroupCallIntent.Prompt, + "dataChannelsEnabled": true, + dataChannelOptions, + }), { roomId: FAKE_ROOM_ID, } as unknown as RoomState, @@ -236,11 +224,7 @@ describe('Group Call Event Handler', function() { const setupCallAndStart = async (content?: IContent) => { mocked(mockRoom.currentState.getStateEvents).mockReturnValue([ - makeMockGroupCallStateEvent( - FAKE_ROOM_ID, - FAKE_GROUP_CALL_ID, - content, - ), + makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, content), ] as unknown as MatrixEvent); mockClient.getRooms.mockReturnValue([mockRoom]); await groupCallEventHandler.start(); diff --git a/spec/unit/webrtc/mediaHandler.spec.ts b/spec/unit/webrtc/mediaHandler.spec.ts index 252d8d65438..8a595cdc0a9 100644 --- a/spec/unit/webrtc/mediaHandler.spec.ts +++ b/spec/unit/webrtc/mediaHandler.spec.ts @@ -22,7 +22,7 @@ const FAKE_AUDIO_INPUT_ID = "aaaaaaaa"; const FAKE_VIDEO_INPUT_ID = "vvvvvvvv"; const FAKE_DESKTOP_SOURCE_ID = "ddddddd"; -describe('Media Handler', function() { +describe("Media Handler", function () { let mockMediaDevices: MockMediaDevices; let mediaHandler: MediaHandler; let calls: Map; @@ -58,25 +58,29 @@ describe('Media Handler', function() { mediaHandler.restoreMediaSettings(FAKE_AUDIO_INPUT_ID, FAKE_VIDEO_INPUT_ID); await mediaHandler.getUserMediaStream(true, true); - expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({ - audio: expect.objectContaining({ - deviceId: { ideal: FAKE_AUDIO_INPUT_ID }, + expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith( + expect.objectContaining({ + audio: expect.objectContaining({ + deviceId: { ideal: FAKE_AUDIO_INPUT_ID }, + }), + video: expect.objectContaining({ + deviceId: { ideal: FAKE_VIDEO_INPUT_ID }, + }), }), - video: expect.objectContaining({ - deviceId: { ideal: FAKE_VIDEO_INPUT_ID }, - }), - })); + ); }); it("sets audio device ID", async () => { await mediaHandler.setAudioInput(FAKE_AUDIO_INPUT_ID); await mediaHandler.getUserMediaStream(true, false); - expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({ - audio: expect.objectContaining({ - deviceId: { ideal: FAKE_AUDIO_INPUT_ID }, + expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith( + expect.objectContaining({ + audio: expect.objectContaining({ + deviceId: { ideal: FAKE_AUDIO_INPUT_ID }, + }), }), - })); + ); }); it("sets audio settings", async () => { @@ -87,38 +91,44 @@ describe('Media Handler', function() { }); await mediaHandler.getUserMediaStream(true, false); - expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({ - audio: expect.objectContaining({ - autoGainControl: { ideal: false }, - echoCancellation: { ideal: true }, - noiseSuppression: { ideal: false }, + expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith( + expect.objectContaining({ + audio: expect.objectContaining({ + autoGainControl: { ideal: false }, + echoCancellation: { ideal: true }, + noiseSuppression: { ideal: false }, + }), }), - })); + ); }); it("sets video device ID", async () => { await mediaHandler.setVideoInput(FAKE_VIDEO_INPUT_ID); await mediaHandler.getUserMediaStream(false, true); - expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({ - video: expect.objectContaining({ - deviceId: { ideal: FAKE_VIDEO_INPUT_ID }, + expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith( + expect.objectContaining({ + video: expect.objectContaining({ + deviceId: { ideal: FAKE_VIDEO_INPUT_ID }, + }), }), - })); + ); }); it("sets media inputs", async () => { await mediaHandler.setMediaInputs(FAKE_AUDIO_INPUT_ID, FAKE_VIDEO_INPUT_ID); await mediaHandler.getUserMediaStream(true, true); - expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({ - audio: expect.objectContaining({ - deviceId: { ideal: FAKE_AUDIO_INPUT_ID }, - }), - video: expect.objectContaining({ - deviceId: { ideal: FAKE_VIDEO_INPUT_ID }, + expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith( + expect.objectContaining({ + audio: expect.objectContaining({ + deviceId: { ideal: FAKE_AUDIO_INPUT_ID }, + }), + video: expect.objectContaining({ + deviceId: { ideal: FAKE_VIDEO_INPUT_ID }, + }), }), - })); + ); }); describe("updateLocalUsermediaStreams", () => { @@ -153,9 +163,11 @@ describe('Media Handler', function() { mediaHandler.userMediaStreams = [ { - getTracks: () => [{ - stop: stopTrack, - } as unknown as MediaStreamTrack], + getTracks: () => [ + { + stop: stopTrack, + } as unknown as MediaStreamTrack, + ], } as unknown as MediaStream, ]; @@ -225,9 +237,9 @@ describe('Media Handler', function() { }); it("returns false if the system has no audio inputs", async () => { - mockMediaDevices.enumerateDevices.mockReturnValue(Promise.resolve([ - new MockMediaDeviceInfo("videoinput").typed(), - ])); + mockMediaDevices.enumerateDevices.mockReturnValue( + Promise.resolve([new MockMediaDeviceInfo("videoinput").typed()]), + ); expect(await mediaHandler.hasAudioDevice()).toEqual(false); }); }); @@ -238,9 +250,9 @@ describe('Media Handler', function() { }); it("returns false if the system has no video inputs", async () => { - mockMediaDevices.enumerateDevices.mockReturnValue(Promise.resolve([ - new MockMediaDeviceInfo("audioinput").typed(), - ])); + mockMediaDevices.enumerateDevices.mockReturnValue( + Promise.resolve([new MockMediaDeviceInfo("audioinput").typed()]), + ); expect(await mediaHandler.hasVideoDevice()).toEqual(false); }); }); @@ -270,35 +282,35 @@ describe('Media Handler', function() { it("returns the same stream for reusable streams", async () => { const stream1 = await mediaHandler.getUserMediaStream(true, false); - const stream2 = await mediaHandler.getUserMediaStream(true, false) as unknown as MockMediaStream; + const stream2 = (await mediaHandler.getUserMediaStream(true, false)) as unknown as MockMediaStream; expect(stream2.isCloneOf(stream1)).toEqual(true); }); it("doesn't re-use stream if reusable is false", async () => { const stream1 = await mediaHandler.getUserMediaStream(true, false, false); - const stream2 = await mediaHandler.getUserMediaStream(true, false) as unknown as MockMediaStream; + const stream2 = (await mediaHandler.getUserMediaStream(true, false)) as unknown as MockMediaStream; expect(stream2.isCloneOf(stream1)).toEqual(false); }); it("doesn't re-use stream if existing stream lacks audio", async () => { const stream1 = await mediaHandler.getUserMediaStream(false, true); - const stream2 = await mediaHandler.getUserMediaStream(true, false) as unknown as MockMediaStream; + const stream2 = (await mediaHandler.getUserMediaStream(true, false)) as unknown as MockMediaStream; expect(stream2.isCloneOf(stream1)).toEqual(false); }); it("doesn't re-use stream if existing stream lacks video", async () => { const stream1 = await mediaHandler.getUserMediaStream(true, false); - const stream2 = await mediaHandler.getUserMediaStream(false, true) as unknown as MockMediaStream; + const stream2 = (await mediaHandler.getUserMediaStream(false, true)) as unknown as MockMediaStream; expect(stream2.isCloneOf(stream1)).toEqual(false); }); it("strips unwanted audio tracks from re-used stream", async () => { const stream1 = await mediaHandler.getUserMediaStream(true, true); - const stream2 = await mediaHandler.getUserMediaStream(false, true) as unknown as MockMediaStream; + const stream2 = (await mediaHandler.getUserMediaStream(false, true)) as unknown as MockMediaStream; expect(stream2.isCloneOf(stream1)).toEqual(true); expect(stream2.getAudioTracks().length).toEqual(0); @@ -306,7 +318,7 @@ describe('Media Handler', function() { it("strips unwanted video tracks from re-used stream", async () => { const stream1 = await mediaHandler.getUserMediaStream(true, true); - const stream2 = await mediaHandler.getUserMediaStream(true, false) as unknown as MockMediaStream; + const stream2 = (await mediaHandler.getUserMediaStream(true, false)) as unknown as MockMediaStream; expect(stream2.isCloneOf(stream1)).toEqual(true); expect(stream2.getVideoTracks().length).toEqual(0); @@ -326,7 +338,7 @@ describe('Media Handler', function() { expect(mockMediaDevices.getDisplayMedia).toHaveBeenCalled(); mockMediaDevices.getDisplayMedia.mockClear(); - const stream2 = await mediaHandler.getScreensharingStream() as unknown as MockMediaStream; + const stream2 = (await mediaHandler.getScreensharingStream()) as unknown as MockMediaStream; expect(mockMediaDevices.getDisplayMedia).not.toHaveBeenCalled(); @@ -338,14 +350,16 @@ describe('Media Handler', function() { desktopCapturerSourceId: FAKE_DESKTOP_SOURCE_ID, }); - expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({ - video: { - mandatory: expect.objectContaining({ - chromeMediaSource: "desktop", - chromeMediaSourceId: FAKE_DESKTOP_SOURCE_ID, - }), - }, - })); + expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith( + expect.objectContaining({ + video: { + mandatory: expect.objectContaining({ + chromeMediaSource: "desktop", + chromeMediaSourceId: FAKE_DESKTOP_SOURCE_ID, + }), + }, + }), + ); }); it("emits LocalStreamsChanged", async () => { diff --git a/src/@types/IIdentityServerProvider.ts b/src/@types/IIdentityServerProvider.ts index 7b905e316b3..05793d53a5d 100644 --- a/src/@types/IIdentityServerProvider.ts +++ b/src/@types/IIdentityServerProvider.ts @@ -18,7 +18,7 @@ export interface IIdentityServerProvider { /** * Gets an access token for use against the identity server, * for the associated client. - * @returns {Promise} Resolves to the access token. + * @returns Promise which resolves to the access token. */ getAccessToken(): Promise; } diff --git a/src/@types/PushRules.ts b/src/@types/PushRules.ts index 7cdfeee2e23..56a93df9fb3 100644 --- a/src/@types/PushRules.ts +++ b/src/@types/PushRules.ts @@ -48,9 +48,9 @@ export enum ConditionOperator { export type PushRuleAction = Tweaks | PushRuleActionName; -export type MemberCountCondition - - = `${Op}${N}` | (Op extends ConditionOperator.ExactEquals ? `${N}` : never); +export type MemberCountCondition = + | `${Op}${N}` + | (Op extends ConditionOperator.ExactEquals ? `${N}` : never); export type AnyMemberCountCondition = MemberCountCondition; @@ -103,7 +103,8 @@ export interface ICallStartedPrefixCondition extends IPushRuleCondition> unfortunately does not resolve this at the time of writing. -export type PushRuleCondition = IEventMatchCondition +export type PushRuleCondition = + | IEventMatchCondition | IContainsDisplayNameCondition | IRoomMemberCountCondition | ISenderNotificationPermissionCondition @@ -157,21 +158,21 @@ export interface IPushRules { } export interface IPusher { - app_display_name: string; - app_id: string; - data: { + "app_display_name": string; + "app_id": string; + "data": { format?: string; url?: string; // TODO: Required if kind==http brand?: string; // TODO: For email notifications only? Unspecced field }; - device_display_name: string; - kind: "http" | string; - lang: string; - profile_tag?: string; - pushkey: string; - enabled?: boolean | null; + "device_display_name": string; + "kind": "http" | string; + "lang": string; + "profile_tag"?: string; + "pushkey": string; + "enabled"?: boolean | null; "org.matrix.msc3881.enabled"?: boolean | null; - device_id?: string | null; + "device_id"?: string | null; "org.matrix.msc3881.device_id"?: string | null; } diff --git a/src/@types/beacon.ts b/src/@types/beacon.ts index 6da17061e61..4f2b257a767 100644 --- a/src/@types/beacon.ts +++ b/src/@types/beacon.ts @@ -35,7 +35,8 @@ import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location"; * To achieve an arbitrary number of only owner-writable state events * we introduce a variable suffix to the event type * - * Eg + * @example + * ``` * { * "type": "m.beacon_info.@matthew:matrix.org.1", * "state_key": "@matthew:matrix.org", @@ -58,6 +59,7 @@ import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location"; * // more content as described below * } * } + * ``` */ /** @@ -78,27 +80,29 @@ export type MBeaconInfoContent = { /** * m.beacon_info Event example from the spec * https://github.com/matrix-org/matrix-spec-proposals/pull/3672 + * @example + * ``` * { - "type": "m.beacon_info", - "state_key": "@matthew:matrix.org", - "content": { - "m.beacon_info": { - "description": "The Matthew Tracker", // same as an `m.location` description - "timeout": 86400000, // how long from the last event until we consider the beacon inactive in milliseconds - }, - "m.ts": 1436829458432, // creation timestamp of the beacon on the client - "m.asset": { - "type": "m.self" // the type of asset being tracked as per MSC3488 - } - } -} + * "type": "m.beacon_info", + * "state_key": "@matthew:matrix.org", + * "content": { + * "m.beacon_info": { + * "description": "The Matthew Tracker", // same as an `m.location` description + * "timeout": 86400000, // how long from the last event until we consider the beacon inactive in milliseconds + * }, + * "m.ts": 1436829458432, // creation timestamp of the beacon on the client + * "m.asset": { + * "type": "m.self" // the type of asset being tracked as per MSC3488 + * } + * } + * } + * ``` */ /** * m.beacon_info.* event content */ -export type MBeaconInfoEventContent = & - MBeaconInfoContent & +export type MBeaconInfoEventContent = MBeaconInfoContent & // creation timestamp of the beacon on the client MTimestampEvent & // the type of asset being tracked as per MSC3488 @@ -107,31 +111,31 @@ export type MBeaconInfoEventContent = & /** * m.beacon event example * https://github.com/matrix-org/matrix-spec-proposals/pull/3672 - * + * @example + * ``` * { - "type": "m.beacon", - "sender": "@matthew:matrix.org", - "content": { - "m.relates_to": { // from MSC2674: https://github.com/matrix-org/matrix-doc/pull/2674 - "rel_type": "m.reference", // from MSC3267: https://github.com/matrix-org/matrix-doc/pull/3267 - "event_id": "$beacon_info" - }, - "m.location": { - "uri": "geo:51.5008,0.1247;u=35", - "description": "Arbitrary beacon information" - }, - "m.ts": 1636829458432, - } -} -*/ + * "type": "m.beacon", + * "sender": "@matthew:matrix.org", + * "content": { + * "m.relates_to": { // from MSC2674: https://github.com/matrix-org/matrix-doc/pull/2674 + * "rel_type": "m.reference", // from MSC3267: https://github.com/matrix-org/matrix-doc/pull/3267 + * "event_id": "$beacon_info" + * }, + * "m.location": { + * "uri": "geo:51.5008,0.1247;u=35", + * "description": "Arbitrary beacon information" + * }, + * "m.ts": 1636829458432, + * } + * } + * ``` + */ /** * Content of an m.beacon event */ -export type MBeaconEventContent = & - MLocationEvent & +export type MBeaconEventContent = MLocationEvent & // timestamp when location was taken MTimestampEvent & // relates to a beacon_info event RELATES_TO_RELATIONSHIP; - diff --git a/src/@types/crypto.ts b/src/@types/crypto.ts index 3c46d993910..91e4d161c7b 100644 --- a/src/@types/crypto.ts +++ b/src/@types/crypto.ts @@ -14,7 +14,33 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type { IClearEvent } from "../models/event"; + export type OlmGroupSessionExtraData = { untrusted?: boolean; sharedHistory?: boolean; }; + +/** + * The result of a (successful) call to {@link Crypto.decryptEvent} + */ +export interface IEventDecryptionResult { + /** + * The plaintext payload for the event (typically containing type and content fields). + */ + clearEvent: IClearEvent; + /** + * List of curve25519 keys involved in telling us about the senderCurve25519Key and claimedEd25519Key. + * See {@link MatrixEvent#getForwardingCurve25519KeyChain}. + */ + forwardingCurve25519KeyChain?: string[]; + /** + * Key owned by the sender of this event. See {@link MatrixEvent#getSenderKey}. + */ + senderCurve25519Key?: string; + /** + * ed25519 key claimed by the sender of this event. See {@link MatrixEvent#getClaimedEd25519Key}. + */ + claimedEd25519Key?: string; + untrusted?: boolean; +} diff --git a/src/@types/event.ts b/src/@types/event.ts index 168097925b2..230cb05039d 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -120,6 +120,8 @@ export enum RoomType { ElementVideo = "io.element.video", } +export const ToDeviceMessageId = "org.matrix.msgid"; + /** * Identifier for an [MSC3088](https://github.com/matrix-org/matrix-doc/pull/3088) * room purpose. Note that this reference is UNSTABLE and subject to breaking changes, @@ -169,21 +171,26 @@ export const UNSTABLE_MSC2716_MARKER = new UnstableValue("m.room.marker", "org.m * eventual removal. * * Schema (TypeScript): + * ``` * { * service_members?: string[] * } + * ``` * - * Example: + * @example + * ``` * { * "service_members": [ * "@helperbot:localhost", * "@reminderbot:alice.tdl" * ] * } + * ``` */ export const UNSTABLE_ELEMENT_FUNCTIONAL_USERS = new UnstableValue( "io.element.functional_members", - "io.element.functional_members"); + "io.element.functional_members", +); /** * A type of message that affects visibility of a message, @@ -191,27 +198,21 @@ export const UNSTABLE_ELEMENT_FUNCTIONAL_USERS = new UnstableValue( * * @experimental */ -export const EVENT_VISIBILITY_CHANGE_TYPE = new UnstableValue( - "m.visibility", - "org.matrix.msc3531.visibility"); +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"); +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/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 @@ -220,7 +221,8 @@ export const PUSHER_DEVICE_ID = new UnstableValue( */ export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue( "m.local_notification_settings", - "org.matrix.msc3890.local_notification_settings"); + "org.matrix.msc3890.local_notification_settings", +); export interface IEncryptedFile { url: string; @@ -233,6 +235,6 @@ export interface IEncryptedFile { ext: boolean; }; iv: string; - hashes: {[alg: string]: string}; + hashes: { [alg: string]: string }; v: string; } diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 6b512434902..749eb7f417b 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -51,18 +51,22 @@ declare global { } interface DesktopCapturerConstraints { - audio: boolean | { - mandatory: { - chromeMediaSource: string; - chromeMediaSourceId: string; - }; - }; - video: boolean | { - mandatory: { - chromeMediaSource: string; - chromeMediaSourceId: string; - }; - }; + audio: + | boolean + | { + mandatory: { + chromeMediaSource: string; + chromeMediaSourceId: string; + }; + }; + video: + | boolean + | { + mandatory: { + chromeMediaSource: string; + chromeMediaSourceId: string; + }; + }; } interface DummyInterfaceWeShouldntBeUsingThis {} diff --git a/src/@types/location.ts b/src/@types/location.ts index 9fc37d349e7..023557b7788 100644 --- a/src/@types/location.ts +++ b/src/@types/location.ts @@ -38,8 +38,7 @@ export const M_TIMESTAMP = new UnstableValue("m.ts", "org.matrix.msc3488.ts"); */ export type MTimestampEvent = EitherAnd<{ [M_TIMESTAMP.name]: number }, { [M_TIMESTAMP.altName]: number }>; -export const M_LOCATION = new UnstableValue( - "m.location", "org.matrix.msc3488.location"); +export const M_LOCATION = new UnstableValue("m.location", "org.matrix.msc3488.location"); export type MLocationContent = { uri: string; @@ -76,12 +75,8 @@ export type MTextEvent = EitherAnd<{ [TEXT_NODE_TYPE.name]: string }, { [TEXT_NO type OptionalTimestampEvent = MTimestampEvent | undefined; /** * The content for an m.location event -*/ -export type MLocationEventContent = & - MLocationEvent & - MAssetEvent & - MTextEvent & - OptionalTimestampEvent; + */ +export type MLocationEventContent = MLocationEvent & MAssetEvent & MTextEvent & OptionalTimestampEvent; export type LegacyLocationEventContent = { body: string; diff --git a/src/@types/partials.ts b/src/@types/partials.ts index bf27eab0e53..49f92f32759 100644 --- a/src/@types/partials.ts +++ b/src/@types/partials.ts @@ -17,7 +17,8 @@ limitations under the License. export interface IImageInfo { size?: number; mimetype?: string; - thumbnail_info?: { // eslint-disable-line camelcase + thumbnail_info?: { + // eslint-disable-line camelcase w?: number; h?: number; size?: number; diff --git a/src/@types/read_receipts.ts b/src/@types/read_receipts.ts index 689313672a3..34f1c67fad7 100644 --- a/src/@types/read_receipts.ts +++ b/src/@types/read_receipts.ts @@ -38,11 +38,11 @@ export interface CachedReceipt { data: Receipt; } -export type ReceiptCache = {[eventId: string]: CachedReceipt[]}; +export type ReceiptCache = { [eventId: string]: CachedReceipt[] }; export interface ReceiptContent { [eventId: string]: { - [key in ReceiptType]: { + [key in ReceiptType | string]: { [userId: string]: Receipt; }; }; diff --git a/src/@types/requests.ts b/src/@types/requests.ts index f9095455e15..75296940a9c 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -54,17 +54,31 @@ export interface ISendEventResponse { } export interface IPresenceOpts { + // One of "online", "offline" or "unavailable" presence: "online" | "offline" | "unavailable"; + // The status message to attach. status_msg?: string; } export interface IPaginateOpts { + // true to fill backwards, false to go forwards backwards?: boolean; + // number of events to request limit?: number; } export interface IGuestAccessOpts { + /** + * True to allow guests to join this room. This + * implicitly gives guests write access. If false or not given, guests are + * explicitly forbidden from joining the room. + */ allowJoin: boolean; + /** + * True to set history visibility to + * be world_readable. This gives guests read access *from this point forward*. + * If false or not given, history visibility is not modified. + */ allowRead: boolean; } @@ -74,7 +88,9 @@ export interface ISearchOpts { } export interface IEventSearchOpts { + // a JSON filter object to pass in the request filter?: IRoomEventFilter; + // the term to search for term: string; } @@ -92,9 +108,13 @@ export interface ICreateRoomStateEvent { } export interface ICreateRoomOpts { + // The alias localpart to assign to this room. room_alias_name?: string; + // Either 'public' or 'private'. visibility?: Visibility; + // The name to give this room. name?: string; + // The topic to give this room. topic?: string; preset?: Preset; power_level_content_override?: { @@ -111,6 +131,7 @@ export interface ICreateRoomOpts { }; creation_content?: object; initial_state?: ICreateRoomStateEvent[]; + // A list of user IDs to invite to this room. invite?: string[]; invite_3pid?: IInvite3PID[]; is_direct?: boolean; @@ -121,7 +142,10 @@ export interface IRoomDirectoryOptions { server?: string; limit?: number; since?: string; + + // Filter parameters filter?: { + // String to search for generic_search_term?: string; room_types?: Array; }; @@ -153,7 +177,6 @@ export interface IRelationsRequestOpts { } export interface IRelationsResponse { - original_event: IEvent; chunk: IEvent[]; next_batch?: string; prev_batch?: string; diff --git a/src/@types/sync.ts b/src/@types/sync.ts index 036c542bafa..d9a2a6f564d 100644 --- a/src/@types/sync.ts +++ b/src/@types/sync.ts @@ -23,4 +23,5 @@ import { ServerControlledNamespacedValue } from "../NamespacedValue"; */ export const UNREAD_THREAD_NOTIFICATIONS = new ServerControlledNamespacedValue( "unread_thread_notifications", - "org.matrix.msc3773.unread_thread_notifications"); + "org.matrix.msc3773.unread_thread_notifications", +); diff --git a/src/@types/topic.ts b/src/@types/topic.ts index 0d2708b2e50..5b66e07c46f 100644 --- a/src/@types/topic.ts +++ b/src/@types/topic.ts @@ -21,10 +21,9 @@ import { UnstableValue } from "../NamespacedValue"; /** * Extensible topic event type based on MSC3765 * https://github.com/matrix-org/matrix-spec-proposals/pull/3765 - */ - -/** - * Eg + * + * @example + * ``` * { * "type": "m.room.topic, * "state_key": "", @@ -39,6 +38,7 @@ import { UnstableValue } from "../NamespacedValue"; * }], * } * } + * ``` */ /** diff --git a/src/@types/uia.ts b/src/@types/uia.ts index a976083a65d..e6114200982 100644 --- a/src/@types/uia.ts +++ b/src/@types/uia.ts @@ -20,7 +20,7 @@ import { IAuthData } from "../interactive-auth"; * Helper type to represent HTTP request body for a UIA enabled endpoint */ export type UIARequest = T & { - auth?: IAuthData; + auth?: IAuthData; }; /** diff --git a/src/NamespacedValue.ts b/src/NamespacedValue.ts index dc028bec4ca..a1a7e5d2bbd 100644 --- a/src/NamespacedValue.ts +++ b/src/NamespacedValue.ts @@ -82,8 +82,7 @@ export class NamespacedValue { } } -export class ServerControlledNamespacedValue - extends NamespacedValue { +export class ServerControlledNamespacedValue extends NamespacedValue { private preferUnstable = false; public setPreferUnstable(preferUnstable: boolean): void { diff --git a/src/ReEmitter.ts b/src/ReEmitter.ts index 2fa6eface1e..565e8ea702c 100644 --- a/src/ReEmitter.ts +++ b/src/ReEmitter.ts @@ -49,7 +49,7 @@ export class ReEmitter { // later by a different part of the code where 'emit' throwing because the app hasn't // added an error handler isn't terribly helpful. (A better fix in retrospect may // have been to just avoid using the event name 'error', but backwards compat...) - if (eventName === 'error' && this.target.listenerCount('error') === 0) return; + if (eventName === "error" && this.target.listenerCount("error") === 0) return; this.target.emit(eventName, ...args, source); }; source.on(eventName, forSource); @@ -70,10 +70,7 @@ export class ReEmitter { } } -export class TypedReEmitter< - Events extends string, - Arguments extends ListenerMap, -> extends ReEmitter { +export class TypedReEmitter> extends ReEmitter { public constructor(target: TypedEventEmitter) { super(target); } diff --git a/src/ToDeviceMessageQueue.ts b/src/ToDeviceMessageQueue.ts index 0b5b1786a63..e78c46ba200 100644 --- a/src/ToDeviceMessageQueue.ts +++ b/src/ToDeviceMessageQueue.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { ToDeviceMessageId } from "./@types/event"; import { logger } from "./logger"; import { MatrixError, MatrixClient } from "./matrix"; import { IndexedToDeviceBatch, ToDeviceBatch, ToDeviceBatchWithTxnId, ToDevicePayload } from "./models/ToDeviceMessage"; @@ -31,8 +32,7 @@ export class ToDeviceMessageQueue { private retryTimeout: ReturnType | null = null; private retryAttempts = 0; - public constructor(private client: MatrixClient) { - } + public constructor(private client: MatrixClient) {} public start(): void { this.running = true; @@ -54,12 +54,16 @@ export class ToDeviceMessageQueue { txnId: this.client.makeTxnId(), }; batches.push(batchWithTxnId); - const recips = batchWithTxnId.batch.map((msg) => `${msg.userId}:${msg.deviceId}`); - logger.info(`Created batch of to-device messages with txn id ${batchWithTxnId.txnId} for ${recips}`); + const msgmap = batchWithTxnId.batch.map( + (msg) => `${msg.userId}/${msg.deviceId} (msgid ${msg.payload[ToDeviceMessageId]})`, + ); + logger.info( + `Enqueuing batch of to-device messages. type=${batch.eventType} txnid=${batchWithTxnId.txnId}`, + msgmap, + ); } await this.client.store.saveToDeviceBatches(batches); - logger.info(`Enqueued to-device messages with txn ids ${batches.map((batch) => batch.txnId)}`); this.sendQueue(); } diff --git a/src/autodiscovery.ts b/src/autodiscovery.ts index d26e4d5c683..aa839550f9e 100644 --- a/src/autodiscovery.ts +++ b/src/autodiscovery.ts @@ -15,10 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -/** @module auto-discovery */ - import { IClientWellKnown, IWellKnownConfig } from "./client"; -import { logger } from './logger'; +import { logger } from "./logger"; import { MatrixError, Method, timeoutSignal } from "./http-api"; // Dev note: Auto discovery is part of the spec. @@ -49,7 +47,7 @@ interface WellKnownConfig extends Omit { error?: IWellKnownConfig["error"] | null; } -interface ClientConfig { +interface ClientConfig extends Omit { "m.homeserver": WellKnownConfig; "m.identity_server": WellKnownConfig; } @@ -87,8 +85,6 @@ export class AutoDiscovery { /** * The auto discovery failed. The client is expected to communicate * the error to the user and refuse logging in. - * @return {string} - * @constructor */ public static readonly FAIL_ERROR = AutoDiscoveryAction.FAIL_ERROR; @@ -98,8 +94,6 @@ export class AutoDiscovery { * action it would for PROMPT while also warning the user about * what went wrong. The client may also treat this the same as * a FAIL_ERROR state. - * @return {string} - * @constructor */ public static readonly FAIL_PROMPT = AutoDiscoveryAction.FAIL_PROMPT; @@ -107,15 +101,11 @@ export class AutoDiscovery { * The auto discovery didn't fail but did not find anything of * interest. The client is expected to prompt the user for more * information, or fail if it prefers. - * @return {string} - * @constructor */ public static readonly PROMPT = AutoDiscoveryAction.PROMPT; /** * The auto discovery was successful. - * @return {string} - * @constructor */ public static readonly SUCCESS = AutoDiscoveryAction.SUCCESS; @@ -125,13 +115,13 @@ export class AutoDiscovery { * and identity server URL the client would want. Additional details * may also be included, and will be transparently brought into the * response object unaltered. - * @param {object} wellknown The configuration object itself, as returned + * @param wellknown - The configuration object itself, as returned * by the .well-known auto-discovery endpoint. - * @return {Promise} Resolves to the verified + * @returns Promise which resolves to the verified * configuration, which may include error states. Rejects on unexpected * failure, not when verification fails. */ - public static async fromDiscoveryConfig(wellknown: any): Promise { + public static async fromDiscoveryConfig(wellknown: IClientWellKnown): Promise { // Step 1 is to get the config, which is provided to us here. // We default to an error state to make the first few checks easier to @@ -172,9 +162,7 @@ export class AutoDiscovery { // Step 2: Make sure the homeserver URL is valid *looking*. We'll make // sure it points to a homeserver in Step 3. - const hsUrl = this.sanitizeWellKnownUrl( - wellknown["m.homeserver"]["base_url"], - ); + const hsUrl = this.sanitizeWellKnownUrl(wellknown["m.homeserver"]["base_url"]); if (!hsUrl) { logger.error("Invalid base_url for m.homeserver"); clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HS_BASE_URL; @@ -182,10 +170,8 @@ export class AutoDiscovery { } // Step 3: Make sure the homeserver URL points to a homeserver. - const hsVersions = await this.fetchWellKnownObject( - `${hsUrl}/_matrix/client/versions`, - ); - if (!hsVersions || !hsVersions.raw["versions"]) { + const hsVersions = await this.fetchWellKnownObject(`${hsUrl}/_matrix/client/versions`); + if (!hsVersions || !hsVersions.raw?.["versions"]) { logger.error("Invalid /versions response"); clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER; @@ -219,25 +205,19 @@ export class AutoDiscovery { // Step 5a: Make sure the URL is valid *looking*. We'll make sure it // points to an identity server in Step 5b. - isUrl = this.sanitizeWellKnownUrl( - wellknown["m.identity_server"]["base_url"], - ); + isUrl = this.sanitizeWellKnownUrl(wellknown["m.identity_server"]["base_url"]); if (!isUrl) { logger.error("Invalid base_url for m.identity_server"); - failingClientConfig["m.identity_server"].error = - AutoDiscovery.ERROR_INVALID_IS_BASE_URL; + failingClientConfig["m.identity_server"].error = AutoDiscovery.ERROR_INVALID_IS_BASE_URL; return Promise.resolve(failingClientConfig); } // Step 5b: Verify there is an identity server listening on the provided // URL. - const isResponse = await this.fetchWellKnownObject( - `${isUrl}/_matrix/identity/api/v1`, - ); - if (!isResponse || !isResponse.raw || isResponse.action !== AutoDiscoveryAction.SUCCESS) { + const isResponse = await this.fetchWellKnownObject(`${isUrl}/_matrix/identity/api/v1`); + if (!isResponse?.raw || isResponse.action !== AutoDiscoveryAction.SUCCESS) { logger.error("Invalid /api/v1 response"); - failingClientConfig["m.identity_server"].error = - AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER; + failingClientConfig["m.identity_server"].error = AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER; // Supply the base_url to the caller because they may be ignoring // liveliness errors, like this one. @@ -259,14 +239,16 @@ export class AutoDiscovery { // Step 7: Copy any other keys directly into the clientConfig. This is for // things like custom configuration of services. - Object.keys(wellknown).forEach((k) => { + Object.keys(wellknown).forEach((k: keyof IClientWellKnown) => { if (k === "m.homeserver" || k === "m.identity_server") { // Only copy selected parts of the config to avoid overwriting // properties computed by the validation logic above. const notProps = ["error", "state", "base_url"]; - for (const prop of Object.keys(wellknown[k])) { + for (const prop of Object.keys(wellknown[k]!)) { if (notProps.includes(prop)) continue; - clientConfig[k][prop] = wellknown[k][prop]; + type Prop = Exclude; + // @ts-ignore - ts gets unhappy as we're mixing types here + clientConfig[k][prop as Prop] = wellknown[k]![prop as Prop]; } } else { // Just copy the whole thing over otherwise @@ -284,14 +266,14 @@ export class AutoDiscovery { * and identity server URL the client would want. Additional details * may also be discovered, and will be transparently included in the * response object unaltered. - * @param {string} domain The homeserver domain to perform discovery + * @param domain - The homeserver domain to perform discovery * on. For example, "matrix.org". - * @return {Promise} Resolves to the discovered + * @returns Promise which resolves to the discovered * configuration, which may include error states. Rejects on unexpected * failure, not when discovery fails. */ public static async findClientConfig(domain: string): Promise { - if (!domain || typeof(domain) !== "string" || domain.length === 0) { + if (!domain || typeof domain !== "string" || domain.length === 0) { throw new Error("'domain' must be a string of non-zero length"); } @@ -326,9 +308,7 @@ export class AutoDiscovery { // Step 1: Actually request the .well-known JSON file and make sure it // at least has a homeserver definition. - const wellknown = await this.fetchWellKnownObject( - `https://${domain}/.well-known/matrix/client`, - ); + const wellknown = await this.fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`); if (!wellknown || wellknown.action !== AutoDiscoveryAction.SUCCESS) { logger.error("No response or error when parsing .well-known"); if (wellknown.reason) logger.error(wellknown.reason); @@ -347,25 +327,23 @@ export class AutoDiscovery { } // Step 2: Validate and parse the config - return AutoDiscovery.fromDiscoveryConfig(wellknown.raw); + return AutoDiscovery.fromDiscoveryConfig(wellknown.raw!); } /** * Gets the raw discovery client configuration for the given domain name. * Should only be used if there's no validation to be done on the resulting * object, otherwise use findClientConfig(). - * @param {string} domain The domain to get the client config for. - * @returns {Promise} Resolves to the domain's client config. Can + * @param domain - The domain to get the client config for. + * @returns Promise which resolves to the domain's client config. Can * be an empty object. */ public static async getRawClientConfig(domain?: string): Promise { - if (!domain || typeof(domain) !== "string" || domain.length === 0) { + if (!domain || typeof domain !== "string" || domain.length === 0) { throw new Error("'domain' must be a string of non-zero length"); } - const response = await this.fetchWellKnownObject( - `https://${domain}/.well-known/matrix/client`, - ); + const response = await this.fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`); if (!response) return {}; return response.raw || {}; } @@ -374,11 +352,11 @@ export class AutoDiscovery { * Sanitizes a given URL to ensure it is either an HTTP or HTTP URL and * is suitable for the requirements laid out by .well-known auto discovery. * If valid, the URL will also be stripped of any trailing slashes. - * @param {string} url The potentially invalid URL to sanitize. - * @return {string|boolean} The sanitized URL or a falsey value if the URL is invalid. - * @private + * @param url - The potentially invalid URL to sanitize. + * @returns The sanitized URL or a falsey value if the URL is invalid. + * @internal */ - private static sanitizeWellKnownUrl(url: string): string | false { + private static sanitizeWellKnownUrl(url?: string | null): string | false { if (!url) return false; try { @@ -430,9 +408,9 @@ export class AutoDiscovery { * action: One of SUCCESS, IGNORE, or FAIL_PROMPT. * reason: Relatively human-readable description of what went wrong. * error: The actual Error, if one exists. - * @param {string} url The URL to fetch a JSON object from. - * @return {Promise} Resolves to the returned state. - * @private + * @param url - The URL to fetch a JSON object from. + * @returns Promise which resolves to the returned state. + * @internal */ private static async fetchWellKnownObject(url: string): Promise { let response: Response; @@ -484,9 +462,10 @@ export class AutoDiscovery { error, raw: {}, action: AutoDiscoveryAction.FAIL_PROMPT, - reason: (error as MatrixError)?.name === "SyntaxError" - ? AutoDiscovery.ERROR_INVALID_JSON - : AutoDiscovery.ERROR_INVALID, + reason: + (error as MatrixError)?.name === "SyntaxError" + ? AutoDiscovery.ERROR_INVALID_JSON + : AutoDiscovery.ERROR_INVALID, }; } } diff --git a/src/browser-index.js b/src/browser-index.ts similarity index 76% rename from src/browser-index.js rename to src/browser-index.ts index 86e887bd49f..200b2a32d7c 100644 --- a/src/browser-index.js +++ b/src/browser-index.ts @@ -16,27 +16,28 @@ limitations under the License. import * as matrixcs from "./matrix"; +type BrowserMatrix = typeof matrixcs; +declare global { + /* eslint-disable no-var, camelcase */ + var __js_sdk_entrypoint: boolean; + var matrixcs: BrowserMatrix; + /* eslint-enable no-var */ +} + if (global.__js_sdk_entrypoint) { throw new Error("Multiple matrix-js-sdk entrypoints detected!"); } global.__js_sdk_entrypoint = true; -// just *accessing* indexedDB throws an exception in firefox with -// indexeddb disabled. -let indexedDB; +// just *accessing* indexedDB throws an exception in firefox with indexeddb disabled. +let indexedDB: IDBFactory | undefined; try { indexedDB = global.indexedDB; } catch (e) {} // if our browser (appears to) support indexeddb, use an indexeddb crypto store. if (indexedDB) { - matrixcs.setCryptoStoreFactory( - function() { - return new matrixcs.IndexedDBCryptoStore( - indexedDB, "matrix-js-sdk:crypto", - ); - }, - ); + matrixcs.setCryptoStoreFactory(() => new matrixcs.IndexedDBCryptoStore(indexedDB!, "matrix-js-sdk:crypto")); } // We export 3 things to make browserify happy as well as downstream projects. diff --git a/src/client.ts b/src/client.ts index ed2dbc06d8c..a4aa9ded27c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -16,7 +16,6 @@ limitations under the License. /** * This is an internal module. See {@link MatrixClient} for the public class. - * @module client */ import { EmoteEvent, IPartialEvent, MessageEvent, NoticeEvent, Optional } from "matrix-events-sdk"; @@ -34,10 +33,10 @@ import { import { StubStore } from "./store/stub"; import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call"; import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter"; -import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler'; -import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from './webrtc/groupCallEventHandler'; -import * as utils from './utils'; -import { replaceParam, QueryDict, sleep } from './utils'; +import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from "./webrtc/callEventHandler"; +import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from "./webrtc/groupCallEventHandler"; +import * as utils from "./utils"; +import { replaceParam, QueryDict, sleep } from "./utils"; import { Direction, EventTimeline } from "./models/event-timeline"; import { IActionsObject, PushProcessor } from "./pushprocessor"; import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery"; @@ -45,10 +44,10 @@ import * as olmlib from "./crypto/olmlib"; import { decodeBase64, encodeBase64 } from "./crypto/olmlib"; import { IExportedDevice as IExportedOlmDevice } from "./crypto/OlmDevice"; import { IOlmDevice } from "./crypto/algorithms/megolm"; -import { TypedReEmitter } from './ReEmitter'; -import { IRoomEncryption, RoomList } from './crypto/RoomList'; -import { logger } from './logger'; -import { SERVICE_TYPES } from './service-types'; +import { TypedReEmitter } from "./ReEmitter"; +import { IRoomEncryption, RoomList } from "./crypto/RoomList"; +import { logger } from "./logger"; +import { SERVICE_TYPES } from "./service-types"; import { HttpApiEvent, HttpApiEventHandlerMap, @@ -78,10 +77,11 @@ import { IMegolmSessionData, isCryptoAvailable, VerificationMethod, -} from './crypto'; + IRoomKeyRequestBody, +} from "./crypto"; import { DeviceInfo, IDevice } from "./crypto/deviceinfo"; -import { decodeRecoveryKey } from './crypto/recoverykey'; -import { keyFromAuthData } from './crypto/key_passphrase'; +import { decodeRecoveryKey } from "./crypto/recoverykey"; +import { keyFromAuthData } from "./crypto/key_passphrase"; import { User, UserEvent, UserEventHandlerMap } from "./models/user"; import { getHttpUriForMxc } from "./content-repo"; import { SearchResult } from "./models/search-result"; @@ -184,13 +184,8 @@ import { RuleId, } from "./@types/PushRules"; import { IThreepid } from "./@types/threepids"; -import { CryptoStore } from "./crypto/store/base"; -import { - GroupCall, - IGroupCallDataChannelOptions, - GroupCallIntent, - GroupCallType, -} from "./webrtc/groupCall"; +import { CryptoStore, OutgoingRoomKeyRequest } from "./crypto/store/base"; +import { GroupCall, IGroupCallDataChannelOptions, GroupCallIntent, GroupCallType } from "./webrtc/groupCall"; import { MediaHandler } from "./webrtc/mediaHandler"; import { GroupCallEventHandler } from "./webrtc/groupCallEventHandler"; import { LoginTokenPostResponse, ILoginFlowsResponse, IRefreshTokenResponse, SSOAction } from "./@types/auth"; @@ -215,6 +210,7 @@ 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"; +import { CryptoBackend } from "./common-crypto/CryptoBackend"; export type Store = IStore; @@ -237,7 +233,8 @@ interface IExportedDevice { } export interface IKeysUploadResponse { - one_time_key_counts: { // eslint-disable-line camelcase + one_time_key_counts: { + // eslint-disable-line camelcase [algorithm: string]: number; }; } @@ -257,7 +254,10 @@ export interface ICreateClientOpts { /** * A store to be used for end-to-end crypto session data. If not specified, * end-to-end crypto will be disabled. The `createClient` helper will create - * a default store if needed. + * a default store if needed. Calls the factory supplied to + * {@link setCryptoStoreFactory} if unspecified; or if no factory has been + * specified, uses a default implementation (indexeddb in the browser, + * in-memory otherwise). */ cryptoStore?: CryptoStore; @@ -265,7 +265,7 @@ export interface ICreateClientOpts { * The scheduler to use. If not * specified, this client will not retry requests on failure. This client * will supply its own processing function to - * {@link module:scheduler~MatrixScheduler#setProcessFunction}. + * {@link MatrixScheduler#setProcessFunction}. */ scheduler?: MatrixScheduler; @@ -310,8 +310,8 @@ export interface ICreateClientOpts { /** * Set to true to enable - * improved timeline support ({@link module:client~MatrixClient#getEventTimeline getEventTimeline}). It is - * disabled by default for compatibility with older clients - in particular to + * improved timeline support, see {@link MatrixClient#getEventTimeline}. + * It is disabled by default for compatibility with older clients - in particular to * maintain support for back-paginating the live timeline after a '/sync' * result with a gap. */ @@ -320,7 +320,7 @@ export interface ICreateClientOpts { /** * Extra query parameters to append * to all requests with this client. Useful for application services which require - * ?user_id=. + * `?user_id=`. */ queryParams?: Record; @@ -394,12 +394,12 @@ export enum PendingEventOrdering { export interface IStartClientOpts { /** - * The event limit= to apply to initial sync. Default: 8. + * The event `limit=` to apply to initial sync. Default: 8. */ initialSyncLimit?: number; /** - * True to put archived=true on the /initialSync request. Default: false. + * True to put `archived=true on the /initialSync` request. Default: false. */ includeArchivedRooms?: boolean; @@ -410,8 +410,8 @@ export interface IStartClientOpts { /** * Controls where pending messages appear in a room's timeline. If "chronological", messages will - * appear in the timeline when the call to sendEvent was made. If "detached", - * pending messages will appear in a separate list, accessbile via {@link module:models/room#getPendingEvents}. + * appear in the timeline when the call to `sendEvent` was made. If "detached", + * pending messages will appear in a separate list, accessbile via {@link Room#getPendingEvents}. * Default: "chronological". */ pendingEventOrdering?: PendingEventOrdering; @@ -451,11 +451,18 @@ export interface IStartClientOpts { /** * @experimental */ - slidingSync?: SlidingSync; + slidingSync?: SlidingSync; } export interface IStoredClientOpts extends IStartClientOpts { + // Crypto manager crypto?: Crypto; + /** + * A function which is called + * with a room ID and returns a boolean. It should return 'true' if the SDK can + * SAFELY remove events from this room. It may not be safe to remove events if + * there are other references to the timelines for this room. + */ canResetEntireTimeline: ResetTimelineCallback; } @@ -510,10 +517,16 @@ export interface ISignedKey { export type KeySignatures = Record>; export interface IUploadKeySignaturesResponse { - failures: Record>; + failures: Record< + string, + Record< + string, + { + errcode: string; + error: string; + } + > + >; } export interface IPreviewUrlResponse { @@ -529,7 +542,7 @@ export interface IPreviewUrlResponse { "matrix:image:size"?: number; } -interface ITurnServerResponse { +export interface ITurnServerResponse { uris: string[]; username: string; password: string; @@ -547,10 +560,7 @@ export interface IServerVersions { unstable_features: Record; } -export const M_AUTHENTICATION = new UnstableValue( - "m.authentication", - "org.matrix.msc2965.authentication", -); +export const M_AUTHENTICATION = new UnstableValue("m.authentication", "org.matrix.msc2965.authentication"); export interface IClientWellKnown { [key: string]: any; @@ -560,7 +570,7 @@ export interface IClientWellKnown { } export interface IWellKnownConfig { - raw?: any; // todo typings + raw?: IClientWellKnown; action?: AutoDiscoveryAction; reason?: string; error?: Error | string; @@ -568,7 +578,8 @@ export interface IWellKnownConfig { base_url?: string | null; } -export interface IDelegatedAuthConfig { // MSC2965 +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 */ @@ -604,10 +615,10 @@ interface ITagMetadata { } interface IMessagesResponse { - start: string; - end: string; + start?: string; + end?: string; chunk: IRoomEvent[]; - state: IStateEvent[]; + state?: IStateEvent[]; } interface IThreadedMessagesResponse { @@ -629,11 +640,22 @@ export interface IRequestMsisdnTokenResponse extends IRequestTokenResponse { } export interface IUploadKeysRequest { - device_keys?: Required; - one_time_keys?: Record; + "device_keys"?: Required; + "one_time_keys"?: Record; "org.matrix.msc2732.fallback_keys"?: Record; } +export interface IQueryKeysRequest { + device_keys: { [userId: string]: string[] }; + timeout?: number; + token?: string; +} + +export interface IClaimKeysRequest { + one_time_keys: { [userId: string]: { [deviceId: string]: string } }; + timeout?: number; +} + export interface IOpenIDToken { access_token: string; token_type: "Bearer" | string; @@ -711,12 +733,12 @@ interface IUserDirectoryResponse { } export interface IMyDevice { - device_id: string; - display_name?: string; - last_seen_ip?: string; - last_seen_ts?: number; + "device_id": string; + "display_name"?: string; + "last_seen_ip"?: string; + "last_seen_ts"?: number; // UNSTABLE_MSC3852_LAST_SEEN_UA - last_seen_user_agent?: string; + "last_seen_user_agent"?: string; "org.matrix.msc3852.last_seen_user_agent"?: string; } @@ -840,7 +862,8 @@ export enum ClientEvent { TurnServersError = "turnServers.error", } -type RoomEvents = RoomEvent.Name +type RoomEvents = + | RoomEvent.Name | RoomEvent.Redaction | RoomEvent.RedactionCancelled | RoomEvent.Receipt @@ -852,14 +875,15 @@ type RoomEvents = RoomEvent.Name | RoomEvent.Timeline | RoomEvent.TimelineReset; -type RoomStateEvents = RoomStateEvent.Events +type RoomStateEvents = + | RoomStateEvent.Events | RoomStateEvent.Members | RoomStateEvent.NewMember | RoomStateEvent.Update - | RoomStateEvent.Marker - ; + | RoomStateEvent.Marker; -type CryptoEvents = CryptoEvent.KeySignatureUploadFailure +type CryptoEvents = + | CryptoEvent.KeySignatureUploadFailure | CryptoEvent.KeyBackupStatus | CryptoEvent.KeyBackupFailed | CryptoEvent.KeyBackupSessionsRemaining @@ -875,18 +899,21 @@ type CryptoEvents = CryptoEvent.KeySignatureUploadFailure type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange; -type RoomMemberEvents = RoomMemberEvent.Name +type RoomMemberEvents = + | RoomMemberEvent.Name | RoomMemberEvent.Typing | RoomMemberEvent.PowerLevel | RoomMemberEvent.Membership; -type UserEvents = UserEvent.AvatarUrl +type UserEvents = + | UserEvent.AvatarUrl | UserEvent.DisplayName | UserEvent.Presence | UserEvent.CurrentlyActive | UserEvent.LastPresenceTs; -export type EmittedEvents = ClientEvent +export type EmittedEvents = + | ClientEvent | RoomEvents | RoomStateEvents | CryptoEvents @@ -904,28 +931,199 @@ export type EmittedEvents = ClientEvent | BeaconEvent; export type ClientEventHandlerMap = { + /** + * Fires whenever the SDK's syncing state is updated. The state can be one of: + *
    + * + *
  • PREPARED: The client has synced with the server at least once and is + * ready for methods to be called on it. This will be immediately followed by + * a state of SYNCING. This is the equivalent of "syncComplete" in the + * previous API.
  • + * + *
  • CATCHUP: The client has detected the connection to the server might be + * available again and will now try to do a sync again. As this sync might take + * a long time (depending how long ago was last synced, and general server + * performance) the client is put in this mode so the UI can reflect trying + * to catch up with the server after losing connection.
  • + * + *
  • SYNCING : The client is currently polling for new events from the server. + * This will be called after processing latest events from a sync.
  • + * + *
  • ERROR : The client has had a problem syncing with the server. If this is + * called before PREPARED then there was a problem performing the initial + * sync. If this is called after PREPARED then there was a problem polling + * the server for updates. This may be called multiple times even if the state is + * already ERROR. This is the equivalent of "syncError" in the previous + * API.
  • + * + *
  • RECONNECTING: The sync connection has dropped, but not (yet) in a way that + * should be considered erroneous. + *
  • + * + *
  • STOPPED: The client has stopped syncing with server due to stopClient + * being called. + *
  • + *
+ * State transition diagram: + * ``` + * +---->STOPPED + * | + * +----->PREPARED -------> SYNCING <--+ + * | ^ | ^ | + * | CATCHUP ----------+ | | | + * | ^ V | | + * null ------+ | +------- RECONNECTING | + * | V V | + * +------->ERROR ---------------------+ + * + * NB: 'null' will never be emitted by this event. + * + * ``` + * Transitions: + *
    + * + *
  • `null -> PREPARED` : Occurs when the initial sync is completed + * first time. This involves setting up filters and obtaining push rules. + * + *
  • `null -> ERROR` : Occurs when the initial sync failed first time. + * + *
  • `ERROR -> PREPARED` : Occurs when the initial sync succeeds + * after previously failing. + * + *
  • `PREPARED -> SYNCING` : Occurs immediately after transitioning + * to PREPARED. Starts listening for live updates rather than catching up. + * + *
  • `SYNCING -> RECONNECTING` : Occurs when the live update fails. + * + *
  • `RECONNECTING -> RECONNECTING` : Can occur if the update calls + * continue to fail, but the keepalive calls (to /versions) succeed. + * + *
  • `RECONNECTING -> ERROR` : Occurs when the keepalive call also fails + * + *
  • `ERROR -> SYNCING` : Occurs when the client has performed a + * live update after having previously failed. + * + *
  • `ERROR -> ERROR` : Occurs when the client has failed to keepalive + * for a second time or more.
  • + * + *
  • `SYNCING -> SYNCING` : Occurs when the client has performed a live + * update. This is called after processing.
  • + * + *
  • `* -> STOPPED` : Occurs once the client has stopped syncing or + * trying to sync after stopClient has been called.
  • + *
+ * + * @param state - An enum representing the syncing state. One of "PREPARED", + * "SYNCING", "ERROR", "STOPPED". + * + * @param prevState - An enum representing the previous syncing state. + * One of "PREPARED", "SYNCING", "ERROR", "STOPPED" or null. + * + * @param data - Data about this transition. + * + * @example + * ``` + * matrixClient.on("sync", function(state, prevState, data) { + * switch (state) { + * case "ERROR": + * // update UI to say "Connection Lost" + * break; + * case "SYNCING": + * // update UI to remove any "Connection Lost" message + * break; + * case "PREPARED": + * // the client instance is ready to be queried. + * var rooms = matrixClient.getRooms(); + * break; + * } + * }); + * ``` + */ [ClientEvent.Sync]: (state: SyncState, lastState: SyncState | null, data?: ISyncStateData) => void; + /** + * Fires whenever the SDK receives a new event. + *

+ * This is only fired for live events received via /sync - it is not fired for + * events received over context, search, or pagination APIs. + * + * @param event - The matrix event which caused this event to fire. + * @example + * ``` + * matrixClient.on("event", function(event){ + * var sender = event.getSender(); + * }); + * ``` + */ [ClientEvent.Event]: (event: MatrixEvent) => void; + /** + * Fires whenever the SDK receives a new to-device event. + * @param event - The matrix event which caused this event to fire. + * @example + * ``` + * matrixClient.on("toDeviceEvent", function(event){ + * var sender = event.getSender(); + * }); + * ``` + */ [ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void; + /** + * Fires whenever new user-scoped account_data is added. + * @param event - The event describing the account_data just added + * @param event - The previous account data, if known. + * @example + * ``` + * matrixClient.on("accountData", function(event, oldEvent){ + * myAccountData[event.type] = event.content; + * }); + * ``` + */ [ClientEvent.AccountData]: (event: MatrixEvent, lastEvent?: MatrixEvent) => void; + /** + * Fires whenever a new Room is added. This will fire when you are invited to a + * room, as well as when you join a room. This event is experimental and + * may change. + * @param room - The newly created, fully populated room. + * @example + * ``` + * matrixClient.on("Room", function(room){ + * var roomId = room.roomId; + * }); + * ``` + */ [ClientEvent.Room]: (room: Room) => void; + /** + * Fires whenever a Room is removed. This will fire when you forget a room. + * This event is experimental and may change. + * @param roomId - The deleted room ID. + * @example + * ``` + * matrixClient.on("deleteRoom", function(roomId){ + * // update UI from getRooms() + * }); + * ``` + */ [ClientEvent.DeleteRoom]: (roomId: string) => void; [ClientEvent.SyncUnexpectedError]: (error: Error) => void; + /** + * Fires when the client .well-known info is fetched. + * + * @param data - The JSON object returned by the server + */ [ClientEvent.ClientWellKnown]: (data: IClientWellKnown) => void; [ClientEvent.ReceivedVoipEvent]: (event: MatrixEvent) => void; [ClientEvent.TurnServers]: (servers: ITurnServer[]) => void; [ClientEvent.TurnServersError]: (error: Error, fatal: boolean) => void; -} & RoomEventHandlerMap - & RoomStateEventHandlerMap - & CryptoEventHandlerMap - & MatrixEventHandlerMap - & RoomMemberEventHandlerMap - & UserEventHandlerMap - & CallEventHandlerEventHandlerMap - & GroupCallEventHandlerEventHandlerMap - & CallEventHandlerMap - & HttpApiEventHandlerMap - & BeaconEventHandlerMap; +} & RoomEventHandlerMap & + RoomStateEventHandlerMap & + CryptoEventHandlerMap & + MatrixEventHandlerMap & + RoomMemberEventHandlerMap & + UserEventHandlerMap & + CallEventHandlerEventHandlerMap & + GroupCallEventHandlerEventHandlerMap & + CallEventHandlerMap & + HttpApiEventHandlerMap & + BeaconEventHandlerMap; const SSO_ACTION_PARAM = new UnstableValue("action", "org.matrix.msc3824.action"); @@ -935,7 +1133,7 @@ const SSO_ACTION_PARAM = new UnstableValue("action", "org.matrix.msc3824.action" * as it specifies 'sensible' defaults for these modules. */ export class MatrixClient extends TypedEventEmitter { - public static readonly RESTORE_BACKUP_ERROR_BAD_KEY = 'RESTORE_BACKUP_ERROR_BAD_KEY'; + public static readonly RESTORE_BACKUP_ERROR_BAD_KEY = "RESTORE_BACKUP_ERROR_BAD_KEY"; public reEmitter = new TypedReEmitter(this); public olmVersion: [number, number, number] | null = null; // populated after initCrypto @@ -950,7 +1148,8 @@ 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; // libolm crypto implementation. XXX: Intended private, used in code. Being replaced by cryptoBackend + private cryptoBackend?: CryptoBackend; // one of crypto or rustCrypto public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code. public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code. public groupCallEventHandler?: GroupCallEventHandler; @@ -966,7 +1165,7 @@ export class MatrixClient extends TypedEventEmitter, errorTs?: number}} = {}; + protected ongoingScrollbacks: { [roomId: string]: { promise?: Promise; errorTs?: number } } = {}; protected notifTimelineSet: EventTimelineSet | null = null; protected cryptoStore?: CryptoStore; protected verificationMethods?: VerificationMethod[]; @@ -1044,16 +1243,16 @@ export class MatrixClient extends TypedEventEmitter { - for (const [key, value] of Object.entries(content[eid])) { - if (!utils.isSupportedReceiptType(key)) continue; - if (!value) continue; + const isSelf = + Object.keys(content).filter((eid) => { + for (const [key, value] of Object.entries(content[eid])) { + if (!utils.isSupportedReceiptType(key)) continue; + if (!value) continue; - if (Object.keys(value).includes(this.getUserId()!)) return true; - } + if (Object.keys(value).includes(this.getUserId()!)) return true; + } - return false; - }).length > 0; + return false; + }).length > 0; if (!isSelf) return; @@ -1173,10 +1373,10 @@ export class MatrixClient extends TypedEventEmitter { if (this.clientRunning) { @@ -1198,10 +1398,6 @@ export class MatrixClient extends TypedEventEmitter { @@ -1261,11 +1457,11 @@ export class MatrixClient extends TypedEventEmitter} Resolves to undefined if a device could not be dehydrated, or + * @returns Promise which resolves to undefined if a device could not be dehydrated, or * to the new device ID if the dehydration was successful. - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Rejects: with an error response. */ public async rehydrateDevice(): Promise { if (this.crypto) { @@ -1325,13 +1521,10 @@ export class MatrixClient extends TypedEventEmitter { - // copy the key so that it doesn't get clobbered - account.unpickle(new Uint8Array(k), deviceData.account); - }, - ); + const key = await this.cryptoCallbacks.getDehydrationKey(deviceData, (k) => { + // copy the key so that it doesn't get clobbered + account.unpickle(new Uint8Array(k), deviceData.account); + }); account.unpickle(key, deviceData.account); logger.log("unpickled device"); @@ -1371,14 +1564,15 @@ export class MatrixClient extends TypedEventEmitter { try { return await this.http.authedRequest( Method.Get, "/dehydrated_device", - undefined, undefined, + undefined, + undefined, { prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2", }, @@ -1393,12 +1587,12 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { - logger.warn('not dehydrating device if crypto is not enabled'); + logger.warn("not dehydrating device if crypto is not enabled"); return; } return this.crypto.dehydrationManager.setKeyAndQueueDehydration(key, keyInfo, deviceDisplayName); @@ -1414,12 +1608,12 @@ export class MatrixClient extends TypedEventEmitter} the device id of the newly created dehydrated device + * @returns the device id of the newly created dehydrated device */ public async createDehydratedDevice( key: Uint8Array, @@ -1427,7 +1621,7 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { - logger.warn('not dehydrating device if crypto is not enabled'); + logger.warn("not dehydrating device if crypto is not enabled"); return; } await this.crypto.dehydrationManager.setKey(key, keyInfo, deviceDisplayName); @@ -1436,7 +1630,7 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { - logger.warn('not exporting device if crypto is not enabled'); + logger.warn("not exporting device if crypto is not enabled"); return; } return { @@ -1450,7 +1644,7 @@ export class MatrixClient extends TypedEventEmitter { if (this.clientRunning) { @@ -1469,7 +1663,7 @@ export class MatrixClient extends TypedEventEmitterThis method is experimental * and may change without warning. - * @param {boolean} guest True if this is a guest account. + * @param guest - True if this is a guest account. */ public setGuest(guest: boolean): void { // EXPERIMENTAL: @@ -1689,7 +1880,7 @@ export class MatrixClient extends TypedEventEmitterexplicitly attempts to retry their lost connection. * Will also retry any outbound to-device messages currently in the queue to be sent * (retries of regular outgoing events are handled separately, per-event). - * @return {boolean} True if this resulted in a request being retried. + * @returns True if this resulted in a request being retried. */ public retryImmediately(): boolean { // don't await for this promise: we just want to kick it off @@ -1711,7 +1902,7 @@ export class MatrixClient extends TypedEventEmitter { const now = new Date().getTime(); @@ -1743,32 +1933,35 @@ export class MatrixClient extends TypedEventEmitter(Method.Get, "/capabilities").catch((e: Error): void => { - // We swallow errors because we need a default object anyhow - logger.error(e); - }).then((r = {}) => { - const capabilities: ICapabilities = r["capabilities"] || {}; - - // If the capabilities missed the cache, cache it for a shorter amount - // of time to try and refresh them later. - const cacheMs = Object.keys(capabilities).length - ? CAPABILITIES_CACHE_MS - : 60000 + (Math.random() * 5000); - - this.cachedCapabilities = { - capabilities, - expiration: now + cacheMs, - }; + }; + return this.http + .authedRequest(Method.Get, "/capabilities") + .catch((e: Error): Response => { + // We swallow errors because we need a default object anyhow + logger.error(e); + return {}; + }) + .then((r = {}) => { + const capabilities = r["capabilities"] || {}; - logger.log("Caching capabilities: ", capabilities); - return capabilities; - }); + // If the capabilities missed the cache, cache it for a shorter amount + // of time to try and refresh them later. + const cacheMs = Object.keys(capabilities).length ? CAPABILITIES_CACHE_MS : 60000 + Math.random() * 5000; + + this.cachedCapabilities = { + capabilities, + expiration: now + cacheMs, + }; + + logger.log("Caching capabilities: ", capabilities); + return capabilities; + }); } /** - * Initialise support for end-to-end encryption in this client + * Initialise support for end-to-end encryption in this client, using libolm. * * You should call this method after creating the matrixclient, but *before* * calling `startClient`, if you want to support end-to-end encryption. @@ -1780,11 +1973,11 @@ export class MatrixClient extends TypedEventEmitter[0]); - this.crypto = crypto; + this.cryptoBackend = this.crypto = crypto; + + // upload our keys in the background + this.crypto.uploadDeviceKeys().catch((e) => { + // TODO: throwing away this error is a really bad idea. + logger.error("Error uploading device keys", e); + }); } /** * Is end-to-end crypto enabled for this client. - * @return {boolean} True if end-to-end is enabled. + * @returns True if end-to-end is enabled. */ public isCryptoEnabled(): boolean { - return !!this.crypto; + return !!this.cryptoBackend; } /** * Get the Ed25519 key for this device * - * @return {?string} base64-encoded ed25519 key. Null if crypto is + * @returns base64-encoded ed25519 key. Null if crypto is * disabled. */ public getDeviceEd25519Key(): string | null { @@ -1873,7 +2072,7 @@ export class MatrixClient extends TypedEventEmitter} A promise that will resolve when the keys are uploaded. + * @deprecated Does nothing. */ public async uploadKeys(): Promise { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - - await this.crypto.uploadDeviceKeys(); + logger.warn("MatrixClient.uploadKeys is deprecated"); } /** * Download the keys for a list of users and stores the keys in the session * store. - * @param {Array} userIds The users to fetch. - * @param {boolean} forceDownload Always download the keys even if cached. + * @param userIds - The users to fetch. + * @param forceDownload - Always download the keys even if cached. * - * @return {Promise} A promise which resolves to a map userId->deviceId->{@link - * module:crypto~DeviceInfo|DeviceInfo}. + * @returns A promise which resolves to a map userId-\>deviceId-\>{@link DeviceInfo} */ - public downloadKeys( - userIds: string[], - forceDownload?: boolean, - ): Promise>> { + public downloadKeys(userIds: string[], forceDownload?: boolean): Promise>> { if (!this.crypto) { return Promise.reject(new Error("End-to-end encryption disabled")); } @@ -1914,9 +2104,9 @@ export class MatrixClient extends TypedEventEmitter { const prom = this.setDeviceVerification(userId, deviceId, verified, null, null); @@ -1969,16 +2160,17 @@ export class MatrixClient extends TypedEventEmitter { return this.setDeviceVerification(userId, deviceId, null, blocked, null); @@ -1987,16 +2179,17 @@ export class MatrixClient extends TypedEventEmitter { return this.setDeviceVerification(userId, deviceId, null, null, known); @@ -2018,10 +2211,10 @@ export class MatrixClient extends TypedEventEmitter} resolves to a VerificationRequest + * @returns resolves to a VerificationRequest * when the request has been sent to the other party. */ public requestVerificationDM(userId: string, roomId: string): Promise { @@ -2034,9 +2227,9 @@ export class MatrixClient extends TypedEventEmitter} resolves to a VerificationRequest + * @returns resolves to a VerificationRequest * when the request has been sent to the other party. */ public requestVerification(userId: string, devices?: string[]): Promise { @@ -2079,11 +2272,11 @@ export class MatrixClient extends TypedEventEmitter { @@ -2105,24 +2298,24 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { @@ -2252,9 +2442,9 @@ export class MatrixClient extends TypedEventEmitter { + if (!this.cryptoBackend) { + throw new Error("End-to-end encryption disabled"); + } + return this.cryptoBackend.userHasCrossSigningKeys(); + } + /** * Checks whether cross signing: * - is enabled on this account and trusted by this device @@ -2296,7 +2499,7 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { @@ -2315,15 +2518,6 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { @@ -2339,7 +2533,7 @@ export class MatrixClient extends TypedEventEmitter} Resolves to the number of sessions requiring backup + * @returns Promise which resolves to the number of sessions requiring backup */ public countSessionsNeedingBackup(): Promise { if (!this.crypto) { @@ -2374,8 +2568,8 @@ export class MatrixClient extends TypedEventEmitter} Object with public key metadata, encoded private + * @returns Object with public key metadata, encoded private * recovery key which should be disposed of after displaying to the user, * and raw private key to avoid round tripping if needed. */ @@ -2416,7 +2610,7 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { @@ -2438,7 +2632,6 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { @@ -2452,20 +2645,20 @@ export class MatrixClient extends TypedEventEmitter { + ): Promise<{ keyId: string; keyInfo: ISecretStorageKeyInfo }> { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -2477,9 +2670,9 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { @@ -2493,9 +2686,9 @@ export class MatrixClient extends TypedEventEmitter { @@ -2510,9 +2703,9 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { @@ -2526,8 +2719,8 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { @@ -2574,7 +2767,7 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { @@ -2590,9 +2783,9 @@ export class MatrixClient extends TypedEventEmitter} + * @param event - event to be checked */ public async getEventSenderDeviceInfo(event: MatrixEvent): Promise { if (!this.crypto) { @@ -2618,10 +2809,10 @@ export class MatrixClient extends TypedEventEmitter { const device = await this.getEventSenderDeviceInfo(event); @@ -2631,12 +2822,35 @@ export class MatrixClient extends TypedEventEmitter { + if (!this.crypto) { + throw new Error("End-to-End encryption disabled"); + } + const wireContent = event.getWireContent(); + const requestBody: IRoomKeyRequestBody = { + session_id: wireContent.session_id, + sender_key: wireContent.sender_key, + algorithm: wireContent.algorithm, + room_id: event.getRoomId()!, + }; + if (!requestBody.session_id || !requestBody.sender_key || !requestBody.algorithm || !requestBody.room_id) { + return Promise.resolve(null); + } + return this.crypto.cryptoStore.getOutgoingRoomKeyRequest(requestBody); + } + /** * Cancel a room key request for this event if one is ongoing and resend the * request. - * @param {MatrixEvent} event event of which to cancel and resend the room + * @param event - event of which to cancel and resend the room * key request. - * @return {Promise} A promise that will resolve when the key request is queued + * @returns A promise that will resolve when the key request is queued */ public cancelAndResendEventRoomKeyRequest(event: MatrixEvent): Promise { if (!this.crypto) { @@ -2648,9 +2862,9 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { @@ -2661,8 +2875,8 @@ export class MatrixClient extends TypedEventEmitter} Promise which + * @param payload - fields to include in the encrypted payload + * + * @returns Promise which * resolves once the message has been encrypted and sent to the given - * userDeviceMap, and returns the { contentMap, deviceInfoByDeviceId } + * userDeviceMap, and returns the `{ contentMap, deviceInfoByDeviceId }` * of the successfully sent messages. */ - public encryptAndSendToDevices( - userDeviceInfoArr: IOlmDevice[], - payload: object, - ): Promise { + public encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice[], payload: object): Promise { if (!this.crypto) { throw new Error("End-to-End encryption disabled"); } @@ -2713,7 +2923,7 @@ export class MatrixClient extends TypedEventEmitter { @@ -2742,12 +2952,9 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { @@ -2760,7 +2967,7 @@ export class MatrixClient extends TypedEventEmitter} Information object from API or null + * @returns Information object from API or null */ public async getKeyBackupVersion(): Promise { let res: IKeyBackupInfo; try { res = await this.http.authedRequest( - Method.Get, "/room_keys/version", undefined, undefined, + Method.Get, + "/room_keys/version", + undefined, + undefined, { prefix: ClientPrefix.V3 }, ); } catch (e) { - if ((e).errcode === 'M_NOT_FOUND') { + if ((e).errcode === "M_NOT_FOUND") { return null; } else { throw e; @@ -2795,14 +3005,7 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { @@ -2812,7 +3015,7 @@ export class MatrixClient extends TypedEventEmitter} Resolves when complete. + * @param info - Backup information object as returned by getKeyBackupVersion + * @returns Promise which resolves when complete. */ public enableKeyBackup(info: IKeyBackupInfo): Promise { if (!this.crypto) { @@ -2853,17 +3056,13 @@ export class MatrixClient extends TypedEventEmitter} Object that can be passed to createKeyBackupVersion and + * @returns Object that can be passed to createKeyBackupVersion and * additionally has a 'recovery_key' member with the user-facing recovery key string. */ - // TODO: Verify types public async prepareKeyBackupVersion( password?: string | Uint8Array | null, opts: IKeyBackupPrepareOpts = { secureSecretStorage: false }, @@ -2892,7 +3091,7 @@ export class MatrixClient extends TypedEventEmitter} map of key name to key info the secret is + * @returns map of key name to key info the secret is * encrypted with, or null if it is not present or not encrypted with a * trusted key */ @@ -2904,8 +3103,8 @@ export class MatrixClient extends TypedEventEmitter} Object with 'version' param indicating the version created + * @param info - Info object from prepareKeyBackupVersion + * @returns Object with 'version' param indicating the version created */ public async createKeyBackupVersion(info: IKeyBackupInfo): Promise { if (!this.crypto) { @@ -2938,10 +3137,9 @@ export class MatrixClient extends TypedEventEmitter( - Method.Post, "/room_keys/version", undefined, data, - { prefix: ClientPrefix.V3 }, - ); + const res = await this.http.authedRequest(Method.Post, "/room_keys/version", undefined, data, { + prefix: ClientPrefix.V3, + }); // We could assume everything's okay and enable directly, but this ensures // we run the same signature verification that will be used for future @@ -2970,10 +3168,7 @@ export class MatrixClient extends TypedEventEmitter} Resolves to the number of sessions requiring a backup. + * @returns Promise which resolves to the number of sessions requiring a backup. */ public flagAllGroupSessionsForBackup(): Promise { if (!this.crypto) { @@ -3081,9 +3273,9 @@ export class MatrixClient extends TypedEventEmitter} key backup key + * @param password - Passphrase + * @param backupInfo - Backup metadata from `checkKeyBackup` + * @returns key backup key */ public keyBackupKeyFromPassword(password: string, backupInfo: IKeyBackupInfo): Promise { return keyFromAuthData(backupInfo.auth_data, password); @@ -3095,8 +3287,8 @@ export class MatrixClient extends TypedEventEmitter} Status of restoration with `total` and `imported` + * @param backupInfo - Backup metadata from `checkKeyBackup` + * @param opts - Optional params such as callbacks + * @returns Status of restoration with `total` and `imported` * key counts. */ public async restoreKeyBackupWithPassword( @@ -3151,13 +3343,13 @@ export class MatrixClient extends TypedEventEmitter} Status of restoration with `total` and `imported` + * @param opts - Optional params such as callbacks + * @returns Status of restoration with `total` and `imported` * key counts. */ public async restoreKeyBackupWithSecretStorage( @@ -3186,15 +3378,15 @@ export class MatrixClient extends TypedEventEmitter} Status of restoration with `total` and `imported` + * @returns Status of restoration with `total` and `imported` * key counts. */ public restoreKeyBackupWithRecoveryKey( @@ -3202,28 +3394,28 @@ export class MatrixClient extends TypedEventEmitter; public restoreKeyBackupWithRecoveryKey( recoveryKey: string, targetRoomId: string, targetSessionId: undefined, backupInfo: IKeyBackupInfo, - opts: IKeyBackupRestoreOpts, + opts?: IKeyBackupRestoreOpts, ): Promise; public restoreKeyBackupWithRecoveryKey( recoveryKey: string, targetRoomId: string, targetSessionId: string, backupInfo: IKeyBackupInfo, - opts: IKeyBackupRestoreOpts, + opts?: IKeyBackupRestoreOpts, ): Promise; public restoreKeyBackupWithRecoveryKey( recoveryKey: string, targetRoomId: string | undefined, targetSessionId: string | undefined, backupInfo: IKeyBackupInfo, - opts: IKeyBackupRestoreOpts, + opts?: IKeyBackupRestoreOpts, ): Promise { const privKey = decodeRecoveryKey(recoveryKey); return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts); @@ -3303,7 +3495,9 @@ export class MatrixClient extends TypedEventEmitter { return privKey; }); + const algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => { + return privKey; + }); const untrusted = algorithm.untrusted; @@ -3311,16 +3505,18 @@ export class MatrixClient extends TypedEventEmitter { logger.warn("Error caching session backup key:", e); - }).then(cacheCompleteCallback); + }) + .then(cacheCompleteCallback); if (progressCallback) { progressCallback({ @@ -3329,7 +3525,10 @@ export class MatrixClient extends TypedEventEmitter( - Method.Get, path.path, path.queryData, undefined, + Method.Get, + path.path, + path.queryData, + undefined, { prefix: ClientPrefix.V3 }, ); @@ -3389,17 +3588,14 @@ export class MatrixClient extends TypedEventEmitter { @@ -3415,7 +3611,7 @@ export class MatrixClient extends TypedEventEmitter = {}; for (const [userId, devices] of Object.entries(deviceInfos)) { devicesByUser[userId] = Object.values(devices); } @@ -3431,14 +3627,12 @@ export class MatrixClient extends TypedEventEmitter { - return this.http.authedRequest( - Method.Get, "/config", undefined, undefined, { - prefix: MediaPrefix.R0, - }, - ); + return this.http.authedRequest(Method.Get, "/config", undefined, undefined, { + prefix: MediaPrefix.R0, + }); } /** @@ -3446,8 +3640,8 @@ export class MatrixClient extends TypedEventEmitter { - const tombstone = r.currentState.getStateEvents(EventType.RoomTombstone, ''); + const tombstone = r.currentState.getStateEvents(EventType.RoomTombstone, ""); if (tombstone && replacedRooms.has(r.roomId)) { return false; } @@ -3497,8 +3691,8 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/user/$userId/account_data/$type", { @@ -3533,8 +3727,8 @@ export class MatrixClient extends TypedEventEmitter(eventType: string): Promise { + public async getAccountDataFromServer(eventType: string): Promise { if (this.isInitialSyncComplete()) { const event = this.store.getAccountData(eventType); if (!event) { @@ -3565,7 +3759,7 @@ export class MatrixClient extends TypedEventEmittere).data?.errcode === 'M_NOT_FOUND') { + if ((e).data?.errcode === "M_NOT_FOUND") { return null; } throw e; @@ -3574,7 +3768,7 @@ export class MatrixClient extends TypedEventEmitter { - const content = { ignored_users: {} }; + const content = { ignored_users: {} as Record }; userIds.forEach((u) => { content.ignored_users[u] = {}; }); @@ -3598,8 +3792,8 @@ export class MatrixClient extends TypedEventEmitterreturned Room object will have no current state. - * Default: true. - * @param {boolean} opts.inviteSignUrl If the caller has a keypair 3pid invite, the signing URL is passed in this parameter. - * @param {string[]} opts.viaServers The server names to try and join through in addition to those that are automatically chosen. - * @return {Promise} Resolves: Room object. - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @param roomIdOrAlias - The room ID or room alias to join. + * @param opts - Options when joining the room. + * @returns Promise which resolves: Room object. + * @returns Rejects: with an error response. */ public async joinRoom(roomIdOrAlias: string, opts: IJoinRoomOpts = {}): Promise { if (opts.syncRoom === undefined) { @@ -3665,11 +3854,11 @@ export class MatrixClient extends TypedEventEmitter { // also kick the to-device queue to retry @@ -3682,7 +3871,7 @@ export class MatrixClient extends TypedEventEmitter { return this.sendStateEvent(roomId, EventType.RoomName, { name: name }); } /** - * @param {string} roomId - * @param {string} topic - * @param {string} htmlTopic Optional. - * @return {Promise} Resolves: TODO - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @param htmlTopic - Optional. + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. */ - public setRoomTopic( - roomId: string, - topic: string, - htmlTopic?: string, - ): Promise { + public setRoomTopic(roomId: string, topic: string, htmlTopic?: string): Promise { const content = ContentHelpers.makeTopicContent(topic, htmlTopic); return this.sendStateEvent(roomId, EventType.RoomTopic, content); } /** - * @param {string} roomId - * @return {Promise} Resolves: to an object keyed by tagId with objects containing a numeric order field. - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Promise which resolves: to an object keyed by tagId with objects containing a numeric order field. + * @returns Rejects: with an error response. */ public getRoomTags(roomId: string): Promise { const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags", { @@ -3744,11 +3924,10 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", { @@ -3760,10 +3939,9 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", { @@ -3775,17 +3953,12 @@ export class MatrixClient extends TypedEventEmitter, - ): Promise<{}> { + public setRoomAccountData(roomId: string, eventType: string, content: Record): Promise<{}> { const path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", { $userId: this.credentials.userId!, $roomId: roomId, @@ -3795,29 +3968,31 @@ export class MatrixClient extends TypedEventEmitter { let content = { - users: {}, + users: {} as Record, }; - if (event?.getType() === EventType.RoomPowerLevels) { + if (event.getType() === EventType.RoomPowerLevels) { // take a copy of the content to ensure we don't corrupt // existing client state with a failed power level change content = utils.deepCopy(event.getContent()); } - content.users[userId] = powerLevel; + if (Array.isArray(userId)) { + for (const user of userId) { + content.users[user] = powerLevel; + } + } else { + content.users[userId] = powerLevel; + } const path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", { $roomId: roomId, }); @@ -3826,9 +4001,7 @@ export class MatrixClient extends TypedEventEmitter; + public sendEvent(roomId: string, eventType: string, content: IContent, txnId?: string): Promise; public sendEvent( roomId: string, threadId: string | null, @@ -3877,16 +4035,25 @@ export class MatrixClient extends TypedEventEmitter; public sendEvent( roomId: string, - threadId: string | null, - eventType: string | IContent, - content?: IContent | string, - txnId?: string, + threadIdOrEventType: string | null, + eventTypeOrContent: string | IContent, + contentOrTxnId?: IContent | string, + txnIdOrVoid?: string, ): Promise { - if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - txnId = content as string; - content = eventType as IContent; - eventType = threadId; + let threadId: string | null; + let eventType: string; + let content: IContent; + let txnId: string | undefined; + if (!threadIdOrEventType?.startsWith(EVENT_ID_PREFIX) && threadIdOrEventType !== null) { + txnId = contentOrTxnId as string; + content = eventTypeOrContent as IContent; + eventType = threadIdOrEventType; threadId = null; + } else { + txnId = txnIdOrVoid; + content = contentOrTxnId as IContent; + eventType = eventTypeOrContent as string; + threadId = threadIdOrEventType; } // If we expect that an event is part of a thread but is missing the relation @@ -3895,17 +4062,20 @@ export class MatrixClient extends TypedEventEmitter { - return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; - })?.getId() ?? threadId, + event_id: + thread + .lastReply((ev: MatrixEvent) => { + return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; + }) + ?.getId() ?? threadId, }; } } @@ -3914,12 +4084,10 @@ export class MatrixClient extends TypedEventEmitter e.getId() === targetId); + const target = room?.getPendingEvents().find((e) => e.getId() === targetId); target?.once(MatrixEventEvent.LocalEventIdReplaced, () => { localEvent.updateAssociatedId(target.getId()!); }); @@ -3989,72 +4154,73 @@ export class MatrixClient extends TypedEventEmitter { let cancelled = false; // Add an extra Promise.resolve() to turn synchronous exceptions into promise rejections, // so that we can handle synchronous and asynchronous exceptions with the // same code path. - return Promise.resolve().then(() => { - const encryptionPromise = this.encryptEventIfNeeded(event, room ?? undefined); - if (!encryptionPromise) return null; // doesn't need encryption - - this.pendingEventEncryption.set(event.getId()!, encryptionPromise); - this.updatePendingEventStatus(room, event, EventStatus.ENCRYPTING); - return encryptionPromise.then(() => { - if (!this.pendingEventEncryption.has(event.getId()!)) { - // cancelled via MatrixClient::cancelPendingEvent - cancelled = true; - return; - } - this.updatePendingEventStatus(room, event, EventStatus.SENDING); - }); - }).then(() => { - if (cancelled) return {} as ISendEventResponse; - let promise: Promise | null = null; - if (this.scheduler) { - // if this returns a promise then the scheduler has control now and will - // resolve/reject when it is done. Internally, the scheduler will invoke - // processFn which is set to this._sendEventHttpRequest so the same code - // path is executed regardless. - promise = this.scheduler.queueEvent(event); - if (promise && this.scheduler.getQueueForEvent(event)!.length > 1) { - // event is processed FIFO so if the length is 2 or more we know - // this event is stuck behind an earlier event. - this.updatePendingEventStatus(room, event, EventStatus.QUEUED); + return Promise.resolve() + .then(() => { + const encryptionPromise = this.encryptEventIfNeeded(event, room ?? undefined); + if (!encryptionPromise) return null; // doesn't need encryption + + this.pendingEventEncryption.set(event.getId()!, encryptionPromise); + this.updatePendingEventStatus(room, event, EventStatus.ENCRYPTING); + return encryptionPromise.then(() => { + if (!this.pendingEventEncryption.has(event.getId()!)) { + // cancelled via MatrixClient::cancelPendingEvent + cancelled = true; + return; + } + this.updatePendingEventStatus(room, event, EventStatus.SENDING); + }); + }) + .then(() => { + if (cancelled) return {} as ISendEventResponse; + let promise: Promise | null = null; + if (this.scheduler) { + // if this returns a promise then the scheduler has control now and will + // resolve/reject when it is done. Internally, the scheduler will invoke + // processFn which is set to this._sendEventHttpRequest so the same code + // path is executed regardless. + promise = this.scheduler.queueEvent(event); + if (promise && this.scheduler.getQueueForEvent(event)!.length > 1) { + // event is processed FIFO so if the length is 2 or more we know + // this event is stuck behind an earlier event. + this.updatePendingEventStatus(room, event, EventStatus.QUEUED); + } } - } - if (!promise) { - promise = this.sendEventHttpRequest(event); - if (room) { - promise = promise.then(res => { - room.updatePendingEvent(event, EventStatus.SENT, res['event_id']); - return res; - }); + if (!promise) { + promise = this.sendEventHttpRequest(event); + if (room) { + promise = promise.then((res) => { + room.updatePendingEvent(event, EventStatus.SENT, res["event_id"]); + return res; + }); + } } - } - return promise; - }).catch(err => { - logger.error("Error sending event", err.stack || err); - try { - // set the error on the event before we update the status: - // updating the status emits the event, so the state should be - // consistent at that point. - event.error = err; - this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT); - } catch (e) { - logger.error("Exception in error handler!", (e).stack || err); - } - if (err instanceof MatrixError) { - err.event = event; - } - throw err; - }); + return promise; + }) + .catch((err) => { + logger.error("Error sending event", err.stack || err); + try { + // set the error on the event before we update the status: + // updating the status emits the event, so the state should be + // consistent at that point. + event.error = err; + this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT); + } catch (e) { + logger.error("Exception in error handler!", (e).stack || err); + } + if (err instanceof MatrixError) { + err.event = event; + } + throw err; + }); } private encryptEventIfNeeded(event: MatrixEvent, room?: Room): Promise | null { @@ -4098,8 +4264,7 @@ export class MatrixClient extends TypedEventEmitter( - Method.Put, path, undefined, event.getWireContent(), - ).then((res) => { - logger.log(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`); - return res; - }); + return this.http + .authedRequest(Method.Put, path, undefined, event.getWireContent()) + .then((res) => { + logger.log(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`); + return res; + }); } /** - * @param {string} roomId - * @param {string} eventId - * @param {string} [txnId] transaction id. One will be made up if not supplied. - * @param {IRedactOpts} opts Options to pass on, may contain `reason`. - * @return {Promise} Resolves: TODO - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @param txnId - transaction id. One will be made up if not supplied. + * @param opts - Options to pass on, may contain `reason`. + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. */ public redactEvent( roomId: string, @@ -4204,26 +4367,24 @@ export class MatrixClient extends TypedEventEmitter; + public sendMessage(roomId: string, content: IContent, txnId?: string): Promise; public sendMessage( roomId: string, threadId: string | null, @@ -4247,20 +4408,20 @@ export class MatrixClient extends TypedEventEmitter | undefined => { - let newEvent: IPartialEvent | undefined; - - if (content['msgtype'] === MsgType.Text) { - newEvent = MessageEvent.from(content['body'], content['formatted_body']).serialize(); - } else if (content['msgtype'] === MsgType.Emote) { - newEvent = EmoteEvent.from(content['body'], content['formatted_body']).serialize(); - } else if (content['msgtype'] === MsgType.Notice) { - newEvent = NoticeEvent.from(content['body'], content['formatted_body']).serialize(); + let newEvent: IPartialEvent | undefined; + + if (content["msgtype"] === MsgType.Text) { + newEvent = MessageEvent.from(content["body"], content["formatted_body"]).serialize(); + } else if (content["msgtype"] === MsgType.Emote) { + newEvent = EmoteEvent.from(content["body"], content["formatted_body"]).serialize(); + } else if (content["msgtype"] === MsgType.Notice) { + newEvent = NoticeEvent.from(content["body"], content["formatted_body"]).serialize(); } - if (newEvent && content['m.new_content'] && recurse) { - const newContent = makeContentExtensible(content['m.new_content'], false); + if (newEvent && content["m.new_content"] && recurse) { + const newContent = makeContentExtensible(content["m.new_content"], false); if (newContent) { - newEvent.content['m.new_content'] = newContent.content; + newEvent.content["m.new_content"] = newContent.content; } } @@ -4281,28 +4442,15 @@ export class MatrixClient extends TypedEventEmitter; + public sendTextMessage(roomId: string, body: string, txnId?: string): Promise; public sendTextMessage( roomId: string, threadId: string | null, @@ -4325,18 +4473,11 @@ export class MatrixClient extends TypedEventEmitter; + public sendNotice(roomId: string, body: string, txnId?: string): Promise; public sendNotice( roomId: string, threadId: string | null, @@ -4359,18 +4500,11 @@ export class MatrixClient extends TypedEventEmitter; + public sendEmoteMessage(roomId: string, body: string, txnId?: string): Promise; public sendEmoteMessage( roomId: string, threadId: string | null, @@ -4393,20 +4527,10 @@ export class MatrixClient extends TypedEventEmitter; + public sendImageMessage(roomId: string, url: string, info?: IImageInfo, text?: string): Promise; public sendImageMessage( roomId: string, threadId: string | null, @@ -4422,7 +4546,7 @@ export class MatrixClient extends TypedEventEmitter { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - text = info as string || "Image"; + text = (info as string) || "Image"; info = url as IImageInfo; url = threadId as string; threadId = null; @@ -4437,13 +4561,8 @@ export class MatrixClient extends TypedEventEmitter { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - text = info as string || "Sticker"; + text = (info as string) || "Sticker"; info = url as IImageInfo; url = threadId as string; threadId = null; @@ -4481,18 +4600,10 @@ export class MatrixClient extends TypedEventEmitter; + public sendHtmlMessage(roomId: string, body: string, htmlBody: string): Promise; public sendHtmlMessage( roomId: string, threadId: string | null, @@ -4515,17 +4626,10 @@ export class MatrixClient extends TypedEventEmitter; + public sendHtmlNotice(roomId: string, body: string, htmlBody: string): Promise; public sendHtmlNotice( roomId: string, threadId: string | null, @@ -4548,18 +4652,10 @@ export class MatrixClient extends TypedEventEmitter; + public sendHtmlEmote(roomId: string, body: string, htmlBody: string): Promise; public sendHtmlEmote( roomId: string, threadId: string | null, @@ -4583,20 +4679,15 @@ export class MatrixClient extends TypedEventEmitter { + public async sendReceipt(event: MatrixEvent, receiptType: ReceiptType, body: any, unthreaded = false): Promise<{}> { if (this.isGuest()) { return Promise.resolve({}); // guests cannot send receipts so don't bother. } @@ -4612,9 +4703,7 @@ export class MatrixClient extends TypedEventEmitter { @@ -4728,23 +4817,26 @@ export class MatrixClient extends TypedEventEmitter(Method.Get, "/preview_url", { - url, - ts: ts.toString(), - }, undefined, { - prefix: MediaPrefix.R0, - }); + const resp = this.http.authedRequest( + Method.Get, + "/preview_url", + { + url, + ts: ts.toString(), + }, + undefined, + { + prefix: MediaPrefix.R0, + }, + ); // TODO: Expire the URL preview cache sometimes this.urlPreviewCache[key] = resp; return resp; } /** - * @param {string} roomId - * @param {boolean} isTyping - * @param {Number} timeoutMs - * @return {Promise} Resolves: to an empty object {} - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. */ public sendTyping(roomId: string, isTyping: boolean, timeoutMs: number): Promise<{}> { if (this.isGuest()) { @@ -4770,12 +4862,12 @@ export class MatrixClient extends TypedEventEmitter { return this.membershipChange(roomId, userId, "invite", reason); @@ -4855,10 +4944,10 @@ export class MatrixClient extends TypedEventEmitter { return this.inviteByThreePid(roomId, "email", email); @@ -4866,24 +4955,23 @@ export class MatrixClient extends TypedEventEmitter { - const path = utils.encodeUri( - "/rooms/$roomId/invite", - { $roomId: roomId }, - ); + const path = utils.encodeUri("/rooms/$roomId/invite", { $roomId: roomId }); const identityServerUrl = this.getIdentityServerUrl(true); if (!identityServerUrl) { - return Promise.reject(new MatrixError({ - error: "No supplied identity server URL", - errcode: "ORG.MATRIX.JSSDK_MISSING_PARAM", - })); + return Promise.reject( + new MatrixError({ + error: "No supplied identity server URL", + errcode: "ORG.MATRIX.JSSDK_MISSING_PARAM", + }), + ); } const params: Record = { id_server: identityServerUrl, @@ -4891,13 +4979,10 @@ export class MatrixClient extends TypedEventEmitter { return this.membershipChange(roomId, undefined, "leave"); @@ -4918,10 +5002,10 @@ export class MatrixClient extends TypedEventEmitter[] = []; const doLeave = (roomId: string): Promise => { - return this.leave(roomId).then(() => { - delete populationResults[roomId]; - }).catch((err) => { - // suppress error - populationResults[roomId] = err; - }); + return this.leave(roomId) + .then(() => { + delete populationResults[roomId]; + }) + .catch((err) => { + // suppress error + populationResults[roomId] = err; + }); }; for (const room of eligibleToLeave) { @@ -4961,22 +5047,19 @@ export class MatrixClient extends TypedEventEmitter { return this.membershipChange(roomId, userId, "ban", reason); } /** - * @param {string} roomId - * @param {boolean} deleteRoom True to delete the room from the store on success. + * @param deleteRoom - True to delete the room from the store on success. * Default: true. - * @return {Promise} Resolves: {} an empty object. - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Promise which resolves: `{}` an empty object. + * @returns Rejects: with an error response. */ public forget(roomId: string, deleteRoom = true): Promise<{}> { const promise = this.membershipChange(roomId, undefined, "forget"); @@ -4991,10 +5074,8 @@ export class MatrixClient extends TypedEventEmitter { // unbanning != set their state to leave: this used to be @@ -5012,11 +5093,9 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomId/kick", { @@ -5034,26 +5113,25 @@ export class MatrixClient extends TypedEventEmitter { // API returns an empty object + ): Promise<{}> { + // API returns an empty object const path = utils.encodeUri("/rooms/$room_id/$membership", { $room_id: roomId, $membership: membership, }); - return this.http.authedRequest( - Method.Post, path, undefined, { - user_id: userId, // may be undefined e.g. on leave - reason: reason, - }, - ); + return this.http.authedRequest(Method.Post, path, undefined, { + user_id: userId, // may be undefined e.g. on leave + reason: reason, + }); } /** * Obtain a dict of actions which should be performed for this event according * to the push rules for this user. Caches the dict on the event. - * @param {MatrixEvent} event The event to get push actions for. - * @param {boolean} forceRecalculate forces to recalculate actions for an event + * @param event - The event to get push actions for. + * @param forceRecalculate - forces to recalculate actions for an event * Useful when an event just got decrypted - * @return {module:pushprocessor~PushAction} A dict of actions to perform. + * @returns A dict of actions to perform. */ public getPushActionsForEvent(event: MatrixEvent, forceRecalculate = false): IActionsObject | null { if (!event.getPushActions() || forceRecalculate) { @@ -5063,10 +5141,10 @@ export class MatrixClient extends TypedEventEmitter; @@ -5080,9 +5158,8 @@ export class MatrixClient extends TypedEventEmitter { const prom = await this.setProfileInfo("displayname", { displayname: name }); @@ -5096,9 +5173,8 @@ export class MatrixClient extends TypedEventEmitter { const prom = await this.setProfileInfo("avatar_url", { avatar_url: url }); @@ -5114,15 +5190,15 @@ export class MatrixClient extends TypedEventEmitterThis method is experimental and * may change. - * @param {string} mxcUrl The MXC URL - * @param {Number} width The desired width of the thumbnail. - * @param {Number} height The desired height of the thumbnail. - * @param {string} resizeMethod The thumbnail resize method to use, either + * @param mxcUrl - The MXC URL + * @param width - The desired width of the thumbnail. + * @param height - The desired height of the thumbnail. + * @param resizeMethod - The thumbnail resize method to use, either * "crop" or "scale". - * @param {Boolean} allowDirectLinks If true, return any non-mxc URLs + * @param allowDirectLinks - If true, return any non-mxc URLs * directly. Fetching such URLs will leak information about the user to * anyone they share a room with. If false, will return null for such URLs. - * @return {?string} the avatar URL or null. + * @returns the avatar URL or null. */ public mxcUrlToHttp( mxcUrl: string, @@ -5135,11 +5211,9 @@ export class MatrixClient extends TypedEventEmitter { @@ -5155,9 +5229,9 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/presence/$userId/status", { @@ -5175,13 +5249,13 @@ export class MatrixClient extends TypedEventEmitterRoom.oldState.paginationToken will be - * null. - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Promise which resolves: Room. If you are at the beginning + * of the timeline, `Room.oldState.paginationToken` will be + * `null`. + * @returns Rejects: with an error response. */ public scrollback(room: Room, limit = 30): Promise { let timeToWaitMs = 0; @@ -5209,39 +5283,42 @@ export class MatrixClient extends TypedEventEmitter((resolve, reject) => { // wait for a time before doing this request // (which may be 0 in order not to special case the code paths) - sleep(timeToWaitMs).then(() => { - return this.createMessagesRequest( - room.roomId, - room.oldState.paginationToken, - limit, - Direction.Backward, - ); - }).then((res: IMessagesResponse) => { - const matrixEvents = res.chunk.map(this.getEventMapper()); - if (res.state) { - const stateEvents = res.state.map(this.getEventMapper()); - room.currentState.setUnknownStateEvents(stateEvents); - } + sleep(timeToWaitMs) + .then(() => { + return this.createMessagesRequest( + room.roomId, + room.oldState.paginationToken, + limit, + Direction.Backward, + ); + }) + .then((res: IMessagesResponse) => { + const matrixEvents = res.chunk.map(this.getEventMapper()); + if (res.state) { + const stateEvents = res.state.map(this.getEventMapper()); + room.currentState.setUnknownStateEvents(stateEvents); + } - const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents); + const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents); - this.processBeaconEvents(room, timelineEvents); - room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline()); - this.processThreadEvents(room, threadedEvents, true); + this.processBeaconEvents(room, timelineEvents); + room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline()); + this.processThreadEvents(room, threadedEvents, true); - room.oldState.paginationToken = res.end; - if (res.chunk.length === 0) { - room.oldState.paginationToken = null; - } - this.store.storeEvents(room, matrixEvents, res.end, true); - delete this.ongoingScrollbacks[room.roomId]; - resolve(room); - }).catch((err) => { - this.ongoingScrollbacks[room.roomId] = { - errorTs: Date.now(), - }; - reject(err); - }); + room.oldState.paginationToken = res.end ?? null; + if (res.chunk.length === 0) { + room.oldState.paginationToken = null; + } + this.store.storeEvents(room, matrixEvents, res.end ?? null, true); + delete this.ongoingScrollbacks[room.roomId]; + resolve(room); + }) + .catch((err) => { + this.ongoingScrollbacks[room.roomId] = { + errorTs: Date.now(), + }; + reject(err); + }); }); info = { promise }; @@ -5250,13 +5327,6 @@ export class MatrixClient extends TypedEventEmitter> { // 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."); + throw new Error( + "timeline support is disabled. Set the 'timelineSupport'" + + " parameter to true when creating MatrixClient to enable it.", + ); } if (!timelineSet?.room) { @@ -5294,12 +5366,10 @@ export class MatrixClient extends TypedEventEmitter | undefined = undefined; if (this.clientOpts?.lazyLoadMembers) { @@ -5351,9 +5421,11 @@ export class MatrixClient extends TypedEventEmitter { @@ -5369,12 +5441,10 @@ export class MatrixClient extends TypedEventEmitter = { limit: "0", @@ -5436,7 +5506,8 @@ export class MatrixClient extends TypedEventEmitter> { // 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."); + throw new Error( + "timeline support is disabled. Set the 'timelineSupport'" + + " parameter to true when creating MatrixClient to enable it.", + ); } if (!timelineSet.room) { @@ -5546,14 +5619,12 @@ export class MatrixClient extends TypedEventEmitter = { - dir: 'b', + dir: "b", }; if (this.clientOpts?.lazyLoadMembers) { params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER); @@ -5574,12 +5645,9 @@ export class MatrixClient extends TypedEventEmitter(Method.Get, path, params, undefined, opts) - .then(res => ({ + return this.http + .authedRequest(Method.Get, path, params, undefined, opts) + .then((res) => ({ ...res, chunk: res.chunk?.reverse(), start: res.prev_batch, @@ -5689,18 +5756,13 @@ export class MatrixClient extends TypedEventEmitter { - const isNotifTimeline = (eventTimeline.getTimelineSet() === this.notifTimelineSet); + const isNotifTimeline = eventTimeline.getTimelineSet() === this.notifTimelineSet; const room = this.getRoom(eventTimeline.getRoomId()!); const threadListType = eventTimeline.getTimelineSet().threadListType; const thread = eventTimeline.getTimelineSet().thread; @@ -5734,45 +5796,44 @@ export class MatrixClient extends TypedEventEmitter( - Method.Get, path, params, - ).then(async (res) => { - const token = res.next_token; - const matrixEvents: MatrixEvent[] = []; - - for (let i = 0; i < res.notifications.length; i++) { - const notification = res.notifications[i]; - const event = this.getEventMapper()(notification.event); - event.setPushActions( - PushProcessor.actionListToActionsObject(notification.actions), - ); - event.event.room_id = notification.room_id; // XXX: gutwrenching - matrixEvents[i] = event; - } + promise = this.http + .authedRequest(Method.Get, path, params) + .then(async (res) => { + const token = res.next_token; + const matrixEvents: MatrixEvent[] = []; + + for (let i = 0; i < res.notifications.length; i++) { + const notification = res.notifications[i]; + const event = this.getEventMapper()(notification.event); + event.setPushActions(PushProcessor.actionListToActionsObject(notification.actions)); + event.event.room_id = notification.room_id; // XXX: gutwrenching + matrixEvents[i] = event; + } - // No need to partition events for threads here, everything lives - // in the notification timeline set - const timelineSet = eventTimeline.getTimelineSet(); - timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token); - this.processBeaconEvents(timelineSet.room, matrixEvents); - - // 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.next_token) { - eventTimeline.setPaginationToken(null, dir); - } - return Boolean(res.next_token); - }).finally(() => { - eventTimeline.paginationRequests[dir] = null; - }); + // No need to partition events for threads here, everything lives + // in the notification timeline set + const timelineSet = eventTimeline.getTimelineSet(); + timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token); + this.processBeaconEvents(timelineSet.room, matrixEvents); + + // 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.next_token) { + eventTimeline.setPaginationToken(null, dir); + } + return Boolean(res.next_token); + }) + .finally(() => { + eventTimeline.paginationRequests[dir] = null; + }); eventTimeline.paginationRequests[dir] = promise; } else if (threadListType !== null) { if (!room) { @@ -5790,30 +5851,32 @@ export class MatrixClient extends TypedEventEmitter { - 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; - }); + ) + .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 if (thread) { const room = this.getRoom(eventTimeline.getRoomId() ?? undefined); @@ -5821,44 +5884,45 @@ export class MatrixClient extends TypedEventEmitter { - const mapper = this.getEventMapper(); - const matrixEvents = res.chunk.map(mapper); - - // Process latest events first - for (const event of matrixEvents.slice().reverse()) { - await thread?.processEvent(event); - const sender = event.getSender()!; - if (!backwards || thread?.getEventReadUpTo(sender) === null) { - room.addLocalEchoReceipt(sender, event, ReceiptType.Read); + promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, THREAD_RELATION_TYPE.name, null, { + dir, + limit: opts.limit, + from: token ?? undefined, + }) + .then(async (res) => { + const mapper = this.getEventMapper(); + const matrixEvents = res.chunk.map(mapper); + + // Process latest events first + for (const event of matrixEvents.slice().reverse()) { + await thread?.processEvent(event); + const sender = event.getSender()!; + if (!backwards || thread?.getEventReadUpTo(sender) === null) { + room.addLocalEchoReceipt(sender, event, ReceiptType.Read); + } } - } - const newToken = res.next_batch; + const newToken = res.next_batch; - const timelineSet = eventTimeline.getTimelineSet(); - timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, newToken ?? null); - if (!newToken && backwards) { - timelineSet.addEventsToTimeline([mapper(res.original_event)], true, eventTimeline, null); - } - this.processBeaconEvents(timelineSet.room, matrixEvents); + const timelineSet = eventTimeline.getTimelineSet(); + timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, newToken ?? null); + if (!newToken && backwards) { + const originalEvent = await this.fetchRoomEvent(eventTimeline.getRoomId() ?? "", thread.id); + timelineSet.addEventsToTimeline([mapper(originalEvent)], true, eventTimeline, null); + } + this.processBeaconEvents(timelineSet.room, matrixEvents); - // 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 && !newToken) { - eventTimeline.setPaginationToken(null, dir); - } - return Boolean(newToken); - }).finally(() => { - eventTimeline.paginationRequests[dir] = null; - }); + // 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 && !newToken) { + eventTimeline.setPaginationToken(null, dir); + } + return Boolean(newToken); + }) + .finally(() => { + eventTimeline.paginationRequests[dir] = null; + }); eventTimeline.paginationRequests[dir] = promise; } else { if (!room) { @@ -5871,35 +5935,39 @@ export class MatrixClient extends TypedEventEmitter { - 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(); - const [timelineEvents] = room.partitionThreadedEvents(matrixEvents); - timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token); - this.processBeaconEvents(room, timelineEvents); - this.processThreadRoots(room, - timelineEvents.filter(it => it.isRelation(THREAD_RELATION_TYPE.name)), - false); - - const atEnd = res.end === undefined || res.end === res.start; - - // 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 && atEnd) { - eventTimeline.setPaginationToken(null, dir); - } - return !atEnd; - }).finally(() => { - eventTimeline.paginationRequests[dir] = null; - }); + ) + .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(); + const [timelineEvents] = room.partitionThreadedEvents(matrixEvents); + timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token); + this.processBeaconEvents(room, timelineEvents); + this.processThreadRoots( + room, + timelineEvents.filter((it) => it.getServerAggregatedRelation(THREAD_RELATION_TYPE.name)), + false, + ); + + const atEnd = res.end === undefined || res.end === res.start; + + // 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 && atEnd) { + eventTimeline.setPaginationToken(null, dir); + } + return !atEnd; + }) + .finally(() => { + eventTimeline.paginationRequests[dir] = null; + }); eventTimeline.paginationRequests[dir] = promise; } @@ -5925,7 +5993,7 @@ export class MatrixClient extends TypedEventEmitter { this.peekSync?.stopPeeking(); @@ -5964,27 +6032,31 @@ export class MatrixClient extends TypedEventEmitter { - const writePromise = this.sendStateEvent(roomId, EventType.RoomGuestAccess, { - guest_access: opts.allowJoin ? "can_join" : "forbidden", - }, ""); + const writePromise = this.sendStateEvent( + roomId, + EventType.RoomGuestAccess, + { + guest_access: opts.allowJoin ? "can_join" : "forbidden", + }, + "", + ); let readPromise: Promise = Promise.resolve(undefined); if (opts.allowRead) { - readPromise = this.sendStateEvent(roomId, EventType.RoomHistoryVisibility, { - history_visibility: "world_readable", - }, ""); + readPromise = this.sendStateEvent( + roomId, + EventType.RoomHistoryVisibility, + { + history_visibility: "world_readable", + }, + "", + ); } return Promise.all([readPromise, writePromise]).then(); // .then() to hide results for contract @@ -5998,11 +6070,11 @@ export class MatrixClient extends TypedEventEmitter { - return this.requestTokenFromEndpoint( - "/register/email/requestToken", - { - email: email, - client_secret: clientSecret, - send_attempt: sendAttempt, - next_link: nextLink, - }, - ); + return this.requestTokenFromEndpoint("/register/email/requestToken", { + email: email, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink, + }); } /** @@ -6027,13 +6096,13 @@ export class MatrixClient extends TypedEventEmitter { - return this.requestTokenFromEndpoint( - "/register/msisdn/requestToken", - { - country: phoneCountry, - phone_number: phoneNumber, - client_secret: clientSecret, - send_attempt: sendAttempt, - next_link: nextLink, - }, - ); + return this.requestTokenFromEndpoint("/register/msisdn/requestToken", { + country: phoneCountry, + phone_number: phoneNumber, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink, + }); } /** @@ -6065,11 +6131,11 @@ export class MatrixClient extends TypedEventEmitter { - return this.requestTokenFromEndpoint( - "/account/3pid/email/requestToken", - { - email: email, - client_secret: clientSecret, - send_attempt: sendAttempt, - next_link: nextLink, - }, - ); + return this.requestTokenFromEndpoint("/account/3pid/email/requestToken", { + email: email, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink, + }); } /** @@ -6095,12 +6158,12 @@ export class MatrixClient extends TypedEventEmitter { - return this.requestTokenFromEndpoint( - "/account/3pid/msisdn/requestToken", - { - country: phoneCountry, - phone_number: phoneNumber, - client_secret: clientSecret, - send_attempt: sendAttempt, - next_link: nextLink, - }, - ); + return this.requestTokenFromEndpoint("/account/3pid/msisdn/requestToken", { + country: phoneCountry, + phone_number: phoneNumber, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink, + }); } /** @@ -6133,11 +6193,11 @@ export class MatrixClient extends TypedEventEmitter { - return this.requestTokenFromEndpoint( - "/account/password/email/requestToken", - { - email: email, - client_secret: clientSecret, - send_attempt: sendAttempt, - next_link: nextLink, - }, - ); + return this.requestTokenFromEndpoint("/account/password/email/requestToken", { + email: email, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink, + }); } /** @@ -6162,12 +6219,12 @@ export class MatrixClient extends TypedEventEmitter { - return this.requestTokenFromEndpoint( - "/account/password/msisdn/requestToken", - { - country: phoneCountry, - phone_number: phoneNumber, - client_secret: clientSecret, - send_attempt: sendAttempt, - next_link: nextLink, - }, - ); + return this.requestTokenFromEndpoint("/account/password/msisdn/requestToken", { + country: phoneCountry, + phone_number: phoneNumber, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink, + }); } /** * Internal utility function for requesting validation tokens from usage-specific * requestToken endpoints. * - * @param {string} endpoint The endpoint to send the request to - * @param {object} params Parameters for the POST request - * @return {Promise} Resolves: As requestEmailToken + * @param endpoint - The endpoint to send the request to + * @param params - Parameters for the POST request + * @returns Promise which resolves: As requestEmailToken */ private async requestTokenFromEndpoint( endpoint: string, @@ -6204,11 +6258,11 @@ export class MatrixClient extends TypedEventEmitter rule.rule_id === roomId); + return this.pushRules[scope]?.room?.find((rule) => rule.rule_id === roomId); } else { - throw new Error( - "SyncApi.sync() must be done before accessing to push rules.", - ); + throw new Error("SyncApi.sync() must be done before accessing to push rules."); } } /** * Set a room-kind muting push rule in a room. * The operation also updates MatrixClient.pushRules at the end. - * @param {string} scope "global" or device-specific. - * @param {string} roomId the id of the room. - * @param {boolean} mute the mute state. - * @return {Promise} Resolves: result object - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @param scope - "global" or device-specific. + * @param roomId - the id of the room. + * @param mute - the mute state. + * @returns Promise which resolves: result object + * @returns Rejects: with an error response. */ - public setRoomMutePushRule(scope: string, roomId: string, mute: boolean): Promise | undefined { + public setRoomMutePushRule(scope: "global" | "device", roomId: string, mute: boolean): Promise | undefined { let promise: Promise | undefined; let hasDontNotifyRule = false; @@ -6270,17 +6322,21 @@ export class MatrixClient extends TypedEventEmitter { - this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, { - actions: [PushRuleActionName.DontNotify], - }).then(() => { - deferred.resolve(); - }).catch((err) => { + this.deletePushRule(scope, PushRuleKind.RoomSpecific, roomPushRule.rule_id) + .then(() => { + this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, { + actions: [PushRuleActionName.DontNotify], + }) + .then(() => { + deferred.resolve(); + }) + .catch((err) => { + deferred.reject(err); + }); + }) + .catch((err) => { deferred.reject(err); }); - }).catch((err) => { - deferred.reject(err); - }); promise = deferred.promise; } @@ -6289,23 +6345,29 @@ export class MatrixClient extends TypedEventEmitter((resolve, reject) => { // Update this.pushRules when the operation completes - promise!.then(() => { - this.getPushRules().then((result) => { - this.pushRules = result; - resolve(); - }).catch((err) => { - reject(err); + promise! + .then(() => { + this.getPushRules() + .then((result) => { + this.pushRules = result; + resolve(); + }) + .catch((err) => { + reject(err); + }); + }) + .catch((err: Error) => { + // Update it even if the previous operation fails. This can help the + // app to recover when push settings has been modified from another client + this.getPushRules() + .then((result) => { + this.pushRules = result; + reject(err); + }) + .catch((err2) => { + reject(err); + }); }); - }).catch((err: Error) => { - // Update it even if the previous operation fails. This can help the - // app to recover when push settings has been modified from another client - this.getPushRules().then((result) => { - this.pushRules = result; - reject(err); - }).catch((err2) => { - reject(err); - }); - }); }); } } @@ -6315,7 +6377,7 @@ export class MatrixClient extends TypedEventEmitter { // TODO: support search groups @@ -6372,15 +6429,15 @@ export class MatrixClient extends TypedEventEmitter this.processRoomEventsSearch(searchResults, res)); + return this.search({ body: body }).then((res) => this.processRoomEventsSearch(searchResults, res)); } /** * Take a result from an earlier searchRoomEvents call, and backfill results. * - * @param {object} searchResults the results object to be updated - * @return {Promise} Resolves: updated result object - * @return {Error} Rejects: with an error response. + * @param searchResults - the results object to be updated + * @returns Promise which resolves: updated result object + * @returns Rejects: with an error response. */ public backPaginateRoomEventsSearch(searchResults: T): Promise { // TODO: we should implement a backoff (as per scrollback()) to deal more @@ -6401,7 +6458,7 @@ export class MatrixClient extends TypedEventEmitter this.processRoomEventsSearch(searchResults, res)) + .then((res) => this.processRoomEventsSearch(searchResults, res)) .finally(() => { searchResults.pendingRequest = undefined; }); @@ -6414,10 +6471,8 @@ export class MatrixClient extends TypedEventEmitter(searchResults: T, response: ISearchResponse): T { @@ -6456,9 +6511,9 @@ export class MatrixClient extends TypedEventEmitter { // Guard against multiple calls whilst ongoing and multiple calls post success @@ -6472,43 +6527,44 @@ export class MatrixClient extends TypedEventEmitter { - logger.log("Marking success of sync left room request"); - this.syncedLeftRooms = true; // flip the bit on success - }).finally(() => { - this.syncLeftRoomsPromise = undefined; // cleanup ongoing request state - }); + this.syncLeftRoomsPromise + .then(() => { + logger.log("Marking success of sync left room request"); + this.syncedLeftRooms = true; // flip the bit on success + }) + .finally(() => { + this.syncLeftRoomsPromise = undefined; // cleanup ongoing request state + }); return this.syncLeftRoomsPromise; } /** * Create a new filter. - * @param {Object} content The HTTP body for the request - * @return {Filter} Resolves to a Filter object. - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @param content - The HTTP body for the request + * @returns Promise which resolves to a Filter object. + * @returns Rejects: with an error response. */ public createFilter(content: IFilterDefinition): Promise { const path = utils.encodeUri("/user/$userId/filter", { $userId: this.credentials.userId!, }); - return this.http.authedRequest(Method.Post, path, undefined, content) - .then((response) => { - // persist the filter - const filter = Filter.fromJson(this.credentials.userId, response.filter_id, content); - this.store.storeFilter(filter); - return filter; - }); + return this.http.authedRequest(Method.Post, path, undefined, content).then((response) => { + // persist the filter + const filter = Filter.fromJson(this.credentials.userId, response.filter_id, content); + this.store.storeFilter(filter); + return filter; + }); } /** * Retrieve a filter. - * @param {string} userId The user ID of the filter owner - * @param {string} filterId The filter ID to retrieve - * @param {boolean} allowCached True to allow cached filters to be returned. + * @param userId - The user ID of the filter owner + * @param filterId - The filter ID to retrieve + * @param allowCached - True to allow cached filters to be returned. * Default: True. - * @return {Promise} Resolves: a Filter object - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Promise which resolves: a Filter object + * @returns Rejects: with an error response. */ public getFilter(userId: string, filterId: string, allowCached: boolean): Promise { if (allowCached) { @@ -6532,9 +6588,7 @@ export class MatrixClient extends TypedEventEmitter} Filter ID + * @returns Filter ID */ public async getOrCreateFilter(filterName: string, filter: Filter): Promise { const filterId = this.store.getFilterIdByName(filterName); @@ -6587,8 +6641,8 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/user/$userId/openid/request_token", { @@ -6607,8 +6661,8 @@ export class MatrixClient extends TypedEventEmitter { return this.http.authedRequest(Method.Get, "/voip/turnServer"); @@ -6616,7 +6670,7 @@ export class MatrixClient extends TypedEventEmitter} The servers or an empty list. + * @returns The servers or an empty list. */ public getTurnServers(): ITurnServer[] { return this.turnServers || []; @@ -6625,7 +6679,7 @@ export class MatrixClient extends TypedEventEmitterThis function is implementation specific and may change * as a result. - * @return {boolean} true if the user appears to be a Synapse administrator. + * @returns true if the user appears to be a Synapse administrator. */ public isSynapseAdministrator(): Promise { - const path = utils.encodeUri( - "/_synapse/admin/v1/users/$userId/admin", - { $userId: this.getUserId()! }, - ); - return this.http.authedRequest<{ admin: boolean }>( - Method.Get, path, undefined, undefined, { prefix: '' }, - ).then(r => r.admin); // pull out the specific boolean we want + const path = utils.encodeUri("/_synapse/admin/v1/users/$userId/admin", { $userId: this.getUserId()! }); + return this.http + .authedRequest<{ admin: boolean }>(Method.Get, path, undefined, undefined, { prefix: "" }) + .then((r) => r.admin); // pull out the specific boolean we want } /** * Performs a whois lookup on a user using Synapse's administrator API. * This function is implementation specific and may change as a * result. - * @param {string} userId the User ID to look up. - * @return {object} the whois response - see Synapse docs for information. + * @param userId - the User ID to look up. + * @returns the whois response - see Synapse docs for information. */ public whoisSynapseUser(userId: string): Promise { - const path = utils.encodeUri( - "/_synapse/admin/v1/whois/$userId", - { $userId: userId }, - ); - return this.http.authedRequest(Method.Get, path, undefined, undefined, { prefix: '' }); + const path = utils.encodeUri("/_synapse/admin/v1/whois/$userId", { $userId: userId }); + return this.http.authedRequest(Method.Get, path, undefined, undefined, { prefix: "" }); } /** * Deactivates a user using Synapse's administrator API. This * function is implementation specific and may change as a result. - * @param {string} userId the User ID to deactivate. - * @return {object} the deactivate response - see Synapse docs for information. + * @param userId - the User ID to deactivate. + * @returns the deactivate response - see Synapse docs for information. */ public deactivateSynapseUser(userId: string): Promise { - const path = utils.encodeUri( - "/_synapse/admin/v1/deactivate/$userId", - { $userId: userId }, - ); - return this.http.authedRequest(Method.Post, path, undefined, undefined, { prefix: '' }); + const path = utils.encodeUri("/_synapse/admin/v1/deactivate/$userId", { $userId: userId }); + return this.http.authedRequest(Method.Post, path, undefined, undefined, { prefix: "" }); } private async fetchClientWellKnown(): Promise { @@ -6773,16 +6817,17 @@ export class MatrixClient extends TypedEventEmitter { // XXX: Intended private, used in code + public storeClientOptions(): Promise { + // XXX: Intended private, used in code const primTypes = ["boolean", "string", "number"]; const serializableOpts = Object.entries(this.clientOpts!) .filter(([key, value]) => { return primTypes.includes(typeof value); }) - .reduce((obj, [key, value]) => { + .reduce>((obj, [key, value]) => { obj[key] = value; return obj; }, {}); @@ -6791,53 +6836,56 @@ export class MatrixClient extends TypedEventEmitter} Resolves to a set of rooms - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @param userId - The userId to check. + * @returns Promise which resolves to a set of rooms + * @returns Rejects: with an error response. */ - public async _unstable_getSharedRooms(userId: string): Promise { // eslint-disable-line + // eslint-disable-next-line + public async _unstable_getSharedRooms(userId: string): Promise { const sharedRoomsSupport = await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666"); const mutualRoomsSupport = await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666.mutual_rooms"); if (!sharedRoomsSupport && !mutualRoomsSupport) { - throw Error('Server does not support mutual_rooms API'); + throw Error("Server does not support mutual_rooms API"); } const path = utils.encodeUri( - `/uk.half-shot.msc2666/user/${mutualRoomsSupport ? 'mutual_rooms' : 'shared_rooms'}/$userId`, + `/uk.half-shot.msc2666/user/${mutualRoomsSupport ? "mutual_rooms" : "shared_rooms"}/$userId`, { $userId: userId }, ); - const res = await this.http.authedRequest<{ joined: string[] }>( - Method.Get, path, undefined, undefined, - { prefix: ClientPrefix.Unstable }, - ); + const res = await this.http.authedRequest<{ joined: string[] }>(Method.Get, path, undefined, undefined, { + prefix: ClientPrefix.Unstable, + }); return res.joined; } /** * Get the API versions supported by the server, along with any * unstable APIs it supports - * @return {Promise} The server /versions response + * @returns The server /versions response */ public async getVersions(): Promise { if (this.serverVersionsPromise) { return this.serverVersionsPromise; } - this.serverVersionsPromise = this.http.request( - Method.Get, "/_matrix/client/versions", - undefined, // queryParams - undefined, // data - { - prefix: '', - }, - ).catch(e => { - // Need to unset this if it fails, otherwise we'll never retry - this.serverVersionsPromise = undefined; - // but rethrow the exception to anything that was waiting - throw e; - }); + this.serverVersionsPromise = this.http + .request( + Method.Get, + "/_matrix/client/versions", + undefined, // queryParams + undefined, // data + { + prefix: "", + }, + ) + .catch((e) => { + // Need to unset this if it fails, otherwise we'll never retry + this.serverVersionsPromise = undefined; + // but rethrow the exception to anything that was waiting + throw e; + }); const serverVersions = await this.serverVersionsPromise; this.canSupport = await buildFeatureSupportMap(serverVersions); @@ -6851,8 +6899,8 @@ export class MatrixClient extends TypedEventEmitter} Whether it is supported + * @param version - The spec version (such as "r0.5.0") to check for. + * @returns Whether it is supported */ public async isVersionSupported(version: string): Promise { const { versions } = await this.getVersions(); @@ -6861,7 +6909,7 @@ export class MatrixClient extends TypedEventEmitter} true if server supports lazy loading + * @returns true if server supports lazy loading */ public async doesServerSupportLazyLoading(): Promise { const response = await this.getVersions(); @@ -6870,14 +6918,15 @@ export class MatrixClient extends TypedEventEmitter} true if id_server parameter is required + * @returns true if id_server parameter is required */ public async doesServerRequireIdServerParam(): Promise { const response = await this.getVersions(); @@ -6903,7 +6952,7 @@ export class MatrixClient extends TypedEventEmitter} true if id_access_token can be sent + * @returns true if id_access_token can be sent */ public async doesServerAcceptIdentityAccessToken(): Promise { const response = await this.getVersions(); @@ -6911,15 +6960,14 @@ export class MatrixClient extends TypedEventEmitter} true if separate functions are supported + * @returns true if separate functions are supported */ public async doesServerSupportSeparateAddAndBind(): Promise { const response = await this.getVersions(); @@ -6934,8 +6982,8 @@ export class MatrixClient extends TypedEventEmitter} true if the feature is supported + * @param feature - the feature name + * @returns true if the feature is supported */ public async doesServerSupportUnstableFeature(feature: string): Promise { const response = await this.getVersions(); @@ -6947,8 +6995,8 @@ export class MatrixClient extends TypedEventEmitter} true if the server is forcing encryption + * @param presetName - The name of the preset to check. + * @returns true if the server is forcing encryption * for the preset. */ public async doesServerForceEncryptionForPreset(presetName: Preset): Promise { @@ -6978,18 +7026,15 @@ export class MatrixClient extends TypedEventEmitter} true if server supports the `logout_devices` parameter + * @returns true if server supports the `logout_devices` parameter */ public doesServerSupportLogoutDevices(): Promise { return this.isVersionSupported("r0.6.1"); @@ -7015,7 +7060,7 @@ export class MatrixClient extends TypedEventEmitter { const fetchedEventType = eventType ? this.getEncryptedIfNeededEventType(roomId, eventType) : null; - const result = await this.fetchRelations( - roomId, - eventId, - relationType, - fetchedEventType, - opts); + const [eventResult, result] = await Promise.all([ + this.fetchRoomEvent(roomId, eventId), + this.fetchRelations(roomId, eventId, relationType, fetchedEventType, opts), + ]); const mapper = this.getEventMapper(); - const originalEvent = result.original_event ? mapper(result.original_event) : undefined; + const originalEvent = eventResult ? mapper(eventResult) : undefined; let events = result.chunk.map(mapper); if (fetchedEventType === EventType.RoomMessageEncrypted) { const allEvents = originalEvent ? events.concat(originalEvent) : events; - await Promise.all(allEvents.map(e => this.decryptEventIfNeeded(e))); + await Promise.all(allEvents.map((e) => this.decryptEventIfNeeded(e))); if (eventType !== null) { - events = events.filter(e => e.getType() === eventType); + events = events.filter((e) => e.getType() === eventType); } } if (originalEvent && relationType === RelationType.Replace) { - events = events.filter(e => e.getSender() === originalEvent.getSender()); + events = events.filter((e) => e.getSender() === originalEvent.getSender()); } return { originalEvent: originalEvent ?? null, @@ -7099,7 +7142,6 @@ export class MatrixClient extends TypedEventEmitterThis * method is experimental and may change. - * @return {string} A new client secret + * @returns A new client secret */ public generateClientSecret(): string { return randomString(32); @@ -7117,15 +7159,12 @@ export class MatrixClient extends TypedEventEmitter} A decryption promise - * @param {object} options - * @param {boolean} options.isRetry True if this is a retry (enables more logging) - * @param {boolean} options.emit Emits "event.decrypted" if set to true + * @param event - The event to decrypt + * @returns A decryption promise */ public decryptEventIfNeeded(event: MatrixEvent, options?: IDecryptOptions): Promise { if (event.shouldAttemptDecryption() && this.isCryptoEnabled()) { - event.attemptDecryption(this.crypto!, options); + event.attemptDecryption(this.cryptoBackend!, options); } if (event.isBeingDecrypted()) { @@ -7142,13 +7181,13 @@ export class MatrixClient extends TypedEventEmitter { - return this.http.authedRequest<{ available: true }>( - Method.Get, '/register/available', { username }, - ).then((response) => { - return response.available; - }).catch(response => { - if (response.errcode === "M_USER_IN_USE") { - return false; - } - return Promise.reject(response); - }); + return this.http + .authedRequest<{ available: true }>(Method.Get, "/register/available", { username }) + .then((response) => { + return response.available; + }) + .catch((response) => { + if (response.errcode === "M_USER_IN_USE") { + return false; + } + return Promise.reject(response); + }); } /** - * @param {string} username - * @param {string} password - * @param {string} sessionId - * @param {Object} auth - * @param {Object} bindThreepids Set key 'email' to true to bind any email + * @param bindThreepids - Set key 'email' to true to bind any email * threepid uses during registration in the identity server. Set 'msisdn' to * true to bind msisdn. - * @param {string} guestAccessToken - * @param {string} inhibitLogin - * @return {Promise} Resolves: TODO - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. */ public register( username: string, password: string, sessionId: string | null, - auth: { session?: string, type: string }, - bindThreepids?: boolean | null | { email?: boolean, msisdn?: boolean }, + auth: { session?: string; type: string }, + bindThreepids?: boolean | null | { email?: boolean; msisdn?: boolean }, guestAccessToken?: string, inhibitLogin?: boolean, ): Promise { @@ -7309,23 +7343,21 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types - opts = opts || {}; - opts.body = opts.body || {}; - return this.registerRequest(opts.body, "guest"); + public registerGuest({ body }: { body?: any } = {}): Promise { + // TODO: Types + return this.registerRequest(body || {}, "guest"); } /** - * @param {Object} data parameters for registration request - * @param {string=} kind type of user to register. may be "guest" - * @return {Promise} Resolves: to the /register response - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @param data - parameters for registration request + * @param kind - type of user to register. may be "guest" + * @returns Promise which resolves: to the /register response + * @returns Rejects: with an error response. */ public registerRequest(data: IRegisterRequestParams, kind?: string): Promise { const params: { kind?: string } = {}; @@ -7343,9 +7375,9 @@ export class MatrixClient extends TypedEventEmitter} Resolves to the new token. - * @return {module:http-api.MatrixError} Rejects with an error response. + * @param refreshToken - The refresh token. + * @returns Promise which resolves to the new token. + * @returns Rejects with an error response. */ public refreshToken(refreshToken: string): Promise { return this.http.authedRequest( @@ -7361,20 +7393,19 @@ export class MatrixClient extends TypedEventEmitter} Resolves to the available login flows - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Promise which resolves to the available login flows + * @returns Rejects: with an error response. */ public loginFlows(): Promise { return this.http.request(Method.Get, "/login"); } /** - * @param {string} loginType - * @param {Object} data - * @return {Promise} Resolves: TODO - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. */ - public login(loginType: string, data: any): Promise { // TODO: Types + public login(loginType: string, data: any): Promise { + // TODO: Types const loginData = { type: loginType, }; @@ -7382,27 +7413,28 @@ export class MatrixClient extends TypedEventEmitter(Method.Post, "/login", undefined, loginData).then(response => { - if (response.access_token && response.user_id) { - this.http.opts.accessToken = response.access_token; - this.credentials = { - userId: response.user_id, - }; - } - return response; - }); + return this.http + .authedRequest<{ + access_token?: string; + user_id?: string; + }>(Method.Post, "/login", undefined, loginData) + .then((response) => { + if (response.access_token && response.user_id) { + this.http.opts.accessToken = response.access_token; + this.credentials = { + userId: response.user_id, + }; + } + return response; + }); } /** - * @param {string} user - * @param {string} password - * @return {Promise} Resolves: TODO - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. */ - public loginWithPassword(user: string, password: string): Promise { // TODO: Types + public loginWithPassword(user: string, password: string): Promise { + // TODO: Types return this.login("m.login.password", { user: user, password: password, @@ -7410,40 +7442,36 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + public loginWithSAML2(relayState: string): Promise { + // TODO: Types return this.login("m.login.saml2", { relay_state: relayState, }); } /** - * @param {string} redirectUrl The URL to redirect to after the HS + * @param redirectUrl - The URL to redirect to after the HS * authenticates with CAS. - * @return {string} The HS URL to hit to begin the CAS login process. + * @returns The HS URL to hit to begin the CAS login process. */ public getCasLoginUrl(redirectUrl: string): string { return this.getSsoLoginUrl(redirectUrl, "cas"); } /** - * @param {string} redirectUrl The URL to redirect to after the HS + * @param redirectUrl - The URL to redirect to after the HS * authenticates with the SSO. - * @param {string} loginType The type of SSO login we are doing (sso or cas). + * @param loginType - The type of SSO login we are doing (sso or cas). * Defaults to 'sso'. - * @param {string} idpId The ID of the Identity Provider being targeted, optional. - * @param {SSOAction} action the SSO flow to indicate to the IdP, optional. - * @return {string} The HS URL to hit to begin the SSO login process. - */ - public getSsoLoginUrl( - redirectUrl: string, - loginType = "sso", - idpId?: string, - action?: SSOAction, - ): string { + * @param idpId - The ID of the Identity Provider being targeted, optional. + * @param action - the SSO flow to indicate to the IdP, optional. + * @returns The HS URL to hit to begin the SSO login process. + */ + public getSsoLoginUrl(redirectUrl: string, loginType = "sso", idpId?: string, action?: SSOAction): string { let url = "/login/" + loginType + "/redirect"; if (idpId) { url += "/" + idpId; @@ -7458,11 +7486,12 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + public loginWithToken(token: string): Promise { + // TODO: Types return this.login("m.login.token", { token: token, }); @@ -7474,18 +7503,15 @@ export class MatrixClient extends TypedEventEmitter { if (this.crypto?.backupManager?.getKeyBackupEnabled()) { try { - while (await this.crypto.backupManager.backupPendingKeys(200) > 0); + while ((await this.crypto.backupManager.backupPendingKeys(200)) > 0); } catch (err) { - logger.error( - "Key backup request failed when logging out. Some keys may be missing from backup", - err, - ); + logger.error("Key backup request failed when logging out. Some keys may be missing from backup", err); } } @@ -7494,7 +7520,7 @@ export class MatrixClient extends TypedEventEmitter { const body: any = {}; @@ -7518,7 +7544,7 @@ export class MatrixClient extends TypedEventEmitter>} Resolves: On success, the token response + * @param auth - Optional. Auth data to supply for User-Interactive auth. + * @returns Promise which resolves: On success, the token response * or UIA auth data. */ public requestLoginToken(auth?: IAuthData): Promise> { @@ -7544,43 +7570,41 @@ export class MatrixClient extends TypedEventEmitter{room_id: {string}} - * @return {module:http-api.MatrixError} Rejects: with an error response. - */ - public async createRoom(options: ICreateRoomOpts): Promise<{ room_id: string }> { // eslint-disable-line camelcase + * @param options - a list of options to pass to the /createRoom API. + * @returns Promise which resolves: `{room_id: {string}}` + * @returns Rejects: with an error response. + */ + public async createRoom(options: ICreateRoomOpts): Promise<{ room_id: string }> { + // eslint-disable-line camelcase // some valid options include: room_alias_name, visibility, invite // inject the id_access_token if inviting 3rd party addresses - const invitesNeedingToken = (options.invite_3pid || []) - .filter(i => !i.id_access_token); + const invitesNeedingToken = (options.invite_3pid || []).filter((i) => !i.id_access_token); if ( invitesNeedingToken.length > 0 && this.identityServer?.getAccessToken && - await this.doesServerAcceptIdentityAccessToken() + (await this.doesServerAcceptIdentityAccessToken()) ) { const identityAccessToken = await this.identityServer.getAccessToken(); if (identityAccessToken) { @@ -7595,12 +7619,12 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomId/state", { $roomId: roomId }); @@ -7653,29 +7673,24 @@ export class MatrixClient extends TypedEventEmitter> { - const path = utils.encodeUri( - "/rooms/$roomId/event/$eventId", { - $roomId: roomId, - $eventId: eventId, - }, - ); + const path = utils.encodeUri("/rooms/$roomId/event/$eventId", { + $roomId: roomId, + $eventId: eventId, + }); return this.http.authedRequest(Method.Get, path); } /** - * @param {string} roomId - * @param {string} includeMembership the membership type to include in the response - * @param {string} excludeMembership the membership type to exclude from the response - * @param {string} atEventId the id of the event for which moment in the timeline the members should be returned for - * @return {Promise} Resolves: dictionary of userid to profile information - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @param includeMembership - the membership type to include in the response + * @param excludeMembership - the membership type to exclude from the response + * @param atEventId - the id of the event for which moment in the timeline the members should be returned for + * @returns Promise which resolves: dictionary of userid to profile information + * @returns Rejects: with an error response. */ public members( roomId: string, @@ -7696,39 +7711,28 @@ export class MatrixClient extends TypedEventEmitter { // eslint-disable-line camelcase + public upgradeRoom(roomId: string, newVersion: string): Promise<{ replacement_room: string }> { + // eslint-disable-line camelcase const path = utils.encodeUri("/rooms/$roomId/upgrade", { $roomId: roomId }); return this.http.authedRequest(Method.Post, path, undefined, { new_version: newVersion }); } /** * Retrieve a state event. - * @param {string} roomId - * @param {string} eventType - * @param {string} stateKey - * @return {Promise} Resolves: TODO - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Promise which resolves: TODO + * @returns Rejects: with an error response. */ - public getStateEvent( - roomId: string, - eventType: string, - stateKey: string, - ): Promise> { + public getStateEvent(roomId: string, eventType: string, stateKey: string): Promise> { const pathParams = { $roomId: roomId, $eventType: eventType, @@ -7742,13 +7746,9 @@ export class MatrixClient extends TypedEventEmitter { - const path = utils.encodeUri("/rooms/$roomId/initialSync", - { $roomId: roomId }, - ); + const path = utils.encodeUri("/rooms/$roomId/initialSync", { $roomId: roomId }); return this.http.authedRequest(Method.Get, path, { limit: limit?.toString() ?? "30" }); } @@ -7788,14 +7784,14 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/joined_rooms", {}); @@ -7833,10 +7829,10 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomId/joined_members", { @@ -7846,20 +7842,21 @@ export class MatrixClient extends TypedEventEmitter { + * @param limit - Maximum number of entries to return + * @param since - Token to paginate from + * @returns Promise which resolves: IPublicRoomsResponse + * @returns Rejects: with an error response. + */ + public publicRooms({ + server, + limit, + since, + ...options + }: IRoomDirectoryOptions = {}): Promise { const queryParams: QueryDict = { server, limit, since }; if (Object.keys(options).length === 0) { return this.http.authedRequest(Method.Get, "/publicRooms", queryParams); @@ -7870,10 +7867,10 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/directory/room/$alias", { @@ -7888,9 +7885,9 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/directory/room/$alias", { @@ -7902,9 +7899,9 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomId/aliases", { $roomId: roomId }); @@ -7914,13 +7911,12 @@ export class MatrixClient extends TypedEventEmitter { // eslint-disable-line camelcase + public getRoomIdForAlias(alias: string): Promise<{ room_id: string; servers: string[] }> { + // eslint-disable-line camelcase // TODO: deprecate this or resolveRoomAlias const path = utils.encodeUri("/directory/room/$alias", { $alias: alias, @@ -7929,12 +7925,11 @@ export class MatrixClient extends TypedEventEmitter { + public resolveRoomAlias(roomAlias: string): Promise<{ room_id: string; servers: string[] }> { // TODO: deprecate this or getRoomIdForAlias const path = utils.encodeUri("/directory/room/$alias", { $alias: roomAlias }); return this.http.request(Method.Get, path); @@ -7942,9 +7937,8 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/directory/list/room/$roomId", { @@ -7955,12 +7949,11 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/directory/list/room/$roomId", { @@ -7972,42 +7965,41 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + ): Promise { + // TODO: Types const path = utils.encodeUri("/directory/list/appservice/$networkId/$roomId", { $networkId: networkId, $roomId: roomId, }); - return this.http.authedRequest(Method.Put, path, undefined, { "visibility": visibility }); + return this.http.authedRequest(Method.Put, path, undefined, { visibility: visibility }); } /** * Query the user directory with a term matching user IDs, display names and domains. - * @param {object} opts options - * @param {string} opts.term the term with which to search. - * @param {number} opts.limit the maximum number of results to return. The server will + * @param term - the term with which to search. + * @param limit - the maximum number of results to return. The server will * apply a limit if unspecified. - * @return {Promise} Resolves: an array of results. + * @returns Promise which resolves: an array of results. */ - public searchUserDirectory(opts: { term: string, limit?: number }): Promise { + public searchUserDirectory({ term, limit }: { term: string; limit?: number }): Promise { const body: any = { - search_term: opts.term, + search_term: term, }; - if (opts.limit !== undefined) { - body.limit = opts.limit; + if (limit !== undefined) { + body.limit = limit; } return this.http.authedRequest(Method.Post, "/user_directory/search", undefined, body); @@ -8016,27 +8008,13 @@ export class MatrixClient extends TypedEventEmitterfile.name. - * - * @param {boolean=} opts.includeFilename if false will not send the filename, - * e.g for encrypted file uploads where filename leaks are undesirable. - * Defaults to true. + * @param opts - options object * - * @param {string=} opts.type Content-type for the upload. Defaults to - * file.type, or applicaton/octet-stream. - * - * @param {Function=} opts.progressHandler Optional. Called when a chunk of - * data has been uploaded, with an object containing the fields `loaded` - * (number of bytes transferred) and `total` (total size, if known). - * - * @return {Promise} Resolves to response object, as + * @returns Promise which resolves to response object, as * determined by this.opts.onlyData, opts.rawResponse, and * opts.onlyContentUri. Rejects with an error (usually a MatrixError). */ @@ -8046,8 +8024,8 @@ export class MatrixClient extends TypedEventEmitter): boolean { return this.http.cancelUpload(upload); @@ -8055,7 +8033,7 @@ export class MatrixClient extends TypedEventEmitter { - const path = info ? - utils.encodeUri("/profile/$userId/$info", - { $userId: userId, $info: info }) : - utils.encodeUri("/profile/$userId", - { $userId: userId }); + ): Promise<{ avatar_url?: string; displayname?: string }> { + const path = info + ? utils.encodeUri("/profile/$userId/$info", { $userId: userId, $info: info }) + : utils.encodeUri("/profile/$userId", { $userId: userId }); return this.http.authedRequest(Method.Get, path); } /** - * @return {Promise} Resolves to a list of the user's threepids. - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Promise which resolves to a list of the user's threepids. + * @returns Rejects: with an error response. */ public getThreePids(): Promise<{ threepids: IThreepid[] }> { return this.http.authedRequest(Method.Get, "/account/3pid"); @@ -8100,16 +8075,15 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + public addThreePid(creds: any, bind: boolean): Promise { + // TODO: Types const path = "/account/3pid"; const data = { - 'threePidCreds': creds, - 'bind': bind, + threePidCreds: creds, + bind: bind, }; return this.http.authedRequest(Method.Post, path, undefined, data); } @@ -8121,14 +8095,14 @@ export class MatrixClient extends TypedEventEmitter/requestToken` on the homeserver. - * @return {Promise} Resolves: to an empty object {} - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. */ public async addThreePidOnly(data: IAddThreePidOnlyBody): Promise<{}> { const path = "/account/3pid/add"; - const prefix = await this.isVersionSupported("r0.6.0") ? ClientPrefix.R0 : ClientPrefix.Unstable; + const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable; return this.http.authedRequest(Method.Post, path, undefined, data, { prefix }); } @@ -8140,15 +8114,15 @@ export class MatrixClient extends TypedEventEmitter/requestToken` on the identity server. It should also * contain `id_server` and `id_access_token` fields as well. - * @return {Promise} Resolves: to an empty object {} - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. */ public async bindThreePid(data: IBindThreePidBody): Promise<{}> { const path = "/account/3pid/bind"; - const prefix = await this.isVersionSupported("r0.6.0") ? ClientPrefix.R0 : ClientPrefix.Unstable; + const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable; return this.http.authedRequest(Method.Post, path, undefined, data, { prefix }); } @@ -8157,11 +8131,11 @@ export class MatrixClient extends TypedEventEmitter { + * @param newPassword - The new desired password. + * @param logoutDevices - Should all sessions be logged out after the password change. Defaults to true. + * @returns Promise which resolves: to an empty object `{}` + * @returns Rejects: with an error response. + */ + public setPassword(authDict: IAuthDict, newPassword: string, logoutDevices?: boolean): Promise<{}> { const path = "/account/password"; const data = { - 'auth': authDict, - 'new_password': newPassword, - 'logout_devices': logoutDevices, + auth: authDict, + new_password: newPassword, + logout_devices: logoutDevices, }; return this.http.authedRequest<{}>(Method.Post, path, undefined, data); @@ -8220,8 +8189,8 @@ export class MatrixClient extends TypedEventEmitter { return this.http.authedRequest(Method.Get, "/devices"); @@ -8229,9 +8198,9 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/devices/$device_id", { @@ -8243,10 +8212,10 @@ export class MatrixClient extends TypedEventEmitter { @@ -8260,10 +8229,10 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/devices/$device_id", { @@ -8282,10 +8251,10 @@ export class MatrixClient extends TypedEventEmitter { const body: any = { devices }; @@ -8301,16 +8270,16 @@ export class MatrixClient extends TypedEventEmitter { const response = await this.http.authedRequest<{ pushers: IPusher[] }>(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 (!(await this.doesServerSupportUnstableFeature("org.matrix.msc3881"))) { + response.pushers = response.pushers.map((pusher) => { if (!pusher.hasOwnProperty(PUSHER_ENABLED.name)) { pusher[PUSHER_ENABLED.name] = true; } @@ -8324,9 +8293,9 @@ export class MatrixClient extends TypedEventEmitter { const path = "/pushers/set"; @@ -8335,10 +8304,8 @@ export class MatrixClient extends TypedEventEmitter { return this.http.authedRequest(Method.Get, "/pushrules/").then((rules: IPushRules) => { @@ -8360,12 +8327,8 @@ export class MatrixClient extends TypedEventEmitter, - ): Promise<{}> { + public deletePushRule(scope: string, kind: PushRuleKind, ruleId: Exclude): Promise<{}> { // NB. Scope not uri encoded because devices need the '/' const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", { $kind: kind, @@ -8403,12 +8359,8 @@ export class MatrixClient extends TypedEventEmitter { const queryParams: any = {}; - if (opts.next_batch) { - queryParams.next_batch = opts.next_batch; + if (nextBatch) { + queryParams.next_batch = nextBatch; } - return this.http.authedRequest(Method.Post, "/search", queryParams, opts.body, { abortSignal }); + return this.http.authedRequest(Method.Post, "/search", queryParams, body, { abortSignal }); } /** * Upload keys * - * @param {Object} content body of upload request + * @param content - body of upload request * - * @param {Object=} opts this method no longer takes any opts, + * @param opts - this method no longer takes any opts, * used to take opts.device_id but this was not removed from the spec as a redundant parameter * - * @return {Promise} Resolves: result object. Rejects: with - * an error response ({@link module:http-api.MatrixError}). + * @returns Promise which resolves: result object. Rejects: with + * an error response ({@link MatrixError}). */ - public uploadKeysRequest( - content: IUploadKeysRequest, - opts?: void, - ): Promise { + public uploadKeysRequest(content: IUploadKeysRequest, opts?: void): Promise { return this.http.authedRequest(Method.Post, "/keys/upload", undefined, content); } public uploadKeySignatures(content: KeySignatures): Promise { - return this.http.authedRequest( - Method.Post, '/keys/signatures/upload', undefined, - content, { - prefix: ClientPrefix.V3, - }, - ); + return this.http.authedRequest(Method.Post, "/keys/signatures/upload", undefined, content, { + prefix: ClientPrefix.V3, + }); } /** * Download device keys * - * @param {string[]} userIds list of users to get keys for - * - * @param {Object=} opts + * @param userIds - list of users to get keys for * - * @param {string=} opts.token sync token to pass in the query request, to help + * @param token - sync token to pass in the query request, to help * the HS give the most recent results * - * @return {Promise} Resolves: result object. Rejects: with - * an error response ({@link module:http-api.MatrixError}). + * @returns Promise which resolves: result object. Rejects: with + * an error response ({@link MatrixError}). */ - public downloadKeysForUsers(userIds: string[], opts: { token?: string } = {}): Promise { - const content: any = { + public downloadKeysForUsers(userIds: string[], { token }: { token?: string } = {}): Promise { + const content: IQueryKeysRequest = { device_keys: {}, }; - if ('token' in opts) { - content.token = opts.token; + if (token !== undefined) { + content.token = token; } userIds.forEach((u) => { content.device_keys[u] = []; @@ -8522,15 +8461,15 @@ export class MatrixClient extends TypedEventEmitter { + public getKeyChanges(oldToken: string, newToken: string): Promise<{ changed: string[]; left: string[] }> { const qps = { from: oldToken, to: newToken, @@ -8575,28 +8512,26 @@ export class MatrixClient extends TypedEventEmitter { // API returns empty object + public uploadDeviceSigningKeys(auth?: IAuthData, keys?: CrossSigningKeys): Promise<{}> { + // API returns empty object const data = Object.assign({}, keys); if (auth) Object.assign(data, { auth }); - return this.http.authedRequest( - Method.Post, "/keys/device_signing/upload", undefined, data, { - prefix: ClientPrefix.Unstable, - }, - ); + return this.http.authedRequest(Method.Post, "/keys/device_signing/upload", undefined, data, { + prefix: ClientPrefix.Unstable, + }); } /** * Register with an identity server using the OpenID token from the user's * Homeserver, which can be retrieved via - * {@link module:client~MatrixClient#getOpenIdToken}. + * {@link MatrixClient#getOpenIdToken}. * * Note that the `/account/register` endpoint (as well as IS authentication in * general) was added as part of the v2 API version. * - * @param {object} hsOpenIdToken - * @return {Promise} Resolves: with object containing an Identity + * @returns Promise which resolves: with object containing an Identity * Server access token. - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Rejects: with an error response. */ public registerWithIdentityServer(hsOpenIdToken: IOpenIDToken): Promise<{ access_token: string; @@ -8617,20 +8552,20 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + ): Promise { + // TODO: Types const params = { client_secret: clientSecret, email: email, @@ -8648,8 +8584,11 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + ): Promise { + // TODO: Types const params = { client_secret: clientSecret, country: phoneCountry, @@ -8696,8 +8636,11 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + ): Promise { + // TODO: Types const params = { sid: sid, client_secret: clientSecret, @@ -8733,8 +8677,11 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + ): Promise { + // TODO: Types const params = { sid: sid, client_secret: clientSecret, @@ -8773,10 +8721,11 @@ export class MatrixClient extends TypedEventEmitter} The hashing information for the identity server. + * @param identityAccessToken - The access token for the identity server. + * @returns The hashing information for the identity server. */ - public getIdentityHashDetails(identityAccessToken: string): Promise { // TODO: Types + public getIdentityHashDetails(identityAccessToken: string): Promise { + // TODO: Types return this.http.idServerRequest( Method.Get, "/hash_details", @@ -8789,17 +8738,17 @@ export class MatrixClient extends TypedEventEmitter>} addressPairs An array of 2 element arrays. + * @param addressPairs - An array of 2 element arrays. * The first element of each pair is the address, the second is the 3PID medium. - * Eg: ["email@example.org", "email"] - * @param {string} identityAccessToken The access token for the identity server. - * @returns {Promise>} A collection of address mappings to + * Eg: `["email@example.org", "email"]` + * @param identityAccessToken - The access token for the identity server. + * @returns A collection of address mappings to * found MXIDs. Results where no user could be found will not be listed. */ public async identityHashedLookup( addressPairs: [string, string][], identityAccessToken: string, - ): Promise<{ address: string, mxid: string }[]> { + ): Promise<{ address: string; mxid: string }[]> { const params: Record = { // addresses: ["email@example.org", "10005550000"], // algorithm: "sha256", @@ -8808,11 +8757,11 @@ export class MatrixClient extends TypedEventEmitter = { // hashed identifier => plain text address @@ -8820,27 +8769,29 @@ export class MatrixClient extends TypedEventEmitter { + params["addresses"] = addressPairs.map((p) => { const addr = p[0].toLowerCase(); // lowercase to get consistent hashes const med = p[1].toLowerCase(); - const hashed = olmutil.sha256(`${addr} ${med} ${params['pepper']}`) - .replace(/\+/g, '-').replace(/\//g, '_'); // URL-safe base64 + const hashed = olmutil + .sha256(`${addr} ${med} ${params["pepper"]}`) + .replace(/\+/g, "-") + .replace(/\//g, "_"); // URL-safe base64 // Map the hash to a known (case-sensitive) address. We use the case // sensitive version because the caller might be expecting that. localMapping[hashed] = p[0]; return hashed; }); params["algorithm"] = "sha256"; - } else if (hashes['algorithms'].includes('none')) { - params["addresses"] = addressPairs.map(p => { + } else if (hashes["algorithms"].includes("none")) { + params["addresses"] = addressPairs.map((p) => { const addr = p[0].toLowerCase(); // lowercase to get consistent hashes const med = p[1].toLowerCase(); const unhashed = `${addr} ${med}`; // Map the unhashed values to a known (case-sensitive) address. We use - // the case sensitive version because the caller might be expecting that. + // the case-sensitive version because the caller might be expecting that. localMapping[unhashed] = p[0]; return unhashed; }); @@ -8849,16 +8800,15 @@ export class MatrixClient extends TypedEventEmitter(Method.Post, "/lookup", params, IdentityPrefix.V2, identityAccessToken); - if (!response || !response['mappings']) return []; // no results + if (!response?.["mappings"]) return []; // no results - const foundAddresses: { address: string, mxid: string }[] = []; - for (const hashed of Object.keys(response['mappings'])) { - const mxid = response['mappings'][hashed]; + const foundAddresses: { address: string; mxid: string }[] = []; + for (const hashed of Object.keys(response["mappings"])) { + const mxid = response["mappings"][hashed]; const plainAddress = localMapping[hashed]; if (!plainAddress) { throw new Error("Identity server returned more results than expected"); @@ -8873,26 +8823,23 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + public async lookupThreePid(medium: string, address: string, identityAccessToken: string): Promise { + // TODO: Types // Note: we're using the V2 API by calling this function, but our // function contract requires a V1 response. We therefore have to // convert it manually. const response = await this.identityHashedLookup([[address, medium]], identityAccessToken); - const result = response.find(p => p.address === address); + const result = response.find((p) => p.address === address); if (!result) { return {}; } @@ -8915,26 +8862,28 @@ export class MatrixClient extends TypedEventEmitter>} query Array of arrays containing + * @param query - Array of arrays containing * [medium, address] - * @param {string} identityAccessToken The `access_token` field of the Identity + * @param identityAccessToken - The `access_token` field of the Identity * Server `/account/register` response (see {@link registerWithIdentityServer}). * - * @return {Promise} Resolves: Lookup results from IS. - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @returns Promise which resolves: Lookup results from IS. + * @returns Rejects: with an error response. */ - public async bulkLookupThreePids(query: [string, string][], identityAccessToken: string): Promise { // TODO: Types + public async bulkLookupThreePids(query: [string, string][], identityAccessToken: string): Promise { + // TODO: Types // Note: we're using the V2 API by calling this function, but our // function contract requires a V1 response. We therefore have to // convert it manually. const response = await this.identityHashedLookup( // We have to reverse the query order to get [address, medium] pairs - query.map(p => [p[1], p[0]]), identityAccessToken, + query.map((p) => [p[1], p[0]]), + identityAccessToken, ); const v1results: [medium: string, address: string, mxid: string][] = []; for (const mapping of response) { - const originalQuery = query.find(p => p[1] === mapping.address); + const originalQuery = query.find((p) => p[1] === mapping.address); if (!originalQuery) { throw new Error("Identity sever returned unexpected results"); } @@ -8954,17 +8903,15 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types - return this.http.idServerRequest( - Method.Get, "/account", - undefined, IdentityPrefix.V2, identityAccessToken, - ); + public getIdentityAccount(identityAccessToken: string): Promise { + // TODO: Types + return this.http.idServerRequest(Method.Get, "/account", undefined, IdentityPrefix.V2, identityAccessToken); } /** @@ -8973,12 +8920,11 @@ export class MatrixClient extends TypedEventEmitter>} contentMap + * @param eventType - type of event to send * content to send. Map from user_id to device_id to content object. - * @param {string=} txnId transaction id. One will be made up if not + * @param txnId - transaction id. One will be made up if not * supplied. - * @return {Promise} Resolves: to an empty object {} + * @returns Promise which resolves: to an empty object `{}` */ public sendToDevice( eventType: string, @@ -8994,7 +8940,7 @@ export class MatrixClient extends TypedEventEmitter { + const targets = Object.keys(contentMap).reduce>((obj, key) => { obj[key] = Object.keys(contentMap[key]); return obj; }, {}); @@ -9008,7 +8954,7 @@ export class MatrixClient extends TypedEventEmitter { return this.toDeviceMessageQueue.queueBatch(batch); @@ -9017,27 +8963,27 @@ export class MatrixClient extends TypedEventEmitter { - return this.http.authedRequest>( - Method.Get, "/thirdparty/protocols", - ).then((response) => { - // sanity check - if (!response || typeof (response) !== 'object') { - throw new Error(`/thirdparty/protocols did not return an object: ${response}`); - } - return response; - }); + return this.http + .authedRequest>(Method.Get, "/thirdparty/protocols") + .then((response) => { + // sanity check + if (!response || typeof response !== "object") { + throw new Error(`/thirdparty/protocols did not return an object: ${response}`); + } + return response; + }); } /** * Get information on how a specific place on a third party protocol * may be reached. - * @param {string} protocol The protocol given in getThirdpartyProtocols() - * @param {object} params Protocol-specific parameters, as given in the + * @param protocol - The protocol given in getThirdpartyProtocols() + * @param params - Protocol-specific parameters, as given in the * response to getThirdpartyProtocols() - * @return {Promise} Resolves to the result object + * @returns Promise which resolves to the result object */ public getThirdpartyLocation( protocol: string, @@ -9053,12 +8999,13 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + public getThirdpartyUser(protocol: string, params: any): Promise { + // TODO: Types const path = utils.encodeUri("/thirdparty/user/$protocol", { $protocol: protocol, }); @@ -9066,7 +9013,8 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + public getTerms(serviceType: SERVICE_TYPES, baseUrl: string): Promise { + // TODO: Types const url = this.termsUrlForService(serviceType, baseUrl); return this.http.requestOtherUrl(Method.Get, url); } @@ -9081,18 +9029,23 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomId/report/$eventId", { @@ -9106,12 +9059,12 @@ export class MatrixClient extends TypedEventEmitter(Method.Get, path, queryParams, undefined, { - prefix: ClientPrefix.V1, - }).catch(e => { - if (e.errcode === "M_UNRECOGNIZED") { - // fall back to the prefixed hierarchy API. - return this.http.authedRequest(Method.Get, path, queryParams, undefined, { - prefix: "/_matrix/client/unstable/org.matrix.msc2946", - }); - } + return this.http + .authedRequest(Method.Get, path, queryParams, undefined, { + prefix: ClientPrefix.V1, + }) + .catch((e) => { + if (e.errcode === "M_UNRECOGNIZED") { + // fall back to the prefixed hierarchy API. + return this.http.authedRequest(Method.Get, path, queryParams, undefined, { + prefix: "/_matrix/client/unstable/org.matrix.msc2946", + }); + } - throw e; - }); + throw e; + }); } /** @@ -9151,8 +9106,8 @@ export class MatrixClient extends TypedEventEmitter} Resolves to the created space. + * @param name - The name of the tree space. + * @returns Promise which resolves to the created space. */ public async unstableCreateFileTree(name: string): Promise { const { room_id: roomId } = await this.createRoom({ @@ -9192,17 +9147,18 @@ export class MatrixClient extends TypedEventEmitter( - Method.Post, - "/sync", - qps, - req, - { - prefix: "/_matrix/client/unstable/org.matrix.msc3575", - baseUrl: proxyBaseUrl, - localTimeoutMs: clientTimeout, - abortSignal, - }, - ); + return this.http.authedRequest(Method.Post, "/sync", qps, req, { + prefix: "/_matrix/client/unstable/org.matrix.msc3575", + baseUrl: proxyBaseUrl, + localTimeoutMs: clientTimeout, + abortSignal, + }); } /** @@ -9260,8 +9210,8 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias }); @@ -9284,10 +9234,7 @@ export class MatrixClient extends TypedEventEmitter { // eslint-disable-line camelcase + public async whoami(): Promise<{ user_id: string }> { + // eslint-disable-line camelcase return this.http.authedRequest(Method.Get, "/account/whoami"); } /** * Find the event_id closest to the given timestamp in the given direction. - * @return {Promise} A promise of an object containing the event_id and + * @returns A promise of an object containing the event_id and * origin_server_ts of the closest event to the timestamp in the given * direction */ - public timestampToEvent( - roomId: string, - timestamp: number, - dir: Direction, - ): Promise { + public timestampToEvent(roomId: string, timestamp: number, dir: Direction): Promise { const path = utils.encodeUri("/rooms/$roomId/timestamp_to_event", { $roomId: roomId, }); @@ -9355,9 +9299,21 @@ export function fixNotificationCountOnDecryption(cli: MatrixClient, event: Matri 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 - const hasReadEvent = isThreadEvent - ? room.getThread(event.threadRootId)?.hasUserReadEvent(cli.getUserId()!, event.getId()!) - : room.hasUserReadEvent(cli.getUserId()!, event.getId()!); + let hasReadEvent; + if (isThreadEvent) { + const thread = room.getThread(event.threadRootId); + hasReadEvent = thread + ? thread.hasUserReadEvent(cli.getUserId()!, event.getId()!) + : // If the thread object does not exist in the room yet, we don't + // want to calculate notification for this event yet. We have not + // restored the read receipts yet and can't accurately calculate + // highlight notifications at this stage. + // + // This issue can likely go away when MSC3874 is implemented + true; + } else { + hasReadEvent = room.hasUserReadEvent(cli.getUserId()!, event.getId()!); + } if (!hasReadEvent) { let newCount = currentCount; @@ -9365,27 +9321,20 @@ export function fixNotificationCountOnDecryption(cli: MatrixClient, event: Matri if (!newHighlight && oldHighlight) newCount--; if (isThreadEvent) { - room.setThreadUnreadNotificationCount( - event.threadRootId, - NotificationCountType.Highlight, - newCount, - ); + 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.getRoomUnreadNotificationCount(NotificationCountType.Total)) ?? 0; + const totalCount = + (isThreadEvent + ? room.getThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Total) + : room.getRoomUnreadNotificationCount(NotificationCountType.Total)) ?? 0; if (totalCount < newCount) { if (isThreadEvent) { - room.setThreadUnreadNotificationCount( - event.threadRootId, - NotificationCountType.Total, - newCount, - ); + room.setThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Total, newCount); } else { room.setUnreadNotificationCount(NotificationCountType.Total, newCount); } @@ -9393,333 +9342,3 @@ export function fixNotificationCountOnDecryption(cli: MatrixClient, event: Matri } } } - -/** - * Fires whenever the SDK receives a new event. - *

- * This is only fired for live events received via /sync - it is not fired for - * events received over context, search, or pagination APIs. - * - * @event module:client~MatrixClient#"event" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @example - * matrixClient.on("event", function(event){ - * var sender = event.getSender(); - * }); - */ - -/** - * Fires whenever the SDK receives a new to-device event. - * @event module:client~MatrixClient#"toDeviceEvent" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @example - * matrixClient.on("toDeviceEvent", function(event){ - * var sender = event.getSender(); - * }); - */ - -/** - * Fires whenever the SDK's syncing state is updated. The state can be one of: - *

    - * - *
  • PREPARED: The client has synced with the server at least once and is - * ready for methods to be called on it. This will be immediately followed by - * a state of SYNCING. This is the equivalent of "syncComplete" in the - * previous API.
  • - * - *
  • CATCHUP: The client has detected the connection to the server might be - * available again and will now try to do a sync again. As this sync might take - * a long time (depending how long ago was last synced, and general server - * performance) the client is put in this mode so the UI can reflect trying - * to catch up with the server after losing connection.
  • - * - *
  • SYNCING : The client is currently polling for new events from the server. - * This will be called after processing latest events from a sync.
  • - * - *
  • ERROR : The client has had a problem syncing with the server. If this is - * called before PREPARED then there was a problem performing the initial - * sync. If this is called after PREPARED then there was a problem polling - * the server for updates. This may be called multiple times even if the state is - * already ERROR. This is the equivalent of "syncError" in the previous - * API.
  • - * - *
  • RECONNECTING: The sync connection has dropped, but not (yet) in a way that - * should be considered erroneous. - *
  • - * - *
  • STOPPED: The client has stopped syncing with server due to stopClient - * being called. - *
  • - *
- * State transition diagram: - *
- *                                          +---->STOPPED
- *                                          |
- *              +----->PREPARED -------> SYNCING <--+
- *              |                        ^  |  ^    |
- *              |      CATCHUP ----------+  |  |    |
- *              |        ^                  V  |    |
- *   null ------+        |  +------- RECONNECTING   |
- *              |        V  V                       |
- *              +------->ERROR ---------------------+
- *
- * NB: 'null' will never be emitted by this event.
- *
- * 
- * Transitions: - *
    - * - *
  • null -> PREPARED : Occurs when the initial sync is completed - * first time. This involves setting up filters and obtaining push rules. - * - *
  • null -> ERROR : Occurs when the initial sync failed first time. - * - *
  • ERROR -> PREPARED : Occurs when the initial sync succeeds - * after previously failing. - * - *
  • PREPARED -> SYNCING : Occurs immediately after transitioning - * to PREPARED. Starts listening for live updates rather than catching up. - * - *
  • SYNCING -> RECONNECTING : Occurs when the live update fails. - * - *
  • RECONNECTING -> RECONNECTING : Can occur if the update calls - * continue to fail, but the keepalive calls (to /versions) succeed. - * - *
  • RECONNECTING -> ERROR : Occurs when the keepalive call also fails - * - *
  • ERROR -> SYNCING : Occurs when the client has performed a - * live update after having previously failed. - * - *
  • ERROR -> ERROR : Occurs when the client has failed to keepalive - * for a second time or more.
  • - * - *
  • SYNCING -> SYNCING : Occurs when the client has performed a live - * update. This is called after processing.
  • - * - *
  • * -> STOPPED : Occurs once the client has stopped syncing or - * trying to sync after stopClient has been called.
  • - *
- * - * @event module:client~MatrixClient#"sync" - * - * @param {string} state An enum representing the syncing state. One of "PREPARED", - * "SYNCING", "ERROR", "STOPPED". - * - * @param {?string} prevState An enum representing the previous syncing state. - * One of "PREPARED", "SYNCING", "ERROR", "STOPPED" or null. - * - * @param {?Object} data Data about this transition. - * - * @param {MatrixError} data.error The matrix error if state=ERROR. - * - * @param {String} data.oldSyncToken The 'since' token passed to /sync. - * null for the first successful sync since this client was - * started. Only present if state=PREPARED or - * state=SYNCING. - * - * @param {String} data.nextSyncToken The 'next_batch' result from /sync, which - * will become the 'since' token for the next call to /sync. Only present if - * state=PREPARED or state=SYNCING. - * - * @param {boolean} data.catchingUp True if we are working our way through a - * backlog of events after connecting. Only present if state=SYNCING. - * - * @example - * matrixClient.on("sync", function(state, prevState, data) { - * switch (state) { - * case "ERROR": - * // update UI to say "Connection Lost" - * break; - * case "SYNCING": - * // update UI to remove any "Connection Lost" message - * break; - * case "PREPARED": - * // the client instance is ready to be queried. - * var rooms = matrixClient.getRooms(); - * break; - * } - * }); - */ - -/** - * Fires whenever a new Room is added. This will fire when you are invited to a - * room, as well as when you join a room. This event is experimental and - * may change. - * @event module:client~MatrixClient#"Room" - * @param {Room} room The newly created, fully populated room. - * @example - * matrixClient.on("Room", function(room){ - * var roomId = room.roomId; - * }); - */ - -/** - * Fires whenever a Room is removed. This will fire when you forget a room. - * This event is experimental and may change. - * @event module:client~MatrixClient#"deleteRoom" - * @param {string} roomId The deleted room ID. - * @example - * matrixClient.on("deleteRoom", function(roomId){ - * // update UI from getRooms() - * }); - */ - -/** - * Fires whenever an incoming call arrives. - * @event module:client~MatrixClient#"Call.incoming" - * @param {module:webrtc/call~MatrixCall} call The incoming call. - * @example - * matrixClient.on("Call.incoming", function(call){ - * call.answer(); // auto-answer - * }); - */ - -/** - * Fires whenever the login session the JS SDK is using is no - * longer valid and the user must log in again. - * NB. This only fires when action is required from the user, not - * when then login session can be renewed by using a refresh token. - * @event module:client~MatrixClient#"Session.logged_out" - * @example - * matrixClient.on("Session.logged_out", function(errorObj){ - * // show the login screen - * }); - */ - -/** - * Fires when the JS SDK receives a M_CONSENT_NOT_GIVEN error in response - * to a HTTP request. - * @event module:client~MatrixClient#"no_consent" - * @example - * matrixClient.on("no_consent", function(message, contentUri) { - * console.info(message + ' Go to ' + contentUri); - * }); - */ - -/** - * Fires when a device is marked as verified/unverified/blocked/unblocked by - * {@link module:client~MatrixClient#setDeviceVerified|MatrixClient.setDeviceVerified} or - * {@link module:client~MatrixClient#setDeviceBlocked|MatrixClient.setDeviceBlocked}. - * - * @event module:client~MatrixClient#"deviceVerificationChanged" - * @param {string} userId the owner of the verified device - * @param {string} deviceId the id of the verified device - * @param {module:crypto/deviceinfo} deviceInfo updated device information - */ - -/** - * Fires when the trust status of a user changes - * If userId is the userId of the logged in user, this indicated a change - * in the trust status of the cross-signing data on the account. - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @event module:client~MatrixClient#"userTrustStatusChanged" - * @param {string} userId the userId of the user in question - * @param {UserTrustLevel} trustLevel The new trust level of the user - */ - -/** - * Fires when the user's cross-signing keys have changed or cross-signing - * has been enabled/disabled. The client can use getStoredCrossSigningForUser - * with the user ID of the logged in user to check if cross-signing is - * enabled on the account. If enabled, it can test whether the current key - * is trusted using with checkUserTrust with the user ID of the logged - * in user. The checkOwnCrossSigningTrust function may be used to reconcile - * the trust in the account key. - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @event module:client~MatrixClient#"crossSigning.keysChanged" - */ - -/** - * Fires whenever new user-scoped account_data is added. - * @event module:client~MatrixClient#"accountData" - * @param {MatrixEvent} event The event describing the account_data just added - * @param {MatrixEvent} event The previous account data, if known. - * @example - * matrixClient.on("accountData", function(event, oldEvent){ - * myAccountData[event.type] = event.content; - * }); - */ - -/** - * Fires whenever the stored devices for a user have changed - * @event module:client~MatrixClient#"crypto.devicesUpdated" - * @param {String[]} users A list of user IDs that were updated - * @param {boolean} initialFetch If true, the store was empty (apart - * from our own device) and has been seeded. - */ - -/** - * Fires whenever the stored devices for a user will be updated - * @event module:client~MatrixClient#"crypto.willUpdateDevices" - * @param {String[]} users A list of user IDs that will be updated - * @param {boolean} initialFetch If true, the store is empty (apart - * from our own device) and is being seeded. - */ - -/** - * Fires whenever the status of e2e key backup changes, as returned by getKeyBackupEnabled() - * @event module:client~MatrixClient#"crypto.keyBackupStatus" - * @param {boolean} enabled true if key backup has been enabled, otherwise false - * @example - * matrixClient.on("crypto.keyBackupStatus", function(enabled){ - * if (enabled) { - * [...] - * } - * }); - */ - -/** - * Fires when we want to suggest to the user that they restore their megolm keys - * from backup or by cross-signing the device. - * - * @event module:client~MatrixClient#"crypto.suggestKeyRestore" - */ - -/** - * Fires when a key verification is requested. - * @event module:client~MatrixClient#"crypto.verification.request" - * @param {object} data - * @param {MatrixEvent} data.event the original verification request message - * @param {Array} data.methods the verification methods that can be used - * @param {Number} data.timeout the amount of milliseconds that should be waited - * before cancelling the request automatically. - * @param {Function} data.beginKeyVerification a function to call if a key - * verification should be performed. The function takes one argument: the - * name of the key verification method (taken from data.methods) to use. - * @param {Function} data.cancel a function to call if the key verification is - * rejected. - */ - -/** - * Fires when a key verification is requested with an unknown method. - * @event module:client~MatrixClient#"crypto.verification.request.unknown" - * @param {string} userId the user ID who requested the key verification - * @param {Function} cancel a function that will send a cancellation message to - * reject the key verification. - */ - -/** - * Fires when a secret request has been cancelled. If the client is prompting - * the user to ask whether they want to share a secret, the prompt can be - * dismissed. - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @event module:client~MatrixClient#"crypto.secrets.requestCancelled" - * @param {object} data - * @param {string} data.user_id The user ID of the client that had requested the secret. - * @param {string} data.device_id The device ID of the client that had requested the - * secret. - * @param {string} data.request_id The ID of the original request. - */ - -/** - * Fires when the client .well-known info is fetched. - * - * @event module:client~MatrixClient#"WellKnown.client" - * @param {object} data The JSON object returned by the server - */ diff --git a/src/common-crypto/CryptoBackend.ts b/src/common-crypto/CryptoBackend.ts new file mode 100644 index 00000000000..77748cb5050 --- /dev/null +++ b/src/common-crypto/CryptoBackend.ts @@ -0,0 +1,63 @@ +/* +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 type { IEventDecryptionResult } from "../@types/crypto"; +import { MatrixEvent } from "../models/event"; + +/** + * Common interface for the crypto implementations + */ +export interface CryptoBackend { + /** + * Global override for whether the client should ever send encrypted + * messages to unverified devices. This provides the default for rooms which + * do not specify a value. + * + * If true, all unverified devices will be blacklisted by default + */ + globalBlacklistUnverifiedDevices: boolean; + + /** + * Whether sendMessage in a room with unknown and unverified devices + * should throw an error and not send the message. This has 'Global' for + * symmetry with setGlobalBlacklistUnverifiedDevices but there is currently + * no room-level equivalent for this setting. + */ + globalErrorOnUnknownDevices: boolean; + + /** + * Shut down any background processes related to crypto + */ + stop(): void; + + /** + * Checks if the user has previously published cross-signing keys + * + * This means downloading the devicelist for the user and checking if the list includes + * the cross-signing pseudo-device. + + * @returns true if the user has previously published cross-signing keys + */ + userHasCrossSigningKeys(): Promise; + + /** + * Decrypt a received event + * + * @returns a promise which resolves once we have finished decrypting. + * Rejects with an error if there is a problem decrypting the event. + */ + decryptEvent(event: MatrixEvent): Promise; +} diff --git a/src/common-crypto/README.md b/src/common-crypto/README.md new file mode 100644 index 00000000000..7af3298af48 --- /dev/null +++ b/src/common-crypto/README.md @@ -0,0 +1,4 @@ +This directory contains functionality which is common to both the legacy (libolm-based) crypto implementation, +and the new rust-based implementation. + +It is an internal module, and is _not_ directly exposed to applications. diff --git a/src/content-helpers.ts b/src/content-helpers.ts index 6fa4b684b5b..03bacbb9f4a 100644 --- a/src/content-helpers.ts +++ b/src/content-helpers.ts @@ -14,8 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -/** @module ContentHelpers */ - import { isProvided, REFERENCE_RELATION } from "matrix-events-sdk"; import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent } from "./@types/beacon"; @@ -37,9 +35,9 @@ import { IContent } from "./models/event"; /** * Generates the content for a HTML Message event - * @param {string} body the plaintext body of the message - * @param {string} htmlBody the HTML representation of the message - * @returns {{msgtype: string, format: string, body: string, formatted_body: string}} + * @param body - the plaintext body of the message + * @param htmlBody - the HTML representation of the message + * @returns */ export function makeHtmlMessage(body: string, htmlBody: string): IContent { return { @@ -52,9 +50,9 @@ export function makeHtmlMessage(body: string, htmlBody: string): IContent { /** * Generates the content for a HTML Notice event - * @param {string} body the plaintext body of the notice - * @param {string} htmlBody the HTML representation of the notice - * @returns {{msgtype: string, format: string, body: string, formatted_body: string}} + * @param body - the plaintext body of the notice + * @param htmlBody - the HTML representation of the notice + * @returns */ export function makeHtmlNotice(body: string, htmlBody: string): IContent { return { @@ -67,9 +65,9 @@ export function makeHtmlNotice(body: string, htmlBody: string): IContent { /** * Generates the content for a HTML Emote event - * @param {string} body the plaintext body of the emote - * @param {string} htmlBody the HTML representation of the emote - * @returns {{msgtype: string, format: string, body: string, formatted_body: string}} + * @param body - the plaintext body of the emote + * @param htmlBody - the HTML representation of the emote + * @returns */ export function makeHtmlEmote(body: string, htmlBody: string): IContent { return { @@ -82,8 +80,8 @@ export function makeHtmlEmote(body: string, htmlBody: string): IContent { /** * Generates the content for a Plaintext Message event - * @param {string} body the plaintext body of the emote - * @returns {{msgtype: string, body: string}} + * @param body - the plaintext body of the emote + * @returns */ export function makeTextMessage(body: string): IContent { return { @@ -94,8 +92,8 @@ export function makeTextMessage(body: string): IContent { /** * Generates the content for a Plaintext Notice event - * @param {string} body the plaintext body of the notice - * @returns {{msgtype: string, body: string}} + * @param body - the plaintext body of the notice + * @returns */ export function makeNotice(body: string): IContent { return { @@ -106,8 +104,8 @@ export function makeNotice(body: string): IContent { /** * Generates the content for a Plaintext Emote event - * @param {string} body the plaintext body of the emote - * @returns {{msgtype: string, body: string}} + * @param body - the plaintext body of the emote + * @returns */ export function makeEmoteMessage(body: string): IContent { return { @@ -125,25 +123,19 @@ export const getTextForLocationEvent = ( description?: string | null, ): string => { const date = `at ${new Date(timestamp!).toISOString()}`; - const assetName = assetType === LocationAssetType.Self ? 'User' : undefined; + const assetName = assetType === LocationAssetType.Self ? "User" : undefined; const quotedDescription = description ? `"${description}"` : undefined; - return [ - assetName, - 'Location', - quotedDescription, - uri, - date, - ].filter(Boolean).join(' '); + return [assetName, "Location", quotedDescription, uri, date].filter(Boolean).join(" "); }; /** * Generates the content for a Location event - * @param uri a geo:// uri for the location - * @param timestamp the timestamp when the location was correct (milliseconds since the UNIX epoch) - * @param description the (optional) label for this location on the map - * @param assetType the (optional) asset type of this location e.g. "m.self" - * @param text optional. A text for the location + * @param uri - a geo:// uri for the location + * @param timestamp - the timestamp when the location was correct (milliseconds since the UNIX epoch) + * @param description - the (optional) label for this location on the map + * @param assetType - the (optional) asset type of this location e.g. "m.self" + * @param text - optional. A text for the location */ export const makeLocationContent = ( // this is first but optional @@ -154,8 +146,8 @@ export const makeLocationContent = ( description?: string | null, assetType?: LocationAssetType, ): LegacyLocationEventContent & MLocationEventContent => { - const defaultedText = text ?? - getTextForLocationEvent(uri, assetType || LocationAssetType.Self, timestamp, description); + const defaultedText = + text ?? getTextForLocationEvent(uri, assetType || LocationAssetType.Self, timestamp, description); const timestampEvent = timestamp ? { [M_TIMESTAMP.name]: timestamp } : {}; return { msgtype: MsgType.Location, @@ -194,10 +186,7 @@ export const parseLocationEvent = (wireEventContent: LocationEventWireContent): /** * Topic event helpers */ -export type MakeTopicContent = ( - topic: string, - htmlTopic?: string, -) => MRoomTopicEventContent; +export type MakeTopicContent = (topic: string, htmlTopic?: string) => MRoomTopicEventContent; export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => { const renderings = [{ body: topic, mimetype: "text/plain" }]; @@ -214,8 +203,8 @@ export type TopicState = { export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => { const mtopic = M_TOPIC.findIn(content); - const text = mtopic?.find(r => !isProvided(r.mimetype) || r.mimetype === "text/plain")?.body ?? content.topic; - const html = mtopic?.find(r => r.mimetype === "text/html")?.body; + const text = mtopic?.find((r) => !isProvided(r.mimetype) || r.mimetype === "text/plain")?.body ?? content.topic; + const html = mtopic?.find((r) => r.mimetype === "text/html")?.body; return { text, html }; }; @@ -227,16 +216,10 @@ export type MakeBeaconInfoContent = ( isLive?: boolean, description?: string, assetType?: LocationAssetType, - timestamp?: number + timestamp?: number, ) => MBeaconInfoEventContent; -export const makeBeaconInfoContent: MakeBeaconInfoContent = ( - timeout, - isLive, - description, - assetType, - timestamp, -) => ({ +export const makeBeaconInfoContent: MakeBeaconInfoContent = (timeout, isLive, description, assetType, timestamp) => ({ description, timeout, live: isLive, @@ -274,12 +257,7 @@ export type MakeBeaconContent = ( description?: string, ) => MBeaconEventContent; -export const makeBeaconContent: MakeBeaconContent = ( - uri, - timestamp, - beaconInfoEventId, - description, -) => ({ +export const makeBeaconContent: MakeBeaconContent = (uri, timestamp, beaconInfoEventId, description) => ({ [M_LOCATION.name]: { description, uri, diff --git a/src/content-repo.ts b/src/content-repo.ts index d6cf81f2ec6..257541296bb 100644 --- a/src/content-repo.ts +++ b/src/content-repo.ts @@ -13,25 +13,22 @@ 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. */ -/** - * @module content-repo - */ import * as utils from "./utils"; /** * Get the HTTP URL for an MXC URI. - * @param {string} baseUrl The base homeserver url which has a content repo. - * @param {string} mxc The mxc:// URI. - * @param {Number} width The desired width of the thumbnail. - * @param {Number} height The desired height of the thumbnail. - * @param {string} resizeMethod The thumbnail resize method to use, either + * @param baseUrl - The base homeserver url which has a content repo. + * @param mxc - The mxc:// URI. + * @param width - The desired width of the thumbnail. + * @param height - The desired height of the thumbnail. + * @param resizeMethod - The thumbnail resize method to use, either * "crop" or "scale". - * @param {Boolean} allowDirectLinks If true, return any non-mxc URLs + * @param allowDirectLinks - If true, return any non-mxc URLs * directly. Fetching such URLs will leak information about the user to * anyone they share a room with. If false, will return the emptry string * for such URLs. - * @return {string} The complete URL to the content. + * @returns The complete URL to the content. */ export function getHttpUriForMxc( baseUrl: string, @@ -42,13 +39,13 @@ export function getHttpUriForMxc( allowDirectLinks = false, ): string { if (typeof mxc !== "string" || !mxc) { - return ''; + return ""; } if (mxc.indexOf("mxc://") !== 0) { if (allowDirectLinks) { return mxc; } else { - return ''; + return ""; } } let serverAndMediaId = mxc.slice(6); // strips mxc:// @@ -77,6 +74,6 @@ export function getHttpUriForMxc( serverAndMediaId = serverAndMediaId.slice(0, fragmentOffset); } - const urlParams = (Object.keys(params).length === 0 ? "" : ("?" + utils.encodeParams(params))); + const urlParams = Object.keys(params).length === 0 ? "" : "?" + utils.encodeParams(params); return baseUrl + prefix + serverAndMediaId + urlParams + fragment; } diff --git a/src/crypto/CrossSigning.ts b/src/crypto/CrossSigning.ts index e776b93ad94..c9454621995 100644 --- a/src/crypto/CrossSigning.ts +++ b/src/crypto/CrossSigning.ts @@ -16,15 +16,14 @@ limitations under the License. /** * Cross signing methods - * @module crypto/CrossSigning */ import { PkSigning } from "@matrix-org/olm"; -import { decodeBase64, encodeBase64, IObject, pkSign, pkVerify } from './olmlib'; -import { logger } from '../logger'; -import { IndexedDBCryptoStore } from '../crypto/store/indexeddb-crypto-store'; -import { decryptAES, encryptAES } from './aes'; +import { decodeBase64, encodeBase64, IObject, pkSign, pkVerify } from "./olmlib"; +import { logger } from "../logger"; +import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store"; +import { decryptAES, encryptAES } from "./aes"; import { DeviceInfo } from "./deviceinfo"; import { SecretStorage } from "./SecretStorage"; import { ICrossSigningKey, ISignedKey, MatrixClient } from "../client"; @@ -67,12 +66,10 @@ export class CrossSigningInfo { /** * Information about a user's cross-signing keys * - * @class - * - * @param {string} userId the user that the information is about - * @param {object} callbacks Callbacks used to interact with the app + * @param userId - the user that the information is about + * @param callbacks - Callbacks used to interact with the app * Requires getCrossSigningKey and saveCrossSigningKeys - * @param {object} cacheCallbacks Callbacks used to interact with the cache + * @param cacheCallbacks - Callbacks used to interact with the cache */ public constructor( public readonly userId: string, @@ -84,6 +81,7 @@ export class CrossSigningInfo { const res = new CrossSigningInfo(userId); for (const prop in obj) { if (obj.hasOwnProperty(prop)) { + // @ts-ignore - ts doesn't like this and nor should we res[prop] = obj[prop]; } } @@ -101,10 +99,10 @@ export class CrossSigningInfo { /** * Calls the app callback to ask for a private key * - * @param {string} type The key type ("master", "self_signing", or "user_signing") - * @param {string} expectedPubkey The matching public key or undefined to use + * @param type - The key type ("master", "self_signing", or "user_signing") + * @param expectedPubkey - The matching public key or undefined to use * the stored public key for the given key type. - * @returns {Array} An array with [ public key, Olm.PkSigning ] + * @returns An array with [ public key, Olm.PkSigning ] */ public async getCrossSigningKey(type: string, expectedPubkey?: string): Promise<[string, PkSigning]> { const shouldCache = ["master", "self_signing", "user_signing"].indexOf(type) >= 0; @@ -148,15 +146,11 @@ export class CrossSigningInfo { /* No keysource even returned a key */ if (!privkey) { - throw new Error( - "getCrossSigningKey callback for " + type + " returned falsey", - ); + throw new Error("getCrossSigningKey callback for " + type + " returned falsey"); } /* We got some keys from the keysource, but none of them were valid */ - throw new Error( - "Key type " + type + " from getCrossSigningKey callback did not match", - ); + throw new Error("Key type " + type + " from getCrossSigningKey callback did not match"); } /** @@ -164,8 +158,8 @@ export class CrossSigningInfo { * XXX: This could be static, be we often seem to have an instance when we * want to know this anyway... * - * @param {SecretStorage} secretStorage The secret store using account data - * @returns {object} map of key name to key info the secret is encrypted + * @param secretStorage - The secret store using account data + * @returns map of key name to key info the secret is encrypted * with, or null if it is not present or not encrypted with a trusted * key */ @@ -173,7 +167,7 @@ export class CrossSigningInfo { secretStorage: SecretStorage, ): Promise | null> { // check what SSSS keys have encrypted the master key (if any) - const stored = await secretStorage.isStored("m.cross_signing.master") || {}; + const stored = (await secretStorage.isStored("m.cross_signing.master")) || {}; // then check which of those SSSS keys have also encrypted the SSK and USK function intersect(s: Record): void { for (const k of Object.keys(stored)) { @@ -183,7 +177,7 @@ export class CrossSigningInfo { } } for (const type of ["self_signing", "user_signing"]) { - intersect(await secretStorage.isStored(`m.cross_signing.${type}`) || {}); + intersect((await secretStorage.isStored(`m.cross_signing.${type}`)) || {}); } return Object.keys(stored).length ? stored : null; } @@ -193,8 +187,8 @@ export class CrossSigningInfo { * typically called in conjunction with the creation of new cross-signing * keys. * - * @param {Map} keys The keys to store - * @param {SecretStorage} secretStorage The secret store using account data + * @param keys - The keys to store + * @param secretStorage - The secret store using account data */ public static async storeInSecretStorage( keys: Map, @@ -210,10 +204,10 @@ export class CrossSigningInfo { * Get private keys from secret storage created by some other device. This * also passes the private keys to the app-specific callback. * - * @param {string} type The type of key to get. One of "master", + * @param type - The type of key to get. One of "master", * "self_signing", or "user_signing". - * @param {SecretStorage} secretStorage The secret store using account data - * @return {Uint8Array} The private key + * @param secretStorage - The secret store using account data + * @returns The private key */ public static async getFromSecretStorage(type: string, secretStorage: SecretStorage): Promise { const encodedKey = await secretStorage.get(`m.cross_signing.${type}`); @@ -226,16 +220,16 @@ export class CrossSigningInfo { /** * Check whether the private keys exist in the local key cache. * - * @param {string} [type] The type of key to get. One of "master", + * @param type - The type of key to get. One of "master", * "self_signing", or "user_signing". Optional, will check all by default. - * @returns {boolean} True if all keys are stored in the local cache. + * @returns True if all keys are stored in the local cache. */ public async isStoredInKeyCache(type?: string): Promise { const cacheCallbacks = this.cacheCallbacks; if (!cacheCallbacks) return false; const types = type ? [type] : ["master", "self_signing", "user_signing"]; for (const t of types) { - if (!await cacheCallbacks.getCrossSigningKeyCache?.(t)) { + if (!(await cacheCallbacks.getCrossSigningKeyCache?.(t))) { return false; } } @@ -245,7 +239,7 @@ export class CrossSigningInfo { /** * Get cross-signing private keys from the local cache. * - * @returns {Map} A map from key type (string) to private key (Uint8Array) + * @returns A map from key type (string) to private key (Uint8Array) */ public async getCrossSigningKeysFromCache(): Promise> { const keys = new Map(); @@ -265,10 +259,10 @@ export class CrossSigningInfo { * Get the ID used to identify the user. This can also be used to test for * the existence of a given key type. * - * @param {string} type The type of key to get the ID of. One of "master", + * @param type - The type of key to get the ID of. One of "master", * "self_signing", or "user_signing". Defaults to "master". * - * @return {string} the ID + * @returns the ID */ public getId(type = "master"): string | null { if (!this.keys[type]) return null; @@ -281,7 +275,7 @@ export class CrossSigningInfo { * will be held in this class, while the private keys are passed off to the * `saveCrossSigningKeys` application callback. * - * @param {CrossSigningLevel} level The key types to reset + * @param level - The key types to reset */ public async resetKeys(level?: CrossSigningLevel): Promise { if (!this.callbacks.saveCrossSigningKeys) { @@ -289,17 +283,9 @@ export class CrossSigningInfo { } // If we're resetting the master key, we reset all keys - if ( - level === undefined || - level & CrossSigningLevel.MASTER || - !this.keys.master - ) { - level = ( - CrossSigningLevel.MASTER | - CrossSigningLevel.USER_SIGNING | - CrossSigningLevel.SELF_SIGNING - ); - } else if (level === 0 as CrossSigningLevel) { + if (level === undefined || level & CrossSigningLevel.MASTER || !this.keys.master) { + level = CrossSigningLevel.MASTER | CrossSigningLevel.USER_SIGNING | CrossSigningLevel.SELF_SIGNING; + } else if (level === (0 as CrossSigningLevel)) { return; } @@ -315,9 +301,9 @@ export class CrossSigningInfo { masterPub = masterSigning.init_with_seed(privateKeys.master); keys.master = { user_id: this.userId, - usage: ['master'], + usage: ["master"], keys: { - ['ed25519:' + masterPub]: masterPub, + ["ed25519:" + masterPub]: masterPub, }, }; } else { @@ -331,9 +317,9 @@ export class CrossSigningInfo { const sskPub = sskSigning.init_with_seed(privateKeys.self_signing); keys.self_signing = { user_id: this.userId, - usage: ['self_signing'], + usage: ["self_signing"], keys: { - ['ed25519:' + sskPub]: sskPub, + ["ed25519:" + sskPub]: sskPub, }, }; pkSign(keys.self_signing, masterSigning, this.userId, masterPub); @@ -349,9 +335,9 @@ export class CrossSigningInfo { const uskPub = uskSigning.init_with_seed(privateKeys.user_signing); keys.user_signing = { user_id: this.userId, - usage: ['user_signing'], + usage: ["user_signing"], keys: { - ['ed25519:' + uskPub]: uskPub, + ["ed25519:" + uskPub]: uskPub, }, }; pkSign(keys.user_signing, masterSigning, this.userId, masterPub); @@ -380,8 +366,7 @@ export class CrossSigningInfo { const signingKeys: Record = {}; if (keys.master) { if (keys.master.user_id !== this.userId) { - const error = "Mismatched user ID " + keys.master.user_id + - " in master key from " + this.userId; + const error = "Mismatched user ID " + keys.master.user_id + " in master key from " + this.userId; logger.error(error); throw new Error(error); } @@ -403,8 +388,7 @@ export class CrossSigningInfo { // verify signatures if (keys.user_signing) { if (keys.user_signing.user_id !== this.userId) { - const error = "Mismatched user ID " + keys.master.user_id + - " in user_signing key from " + this.userId; + const error = "Mismatched user ID " + keys.master.user_id + " in user_signing key from " + this.userId; logger.error(error); throw new Error(error); } @@ -418,8 +402,7 @@ export class CrossSigningInfo { } if (keys.self_signing) { if (keys.self_signing.user_id !== this.userId) { - const error = "Mismatched user ID " + keys.master.user_id + - " in self_signing key from " + this.userId; + const error = "Mismatched user ID " + keys.master.user_id + " in self_signing key from " + this.userId; logger.error(error); throw new Error(error); } @@ -457,9 +440,7 @@ export class CrossSigningInfo { public async signObject(data: T, type: string): Promise { if (!this.keys[type]) { - throw new Error( - "Attempted to sign with " + type + " key but no such key present", - ); + throw new Error("Attempted to sign with " + type + " key but no such key present"); } const [pubkey, signing] = await this.getCrossSigningKey(type); try { @@ -480,9 +461,7 @@ export class CrossSigningInfo { public async signDevice(userId: string, device: DeviceInfo): Promise { if (userId !== this.userId) { - throw new Error( - `Trying to sign ${userId}'s device; can only sign our own device`, - ); + throw new Error(`Trying to sign ${userId}'s device; can only sign our own device`); } if (!this.keys.self_signing) { logger.info("No self signing key: not signing device"); @@ -494,24 +473,27 @@ export class CrossSigningInfo { keys: device.keys, device_id: device.deviceId, user_id: userId, - }, "self_signing", + }, + "self_signing", ); } /** * Check whether a given user is trusted. * - * @param {CrossSigningInfo} userCrossSigning Cross signing info for user + * @param userCrossSigning - Cross signing info for user * - * @returns {UserTrustLevel} + * @returns */ public checkUserTrust(userCrossSigning: CrossSigningInfo): UserTrustLevel { // if we're checking our own key, then it's trusted if the master key // and self-signing key match - if (this.userId === userCrossSigning.userId - && this.getId() && this.getId() === userCrossSigning.getId() - && this.getId("self_signing") - && this.getId("self_signing") === userCrossSigning.getId("self_signing") + if ( + this.userId === userCrossSigning.userId && + this.getId() && + this.getId() === userCrossSigning.getId() && + this.getId("self_signing") && + this.getId("self_signing") === userCrossSigning.getId("self_signing") ) { return new UserTrustLevel(true, true, this.firstUse); } @@ -524,29 +506,25 @@ export class CrossSigningInfo { let userTrusted: boolean; const userMaster = userCrossSigning.keys.master; - const uskId = this.getId('user_signing')!; + const uskId = this.getId("user_signing")!; try { pkVerify(userMaster, uskId, this.userId); userTrusted = true; } catch (e) { userTrusted = false; } - return new UserTrustLevel( - userTrusted, - userCrossSigning.crossSigningVerifiedBefore, - userCrossSigning.firstUse, - ); + return new UserTrustLevel(userTrusted, userCrossSigning.crossSigningVerifiedBefore, userCrossSigning.firstUse); } /** * Check whether a given device is trusted. * - * @param {CrossSigningInfo} userCrossSigning Cross signing info for user - * @param {module:crypto/deviceinfo} device The device to check - * @param {boolean} localTrust Whether the device is trusted locally - * @param {boolean} trustCrossSignedDevices Whether we trust cross signed devices + * @param userCrossSigning - Cross signing info for user + * @param device - The device to check + * @param localTrust - Whether the device is trusted locally + * @param trustCrossSignedDevices - Whether we trust cross signed devices * - * @returns {DeviceTrustLevel} + * @returns */ public checkDeviceTrust( userCrossSigning: CrossSigningInfo, @@ -560,9 +538,7 @@ export class CrossSigningInfo { if (!userSSK) { // if the user has no self-signing key then we cannot make any // trust assertions about this device from cross-signing - return new DeviceTrustLevel( - false, false, localTrust, trustCrossSignedDevices, - ); + return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices); } const deviceObj = deviceToObject(device, userCrossSigning.userId); @@ -579,7 +555,7 @@ export class CrossSigningInfo { } /** - * @returns {object} Cache callbacks + * @returns Cache callbacks */ public getCacheCallbacks(): ICacheCallbacks { return this.cacheCallbacks; @@ -620,21 +596,21 @@ export class UserTrustLevel { ) {} /** - * @returns {boolean} true if this user is verified via any means + * @returns true if this user is verified via any means */ public isVerified(): boolean { return this.isCrossSigningVerified(); } /** - * @returns {boolean} true if this user is verified via cross signing + * @returns true if this user is verified via cross signing */ public isCrossSigningVerified(): boolean { return this.crossSigningVerified; } /** - * @returns {boolean} true if we ever verified this user before (at least for + * @returns true if we ever verified this user before (at least for * the history of verifications observed by this device). */ public wasCrossSigningVerified(): boolean { @@ -642,7 +618,7 @@ export class UserTrustLevel { } /** - * @returns {boolean} true if this user's key is trusted on first use + * @returns true if this user's key is trusted on first use */ public isTofu(): boolean { return this.tofu; @@ -674,30 +650,28 @@ export class DeviceTrustLevel { } /** - * @returns {boolean} true if this device is verified via any means + * @returns true if this device is verified via any means */ public isVerified(): boolean { - return Boolean(this.isLocallyVerified() || ( - this.trustCrossSignedDevices && this.isCrossSigningVerified() - )); + return Boolean(this.isLocallyVerified() || (this.trustCrossSignedDevices && this.isCrossSigningVerified())); } /** - * @returns {boolean} true if this device is verified via cross signing + * @returns true if this device is verified via cross signing */ public isCrossSigningVerified(): boolean { return this.crossSigningVerified; } /** - * @returns {boolean} true if this device is verified locally + * @returns true if this device is verified locally */ public isLocallyVerified(): boolean { return this.localVerified; } /** - * @returns {boolean} true if this device is trusted from a user's key + * @returns true if this device is trusted from a user's key * that is trusted on first use */ public isTofu(): boolean { @@ -707,18 +681,14 @@ export class DeviceTrustLevel { export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: OlmDevice): ICacheCallbacks { return { - getCrossSigningKeyCache: async function( + getCrossSigningKeyCache: async function ( type: keyof SecretStorePrivateKeys, _expectedPublicKey: string, ): Promise { const key = await new Promise((resolve) => { - return store.doTxn( - 'readonly', - [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - store.getSecretStorePrivateKey(txn, resolve, type); - }, - ); + return store.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + store.getSecretStorePrivateKey(txn, resolve, type); + }); }); if (key && key.ciphertext) { @@ -729,24 +699,18 @@ export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: O return key; } }, - storeCrossSigningKeyCache: async function( + storeCrossSigningKeyCache: async function ( type: keyof SecretStorePrivateKeys, key?: Uint8Array, ): Promise { if (!(key instanceof Uint8Array)) { - throw new Error( - `storeCrossSigningKeyCache expects Uint8Array, got ${key}`, - ); + throw new Error(`storeCrossSigningKeyCache expects Uint8Array, got ${key}`); } const pickleKey = Buffer.from(olmDevice.pickleKey); const encryptedKey = await encryptAES(encodeBase64(key), pickleKey, type); - return store.doTxn( - 'readwrite', - [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - store.storeSecretStorePrivateKey(txn, type, encryptedKey); - }, - ); + return store.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + store.storeSecretStorePrivateKey(txn, type, encryptedKey); + }); }, }; } @@ -756,9 +720,9 @@ export type KeysDuringVerification = [[string, PkSigning], [string, PkSigning], /** * Request cross-signing keys from another device during verification. * - * @param {MatrixClient} baseApis base Matrix API interface - * @param {string} userId The user ID being verified - * @param {string} deviceId The device ID being verified + * @param baseApis - base Matrix API interface + * @param userId - The user ID being verified + * @param deviceId - The device ID being verified */ export async function requestKeysDuringVerification( baseApis: MatrixClient, @@ -782,15 +746,15 @@ export async function requestKeysDuringVerification( // CrossSigningInfo.getCrossSigningKey() to validate/cache const crossSigning = new CrossSigningInfo( original.userId, - { getCrossSigningKey: async (type): Promise => { - logger.debug("Cross-signing: requesting secret", type, deviceId); - const { promise } = client.requestSecret( - `m.cross_signing.${type}`, [deviceId], - ); - const result = await promise; - const decoded = decodeBase64(result); - return Uint8Array.from(decoded); - } }, + { + getCrossSigningKey: async (type): Promise => { + logger.debug("Cross-signing: requesting secret", type, deviceId); + const { promise } = client.requestSecret(`m.cross_signing.${type}`, [deviceId]); + const result = await promise; + const decoded = decodeBase64(result); + return Uint8Array.from(decoded); + }, + }, original.getCacheCallbacks(), ); crossSigning.keys = original.keys; @@ -800,11 +764,7 @@ export async function requestKeysDuringVerification( // then change here to reject on the timeout // Requests can be ignored, so don't wait around forever const timeout = new Promise((resolve) => { - setTimeout( - resolve, - KEY_REQUEST_TIMEOUT_MS, - new Error("Timeout"), - ); + setTimeout(resolve, KEY_REQUEST_TIMEOUT_MS, new Error("Timeout")); }); // also request and cache the key backup key @@ -812,16 +772,12 @@ export async function requestKeysDuringVerification( const cachedKey = await client.crypto!.getSessionBackupPrivateKey(); if (!cachedKey) { logger.info("No cached backup key found. Requesting..."); - const secretReq = client.requestSecret( - 'm.megolm_backup.v1', [deviceId], - ); + const secretReq = client.requestSecret("m.megolm_backup.v1", [deviceId]); const base64Key = await secretReq.promise; logger.info("Got key backup key, decoding..."); const decodedKey = decodeBase64(base64Key); logger.info("Decoded backup key, storing..."); - await client.crypto!.storeSessionBackupPrivateKey( - Uint8Array.from(decodedKey), - ); + await client.crypto!.storeSessionBackupPrivateKey(Uint8Array.from(decodedKey)); logger.info("Backup key stored. Starting backup restore..."); const backupInfo = await client.getKeyBackupVersion(); // no need to await for this - just let it go in the bg diff --git a/src/crypto/DeviceList.ts b/src/crypto/DeviceList.ts index da40a03f231..292cf159b5a 100644 --- a/src/crypto/DeviceList.ts +++ b/src/crypto/DeviceList.ts @@ -15,17 +15,15 @@ limitations under the License. */ /** - * @module crypto/DeviceList - * * Manages the list of other users' devices */ -import { logger } from '../logger'; -import { DeviceInfo, IDevice } from './deviceinfo'; -import { CrossSigningInfo, ICrossSigningInfo } from './CrossSigning'; -import * as olmlib from './olmlib'; -import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; -import { chunkPromises, defer, IDeferred, sleep } from '../utils'; +import { logger } from "../logger"; +import { DeviceInfo, IDevice } from "./deviceinfo"; +import { CrossSigningInfo, ICrossSigningInfo } from "./CrossSigning"; +import * as olmlib from "./olmlib"; +import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; +import { chunkPromises, defer, IDeferred, sleep } from "../utils"; import { DeviceKeys, IDownloadKeyResult, Keys, MatrixClient, SigningKeys } from "../client"; import { OlmDevice } from "./OlmDevice"; import { CryptoStore } from "./store/base"; @@ -64,9 +62,6 @@ export type DeviceInfoMap = Record>; type EmittedEvents = CryptoEvent.WillUpdateDevices | CryptoEvent.DevicesUpdated | CryptoEvent.UserCrossSigningUpdated; -/** - * @alias module:crypto/DeviceList - */ export class DeviceList extends TypedEventEmitter { private devices: { [userId: string]: { [deviceId: string]: IDevice } } = {}; @@ -118,29 +113,25 @@ export class DeviceList extends TypedEventEmitter { - await this.cryptoStore.doTxn( - 'readonly', [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { - this.cryptoStore.getEndToEndDeviceData(txn, (deviceData) => { - this.hasFetched = Boolean(deviceData && deviceData.devices); - this.devices = deviceData ? deviceData.devices : {}; - this.crossSigningInfo = deviceData ? - deviceData.crossSigningInfo || {} : {}; - this.deviceTrackingStatus = deviceData ? - deviceData.trackingStatus : {}; - this.syncToken = deviceData?.syncToken ?? null; - this.userByIdentityKey = {}; - for (const user of Object.keys(this.devices)) { - const userDevices = this.devices[user]; - for (const device of Object.keys(userDevices)) { - const idKey = userDevices[device].keys['curve25519:'+device]; - if (idKey !== undefined) { - this.userByIdentityKey[idKey] = user; - } + await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { + this.cryptoStore.getEndToEndDeviceData(txn, (deviceData) => { + this.hasFetched = Boolean(deviceData && deviceData.devices); + this.devices = deviceData ? deviceData.devices : {}; + this.crossSigningInfo = deviceData ? deviceData.crossSigningInfo || {} : {}; + this.deviceTrackingStatus = deviceData ? deviceData.trackingStatus : {}; + this.syncToken = deviceData?.syncToken ?? null; + this.userByIdentityKey = {}; + for (const user of Object.keys(this.devices)) { + const userDevices = this.devices[user]; + for (const device of Object.keys(userDevices)) { + const idKey = userDevices[device].keys["curve25519:" + device]; + if (idKey !== undefined) { + this.userByIdentityKey[idKey] = user; } } - }); - }, - ); + } + }); + }); for (const u of Object.keys(this.deviceTrackingStatus)) { // if a download was in progress when we got shut down, it isn't any more. @@ -163,11 +154,11 @@ export class DeviceList extends TypedEventEmitter} true if the data was saved, false if + * @returns true if the data was saved, false if * it was not (eg. because no changes were pending). The promise * will only resolve once the data is saved, so may take some time * to resolve. @@ -200,7 +191,7 @@ export class DeviceList extends TypedEventEmitter { - logger.log('Saving device tracking data', this.syncToken); + logger.log("Saving device tracking data", this.syncToken); // null out savePromise now (after the delay but before the write), // otherwise we could return the existing promise when the save has @@ -210,23 +201,29 @@ export class DeviceList extends TypedEventEmitter { - this.cryptoStore.storeEndToEndDeviceData({ - devices: this.devices, - crossSigningInfo: this.crossSigningInfo, - trackingStatus: this.deviceTrackingStatus, - syncToken: this.syncToken ?? undefined, - }, txn); - }, - ).then(() => { - // The device list is considered dirty until the write completes. - this.dirty = false; - resolveSavePromise?.(true); - }, err => { - logger.error('Failed to save device tracking data', this.syncToken); - logger.error(err); - }); + this.cryptoStore + .doTxn("readwrite", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { + this.cryptoStore.storeEndToEndDeviceData( + { + devices: this.devices, + crossSigningInfo: this.crossSigningInfo, + trackingStatus: this.deviceTrackingStatus, + syncToken: this.syncToken ?? undefined, + }, + txn, + ); + }) + .then( + () => { + // The device list is considered dirty until the write completes. + this.dirty = false; + resolveSavePromise?.(true); + }, + (err) => { + logger.error("Failed to save device tracking data", this.syncToken); + logger.error(err); + }, + ); }, delay); } @@ -236,7 +233,7 @@ export class DeviceList extends TypedEventEmitterdeviceId->{@link - * module:crypto/deviceinfo|DeviceInfo}. + * @returns A promise which resolves to a map userId-\>deviceId-\>{@link DeviceInfo}. */ public downloadKeys(userIds: string[], forceDownload: boolean): Promise { const usersToDownload: string[] = []; @@ -275,10 +271,7 @@ export class DeviceList extends TypedEventEmitterdeviceId->{@link module:crypto/deviceinfo|DeviceInfo}. + * @returns userId-\>deviceId-\>{@link DeviceInfo}. */ private getDevicesFromStore(userIds: string[]): DeviceInfoMap { const stored: DeviceInfoMap = {}; userIds.forEach((u) => { stored[u] = {}; const devices = this.getStoredDevicesForUser(u) || []; - devices.forEach(function(dev) { + devices.forEach(function (dev) { stored[u][dev.deviceId] = dev; }); }); @@ -322,7 +315,7 @@ export class DeviceList extends TypedEventEmitter{object} devices, or undefined if + * @returns `deviceId->{object}` devices, or undefined if * there is no data for this user. */ public getRawStoredDevicesForUser(userId: string): Record { @@ -376,10 +369,8 @@ export class DeviceList extends TypedEventEmitter): void { this.setRawStoredDevicesForUser(userId, devices); @@ -468,7 +457,6 @@ export class DeviceList extends TypedEventEmitter { @@ -569,15 +555,15 @@ export class DeviceList extends TypedEventEmitter{object} the new devices + * @param devices - `deviceId->{object}` the new devices */ public setRawStoredDevicesForUser(userId: string, devices: Record): void { // remove old devices from userByIdentityKey if (this.devices[userId] !== undefined) { for (const [deviceId, dev] of Object.entries(this.devices[userId])) { - const identityKey = dev.keys['curve25519:'+deviceId]; + const identityKey = dev.keys["curve25519:" + deviceId]; delete this.userByIdentityKey[identityKey]; } @@ -587,7 +573,7 @@ export class DeviceList extends TypedEventEmitter { - finished(true); - }, (e) => { - logger.error( - 'Error downloading keys for ' + users + ":", e, - ); - finished(false); - throw e; - }); + const prom = this.serialiser.updateDevicesForUsers(users, this.syncToken!).then( + () => { + finished(true); + }, + (e) => { + logger.error("Error downloading keys for " + users + ":", e); + finished(false); + throw e; + }, + ); users.forEach((u) => { this.keyDownloadsInProgressByUser.set(u, prom); @@ -641,7 +628,7 @@ export class DeviceList extends TypedEventEmitter { if (this.downloadInProgress) { - throw new Error( - "DeviceListUpdateSerialiser.doQueuedQueries called with request active", - ); + throw new Error("DeviceListUpdateSerialiser.doQueuedQueries called with request active"); } const downloadUsers = Object.keys(this.keyDownloadsQueuedByUser); @@ -747,7 +732,7 @@ class DeviceListUpdateSerialiser { const deferred = this.queuedQueryDeferred; this.queuedQueryDeferred = undefined; - logger.log('Starting key download for', downloadUsers); + logger.log("Starting key download for", downloadUsers); this.downloadInProgress = true; const opts: Parameters[1] = {}; @@ -761,53 +746,64 @@ class DeviceListUpdateSerialiser { factories.push(() => this.baseApis.downloadKeysForUsers(userSlice, opts)); } - chunkPromises(factories, 3).then(async (responses: IDownloadKeyResult[]) => { - const dk: IDownloadKeyResult["device_keys"] - = Object.assign({}, ...(responses.map(res => res.device_keys || {}))); - const masterKeys: IDownloadKeyResult["master_keys"] - = Object.assign({}, ...(responses.map(res => res.master_keys || {}))); - const ssks: IDownloadKeyResult["self_signing_keys"] - = Object.assign({}, ...(responses.map(res => res.self_signing_keys || {}))); - const usks: IDownloadKeyResult["user_signing_keys"] - = Object.assign({}, ...(responses.map(res => res.user_signing_keys || {}))); + chunkPromises(factories, 3) + .then(async (responses: IDownloadKeyResult[]) => { + const dk: IDownloadKeyResult["device_keys"] = Object.assign( + {}, + ...responses.map((res) => res.device_keys || {}), + ); + const masterKeys: IDownloadKeyResult["master_keys"] = Object.assign( + {}, + ...responses.map((res) => res.master_keys || {}), + ); + const ssks: IDownloadKeyResult["self_signing_keys"] = Object.assign( + {}, + ...responses.map((res) => res.self_signing_keys || {}), + ); + const usks: IDownloadKeyResult["user_signing_keys"] = Object.assign( + {}, + ...responses.map((res) => res.user_signing_keys || {}), + ); - // yield to other things that want to execute in between users, to - // avoid wedging the CPU - // (https://github.com/vector-im/element-web/issues/3158) - // - // of course we ought to do this in a web worker or similar, but - // this serves as an easy solution for now. - for (const userId of downloadUsers) { - await sleep(5); - try { - await this.processQueryResponseForUser( - userId, dk[userId], { + // yield to other things that want to execute in between users, to + // avoid wedging the CPU + // (https://github.com/vector-im/element-web/issues/3158) + // + // of course we ought to do this in a web worker or similar, but + // this serves as an easy solution for now. + for (const userId of downloadUsers) { + await sleep(5); + try { + await this.processQueryResponseForUser(userId, dk[userId], { master: masterKeys?.[userId], self_signing: ssks?.[userId], user_signing: usks?.[userId], - }, - ); - } catch (e) { - // log the error but continue, so that one bad key - // doesn't kill the whole process - logger.error(`Error processing keys for ${userId}:`, e); + }); + } catch (e) { + // log the error but continue, so that one bad key + // doesn't kill the whole process + logger.error(`Error processing keys for ${userId}:`, e); + } } - } - }).then(() => { - logger.log('Completed key download for ' + downloadUsers); + }) + .then( + () => { + logger.log("Completed key download for " + downloadUsers); - this.downloadInProgress = false; - deferred?.resolve(); + this.downloadInProgress = false; + deferred?.resolve(); - // if we have queued users, fire off another request. - if (this.queuedQueryDeferred) { - this.doQueuedQueries(); - } - }, (e) => { - logger.warn('Error downloading keys for ' + downloadUsers + ':', e); - this.downloadInProgress = false; - deferred?.reject(e); - }); + // if we have queued users, fire off another request. + if (this.queuedQueryDeferred) { + this.doQueuedQueries(); + } + }, + (e) => { + logger.warn("Error downloading keys for " + downloadUsers + ":", e); + this.downloadInProgress = false; + deferred?.reject(e); + }, + ); return deferred!.promise; } @@ -821,8 +817,8 @@ class DeviceListUpdateSerialiser { user_signing?: SigningKeys; }, ): Promise { - logger.log('got device keys for ' + userId + ':', dkResponse); - logger.log('got cross-signing keys for ' + userId + ':', crossSigningResponse); + logger.log("got device keys for " + userId + ":", dkResponse); + logger.log("got cross-signing keys for " + userId + ":", crossSigningResponse); { // map from deviceid -> deviceinfo for this user @@ -836,8 +832,12 @@ class DeviceListUpdateSerialiser { } await updateStoredDeviceKeysForUser( - this.olmDevice, userId, userStore, dkResponse || {}, - this.baseApis.getUserId()!, this.baseApis.deviceId!, + this.olmDevice, + userId, + userStore, + dkResponse || {}, + this.baseApis.getUserId()!, + this.baseApis.deviceId!, ); // put the updates into the object that will be returned as our results @@ -853,12 +853,12 @@ class DeviceListUpdateSerialiser { { // FIXME: should we be ignoring empty cross-signing responses, or // should we be dropping the keys? - if (crossSigningResponse - && (crossSigningResponse.master || crossSigningResponse.self_signing - || crossSigningResponse.user_signing)) { - const crossSigning - = this.deviceList.getStoredCrossSigningForUser(userId) - || new CrossSigningInfo(userId); + if ( + crossSigningResponse && + (crossSigningResponse.master || crossSigningResponse.self_signing || crossSigningResponse.user_signing) + ) { + const crossSigning = + this.deviceList.getStoredCrossSigningForUser(userId) || new CrossSigningInfo(userId); crossSigning.setKeys(crossSigningResponse); @@ -890,14 +890,11 @@ async function updateStoredDeviceKeysForUser( if (!(deviceId in userResult)) { if (userId === localUserId && deviceId === localDeviceId) { - logger.warn( - `Local device ${deviceId} missing from sync, skipping removal`, - ); + logger.warn(`Local device ${deviceId} missing from sync, skipping removal`); continue; } - logger.log("Device " + userId + ":" + deviceId + - " has been removed"); + logger.log("Device " + userId + ":" + deviceId + " has been removed"); delete userStore[deviceId]; updated = true; } @@ -913,13 +910,11 @@ async function updateStoredDeviceKeysForUser( // check that the user_id and device_id in the response object are // correct if (deviceResult.user_id !== userId) { - logger.warn("Mismatched user_id " + deviceResult.user_id + - " in keys from " + userId + ":" + deviceId); + logger.warn("Mismatched user_id " + deviceResult.user_id + " in keys from " + userId + ":" + deviceId); continue; } if (deviceResult.device_id !== deviceId) { - logger.warn("Mismatched device_id " + deviceResult.device_id + - " in keys from " + userId + ":" + deviceId); + logger.warn("Mismatched device_id " + deviceResult.device_id + " in keys from " + userId + ":" + deviceId); continue; } @@ -978,8 +973,7 @@ async function storeDeviceKeys( // best off sticking with the original keys. // // Should we warn the user about it somehow? - logger.warn("Ed25519 key for device " + userId + ":" + - deviceId + " has changed"); + logger.warn("Ed25519 key for device " + userId + ":" + deviceId + " has changed"); return false; } } else { diff --git a/src/crypto/EncryptionSetup.ts b/src/crypto/EncryptionSetup.ts index 253c83c1d64..7fe6d6457c3 100644 --- a/src/crypto/EncryptionSetup.ts +++ b/src/crypto/EncryptionSetup.ts @@ -17,7 +17,7 @@ limitations under the License. import { logger } from "../logger"; import { IContent, MatrixEvent } from "../models/event"; import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning"; -import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; +import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; import { Method, ClientPrefix } from "../http-api"; import { Crypto, ICryptoCallbacks, IBootstrapCrossSigningOpts } from "./index"; import { @@ -35,7 +35,7 @@ import { IAccountDataClient } from "./SecretStorage"; interface ICrossSigningKeys { authUpload: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"]; - keys: Record; + keys: Record<"master" | "self_signing" | "user_signing", ICrossSigningKey>; } /** @@ -58,8 +58,8 @@ export class EncryptionSetupBuilder { private sessionBackupPrivateKey?: Uint8Array; /** - * @param {Object.} accountData pre-existing account data, will only be read, not written. - * @param {CryptoCallbacks} delegateCryptoCallbacks crypto callbacks to delegate to if the key isn't in cache yet + * @param accountData - pre-existing account data, will only be read, not written. + * @param delegateCryptoCallbacks - crypto callbacks to delegate to if the key isn't in cache yet */ public constructor(accountData: Record, delegateCryptoCallbacks?: ICryptoCallbacks) { this.accountDataClientAdapter = new AccountDataClientAdapter(accountData); @@ -70,13 +70,13 @@ export class EncryptionSetupBuilder { /** * Adds new cross-signing public keys * - * @param {function} authUpload Function called to await an interactive auth + * @param authUpload - Function called to await an interactive auth * flow when uploading device signing keys. * Args: - * {function} A function that makes the request requiring auth. Receives + * A function that makes the request requiring auth. Receives * the auth data as an object. Can be called multiple times, first with * an empty authDict, to obtain the flows. - * @param {Object} keys the new keys + * @param keys - the new keys */ public addCrossSigningKeys(authUpload: ICrossSigningKeys["authUpload"], keys: ICrossSigningKeys["keys"]): void { this.crossSigningKeys = { authUpload, keys }; @@ -88,7 +88,7 @@ export class EncryptionSetupBuilder { * Used either to create a new key backup, or add signatures * from the new MSK. * - * @param {Object} keyBackupInfo as received from/sent to the server + * @param keyBackupInfo - as received from/sent to the server */ public addSessionBackup(keyBackupInfo: IKeyBackupInfo): void { this.keyBackupInfo = keyBackupInfo; @@ -99,7 +99,6 @@ export class EncryptionSetupBuilder { * * Used after fixing the format of the key * - * @param {Uint8Array} privateKey */ public addSessionBackupPrivateKeyToCache(privateKey: Uint8Array): void { this.sessionBackupPrivateKey = privateKey; @@ -109,9 +108,6 @@ export class EncryptionSetupBuilder { * Add signatures from a given user and device/x-sign key * Used to sign the new cross-signing key with the device key * - * @param {String} userId - * @param {String} deviceId - * @param {Object} signature */ public addKeySignature(userId: string, deviceId: string, signature: ISignedKey): void { if (!this.keySignatures) { @@ -122,27 +118,16 @@ export class EncryptionSetupBuilder { userSignatures[deviceId] = signature; } - /** - * @param {String} type - * @param {Object} content - * @return {Promise} - */ public async setAccountData(type: string, content: object): Promise { await this.accountDataClientAdapter.setAccountData(type, content); } /** * builds the operation containing all the parts that have been added to the builder - * @return {EncryptionSetupOperation} */ public buildOperation(): EncryptionSetupOperation { const accountData = this.accountDataClientAdapter.values; - return new EncryptionSetupOperation( - accountData, - this.crossSigningKeys, - this.keyBackupInfo, - this.keySignatures, - ); + return new EncryptionSetupOperation(accountData, this.crossSigningKeys, this.keyBackupInfo, this.keySignatures); } /** @@ -150,9 +135,6 @@ export class EncryptionSetupBuilder { * * This does not yet store the operation in a way that it can be restored, * but that is the idea in the future. - * - * @param {Crypto} crypto - * @return {Promise} */ public async persist(crypto: Crypto): Promise { // store private keys in cache @@ -164,13 +146,9 @@ export class EncryptionSetupBuilder { await cacheCallbacks.storeCrossSigningKeyCache?.(type, privateKey); } // store own cross-sign pubkeys as trusted - await crypto.cryptoStore.doTxn( - 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - crypto.cryptoStore.storeCrossSigningKeys( - txn, this.crossSigningKeys!.keys); - }, - ); + await crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + crypto.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningKeys!.keys); + }); } // store session backup key in cache if (this.sessionBackupPrivateKey) { @@ -187,10 +165,6 @@ export class EncryptionSetupBuilder { */ export class EncryptionSetupOperation { /** - * @param {Map} accountData - * @param {Object} crossSigningKeys - * @param {Object} keyBackupInfo - * @param {Object} keySignatures */ public constructor( private readonly accountData: Map, @@ -201,7 +175,6 @@ export class EncryptionSetupOperation { /** * Runs the (remaining part of, in the future) operation by sending requests to the server. - * @param {Crypto} crypto */ public async apply(crypto: Crypto): Promise { const baseApis = crypto.baseApis; @@ -209,12 +182,12 @@ export class EncryptionSetupOperation { if (this.crossSigningKeys) { const keys: Partial = {}; for (const [name, key] of Object.entries(this.crossSigningKeys.keys)) { - keys[name + "_key"] = key; + keys[((name as keyof ICrossSigningKeys["keys"]) + "_key") as keyof CrossSigningKeys] = key; } // We must only call `uploadDeviceSigningKeys` from inside this auth // helper to ensure we properly handle auth errors. - await this.crossSigningKeys.authUpload?.(authDict => { + await this.crossSigningKeys.authUpload?.((authDict) => { return baseApis.uploadDeviceSigningKeys(authDict, keys as CrossSigningKeys); }); @@ -240,8 +213,10 @@ export class EncryptionSetupOperation { // Sign the backup with the cross signing key so the key backup can // be trusted via cross-signing. await baseApis.http.authedRequest( - Method.Put, "/room_keys/version/" + this.keyBackupInfo.version, - undefined, { + Method.Put, + "/room_keys/version/" + this.keyBackupInfo.version, + undefined, + { algorithm: this.keyBackupInfo.algorithm, auth_data: this.keyBackupInfo.auth_data, }, @@ -249,11 +224,9 @@ export class EncryptionSetupOperation { ); } else { // add new key backup - await baseApis.http.authedRequest( - Method.Post, "/room_keys/version", - undefined, this.keyBackupInfo, - { prefix: ClientPrefix.V3 }, - ); + await baseApis.http.authedRequest(Method.Post, "/room_keys/version", undefined, this.keyBackupInfo, { + prefix: ClientPrefix.V3, + }); } } } @@ -265,28 +238,27 @@ export class EncryptionSetupOperation { */ class AccountDataClientAdapter extends TypedEventEmitter - implements IAccountDataClient { + implements IAccountDataClient +{ // public readonly values = new Map(); /** - * @param {Object.} existingValues existing account data + * @param existingValues - existing account data */ public constructor(private readonly existingValues: Record) { super(); } /** - * @param {String} type - * @return {Promise} the content of the account data + * @returns the content of the account data */ - public getAccountDataFromServer(type: string): Promise { + public getAccountDataFromServer(type: string): Promise { return Promise.resolve(this.getAccountData(type) as T); } /** - * @param {String} type - * @return {Object} the content of the account data + * @returns the content of the account data */ public getAccountData(type: string): IContent | null { const modifiedValue = this.values.get(type); @@ -300,11 +272,6 @@ class AccountDataClientAdapter return null; } - /** - * @param {String} type - * @param {Object} content - * @return {Promise} - */ public setAccountData(type: string, content: any): Promise<{}> { const lastEvent = this.values.get(type); this.values.set(type, content); @@ -361,7 +328,7 @@ class SSSSCryptoCallbacks { public async getSecretStorageKey( { keys }: { keys: Record }, name: string, - ): Promise<[string, Uint8Array]|null> { + ): Promise<[string, Uint8Array] | null> { for (const keyId of Object.keys(keys)) { const privateKey = this.privateKeys.get(keyId); if (privateKey) { @@ -371,8 +338,7 @@ class SSSSCryptoCallbacks { // if we don't have the key cached yet, ask // for it to the general crypto callbacks and cache it if (this?.delegateCryptoCallbacks?.getSecretStorageKey) { - const result = await this.delegateCryptoCallbacks. - getSecretStorageKey({ keys }, name); + const result = await this.delegateCryptoCallbacks.getSecretStorageKey({ keys }, name); if (result) { const [keyId, privateKey] = result; this.privateKeys.set(keyId, privateKey); diff --git a/src/crypto/OlmDevice.ts b/src/crypto/OlmDevice.ts index efa34ad4159..9d342b8cc16 100644 --- a/src/crypto/OlmDevice.ts +++ b/src/crypto/OlmDevice.ts @@ -16,17 +16,25 @@ limitations under the License. import { Account, InboundGroupSession, OutboundGroupSession, Session, Utility } from "@matrix-org/olm"; -import { logger, PrefixedLogger } from '../logger'; -import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; -import * as algorithms from './algorithms'; +import { logger, PrefixedLogger } from "../logger"; +import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; +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"; +import { IMessage } from "./algorithms/olm"; // 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. -const MAX_PLAINTEXT_LENGTH = 65536 * 3 / 4; +const MAX_PLAINTEXT_LENGTH = (65536 * 3) / 4; + +export class PayloadTooLargeError extends Error { + public readonly data = { + errcode: "M_TOO_LARGE", + error: "Payload too large for encrypted message", + }; +} function checkPayloadLength(payloadString: string): void { if (payloadString === undefined) { @@ -40,55 +48,29 @@ function checkPayloadLength(payloadString: string): void { // Note that even if we manage to do the encryption, the message send may fail, // because by the time we've wrapped the ciphertext in the event object, it may // exceed 65K. But at least we won't just fail with "abort()" in that case. - const err = new Error("Message too long (" + payloadString.length + " bytes). " + - "The maximum for an encrypted message is " + - MAX_PLAINTEXT_LENGTH + " bytes."); - // TODO: [TypeScript] We should have our own error types - err["data"] = { - errcode: "M_TOO_LARGE", - error: "Payload too large for encrypted message", - }; - throw err; + throw new PayloadTooLargeError( + `Message too long (${payloadString.length} bytes). ` + + `The maximum for an encrypted message is ${MAX_PLAINTEXT_LENGTH} bytes.`, + ); } } -/** - * The type of object we use for importing and exporting megolm session data. - * - * @typedef {Object} module:crypto/OlmDevice.MegolmSessionData - * @property {String} sender_key Sender's Curve25519 device key - * @property {String[]} forwarding_curve25519_key_chain Devices which forwarded - * this session to us (normally empty). - * @property {Object} sender_claimed_keys Other keys the sender claims. - * @property {String} room_id Room this session is used in - * @property {String} session_id Unique id for the session - * @property {String} session_key Base64'ed key data - */ - interface IInitOpts { fromExportedDevice?: IExportedDevice; pickleKey?: string; } -/** - * data stored in the session store about an inbound group session - * - * @typedef {Object} InboundGroupSessionData - * @property {string} room_id - * @property {string} session pickled Olm.InboundGroupSession - * @property {Object} keysClaimed - * @property {Array} forwardingCurve25519KeyChain Devices involved in forwarding - * this session to us (normally empty). - * @property {boolean=} untrusted whether this session is untrusted. - * @property {boolean=} sharedHistory whether this session exists during the room being set to shared history. - */ - +/** data stored in the session store about an inbound group session */ export interface InboundGroupSessionData { room_id: string; // eslint-disable-line camelcase + /** pickled Olm.InboundGroupSession */ session: string; keysClaimed: Record; + /** Devices involved in forwarding this session to us (normally empty). */ forwardingCurve25519KeyChain: string[]; + /** whether this session is untrusted. */ untrusted?: boolean; + /** whether this session exists during the room being set to shared history. */ sharedHistory?: boolean; } @@ -126,25 +108,20 @@ interface IInboundGroupSessionKey { } /* eslint-enable camelcase */ +type OneTimeKeys = { curve25519: { [keyId: string]: string } }; + /** * Manages the olm cryptography functions. Each OlmDevice has a single * OlmAccount and a number of OlmSessions. * * Accounts and sessions are kept pickled in the cryptoStore. - * - * @constructor - * @alias module:crypto/OlmDevice - * - * @param {Object} cryptoStore A store for crypto data - * - * @property {string} deviceCurve25519Key Curve25519 key for the account - * @property {string} deviceEd25519Key Ed25519 key for the account */ export class OlmDevice { public pickleKey = "DEFAULT_KEY"; // set by consumers - // don't know these until we load the account from storage in init() + /** Curve25519 key for the account, unknown until we load the account from storage in init() */ public deviceCurve25519Key: string | null = null; + /** Ed25519 key for the account, unknown until we load the account from storage in init() */ public deviceEd25519Key: string | null = null; private maxOneTimeKeys: number | null = null; @@ -168,7 +145,7 @@ export class OlmDevice { // // Keys are strings of form "||" // Values are objects of the form "{id: , timestamp: }" - private inboundGroupSessionMessageIndexes: Record = {}; + private inboundGroupSessionMessageIndexes: Record = {}; // Keep track of sessions that we're starting, so that we don't start // multiple sessions for the same device at the same time. @@ -177,11 +154,10 @@ export class OlmDevice { // Used by olm to serialise prekey message decryptions public olmPrekeyPromise: Promise = Promise.resolve(); // set by consumers - public constructor(private readonly cryptoStore: CryptoStore) { - } + public constructor(private readonly cryptoStore: CryptoStore) {} /** - * @return {array} The version of Olm. + * @returns The version of Olm. */ public static getOlmVersion(): [number, number, number] { return global.Olm.get_library_version(); @@ -199,12 +175,11 @@ export class OlmDevice { * * Reads the device keys from the OlmAccount object. * - * @param {object} opts - * @param {object} opts.fromExportedDevice (Optional) data from exported device + * @param fromExportedDevice - (Optional) data from exported device * that must be re-created. * If present, opts.pickleKey is ignored * (exported data already provides a pickle key) - * @param {object} opts.pickleKey (Optional) pickle key to set instead of default one + * @param pickleKey - (Optional) pickle key to set instead of default one */ public async init({ pickleKey, fromExportedDevice }: IInitOpts = {}): Promise { let e2eKeys; @@ -213,10 +188,7 @@ export class OlmDevice { try { if (fromExportedDevice) { if (pickleKey) { - logger.warn( - 'ignoring opts.pickleKey' - + ' because opts.fromExportedDevice is present.', - ); + logger.warn("ignoring opts.pickleKey" + " because opts.fromExportedDevice is present."); } this.pickleKey = fromExportedDevice.pickleKey; await this.initialiseFromExportedDevice(fromExportedDevice, account); @@ -242,55 +214,41 @@ export class OlmDevice { * Note that for now only the “account” and “sessions” stores are populated; * Other stores will be as with a new device. * - * @param {IExportedDevice} exportedData Data exported from another device + * @param exportedData - Data exported from another device * through the “export” method. - * @param {Olm.Account} account an olm account to initialize + * @param account - an olm account to initialize */ private async initialiseFromExportedDevice(exportedData: IExportedDevice, account: Account): Promise { await this.cryptoStore.doTxn( - 'readwrite', - [ - IndexedDBCryptoStore.STORE_ACCOUNT, - IndexedDBCryptoStore.STORE_SESSIONS, - ], + "readwrite", + [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { this.cryptoStore.storeAccount(txn, exportedData.pickledAccount); exportedData.sessions.forEach((session) => { - const { - deviceKey, - sessionId, - } = session; + const { deviceKey, sessionId } = session; const sessionInfo = { session: session.session, lastReceivedMessageTs: session.lastReceivedMessageTs, }; - this.cryptoStore.storeEndToEndSession( - deviceKey!, - sessionId!, - sessionInfo, - txn, - ); + this.cryptoStore.storeEndToEndSession(deviceKey!, sessionId!, sessionInfo, txn); }); - }); + }, + ); account.unpickle(this.pickleKey, exportedData.pickledAccount); } private async initialiseAccount(account: Account): Promise { - await this.cryptoStore.doTxn( - 'readwrite', - [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this.cryptoStore.getAccount(txn, (pickledAccount) => { - if (pickledAccount !== null) { - account.unpickle(this.pickleKey, pickledAccount); - } else { - account.create(); - pickledAccount = account.pickle(this.pickleKey); - this.cryptoStore.storeAccount(txn, pickledAccount); - } - }); - }, - ); + await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.cryptoStore.getAccount(txn, (pickledAccount) => { + if (pickledAccount !== null) { + account.unpickle(this.pickleKey, pickledAccount); + } else { + account.create(); + pickledAccount = account.pickle(this.pickleKey); + this.cryptoStore.storeAccount(txn, pickledAccount); + } + }); + }); } /** @@ -302,9 +260,8 @@ export class OlmDevice { * This function requires a live transaction object from cryptoStore.doTxn() * and therefore may only be called in a doTxn() callback. * - * @param {*} txn Opaque transaction object from cryptoStore.doTxn() - * @param {function} func - * @private + * @param txn - Opaque transaction object from cryptoStore.doTxn() + * @internal */ private getAccount(txn: unknown, func: (account: Account) => void): void { this.cryptoStore.getAccount(txn, (pickledAccount: string | null) => { @@ -323,9 +280,9 @@ export class OlmDevice { * This function requires a live transaction object from cryptoStore.doTxn() * and therefore may only be called in a doTxn() callback. * - * @param {*} txn Opaque transaction object from cryptoStore.doTxn() - * @param {object} Olm.Account object - * @private + * @param txn - Opaque transaction object from cryptoStore.doTxn() + * @param Olm.Account object + * @internal */ private storeAccount(txn: unknown, account: Account): void { this.cryptoStore.storeAccount(txn, account.pickle(this.pickleKey)); @@ -335,7 +292,7 @@ export class OlmDevice { * Export data for re-creating the Olm device later. * TODO export data other than just account and (P2P) sessions. * - * @return {Promise} The exported data + * @returns The exported data */ public async export(): Promise { const result: Partial = { @@ -343,11 +300,8 @@ export class OlmDevice { }; await this.cryptoStore.doTxn( - 'readonly', - [ - IndexedDBCryptoStore.STORE_ACCOUNT, - IndexedDBCryptoStore.STORE_SESSIONS, - ], + "readonly", + [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { this.cryptoStore.getAccount(txn, (pickledAccount: string | null) => { result.pickledAccount = pickledAccount!; @@ -370,11 +324,8 @@ export class OlmDevice { * function and will be freed as soon the callback returns. It is *not* * usable for the rest of the lifetime of the transaction. * - * @param {string} deviceKey - * @param {string} sessionId - * @param {*} txn Opaque transaction object from cryptoStore.doTxn() - * @param {function} func - * @private + * @param txn - Opaque transaction object from cryptoStore.doTxn() + * @internal */ private getSession( deviceKey: string, @@ -382,11 +333,9 @@ export class OlmDevice { txn: unknown, func: (unpickledSessionInfo: IUnpickledSessionInfo) => void, ): void { - this.cryptoStore.getEndToEndSession( - deviceKey, sessionId, txn, (sessionInfo: ISessionInfo | null) => { - this.unpickleSession(sessionInfo!, func); - }, - ); + this.cryptoStore.getEndToEndSession(deviceKey, sessionId, txn, (sessionInfo: ISessionInfo | null) => { + this.unpickleSession(sessionInfo!, func); + }); } /** @@ -394,9 +343,7 @@ export class OlmDevice { * function with it. The session object is destroyed once the function * returns. * - * @param {object} sessionInfo - * @param {function} func - * @private + * @internal */ private unpickleSession( sessionInfo: ISessionInfo, @@ -416,10 +363,9 @@ export class OlmDevice { /** * store our OlmSession in the session store * - * @param {string} deviceKey - * @param {object} sessionInfo {session: OlmSession, lastReceivedMessageTs: int} - * @param {*} txn Opaque transaction object from cryptoStore.doTxn() - * @private + * @param sessionInfo - `{session: OlmSession, lastReceivedMessageTs: int}` + * @param txn - Opaque transaction object from cryptoStore.doTxn() + * @internal */ private saveSession(deviceKey: string, sessionInfo: IUnpickledSessionInfo, txn: unknown): void { const sessionId = sessionInfo.session.session_id(); @@ -432,9 +378,8 @@ export class OlmDevice { /** * get an OlmUtility and call the given function * - * @param {function} func - * @return {object} result of func - * @private + * @returns result of func + * @internal */ private getUtility(func: (utility: Utility) => T): T { const utility = new global.Olm.Utility(); @@ -448,46 +393,41 @@ export class OlmDevice { /** * Signs a message with the ed25519 key for this account. * - * @param {string} message message to be signed - * @return {Promise} base64-encoded signature + * @param message - message to be signed + * @returns base64-encoded signature */ public async sign(message: string): Promise { - let result; - await this.cryptoStore.doTxn( - 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this.getAccount(txn, (account: Account) => { - result = account.sign(message); - }); + let result: string; + await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.getAccount(txn, (account: Account) => { + result = account.sign(message); }); - return result; + }); + return result!; } /** * Get the current (unused, unpublished) one-time keys for this account. * - * @return {object} one time keys; an object with the single property + * @returns one time keys; an object with the single property * curve25519, which is itself an object mapping key id to Curve25519 * key. */ - public async getOneTimeKeys(): Promise<{ curve25519: { [keyId: string]: string } }> { - let result; - await this.cryptoStore.doTxn( - 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this.getAccount(txn, (account) => { - result = JSON.parse(account.one_time_keys()); - }); - }, - ); + public async getOneTimeKeys(): Promise { + let result: OneTimeKeys; + await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.getAccount(txn, (account) => { + result = JSON.parse(account.one_time_keys()); + }); + }); - return result; + return result!; } /** * Get the maximum number of one-time keys we can store. * - * @return {number} number of keys + * @returns number of keys */ public maxNumberOfOneTimeKeys(): number { return this.maxOneTimeKeys ?? -1; @@ -497,75 +437,60 @@ export class OlmDevice { * Marks all of the one-time keys as published. */ public async markKeysAsPublished(): Promise { - await this.cryptoStore.doTxn( - 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this.getAccount(txn, (account: Account) => { - account.mark_keys_as_published(); - this.storeAccount(txn, account); - }); - }, - ); + await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.getAccount(txn, (account: Account) => { + account.mark_keys_as_published(); + this.storeAccount(txn, account); + }); + }); } /** * Generate some new one-time keys * - * @param {number} numKeys number of keys to generate - * @return {Promise} Resolved once the account is saved back having generated the keys + * @param numKeys - number of keys to generate + * @returns Resolved once the account is saved back having generated the keys */ public generateOneTimeKeys(numKeys: number): Promise { - return this.cryptoStore.doTxn( - 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this.getAccount(txn, (account) => { - account.generate_one_time_keys(numKeys); - this.storeAccount(txn, account); - }); - }, - ); + return this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.getAccount(txn, (account) => { + account.generate_one_time_keys(numKeys); + this.storeAccount(txn, account); + }); + }); } /** * Generate a new fallback keys * - * @return {Promise} Resolved once the account is saved back having generated the key + * @returns Resolved once the account is saved back having generated the key */ public async generateFallbackKey(): Promise { - await this.cryptoStore.doTxn( - 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this.getAccount(txn, (account) => { - account.generate_fallback_key(); - this.storeAccount(txn, account); - }); - }, - ); + await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.getAccount(txn, (account) => { + account.generate_fallback_key(); + this.storeAccount(txn, account); + }); + }); } public async getFallbackKey(): Promise>> { let result: Record>; - await this.cryptoStore.doTxn( - 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this.getAccount(txn, (account: Account) => { - result = JSON.parse(account.unpublished_fallback_key()); - }); - }, - ); + await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.getAccount(txn, (account: Account) => { + result = JSON.parse(account.unpublished_fallback_key()); + }); + }); return result!; } public async forgetOldFallbackKey(): Promise { - await this.cryptoStore.doTxn( - 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this.getAccount(txn, (account: Account) => { - account.forget_old_fallback_key(); - this.storeAccount(txn, account); - }); - }, - ); + await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.getAccount(txn, (account: Account) => { + account.forget_old_fallback_key(); + this.storeAccount(txn, account); + }); + }); } /** @@ -573,17 +498,15 @@ export class OlmDevice { * * The new session will be stored in the cryptoStore. * - * @param {string} theirIdentityKey remote user's Curve25519 identity key - * @param {string} theirOneTimeKey remote user's one-time Curve25519 key - * @return {string} sessionId for the outbound session. + * @param theirIdentityKey - remote user's Curve25519 identity key + * @param theirOneTimeKey - remote user's one-time Curve25519 key + * @returns sessionId for the outbound session. */ public async createOutboundSession(theirIdentityKey: string, theirOneTimeKey: string): Promise { let newSessionId: string; await this.cryptoStore.doTxn( - 'readwrite', [ - IndexedDBCryptoStore.STORE_ACCOUNT, - IndexedDBCryptoStore.STORE_SESSIONS, - ], + "readwrite", + [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { this.getAccount(txn, (account: Account) => { const session = new global.Olm.Session(); @@ -612,15 +535,14 @@ export class OlmDevice { /** * Generate a new inbound session, given an incoming message * - * @param {string} theirDeviceIdentityKey remote user's Curve25519 identity key - * @param {number} messageType messageType field from the received message (must be 0) - * @param {string} ciphertext base64-encoded body from the received message + * @param theirDeviceIdentityKey - remote user's Curve25519 identity key + * @param messageType - messageType field from the received message (must be 0) + * @param ciphertext - base64-encoded body from the received message * - * @return {{payload: string, session_id: string}} decrypted payload, and + * @returns decrypted payload, and * session id of new session * - * @raises {Error} if the received message was not valid (for instance, it - * didn't use a valid one-time key). + * @throws Error if the received message was not valid (for instance, it didn't use a valid one-time key). */ public async createInboundSession( theirDeviceIdentityKey: string, @@ -631,12 +553,10 @@ export class OlmDevice { throw new Error("Need messageType == 0 to create inbound session"); } - let result: { payload: string, session_id: string }; // eslint-disable-line camelcase + let result: { payload: string; session_id: string }; // eslint-disable-line camelcase await this.cryptoStore.doTxn( - 'readwrite', [ - IndexedDBCryptoStore.STORE_ACCOUNT, - IndexedDBCryptoStore.STORE_SESSIONS, - ], + "readwrite", + [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { this.getAccount(txn, (account: Account) => { const session = new global.Olm.Session(); @@ -673,9 +593,9 @@ export class OlmDevice { /** * Get a list of known session IDs for the given device * - * @param {string} theirDeviceIdentityKey Curve25519 identity key for the + * @param theirDeviceIdentityKey - Curve25519 identity key for the * remote device - * @return {Promise} a list of known session ids for the device + * @returns a list of known session ids for the device */ public async getSessionIdsForDevice(theirDeviceIdentityKey: string): Promise { const log = logger.withPrefix("[getSessionIdsForDevice]"); @@ -691,13 +611,12 @@ export class OlmDevice { } let sessionIds: string[]; await this.cryptoStore.doTxn( - 'readonly', [IndexedDBCryptoStore.STORE_SESSIONS], + "readonly", + [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { - this.cryptoStore.getEndToEndSessions( - theirDeviceIdentityKey, txn, (sessions) => { - sessionIds = Object.keys(sessions); - }, - ); + this.cryptoStore.getEndToEndSessions(theirDeviceIdentityKey, txn, (sessions) => { + sessionIds = Object.keys(sessions); + }); }, log, ); @@ -708,13 +627,13 @@ export class OlmDevice { /** * Get the right olm session id for encrypting messages to the given identity key * - * @param {string} theirDeviceIdentityKey Curve25519 identity key for the + * @param theirDeviceIdentityKey - Curve25519 identity key for the * remote device - * @param {boolean} nowait Don't wait for an in-progress session to complete. + * @param nowait - Don't wait for an in-progress session to complete. * This should only be set to true of the calling function is the function * that marked the session as being in-progress. - * @param {PrefixedLogger} [log] A possibly customised log - * @return {Promise} session id, or null if no established session + * @param log - A possibly customised log + * @returns session id, or null if no established session */ public async getSessionIdForDevice( theirDeviceIdentityKey: string, @@ -730,17 +649,15 @@ export class OlmDevice { let idxOfBest = 0; for (let i = 1; i < sessionInfos.length; i++) { const thisSessInfo = sessionInfos[i]; - const thisLastReceived = thisSessInfo.lastReceivedMessageTs === undefined ? - 0 : thisSessInfo.lastReceivedMessageTs; + const thisLastReceived = + thisSessInfo.lastReceivedMessageTs === undefined ? 0 : thisSessInfo.lastReceivedMessageTs; const bestSessInfo = sessionInfos[idxOfBest]; - const bestLastReceived = bestSessInfo.lastReceivedMessageTs === undefined ? - 0 : bestSessInfo.lastReceivedMessageTs; + const bestLastReceived = + bestSessInfo.lastReceivedMessageTs === undefined ? 0 : bestSessInfo.lastReceivedMessageTs; if ( - thisLastReceived > bestLastReceived || ( - thisLastReceived === bestLastReceived && - thisSessInfo.sessionId < bestSessInfo.sessionId - ) + thisLastReceived > bestLastReceived || + (thisLastReceived === bestLastReceived && thisSessInfo.sessionId < bestSessInfo.sessionId) ) { idxOfBest = i; } @@ -756,18 +673,17 @@ export class OlmDevice { * the keys 'hasReceivedMessage' (true if the session has received an incoming * message and is therefore past the pre-key stage), and 'sessionId'. * - * @param {string} deviceIdentityKey Curve25519 identity key for the device - * @param {boolean} nowait Don't wait for an in-progress session to complete. + * @param deviceIdentityKey - Curve25519 identity key for the device + * @param nowait - Don't wait for an in-progress session to complete. * This should only be set to true of the calling function is the function * that marked the session as being in-progress. - * @param {Logger} [log] A possibly customised log - * @return {Array.<{sessionId: string, hasReceivedMessage: boolean}>} + * @param log - A possibly customised log */ public async getSessionInfoForDevice( deviceIdentityKey: string, nowait = false, log = logger, - ): Promise<{ sessionId: string, lastReceivedMessageTs: number, hasReceivedMessage: boolean }[]> { + ): Promise<{ sessionId: string; lastReceivedMessageTs: number; hasReceivedMessage: boolean }[]> { log = log.withPrefix("[getSessionInfoForDevice]"); if (deviceIdentityKey in this.sessionsInProgress && !nowait) { @@ -786,7 +702,8 @@ export class OlmDevice { }[] = []; await this.cryptoStore.doTxn( - 'readonly', [IndexedDBCryptoStore.STORE_SESSIONS], + "readonly", + [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { this.cryptoStore.getEndToEndSessions(deviceIdentityKey, txn, (sessions) => { const sessionIds = Object.keys(sessions).sort(); @@ -810,29 +727,34 @@ export class OlmDevice { /** * Encrypt an outgoing message using an existing session * - * @param {string} theirDeviceIdentityKey Curve25519 identity key for the + * @param theirDeviceIdentityKey - Curve25519 identity key for the * remote device - * @param {string} sessionId the id of the active session - * @param {string} payloadString payload to be encrypted and sent + * @param sessionId - the id of the active session + * @param payloadString - payload to be encrypted and sent * - * @return {Promise} ciphertext + * @returns ciphertext */ public async encryptMessage( theirDeviceIdentityKey: string, sessionId: string, payloadString: string, - ): Promise { + ): Promise { checkPayloadLength(payloadString); - let res; + let res: IMessage; await this.cryptoStore.doTxn( - 'readwrite', [IndexedDBCryptoStore.STORE_SESSIONS], + "readwrite", + [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { this.getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { const sessionDesc = sessionInfo.session.describe(); logger.log( - "encryptMessage: Olm Session ID " + sessionId + " to " + - theirDeviceIdentityKey + ": " + sessionDesc, + "encryptMessage: Olm Session ID " + + sessionId + + " to " + + theirDeviceIdentityKey + + ": " + + sessionDesc, ); res = sessionInfo.session.encrypt(payloadString); this.saveSession(theirDeviceIdentityKey, sessionInfo, txn); @@ -840,19 +762,19 @@ export class OlmDevice { }, logger.withPrefix("[encryptMessage]"), ); - return res; + return res!; } /** * Decrypt an incoming message using an existing session * - * @param {string} theirDeviceIdentityKey Curve25519 identity key for the + * @param theirDeviceIdentityKey - Curve25519 identity key for the * remote device - * @param {string} sessionId the id of the active session - * @param {number} messageType messageType field from the received message - * @param {string} ciphertext base64-encoded body from the received message + * @param sessionId - the id of the active session + * @param messageType - messageType field from the received message + * @param ciphertext - base64-encoded body from the received message * - * @return {Promise} decrypted payload. + * @returns decrypted payload. */ public async decryptMessage( theirDeviceIdentityKey: string, @@ -860,15 +782,20 @@ export class OlmDevice { messageType: number, ciphertext: string, ): Promise { - let payloadString; + let payloadString: string; await this.cryptoStore.doTxn( - 'readwrite', [IndexedDBCryptoStore.STORE_SESSIONS], + "readwrite", + [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { this.getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo: IUnpickledSessionInfo) => { const sessionDesc = sessionInfo.session.describe(); logger.log( - "decryptMessage: Olm Session ID " + sessionId + " from " + - theirDeviceIdentityKey + ": " + sessionDesc, + "decryptMessage: Olm Session ID " + + sessionId + + " from " + + theirDeviceIdentityKey + + ": " + + sessionDesc, ); payloadString = sessionInfo.session.decrypt(messageType, ciphertext); sessionInfo.lastReceivedMessageTs = Date.now(); @@ -877,19 +804,19 @@ export class OlmDevice { }, logger.withPrefix("[decryptMessage]"), ); - return payloadString; + return payloadString!; } /** * Determine if an incoming messages is a prekey message matching an existing session * - * @param {string} theirDeviceIdentityKey Curve25519 identity key for the + * @param theirDeviceIdentityKey - Curve25519 identity key for the * remote device - * @param {string} sessionId the id of the active session - * @param {number} messageType messageType field from the received message - * @param {string} ciphertext base64-encoded body from the received message + * @param sessionId - the id of the active session + * @param messageType - messageType field from the received message + * @param ciphertext - base64-encoded body from the received message * - * @return {Promise} true if the received message is a prekey message which matches + * @returns true if the received message is a prekey message which matches * the given session. */ public async matchesSession( @@ -902,9 +829,10 @@ export class OlmDevice { return false; } - let matches; + let matches: boolean; await this.cryptoStore.doTxn( - 'readonly', [IndexedDBCryptoStore.STORE_SESSIONS], + "readonly", + [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { this.getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { matches = sessionInfo.session.matches_inbound(ciphertext); @@ -912,7 +840,7 @@ export class OlmDevice { }, logger.withPrefix("[matchesSession]"), ); - return matches; + return matches!; } public async recordSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise { @@ -934,8 +862,7 @@ export class OlmDevice { /** * store an OutboundGroupSession in outboundGroupSessionStore * - * @param {Olm.OutboundGroupSession} session - * @private + * @internal */ private saveOutboundGroupSession(session: OutboundGroupSession): void { this.outboundGroupSessionStore[session.session_id()] = session.pickle(this.pickleKey); @@ -945,10 +872,8 @@ export class OlmDevice { * extract an OutboundGroupSession from outboundGroupSessionStore and call the * given function * - * @param {string} sessionId - * @param {function} func - * @return {object} result of func - * @private + * @returns result of func + * @internal */ private getOutboundGroupSession(sessionId: string, func: (session: OutboundGroupSession) => T): T { const pickled = this.outboundGroupSessionStore[sessionId]; @@ -968,7 +893,7 @@ export class OlmDevice { /** * Generate a new outbound group session * - * @return {string} sessionId for the outbound session. + * @returns sessionId for the outbound session. */ public createOutboundGroupSession(): string { const session = new global.Olm.OutboundGroupSession(); @@ -984,10 +909,10 @@ export class OlmDevice { /** * Encrypt an outgoing message with an outbound group session * - * @param {string} sessionId the id of the outboundgroupsession - * @param {string} payloadString payload to be encrypted and sent + * @param sessionId - the id of the outboundgroupsession + * @param payloadString - payload to be encrypted and sent * - * @return {string} ciphertext + * @returns ciphertext */ public encryptGroupMessage(sessionId: string, payloadString: string): string { logger.log(`encrypting msg with megolm session ${sessionId}`); @@ -1004,13 +929,13 @@ export class OlmDevice { /** * Get the session keys for an outbound group session * - * @param {string} sessionId the id of the outbound group session + * @param sessionId - the id of the outbound group session * - * @return {{chain_index: number, key: string}} current chain index, and + * @returns current chain index, and * base64-encoded secret key. */ public getOutboundGroupSessionKey(sessionId: string): IOutboundGroupSessionKey { - return this.getOutboundGroupSession(sessionId, function(session: OutboundGroupSession) { + return this.getOutboundGroupSession(sessionId, function (session: OutboundGroupSession) { return { chain_index: session.message_index(), key: session.session_key(), @@ -1025,9 +950,9 @@ export class OlmDevice { * Unpickle a session from a sessionData object and invoke the given function. * The session is valid only until func returns. * - * @param {Object} sessionData Object describing the session. - * @param {function(Olm.InboundGroupSession)} func Invoked with the unpickled session - * @return {*} result of func + * @param sessionData - Object describing the session. + * @param func - Invoked with the unpickled session + * @returns result of func */ private unpickleInboundGroupSession( sessionData: InboundGroupSessionData, @@ -1045,15 +970,12 @@ export class OlmDevice { /** * extract an InboundGroupSession from the crypto store and call the given function * - * @param {string} roomId The room ID to extract the session for, or null to fetch + * @param roomId - The room ID to extract the session for, or null to fetch * sessions for any room. - * @param {string} senderKey - * @param {string} sessionId - * @param {*} txn Opaque transaction object from cryptoStore.doTxn() - * @param {function(Olm.InboundGroupSession, InboundGroupSessionData)} func - * function to call. + * @param txn - Opaque transaction object from cryptoStore.doTxn() + * @param func - function to call. * - * @private + * @internal */ private getInboundGroupSession( roomId: string, @@ -1067,7 +989,10 @@ export class OlmDevice { ) => void, ): void { this.cryptoStore.getEndToEndInboundGroupSession( - senderKey, sessionId, txn, (sessionData: InboundGroupSessionData | null, withheld: IWithheld | null) => { + senderKey, + sessionId, + txn, + (sessionData: InboundGroupSessionData | null, withheld: IWithheld | null) => { if (sessionData === null) { func(null, null, withheld); return; @@ -1078,7 +1003,10 @@ export class OlmDevice { if (roomId !== null && roomId !== sessionData.room_id) { throw new Error( "Mismatched room_id for inbound group session (expected " + - sessionData.room_id + ", was " + roomId + ")", + sessionData.room_id + + ", was " + + roomId + + ")", ); } @@ -1092,16 +1020,16 @@ export class OlmDevice { /** * Add an inbound group session to the session store * - * @param {string} roomId room in which this session will be used - * @param {string} senderKey base64-encoded curve25519 key of the sender - * @param {Array} forwardingCurve25519KeyChain Devices involved in forwarding + * @param roomId - room in which this session will be used + * @param senderKey - base64-encoded curve25519 key of the sender + * @param forwardingCurve25519KeyChain - Devices involved in forwarding * this session to us. - * @param {string} sessionId session identifier - * @param {string} sessionKey base64-encoded secret key - * @param {Object} keysClaimed Other keys the sender claims. - * @param {boolean} exportFormat true if the megolm keys are in export format + * @param sessionId - session identifier + * @param sessionKey - base64-encoded secret key + * @param keysClaimed - Other keys the sender claims. + * @param exportFormat - true if the megolm keys are in export format * (ie, they lack an ed25519 signature) - * @param {Object} [extraSessionData={}] any other data to be include with the session + * @param extraSessionData - any other data to be include with the session */ public async addInboundGroupSession( roomId: string, @@ -1114,97 +1042,101 @@ export class OlmDevice { extraSessionData: OlmGroupSessionExtraData = {}, ): Promise { await this.cryptoStore.doTxn( - 'readwrite', [ + "readwrite", + [ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS, - ], (txn) => { + ], + (txn) => { /* if we already have this session, consider updating it */ - this.getInboundGroupSession(roomId, senderKey, sessionId, txn, ( - existingSession: InboundGroupSession | null, - existingSessionData: InboundGroupSessionData | null, - ) => { - // new session. - const session = new global.Olm.InboundGroupSession(); - try { - if (exportFormat) { - session.import_session(sessionKey); - } else { - session.create(sessionKey); - } - if (sessionId != session.session_id()) { - throw new Error( - "Mismatched group session ID from senderKey: " + - senderKey, - ); - } + this.getInboundGroupSession( + roomId, + senderKey, + sessionId, + txn, + ( + existingSession: InboundGroupSession | null, + existingSessionData: InboundGroupSessionData | null, + ) => { + // new session. + const session = new global.Olm.InboundGroupSession(); + try { + if (exportFormat) { + session.import_session(sessionKey); + } else { + session.create(sessionKey); + } + if (sessionId != session.session_id()) { + throw new Error("Mismatched group session ID from senderKey: " + senderKey); + } - if (existingSession) { - logger.log(`Update for megolm session ${senderKey}|${sessionId}`); - 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 ${senderKey}|${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 " + - `${senderKey}|${sessionId} based on newly-received trusted session`, - ); - existingSessionData!.untrusted = false; - this.cryptoStore.storeEndToEndInboundGroupSession( - senderKey, sessionId, existingSessionData!, txn, - ); - } else { - logger.warn( - `Newly-received megolm session ${senderKey}|$sessionId}` + - " does not match existing session! Keeping existing session", - ); + if (existingSession) { + logger.log(`Update for megolm session ${senderKey}|${sessionId}`); + 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 ${senderKey}|${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 " + + `${senderKey}|${sessionId} based on newly-received trusted session`, + ); + existingSessionData!.untrusted = false; + this.cryptoStore.storeEndToEndInboundGroupSession( + senderKey, + sessionId, + existingSessionData!, + txn, + ); + } else { + logger.warn( + `Newly-received megolm session ${senderKey}|$sessionId}` + + " does not match existing session! Keeping existing session", + ); + } + return; } - return; + // If the sessions have the same index, go ahead and store the new trusted one. } - // If the sessions have the same index, go ahead and store the new trusted one. } - } - logger.info( - `Storing megolm session ${senderKey}|${sessionId} with first index `+ - session.first_known_index(), - ); + logger.info( + `Storing megolm session ${senderKey}|${sessionId} with first index ` + + session.first_known_index(), + ); - const sessionData = Object.assign({}, extraSessionData, { - room_id: roomId, - session: session.pickle(this.pickleKey), - keysClaimed: keysClaimed, - forwardingCurve25519KeyChain: forwardingCurve25519KeyChain, - }); + const sessionData = Object.assign({}, extraSessionData, { + room_id: roomId, + session: session.pickle(this.pickleKey), + keysClaimed: keysClaimed, + forwardingCurve25519KeyChain: forwardingCurve25519KeyChain, + }); - this.cryptoStore.storeEndToEndInboundGroupSession( - senderKey, sessionId, sessionData, txn, - ); + this.cryptoStore.storeEndToEndInboundGroupSession(senderKey, sessionId, sessionData, txn); - if (!existingSession && extraSessionData.sharedHistory) { - this.cryptoStore.addSharedHistoryInboundGroupSession( - roomId, senderKey, sessionId, txn, - ); + if (!existingSession && extraSessionData.sharedHistory) { + this.cryptoStore.addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn); + } + } finally { + session.free(); } - } finally { - session.free(); - } - }); + }, + ); }, logger.withPrefix("[addInboundGroupSession]"), ); @@ -1213,11 +1145,11 @@ export class OlmDevice { /** * Record in the data store why an inbound group session was withheld. * - * @param {string} roomId room that the session belongs to - * @param {string} senderKey base64-encoded curve25519 key of the sender - * @param {string} sessionId session identifier - * @param {string} code reason code - * @param {string} reason human-readable version of `code` + * @param roomId - room that the session belongs to + * @param senderKey - base64-encoded curve25519 key of the sender + * @param sessionId - session identifier + * @param code - reason code + * @param reason - human-readable version of `code` */ public async addInboundGroupSessionWithheld( roomId: string, @@ -1227,10 +1159,12 @@ export class OlmDevice { reason: string, ): Promise { await this.cryptoStore.doTxn( - 'readwrite', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD], + "readwrite", + [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD], (txn) => { this.cryptoStore.storeEndToEndInboundGroupSessionWithheld( - senderKey, sessionId, + senderKey, + sessionId, { room_id: roomId, code: code, @@ -1245,18 +1179,14 @@ export class OlmDevice { /** * Decrypt a received message with an inbound group session * - * @param {string} roomId room in which the message was received - * @param {string} senderKey base64-encoded curve25519 key of the sender - * @param {string} sessionId session identifier - * @param {string} body base64-encoded body of the encrypted message - * @param {string} eventId ID of the event being decrypted - * @param {Number} timestamp timestamp of the event being decrypted - * - * @return {null} the sessionId is unknown + * @param roomId - room in which the message was received + * @param senderKey - base64-encoded curve25519 key of the sender + * @param sessionId - session identifier + * @param body - base64-encoded body of the encrypted message + * @param eventId - ID of the event being decrypted + * @param timestamp - timestamp of the event being decrypted * - * @return {Promise<{result: string, senderKey: string, - * forwardingCurve25519KeyChain: Array, - * keysClaimed: Object}>} + * @returns null if the sessionId is unknown */ public async decryptGroupMessage( roomId: string, @@ -1274,91 +1204,79 @@ export class OlmDevice { let error: Error; await this.cryptoStore.doTxn( - 'readwrite', [ + "readwrite", + [ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, - ], (txn) => { - this.getInboundGroupSession( - roomId, senderKey, sessionId, txn, (session, sessionData, withheld) => { - if (session === null || sessionData === null) { - if (withheld) { - error = new algorithms.DecryptionError( - "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", - calculateWithheldMessage(withheld), - { - session: senderKey + '|' + sessionId, - }, - ); - } - result = null; - return; + ], + (txn) => { + this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData, withheld) => { + if (session === null || sessionData === null) { + if (withheld) { + error = new algorithms.DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + calculateWithheldMessage(withheld), + { + session: senderKey + "|" + sessionId, + }, + ); } - let res; - try { - res = session.decrypt(body); - } catch (e) { - if ((e)?.message === 'OLM.UNKNOWN_MESSAGE_INDEX' && withheld) { - error = new algorithms.DecryptionError( - "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", - calculateWithheldMessage(withheld), - { - session: senderKey + '|' + sessionId, - }, - ); - } else { - error = e; - } - return; + result = null; + return; + } + let res: ReturnType; + try { + res = session.decrypt(body); + } catch (e) { + if ((e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX" && withheld) { + error = new algorithms.DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + calculateWithheldMessage(withheld), + { + session: senderKey + "|" + sessionId, + }, + ); + } else { + error = e; } + return; + } - let plaintext: string = res.plaintext; - if (plaintext === undefined) { - // Compatibility for older olm versions. - plaintext = res; - } else { - // Check if we have seen this message index before to detect replay attacks. - // If the event ID and timestamp are specified, and the match the event ID - // and timestamp from the last time we used this message index, then we - // don't consider it a replay attack. - const messageIndexKey = ( - senderKey + "|" + sessionId + "|" + res.message_index - ); - if (messageIndexKey in this.inboundGroupSessionMessageIndexes) { - const msgInfo = ( - this.inboundGroupSessionMessageIndexes[messageIndexKey] + let plaintext: string = res.plaintext; + if (plaintext === undefined) { + // @ts-ignore - Compatibility for older olm versions. + plaintext = res as string; + } else { + // Check if we have seen this message index before to detect replay attacks. + // If the event ID and timestamp are specified, and the match the event ID + // and timestamp from the last time we used this message index, then we + // don't consider it a replay attack. + const messageIndexKey = senderKey + "|" + sessionId + "|" + res.message_index; + if (messageIndexKey in this.inboundGroupSessionMessageIndexes) { + const msgInfo = this.inboundGroupSessionMessageIndexes[messageIndexKey]; + if (msgInfo.id !== eventId || msgInfo.timestamp !== timestamp) { + error = new Error( + "Duplicate message index, possible replay attack: " + messageIndexKey, ); - if ( - msgInfo.id !== eventId || - msgInfo.timestamp !== timestamp - ) { - error = new Error( - "Duplicate message index, possible replay attack: " + - messageIndexKey, - ); - return; - } + return; } - this.inboundGroupSessionMessageIndexes[messageIndexKey] = { - id: eventId, - timestamp: timestamp, - }; } - - sessionData.session = session.pickle(this.pickleKey); - this.cryptoStore.storeEndToEndInboundGroupSession( - senderKey, sessionId, sessionData, txn, - ); - result = { - result: plaintext, - keysClaimed: sessionData.keysClaimed || {}, - senderKey: senderKey, - forwardingCurve25519KeyChain: ( - sessionData.forwardingCurve25519KeyChain || [] - ), - untrusted: !!sessionData.untrusted, + this.inboundGroupSessionMessageIndexes[messageIndexKey] = { + id: eventId, + timestamp: timestamp, }; - }, - ); + } + + sessionData.session = session.pickle(this.pickleKey); + this.cryptoStore.storeEndToEndInboundGroupSession(senderKey, sessionId, sessionData, txn); + result = { + result: plaintext, + keysClaimed: sessionData.keysClaimed || {}, + senderKey: senderKey, + forwardingCurve25519KeyChain: sessionData.forwardingCurve25519KeyChain || [], + untrusted: !!sessionData.untrusted, + }; + }); }, logger.withPrefix("[decryptGroupMessage]"), ); @@ -1372,39 +1290,39 @@ export class OlmDevice { /** * Determine if we have the keys for a given megolm session * - * @param {string} roomId room in which the message was received - * @param {string} senderKey base64-encoded curve25519 key of the sender - * @param {string} sessionId session identifier + * @param roomId - room in which the message was received + * @param senderKey - base64-encoded curve25519 key of the sender + * @param sessionId - session identifier * - * @returns {Promise} true if we have the keys to this session + * @returns true if we have the keys to this session */ public async hasInboundSessionKeys(roomId: string, senderKey: string, sessionId: string): Promise { let result: boolean; await this.cryptoStore.doTxn( - 'readonly', [ + "readonly", + [ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, - ], (txn) => { - this.cryptoStore.getEndToEndInboundGroupSession( - senderKey, sessionId, txn, (sessionData) => { - if (sessionData === null) { - result = false; - return; - } + ], + (txn) => { + this.cryptoStore.getEndToEndInboundGroupSession(senderKey, sessionId, txn, (sessionData) => { + if (sessionData === null) { + result = false; + return; + } - if (roomId !== sessionData.room_id) { - logger.warn( - `requested keys for inbound group session ${senderKey}|` + + if (roomId !== sessionData.room_id) { + logger.warn( + `requested keys for inbound group session ${senderKey}|` + `${sessionId}, with incorrect room_id ` + `(expected ${sessionData.room_id}, ` + `was ${roomId})`, - ); - result = false; - } else { - result = true; - } - }, - ); + ); + result = false; + } else { + result = true; + } + }); }, logger.withPrefix("[hasInboundSessionKeys]"), ); @@ -1415,16 +1333,13 @@ export class OlmDevice { /** * Extract the keys to a given megolm session, for sharing * - * @param {string} roomId room in which the message was received - * @param {string} senderKey base64-encoded curve25519 key of the sender - * @param {string} sessionId session identifier - * @param {number} chainIndex The chain index at which to export the session. + * @param roomId - room in which the message was received + * @param senderKey - base64-encoded curve25519 key of the sender + * @param sessionId - session identifier + * @param chainIndex - The chain index at which to export the session. * If omitted, export at the first index we know about. * - * @returns {Promise<{chain_index: number, key: string, - * forwarding_curve25519_key_chain: Array, - * sender_claimed_ed25519_key: string - * }>} + * @returns * details of the session key. The key is a base64-encoded megolm key in * export format. * @@ -1439,46 +1354,45 @@ export class OlmDevice { ): Promise { let result: IInboundGroupSessionKey | null = null; await this.cryptoStore.doTxn( - 'readonly', [ + "readonly", + [ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, - ], (txn) => { - this.getInboundGroupSession( - roomId, senderKey, sessionId, txn, (session, sessionData) => { - if (session === null || sessionData === null) { - result = null; - return; - } - - if (chainIndex === undefined) { - chainIndex = session.first_known_index(); - } - - const exportedSession = session.export_session(chainIndex); - - const claimedKeys = sessionData.keysClaimed || {}; - const senderEd25519Key = claimedKeys.ed25519 || null; + ], + (txn) => { + this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData) => { + if (session === null || sessionData === null) { + result = null; + return; + } - 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; + if (chainIndex === undefined) { + chainIndex = session.first_known_index(); + } - result = { - "chain_index": chainIndex, - "key": exportedSession, - "forwarding_curve25519_key_chain": forwardingKeyChain, - "sender_claimed_ed25519_key": senderEd25519Key, - "shared_history": sessionData.sharedHistory || false, - "untrusted": untrusted, - }; - }, - ); + const exportedSession = session.export_session(chainIndex); + + 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: forwardingKeyChain, + sender_claimed_ed25519_key: senderEd25519Key, + shared_history: sessionData.sharedHistory || false, + untrusted: untrusted, + }; + }); }, logger.withPrefix("[getInboundGroupSessionKey]"), ); @@ -1489,10 +1403,10 @@ export class OlmDevice { /** * Export an inbound group session * - * @param {string} senderKey base64-encoded curve25519 key of the sender - * @param {string} sessionId session identifier - * @param {ISessionInfo} sessionData The session object from the store - * @return {module:crypto/OlmDevice.MegolmSessionData} exported session data + * @param senderKey - base64-encoded curve25519 key of the sender + * @param sessionId - session identifier + * @param sessionData - The session object from the store + * @returns exported session data */ public exportInboundGroupSession( senderKey: string, @@ -1520,9 +1434,9 @@ export class OlmDevice { ): Promise<[senderKey: string, sessionId: string][]> { let result: Promise<[senderKey: string, sessionId: string][]>; await this.cryptoStore.doTxn( - 'readonly', [ - IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS, - ], (txn) => { + "readonly", + [IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS], + (txn) => { result = this.cryptoStore.getSharedHistoryInboundGroupSessions(roomId, txn); }, logger.withPrefix("[getSharedHistoryInboundGroupSessionsForRoom]"), @@ -1536,26 +1450,22 @@ export class OlmDevice { /** * Verify an ed25519 signature. * - * @param {string} key ed25519 key - * @param {string} message message which was signed - * @param {string} signature base64-encoded signature to be checked + * @param key - ed25519 key + * @param message - message which was signed + * @param signature - base64-encoded signature to be checked * - * @raises {Error} if there is a problem with the verification. If the key was + * @throws Error if there is a problem with the verification. If the key was * too small then the message will be "OLM.INVALID_BASE64". If the signature * was invalid then the message will be "OLM.BAD_MESSAGE_MAC". */ - public verifySignature( - key: string, - message: string, - signature: string, - ): void { - this.getUtility(function(util: Utility) { + public verifySignature(key: string, message: string, signature: string): void { + this.getUtility(function (util: Utility) { util.ed25519_verify(key, message, signature); }); } } -export const WITHHELD_MESSAGES = { +export const WITHHELD_MESSAGES: Record = { "m.unverified": "The sender has disabled encrypting to unverified devices.", "m.blacklisted": "The sender has blocked you.", "m.unauthorised": "You are not authorised to read the message.", @@ -1565,11 +1475,11 @@ export const WITHHELD_MESSAGES = { /** * Calculate the message to use for the exception when a session key is withheld. * - * @param {object} withheld An object that describes why the key was withheld. + * @param withheld - An object that describes why the key was withheld. * - * @return {string} the message + * @returns the message * - * @private + * @internal */ function calculateWithheldMessage(withheld: IWithheld): string { if (withheld.code && withheld.code in WITHHELD_MESSAGES) { diff --git a/src/crypto/OutgoingRoomKeyRequestManager.ts b/src/crypto/OutgoingRoomKeyRequestManager.ts index 42723434778..27bf8389bc0 100644 --- a/src/crypto/OutgoingRoomKeyRequestManager.ts +++ b/src/crypto/OutgoingRoomKeyRequestManager.ts @@ -14,28 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { logger } from '../logger'; +import { v4 as uuidv4 } from "uuid"; + +import { logger } from "../logger"; import { MatrixClient } from "../client"; import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "./index"; -import { CryptoStore, OutgoingRoomKeyRequest } from './store/base'; -import { EventType } from "../@types/event"; +import { CryptoStore, OutgoingRoomKeyRequest } from "./store/base"; +import { EventType, ToDeviceMessageId } from "../@types/event"; /** * Internal module. Management of outgoing room key requests. * * See https://docs.google.com/document/d/1m4gQkcnJkxNuBmb5NoFCIadIY-DyqqNAS3lloE73BlQ * for draft documentation on what we're supposed to be implementing here. - * - * @module */ // delay between deciding we want some keys, and sending out the request, to // allow for (a) it turning up anyway, (b) grouping requests together const SEND_KEY_REQUESTS_DELAY_MS = 500; -/** possible states for a room key request +/** + * possible states for a room key request * * The state machine looks like: + * ``` * * | (cancellation sent) * | .-------------------------------------------------. @@ -58,8 +60,7 @@ const SEND_KEY_REQUESTS_DELAY_MS = 500; * | (cancellation sent) | * V | * (deleted) <---------------------------+ - * - * @enum {number} + * ``` */ export enum RoomKeyRequestState { /** request not yet sent */ @@ -112,7 +113,7 @@ export class OutgoingRoomKeyRequestManager { * Called when the client is stopped. Stops any running background processes. */ public stop(): void { - logger.log('stopping OutgoingRoomKeyRequestManager'); + logger.log("stopping OutgoingRoomKeyRequestManager"); // stop the timer on the next run this.clientRunning = false; } @@ -132,12 +133,10 @@ export class OutgoingRoomKeyRequestManager { * Otherwise, a request is added to the pending list, and a job is started * in the background to send it. * - * @param {module:crypto~RoomKeyRequestBody} requestBody - * @param {Array<{userId: string, deviceId: string}>} recipients - * @param {boolean} resend whether to resend the key request if there is + * @param resend - whether to resend the key request if there is * already one * - * @returns {Promise} resolves when the request has been added to the + * @returns resolves when the request has been added to the * pending list (or we have established that a similar request already * exists) */ @@ -165,11 +164,13 @@ export class OutgoingRoomKeyRequestManager { // existing request is about to be cancelled. If we want to // resend, then change the state so that it resends after // cancelling. Otherwise, just cancel the cancellation. - const state = resend ? - RoomKeyRequestState.CancellationPendingAndWillResend : - RoomKeyRequestState.Sent; + const state = resend + ? RoomKeyRequestState.CancellationPendingAndWillResend + : RoomKeyRequestState.Sent; await this.cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, RoomKeyRequestState.CancellationPending, { + req.requestId, + RoomKeyRequestState.CancellationPending, + { state, cancellationTxnId: this.baseApis.makeTxnId(), }, @@ -181,18 +182,18 @@ export class OutgoingRoomKeyRequestManager { // resend, then do nothing. If we do want to, then cancel the // existing request and send a new one. if (resend) { - const state = - RoomKeyRequestState.CancellationPendingAndWillResend; - const updatedReq = - await this.cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, RoomKeyRequestState.Sent, { - state, - cancellationTxnId: this.baseApis.makeTxnId(), - // need to use a new transaction ID so that - // the request gets sent - requestTxnId: this.baseApis.makeTxnId(), - }, - ); + const state = RoomKeyRequestState.CancellationPendingAndWillResend; + const updatedReq = await this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, + RoomKeyRequestState.Sent, + { + state, + cancellationTxnId: this.baseApis.makeTxnId(), + // need to use a new transaction ID so that + // the request gets sent + requestTxnId: this.baseApis.makeTxnId(), + }, + ); if (!updatedReq) { // updateOutgoingRoomKeyRequest couldn't find the request // in state ROOM_KEY_REQUEST_STATES.SENT, so we must have @@ -211,15 +212,9 @@ export class OutgoingRoomKeyRequestManager { // here, as it will slow down processing of received keys if we // do.) try { - await this.sendOutgoingRoomKeyRequestCancellation( - updatedReq, - true, - ); + await this.sendOutgoingRoomKeyRequestCancellation(updatedReq, true); } catch (e) { - logger.error( - "Error sending room key request cancellation;" - + " will retry later.", e, - ); + logger.error("Error sending room key request cancellation;" + " will retry later.", e); } // The request has transitioned from // CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We @@ -229,7 +224,7 @@ export class OutgoingRoomKeyRequestManager { break; } default: - throw new Error('unhandled state: ' + req.state); + throw new Error("unhandled state: " + req.state); } } } @@ -237,15 +232,12 @@ export class OutgoingRoomKeyRequestManager { /** * Cancel room key requests, if any match the given requestBody * - * @param {module:crypto~RoomKeyRequestBody} requestBody * - * @returns {Promise} resolves when the request has been updated in our + * @returns resolves when the request has been updated in our * pending list. */ public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise { - return this.cryptoStore.getOutgoingRoomKeyRequest( - requestBody, - ).then((req): unknown => { + return this.cryptoStore.getOutgoingRoomKeyRequest(requestBody).then((req): unknown => { if (!req) { // no request was made for this key return; @@ -264,57 +256,49 @@ export class OutgoingRoomKeyRequestManager { // may have seen it, so we still need to send a cancellation // in that case :/ - logger.log( - 'deleting unnecessary room key request for ' + - stringifyRequestBody(requestBody), - ); + logger.log("deleting unnecessary room key request for " + stringifyRequestBody(requestBody)); return this.cryptoStore.deleteOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Unsent); case RoomKeyRequestState.Sent: { // send a cancellation. - return this.cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, RoomKeyRequestState.Sent, { + return this.cryptoStore + .updateOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Sent, { state: RoomKeyRequestState.CancellationPending, cancellationTxnId: this.baseApis.makeTxnId(), - }, - ).then((updatedReq) => { - if (!updatedReq) { - // updateOutgoingRoomKeyRequest couldn't find the - // request in state ROOM_KEY_REQUEST_STATES.SENT, - // so we must have raced with another tab to mark - // the request cancelled. There is no point in - // sending another cancellation since the other tab - // will do it. - logger.log( - 'Tried to cancel room key request for ' + - stringifyRequestBody(requestBody) + - ' but it was already cancelled in another tab', - ); - return; - } - - // We don't want to wait for the timer, so we send it - // immediately. (We might actually end up racing with the timer, - // but that's ok: even if we make the request twice, we'll do it - // with the same transaction_id, so only one message will get - // sent). - // - // (We also don't want to wait for the response from the server - // here, as it will slow down processing of received keys if we - // do.) - this.sendOutgoingRoomKeyRequestCancellation( - updatedReq, - ).catch((e) => { - logger.error( - "Error sending room key request cancellation;" - + " will retry later.", e, - ); - this.startTimer(); + }) + .then((updatedReq) => { + if (!updatedReq) { + // updateOutgoingRoomKeyRequest couldn't find the + // request in state ROOM_KEY_REQUEST_STATES.SENT, + // so we must have raced with another tab to mark + // the request cancelled. There is no point in + // sending another cancellation since the other tab + // will do it. + logger.log( + "Tried to cancel room key request for " + + stringifyRequestBody(requestBody) + + " but it was already cancelled in another tab", + ); + return; + } + + // We don't want to wait for the timer, so we send it + // immediately. (We might actually end up racing with the timer, + // but that's ok: even if we make the request twice, we'll do it + // with the same transaction_id, so only one message will get + // sent). + // + // (We also don't want to wait for the response from the server + // here, as it will slow down processing of received keys if we + // do.) + this.sendOutgoingRoomKeyRequestCancellation(updatedReq).catch((e) => { + logger.error("Error sending room key request cancellation;" + " will retry later.", e); + this.startTimer(); + }); }); - }); } default: - throw new Error('unhandled state: ' + req.state); + throw new Error("unhandled state: " + req.state); } }); } @@ -322,11 +306,10 @@ export class OutgoingRoomKeyRequestManager { /** * Look for room key requests by target device and state * - * @param {string} userId Target user ID - * @param {string} deviceId Target device ID + * @param userId - Target user ID + * @param deviceId - Target device ID * - * @return {Promise} resolves to a list of all the - * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + * @returns resolves to a list of all the {@link OutgoingRoomKeyRequest} */ public getOutgoingSentRoomKeyRequest(userId: string, deviceId: string): Promise { return this.cryptoStore.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, [RoomKeyRequestState.Sent]); @@ -337,12 +320,13 @@ export class OutgoingRoomKeyRequestManager { * This is intended for situations where something substantial has changed, and we * don't really expect the other end to even care about the cancellation. * For example, after initialization or self-verification. - * @return {Promise} An array of `queueRoomKeyRequest` outputs. + * @returns An array of `queueRoomKeyRequest` outputs. */ public async cancelAndResendAllOutgoingRequests(): Promise { const outgoings = await this.cryptoStore.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); - return Promise.all(outgoings.map(({ requestBody, recipients }) => - this.queueRoomKeyRequest(requestBody, recipients, true))); + return Promise.all( + outgoings.map(({ requestBody, recipients }) => this.queueRoomKeyRequest(requestBody, recipients, true)), + ); } // start the background timer to send queued requests, if the timer isn't @@ -358,15 +342,15 @@ export class OutgoingRoomKeyRequestManager { } this.sendOutgoingRoomKeyRequestsRunning = true; - this.sendOutgoingRoomKeyRequests().finally(() => { - this.sendOutgoingRoomKeyRequestsRunning = false; - }).catch((e) => { - // this should only happen if there is an indexeddb error, - // in which case we're a bit stuffed anyway. - logger.warn( - `error in OutgoingRoomKeyRequestManager: ${e}`, - ); - }); + this.sendOutgoingRoomKeyRequests() + .finally(() => { + this.sendOutgoingRoomKeyRequestsRunning = false; + }) + .catch((e) => { + // this should only happen if there is an indexeddb error, + // in which case we're a bit stuffed anyway. + logger.warn(`error in OutgoingRoomKeyRequestManager: ${e}`); + }); }; this.sendOutgoingRoomKeyRequestsTimer = setTimeout( @@ -420,8 +404,8 @@ export class OutgoingRoomKeyRequestManager { private sendOutgoingRoomKeyRequest(req: OutgoingRoomKeyRequest): Promise { logger.log( `Requesting keys for ${stringifyRequestBody(req.requestBody)}` + - ` from ${stringifyRecipientList(req.recipients)}` + - `(id ${req.requestId})`, + ` from ${stringifyRecipientList(req.recipients)}` + + `(id ${req.requestId})`, ); const requestMessage: RequestMessage = { @@ -432,10 +416,9 @@ export class OutgoingRoomKeyRequestManager { }; return this.sendMessageToDevices(requestMessage, req.recipients, req.requestTxnId || req.requestId).then(() => { - return this.cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, RoomKeyRequestState.Unsent, - { state: RoomKeyRequestState.Sent }, - ); + return this.cryptoStore.updateOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Unsent, { + state: RoomKeyRequestState.Sent, + }); }); } @@ -444,9 +427,9 @@ export class OutgoingRoomKeyRequestManager { private sendOutgoingRoomKeyRequestCancellation(req: OutgoingRoomKeyRequest, andResend = false): Promise { logger.log( `Sending cancellation for key request for ` + - `${stringifyRequestBody(req.requestBody)} to ` + - `${stringifyRecipientList(req.recipients)} ` + - `(cancellation id ${req.cancellationTxnId})`, + `${stringifyRequestBody(req.requestBody)} to ` + + `${stringifyRecipientList(req.recipients)} ` + + `(cancellation id ${req.cancellationTxnId})`, ); const requestMessage: RequestMessage = { @@ -455,9 +438,7 @@ export class OutgoingRoomKeyRequestManager { request_id: req.requestId, }; - return this.sendMessageToDevices( - requestMessage, req.recipients, req.cancellationTxnId, - ).then(() => { + return this.sendMessageToDevices(requestMessage, req.recipients, req.cancellationTxnId).then(() => { if (andResend) { // We want to resend, so transition to UNSENT return this.cryptoStore.updateOutgoingRoomKeyRequest( @@ -467,7 +448,8 @@ export class OutgoingRoomKeyRequestManager { ); } return this.cryptoStore.deleteOutgoingRoomKeyRequest( - req.requestId, RoomKeyRequestState.CancellationPending, + req.requestId, + RoomKeyRequestState.CancellationPending, ); }); } @@ -483,7 +465,10 @@ export class OutgoingRoomKeyRequestManager { if (!contentMap[recip.userId]) { contentMap[recip.userId] = {}; } - contentMap[recip.userId][recip.deviceId] = message; + contentMap[recip.userId][recip.deviceId] = { + ...message, + [ToDeviceMessageId]: uuidv4(), + }; } return this.baseApis.sendToDevice(EventType.RoomKeyRequest, contentMap, txnId); @@ -499,4 +484,3 @@ function stringifyRequestBody(requestBody: IRoomKeyRequestBody): string { function stringifyRecipientList(recipients: IRoomKeyRequestRecipient[]): string { return `[${recipients.map((r) => `${r.userId}:${r.deviceId}`).join(",")}]`; } - diff --git a/src/crypto/RoomList.ts b/src/crypto/RoomList.ts index 672ef473c05..a73efcd5528 100644 --- a/src/crypto/RoomList.ts +++ b/src/crypto/RoomList.ts @@ -15,13 +15,11 @@ limitations under the License. */ /** - * @module crypto/RoomList - * * Manages the list of encrypted rooms */ -import { CryptoStore } from './store/base'; -import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; +import { CryptoStore } from "./store/base"; +import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; /* eslint-disable camelcase */ export interface IRoomEncryption { @@ -31,9 +29,6 @@ export interface IRoomEncryption { } /* eslint-enable camelcase */ -/** - * @alias module:crypto/RoomList - */ export class RoomList { // Object of roomId -> room e2e info object (body of the m.room.encryption event) private roomEncryption: Record = {}; @@ -41,13 +36,11 @@ export class RoomList { public constructor(private readonly cryptoStore?: CryptoStore) {} public async init(): Promise { - await this.cryptoStore!.doTxn( - 'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { - this.cryptoStore!.getEndToEndRooms(txn, (result) => { - this.roomEncryption = result; - }); - }, - ); + await this.cryptoStore!.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { + this.cryptoStore!.getEndToEndRooms(txn, (result) => { + this.roomEncryption = result; + }); + }); } public getRoomEncryption(roomId: string): IRoomEncryption { @@ -63,10 +56,8 @@ export class RoomList { // as it prevents the Crypto::setRoomEncryption from calling // this twice for consecutive m.room.encryption events this.roomEncryption[roomId] = roomInfo; - await this.cryptoStore!.doTxn( - 'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { - this.cryptoStore!.storeEndToEndRoom(roomId, roomInfo, txn); - }, - ); + await this.cryptoStore!.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { + this.cryptoStore!.storeEndToEndRoom(roomId, roomInfo, txn); + }); } } diff --git a/src/crypto/SecretStorage.ts b/src/crypto/SecretStorage.ts index 5c13ba4b0bb..c0aab32b880 100644 --- a/src/crypto/SecretStorage.ts +++ b/src/crypto/SecretStorage.ts @@ -14,23 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { logger } from '../logger'; -import * as olmlib from './olmlib'; -import { encodeBase64 } from './olmlib'; -import { randomString } from '../randomstring'; -import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from './aes'; -import { ICryptoCallbacks } from "."; +import { v4 as uuidv4 } from "uuid"; + +import { logger } from "../logger"; +import * as olmlib from "./olmlib"; +import { randomString } from "../randomstring"; +import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes"; +import { ICryptoCallbacks, IEncryptedContent } from "."; import { IContent, MatrixEvent } from "../models/event"; import { ClientEvent, ClientEventHandlerMap, MatrixClient } from "../client"; -import { IAddSecretStorageKeyOpts, ISecretStorageKeyInfo } from './api'; -import { TypedEventEmitter } from '../models/typed-event-emitter'; +import { IAddSecretStorageKeyOpts, ISecretStorageKeyInfo } from "./api"; +import { TypedEventEmitter } from "../models/typed-event-emitter"; import { defer, IDeferred } from "../utils"; +import { ToDeviceMessageId } from "../@types/event"; export const SECRET_STORAGE_ALGORITHM_V1_AES = "m.secret_storage.v1.aes-hmac-sha2"; // Some of the key functions use a tuple and some use an object... export type SecretStorageKeyTuple = [keyId: string, keyInfo: ISecretStorageKeyInfo]; -export type SecretStorageKeyObject = {keyId: string, keyInfo: ISecretStorageKeyInfo}; +export type SecretStorageKeyObject = { keyId: string; keyInfo: ISecretStorageKeyInfo }; export interface ISecretRequest { requestId: string; @@ -40,7 +42,7 @@ export interface ISecretRequest { export interface IAccountDataClient extends TypedEventEmitter { // Subset of MatrixClient (which also uses any for the event content) - getAccountDataFromServer: (eventType: string) => Promise; + getAccountDataFromServer: (eventType: string) => Promise; getAccountData: (eventType: string) => IContent | null; setAccountData: (eventType: string, content: any) => Promise<{}>; } @@ -58,14 +60,12 @@ interface IDecryptors { interface ISecretInfo { encrypted: { - // eslint-disable-next-line camelcase - key_id: IEncryptedPayload; + [keyId: string]: IEncryptedPayload; }; } /** * Implements Secure Secret Storage and Sharing (MSC1946) - * @module crypto/SecretStorage */ export class SecretStorage { private requests = new Map(); @@ -86,7 +86,7 @@ export class SecretStorage { public async getDefaultKeyId(): Promise { const defaultKey = await this.accountDataAdapter.getAccountDataFromServer<{ key: string }>( - 'm.secret_storage.default_key', + "m.secret_storage.default_key", ); if (!defaultKey) return null; return defaultKey.key; @@ -95,20 +95,14 @@ export class SecretStorage { public setDefaultKeyId(keyId: string): Promise { return new Promise((resolve, reject) => { const listener = (ev: MatrixEvent): void => { - if ( - ev.getType() === 'm.secret_storage.default_key' && - ev.getContent().key === keyId - ) { + if (ev.getType() === "m.secret_storage.default_key" && ev.getContent().key === keyId) { this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener); resolve(); } }; this.accountDataAdapter.on(ClientEvent.AccountData, listener); - this.accountDataAdapter.setAccountData( - 'm.secret_storage.default_key', - { key: keyId }, - ).catch(e => { + this.accountDataAdapter.setAccountData("m.secret_storage.default_key", { key: keyId }).catch((e) => { this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener); reject(e); }); @@ -118,15 +112,15 @@ export class SecretStorage { /** * Add a key for encrypting secrets. * - * @param {string} algorithm the algorithm used by the key. - * @param {object} opts the options for the algorithm. The properties used + * @param algorithm - the algorithm used by the key. + * @param opts - the options for the algorithm. The properties used * depend on the algorithm given. - * @param {string} [keyId] the ID of the key. If not given, a random + * @param keyId - the ID of the key. If not given, a random * ID will be generated. * - * @return {object} An object with: - * keyId: {string} the ID of the key - * keyInfo: {object} details about the key (iv, mac, passphrase) + * @returns An object with: + * keyId: the ID of the key + * keyInfo: details about the key (iv, mac, passphrase) */ public async addKey( algorithm: string, @@ -162,9 +156,7 @@ export class SecretStorage { ); } - await this.accountDataAdapter.setAccountData( - `m.secret_storage.key.${keyId}`, keyInfo, - ); + await this.accountDataAdapter.setAccountData(`m.secret_storage.key.${keyId}`, keyInfo); return { keyId, @@ -175,9 +167,9 @@ export class SecretStorage { /** * Get the key information for a given ID. * - * @param {string} [keyId = default key's ID] The ID of the key to check + * @param keyId - The ID of the key to check * for. Defaults to the default key ID if not provided. - * @returns {Array?} If the key was found, the return value is an array of + * @returns If the key was found, the return value is an array of * the form [keyId, keyInfo]. Otherwise, null is returned. * XXX: why is this an array when addKey returns an object? */ @@ -198,9 +190,9 @@ export class SecretStorage { /** * Check whether we have a key with a given ID. * - * @param {string} [keyId = default key's ID] The ID of the key to check + * @param keyId - The ID of the key to check * for. Defaults to the default key ID if not provided. - * @return {boolean} Whether we have the key. + * @returns Whether we have the key. */ public async hasKey(keyId?: string): Promise { return Boolean(await this.getKey(keyId)); @@ -209,16 +201,16 @@ export class SecretStorage { /** * Check whether a key matches what we expect based on the key info * - * @param {Uint8Array} key the key to check - * @param {object} info the key info + * @param key - the key to check + * @param info - the key info * - * @return {boolean} whether or not the key matches + * @returns whether or not the key matches */ public async checkKey(key: Uint8Array, info: ISecretStorageKeyInfo): Promise { if (info.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { if (info.mac) { const { mac } = await calculateKeyCheck(key, info.iv); - return info.mac.replace(/=+$/g, '') === mac.replace(/=+$/g, ''); + return info.mac.replace(/=+$/g, "") === mac.replace(/=+$/g, ""); } else { // if we have no information, we have to assume the key is right return true; @@ -231,9 +223,9 @@ export class SecretStorage { /** * Store an encrypted secret on the server * - * @param {string} name The name of the secret - * @param {string} secret The secret contents. - * @param {Array} keys The IDs of the keys to use to encrypt the secret + * @param name - The name of the secret + * @param secret - The secret contents. + * @param keys - The IDs of the keys to use to encrypt the secret * or null/undefined to use the default key. */ public async store(name: string, secret: string, keys?: string[] | null): Promise { @@ -266,8 +258,7 @@ export class SecretStorage { const [, encryption] = await this.getSecretStorageKey(keys, name); encrypted[keyId] = await encryption.encrypt(secret); } else { - logger.warn("unknown algorithm for secret storage key " + keyId - + ": " + keyInfo.algorithm); + logger.warn("unknown algorithm for secret storage key " + keyId + ": " + keyInfo.algorithm); // do nothing if we don't understand the encryption algorithm } } @@ -279,9 +270,9 @@ export class SecretStorage { /** * Get a secret from storage. * - * @param {string} name the name of the secret + * @param name - the name of the secret * - * @return {string} the contents of the secret + * @returns the contents of the secret */ public async get(name: string): Promise { const secretInfo = await this.accountDataAdapter.getAccountDataFromServer(name); @@ -296,10 +287,8 @@ export class SecretStorage { const keys: Record = {}; for (const keyId of Object.keys(secretInfo.encrypted)) { // get key information from key storage - const keyInfo = ( - await this.accountDataAdapter.getAccountDataFromServer( - "m.secret_storage.key." + keyId, - ) + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer( + "m.secret_storage.key." + keyId, ); const encInfo = secretInfo.encrypted[keyId]; // only use keys we understand the encryption algorithm of @@ -311,35 +300,25 @@ export class SecretStorage { } if (Object.keys(keys).length === 0) { - throw new Error(`Could not decrypt ${name} because none of ` + - `the keys it is encrypted with are for a supported algorithm`); + throw new Error( + `Could not decrypt ${name} because none of ` + + `the keys it is encrypted with are for a supported algorithm`, + ); } - let keyId: string; - let decryption; - try { - // fetch private key from app - [keyId, decryption] = await this.getSecretStorageKey(keys, name); - - const encInfo = secretInfo.encrypted[keyId]; - - // We don't actually need the decryption object if it's a passthrough - // since we just want to return the key itself. It must be base64 - // encoded, since this is how a key would normally be stored. - if (encInfo.passthrough) return encodeBase64(decryption.get_private_key()); + // fetch private key from app + const [keyId, decryption] = await this.getSecretStorageKey(keys, name); + const encInfo = secretInfo.encrypted[keyId]; - return decryption.decrypt(encInfo); - } finally { - if (decryption && decryption.free) decryption.free(); - } + return decryption.decrypt(encInfo); } /** * Check if a secret is stored on the server. * - * @param {string} name the name of the secret + * @param name - the name of the secret * - * @return {object?} map of key name to key info the secret is encrypted + * @returns map of key name to key info the secret is encrypted * with, or null if it is not present or not encrypted with a trusted * key */ @@ -348,7 +327,7 @@ export class SecretStorage { const secretInfo = await this.accountDataAdapter.getAccountDataFromServer(name); if (!secretInfo?.encrypted) return null; - const ret = {}; + const ret: Record = {}; // filter secret encryption keys with supported algorithm for (const keyId of Object.keys(secretInfo.encrypted)) { @@ -372,8 +351,8 @@ export class SecretStorage { /** * Request a secret from another device * - * @param {string} name the name of the secret to request - * @param {string[]} devices the devices to request the secret from + * @param name - the name of the secret to request + * @param devices - the devices to request the secret from */ public request(this: SecretStorage, name: string, devices: string[]): ISecretRequest { const requestId = this.baseApis.makeTxnId(); @@ -388,7 +367,7 @@ export class SecretStorage { requesting_device_id: this.baseApis.deviceId, request_id: requestId, }; - const toDevice = {}; + const toDevice: Record = {}; for (const device of devices) { toDevice[device] = cancelData; } @@ -407,8 +386,9 @@ export class SecretStorage { action: "request", requesting_device_id: this.baseApis.deviceId, request_id: requestId, + [ToDeviceMessageId]: uuidv4(), }; - const toDevice = {}; + const toDevice: Record = {}; for (const device of devices) { toDevice[device] = requestData; } @@ -427,9 +407,9 @@ export class SecretStorage { public async onRequestReceived(this: SecretStorage, event: MatrixEvent): Promise { const sender = event.getSender(); const content = event.getContent(); - if (sender !== this.baseApis.getUserId() - || !(content.name && content.action - && content.requesting_device_id && content.request_id) + if ( + sender !== this.baseApis.getUserId() || + !(content.name && content.action && content.requesting_device_id && content.request_id) ) { // ignore requests from anyone else, for now return; @@ -463,10 +443,7 @@ export class SecretStorage { } // check if we have the secret - logger.info( - "received request for secret (" + sender + - ", " + deviceId + ", " + content.request_id + ")", - ); + logger.info("received request for secret (" + sender + ", " + deviceId + ", " + content.request_id + ")"); if (!this.cryptoCallbacks.onSecretRequested) { return; } @@ -486,20 +463,15 @@ export class SecretStorage { secret: secret, }, }; - const encryptedContent = { + const encryptedContent: IEncryptedContent = { algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.baseApis.crypto!.olmDevice.deviceCurve25519Key, + sender_key: this.baseApis.crypto!.olmDevice.deviceCurve25519Key!, ciphertext: {}, + [ToDeviceMessageId]: uuidv4(), }; - await olmlib.ensureOlmSessionsForDevices( - this.baseApis.crypto!.olmDevice, - this.baseApis, - { - [sender]: [ - this.baseApis.getStoredDevice(sender, deviceId)!, - ], - }, - ); + await olmlib.ensureOlmSessionsForDevices(this.baseApis.crypto!.olmDevice, this.baseApis, { + [sender]: [this.baseApis.getStoredDevice(sender, deviceId)!], + }); await olmlib.encryptMessageForDevice( encryptedContent.ciphertext, this.baseApis.getUserId()!, @@ -556,9 +528,7 @@ export class SecretStorage { event.getSenderKey()!, ); if (!deviceInfo) { - logger.log( - "secret share from unknown device with key", event.getSenderKey(), - ); + logger.log("secret share from unknown device with key", event.getSenderKey()); return; } if (!requestControl.devices.includes(deviceInfo.deviceId)) { @@ -574,10 +544,7 @@ export class SecretStorage { return; } - logger.log( - `Successfully received secret ${requestControl.name} ` + - `from ${deviceInfo.deviceId}`, - ); + logger.log(`Successfully received secret ${requestControl.name} ` + `from ${deviceInfo.deviceId}`); requestControl.deferred.resolve(content.secret); } } @@ -606,10 +573,10 @@ export class SecretStorage { if (keys[keyId].algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { const decryption = { - encrypt: function(secret: string): Promise { + encrypt: function (secret: string): Promise { return encryptAES(secret, privateKey, name); }, - decrypt: function(encInfo: IEncryptedPayload): Promise { + decrypt: function (encInfo: IEncryptedPayload): Promise { return decryptAES(encInfo, privateKey, name); }, }; diff --git a/src/crypto/aes.ts b/src/crypto/aes.ts index 33971e6346b..48470af6261 100644 --- a/src/crypto/aes.ts +++ b/src/crypto/aes.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { decodeBase64, encodeBase64 } from './olmlib'; +import { decodeBase64, encodeBase64 } from "./olmlib"; import { subtleCrypto, crypto, TextEncoder } from "./crypto"; // salt for HKDF, with 8 bytes of zeros @@ -22,18 +22,21 @@ const zeroSalt = new Uint8Array(8); export interface IEncryptedPayload { [key: string]: any; // extensible + /** the initialization vector in base64 */ iv: string; + /** the ciphertext in base64 */ ciphertext: string; + /** the HMAC in base64 */ mac: string; } /** * encrypt a string * - * @param {string} data the plaintext to encrypt - * @param {Uint8Array} key the encryption key to use - * @param {string} name the name of the secret - * @param {string} ivStr the initialization vector to use + * @param data - the plaintext to encrypt + * @param key - the encryption key to use + * @param name - the name of the secret + * @param ivStr - the initialization vector to use */ export async function encryptAES( data: string, @@ -67,11 +70,7 @@ export async function encryptAES( encodedData, ); - const hmac = await subtleCrypto.sign( - { name: 'HMAC' }, - hmacKey, - ciphertext, - ); + const hmac = await subtleCrypto.sign({ name: "HMAC" }, hmacKey, ciphertext); return { iv: encodeBase64(iv), @@ -83,24 +82,16 @@ export async function encryptAES( /** * decrypt a string * - * @param {object} data the encrypted data - * @param {string} data.ciphertext the ciphertext in base64 - * @param {string} data.iv the initialization vector in base64 - * @param {string} data.mac the HMAC in base64 - * @param {Uint8Array} key the encryption key to use - * @param {string} name the name of the secret + * @param data - the encrypted data + * @param key - the encryption key to use + * @param name - the name of the secret */ export async function decryptAES(data: IEncryptedPayload, key: Uint8Array, name: string): Promise { const [aesKey, hmacKey] = await deriveKeys(key, name); const ciphertext = decodeBase64(data.ciphertext); - if (!await subtleCrypto.verify( - { name: "HMAC" }, - hmacKey, - decodeBase64(data.mac), - ciphertext, - )) { + if (!(await subtleCrypto.verify({ name: "HMAC" }, hmacKey, decodeBase64(data.mac), ciphertext))) { throw new Error(`Error decrypting secret ${name}: bad MAC`); } @@ -118,20 +109,14 @@ export async function decryptAES(data: IEncryptedPayload, key: Uint8Array, name: } async function deriveKeys(key: Uint8Array, name: string): Promise<[CryptoKey, CryptoKey]> { - const hkdfkey = await subtleCrypto.importKey( - 'raw', - key, - { name: "HKDF" }, - false, - ["deriveBits"], - ); + const hkdfkey = await subtleCrypto.importKey("raw", key, { name: "HKDF" }, false, ["deriveBits"]); const keybits = await subtleCrypto.deriveBits( { name: "HKDF", salt: zeroSalt, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/879 - info: (new TextEncoder().encode(name)), + info: new TextEncoder().encode(name), hash: "SHA-256", }, hkdfkey, @@ -141,23 +126,17 @@ async function deriveKeys(key: Uint8Array, name: string): Promise<[CryptoKey, Cr const aesKey = keybits.slice(0, 32); const hmacKey = keybits.slice(32); - const aesProm = subtleCrypto.importKey( - 'raw', - aesKey, - { name: 'AES-CTR' }, - false, - ['encrypt', 'decrypt'], - ); + const aesProm = subtleCrypto.importKey("raw", aesKey, { name: "AES-CTR" }, false, ["encrypt", "decrypt"]); const hmacProm = subtleCrypto.importKey( - 'raw', + "raw", hmacKey, { - name: 'HMAC', - hash: { name: 'SHA-256' }, + name: "HMAC", + hash: { name: "SHA-256" }, }, false, - ['sign', 'verify'], + ["sign", "verify"], ); return Promise.all([aesProm, hmacProm]); @@ -168,10 +147,10 @@ const ZERO_STR = "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0 /** Calculate the MAC for checking the key. * - * @param {Uint8Array} key the key to use - * @param {string} [iv] The initialization vector as a base64-encoded string. + * @param key - the key to use + * @param iv - The initialization vector as a base64-encoded string. * If omitted, a random initialization vector will be created. - * @return {Promise} An object that contains, `mac` and `iv` properties. + * @returns An object that contains, `mac` and `iv` properties. */ export function calculateKeyCheck(key: Uint8Array, iv?: string): Promise { return encryptAES(ZERO_STR, key, "", iv); diff --git a/src/crypto/algorithms/base.ts b/src/crypto/algorithms/base.ts index d6c70bc1067..e5c7a379408 100644 --- a/src/crypto/algorithms/base.ts +++ b/src/crypto/algorithms/base.ts @@ -16,59 +16,47 @@ limitations under the License. /** * Internal module. Defines the base classes of the encryption implementations - * - * @module */ import { MatrixClient } from "../../client"; import { Room } from "../../models/room"; import { OlmDevice } from "../OlmDevice"; -import { MatrixEvent, RoomMember } from "../../matrix"; -import { Crypto, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from ".."; +import { IContent, MatrixEvent, RoomMember } from "../../matrix"; +import { Crypto, IEncryptedContent, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from ".."; import { DeviceInfo } from "../deviceinfo"; import { IRoomEncryption } from "../RoomList"; /** - * map of registered encryption algorithm classes. A map from string to {@link - * module:crypto/algorithms/base.EncryptionAlgorithm|EncryptionAlgorithm} class - * - * @type {Object.} + * Map of registered encryption algorithm classes. A map from string to {@link EncryptionAlgorithm} class */ export const ENCRYPTION_CLASSES = new Map EncryptionAlgorithm>(); export type DecryptionClassParams

= Omit; /** - * map of registered encryption algorithm classes. Map from string to {@link - * module:crypto/algorithms/base.DecryptionAlgorithm|DecryptionAlgorithm} class - * - * @type {Object.} + * map of registered encryption algorithm classes. Map from string to {@link DecryptionAlgorithm} class */ export const DECRYPTION_CLASSES = new Map DecryptionAlgorithm>(); export interface IParams { + /** The UserID for the local user */ userId: string; + /** The identifier for this device. */ deviceId: string; + /** crypto core */ crypto: Crypto; + /** olm.js wrapper */ olmDevice: OlmDevice; + /** base matrix api interface */ baseApis: MatrixClient; + /** The ID of the room we will be sending to */ roomId?: string; + /** The body of the m.room.encryption event */ config: IRoomEncryption & object; } /** * base type for encryption implementations - * - * @alias module:crypto/algorithms/base.EncryptionAlgorithm - * - * @param {object} params parameters - * @param {string} params.userId The UserID for the local user - * @param {string} params.deviceId The identifier for this device. - * @param {module:crypto} params.crypto crypto core - * @param {module:crypto/OlmDevice} params.olmDevice olm.js wrapper - * @param {MatrixClient} baseApis base matrix api interface - * @param {string} params.roomId The ID of the room we will be sending to - * @param {object} params.config The body of the m.room.encryption event */ export abstract class EncryptionAlgorithm { protected readonly userId: string; @@ -78,6 +66,9 @@ export abstract class EncryptionAlgorithm { protected readonly baseApis: MatrixClient; protected readonly roomId?: string; + /** + * @param params - parameters + */ public constructor(params: IParams) { this.userId = params.userId; this.deviceId = params.deviceId; @@ -91,33 +82,28 @@ export abstract class EncryptionAlgorithm { * Perform any background tasks that can be done before a message is ready to * send, in order to speed up sending of the message. * - * @param {module:models/room} room the room the event is in + * @param room - the room the event is in */ public prepareToEncrypt(room: Room): void {} /** * Encrypt a message event * - * @method module:crypto/algorithms/base.EncryptionAlgorithm.encryptMessage * @public - * @abstract * - * @param {module:models/room} room - * @param {string} eventType - * @param {object} content event content + * @param content - event content * - * @return {Promise} Promise which resolves to the new event body + * @returns Promise which resolves to the new event body */ - public abstract encryptMessage(room: Room, eventType: string, content: object): Promise; + public abstract encryptMessage(room: Room, eventType: string, content: IContent): Promise; /** * Called when the membership of a member of the room changes. * - * @param {module:models/event.MatrixEvent} event event causing the change - * @param {module:models/room-member} member user whose membership changed - * @param {string=} oldMembership previous membership + * @param event - event causing the change + * @param member - user whose membership changed + * @param oldMembership - previous membership * @public - * @abstract */ public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {} @@ -133,15 +119,6 @@ export abstract class EncryptionAlgorithm { /** * base type for decryption implementations - * - * @alias module:crypto/algorithms/base.DecryptionAlgorithm - * @param {object} params parameters - * @param {string} params.userId The UserID for the local user - * @param {module:crypto} params.crypto crypto core - * @param {module:crypto/OlmDevice} params.olmDevice olm.js wrapper - * @param {MatrixClient} baseApis base matrix api interface - * @param {string=} params.roomId The ID of the room we will be receiving - * from. Null for to-device events. */ export abstract class DecryptionAlgorithm { protected readonly userId: string; @@ -161,12 +138,9 @@ export abstract class DecryptionAlgorithm { /** * Decrypt an event * - * @method module:crypto/algorithms/base.DecryptionAlgorithm#decryptEvent - * @abstract - * - * @param {MatrixEvent} event undecrypted event + * @param event - undecrypted event * - * @return {Promise} promise which + * @returns promise which * resolves once we have finished decrypting. Rejects with an * `algorithms.DecryptionError` if there is a problem decrypting the event. */ @@ -175,9 +149,7 @@ export abstract class DecryptionAlgorithm { /** * Handle a key event * - * @method module:crypto/algorithms/base.DecryptionAlgorithm#onRoomKeyEvent - * - * @param {module:models/event.MatrixEvent} params event key event + * @param params - event key event */ public async onRoomKeyEvent(params: MatrixEvent): Promise { // ignore by default @@ -186,8 +158,7 @@ export abstract class DecryptionAlgorithm { /** * Import a room key * - * @param {module:crypto/OlmDevice.MegolmSessionData} session - * @param {object} opts object + * @param opts - object */ public async importRoomKey(session: IMegolmSessionData, opts: object): Promise { // ignore by default @@ -196,8 +167,7 @@ export abstract class DecryptionAlgorithm { /** * Determine if we have the keys necessary to respond to a room key request * - * @param {module:crypto~IncomingRoomKeyRequest} keyRequest - * @return {Promise} true if we have the keys and could (theoretically) share + * @returns true if we have the keys and could (theoretically) share * them; else false. */ public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise { @@ -207,7 +177,6 @@ export abstract class DecryptionAlgorithm { /** * Send the response to a room key request * - * @param {module:crypto~IncomingRoomKeyRequest} keyRequest */ public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm"); @@ -217,7 +186,7 @@ export abstract class DecryptionAlgorithm { * Retry decrypting all the events from a sender that haven't been * decrypted yet. * - * @param {string} senderKey the sender's key + * @param senderKey - the sender's key */ public async retryDecryptionFromSender(senderKey: string): Promise { // ignore by default @@ -231,13 +200,10 @@ export abstract class DecryptionAlgorithm { /** * Exception thrown when decryption fails * - * @alias module:crypto/algorithms/base.DecryptionError - * @param {string} msg user-visible message describing the problem + * @param msg - user-visible message describing the problem * - * @param {Object=} details key/value pairs reported in the logs but not shown + * @param details - key/value pairs reported in the logs but not shown * to the user. - * - * @extends Error */ export class DecryptionError extends Error { public readonly detailedString: string; @@ -245,33 +211,35 @@ export class DecryptionError extends Error { public constructor(public readonly code: string, msg: string, details?: Record) { super(msg); this.code = code; - this.name = 'DecryptionError'; + this.name = "DecryptionError"; this.detailedString = detailedStringForDecryptionError(this, details); } } function detailedStringForDecryptionError(err: DecryptionError, details?: Record): string { - let result = err.name + '[msg: ' + err.message; + let result = err.name + "[msg: " + err.message; if (details) { - result += ', ' + Object.keys(details).map((k) => k + ': ' + details[k]).join(', '); + result += + ", " + + Object.keys(details) + .map((k) => k + ": " + details[k]) + .join(", "); } - result += ']'; + result += "]"; return result; } -/** - * Exception thrown specifically when we want to warn the user to consider - * the security of their conversation before continuing - * - * @param {string} msg message describing the problem - * @param {Object} devices userId -> {deviceId -> object} - * set of unknown devices per user we're warning about - * @extends Error - */ export class UnknownDeviceError extends Error { + /** + * Exception thrown specifically when we want to warn the user to consider + * the security of their conversation before continuing + * + * @param msg - message describing the problem + * @param devices - set of unknown devices per user we're warning about + */ public constructor( msg: string, public readonly devices: Record>, @@ -286,15 +254,11 @@ export class UnknownDeviceError extends Error { /** * Registers an encryption/decryption class for a particular algorithm * - * @param {string} algorithm algorithm tag to register for + * @param algorithm - algorithm tag to register for * - * @param {class} encryptor {@link - * module:crypto/algorithms/base.EncryptionAlgorithm|EncryptionAlgorithm} - * implementation + * @param encryptor - {@link EncryptionAlgorithm} implementation * - * @param {class} decryptor {@link - * module:crypto/algorithms/base.DecryptionAlgorithm|DecryptionAlgorithm} - * implementation + * @param decryptor - {@link DecryptionAlgorithm} implementation */ export function registerAlgorithm

( algorithm: string, diff --git a/src/crypto/algorithms/index.ts b/src/crypto/algorithms/index.ts index 3dd1158a0c5..b3c5b0ede84 100644 --- a/src/crypto/algorithms/index.ts +++ b/src/crypto/algorithms/index.ts @@ -14,10 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -/** - * @module crypto/algorithms - */ - import "./olm"; import "./megolm"; diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts index cbad327a608..ef04e8f4e0b 100644 --- a/src/crypto/algorithms/megolm.ts +++ b/src/crypto/algorithms/megolm.ts @@ -16,11 +16,12 @@ limitations under the License. /** * Defines m.olm encryption/decryption - * - * @module crypto/algorithms/megolm */ -import { logger } from '../../logger'; +import { v4 as uuidv4 } from "uuid"; + +import type { IEventDecryptionResult } from "../../@types/crypto"; +import { logger } from "../../logger"; import * as olmlib from "../olmlib"; import { DecryptionAlgorithm, @@ -31,15 +32,15 @@ import { registerAlgorithm, UnknownDeviceError, } from "./base"; -import { IDecryptedGroupMessage, WITHHELD_MESSAGES } from '../OlmDevice'; -import { Room } from '../../models/room'; +import { IDecryptedGroupMessage, WITHHELD_MESSAGES } from "../OlmDevice"; +import { Room } from "../../models/room"; import { DeviceInfo } from "../deviceinfo"; import { IOlmSessionResult } from "../olmlib"; import { DeviceInfoMap } from "../DeviceList"; -import { MatrixEvent } from "../../models/event"; -import { EventType, MsgType } from '../../@types/event'; -import { IEncryptedContent, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index"; -import { RoomKeyRequestState } from '../OutgoingRoomKeyRequestManager'; +import { IContent, MatrixEvent } from "../../models/event"; +import { EventType, MsgType, ToDeviceMessageId } from "../../@types/event"; +import { IMegolmEncryptedContent, IMegolmSessionData, IncomingRoomKeyRequest, IEncryptedContent } from "../index"; +import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager"; import { OlmGroupSessionExtraData } from "../../@types/crypto"; import { MatrixError } from "../../http-api"; @@ -81,14 +82,14 @@ export interface IOutboundGroupSessionKey { interface IMessage { type: string; content: { - algorithm: string; - room_id: string; - sender_key?: string; - sender_claimed_ed25519_key?: string; - session_id: string; - session_key: string; - chain_index: number; - forwarding_curve25519_key_chain?: string[]; + "algorithm": string; + "room_id": string; + "sender_key"?: string; + "sender_claimed_ed25519_key"?: string; + "session_id": string; + "session_key": string; + "chain_index": number; + "forwarding_curve25519_key_chain"?: string[]; "org.matrix.msc3061.shared_history": boolean; }; } @@ -115,48 +116,33 @@ interface SharedWithData { } /** - * @private - * @constructor - * - * @param {string} sessionId - * @param {boolean} sharedHistory whether the session can be freely shared with - * other group members, according to the room history visibility settings - * - * @property {string} sessionId - * @property {Number} useCount number of times this session has been used - * @property {Number} creationTime when the session was created (ms since the epoch) - * - * @property {object} sharedWithDevices - * devices with which we have shared the session key - * userId -> {deviceId -> SharedWithData} + * @internal */ class OutboundSessionInfo { + /** number of times this session has been used */ public useCount = 0; + /** when the session was created (ms since the epoch) */ public creationTime: number; + /** devices with which we have shared the session key `userId -> {deviceId -> SharedWithData}` */ public sharedWithDevices: Record> = {}; public blockedDevicesNotified: Record> = {}; + /** + * @param sharedHistory - whether the session can be freely shared with + * other group members, according to the room history visibility settings + */ public constructor(public readonly sessionId: string, public readonly sharedHistory = false) { this.creationTime = new Date().getTime(); } /** * Check if it's time to rotate the session - * - * @param {Number} rotationPeriodMsgs - * @param {Number} rotationPeriodMs - * @return {Boolean} */ public needsRotation(rotationPeriodMsgs: number, rotationPeriodMs: number): boolean { const sessionLifetime = new Date().getTime() - this.creationTime; - if (this.useCount >= rotationPeriodMsgs || - sessionLifetime >= rotationPeriodMs - ) { - logger.log( - "Rotating megolm session after " + this.useCount + - " messages, " + sessionLifetime + "ms", - ); + if (this.useCount >= rotationPeriodMsgs || sessionLifetime >= rotationPeriodMs) { + logger.log("Rotating megolm session after " + this.useCount + " messages, " + sessionLifetime + "ms"); return true; } @@ -181,10 +167,10 @@ class OutboundSessionInfo { * Determine if this session has been shared with devices which it shouldn't * have been. * - * @param {Object} devicesInRoom userId -> {deviceId -> object} + * @param devicesInRoom - `userId -> {deviceId -> object}` * devices we should shared the session with. * - * @return {Boolean} true if we have shared the session with devices which aren't + * @returns true if we have shared the session with devices which aren't * in devicesInRoom. */ public sharedWithTooManyDevices(devicesInRoom: Record>): boolean { @@ -204,10 +190,7 @@ class OutboundSessionInfo { } if (!devicesInRoom[userId].hasOwnProperty(deviceId)) { - logger.log( - "Starting new megolm session because we shared with " + - userId + ":" + deviceId, - ); + logger.log("Starting new megolm session because we shared with " + userId + ":" + deviceId); return true; } } @@ -220,13 +203,9 @@ class OutboundSessionInfo { /** * Megolm encryption implementation * - * @constructor - * @extends {module:crypto/algorithms/EncryptionAlgorithm} - * - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/EncryptionAlgorithm} + * @param params - parameters, as per {@link EncryptionAlgorithm} */ -class MegolmEncryption extends EncryptionAlgorithm { +export class MegolmEncryption extends EncryptionAlgorithm { // the most recent attempt to set up a session. This is used to serialise // the session setups, so that we have a race-free view of which session we // are using, and which devices we have shared the keys with. It resolves @@ -257,15 +236,31 @@ class MegolmEncryption extends EncryptionAlgorithm { } /** - * @private + * @internal * - * @param {module:models/room} room - * @param {Object} devicesInRoom The devices in this room, indexed by user ID - * @param {Object} blocked The devices that are blocked, indexed by user ID - * @param {boolean} [singleOlmCreationPhase] Only perform one round of olm + * @param devicesInRoom - The devices in this room, indexed by user ID + * @param blocked - The devices that are blocked, indexed by user ID + * @param singleOlmCreationPhase - Only perform one round of olm * session creation * - * @return {Promise} Promise which resolves to the + * This method updates the setupPromise field of the class by chaining a new + * call on top of the existing promise, and then catching and discarding any + * errors that might happen while setting up the outbound group session. This + * is done to ensure that `setupPromise` always resolves to `null` or the + * `OutboundSessionInfo`. + * + * Using `>>=` to represent the promise chaining operation, it does the + * following: + * + * ``` + * setupPromise = previousSetupPromise >>= setup >>= discardErrors + * ``` + * + * The initial value for the `setupPromise` is a promise that resolves to + * `null`. The forceDiscardSession() resets setupPromise to this initial + * promise. + * + * @returns Promise which resolves to the * OutboundSessionInfo when setup is complete. */ private async ensureOutboundSession( @@ -277,36 +272,31 @@ class MegolmEncryption extends EncryptionAlgorithm { // takes the previous OutboundSessionInfo, and considers whether to create // a new one. Also shares the key with any (new) devices in the room. // - // Returns the successful session whether keyshare succeeds or not. - // // returns a promise which resolves once the keyshare is successful. const setup = async (oldSession: OutboundSessionInfo | null): Promise => { const sharedHistory = isRoomSharedHistory(room); - const session = await this.prepareSession(devicesInRoom, sharedHistory, oldSession); - try { - await this.shareSession(devicesInRoom, sharedHistory, singleOlmCreationPhase, blocked, session); - } catch (e) { - logger.error(`Failed to ensure outbound session in ${this.roomId}`, e); - } + await this.shareSession(devicesInRoom, sharedHistory, singleOlmCreationPhase, blocked, session); return session; }; // first wait for the previous share to complete - const prom = this.setupPromise.then(setup); + const fallible = this.setupPromise.then(setup); - // Ensure any failures are logged for debugging - prom.catch(e => { + // Ensure any failures are logged for debugging and make sure that the + // promise chain remains unbroken + // + // setupPromise resolves to `null` or the `OutboundSessionInfo` whether + // or not the share succeeds + this.setupPromise = fallible.catch((e) => { logger.error(`Failed to setup outbound session in ${this.roomId}`, e); + return null; }); - // setupPromise resolves to `session` whether or not the share succeeds - this.setupPromise = prom; - // but we return a promise which only resolves if the share was successful. - return prom; + return fallible; } private async prepareSession( @@ -333,8 +323,7 @@ class MegolmEncryption extends EncryptionAlgorithm { if (!session) { logger.log(`Starting new megolm session for room ${this.roomId}`); session = await this.prepareNewSession(sharedHistory); - logger.log(`Started new megolm session ${session.sessionId} ` + - `for room ${this.roomId}`); + logger.log(`Started new megolm session ${session.sessionId} ` + `for room ${this.roomId}`); this.outboundSessions[session.sessionId] = session; } @@ -359,10 +348,7 @@ class MegolmEncryption extends EncryptionAlgorithm { continue; } - if ( - !session.sharedWithDevices[userId] || - session.sharedWithDevices[userId][deviceId] === undefined - ) { + if (!session.sharedWithDevices[userId] || session.sharedWithDevices[userId][deviceId] === undefined) { shareMap[userId] = shareMap[userId] || []; shareMap[userId].push(deviceInfo); } @@ -382,7 +368,9 @@ class MegolmEncryption extends EncryptionAlgorithm { }, }; const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( - this.olmDevice, this.baseApis, shareMap, + this.olmDevice, + this.baseApis, + shareMap, ); await Promise.all([ @@ -407,12 +395,17 @@ class MegolmEncryption extends EncryptionAlgorithm { const start = Date.now(); const failedServers: string[] = []; await this.shareKeyWithDevices( - session, key, payload, devicesWithoutSession, errorDevices, - singleOlmCreationPhase ? 10000 : 2000, failedServers, + session, + key, + payload, + devicesWithoutSession, + errorDevices, + singleOlmCreationPhase ? 10000 : 2000, + failedServers, ); logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this.roomId}`); - if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { + if (!singleOlmCreationPhase && Date.now() - start < 10000) { // perform the second phase of olm session creation if requested, // and if the first phase didn't take too long (async (): Promise => { @@ -422,7 +415,7 @@ class MegolmEncryption extends EncryptionAlgorithm { // do this. We only need to retry users from servers that didn't // respond the first time. const retryDevices: Record = {}; - const failedServerMap = new Set; + const failedServerMap = new Set(); for (const server of failedServers) { failedServerMap.add(server); } @@ -440,9 +433,7 @@ class MegolmEncryption extends EncryptionAlgorithm { } logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this.roomId}`); - await this.shareKeyWithDevices( - session, key, payload, retryDevices, failedDevices, 30000, - ); + await this.shareKeyWithDevices(session, key, payload, retryDevices, failedDevices, 30000); logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this.roomId}`); await this.notifyFailedOlmDevices(session, key, failedDevices); @@ -453,8 +444,10 @@ class MegolmEncryption extends EncryptionAlgorithm { logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this.roomId}`); })(), (async (): Promise => { - logger.debug(`There are ${Object.entries(blocked).length} blocked devices in ${this.roomId}`, - Object.entries(blocked)); + logger.debug( + `There are ${Object.entries(blocked).length} blocked devices in ${this.roomId}`, + Object.entries(blocked), + ); // also, notify newly blocked devices that they're blocked logger.debug(`Notifying newly blocked devices in ${this.roomId}`); @@ -480,19 +473,23 @@ class MegolmEncryption extends EncryptionAlgorithm { } /** - * @private + * @internal * - * @param {boolean} sharedHistory * - * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * @returns session */ private async prepareNewSession(sharedHistory: boolean): Promise { const sessionId = this.olmDevice.createOutboundGroupSession(); const key = this.olmDevice.getOutboundGroupSessionKey(sessionId); await this.olmDevice.addInboundGroupSession( - this.roomId, this.olmDevice.deviceCurve25519Key!, [], sessionId, - key.key, { ed25519: this.olmDevice.deviceEd25519Key! }, false, + this.roomId, + this.olmDevice.deviceCurve25519Key!, + [], + sessionId, + key.key, + { ed25519: this.olmDevice.deviceEd25519Key! }, + false, { sharedHistory }, ); @@ -506,15 +503,15 @@ class MegolmEncryption extends EncryptionAlgorithm { * Determines what devices in devicesByUser don't have an olm session as given * in devicemap. * - * @private + * @internal * - * @param {object} devicemap the devices that have olm sessions, as returned by + * @param devicemap - the devices that have olm sessions, as returned by * olmlib.ensureOlmSessionsForDevices. - * @param {object} devicesByUser a map of user IDs to array of deviceInfo - * @param {array} [noOlmDevices] an array to fill with devices that don't have + * @param devicesByUser - a map of user IDs to array of deviceInfo + * @param noOlmDevices - an array to fill with devices that don't have * olm sessions * - * @return {array} an array of devices that don't have olm sessions. If + * @returns an array of devices that don't have olm sessions. If * noOlmDevices is specified, then noOlmDevices will be returned. */ private getDevicesWithoutSessions( @@ -550,11 +547,11 @@ class MegolmEncryption extends EncryptionAlgorithm { * Splits the user device map into multiple chunks to reduce the number of * devices we encrypt to per API call. * - * @private + * @internal * - * @param {object} devicesByUser map from userid to list of devices + * @param devicesByUser - map from userid to list of devices * - * @return {array>} the blocked devices, split into chunks + * @returns the blocked devices, split into chunks */ private splitDevices( devicesByUser: Record>, @@ -591,18 +588,16 @@ class MegolmEncryption extends EncryptionAlgorithm { } /** - * @private + * @internal * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session * - * @param {number} chainIndex current chain index + * @param chainIndex - current chain index * - * @param {object} userDeviceMap - * mapping from userId to deviceInfo + * @param userDeviceMap - mapping from userId to deviceInfo * - * @param {object} payload fields to include in the encrypted payload + * @param payload - fields to include in the encrypted payload * - * @return {Promise} Promise which resolves once the key sharing + * @returns Promise which resolves once the key sharing * for the given userDeviceMap is generated and has been sent. */ private encryptAndSendKeysToDevices( @@ -611,35 +606,34 @@ class MegolmEncryption extends EncryptionAlgorithm { devices: IOlmDevice[], payload: IPayload, ): Promise { - return this.crypto.encryptAndSendToDevices( - devices, - payload, - ).then(() => { - // store that we successfully uploaded the keys of the current slice - for (const device of devices) { - session.markSharedWithDevice( - device.userId, - device.deviceInfo.deviceId, - device.deviceInfo.getIdentityKey(), - chainIndex, - ); - } - }).catch((error) => { - logger.error("failed to encryptAndSendToDevices", error); - throw error; - }); + return this.crypto + .encryptAndSendToDevices(devices, payload) + .then(() => { + // store that we successfully uploaded the keys of the current slice + for (const device of devices) { + session.markSharedWithDevice( + device.userId, + device.deviceInfo.deviceId, + device.deviceInfo.getIdentityKey(), + chainIndex, + ); + } + }) + .catch((error) => { + logger.error("failed to encryptAndSendToDevices", error); + throw error; + }); } /** - * @private + * @internal * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session * - * @param {array} userDeviceMap list of blocked devices to notify + * @param userDeviceMap - list of blocked devices to notify * - * @param {object} payload fields to include in the notification payload + * @param payload - fields to include in the notification payload * - * @return {Promise} Promise which resolves once the notifications + * @returns Promise which resolves once the notifications * for the given userDeviceMap is generated and has been sent. */ private async sendBlockedNotificationsToDevices( @@ -655,9 +649,13 @@ class MegolmEncryption extends EncryptionAlgorithm { const deviceInfo = blockedInfo.deviceInfo; const deviceId = deviceInfo.deviceId; - const message = Object.assign({}, payload); - message.code = blockedInfo.code; - message.reason = blockedInfo.reason; + const message = { + ...payload, + code: blockedInfo.code, + reason: blockedInfo.reason, + [ToDeviceMessageId]: uuidv4(), + }; + if (message.code === "m.no_olm") { delete message.room_id; delete message.session_id; @@ -683,10 +681,10 @@ class MegolmEncryption extends EncryptionAlgorithm { * Re-shares a megolm session key with devices if the key has already been * sent to them. * - * @param {string} senderKey The key of the originating device for the session - * @param {string} sessionId ID of the outbound session to share - * @param {string} userId ID of the user who owns the target device - * @param {module:crypto/deviceinfo} device The target device + * @param senderKey - The key of the originating device for the session + * @param sessionId - ID of the outbound session to share + * @param userId - ID of the user who owns the target device + * @param device - The target device */ public async reshareKeyWithDevice( senderKey: string, @@ -716,7 +714,7 @@ class MegolmEncryption extends EncryptionAlgorithm { if (sessionSharedData.deviceKey !== device.getIdentityKey()) { logger.warn( `Megolm session ${senderKey}|${sessionId} has been shared with device ${device.deviceId} but ` + - `with identity key ${sessionSharedData.deviceKey}. Key is now ${device.getIdentityKey()}!`, + `with identity key ${sessionSharedData.deviceKey}. Key is now ${device.getIdentityKey()}!`, ); return; } @@ -724,7 +722,10 @@ class MegolmEncryption extends EncryptionAlgorithm { // get the key from the inbound session: the outbound one will already // have been ratcheted to the next chain index. const key = await this.olmDevice.getInboundGroupSessionKey( - this.roomId, senderKey, sessionId, sessionSharedData.messageIndex, + this.roomId, + senderKey, + sessionId, + sessionSharedData.messageIndex, ); if (!key) { @@ -734,11 +735,9 @@ class MegolmEncryption extends EncryptionAlgorithm { return; } - await olmlib.ensureOlmSessionsForDevices( - this.olmDevice, this.baseApis, { - [userId]: [device], - }, - ); + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, { + [userId]: [device], + }); const payload = { type: "m.forwarded_room_key", @@ -755,10 +754,11 @@ class MegolmEncryption extends EncryptionAlgorithm { }, }; - const encryptedContent = { + const encryptedContent: IEncryptedContent = { algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key, + sender_key: this.olmDevice.deviceCurve25519Key!, ciphertext: {}, + [ToDeviceMessageId]: uuidv4(), }; await olmlib.encryptMessageForDevice( encryptedContent.ciphertext, @@ -779,26 +779,23 @@ class MegolmEncryption extends EncryptionAlgorithm { } /** - * @private + * @internal * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session * - * @param {object} key the session key as returned by + * @param key - the session key as returned by * OlmDevice.getOutboundGroupSessionKey * - * @param {object} payload the base to-device message payload for sharing keys + * @param payload - the base to-device message payload for sharing keys * - * @param {object} devicesByUser - * map from userid to list of devices + * @param devicesByUser - map from userid to list of devices * - * @param {array} errorDevices - * array that will be populated with the devices that we can't get an + * @param errorDevices - array that will be populated with the devices that we can't get an * olm session for * - * @param {Number} [otkTimeout] The timeout in milliseconds when requesting + * @param otkTimeout - The timeout in milliseconds when requesting * one-time keys for establishing new olm sessions. * - * @param {Array} [failedServers] An array to fill with remote servers that + * @param failedServers - An array to fill with remote servers that * failed to respond to one-time-key requests. */ private async shareKeyWithDevices( @@ -812,7 +809,12 @@ class MegolmEncryption extends EncryptionAlgorithm { ): Promise { logger.debug(`Ensuring Olm sessions for devices in ${this.roomId}`); const devicemap = await olmlib.ensureOlmSessionsForDevices( - this.olmDevice, this.baseApis, devicesByUser, false, otkTimeout, failedServers, + this.olmDevice, + this.baseApis, + devicesByUser, + false, + otkTimeout, + failedServers, logger.withPrefix?.(`[${this.roomId}]`), ); logger.debug(`Ensured Olm sessions for devices in ${this.roomId}`); @@ -834,14 +836,13 @@ class MegolmEncryption extends EncryptionAlgorithm { for (let i = 0; i < userDeviceMaps.length; i++) { const taskDetail = - `megolm keys for ${session.sessionId} ` + - `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`; + `megolm keys for ${session.sessionId} ` + `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`; try { - logger.debug(`Sharing ${taskDetail}`, - userDeviceMaps[i].map((d) => `${d.userId}/${d.deviceInfo.deviceId}`)); - await this.encryptAndSendKeysToDevices( - session, key.chain_index, userDeviceMaps[i], payload, + logger.debug( + `Sharing ${taskDetail}`, + userDeviceMaps[i].map((d) => `${d.userId}/${d.deviceInfo.deviceId}`), ); + await this.encryptAndSendKeysToDevices(session, key.chain_index, userDeviceMaps[i], payload); logger.debug(`Shared ${taskDetail}`); } catch (e) { logger.error(`Failed to share ${taskDetail}`); @@ -853,11 +854,9 @@ class MegolmEncryption extends EncryptionAlgorithm { /** * Notify devices that we weren't able to create olm sessions. * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session * - * @param {object} key * - * @param {Array} failedDevices the devices that we were unable to + * @param failedDevices - the devices that we were unable to * create olm sessions for, as returned by shareKeyWithDevices */ private async notifyFailedOlmDevices( @@ -866,8 +865,7 @@ class MegolmEncryption extends EncryptionAlgorithm { failedDevices: IOlmDevice[], ): Promise { logger.debug( - `Notifying ${failedDevices.length} devices we failed to ` + - `create Olm sessions in ${this.roomId}`, + `Notifying ${failedDevices.length} devices we failed to ` + `create Olm sessions in ${this.roomId}`, ); // mark the devices that failed as "handled" because we don't want to try @@ -875,18 +873,13 @@ class MegolmEncryption extends EncryptionAlgorithm { for (const { userId, deviceInfo } of failedDevices) { const deviceId = deviceInfo.deviceId; - session.markSharedWithDevice( - userId, deviceId, deviceInfo.getIdentityKey(), key.chain_index, - ); + session.markSharedWithDevice(userId, deviceId, deviceInfo.getIdentityKey(), key.chain_index); } - const unnotifiedFailedDevices = - await this.olmDevice.filterOutNotifiedErrorDevices( - failedDevices, - ); + const unnotifiedFailedDevices = await this.olmDevice.filterOutNotifiedErrorDevices(failedDevices); logger.debug( `Need to notify ${unnotifiedFailedDevices.length} failed devices ` + - `which haven't been notified before in ${this.roomId}`, + `which haven't been notified before in ${this.roomId}`, ); const blockedMap: Record> = {}; for (const { userId, deviceInfo } of unnotifiedFailedDevices) { @@ -907,17 +900,15 @@ class MegolmEncryption extends EncryptionAlgorithm { await this.notifyBlockedDevices(session, blockedMap); logger.debug( `Notified ${unnotifiedFailedDevices.length} devices we failed to ` + - `create Olm sessions in ${this.roomId}`, + `create Olm sessions in ${this.roomId}`, ); } /** * Notify blocked devices that they have been blocked. * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session * - * @param {object} devicesByUser - * map from userid to device ID to blocked data + * @param devicesByUser - map from userid to device ID to blocked data */ private async notifyBlockedDevices( session: OutboundSessionInfo, @@ -935,11 +926,15 @@ class MegolmEncryption extends EncryptionAlgorithm { for (let i = 0; i < userDeviceMaps.length; i++) { try { await this.sendBlockedNotificationsToDevices(session, userDeviceMaps[i], payload); - logger.log(`Completed blacklist notification for ${session.sessionId} ` - + `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`); + logger.log( + `Completed blacklist notification for ${session.sessionId} ` + + `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`, + ); } catch (e) { - logger.log(`blacklist notification for ${session.sessionId} in ` - + `${this.roomId} (slice ${i + 1}/${userDeviceMaps.length}) failed`); + logger.log( + `blacklist notification for ${session.sessionId} in ` + + `${this.roomId} (slice ${i + 1}/${userDeviceMaps.length}) failed`, + ); throw e; } @@ -950,7 +945,7 @@ class MegolmEncryption extends EncryptionAlgorithm { * Perform any background tasks that can be done before a message is ready to * send, in order to speed up sending of the message. * - * @param {module:models/room} room the room the event is in + * @param room - the room the event is in */ public prepareToEncrypt(room: Room): void { if (this.encryptionPreparation != null) { @@ -959,8 +954,7 @@ class MegolmEncryption extends EncryptionAlgorithm { // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) const elapsedTime = Date.now() - this.encryptionPreparation.startTime; logger.debug( - `Already started preparing to encrypt for ${this.roomId} ` + - `${elapsedTime} ms ago, skipping`, + `Already started preparing to encrypt for ${this.roomId} ` + `${elapsedTime} ms ago, skipping`, ); return; } @@ -995,15 +989,11 @@ class MegolmEncryption extends EncryptionAlgorithm { } /** - * @inheritdoc + * @param content - plaintext event content * - * @param {module:models/room} room - * @param {string} eventType - * @param {object} content plaintext event content - * - * @return {Promise} Promise which resolves to the new event body + * @returns Promise which resolves to the new event body */ - public async encryptMessage(room: Room, eventType: string, content: object): Promise { + public async encryptMessage(room: Room, eventType: string, content: IContent): Promise { logger.log(`Starting to encrypt event for ${this.roomId}`); if (this.encryptionPreparation != null) { @@ -1038,12 +1028,10 @@ class MegolmEncryption extends EncryptionAlgorithm { content: content, }; - const ciphertext = this.olmDevice.encryptGroupMessage( - session.sessionId, JSON.stringify(payloadJson), - ); - const encryptedContent = { + const ciphertext = this.olmDevice.encryptGroupMessage(session.sessionId, JSON.stringify(payloadJson)); + const encryptedContent: IEncryptedContent = { algorithm: olmlib.MEGOLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key, + sender_key: this.olmDevice.deviceCurve25519Key!, ciphertext: ciphertext, session_id: session.sessionId, // Include our device ID so that recipients can send us a @@ -1057,7 +1045,7 @@ class MegolmEncryption extends EncryptionAlgorithm { return encryptedContent; } - private isVerificationEvent(eventType: string, content: object): boolean { + private isVerificationEvent(eventType: string, content: IContent): boolean { switch (eventType) { case EventType.KeyVerificationCancel: case EventType.KeyVerificationDone: @@ -1069,7 +1057,7 @@ class MegolmEncryption extends EncryptionAlgorithm { return true; } case EventType.RoomMessage: { - return content['msgtype'] === MsgType.KeyVerificationRequest; + return content["msgtype"] === MsgType.KeyVerificationRequest; } default: { return false; @@ -1092,7 +1080,7 @@ class MegolmEncryption extends EncryptionAlgorithm { * unknown to the user. If so, warn the user, and mark them as known to * give the user a chance to go verify them before re-sending this message. * - * @param {Object} devicesInRoom userId -> {deviceId -> object} + * @param devicesInRoom - `userId -> {deviceId -> object}` * devices we should shared the session with. */ private checkForUnknownDevices(devicesInRoom: DeviceInfoMap): void { @@ -1114,7 +1102,9 @@ class MegolmEncryption extends EncryptionAlgorithm { // it'd be kind to pass unknownDevices up to the user in this error throw new UnknownDeviceError( "This room contains unknown devices which have not been verified. " + - "We strongly recommend you verify them before continuing.", unknownDevices); + "We strongly recommend you verify them before continuing.", + unknownDevices, + ); } } @@ -1122,7 +1112,7 @@ class MegolmEncryption extends EncryptionAlgorithm { * Remove unknown devices from a set of devices. The devicesInRoom parameter * will be modified. * - * @param {Object} devicesInRoom userId -> {deviceId -> object} + * @param devicesInRoom - `userId -> {deviceId -> object}` * devices we should shared the session with. */ private removeUnknownDevices(devicesInRoom: DeviceInfoMap): void { @@ -1142,11 +1132,10 @@ class MegolmEncryption extends EncryptionAlgorithm { /** * Get the list of unblocked devices for all users in the room * - * @param {module:models/room} room - * @param forceDistributeToUnverified if set to true will include the unverified devices + * @param forceDistributeToUnverified - if set to true will include the unverified devices * even if setting is set to block them (useful for verification) * - * @return {Promise} Promise which resolves to an array whose + * @returns Promise which resolves to an array whose * first element is a map from userId to deviceId to deviceInfo indicating * the devices that messages should be encrypted to, and whose second * element is a map from userId to deviceId to data indicating the devices @@ -1157,14 +1146,14 @@ class MegolmEncryption extends EncryptionAlgorithm { forceDistributeToUnverified = false, ): Promise<[DeviceInfoMap, IBlockedMap]> { const members = await room.getEncryptionTargetMembers(); - const roomMembers = members.map(function(u) { + const roomMembers = members.map(function (u) { return u.userId; }); // The global value is treated as a default for when rooms don't specify a value. let isBlacklisting = this.crypto.globalBlacklistUnverifiedDevices; const isRoomBlacklisting = room.getBlacklistUnverifiedDevices(); - if (typeof isRoomBlacklisting === 'boolean') { + if (typeof isRoomBlacklisting === "boolean") { isBlacklisting = isRoomBlacklisting; } @@ -1190,7 +1179,8 @@ class MegolmEncryption extends EncryptionAlgorithm { const deviceTrust = this.crypto.checkDeviceTrust(userId, deviceId); - if (userDevices[deviceId].isBlocked() || + if ( + userDevices[deviceId].isBlocked() || (!deviceTrust.isVerified() && isBlacklisting && !forceDistributeToUnverified) ) { if (!blocked[userId]) { @@ -1214,13 +1204,9 @@ class MegolmEncryption extends EncryptionAlgorithm { /** * Megolm decryption implementation * - * @constructor - * @extends {module:crypto/algorithms/DecryptionAlgorithm} - * - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/DecryptionAlgorithm} + * @param params - parameters, as per {@link DecryptionAlgorithm} */ -class MegolmDecryption extends DecryptionAlgorithm { +export class MegolmDecryption extends DecryptionAlgorithm { // 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 @@ -1237,25 +1223,16 @@ class MegolmDecryption extends DecryptionAlgorithm { } /** - * @inheritdoc - * - * @param {MatrixEvent} event - * * returns a promise which resolves to a - * {@link module:crypto~EventDecryptionResult} once we have finished + * {@link EventDecryptionResult} once we have finished * decrypting, or rejects with an `algorithms.DecryptionError` if there is a * problem decrypting the event. */ public async decryptEvent(event: MatrixEvent): Promise { const content = event.getWireContent(); - if (!content.sender_key || !content.session_id || - !content.ciphertext - ) { - throw new DecryptionError( - "MEGOLM_MISSING_FIELDS", - "Missing fields in input", - ); + if (!content.sender_key || !content.session_id || !content.ciphertext) { + throw new DecryptionError("MEGOLM_MISSING_FIELDS", "Missing fields in input"); } // we add the event to the pending list *before* we start decryption. @@ -1268,8 +1245,12 @@ class MegolmDecryption extends DecryptionAlgorithm { let res: IDecryptedGroupMessage | null; try { res = await this.olmDevice.decryptGroupMessage( - event.getRoomId()!, content.sender_key, content.session_id, content.ciphertext, - event.getId()!, event.getTs(), + event.getRoomId()!, + content.sender_key, + content.session_id, + content.ciphertext, + event.getId()!, + event.getTs(), ); } catch (e) { if ((e).name === "DecryptionError") { @@ -1279,18 +1260,15 @@ class MegolmDecryption extends DecryptionAlgorithm { let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR"; - if ((e)?.message === 'OLM.UNKNOWN_MESSAGE_INDEX') { + if ((e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX") { this.requestKeysForEvent(event); - errorCode = 'OLM_UNKNOWN_MESSAGE_INDEX'; + errorCode = "OLM_UNKNOWN_MESSAGE_INDEX"; } - throw new DecryptionError( - errorCode, - e ? e.toString() : "Unknown Error: Error is undefined", { - session: content.sender_key + '|' + content.session_id, - }, - ); + throw new DecryptionError(errorCode, e ? e.toString() : "Unknown Error: Error is undefined", { + session: content.sender_key + "|" + content.session_id, + }); } if (res === null) { @@ -1307,33 +1285,26 @@ class MegolmDecryption extends DecryptionAlgorithm { // See if there was a problem with the olm session at the time the // event was sent. Use a fuzz factor of 2 minutes. - const problem = await this.olmDevice.sessionMayHaveProblems( - content.sender_key, event.getTs() - 120000, - ); + const problem = await this.olmDevice.sessionMayHaveProblems(content.sender_key, event.getTs() - 120000); if (problem) { logger.info( `When handling UISI from ${event.getSender()} (sender key ${content.sender_key}): ` + - `recent session problem with that sender: ${problem}`, + `recent session problem with that sender: ${problem}`, ); let problemDescription = PROBLEM_DESCRIPTIONS[problem.type as "no_olm"] || PROBLEM_DESCRIPTIONS.unknown; if (problem.fixed) { - problemDescription += - " Trying to create a new secure channel and re-requesting the keys."; + problemDescription += " Trying to create a new secure channel and re-requesting the keys."; } - throw new DecryptionError( - "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", - problemDescription, - { - session: content.sender_key + '|' + content.session_id, - }, - ); + throw new DecryptionError("MEGOLM_UNKNOWN_INBOUND_SESSION_ID", problemDescription, { + session: content.sender_key + "|" + content.session_id, + }); } throw new DecryptionError( "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", "The sender's device has not sent us the keys for this message.", { - session: content.sender_key + '|' + content.session_id, + session: content.sender_key + "|" + content.session_id, }, ); } @@ -1352,10 +1323,7 @@ class MegolmDecryption extends DecryptionAlgorithm { // (this is somewhat redundant, since the megolm session is scoped to the // room, so neither the sender nor a MITM can lie about the room_id). if (payload.room_id !== event.getRoomId()) { - throw new DecryptionError( - "MEGOLM_BAD_ROOM", - "Message intended for room " + payload.room_id, - ); + throw new DecryptionError("MEGOLM_BAD_ROOM", "Message intended for room " + payload.room_id); } return { @@ -1372,20 +1340,22 @@ class MegolmDecryption extends DecryptionAlgorithm { const recipients = event.getKeyRequestRecipients(this.userId); - this.crypto.requestRoomKey({ - room_id: event.getRoomId()!, - algorithm: wireContent.algorithm, - sender_key: wireContent.sender_key, - session_id: wireContent.session_id, - }, recipients); + this.crypto.requestRoomKey( + { + room_id: event.getRoomId()!, + algorithm: wireContent.algorithm, + sender_key: wireContent.sender_key, + session_id: wireContent.session_id, + }, + recipients, + ); } /** * Add an event to the list of those awaiting their session keys. * - * @private + * @internal * - * @param {module:models/event.MatrixEvent} event */ private addEventToPendingList(event: MatrixEvent): void { const content = event.getWireContent(); @@ -1404,9 +1374,8 @@ class MegolmDecryption extends DecryptionAlgorithm { /** * Remove an event from the list of those awaiting their session keys. * - * @private + * @internal * - * @param {module:models/event.MatrixEvent} event */ private removeEventFromPendingList(event: MatrixEvent): void { const content = event.getWireContent(); @@ -1427,11 +1396,6 @@ class MegolmDecryption extends DecryptionAlgorithm { } } - /** - * @inheritdoc - * - * @param {module:models/event.MatrixEvent} event key event - */ public async onRoomKeyEvent(event: MatrixEvent): Promise { const content = event.getContent>(); let senderKey = event.getSenderKey()!; @@ -1441,11 +1405,7 @@ class MegolmDecryption extends DecryptionAlgorithm { const extraSessionData: OlmGroupSessionExtraData = {}; - if (!content.room_id || - !content.session_key || - !content.session_id || - !content.algorithm - ) { + if (!content.room_id || !content.session_key || !content.session_id || !content.algorithm) { logger.error("key event is missing fields"); return; } @@ -1460,10 +1420,7 @@ class MegolmDecryption extends DecryptionAlgorithm { } if (event.getType() == "m.forwarded_room_key") { - const deviceInfo = this.crypto.deviceList.getDeviceByIdentityKey( - olmlib.OLM_ALGORITHM, - senderKey, - ); + const deviceInfo = this.crypto.deviceList.getDeviceByIdentityKey(olmlib.OLM_ALGORITHM, senderKey); const senderKeyUser = this.baseApis.crypto!.deviceList.getUserByIdentityKey( olmlib.OLM_ALGORITHM, senderKey, @@ -1472,15 +1429,21 @@ class MegolmDecryption extends DecryptionAlgorithm { 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 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() || + const fromInviter = + memberEvent?.getSender() === event.getSender() || (memberEvent?.getUnsigned()?.prev_sender === event.getSender() && memberEvent?.getPrevContent()?.membership === "invite"); const fromUs = event.getSender() === this.baseApis.getUserId(); @@ -1504,8 +1467,9 @@ class MegolmDecryption extends DecryptionAlgorithm { } exportFormat = true; - forwardingKeyChain = Array.isArray(content.forwarding_curve25519_key_chain) ? - content.forwarding_curve25519_key_chain : []; + forwardingKeyChain = Array.isArray(content.forwarding_curve25519_key_chain) + ? content.forwarding_curve25519_key_chain + : []; // copy content before we modify it forwardingKeyChain = forwardingKeyChain.slice(); @@ -1518,9 +1482,7 @@ class MegolmDecryption extends DecryptionAlgorithm { const ed25519Key = content.sender_claimed_ed25519_key; if (!ed25519Key) { - logger.error( - `forwarded_room_key_event is missing sender_claimed_ed25519_key field`, - ); + logger.error(`forwarded_room_key_event is missing sender_claimed_ed25519_key field`); return; } @@ -1541,18 +1503,16 @@ class MegolmDecryption extends DecryptionAlgorithm { forwardingCurve25519KeyChain: forwardingKeyChain, }; await this.crypto.cryptoStore.doTxn( - 'readwrite', - ['parked_shared_history'], + "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, - ) ?? undefined; + const sendingDevice = + this.crypto.deviceList.getDeviceByIdentityKey(olmlib.OLM_ALGORITHM, senderKey) ?? undefined; const deviceTrust = this.crypto.checkDeviceInfoTrust(event.getSender()!, sendingDevice); if (fromUs && !deviceTrust.isVerified()) { @@ -1608,9 +1568,7 @@ class MegolmDecryption extends DecryptionAlgorithm { } /** - * @inheritdoc - * - * @param {module:models/event.MatrixEvent} event key event + * @param event - key event */ public async onRoomKeyWithheldEvent(event: MatrixEvent): Promise { const content = event.getContent(); @@ -1618,9 +1576,7 @@ class MegolmDecryption extends DecryptionAlgorithm { if (content.code === "m.no_olm") { const sender = event.getSender()!; - logger.warn( - `${sender}:${senderKey} was unable to establish an olm session with us`, - ); + logger.warn(`${sender}:${senderKey} was unable to establish an olm session with us`); // if the sender says that they haven't been able to establish an olm // session, let's proactively establish one @@ -1637,21 +1593,14 @@ class MegolmDecryption extends DecryptionAlgorithm { this.retryDecryptionFromSender(senderKey); return; } - let device = this.crypto.deviceList.getDeviceByIdentityKey( - content.algorithm, senderKey, - ); + let device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey); if (!device) { // if we don't know about the device, fetch the user's devices again // and retry before giving up await this.crypto.downloadKeys([sender], false); - device = this.crypto.deviceList.getDeviceByIdentityKey( - content.algorithm, senderKey, - ); + device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey); if (!device) { - logger.info( - "Couldn't find device for identity key " + senderKey + - ": not establishing session", - ); + logger.info("Couldn't find device for identity key " + senderKey + ": not establishing session"); await this.olmDevice.recordSessionProblem(senderKey, "no_olm", false); this.retryDecryptionFromSender(senderKey); return; @@ -1660,13 +1609,12 @@ class MegolmDecryption extends DecryptionAlgorithm { // XXX: switch this to use encryptAndSendToDevices() rather than duplicating it? - await olmlib.ensureOlmSessionsForDevices( - this.olmDevice, this.baseApis, { [sender]: [device] }, false, - ); - const encryptedContent = { + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, { [sender]: [device] }, false); + const encryptedContent: IEncryptedContent = { algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key, + sender_key: this.olmDevice.deviceCurve25519Key!, ciphertext: {}, + [ToDeviceMessageId]: uuidv4(), }; await olmlib.encryptMessageForDevice( encryptedContent.ciphertext, @@ -1688,15 +1636,15 @@ class MegolmDecryption extends DecryptionAlgorithm { }); } else { await this.olmDevice.addInboundGroupSessionWithheld( - content.room_id, senderKey, content.session_id, content.code, + content.room_id, + senderKey, + content.session_id, + content.code, content.reason, ); } } - /** - * @inheritdoc - */ public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise { const body = keyRequest.requestBody; @@ -1708,9 +1656,6 @@ class MegolmDecryption extends DecryptionAlgorithm { ); } - /** - * @inheritdoc - */ public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { const userId = keyRequest.userId; const deviceId = keyRequest.deviceId; @@ -1719,56 +1664,63 @@ class MegolmDecryption extends DecryptionAlgorithm { // XXX: switch this to use encryptAndSendToDevices()? - this.olmlib.ensureOlmSessionsForDevices( - this.olmDevice, this.baseApis, { + this.olmlib + .ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, { [userId]: [deviceInfo], - }, - ).then((devicemap) => { - const olmSessionResult = devicemap[userId][deviceId]; - if (!olmSessionResult.sessionId) { - // no session with this device, probably because there - // were no one-time keys. - // - // ensureOlmSessionsForUsers has already done the logging, - // so just skip it. - return null; - } - - logger.log( - "sharing keys for session " + body.sender_key + "|" - + body.session_id + " with device " - + userId + ":" + deviceId, - ); + }) + .then((devicemap) => { + const olmSessionResult = devicemap[userId][deviceId]; + if (!olmSessionResult.sessionId) { + // no session with this device, probably because there + // were no one-time keys. + // + // ensureOlmSessionsForUsers has already done the logging, + // so just skip it. + return null; + } - return this.buildKeyForwardingMessage( - body.room_id, body.sender_key, body.session_id, - ); - }).then((payload) => { - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; + logger.log( + "sharing keys for session " + + body.sender_key + + "|" + + body.session_id + + " with device " + + userId + + ":" + + deviceId, + ); - return this.olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this.userId, - undefined, - this.olmDevice, - userId, - deviceInfo, - payload!, - ).then(() => { - const contentMap = { - [userId]: { - [deviceId]: encryptedContent, - }, + return this.buildKeyForwardingMessage(body.room_id, body.sender_key, body.session_id); + }) + .then((payload) => { + const encryptedContent: IEncryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key!, + ciphertext: {}, + [ToDeviceMessageId]: uuidv4(), }; - // TODO: retries - return this.baseApis.sendToDevice("m.room.encrypted", contentMap); + return this.olmlib + .encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + undefined, + this.olmDevice, + userId, + deviceInfo, + payload!, + ) + .then(() => { + const contentMap = { + [userId]: { + [deviceId]: encryptedContent, + }, + }; + + // TODO: retries + return this.baseApis.sendToDevice("m.room.encrypted", contentMap); + }); }); - }); } private async buildKeyForwardingMessage( @@ -1795,60 +1747,54 @@ class MegolmDecryption extends DecryptionAlgorithm { } /** - * @inheritdoc - * - * @param {module:crypto/OlmDevice.MegolmSessionData} session - * @param {object} [opts={}] options for the import - * @param {boolean} [opts.untrusted] whether the key should be considered as untrusted - * @param {string} [opts.source] where the key came from + * @param untrusted - whether the key should be considered as untrusted + * @param source - where the key came from */ public importRoomKey( session: IMegolmSessionData, - opts: { untrusted?: boolean, source?: string } = {}, + { untrusted, source }: { untrusted?: boolean; source?: string } = {}, ): Promise { const extraSessionData: OlmGroupSessionExtraData = {}; - if (opts.untrusted || session.untrusted) { + if (untrusted || session.untrusted) { extraSessionData.untrusted = true; } if (session["org.matrix.msc3061.shared_history"]) { extraSessionData.sharedHistory = true; } - return this.olmDevice.addInboundGroupSession( - session.room_id, - session.sender_key, - session.forwarding_curve25519_key_chain, - session.session_id, - session.session_key, - session.sender_claimed_keys, - true, - extraSessionData, - ).then(() => { - if (opts.source !== "backup") { - // don't wait for it to complete - this.crypto.backupManager.backupGroupSession( - session.sender_key, session.session_id, - ).catch((e) => { - // This throws if the upload failed, but this is fine - // since it will have written it to the db and will retry. - logger.log("Failed to back up megolm session", e); - }); - } - // have another go at decrypting events sent with this session. - this.retryDecryption(session.sender_key, session.session_id, !extraSessionData.untrusted); - }); + return this.olmDevice + .addInboundGroupSession( + session.room_id, + session.sender_key, + session.forwarding_curve25519_key_chain, + session.session_id, + session.session_key, + session.sender_claimed_keys, + true, + extraSessionData, + ) + .then(() => { + if (source !== "backup") { + // don't wait for it to complete + this.crypto.backupManager.backupGroupSession(session.sender_key, session.session_id).catch((e) => { + // This throws if the upload failed, but this is fine + // since it will have written it to the db and will retry. + logger.log("Failed to back up megolm session", e); + }); + } + // have another go at decrypting events sent with this session. + this.retryDecryption(session.sender_key, session.session_id, !extraSessionData.untrusted); + }); } /** * Have another go at decrypting events after we receive a key. Resolves once * decryption has been re-attempted on all events. * - * @private - * @param {String} senderKey - * @param {String} sessionId - * @param {Boolean} forceRedecryptIfUntrusted whether messages that were already + * @internal + * @param forceRedecryptIfUntrusted - whether messages that were already * successfully decrypted using untrusted keys should be re-decrypted * - * @return {Boolean} whether all messages were successfully + * @returns whether all messages were successfully * decrypted with trusted keys */ private async retryDecryption( @@ -1868,13 +1814,15 @@ class MegolmDecryption extends DecryptionAlgorithm { logger.debug("Retrying decryption on events", [...pending]); - await Promise.all([...pending].map(async (ev) => { - try { - await ev.attemptDecryption(this.crypto, { isRetry: true, forceRedecryptIfUntrusted }); - } catch (e) { - // don't die if something goes wrong - } - })); + await Promise.all( + [...pending].map(async (ev) => { + try { + await ev.attemptDecryption(this.crypto, { isRetry: true, forceRedecryptIfUntrusted }); + } catch (e) { + // don't die if something goes wrong + } + }), + ); // If decrypted successfully with trusted keys, they'll have // been removed from pendingEvents @@ -1889,15 +1837,19 @@ class MegolmDecryption extends DecryptionAlgorithm { this.pendingEvents.delete(senderKey); - await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => { - await Promise.all([...pending].map(async (ev) => { - try { - await ev.attemptDecryption(this.crypto); - } catch (e) { - // don't die if something goes wrong - } - })); - })); + await Promise.all( + [...senderPendingEvents].map(async ([_sessionId, pending]) => { + await Promise.all( + [...pending].map(async (ev) => { + try { + await ev.attemptDecryption(this.crypto); + } catch (e) { + // don't die if something goes wrong + } + }), + ); + }), + ); return !this.pendingEvents.has(senderKey); } @@ -1923,6 +1875,7 @@ class MegolmDecryption extends DecryptionAlgorithm { algorithm: olmlib.OLM_ALGORITHM, sender_key: this.olmDevice.deviceCurve25519Key!, ciphertext: {}, + [ToDeviceMessageId]: uuidv4(), }; contentMap[userId][deviceInfo.deviceId] = encryptedContent; promises.push( @@ -1947,10 +1900,7 @@ class MegolmDecryption extends DecryptionAlgorithm { for (const userId of Object.keys(contentMap)) { for (const deviceId of Object.keys(contentMap[userId])) { if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { - logger.log( - "No ciphertext for device " + - userId + ":" + deviceId + ": pruning", - ); + logger.log("No ciphertext for device " + userId + ":" + deviceId + ": pruning"); delete contentMap[userId][deviceId]; } } diff --git a/src/crypto/algorithms/olm.ts b/src/crypto/algorithms/olm.ts index 682aa4c7c87..1a795545916 100644 --- a/src/crypto/algorithms/olm.ts +++ b/src/crypto/algorithms/olm.ts @@ -16,27 +16,21 @@ limitations under the License. /** * Defines m.olm encryption/decryption - * - * @module crypto/algorithms/olm */ -import { logger } from '../../logger'; +import type { IEventDecryptionResult } from "../../@types/crypto"; +import { logger } from "../../logger"; import * as olmlib from "../olmlib"; import { DeviceInfo } from "../deviceinfo"; -import { - DecryptionAlgorithm, - DecryptionError, - EncryptionAlgorithm, - registerAlgorithm, -} from "./base"; -import { Room } from '../../models/room'; -import { MatrixEvent } from "../../models/event"; -import { IEventDecryptionResult } from "../index"; +import { DecryptionAlgorithm, DecryptionError, EncryptionAlgorithm, registerAlgorithm } from "./base"; +import { Room } from "../../models/room"; +import { IContent, MatrixEvent } from "../../models/event"; +import { IEncryptedContent, IOlmEncryptedContent } from "../index"; import { IInboundSession } from "../OlmDevice"; const DeviceVerification = DeviceInfo.DeviceVerification; -interface IMessage { +export interface IMessage { type: number; body: string; } @@ -44,21 +38,17 @@ interface IMessage { /** * Olm encryption implementation * - * @constructor - * @extends {module:crypto/algorithms/EncryptionAlgorithm} - * - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/EncryptionAlgorithm} + * @param params - parameters, as per {@link EncryptionAlgorithm} */ class OlmEncryption extends EncryptionAlgorithm { private sessionPrepared = false; private prepPromise: Promise | null = null; /** - * @private + * @internal - * @param {string[]} roomMembers list of currently-joined users in the room - * @return {Promise} Promise which resolves when setup is complete + * @param roomMembers - list of currently-joined users in the room + * @returns Promise which resolves when setup is complete */ private ensureSession(roomMembers: string[]): Promise { if (this.prepPromise) { @@ -71,27 +61,27 @@ class OlmEncryption extends EncryptionAlgorithm { return Promise.resolve(); } - this.prepPromise = this.crypto.downloadKeys(roomMembers).then(() => { - return this.crypto.ensureOlmSessionsForUsers(roomMembers); - }).then(() => { - this.sessionPrepared = true; - }).finally(() => { - this.prepPromise = null; - }); + this.prepPromise = this.crypto + .downloadKeys(roomMembers) + .then(() => { + return this.crypto.ensureOlmSessionsForUsers(roomMembers); + }) + .then(() => { + this.sessionPrepared = true; + }) + .finally(() => { + this.prepPromise = null; + }); return this.prepPromise; } /** - * @inheritdoc - * - * @param {module:models/room} room - * @param {string} eventType - * @param {object} content plaintext event content + * @param content - plaintext event content * - * @return {Promise} Promise which resolves to the new event body + * @returns Promise which resolves to the new event body */ - public async encryptMessage(room: Room, eventType: string, content: object): Promise { + public async encryptMessage(room: Room, eventType: string, content: IContent): Promise { // pick the list of recipients based on the membership list. // // TODO: there is a race condition here! What if a new user turns up @@ -99,7 +89,7 @@ class OlmEncryption extends EncryptionAlgorithm { const members = await room.getEncryptionTargetMembers(); - const users = members.map(function(u) { + const users = members.map(function (u) { return u.userId; }); @@ -111,9 +101,9 @@ class OlmEncryption extends EncryptionAlgorithm { content: content, }; - const encryptedContent = { + const encryptedContent: IEncryptedContent = { algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key, + sender_key: this.olmDevice.deviceCurve25519Key!, ciphertext: {}, }; @@ -136,8 +126,12 @@ class OlmEncryption extends EncryptionAlgorithm { promises.push( olmlib.encryptMessageForDevice( encryptedContent.ciphertext, - this.userId, this.deviceId, this.olmDevice, - userId, deviceInfo, payloadFields, + this.userId, + this.deviceId, + this.olmDevice, + userId, + deviceInfo, + payloadFields, ), ); } @@ -150,19 +144,12 @@ class OlmEncryption extends EncryptionAlgorithm { /** * Olm decryption implementation * - * @constructor - * @extends {module:crypto/algorithms/DecryptionAlgorithm} - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/DecryptionAlgorithm} + * @param params - parameters, as per {@link DecryptionAlgorithm} */ class OlmDecryption extends DecryptionAlgorithm { /** - * @inheritdoc - * - * @param {MatrixEvent} event - * * returns a promise which resolves to a - * {@link module:crypto~EventDecryptionResult} once we have finished + * {@link EventDecryptionResult} once we have finished * decrypting. Rejects with an `algorithms.DecryptionError` if there is a * problem decrypting the event. */ @@ -172,17 +159,11 @@ class OlmDecryption extends DecryptionAlgorithm { const ciphertext = content.ciphertext; if (!ciphertext) { - throw new DecryptionError( - "OLM_MISSING_CIPHERTEXT", - "Missing ciphertext", - ); + throw new DecryptionError("OLM_MISSING_CIPHERTEXT", "Missing ciphertext"); } if (!(this.olmDevice.deviceCurve25519Key! in ciphertext)) { - throw new DecryptionError( - "OLM_NOT_INCLUDED_IN_RECIPIENTS", - "Not included in recipients", - ); + throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", "Not included in recipients"); } const message = ciphertext[this.olmDevice.deviceCurve25519Key!]; let payloadString: string; @@ -190,13 +171,10 @@ class OlmDecryption extends DecryptionAlgorithm { try { payloadString = await this.decryptMessage(deviceKey, message); } catch (e) { - throw new DecryptionError( - "OLM_BAD_ENCRYPTED_MESSAGE", - "Bad Encrypted Message", { - sender: deviceKey, - err: e as Error, - }, - ); + throw new DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", "Bad Encrypted Message", { + sender: deviceKey, + err: e as Error, + }); } const payload = JSON.parse(payloadString); @@ -204,20 +182,14 @@ class OlmDecryption extends DecryptionAlgorithm { // check that we were the intended recipient, to avoid unknown-key attack // https://github.com/vector-im/vector-web/issues/2483 if (payload.recipient != this.userId) { - throw new DecryptionError( - "OLM_BAD_RECIPIENT", - "Message was intented for " + payload.recipient, - ); + throw new DecryptionError("OLM_BAD_RECIPIENT", "Message was intented for " + payload.recipient); } if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) { - throw new DecryptionError( - "OLM_BAD_RECIPIENT_KEY", - "Message not intended for this device", { - intended: payload.recipient_keys.ed25519, - our_key: this.olmDevice.deviceEd25519Key!, - }, - ); + throw new DecryptionError("OLM_BAD_RECIPIENT_KEY", "Message not intended for this device", { + intended: payload.recipient_keys.ed25519, + our_key: this.olmDevice.deviceEd25519Key!, + }); } // check that the device that encrypted the event belongs to the user @@ -227,17 +199,11 @@ class OlmDecryption extends DecryptionAlgorithm { // 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, - ); + 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, - }, - ); + 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 @@ -245,22 +211,16 @@ class OlmDecryption extends DecryptionAlgorithm { // (this check is also provided via the sender's embedded ed25519 key, // which is checked elsewhere). if (payload.sender != event.getSender()) { - throw new DecryptionError( - "OLM_FORWARDED_MESSAGE", - "Message forwarded from " + payload.sender, { - reported_sender: event.getSender()!, - }, - ); + throw new DecryptionError("OLM_FORWARDED_MESSAGE", "Message forwarded from " + payload.sender, { + reported_sender: event.getSender()!, + }); } // Olm events intended for a room have a room_id. if (payload.room_id !== event.getRoomId()) { - throw new DecryptionError( - "OLM_BAD_ROOM", - "Message intended for room " + payload.room_id, { - reported_room: event.getRoomId() || "ROOM_ID_UNDEFINED", - }, - ); + throw new DecryptionError("OLM_BAD_ROOM", "Message intended for room " + payload.room_id, { + reported_room: event.getRoomId() || "ROOM_ID_UNDEFINED", + }); } const claimedKeys = payload.keys || {}; @@ -275,10 +235,10 @@ class OlmDecryption extends DecryptionAlgorithm { /** * Attempt to decrypt an Olm message * - * @param {string} theirDeviceIdentityKey Curve25519 identity key of the sender - * @param {object} message message object, with 'type' and 'body' fields + * @param theirDeviceIdentityKey - Curve25519 identity key of the sender + * @param message - message object, with 'type' and 'body' fields * - * @return {string} payload, if decrypted successfully. + * @returns payload, if decrypted successfully. */ private decryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise { // This is a wrapper that serialises decryptions of prekey messages, because @@ -305,16 +265,19 @@ class OlmDecryption extends DecryptionAlgorithm { for (const sessionId of sessionIds) { try { const payload = await this.olmDevice.decryptMessage( - theirDeviceIdentityKey, sessionId, message.type, message.body, - ); - logger.log( - "Decrypted Olm message from " + theirDeviceIdentityKey + - " with session " + sessionId, + theirDeviceIdentityKey, + sessionId, + message.type, + message.body, ); + logger.log("Decrypted Olm message from " + theirDeviceIdentityKey + " with session " + sessionId); return payload; } catch (e) { const foundSession = await this.olmDevice.matchesSession( - theirDeviceIdentityKey, sessionId, message.type, message.body, + theirDeviceIdentityKey, + sessionId, + message.type, + message.body, ); if (foundSession) { @@ -322,7 +285,9 @@ class OlmDecryption extends DecryptionAlgorithm { // session, so it should have worked. throw new Error( "Error decrypting prekey message with existing session id " + - sessionId + ": " + (e).message, + sessionId + + ": " + + (e).message, ); } @@ -341,8 +306,7 @@ class OlmDecryption extends DecryptionAlgorithm { } throw new Error( - "Error decrypting non-prekey message with existing sessions: " + - JSON.stringify(decryptionErrors), + "Error decrypting non-prekey message with existing sessions: " + JSON.stringify(decryptionErrors), ); } @@ -351,21 +315,13 @@ class OlmDecryption extends DecryptionAlgorithm { let res: IInboundSession; try { - res = await this.olmDevice.createInboundSession( - theirDeviceIdentityKey, message.type, message.body, - ); + res = await this.olmDevice.createInboundSession(theirDeviceIdentityKey, message.type, message.body); } catch (e) { decryptionErrors["(new)"] = (e).message; - throw new Error( - "Error decrypting prekey message: " + - JSON.stringify(decryptionErrors), - ); + throw new Error("Error decrypting prekey message: " + JSON.stringify(decryptionErrors)); } - logger.log( - "created new inbound Olm session ID " + - res.session_id + " with " + theirDeviceIdentityKey, - ); + logger.log("created new inbound Olm session ID " + res.session_id + " with " + theirDeviceIdentityKey); return res.payload; } } diff --git a/src/crypto/api.ts b/src/crypto/api.ts index f6487ca913d..468cc993383 100644 --- a/src/crypto/api.ts +++ b/src/crypto/api.ts @@ -66,8 +66,7 @@ export interface IRecoveryKey { export interface ICreateSecretStorageOpts { /** * Function called to await a secret storage key creation flow. - * Returns: - * {Promise} Object with public key metadata, encoded private + * @returns Promise resolving to an object with public key metadata, encoded private * recovery key which should be disposed of after displaying to the user, * and raw private key to avoid round tripping if needed. */ @@ -131,6 +130,7 @@ export interface IImportOpts { } export interface IImportRoomKeysOpts { + /** called with an object that has a "stage" param */ progressCallback?: (stage: IImportOpts) => void; untrusted?: boolean; source?: string; // TODO: Enum diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index f2160165bd8..fe1ae6622af 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -15,8 +15,6 @@ limitations under the License. */ /** - * @module crypto/backup - * * Classes for dealing with key backup. */ @@ -24,12 +22,12 @@ import { MatrixClient } from "../client"; import { logger } from "../logger"; import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib"; import { DeviceInfo } from "./deviceinfo"; -import { DeviceTrustLevel } from './CrossSigning'; -import { keyFromPassphrase } from './key_passphrase'; +import { DeviceTrustLevel } from "./CrossSigning"; +import { keyFromPassphrase } from "./key_passphrase"; import { sleep } from "../utils"; -import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; -import { encodeRecoveryKey } from './recoverykey'; -import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from './aes'; +import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; +import { encodeRecoveryKey } from "./recoverykey"; +import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes"; import { Curve25519SessionData, IAes256AuthData, @@ -93,7 +91,7 @@ interface BackupAlgorithmClass { interface BackupAlgorithm { untrusted: boolean; - encryptSession(data: Record): Promise; + encryptSession(data: Record): Promise; decryptSessions(ciphertexts: Record): Promise; authData: AuthData; keyMatches(key: ArrayLike): Promise; @@ -134,7 +132,7 @@ export class BackupManager { * * Throws an error if a problem is detected. * - * @param {IKeyBackupInfo} info the key backup info + * @param info - the key backup info */ public static checkBackupVersion(info: IKeyBackupInfo): void { const Algorithm = algorithmsByName[info.algorithm]; @@ -229,7 +227,7 @@ export class BackupManager { } let backupInfo: IKeyBackupInfo | undefined; try { - backupInfo = await this.baseApis.getKeyBackupVersion() ?? undefined; + backupInfo = (await this.baseApis.getKeyBackupVersion()) ?? undefined; } catch (e) { logger.log("Error checking for active key backup", e); if ((e).httpStatus === 404) { @@ -254,8 +252,10 @@ export class BackupManager { } else if (trustInfo.usable && this.backupInfo) { // may not be the same version: if not, we should switch if (backupInfo!.version !== this.backupInfo.version) { - logger.log(`On backup version ${this.backupInfo.version} but ` + - `found version ${backupInfo!.version}: switching.`); + logger.log( + `On backup version ${this.backupInfo.version} but ` + + `found version ${backupInfo!.version}: switching.`, + ); this.disableKeyBackup(); await this.enableKeyBackup(backupInfo!); // We're now using a new backup, so schedule all the keys we have to be @@ -276,7 +276,7 @@ export class BackupManager { * Forces a re-check of the key backup and enables/disables it * as appropriate. * - * @return {Object} Object with backup info (as returned by + * @returns Object with backup info (as returned by * getKeyBackupVersion) in backupInfo and * trust information (as returned by isKeyBackupTrusted) * in trustInfo. @@ -294,12 +294,14 @@ export class BackupManager { targetRoomId: string | undefined, targetSessionId: string | undefined, ): Promise { - if (!this.backupInfo) { return; } + if (!this.backupInfo) { + return; + } const now = new Date().getTime(); if ( - !this.sessionLastCheckAttemptedTime[targetSessionId!] - || now - this.sessionLastCheckAttemptedTime[targetSessionId!] > KEY_BACKUP_CHECK_RATE_LIMIT + !this.sessionLastCheckAttemptedTime[targetSessionId!] || + now - this.sessionLastCheckAttemptedTime[targetSessionId!] > KEY_BACKUP_CHECK_RATE_LIMIT ) { this.sessionLastCheckAttemptedTime[targetSessionId!] = now; await this.baseApis.restoreKeyBackupWithCache(targetRoomId!, targetSessionId!, this.backupInfo, {}); @@ -309,15 +311,7 @@ export class BackupManager { /** * Check if the given backup info is trusted. * - * @param {IKeyBackupInfo} backupInfo key backup info dict from /room_keys/version - * @return {object} { - * usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device - * sigs: [ - * valid: [bool || null], // true: valid, false: invalid, null: cannot attempt validation - * deviceId: [string], - * device: [DeviceInfo || null], - * ] - * } + * @param backupInfo - key backup info dict from /room_keys/version */ public async isKeyBackupTrusted(backupInfo?: IKeyBackupInfo): Promise { const ret = { @@ -326,12 +320,7 @@ export class BackupManager { sigs: [] as SigInfo[], }; - if ( - !backupInfo || - !backupInfo.algorithm || - !backupInfo.auth_data || - !backupInfo.auth_data.signatures - ) { + if (!backupInfo || !backupInfo.algorithm || !backupInfo.auth_data || !backupInfo.auth_data.signatures) { logger.info("Key backup is absent or missing required data"); return ret; } @@ -358,8 +347,8 @@ export class BackupManager { const mySigs = backupInfo.auth_data.signatures[userId] || {}; for (const keyId of Object.keys(mySigs)) { - const keyIdParts = keyId.split(':'); - if (keyIdParts[0] !== 'ed25519') { + const keyIdParts = keyId.split(":"); + if (keyIdParts[0] !== "ed25519") { logger.log("Ignoring unknown signature type: " + keyIdParts[0]); continue; } @@ -381,9 +370,7 @@ export class BackupManager { ); sigInfo.valid = true; } catch (e) { - logger.warn( - "Bad signature from cross signing key " + crossSigningId, e, - ); + logger.warn("Bad signature from cross signing key " + crossSigningId, e); sigInfo.valid = false; } ret.sigs.push(sigInfo); @@ -393,8 +380,7 @@ export class BackupManager { // Now look for a sig from a device // At some point this can probably go away and we'll just support // it being signed by the cross-signing master key - const device = this.baseApis.crypto!.deviceList.getStoredDevice(userId, sigInfo.deviceId, - ); + const device = this.baseApis.crypto!.deviceList.getStoredDevice(userId, sigInfo.deviceId); if (device) { sigInfo.device = device; sigInfo.deviceTrust = this.baseApis.checkDeviceTrust(userId, sigInfo.deviceId); @@ -409,9 +395,16 @@ export class BackupManager { sigInfo.valid = true; } catch (e) { logger.info( - "Bad signature from key ID " + keyId + " userID " + this.baseApis.getUserId() + - " device ID " + device.deviceId + " fingerprint: " + - device.getFingerprint(), backupInfo.auth_data, e, + "Bad signature from key ID " + + keyId + + " userID " + + this.baseApis.getUserId() + + " device ID " + + device.deviceId + + " fingerprint: " + + device.getFingerprint(), + backupInfo.auth_data, + e, ); sigInfo.valid = false; } @@ -423,7 +416,7 @@ export class BackupManager { } ret.usable = ret.sigs.some((s) => { - return s.valid && ((s.device && s.deviceTrust?.isVerified()) || (s.crossSigningId)); + return s.valid && ((s.device && s.deviceTrust?.isVerified()) || s.crossSigningId); }); return ret; } @@ -432,7 +425,7 @@ export class BackupManager { * Schedules sending all keys waiting to be sent to the backup, if not already * scheduled. Retries if necessary. * - * @param maxDelay Maximum delay to wait in ms. 0 means no delay. + * @param maxDelay - Maximum delay to wait in ms. 0 means no delay. */ public async scheduleKeyBackupSend(maxDelay = 10000): Promise { if (this.sendingBackups) return; @@ -451,8 +444,7 @@ export class BackupManager { return; } try { - const numBackedUp = - await this.backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST); + const numBackedUp = await this.backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST); if (numBackedUp === 0) { // no sessions left needing backup: we're done return; @@ -463,8 +455,8 @@ export class BackupManager { logger.log("Key backup request failed", err); if ((err).data) { if ( - (err).data.errcode == 'M_NOT_FOUND' || - (err).data.errcode == 'M_WRONG_ROOM_KEYS_VERSION' + (err).data.errcode == "M_NOT_FOUND" || + (err).data.errcode == "M_WRONG_ROOM_KEYS_VERSION" ) { // Re-check key backup status on error, so we can be // sure to present the current situation when asked. @@ -490,8 +482,8 @@ export class BackupManager { * Take some e2e keys waiting to be backed up and send them * to the backup. * - * @param {number} limit Maximum number of keys to back up - * @returns {number} Number of sessions backed up + * @param limit - Maximum number of keys to back up + * @returns Number of sessions backed up */ public async backupPendingKeys(limit: number): Promise { const sessions = await this.baseApis.crypto!.cryptoStore.getSessionsNeedingBackup(limit); @@ -510,22 +502,21 @@ export class BackupManager { } const sessionData = this.baseApis.crypto!.olmDevice.exportInboundGroupSession( - session.senderKey, session.sessionId, session.sessionData!, + session.senderKey, + session.sessionId, + session.sessionData!, ); sessionData.algorithm = MEGOLM_ALGORITHM; - const forwardedCount = - (sessionData.forwarding_curve25519_key_chain || []).length; + const forwardedCount = (sessionData.forwarding_curve25519_key_chain || []).length; - const userId = this.baseApis.crypto!.deviceList.getUserByIdentityKey( - MEGOLM_ALGORITHM, session.senderKey, - ); - const device = this.baseApis.crypto!.deviceList.getDeviceByIdentityKey( - MEGOLM_ALGORITHM, session.senderKey, - ) ?? undefined; + const userId = this.baseApis.crypto!.deviceList.getUserByIdentityKey(MEGOLM_ALGORITHM, session.senderKey); + const device = + this.baseApis.crypto!.deviceList.getDeviceByIdentityKey(MEGOLM_ALGORITHM, session.senderKey) ?? + undefined; const verified = this.baseApis.crypto!.checkDeviceInfoTrust(userId!, device).isVerified(); - rooms[roomId]['sessions'][session.sessionId] = { + rooms[roomId]["sessions"][session.sessionId] = { first_message_index: sessionData.first_known_index, forwarded_count: forwardedCount, is_verified: verified, @@ -542,13 +533,13 @@ export class BackupManager { return sessions.length; } - public async backupGroupSession( - senderKey: string, sessionId: string, - ): Promise { - await this.baseApis.crypto!.cryptoStore.markSessionsNeedingBackup([{ - senderKey: senderKey, - sessionId: sessionId, - }]); + public async backupGroupSession(senderKey: string, sessionId: string): Promise { + await this.baseApis.crypto!.cryptoStore.markSessionsNeedingBackup([ + { + senderKey: senderKey, + sessionId: sessionId, + }, + ]); if (this.backupInfo) { // don't wait for this to complete: it will delay so @@ -573,16 +564,13 @@ export class BackupManager { /** * Marks all group sessions as needing to be backed up without scheduling * them to upload in the background. - * @returns {Promise} Resolves to the number of sessions now requiring a backup + * @returns Promise which resolves to the number of sessions now requiring a backup * (which will be equal to the number of sessions in the store). */ public async flagAllGroupSessionsForBackup(): Promise { await this.baseApis.crypto!.cryptoStore.doTxn( - 'readwrite', - [ - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, - IndexedDBCryptoStore.STORE_BACKUP, - ], + "readwrite", + [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_BACKUP], (txn) => { this.baseApis.crypto!.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => { if (session !== null) { @@ -599,7 +587,7 @@ export class BackupManager { /** * Counts the number of end to end session keys that are waiting to be backed up - * @returns {Promise} Resolves to the number of sessions requiring backup + * @returns Promise which resolves to the number of sessions requiring backup */ public countSessionsNeedingBackup(): Promise { return this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup(); @@ -615,10 +603,7 @@ export class Curve25519 implements BackupAlgorithm { private getKey: () => Promise, ) {} - public static async init( - authData: AuthData, - getKey: () => Promise, - ): Promise { + public static async init(authData: AuthData, getKey: () => Promise): Promise { if (!authData || !("public_key" in authData)) { throw new Error("auth_data missing required information"); } @@ -627,9 +612,7 @@ export class Curve25519 implements BackupAlgorithm { return new Curve25519(authData as ICurve25519AuthData, publicKey, getKey); } - public static async prepare( - key?: string | Uint8Array | null, - ): Promise<[Uint8Array, AuthData]> { + public static async prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]> { const decryption = new global.Olm.PkDecryption(); try { const authData: Partial = {}; @@ -646,10 +629,7 @@ export class Curve25519 implements BackupAlgorithm { const publicKey = new global.Olm.PkEncryption(); publicKey.set_recipient_key(authData.public_key); - return [ - decryption.get_private_key(), - authData as AuthData, - ]; + return [decryption.get_private_key(), authData as AuthData]; } finally { decryption.free(); } @@ -661,9 +641,11 @@ export class Curve25519 implements BackupAlgorithm { } } - public get untrusted(): boolean { return true; } + public get untrusted(): boolean { + return true; + } - public async encryptSession(data: Record): Promise { + public async encryptSession(data: Record): Promise { const plainText: Record = Object.assign({}, data); delete plainText.session_id; delete plainText.room_id; @@ -687,11 +669,13 @@ export class Curve25519 implements BackupAlgorithm { for (const [sessionId, sessionData] of Object.entries(sessions)) { try { - const decrypted = JSON.parse(decryption.decrypt( - sessionData.session_data.ephemeral, - sessionData.session_data.mac, - sessionData.session_data.ciphertext, - )); + const decrypted = JSON.parse( + decryption.decrypt( + sessionData.session_data.ephemeral, + sessionData.session_data.mac, + sessionData.session_data.ciphertext, + ), + ); decrypted.session_id = sessionId; keys.push(decrypted); } catch (e) { @@ -735,31 +719,23 @@ const UNSTABLE_MSC3270_NAME = new UnstableValue( export class Aes256 implements BackupAlgorithm { public static algorithmName = UNSTABLE_MSC3270_NAME.name; - public constructor( - public readonly authData: IAes256AuthData, - private readonly key: Uint8Array, - ) {} + public constructor(public readonly authData: IAes256AuthData, private readonly key: Uint8Array) {} - public static async init( - authData: IAes256AuthData, - getKey: () => Promise, - ): Promise { + public static async init(authData: IAes256AuthData, getKey: () => Promise): Promise { if (!authData) { throw new Error("auth_data missing"); } const key = await getKey(); if (authData.mac) { const { mac } = await calculateKeyCheck(key, authData.iv); - if (authData.mac.replace(/=+$/g, '') !== mac.replace(/=+/g, '')) { + if (authData.mac.replace(/=+$/g, "") !== mac.replace(/=+/g, "")) { throw new Error("Key does not match"); } } return new Aes256(authData, key); } - public static async prepare( - key?: string | Uint8Array | null, - ): Promise<[Uint8Array, AuthData]> { + public static async prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]> { let outKey: Uint8Array; const authData: Partial = {}; if (!key) { @@ -786,9 +762,11 @@ export class Aes256 implements BackupAlgorithm { } } - public get untrusted(): boolean { return false; } + public get untrusted(): boolean { + return false; + } - public encryptSession(data: Record): Promise { + public encryptSession(data: Record): Promise { const plainText: Record = Object.assign({}, data); delete plainText.session_id; delete plainText.room_id; @@ -816,7 +794,7 @@ export class Aes256 implements BackupAlgorithm { public async keyMatches(key: Uint8Array): Promise { if (this.authData.mac) { const { mac } = await calculateKeyCheck(key, this.authData.iv); - return this.authData.mac.replace(/=+$/g, '') === mac.replace(/=+/g, ''); + return this.authData.mac.replace(/=+$/g, "") === mac.replace(/=+/g, ""); } else { // if we have no information, we have to assume the key is right return true; diff --git a/src/crypto/dehydration.ts b/src/crypto/dehydration.ts index cc6b252da59..f30d2a5329b 100644 --- a/src/crypto/dehydration.ts +++ b/src/crypto/dehydration.ts @@ -16,10 +16,10 @@ limitations under the License. import anotherjson from "another-json"; -import { decodeBase64, encodeBase64 } from './olmlib'; -import { IndexedDBCryptoStore } from '../crypto/store/indexeddb-crypto-store'; -import { decryptAES, encryptAES } from './aes'; -import { logger } from '../logger'; +import { decodeBase64, encodeBase64 } from "./olmlib"; +import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store"; +import { decryptAES, encryptAES } from "./aes"; +import { logger } from "../logger"; import { ISecretStorageKeyInfo } from "./api"; import { Crypto } from "./index"; import { Method } from "../http-api"; @@ -27,7 +27,8 @@ import { ISignatures } from "../@types/signed"; export interface IDehydratedDevice { device_id: string; // eslint-disable-line camelcase - device_data: ISecretStorageKeyInfo & { // eslint-disable-line camelcase + device_data: ISecretStorageKeyInfo & { + // eslint-disable-line camelcase algorithm: string; account: string; // pickle }; @@ -59,7 +60,7 @@ export class DehydrationManager { private inProgress = false; private timeoutId: any; private key?: Uint8Array; - private keyInfo?: {[props: string]: any}; + private keyInfo?: { [props: string]: any }; private deviceDisplayName?: string; public constructor(private readonly crypto: Crypto) { @@ -67,36 +68,31 @@ export class DehydrationManager { } public getDehydrationKeyFromCache(): Promise { - return this.crypto.cryptoStore.doTxn( - 'readonly', - [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this.crypto.cryptoStore.getSecretStorePrivateKey( - txn, - async (result) => { - if (result) { - const { key, keyInfo, deviceDisplayName, time } = result; - const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey); - const decrypted = await decryptAES(key, pickleKey, DEHYDRATION_ALGORITHM); - this.key = decodeBase64(decrypted); - this.keyInfo = keyInfo; - this.deviceDisplayName = deviceDisplayName; - const now = Date.now(); - const delay = Math.max(1, time + oneweek - now); - this.timeoutId = global.setTimeout( - this.dehydrateDevice.bind(this), delay, - ); - } - }, - "dehydration", - ); - }, - ); + return this.crypto.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.crypto.cryptoStore.getSecretStorePrivateKey( + txn, + async (result) => { + if (result) { + const { key, keyInfo, deviceDisplayName, time } = result; + const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey); + const decrypted = await decryptAES(key, pickleKey, DEHYDRATION_ALGORITHM); + this.key = decodeBase64(decrypted); + this.keyInfo = keyInfo; + this.deviceDisplayName = deviceDisplayName; + const now = Date.now(); + const delay = Math.max(1, time + oneweek - now); + this.timeoutId = global.setTimeout(this.dehydrateDevice.bind(this), delay); + } + }, + "dehydration", + ); + }); } /** set the key, and queue periodic dehydration to the server in the background */ public async setKeyAndQueueDehydration( - key: Uint8Array, keyInfo: {[props: string]: any} = {}, + key: Uint8Array, + keyInfo: { [props: string]: any } = {}, deviceDisplayName?: string, ): Promise { const matches = await this.setKey(key, keyInfo, deviceDisplayName); @@ -107,7 +103,8 @@ export class DehydrationManager { } public async setKey( - key: Uint8Array, keyInfo: {[props: string]: any} = {}, + key: Uint8Array, + keyInfo: { [props: string]: any } = {}, deviceDisplayName?: string, ): Promise { if (!key) { @@ -117,15 +114,9 @@ export class DehydrationManager { this.timeoutId = undefined; } // clear storage - await this.crypto.cryptoStore.doTxn( - 'readwrite', - [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this.crypto.cryptoStore.storeSecretStorePrivateKey( - txn, "dehydration", null, - ); - }, - ); + await this.crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", null); + }); this.key = undefined; this.keyInfo = undefined; return; @@ -165,21 +156,14 @@ export class DehydrationManager { // update the crypto store with the timestamp const key = await encryptAES(encodeBase64(this.key!), pickleKey, DEHYDRATION_ALGORITHM); - await this.crypto.cryptoStore.doTxn( - 'readwrite', - [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this.crypto.cryptoStore.storeSecretStorePrivateKey( - txn, "dehydration", - { - keyInfo: this.keyInfo, - key, - deviceDisplayName: this.deviceDisplayName!, - time: Date.now(), - }, - ); - }, - ); + await this.crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", { + keyInfo: this.keyInfo, + key, + deviceDisplayName: this.deviceDisplayName!, + time: Date.now(), + }); + }); logger.log("Attempting to dehydrate device"); logger.log("Creating account"); @@ -199,7 +183,7 @@ export class DehydrationManager { // dehydrate the account and store it on the server const pickledAccount = account.pickle(new Uint8Array(this.key!)); - const deviceData: {[props: string]: any} = { + const deviceData: { [props: string]: any } = { algorithm: DEHYDRATION_ALGORITHM, account: pickledAccount, }; @@ -258,7 +242,7 @@ export class DehydrationManager { } logger.log("Preparing fallback keys"); - const fallbackKeys = {}; + const fallbackKeys: Record = {}; for (const [keyId, key] of Object.entries(fallbacks.curve25519)) { const k: IOneTimeKey = { key, fallback: true }; const signature = account.sign(anotherjson.stringify(k)); @@ -284,9 +268,7 @@ export class DehydrationManager { logger.log("Done dehydrating"); // dehydrate again in a week - this.timeoutId = global.setTimeout( - this.dehydrateDevice.bind(this), oneweek, - ); + this.timeoutId = global.setTimeout(this.dehydrateDevice.bind(this), oneweek); return deviceId; } finally { diff --git a/src/crypto/deviceinfo.ts b/src/crypto/deviceinfo.ts index 3b4d53f6813..b4bb4fd2e80 100644 --- a/src/crypto/deviceinfo.ts +++ b/src/crypto/deviceinfo.ts @@ -16,10 +16,6 @@ limitations under the License. import { ISignatures } from "../@types/signed"; -/** - * @module crypto/deviceinfo - */ - export interface IDevice { keys: Record; algorithms: string[]; @@ -36,70 +32,58 @@ enum DeviceVerification { } /** - * Information about a user's device - * - * @constructor - * @alias module:crypto/deviceinfo - * - * @property {string} deviceId the ID of this device - * - * @property {string[]} algorithms list of algorithms supported by this device - * - * @property {Object.} keys a map from - * <key type>:<id> -> <base64-encoded key>> - * - * @property {module:crypto/deviceinfo.DeviceVerification} verified - * whether the device has been verified/blocked by the user - * - * @property {boolean} known - * whether the user knows of this device's existence (useful when warning - * the user that a user has added new devices) - * - * @property {Object} unsigned additional data from the homeserver - * - * @param {string} deviceId id of the device - */ + * Information about a user's device + */ export class DeviceInfo { /** * rehydrate a DeviceInfo from the session store * - * @param {object} obj raw object from session store - * @param {string} deviceId id of the device + * @param obj - raw object from session store + * @param deviceId - id of the device * - * @return {module:crypto~DeviceInfo} new DeviceInfo + * @returns new DeviceInfo */ public static fromStorage(obj: Partial, deviceId: string): DeviceInfo { const res = new DeviceInfo(deviceId); for (const prop in obj) { if (obj.hasOwnProperty(prop)) { - res[prop] = obj[prop]; + // @ts-ignore - this is messy and typescript doesn't like it + res[prop as keyof IDevice] = obj[prop as keyof IDevice]; } } return res; } - /** - * @enum - */ public static DeviceVerification = { VERIFIED: DeviceVerification.Verified, UNVERIFIED: DeviceVerification.Unverified, BLOCKED: DeviceVerification.Blocked, }; + /** list of algorithms supported by this device */ public algorithms: string[] = []; + /** a map from `: -> ` */ public keys: Record = {}; + /** whether the device has been verified/blocked by the user */ public verified = DeviceVerification.Unverified; + /** + * whether the user knows of this device's existence + * (useful when warning the user that a user has added new devices) + */ public known = false; + /** additional data from the homeserver */ public unsigned: Record = {}; public signatures: ISignatures = {}; + /** + * @param deviceId - id of the device + */ public constructor(public readonly deviceId: string) {} /** * Prepare a DeviceInfo for JSON serialisation in the session store * - * @return {object} deviceinfo with non-serialised members removed + * @returns deviceinfo with non-serialised members removed */ public toStorage(): IDevice { return { @@ -115,7 +99,7 @@ export class DeviceInfo { /** * Get the fingerprint for this device (ie, the Ed25519 key) * - * @return {string} base64-encoded fingerprint of this device + * @returns base64-encoded fingerprint of this device */ public getFingerprint(): string { return this.keys["ed25519:" + this.deviceId]; @@ -124,7 +108,7 @@ export class DeviceInfo { /** * Get the identity key for this device (ie, the Curve25519 key) * - * @return {string} base64-encoded identity key of this device + * @returns base64-encoded identity key of this device */ public getIdentityKey(): string { return this.keys["curve25519:" + this.deviceId]; @@ -133,7 +117,7 @@ export class DeviceInfo { /** * Get the configured display name for this device, if any * - * @return {string?} displayname + * @returns displayname */ public getDisplayName(): string | null { return this.unsigned.device_display_name || null; @@ -142,7 +126,7 @@ export class DeviceInfo { /** * Returns true if this device is blocked * - * @return {Boolean} true if blocked + * @returns true if blocked */ public isBlocked(): boolean { return this.verified == DeviceVerification.Blocked; @@ -151,7 +135,7 @@ export class DeviceInfo { /** * Returns true if this device is verified * - * @return {Boolean} true if verified + * @returns true if verified */ public isVerified(): boolean { return this.verified == DeviceVerification.Verified; @@ -160,7 +144,7 @@ export class DeviceInfo { /** * Returns true if this device is unverified * - * @return {Boolean} true if unverified + * @returns true if unverified */ public isUnverified(): boolean { return this.verified == DeviceVerification.Unverified; @@ -169,7 +153,7 @@ export class DeviceInfo { /** * Returns true if the user knows about this device's existence * - * @return {Boolean} true if known + * @returns true if known */ public isKnown(): boolean { return this.known === true; diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 4a7f73e9bd4..9349550130d 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -17,16 +17,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -/** - * @module crypto - */ - import anotherjson from "another-json"; +import { v4 as uuidv4 } from "uuid"; +import type { IEventDecryptionResult } from "../@types/crypto"; import type { PkDecryption, PkSigning } from "@matrix-org/olm"; -import { EventType } from "../@types/event"; -import { TypedReEmitter } from '../ReEmitter'; -import { logger } from '../logger'; +import { EventType, ToDeviceMessageId } from "../@types/event"; +import { TypedReEmitter } from "../ReEmitter"; +import { logger } from "../logger"; import { IExportedDevice, OlmDevice } from "./OlmDevice"; import { IOlmDevice } from "./algorithms/megolm"; import * as olmlib from "./olmlib"; @@ -34,7 +32,7 @@ import { DeviceInfoMap, DeviceList } from "./DeviceList"; import { DeviceInfo, IDevice } from "./deviceinfo"; import type { DecryptionAlgorithm, EncryptionAlgorithm } from "./algorithms"; import * as algorithms from "./algorithms"; -import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from './CrossSigning'; +import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from "./CrossSigning"; import { EncryptionSetupBuilder } from "./EncryptionSetup"; import { IAccountDataClient, @@ -43,7 +41,7 @@ import { SecretStorage, SecretStorageKeyObject, SecretStorageKeyTuple, -} from './SecretStorage'; +} from "./SecretStorage"; import { IAddSecretStorageKeyOpts, ICreateSecretStorageOpts, @@ -52,25 +50,25 @@ import { IRecoveryKey, ISecretStorageKeyInfo, } from "./api"; -import { OutgoingRoomKeyRequestManager } from './OutgoingRoomKeyRequestManager'; -import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; +import { OutgoingRoomKeyRequestManager } from "./OutgoingRoomKeyRequestManager"; +import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; import { VerificationBase } from "./verification/Base"; -import { ReciprocateQRCode, SCAN_QR_CODE_METHOD, SHOW_QR_CODE_METHOD } from './verification/QRCode'; -import { SAS as SASVerification } from './verification/SAS'; -import { keyFromPassphrase } from './key_passphrase'; -import { decodeRecoveryKey, encodeRecoveryKey } from './recoverykey'; +import { ReciprocateQRCode, SCAN_QR_CODE_METHOD, SHOW_QR_CODE_METHOD } from "./verification/QRCode"; +import { SAS as SASVerification } from "./verification/SAS"; +import { keyFromPassphrase } from "./key_passphrase"; +import { decodeRecoveryKey, encodeRecoveryKey } from "./recoverykey"; import { VerificationRequest } from "./verification/request/VerificationRequest"; import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel"; import { ToDeviceChannel, ToDeviceRequests, Request } from "./verification/request/ToDeviceChannel"; import { IllegalMethod } from "./verification/IllegalMethod"; import { KeySignatureUploadError } from "../errors"; -import { calculateKeyCheck, decryptAES, encryptAES } from './aes'; -import { DehydrationManager, IDeviceKeys, IOneTimeKey } from './dehydration'; +import { calculateKeyCheck, decryptAES, encryptAES } from "./aes"; +import { DehydrationManager, IDeviceKeys, IOneTimeKey } from "./dehydration"; import { BackupManager } from "./backup"; import { IStore } from "../store"; import { Room, RoomEvent } from "../models/room"; import { RoomMember, RoomMemberEvent } from "../models/room-member"; -import { EventStatus, IClearEvent, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event"; +import { EventStatus, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event"; import { ToDeviceBatch } from "../models/ToDeviceMessage"; import { ClientEvent, @@ -86,6 +84,11 @@ import { ISyncStateData } from "../sync"; import { CryptoStore } from "./store/base"; import { IVerificationChannel } from "./verification/request/Channel"; import { TypedEventEmitter } from "../models/typed-event-emitter"; +import { IContent } from "../models/event"; +import { ISyncResponse } from "../sync-accumulator"; +import { ISignatures } from "../@types/signed"; +import { IMessage } from "./algorithms/olm"; +import { CryptoBackend } from "../common-crypto/CryptoBackend"; const DeviceVerification = DeviceInfo.DeviceVerification; @@ -98,7 +101,7 @@ const defaultVerificationMethods = { // to start. [SHOW_QR_CODE_METHOD]: IllegalMethod, [SCAN_QR_CODE_METHOD]: IllegalMethod, -}; +} as const; /** * verification method names @@ -107,7 +110,7 @@ const defaultVerificationMethods = { export const verificationMethods = { RECIPROCATE_QR_CODE: ReciprocateQRCode.NAME, SAS: SASVerification.NAME, -}; +} as const; export type VerificationMethod = keyof typeof verificationMethods | string; @@ -123,30 +126,32 @@ interface IInitOpts { } export interface IBootstrapCrossSigningOpts { + /** Optional. Reset even if keys already exist. */ setupNewCrossSigning?: boolean; + /** + * A function that makes the request requiring auth. Receives the auth data as an object. + * Can be called multiple times, first with an empty authDict, to obtain the flows. + */ authUploadDeviceSigningKeys?(makeRequest: (authData: any) => Promise<{}>): Promise; } export interface ICryptoCallbacks { getCrossSigningKey?: (keyType: string, pubKey: string) => Promise; saveCrossSigningKeys?: (keys: Record) => void; - shouldUpgradeDeviceVerifications?: ( - users: Record - ) => Promise; + shouldUpgradeDeviceVerifications?: (users: Record) => Promise; getSecretStorageKey?: ( - keys: {keys: Record}, name: string + keys: { keys: Record }, + name: string, ) => Promise<[string, Uint8Array] | null>; - cacheSecretStorageKey?: ( - keyId: string, keyInfo: ISecretStorageKeyInfo, key: Uint8Array - ) => void; + cacheSecretStorageKey?: (keyId: string, keyInfo: ISecretStorageKeyInfo, key: Uint8Array) => void; onSecretRequested?: ( - userId: string, deviceId: string, - requestId: string, secretName: string, deviceTrust: DeviceTrustLevel + userId: string, + deviceId: string, + requestId: string, + secretName: string, + deviceTrust: DeviceTrustLevel, ) => Promise; - getDehydrationKey?: ( - keyInfo: ISecretStorageKeyInfo, - checkFunc: (key: Uint8Array) => void, - ) => Promise; + getDehydrationKey?: (keyInfo: ISecretStorageKeyInfo, checkFunc: (key: Uint8Array) => void) => Promise; getBackupKey?: () => Promise; } @@ -156,18 +161,32 @@ interface IRoomKey { algorithm: string; } +/** + * The parameters of a room key request. The details of the request may + * vary with the crypto algorithm, but the management and storage layers for + * outgoing requests expect it to have 'room_id' and 'session_id' properties. + */ export interface IRoomKeyRequestBody extends IRoomKey { session_id: string; sender_key: string; } -export interface IMegolmSessionData { - [key: string]: any; // extensible +interface Extensible { + [key: string]: any; +} + +export interface IMegolmSessionData extends Extensible { + // Sender's Curve25519 device key sender_key: string; + // Devices which forwarded this session to us (normally empty). forwarding_curve25519_key_chain: string[]; + // Other keys the sender claims. sender_claimed_keys: Record; + // Room this session is used in room_id: string; + // Unique id for the session session_id: string; + // Base64'ed key data session_key: string; algorithm?: string; untrusted?: boolean; @@ -183,13 +202,6 @@ export interface ICheckOwnCrossSigningTrustOpts { allowPrivateKeyRequests?: boolean; } -/** - * @typedef {Object} module:crypto~OlmSessionResult - * @property {module:crypto/deviceinfo} device device info - * @property {string?} sessionId base64 olm session id; null if no session - * could be established - */ - interface IUserOlmSession { deviceIdKey: string; sessions: { @@ -198,29 +210,16 @@ interface IUserOlmSession { }[]; } -interface ISyncDeviceLists { - changed: string[]; - left: string[]; -} - export interface IRoomKeyRequestRecipient { userId: string; deviceId: string; } interface ISignableObject { - signatures?: object; + signatures?: ISignatures; unsigned?: object; } -export interface IEventDecryptionResult { - clearEvent: IClearEvent; - forwardingCurve25519KeyChain?: string[]; - senderCurve25519Key?: string; - claimedEd25519Key?: string; - untrusted?: boolean; -} - export interface IRequestsMap { getRequest(event: MatrixEvent): VerificationRequest | undefined; getRequestByChannel(channel: IVerificationChannel): VerificationRequest | undefined; @@ -229,13 +228,25 @@ export interface IRequestsMap { } /* eslint-disable camelcase */ -export interface IEncryptedContent { - algorithm: string; +export interface IOlmEncryptedContent { + algorithm: typeof olmlib.OLM_ALGORITHM; + sender_key: string; + ciphertext: Record; + [ToDeviceMessageId]?: string; +} + +export interface IMegolmEncryptedContent { + algorithm: typeof olmlib.MEGOLM_ALGORITHM; sender_key: string; - ciphertext: Record; + session_id: string; + device_id: string; + ciphertext: string; + [ToDeviceMessageId]?: string; } /* eslint-enable camelcase */ +export type IEncryptedContent = IOlmEncryptedContent | IMegolmEncryptedContent; + export enum CryptoEvent { DeviceVerificationChanged = "deviceVerificationChanged", UserTrustStatusChanged = "userTrustStatusChanged", @@ -254,29 +265,102 @@ export enum CryptoEvent { } export type CryptoEventHandlerMap = { + /** + * Fires when a device is marked as verified/unverified/blocked/unblocked by + * {@link MatrixClient#setDeviceVerified|MatrixClient.setDeviceVerified} or + * {@link MatrixClient#setDeviceBlocked|MatrixClient.setDeviceBlocked}. + * + * @param userId - the owner of the verified device + * @param deviceId - the id of the verified device + * @param deviceInfo - updated device information + */ [CryptoEvent.DeviceVerificationChanged]: (userId: string, deviceId: string, device: DeviceInfo) => void; + /** + * Fires when the trust status of a user changes + * If userId is the userId of the logged-in user, this indicated a change + * in the trust status of the cross-signing data on the account. + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * @experimental + * + * @param userId - the userId of the user in question + * @param trustLevel - The new trust level of the user + */ [CryptoEvent.UserTrustStatusChanged]: (userId: string, trustLevel: UserTrustLevel) => void; + /** + * Fires when we receive a room key request + * + * @param req - request details + */ [CryptoEvent.RoomKeyRequest]: (request: IncomingRoomKeyRequest) => void; + /** + * Fires when we receive a room key request cancellation + */ [CryptoEvent.RoomKeyRequestCancellation]: (request: IncomingRoomKeyRequestCancellation) => void; + /** + * Fires whenever the status of e2e key backup changes, as returned by getKeyBackupEnabled() + * @param enabled - true if key backup has been enabled, otherwise false + * @example + * ``` + * matrixClient.on("crypto.keyBackupStatus", function(enabled){ + * if (enabled) { + * [...] + * } + * }); + * ``` + */ [CryptoEvent.KeyBackupStatus]: (enabled: boolean) => void; [CryptoEvent.KeyBackupFailed]: (errcode: string) => void; [CryptoEvent.KeyBackupSessionsRemaining]: (remaining: number) => void; [CryptoEvent.KeySignatureUploadFailure]: ( failures: IUploadKeySignaturesResponse["failures"], source: "checkOwnCrossSigningTrust" | "afterCrossSigningLocalKeyChange" | "setDeviceVerification", - upload: (opts: { shouldEmit: boolean }) => Promise + upload: (opts: { shouldEmit: boolean }) => Promise, ) => void; + /** + * Fires when a key verification is requested. + */ [CryptoEvent.VerificationRequest]: (request: VerificationRequest) => void; + /** + * Fires when the app may wish to warn the user about something related + * the end-to-end crypto. + * + * @param type - One of the strings listed above + */ [CryptoEvent.Warning]: (type: string) => void; + /** + * Fires when the user's cross-signing keys have changed or cross-signing + * has been enabled/disabled. The client can use getStoredCrossSigningForUser + * with the user ID of the logged in user to check if cross-signing is + * enabled on the account. If enabled, it can test whether the current key + * is trusted using with checkUserTrust with the user ID of the logged + * in user. The checkOwnCrossSigningTrust function may be used to reconcile + * the trust in the account key. + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * @experimental + */ [CryptoEvent.KeysChanged]: (data: {}) => void; + /** + * Fires whenever the stored devices for a user will be updated + * @param users - A list of user IDs that will be updated + * @param initialFetch - If true, the store is empty (apart + * from our own device) and is being seeded. + */ [CryptoEvent.WillUpdateDevices]: (users: string[], initialFetch: boolean) => void; + /** + * Fires whenever the stored devices for a user have changed + * @param users - A list of user IDs that were updated + * @param initialFetch - If true, the store was empty (apart + * from our own device) and has been seeded. + */ [CryptoEvent.DevicesUpdated]: (users: string[], initialFetch: boolean) => void; [CryptoEvent.UserCrossSigningUpdated]: (userId: string) => void; }; -export class Crypto extends TypedEventEmitter { +export class Crypto extends TypedEventEmitter implements CryptoBackend { /** - * @return {string} The version of Olm. + * @returns The version of Olm. */ public static getOlmVersion(): [number, number, number] { return OlmDevice.getOlmVersion(); @@ -350,25 +434,21 @@ export class Crypto extends TypedEventEmitter, + verificationMethods: Array, ) { super(); this.reEmitter = new TypedReEmitter(this); @@ -395,17 +475,16 @@ export class Crypto extends TypedEventEmitter; + this.verificationMethods = new Map(Object.entries(defaultVerificationMethods)) as Map< + VerificationMethod, + typeof VerificationBase + >; } this.backupManager = new BackupManager(baseApis, async () => { @@ -449,7 +528,9 @@ export class Crypto extends TypedEventEmitter { logger.log("Crypto: initialising Olm..."); await global.Olm.init(); - logger.log(exportedOlmDevice - ? "Crypto: initialising Olm device from exported device..." - : "Crypto: initialising Olm device...", + logger.log( + exportedOlmDevice + ? "Crypto: initialising Olm device from exported device..." + : "Crypto: initialising Olm device...", ); await this.olmDevice.init({ fromExportedDevice: exportedOlmDevice, pickleKey }); logger.log("Crypto: loading device list..."); @@ -517,18 +598,15 @@ export class Crypto extends TypedEventEmitter { - this.cryptoStore.getCrossSigningKeys(txn, (keys) => { - // can be an empty object after resetting cross-signing keys, see storeTrustedSelfKeys - if (keys && Object.keys(keys).length !== 0) { - logger.log("Loaded cross-signing public keys from crypto store"); - this.crossSigningInfo.setKeys(keys); - } - }); - }, - ); + await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.cryptoStore.getCrossSigningKeys(txn, (keys) => { + // can be an empty object after resetting cross-signing keys, see storeTrustedSelfKeys + if (keys && Object.keys(keys).length !== 0) { + logger.log("Loaded cross-signing public keys from crypto store"); + this.crossSigningInfo.setKeys(keys); + } + }); + }); // make sure we are keeping track of our own devices // (this is important for key backups & things) this.deviceList.startTrackingDeviceList(this.userId); @@ -544,7 +622,7 @@ export class Crypto extends TypedEventEmitter} Object with public key metadata, encoded private + * @returns Object with public key metadata, encoded private * recovery key which should be disposed of after displaying to the user, * and raw private key to avoid round tripping if needed. */ @@ -615,6 +690,19 @@ export class Crypto extends TypedEventEmitter { + await this.downloadKeys([this.userId]); + return this.deviceList.getStoredCrossSigningForUser(this.userId) !== null; + } + /** * Checks whether cross signing: * - is enabled on this account and trusted by this device @@ -627,14 +715,13 @@ export class Crypto extends TypedEventEmitter { const publicKeysOnDevice = this.crossSigningInfo.getId(); - const privateKeysExistSomewhere = ( - await this.crossSigningInfo.isStoredInKeyCache() || - await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage) - ); + const privateKeysExistSomewhere = + (await this.crossSigningInfo.isStoredInKeyCache()) || + (await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage)); return !!(publicKeysOnDevice && privateKeysExistSomewhere); } @@ -652,23 +739,15 @@ export class Crypto extends TypedEventEmitter { const secretStorageKeyInAccount = await this.secretStorage.hasKey(); - const privateKeysInStorage = await this.crossSigningInfo.isStoredInSecretStorage( - this.secretStorage, - ); - const sessionBackupInStorage = ( - !this.backupManager.getKeyBackupEnabled() || - await this.baseApis.isKeyBackupKeyStored() - ); + const privateKeysInStorage = await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage); + const sessionBackupInStorage = + !this.backupManager.getKeyBackupEnabled() || (await this.baseApis.isKeyBackupKeyStored()); - return !!( - secretStorageKeyInAccount && - privateKeysInStorage && - sessionBackupInStorage - ); + return !!(secretStorageKeyInAccount && privateKeysInStorage && sessionBackupInStorage); } /** @@ -682,12 +761,12 @@ export class Crypto extends TypedEventEmitter} Object with public key metadata, encoded private + * Returns a Promise which resolves to an object with public key metadata, encoded private * recovery key which should be disposed of after displaying to the user, * and raw private key to avoid round tripping if needed. - * @param {object} [opts.keyBackupInfo] The current key backup object. If passed, + * @param keyBackupInfo - The current key backup object. If passed, * the passphrase and recovery key from this backup will be used. - * @param {boolean} [opts.setupNewKeyBackup] If true, a new key backup version will be + * @param setupNewKeyBackup - If true, a new key backup version will be * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo * is supplied. - * @param {boolean} [opts.setupNewSecretStorage] Optional. Reset even if keys already exist. - * @param {func} [opts.getKeyBackupPassphrase] Optional. Function called to get the user's + * @param setupNewSecretStorage - Optional. Reset even if keys already exist. + * @param getKeyBackupPassphrase - Optional. Function called to get the user's * current key backup passphrase. Should return a promise that resolves with a Buffer * containing the key, or rejects if the key cannot be obtained. * Returns: - * {Promise} A promise which resolves to key creation data for + * A promise which resolves to key creation data for * SecretStorage#addKey: an object with `passphrase` etc fields. */ // TODO this does not resolve with what it says it does @@ -854,10 +911,7 @@ export class Crypto extends TypedEventEmitter { logger.log("Bootstrapping Secure Secret Storage"); const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks; - const builder = new EncryptionSetupBuilder( - this.baseApis.store.accountData, - delegateCryptoCallbacks, - ); + const builder = new EncryptionSetupBuilder(this.baseApis.store.accountData, delegateCryptoCallbacks); const secretStorage = new SecretStorage( builder.accountDataClientAdapter, builder.ssssCryptoCallbacks, @@ -887,7 +941,8 @@ export class Crypto extends TypedEventEmitter => { if (!keyInfo.mac) { const key = await this.baseApis.cryptoCallbacks.getSecretStorageKey?.( - { keys: { [keyId]: keyInfo } }, "", + { keys: { [keyId]: keyInfo } }, + "", ); if (key) { const privateKey = key[1]; @@ -896,18 +951,13 @@ export class Crypto extends TypedEventEmitter => { - if ( - this.crossSigningInfo.getId() && - await this.crossSigningInfo.isStoredInKeyCache("master") - ) { + if (this.crossSigningInfo.getId() && (await this.crossSigningInfo.isStoredInKeyCache("master"))) { try { logger.log("Adding cross-signing signature to key backup"); await this.crossSigningInfo.signObject(keyBackupAuthData, "master"); @@ -917,19 +967,14 @@ export class Crypto extends TypedEventEmitter { - let key = await new Promise((resolve) => { // TODO types - this.cryptoStore.doTxn( - 'readonly', - [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this.cryptoStore.getSecretStorePrivateKey( - txn, - resolve, - "m.megolm_backup.v1", - ); - }, - ); + let key = await new Promise((resolve) => { + // TODO types + this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.cryptoStore.getSecretStorePrivateKey(txn, resolve, "m.megolm_backup.v1"); + }); }); // make sure we have a Uint8Array, rather than a string @@ -1188,8 +1216,8 @@ export class Crypto extends TypedEventEmitter): Promise { if (!(key instanceof Uint8Array)) { @@ -1197,13 +1225,9 @@ export class Crypto extends TypedEventEmitter { - this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", encryptedKey); - }, - ); + return this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", encryptedKey); + }); } /** @@ -1211,9 +1235,9 @@ export class Crypto extends TypedEventEmitter => { - return this.baseApis.uploadKeySignatures({ - [this.userId]: { - [this.deviceId]: signedDevice!, - }, - }).then((response) => { - const { failures } = response || {}; - if (Object.keys(failures || []).length > 0) { - if (shouldEmit) { - this.baseApis.emit( - CryptoEvent.KeySignatureUploadFailure, - failures, - "afterCrossSigningLocalKeyChange", - upload, // continuation - ); + return this.baseApis + .uploadKeySignatures({ + [this.userId]: { + [this.deviceId]: signedDevice!, + }, + }) + .then((response) => { + const { failures } = response || {}; + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit( + CryptoEvent.KeySignatureUploadFailure, + failures, + "afterCrossSigningLocalKeyChange", + upload, // continuation + ); + } + throw new KeySignatureUploadError("Key upload failed", { failures }); } - throw new KeySignatureUploadError("Key upload failed", { failures }); - } - logger.info(`Finished background key sig upload for ${this.deviceId}`); - }).catch(e => { - logger.error( - `Error during background key sig upload for ${this.deviceId}`, - e, - ); - }); + logger.info(`Finished background key sig upload for ${this.deviceId}`); + }) + .catch((e) => { + logger.error(`Error during background key sig upload for ${this.deviceId}`, e); + }); }; upload({ shouldEmit: true }); - const shouldUpgradeCb = ( - this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications - ); + const shouldUpgradeCb = this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications; if (shouldUpgradeCb) { logger.info("Starting device verification upgrade"); // Check all users for signatures if upgrade callback present // FIXME: do this in batches const users: Record = {}; - for (const [userId, crossSigningInfo] - of Object.entries(this.deviceList.crossSigningInfo)) { + for (const [userId, crossSigningInfo] of Object.entries(this.deviceList.crossSigningInfo)) { const upgradeInfo = await this.checkForDeviceVerificationUpgrade( - userId, CrossSigningInfo.fromStorage(crossSigningInfo, userId), + userId, + CrossSigningInfo.fromStorage(crossSigningInfo, userId), ); if (upgradeInfo) { users[userId] = upgradeInfo; @@ -1300,9 +1322,7 @@ export class Crypto extends TypedEventEmitter DeviceInfo.fromStorage(devices[deviceId], deviceId), - ), + devices: deviceIds.map((deviceId) => DeviceInfo.fromStorage(devices[deviceId], deviceId)), crossSigningInfo, }; } @@ -1345,9 +1361,9 @@ export class Crypto extends TypedEventEmitter = {}; if (selfSigningChanged) { logger.info("Got new self-signing key", newCrossSigning.getId("self_signing")); } - if ( - allowPrivateKeyRequests && - (selfSigningChanged || selfSigningExistsNotLocallyCached) - ) { + if (allowPrivateKeyRequests && (selfSigningChanged || selfSigningExistsNotLocallyCached)) { logger.info("Attempting to retrieve cross-signing self-signing private key"); let signing: PkSigning | null = null; try { const ret = await this.crossSigningInfo.getCrossSigningKey( - "self_signing", newCrossSigning.getId("self_signing")!, + "self_signing", + newCrossSigning.getId("self_signing")!, ); signing = ret[1]; logger.info("Got cross-signing self-signing private key"); @@ -1618,15 +1614,13 @@ export class Crypto extends TypedEventEmitter => { logger.info(`Starting background key sig upload for ${keysToUpload}`); - return this.baseApis.uploadKeySignatures({ [this.userId]: keySignatures }) + return this.baseApis + .uploadKeySignatures({ [this.userId]: keySignatures }) .then((response) => { const { failures } = response || {}; logger.info(`Finished background key sig upload for ${keysToUpload}`); @@ -1674,11 +1665,9 @@ export class Crypto extends TypedEventEmitter { - logger.error( - `Error during background key sig upload for ${keysToUpload}`, - e, - ); + }) + .catch((e) => { + logger.error(`Error during background key sig upload for ${keysToUpload}`, e); }); }; upload({ shouldEmit: true }); @@ -1700,7 +1689,7 @@ export class Crypto extends TypedEventEmitter | null): Promise { if (keys) { @@ -1708,24 +1697,19 @@ export class Crypto extends TypedEventEmitter { - this.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningInfo.keys); - }, - ); + await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningInfo.keys); + }); } /** * Check if the master key is signed by a verified device, and if so, prompt * the application to mark it as verified. * - * @param {string} userId the user ID whose key should be checked + * @param userId - the user ID whose key should be checked */ private async checkDeviceVerifications(userId: string): Promise { - const shouldUpgradeCb = ( - this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications - ); + const shouldUpgradeCb = this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications; if (!shouldUpgradeCb) { // Upgrading skipped when callback is not present. return; @@ -1734,9 +1718,7 @@ export class Crypto extends TypedEventEmitter): void { + public registerEventHandlers( + eventEmitter: TypedEventEmitter< + RoomMemberEvent.Membership | ClientEvent.ToDeviceEvent | RoomEvent.Timeline | MatrixEventEvent.Decrypted, + any + >, + ): void { eventEmitter.on(RoomMemberEvent.Membership, this.onMembership); eventEmitter.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); eventEmitter.on(RoomEvent.Timeline, this.onTimelineEvent); @@ -1792,7 +1776,7 @@ export class Crypto extends TypedEventEmitter { const deviceKeys = { @@ -1854,7 +1838,7 @@ export class Crypto extends TypedEventEmitter { - if (this.oneTimeKeyCount !== undefined) { - // We already have the current one_time_key count from a /sync response. - // Use this value instead of asking the server for the current key count. - return Promise.resolve(this.oneTimeKeyCount); - } - // ask the server how many keys we have - return this.baseApis.uploadKeysRequest({}).then((res) => { - return res.one_time_key_counts.signed_curve25519 || 0; + Promise.resolve() + .then(() => { + if (this.oneTimeKeyCount !== undefined) { + // We already have the current one_time_key count from a /sync response. + // Use this value instead of asking the server for the current key count. + return Promise.resolve(this.oneTimeKeyCount); + } + // ask the server how many keys we have + return this.baseApis.uploadKeysRequest({}).then((res) => { + return res.one_time_key_counts.signed_curve25519 || 0; + }); + }) + .then((keyCount) => { + // Start the uploadLoop with the current keyCount. The function checks if + // we need to upload new keys or not. + // If there are too many keys on the server then we don't need to + // create any more keys. + return uploadLoop(keyCount); + }) + .catch((e) => { + logger.error("Error uploading one-time keys", e.stack || e); + }) + .finally(() => { + // reset oneTimeKeyCount to prevent start uploading based on old data. + // it will be set again on the next /sync-response + this.oneTimeKeyCount = undefined; + this.oneTimeKeyCheckInProgress = false; }); - }).then((keyCount) => { - // Start the uploadLoop with the current keyCount. The function checks if - // we need to upload new keys or not. - // If there are too many keys on the server then we don't need to - // create any more keys. - return uploadLoop(keyCount); - }).catch((e) => { - logger.error("Error uploading one-time keys", e.stack || e); - }).finally(() => { - // reset oneTimeKeyCount to prevent start uploading based on old data. - // it will be set again on the next /sync-response - this.oneTimeKeyCount = undefined; - this.oneTimeKeyCheckInProgress = false; - }); } // returns a promise which resolves to the response @@ -2020,7 +2006,7 @@ export class Crypto extends TypedEventEmitter = { - "one_time_keys": oneTimeJson, + one_time_keys: oneTimeJson, }; if (fallbackJson) { @@ -2034,7 +2020,7 @@ export class Crypto extends TypedEventEmitter { delete this.fallbackCleanup; this.olmDevice.forgetOldFallbackKey(); - }, 60*60*1000); + }, 60 * 60 * 1000); } await this.olmDevice.markKeysAsPublished(); @@ -2044,11 +2030,10 @@ export class Crypto extends TypedEventEmitterdeviceId->{@link - * module:crypto/deviceinfo|DeviceInfo}. + * @returns A promise which resolves to a map `userId->deviceId->{@link DeviceInfo}`. */ public downloadKeys(userIds: string[], forceDownload?: boolean): Promise { return this.deviceList.downloadKeys(userIds, !!forceDownload); @@ -2057,9 +2042,9 @@ export class Crypto extends TypedEventEmitter | null { @@ -2069,10 +2054,8 @@ export class Crypto extends TypedEventEmitter} true if the data was saved, false if + * @returns true if the data was saved, false if * it was not (eg. because no changes were pending). The promise * will only resolve once the data is saved, so may take some time * to resolve. @@ -2098,24 +2081,24 @@ export class Crypto extends TypedEventEmitter} keys The list of keys that was present + * @param 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 + * @returns updated DeviceInfo */ public async setDeviceVerification( userId: string, @@ -2149,10 +2132,7 @@ export class Crypto extends TypedEventEmitter => { @@ -2173,11 +2153,8 @@ export class Crypto extends TypedEventEmitter r.started), - ]); + await Promise.race([verifier.verify(), request.waitFor((r) => r.started)]); return request; } @@ -2377,13 +2351,11 @@ export class Crypto extends TypedEventEmitter * This method is provided for debugging purposes. * - * @param {string} userId id of user to inspect - * - * @return {Promise>} + * @param userId - id of user to inspect */ public async getOlmSessionsForUser(userId: string): Promise> { const devices = this.getStoredDevicesForUser(userId) || []; @@ -2403,9 +2375,7 @@ export class Crypto extends TypedEventEmitter { + const room = this.clientStore.getRoom(roomId); + if (!room) { + throw new Error(`Unable to enable encryption tracking devices in unknown room ${roomId}`); + } + await this.setRoomEncryptionImpl(room, config); + if (!this.lazyLoadMembers && !inhibitDeviceQuery) { + this.deviceList.refreshOutdatedDeviceLists(); + } + } + + /** + * Set up encryption for a room. + * + * This is called when an m.room.encryption event is received. It saves a flag + * for the room in the cryptoStore (if it wasn't already set), sets up an "encryptor" for + * the room, and enables device-list tracking for the room. + * + * It does not initiate a device list query for the room. That is normally + * done once we finish processing the sync, in onSyncCompleted. + * + * @param room - The room to enable encryption in. + * @param config - The encryption config for the room. + */ + private async setRoomEncryptionImpl(room: Room, config: IRoomEncryption): Promise { + const roomId = room.roomId; + // ignore crypto events with no algorithm defined // This will happen if a crypto event is redacted before we fetch the room state // It would otherwise just throw later as an unknown algorithm would, but we may @@ -2575,8 +2581,7 @@ export class Crypto extends TypedEventEmitter { + const room = this.clientStore.getRoom(roomId); + if (!room) { + throw new Error(`Unable to start tracking devices in unknown room ${roomId}`); + } + return this.trackRoomDevicesImpl(room); + } + + /** + * Make sure we are tracking the device lists for all users in this room. + * + * This is normally called when we are about to send an encrypted event, to make sure + * we have all the devices in the room; but it is also called when processing an + * m.room.encryption state event (if lazy-loading is disabled), or when members are + * loaded (if lazy-loading is enabled), to prepare the device list. + * + * @param room - Room to enable device-list tracking in + */ + private trackRoomDevicesImpl(room: Room): Promise { + const roomId = room.roomId; const trackMembers = async (): Promise => { // not an encrypted room if (!this.roomEncryptors.has(roomId)) { return; } - const room = this.clientStore.getRoom(roomId); - if (!room) { - throw new Error(`Unable to start tracking devices in unknown room ${roomId}`); - } logger.log(`Starting to track devices for room ${roomId} ...`); const members = await room.getEncryptionTargetMembers(); members.forEach((m) => { @@ -2664,7 +2678,7 @@ export class Crypto extends TypedEventEmitter { + this.roomDeviceTrackingState[roomId] = promise.catch((err) => { delete this.roomDeviceTrackingState[roomId]; throw err; }); @@ -2676,12 +2690,12 @@ export class Crypto extends TypedEventEmitter { const exportedSessions: IMegolmSessionData[] = []; - await this.cryptoStore.doTxn( - 'readonly', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { - this.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (s) => { - if (s === null) return; - - const sess = this.olmDevice.exportInboundGroupSession(s.senderKey, s.sessionId, s.sessionData!); - delete sess.first_known_index; - sess.algorithm = olmlib.MEGOLM_ALGORITHM; - exportedSessions.push(sess); - }); - }, - ); + await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { + this.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (s) => { + if (s === null) return; + + const sess = this.olmDevice.exportInboundGroupSession(s.senderKey, s.sessionId, s.sessionData!); + delete sess.first_known_index; + sess.algorithm = olmlib.MEGOLM_ALGORITHM; + exportedSessions.push(sess); + }); + }); return exportedSessions; } @@ -2737,10 +2749,8 @@ export class Crypto extends TypedEventEmitter { let successes = 0; @@ -2756,25 +2766,31 @@ export class Crypto extends TypedEventEmitter { - if (!key.room_id || !key.algorithm) { - logger.warn("ignoring room key entry with missing fields", key); - failures++; - if (opts.progressCallback) { updateProgress(); } - return null; - } + return Promise.all( + keys.map((key) => { + if (!key.room_id || !key.algorithm) { + logger.warn("ignoring room key entry with missing fields", key); + failures++; + if (opts.progressCallback) { + updateProgress(); + } + return null; + } - const alg = this.getRoomDecryptor(key.room_id, key.algorithm); - return alg.importRoomKey(key, opts).finally(() => { - successes++; - if (opts.progressCallback) { updateProgress(); } - }); - })).then(); + const alg = this.getRoomDecryptor(key.room_id, key.algorithm); + return alg.importRoomKey(key, opts).finally(() => { + successes++; + if (opts.progressCallback) { + updateProgress(); + } + }); + }), + ).then(); } /** * Counts the number of end to end session keys that are waiting to be backed up - * @returns {Promise} Resolves to the number of sessions requiring backup + * @returns Promise which resolves to the number of sessions requiring backup */ public countSessionsNeedingBackup(): Promise { return this.backupManager.countSessionsNeedingBackup(); @@ -2784,7 +2800,7 @@ export class Crypto extends TypedEventEmitter { @@ -2815,42 +2831,41 @@ export class Crypto extends TypedEventEmitter} resolves once we have + * @returns resolves once we have * finished decrypting. Rejects with an `algorithms.DecryptionError` if there * is a problem decrypting the event. */ @@ -2899,11 +2913,14 @@ export class Crypto extends TypedEventEmitter { + public async handleDeviceListChanges( + syncData: ISyncStateData, + syncDeviceLists: Required["device_lists"], + ): Promise { // Initial syncs don't have device change lists. We'll either get the complete list // of changes for the interval or will have invalidated everything in willProcessSync if (!syncData.oldSyncToken) return; @@ -2922,48 +2939,43 @@ export class Crypto extends TypedEventEmitter} recipients - * @param {boolean} resend whether to resend the key request if there is + * @param resend - whether to resend the key request if there is * already one * - * @return {Promise} a promise that resolves when the key request is queued + * @returns a promise that resolves when the key request is queued */ public requestRoomKey( requestBody: IRoomKeyRequestBody, recipients: IRoomKeyRequestRecipient[], resend = false, ): Promise { - return this.outgoingRoomKeyRequestManager.queueRoomKeyRequest( - requestBody, recipients, resend, - ).then(() => { - if (this.sendKeyRequestsImmediately) { - this.outgoingRoomKeyRequestManager.sendQueuedRequests(); - } - }).catch((e) => { - // this normally means we couldn't talk to the store - logger.error( - 'Error requesting key for event', e, - ); - }); + return this.outgoingRoomKeyRequestManager + .queueRoomKeyRequest(requestBody, recipients, resend) + .then(() => { + if (this.sendKeyRequestsImmediately) { + this.outgoingRoomKeyRequestManager.sendQueuedRequests(); + } + }) + .catch((e) => { + // this normally means we couldn't talk to the store + logger.error("Error requesting key for event", e); + }); } /** * Cancel any earlier room key request * - * @param {module:crypto~RoomKeyRequestBody} requestBody - * parameters to match for cancellation + * @param requestBody - parameters to match for cancellation */ public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): void { - this.outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody) - .catch((e) => { - logger.warn("Error clearing pending room key requests", e); - }); + this.outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody).catch((e) => { + logger.warn("Error clearing pending room key requests", e); + }); } /** * Re-send any outgoing key requests, eg after verification - * @returns {Promise} + * @returns */ public async cancelAndResendAllOutgoingKeyRequests(): Promise { await this.outgoingRoomKeyRequestManager.cancelAndResendAllOutgoingRequests(); @@ -2972,25 +2984,18 @@ export class Crypto extends TypedEventEmitter { - const roomId = event.getRoomId()!; + public async onCryptoEvent(room: Room, event: MatrixEvent): Promise { const content = event.getContent(); - - try { - // inhibit the device list refresh for now - it will happen once we've - // finished processing the sync, in onSyncCompleted. - await this.setRoomEncryption(roomId, content, true); - } catch (e) { - logger.error(`Error configuring encryption in room ${roomId}`, e); - } + await this.setRoomEncryptionImpl(room, content); } /** * Called before the result of a sync is processed * - * @param {Object} syncData the data from the 'MatrixClient.sync' event + * @param syncData - the data from the 'MatrixClient.sync' event */ public async onSyncWillProcess(syncData: ISyncStateData): Promise { if (!syncData.oldSyncToken) { @@ -3014,7 +3019,7 @@ export class Crypto extends TypedEventEmitter { this.deviceList.setSyncToken(syncData.nextSyncToken ?? null); @@ -3047,18 +3052,17 @@ export class Crypto extends TypedEventEmitter { - if (deviceLists.changed && Array.isArray(deviceLists.changed)) { + private async evalDeviceListChanges(deviceLists: Required["device_lists"]): Promise { + if (Array.isArray(deviceLists?.changed)) { deviceLists.changed.forEach((u) => { this.deviceList.invalidateUserDeviceList(u); }); } - if (deviceLists.left && Array.isArray(deviceLists.left) && - deviceLists.left.length) { + if (Array.isArray(deviceLists?.left) && deviceLists.left.length) { // Check we really don't share any rooms with these users // any more: the server isn't required to give us the // exact correct set. @@ -3076,7 +3080,7 @@ export class Crypto extends TypedEventEmitter { const e2eUserIds: string[] = []; @@ -3093,7 +3097,7 @@ export class Crypto extends TypedEventEmitter { @@ -3115,58 +3119,54 @@ export class Crypto extends TypedEventEmitter} Promise which + * @param userDeviceInfoArr - the devices to send to + * @param payload - fields to include in the encrypted payload + * @returns Promise which * resolves once the message has been encrypted and sent to the given - * userDeviceMap, and returns the { contentMap, deviceInfoByDeviceId } + * userDeviceMap, and returns the `{ contentMap, deviceInfoByDeviceId }` * of the successfully sent messages. */ - public async encryptAndSendToDevices( - userDeviceInfoArr: IOlmDevice[], - payload: object, - ): Promise { + public async encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice[], payload: object): Promise { const toDeviceBatch: ToDeviceBatch = { eventType: EventType.RoomMessageEncrypted, batch: [], }; try { - await Promise.all(userDeviceInfoArr.map(async ({ userId, deviceInfo }) => { - const deviceId = deviceInfo.deviceId; - const encryptedContent: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - }; + await Promise.all( + userDeviceInfoArr.map(async ({ userId, deviceInfo }) => { + const deviceId = deviceInfo.deviceId; + const encryptedContent: IEncryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key!, + ciphertext: {}, + [ToDeviceMessageId]: uuidv4(), + }; - toDeviceBatch.batch.push({ - userId, - deviceId, - payload: encryptedContent, - }); + toDeviceBatch.batch.push({ + userId, + deviceId, + payload: encryptedContent, + }); - await olmlib.ensureOlmSessionsForDevices( - this.olmDevice, - this.baseApis, - { [userId]: [deviceInfo] }, - ); - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this.userId, - this.deviceId, - this.olmDevice, - userId, - deviceInfo, - payload, - ); - })); + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, { [userId]: [deviceInfo] }); + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + deviceInfo, + payload, + ); + }), + ); // prune out any devices that encryptMessageForDevice could not encrypt for, // in which case it will have just not added anything to the ciphertext object. // There's no point sending messages to devices if we couldn't encrypt to them, // since that's effectively a blank message. - toDeviceBatch.batch = toDeviceBatch.batch.filter(msg => { + toDeviceBatch.batch = toDeviceBatch.batch.filter((msg) => { if (Object.keys(msg.payload.ciphertext).length > 0) { return true; } else { @@ -3197,11 +3197,12 @@ export class Crypto extends TypedEventEmitter { try { - logger.log(`received to_device ${event.getType()} from: ` + - `${event.getSender()} id: ${event.getId()}`); + logger.log( + `received to-device ${event.getType()} from: ` + + `${event.getSender()} id: ${event.getContent()[ToDeviceMessageId]}`, + ); - if (event.getType() == "m.room_key" - || event.getType() == "m.forwarded_room_key") { + if (event.getType() == "m.room_key" || event.getType() == "m.forwarded_room_key") { this.onRoomKeyEvent(event); } else if (event.getType() == "m.room_key_request") { this.onRoomKeyRequestEvent(event); @@ -3232,8 +3233,8 @@ export class Crypto extends TypedEventEmitter { const channel = new InRoomChannel(this.baseApis, event.getRoomId()!); - return new VerificationRequest( - channel, this.verificationMethods, this.baseApis); + return new VerificationRequest(channel, this.verificationMethods, this.baseApis); }; this.handleVerificationEvent(event, this.inRoomVerificationRequests, createRequest, liveEvent); }; @@ -3383,8 +3381,10 @@ export class Crypto extends TypedEventEmitter { const content = event.getWireContent(); @@ -3437,8 +3438,13 @@ export class Crypto extends TypedEventEmitter Date.now()) { logger.debug( - "New session already forced with device " + sender + ":" + deviceKey + - " at " + lastNewSessionForced + ": not forcing another", + "New session already forced with device " + + sender + + ":" + + deviceKey + + " at " + + lastNewSessionForced + + ": not forcing another", ); await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true); retryDecryption(); @@ -3456,10 +3462,7 @@ export class Crypto extends TypedEventEmitter { if (this.processingRoomKeyRequests) { @@ -3605,10 +3612,10 @@ export class Crypto extends TypedEventEmitter - this.processReceivedRoomKeyRequest(req))); - await Promise.all(cancellations.map((cancellation) => - this.processReceivedRoomKeyRequestCancellation(cancellation))); + await Promise.all(requests.map((req) => this.processReceivedRoomKeyRequest(req))); + await Promise.all( + cancellations.map((cancellation) => this.processReceivedRoomKeyRequestCancellation(cancellation)), + ); } catch (e) { logger.error(`Error processing room key requsts: ${e}`); } finally { @@ -3619,7 +3626,6 @@ export class Crypto extends TypedEventEmitter { const userId = req.userId; @@ -3629,8 +3635,10 @@ export class Crypto extends TypedEventEmitter { logger.log( `m.room_key_request cancellation for ${cancellation.userId}:` + - `${cancellation.deviceId} (id ${cancellation.requestId})`, + `${cancellation.deviceId} (id ${cancellation.requestId})`, ); // we should probably only notify the app of cancellations we told it @@ -3731,17 +3740,14 @@ export class Crypto extends TypedEventEmitter | undefined; @@ -3763,7 +3769,7 @@ export class Crypto extends TypedEventEmitter { + public async signObject(obj: T): Promise { const sigs = obj.signatures || {}; const unsigned = obj.unsigned; @@ -3824,8 +3830,8 @@ export class Crypto extends TypedEventEmitter parseInt(x)); + const fixedKey = Uint8Array.from(key.split(","), (x) => parseInt(x)); return olmlib.encodeBase64(fixedKey); } -/** - * The parameters of a room key request. The details of the request may - * vary with the crypto algorithm, but the management and storage layers for - * outgoing requests expect it to have 'room_id' and 'session_id' properties. - * - * @typedef {Object} RoomKeyRequestBody - */ - /** * Represents a received m.room_key_request event - * - * @property {string} userId user requesting the key - * @property {string} deviceId device requesting the key - * @property {string} requestId unique id for the request - * @property {module:crypto~RoomKeyRequestBody} requestBody - * @property {function()} share callback which, when called, will ask - * the relevant crypto algorithm implementation to share the keys for - * this request. */ export class IncomingRoomKeyRequest { + /** user requesting the key */ public readonly userId: string; + /** device requesting the key */ public readonly deviceId: string; + /** unique id for the request */ public readonly requestId: string; public readonly requestBody: IRoomKeyRequestBody; + /** + * callback which, when called, will ask + * the relevant crypto algorithm implementation to share the keys for + * this request. + */ public share: () => void; public constructor(event: MatrixEvent) { @@ -3878,14 +3876,13 @@ export class IncomingRoomKeyRequest { /** * Represents a received m.room_key_request cancellation - * - * @property {string} userId user requesting the cancellation - * @property {string} deviceId device requesting the cancellation - * @property {string} requestId unique id for the request to be cancelled */ class IncomingRoomKeyRequestCancellation { + /** user requesting the cancellation */ public readonly userId: string; + /** device requesting the cancellation */ public readonly deviceId: string; + /** unique id for the request to be cancelled */ public readonly requestId: string; public constructor(event: MatrixEvent) { @@ -3897,45 +3894,5 @@ class IncomingRoomKeyRequestCancellation { } } -/** - * The result of a (successful) call to decryptEvent. - * - * @typedef {Object} EventDecryptionResult - * - * @property {Object} clearEvent The plaintext payload for the event - * (typically containing type and content fields). - * - * @property {?string} senderCurve25519Key Key owned by the sender of this - * event. See {@link module:models/event.MatrixEvent#getSenderKey}. - * - * @property {?string} claimedEd25519Key ed25519 key claimed by the sender of - * this event. See - * {@link module:models/event.MatrixEvent#getClaimedEd25519Key}. - * - * @property {?Array} forwardingCurve25519KeyChain list of curve25519 - * keys involved in telling us about the senderCurve25519Key and - * claimedEd25519Key. See - * {@link module:models/event.MatrixEvent#getForwardingCurve25519KeyChain}. - */ - -/** - * Fires when we receive a room key request - * - * @event module:client~MatrixClient#"crypto.roomKeyRequest" - * @param {module:crypto~IncomingRoomKeyRequest} req request details - */ - -/** - * Fires when we receive a room key request cancellation - * - * @event module:client~MatrixClient#"crypto.roomKeyRequestCancellation" - * @param {module:crypto~IncomingRoomKeyRequestCancellation} req - */ - -/** - * Fires when the app may wish to warn the user about something related - * the end-to-end crypto. - * - * @event module:client~MatrixClient#"crypto.warning" - * @param {string} type One of the strings listed above - */ +// IEventDecryptionResult is re-exported for backwards compatibility, in case any applications are referencing it. +export type { IEventDecryptionResult } from "../@types/crypto"; diff --git a/src/crypto/key_passphrase.ts b/src/crypto/key_passphrase.ts index b838fae851b..f6fe7b6b7d7 100644 --- a/src/crypto/key_passphrase.ts +++ b/src/crypto/key_passphrase.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { randomString } from '../randomstring'; +import { randomString } from "../randomstring"; import { subtleCrypto, TextEncoder } from "./crypto"; const DEFAULT_ITERATIONS = 500000; @@ -41,14 +41,12 @@ export function keyFromAuthData(authData: IAuthData, password: string): Promise< } if (!authData.private_key_salt || !authData.private_key_iterations) { - throw new Error( - "Salt and/or iterations not found: " + - "this backup cannot be restored with a passphrase", - ); + throw new Error("Salt and/or iterations not found: " + "this backup cannot be restored with a passphrase"); } return deriveKey( - password, authData.private_key_salt, + password, + authData.private_key_salt, authData.private_key_iterations, authData.private_key_bits || DEFAULT_BITSIZE, ); @@ -76,20 +74,16 @@ export async function deriveKey( throw new Error("Password-based backup is not available on this platform"); } - const key = await subtleCrypto.importKey( - 'raw', - new TextEncoder().encode(password), - { name: 'PBKDF2' }, - false, - ['deriveBits'], - ); + const key = await subtleCrypto.importKey("raw", new TextEncoder().encode(password), { name: "PBKDF2" }, false, [ + "deriveBits", + ]); const keybits = await subtleCrypto.deriveBits( { - name: 'PBKDF2', + name: "PBKDF2", salt: new TextEncoder().encode(salt), iterations: iterations, - hash: 'SHA-512', + hash: "SHA-512", }, key, numBits, diff --git a/src/crypto/keybackup.ts b/src/crypto/keybackup.ts index 919266a3ef8..67e213c4a92 100644 --- a/src/crypto/keybackup.ts +++ b/src/crypto/keybackup.ts @@ -59,6 +59,10 @@ export interface IKeyBackupInfo { /* eslint-enable camelcase */ export interface IKeyBackupPrepareOpts { + /** + * Whether to use Secure Secret Storage to store the key encrypting key backups. + * Optional, defaults to false. + */ secureSecretStorage: boolean; } diff --git a/src/crypto/olmlib.ts b/src/crypto/olmlib.ts index 111e4c16de3..fc9a2790a9c 100644 --- a/src/crypto/olmlib.ts +++ b/src/crypto/olmlib.ts @@ -15,8 +15,6 @@ limitations under the License. */ /** - * @module olmlib - * * Utilities common to olm encryption algorithms */ @@ -25,12 +23,13 @@ import anotherjson from "another-json"; import type { PkSigning } from "@matrix-org/olm"; import { OlmDevice } from "./OlmDevice"; import { DeviceInfo } from "./deviceinfo"; -import { logger } from '../logger'; +import { logger } from "../logger"; import { IOneTimeKey } from "./dehydration"; import { IClaimOTKsResult, MatrixClient } from "../client"; import { ISignatures } from "../@types/signed"; import { MatrixEvent } from "../models/event"; import { EventType } from "../@types/event"; +import { IMessage } from "./algorithms/olm"; enum Algorithm { Olm = "m.olm.v1.curve25519-aes-sha2", @@ -54,28 +53,26 @@ export const MEGOLM_ALGORITHM = Algorithm.Megolm; export const MEGOLM_BACKUP_ALGORITHM = Algorithm.MegolmBackup; export interface IOlmSessionResult { + /** device info */ device: DeviceInfo; + /** base64 olm session id; null if no session could be established */ sessionId: string | null; } /** * Encrypt an event payload for an Olm device * - * @param {Object} resultsObject The `ciphertext` property + * @param resultsObject - The `ciphertext` property * of the m.room.encrypted event to which to add our result * - * @param {string} ourUserId - * @param {string} ourDeviceId - * @param {module:crypto/OlmDevice} olmDevice olm.js wrapper - * @param {string} recipientUserId - * @param {module:crypto/deviceinfo} recipientDevice - * @param {object} payloadFields fields to include in the encrypted payload + * @param olmDevice - olm.js wrapper + * @param payloadFields - fields to include in the encrypted payload * * Returns a promise which resolves (to undefined) when the payload * has been encrypted into `resultsObject` */ export async function encryptMessageForDevice( - resultsObject: Record, + resultsObject: Record, ourUserId: string, ourDeviceId: string | undefined, olmDevice: OlmDevice, @@ -90,14 +87,14 @@ export async function encryptMessageForDevice( // we can't encrypt a message for it. logger.log( `[olmlib.encryptMessageForDevice] Unable to find Olm session for device ` + - `${recipientUserId}:${recipientDevice.deviceId}`, + `${recipientUserId}:${recipientDevice.deviceId}`, ); return; } logger.log( `[olmlib.encryptMessageForDevice] Using Olm session ${sessionId} for device ` + - `${recipientUserId}:${recipientDevice.deviceId}`, + `${recipientUserId}:${recipientDevice.deviceId}`, ); const payload = { @@ -114,7 +111,7 @@ export async function encryptMessageForDevice( // the curve25519 key and the ed25519 key are owned by // the same device. keys: { - "ed25519": olmDevice.deviceEd25519Key, + ed25519: olmDevice.deviceEd25519Key, }, // include the recipient device details in the payload, @@ -122,8 +119,9 @@ export async function encryptMessageForDevice( // https://github.com/vector-im/vector-web/issues/2483 recipient: recipientUserId, recipient_keys: { - "ed25519": recipientDevice.getFingerprint(), + ed25519: recipientDevice.getFingerprint(), }, + ...payloadFields, }; // TODO: technically, a bunch of that stuff only needs to be included for @@ -131,11 +129,7 @@ export async function encryptMessageForDevice( // involved in the session. If we're looking to reduce data transfer in the // future, we could elide them for subsequent messages. - Object.assign(payload, payloadFields); - - resultsObject[deviceKey] = await olmDevice.encryptMessage( - deviceKey, sessionId, JSON.stringify(payload), - ); + resultsObject[deviceKey] = await olmDevice.encryptMessage(deviceKey, sessionId, JSON.stringify(payload)); } interface IExistingOlmSession { @@ -147,25 +141,22 @@ interface IExistingOlmSession { * Get the existing olm sessions for the given devices, and the devices that * don't have olm sessions. * - * @param {module:crypto/OlmDevice} olmDevice * - * @param {MatrixClient} baseApis * - * @param {object} devicesByUser - * map from userid to list of devices to ensure sessions for + * @param devicesByUser - map from userid to list of devices to ensure sessions for * - * @return {Promise} resolves to an array. The first element of the array is a + * @returns resolves to an array. The first element of the array is a * a map of user IDs to arrays of deviceInfo, representing the devices that * don't have established olm sessions. The second element of the array is - * a map from userId to deviceId to {@link module:crypto~OlmSessionResult} + * a map from userId to deviceId to {@link OlmSessionResult} */ export async function getExistingOlmSessions( olmDevice: OlmDevice, baseApis: MatrixClient, devicesByUser: Record, ): Promise<[Record, Record>]> { - const devicesWithoutSession: {[userId: string]: DeviceInfo[]} = {}; - const sessions: {[userId: string]: {[deviceId: string]: IExistingOlmSession}} = {}; + const devicesWithoutSession: { [userId: string]: DeviceInfo[] } = {}; + const sessions: { [userId: string]: { [deviceId: string]: IExistingOlmSession } } = {}; const promises: Promise[] = []; @@ -173,21 +164,21 @@ export async function getExistingOlmSessions( for (const deviceInfo of devices) { const deviceId = deviceInfo.deviceId; const key = deviceInfo.getIdentityKey(); - promises.push((async (): Promise => { - const sessionId = await olmDevice.getSessionIdForDevice( - key, true, - ); - if (sessionId === null) { - devicesWithoutSession[userId] = devicesWithoutSession[userId] || []; - devicesWithoutSession[userId].push(deviceInfo); - } else { - sessions[userId] = sessions[userId] || {}; - sessions[userId][deviceId] = { - device: deviceInfo, - sessionId: sessionId, - }; - } - })()); + promises.push( + (async (): Promise => { + const sessionId = await olmDevice.getSessionIdForDevice(key, true); + if (sessionId === null) { + devicesWithoutSession[userId] = devicesWithoutSession[userId] || []; + devicesWithoutSession[userId].push(deviceInfo); + } else { + sessions[userId] = sessions[userId] || {}; + sessions[userId][deviceId] = { + device: deviceInfo, + sessionId: sessionId, + }; + } + })(), + ); } } @@ -199,27 +190,22 @@ export async function getExistingOlmSessions( /** * Try to make sure we have established olm sessions for the given devices. * - * @param {module:crypto/OlmDevice} olmDevice - * - * @param {MatrixClient} baseApis - * - * @param {object} devicesByUser - * map from userid to list of devices to ensure sessions for + * @param devicesByUser - map from userid to list of devices to ensure sessions for * - * @param {boolean} [force=false] If true, establish a new session even if one + * @param force - If true, establish a new session even if one * already exists. * - * @param {Number} [otkTimeout] The timeout in milliseconds when requesting + * @param otkTimeout - The timeout in milliseconds when requesting * one-time keys for establishing new olm sessions. * - * @param {Array} [failedServers] An array to fill with remote servers that + * @param failedServers - An array to fill with remote servers that * failed to respond to one-time-key requests. * - * @param {Logger} [log] A possibly customised log + * @param log - A possibly customised log * - * @return {Promise} resolves once the sessions are complete, to + * @returns resolves once the sessions are complete, to * an Object mapping from userId to deviceId to - * {@link module:crypto~OlmSessionResult} + * {@link OlmSessionResult} */ export async function ensureOlmSessionsForDevices( olmDevice: OlmDevice, @@ -233,7 +219,7 @@ export async function ensureOlmSessionsForDevices( const devicesWithoutSession: [string, string][] = [ // [userId, deviceId], ... ]; - const result: {[userId: string]: {[deviceId: string]: IExistingOlmSession}} = {}; + const result: { [userId: string]: { [deviceId: string]: IExistingOlmSession } } = {}; const resolveSession: Record void> = {}; // Mark all sessions this task intends to update as in progress. It is @@ -255,7 +241,7 @@ export async function ensureOlmSessionsForDevices( // pre-emptively mark the session as in-progress to avoid race // conditions. If we find that we already have a session, then // we'll resolve - olmDevice.sessionsInProgress[key] = new Promise(resolve => { + olmDevice.sessionsInProgress[key] = new Promise((resolve) => { resolveSession[key] = (v: any): void => { delete olmDevice.sessionsInProgress[key]; resolve(v); @@ -335,7 +321,7 @@ export async function ensureOlmSessionsForDevices( failedServers.push(...Object.keys(res.failures)); } - const otkResult = res.one_time_keys || {} as IClaimOTKsResult["one_time_keys"]; + const otkResult = res.one_time_keys || ({} as IClaimOTKsResult["one_time_keys"]); const promises: Promise[] = []; for (const [userId, devices] of Object.entries(devicesByUser)) { const userRes = otkResult[userId] || {}; @@ -364,10 +350,7 @@ export async function ensureOlmSessionsForDevices( } if (!oneTimeKey) { - log.warn( - `No one-time keys (alg=${oneTimeKeyAlgorithm}) ` + - `for device ${userId}:${deviceId}`, - ); + log.warn(`No one-time keys (alg=${oneTimeKeyAlgorithm}) ` + `for device ${userId}:${deviceId}`); if (resolveSession[key]) { resolveSession[key](); } @@ -375,19 +358,20 @@ export async function ensureOlmSessionsForDevices( } promises.push( - _verifyKeyAndStartSession( - olmDevice, oneTimeKey, userId, deviceInfo, - ).then((sid) => { - if (resolveSession[key]) { - resolveSession[key](sid ?? undefined); - } - result[userId][deviceId].sessionId = sid; - }, (e) => { - if (resolveSession[key]) { - resolveSession[key](); - } - throw e; - }), + _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo).then( + (sid) => { + if (resolveSession[key]) { + resolveSession[key](sid ?? undefined); + } + result[userId][deviceId].sessionId = sid; + }, + (e) => { + if (resolveSession[key]) { + resolveSession[key](); + } + throw e; + }, + ), ); } } @@ -407,32 +391,22 @@ async function _verifyKeyAndStartSession( ): Promise { const deviceId = deviceInfo.deviceId; try { - await verifySignature( - olmDevice, oneTimeKey, userId, deviceId, - deviceInfo.getFingerprint(), - ); + await verifySignature(olmDevice, oneTimeKey, userId, deviceId, deviceInfo.getFingerprint()); } catch (e) { - logger.error( - "Unable to verify signature on one-time key for device " + - userId + ":" + deviceId + ":", e, - ); + logger.error("Unable to verify signature on one-time key for device " + userId + ":" + deviceId + ":", e); return null; } let sid; try { - sid = await olmDevice.createOutboundSession( - deviceInfo.getIdentityKey(), oneTimeKey.key, - ); + sid = await olmDevice.createOutboundSession(deviceInfo.getIdentityKey(), oneTimeKey.key); } catch (e) { // possibly a bad key - logger.error("Error starting olm session with device " + - userId + ":" + deviceId + ": " + e); + logger.error("Error starting olm session with device " + userId + ":" + deviceId + ": " + e); return null; } - logger.log("Started new olm sessionid " + sid + - " for device " + userId + ":" + deviceId); + logger.log("Started new olm sessionid " + sid + " for device " + userId + ":" + deviceId); return sid; } @@ -444,15 +418,15 @@ export interface IObject { /** * Verify the signature on an object * - * @param {module:crypto/OlmDevice} olmDevice olm wrapper to use for verify op + * @param olmDevice - olm wrapper to use for verify op * - * @param {Object} obj object to check signature on. + * @param obj - object to check signature on. * - * @param {string} signingUserId ID of the user whose signature should be checked + * @param signingUserId - ID of the user whose signature should be checked * - * @param {string} signingDeviceId ID of the device whose signature should be checked + * @param signingDeviceId - ID of the device whose signature should be checked * - * @param {string} signingKey base64-ed ed25519 public key + * @param signingKey - base64-ed ed25519 public key * * Returns a promise which resolves (to undefined) if the the signature is good, * or rejects with an Error if it is bad. @@ -480,22 +454,20 @@ export async function verifySignature( delete mangledObj.signatures; const json = anotherjson.stringify(mangledObj); - olmDevice.verifySignature( - signingKey, json, signature, - ); + olmDevice.verifySignature(signingKey, json, signature); } /** * Sign a JSON object using public key cryptography - * @param {Object} obj Object to sign. The object will be modified to include + * @param obj - Object to sign. The object will be modified to include * the new signature - * @param {Olm.PkSigning|Uint8Array} key the signing object or the private key + * @param key - the signing object or the private key * seed - * @param {string} userId The user ID who owns the signing key - * @param {string} pubKey The public key (ignored if key is a seed) - * @returns {string} the signature for the object + * @param userId - The user ID who owns the signing key + * @param pubKey - The public key (ignored if key is a seed) + * @returns the signature for the object */ -export function pkSign(obj: IObject, key: PkSigning, userId: string, pubKey: string): string { +export function pkSign(obj: object & IObject, key: Uint8Array | PkSigning, userId: string, pubKey: string): string { let createdKey = false; if (key instanceof Uint8Array) { const keyObj = new global.Olm.PkSigning(); @@ -511,7 +483,7 @@ export function pkSign(obj: IObject, key: PkSigning, userId: string, pubKey: str const mysigs = sigs[userId] || {}; sigs[userId] = mysigs; - return mysigs['ed25519:' + pubKey] = key.sign(anotherjson.stringify(obj)); + return (mysigs["ed25519:" + pubKey] = key.sign(anotherjson.stringify(obj))); } finally { obj.signatures = sigs; if (unsigned) obj.unsigned = unsigned; @@ -523,9 +495,9 @@ export function pkSign(obj: IObject, key: PkSigning, userId: string, pubKey: str /** * Verify a signed JSON object - * @param {Object} obj Object to verify - * @param {string} pubKey The public key to use to verify - * @param {string} userId The user ID who signed the object + * @param obj - Object to verify + * @param pubKey - The public key to use to verify + * @param userId - The user ID who signed the object */ export function pkVerify(obj: IObject, pubKey: string, userId: string): void { const keyId = "ed25519:" + pubKey; @@ -555,8 +527,10 @@ export function isOlmEncrypted(event: MatrixEvent): boolean { logger.error("Event has no sender key (not encrypted?)"); return false; } - if (event.getWireType() !== EventType.RoomMessageEncrypted || - !(["m.olm.v1.curve25519-aes-sha2"].includes(event.getWireContent().algorithm))) { + if ( + event.getWireType() !== EventType.RoomMessageEncrypted || + !["m.olm.v1.curve25519-aes-sha2"].includes(event.getWireContent().algorithm) + ) { logger.error("Event was not encrypted using an appropriate algorithm"); return false; } @@ -565,8 +539,8 @@ export function isOlmEncrypted(event: MatrixEvent): boolean { /** * Encode a typed array of uint8 as base64. - * @param {Uint8Array} uint8Array The data to encode. - * @return {string} The base64. + * @param uint8Array - The data to encode. + * @returns The base64. */ export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string { return Buffer.from(uint8Array).toString("base64"); @@ -574,17 +548,17 @@ export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string { /** * Encode a typed array of uint8 as unpadded base64. - * @param {Uint8Array} uint8Array The data to encode. - * @return {string} The unpadded base64. + * @param uint8Array - The data to encode. + * @returns The unpadded base64. */ export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string { - return encodeBase64(uint8Array).replace(/=+$/g, ''); + return encodeBase64(uint8Array).replace(/=+$/g, ""); } /** * Decode a base64 string to a typed array of uint8. - * @param {string} base64 The base64 to decode. - * @return {Uint8Array} The decoded data. + * @param base64 - The base64 to decode. + * @returns The decoded data. */ export function decodeBase64(base64: string): Uint8Array { return Buffer.from(base64, "base64"); diff --git a/src/crypto/recoverykey.ts b/src/crypto/recoverykey.ts index 449bc218b35..4107b76f5ab 100644 --- a/src/crypto/recoverykey.ts +++ b/src/crypto/recoverykey.ts @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import * as bs58 from 'bs58'; +import * as bs58 from "bs58"; // picked arbitrarily but to try & avoid clashing with any bitcoin ones // (which are also base58 encoded, but bitcoin's involve a lot more hashing) -const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01]; +const OLM_RECOVERY_KEY_PREFIX = [0x8b, 0x01]; export function encodeRecoveryKey(key: ArrayLike): string | undefined { const buf = Buffer.alloc(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1); @@ -36,7 +36,7 @@ export function encodeRecoveryKey(key: ArrayLike): string | undefined { } export function decodeRecoveryKey(recoveryKey: string): Uint8Array { - const result = bs58.decode(recoveryKey.replace(/ /g, '')); + const result = bs58.decode(recoveryKey.replace(/ /g, "")); let parity = 0; for (const b of result) { @@ -52,15 +52,11 @@ export function decodeRecoveryKey(recoveryKey: string): Uint8Array { } } - if ( - result.length !== - OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH + 1 - ) { + if (result.length !== OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH + 1) { throw new Error("Incorrect length"); } - return Uint8Array.from(result.slice( - OLM_RECOVERY_KEY_PREFIX.length, - OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH, - )); + return Uint8Array.from( + result.slice(OLM_RECOVERY_KEY_PREFIX.length, OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH), + ); } diff --git a/src/crypto/store/base.ts b/src/crypto/store/base.ts index 0615822271d..4c88ec2872e 100644 --- a/src/crypto/store/base.ts +++ b/src/crypto/store/base.ts @@ -30,12 +30,10 @@ import { IEncryptedPayload } from "../aes"; /** * Internal module. Definitions for storage for the crypto module - * - * @module */ export interface SecretStorePrivateKeys { - dehydration: { + "dehydration": { keyInfo: DehydrationManager["keyInfo"]; key: IEncryptedPayload; deviceDisplayName: string; @@ -46,8 +44,6 @@ export interface SecretStorePrivateKeys { /** * Abstraction of things that can store data required for end-to-end encryption - * - * @interface CryptoStore */ export interface CryptoStore { startup(): Promise; @@ -69,7 +65,7 @@ export interface CryptoStore { deleteOutgoingRoomKeyRequest(requestId: string, expectedState: number): Promise; // Olm Account - getAccount(txn: unknown, func: (accountPickle: string | null) => void); + getAccount(txn: unknown, func: (accountPickle: string | null) => void): void; storeAccount(txn: unknown, accountPickle: string): void; getCrossSigningKeys(txn: unknown, func: (keys: Record | null) => void): void; getSecretStorePrivateKey( @@ -110,10 +106,7 @@ export interface CryptoStore { txn: unknown, func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void, ): void; - getAllEndToEndInboundGroupSessions( - txn: unknown, - func: (session: ISession | null) => void, - ): void; + getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void; addEndToEndInboundGroupSession( senderCurve25519Key: string, sessionId: string, @@ -171,12 +164,12 @@ export interface ISessionInfo { export interface IDeviceData { devices: { - [ userId: string ]: { - [ deviceId: string ]: IDevice; + [userId: string]: { + [deviceId: string]: IDevice; }; }; trackingStatus: { - [ userId: string ]: TrackingStatus; + [userId: string]: TrackingStatus; }; crossSigningInfo?: Record; syncToken?: string; @@ -197,32 +190,29 @@ export interface IWithheld { /** * Represents an outgoing room key request - * - * @typedef {Object} OutgoingRoomKeyRequest - * - * @property {string} requestId unique id for this request. Used for both - * an id within the request for later pairing with a cancellation, and for - * the transaction id when sending the to_device messages to our local - * server. - * - * @property {string?} cancellationTxnId - * transaction id for the cancellation, if any - * - * @property {Array<{userId: string, deviceId: string}>} recipients - * list of recipients for the request - * - * @property {module:crypto~RoomKeyRequestBody} requestBody - * parameters for the request. - * - * @property {Number} state current state of this request (states are defined - * in {@link module:crypto/OutgoingRoomKeyRequestManager~ROOM_KEY_REQUEST_STATES}) */ export interface OutgoingRoomKeyRequest { + /** + * Unique id for this request. Used for both an id within the request for later pairing with a cancellation, + * and for the transaction id when sending the to_device messages to our local server. + */ requestId: string; requestTxnId?: string; + /** + * Transaction id for the cancellation, if any + */ cancellationTxnId?: string; + /** + * List of recipients for the request + */ recipients: IRoomKeyRequestRecipient[]; + /** + * Parameters for the request + */ requestBody: IRoomKeyRequestBody; + /** + * current state of this request (states are defined in {@link OutgoingRoomKeyRequestManager}) + */ state: RoomKeyRequestState; } diff --git a/src/crypto/store/indexeddb-crypto-store-backend.ts b/src/crypto/store/indexeddb-crypto-store-backend.ts index cdf35e787a5..7827697ec8d 100644 --- a/src/crypto/store/indexeddb-crypto-store-backend.ts +++ b/src/crypto/store/indexeddb-crypto-store-backend.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { logger, PrefixedLogger } from '../../logger'; +import { logger, PrefixedLogger } from "../../logger"; import * as utils from "../../utils"; import { CryptoStore, @@ -25,7 +25,8 @@ import { IWithheld, Mode, OutgoingRoomKeyRequest, - ParkedSharedHistory, SecretStorePrivateKeys, + ParkedSharedHistory, + SecretStorePrivateKeys, } from "./base"; import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index"; import { ICrossSigningKey } from "../../client"; @@ -39,14 +40,11 @@ const PROFILE_TRANSACTIONS = false; * Implementation of a CryptoStore which is backed by an existing * IndexedDB connection. Generally you want IndexedDBCryptoStore * which connects to the database and defers to one of these. - * - * @implements {module:crypto/store/base~CryptoStore} */ export class Backend implements CryptoStore { private nextTxnId = 0; /** - * @param {IDBDatabase} db */ public constructor(private db: IDBDatabase) { // make sure we close the db on `onversionchange` - otherwise @@ -71,10 +69,9 @@ export class Backend implements CryptoStore { * Look for an existing outgoing room key request, and if none is found, * add a new one * - * @param {module:crypto/store/base~OutgoingRoomKeyRequest} request * - * @returns {Promise} resolves to - * {@link module:crypto/store/base~OutgoingRoomKeyRequest}: either the + * @returns resolves to + * {@link OutgoingRoomKeyRequest}: either the * same instance as passed in, or the existing one. */ public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise { @@ -99,11 +96,10 @@ export class Backend implements CryptoStore { // we got to the end of the list without finding a match // - add the new request. - logger.log( - `enqueueing key request for ${requestBody.room_id} / ` + - requestBody.session_id, - ); - txn.oncomplete = (): void => {resolve(request);}; + logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id); + txn.oncomplete = (): void => { + resolve(request); + }; const store = txn.objectStore("outgoingRoomKeyRequests"); store.add(request); }); @@ -113,11 +109,10 @@ export class Backend implements CryptoStore { /** * Look for an existing room key request * - * @param {module:crypto~RoomKeyRequestBody} requestBody - * existing request to look for + * @param requestBody - existing request to look for * - * @return {Promise} resolves to the matching - * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * @returns resolves to the matching + * {@link OutgoingRoomKeyRequest}, or null if * not found */ public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise { @@ -134,13 +129,12 @@ export class Backend implements CryptoStore { /** * look for an existing room key request in the db * - * @private - * @param {IDBTransaction} txn database transaction - * @param {module:crypto~RoomKeyRequestBody} requestBody - * existing request to look for - * @param {Function} callback function to call with the results of the + * @internal + * @param txn - database transaction + * @param requestBody - existing request to look for + * @param callback - function to call with the results of the * search. Either passed a matching - * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * {@link OutgoingRoomKeyRequest}, or null if * not found. */ // eslint-disable-next-line @typescript-eslint/naming-convention @@ -152,10 +146,7 @@ export class Backend implements CryptoStore { const store = txn.objectStore("outgoingRoomKeyRequests"); const idx = store.index("session"); - const cursorReq = idx.openCursor([ - requestBody.room_id, - requestBody.session_id, - ]); + const cursorReq = idx.openCursor([requestBody.room_id, requestBody.session_id]); cursorReq.onsuccess = (): void => { const cursor = cursorReq.result; @@ -181,10 +172,10 @@ export class Backend implements CryptoStore { /** * Look for room key requests by state * - * @param {Array} wantedStates list of acceptable states + * @param wantedStates - list of acceptable states * - * @return {Promise} resolves to the a - * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * @returns resolves to the a + * {@link OutgoingRoomKeyRequest}, or null if * there are no pending requests in those states. If there are multiple * requests in those states, an arbitrary one is chosen. */ @@ -233,8 +224,7 @@ export class Backend implements CryptoStore { /** * - * @param {Number} wantedState - * @return {Promise>} All elements in a given state + * @returns All elements in a given state */ public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise { return new Promise((resolve, reject) => { @@ -260,9 +250,12 @@ export class Backend implements CryptoStore { const cursor = this.result; if (cursor) { const keyReq = cursor.value; - if (keyReq.recipients.some((recipient: IRoomKeyRequestRecipient) => - recipient.userId === userId && recipient.deviceId === deviceId, - )) { + if ( + keyReq.recipients.some( + (recipient: IRoomKeyRequestRecipient) => + recipient.userId === userId && recipient.deviceId === deviceId, + ) + ) { results.push(keyReq); } cursor.continue(); @@ -294,12 +287,12 @@ export class Backend implements CryptoStore { * Look for an existing room key request by id and state, and update it if * found * - * @param {string} requestId ID of request to update - * @param {number} expectedState state we expect to find the request in - * @param {Object} updates name/value map of updates to apply + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in + * @param updates - name/value map of updates to apply * - * @returns {Promise} resolves to - * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + * @returns resolves to + * {@link OutgoingRoomKeyRequest} * updated request, or null if no matching row was found */ public updateOutgoingRoomKeyRequest( @@ -318,7 +311,7 @@ export class Backend implements CryptoStore { if (data.state != expectedState) { logger.warn( `Cannot update room key request from ${expectedState} ` + - `as it was already updated to ${data.state}`, + `as it was already updated to ${data.state}`, ); return; } @@ -337,10 +330,10 @@ export class Backend implements CryptoStore { * Look for an existing room key request by id and state, and delete it if * found * - * @param {string} requestId ID of request to update - * @param {number} expectedState state we expect to find the request in + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in * - * @returns {Promise} resolves once the operation is completed + * @returns resolves once the operation is completed */ public deleteOutgoingRoomKeyRequest( requestId: string, @@ -355,10 +348,7 @@ export class Backend implements CryptoStore { } const data = cursor.value; if (data.state != expectedState) { - logger.warn( - `Cannot delete room key request in state ${data.state} ` - + `(expected ${expectedState})`, - ); + logger.warn(`Cannot delete room key request in state ${data.state} ` + `(expected ${expectedState})`); return; } cursor.delete(); @@ -371,7 +361,7 @@ export class Backend implements CryptoStore { public getAccount(txn: IDBTransaction, func: (accountPickle: string | null) => void): void { const objectStore = txn.objectStore("account"); const getReq = objectStore.get("-"); - getReq.onsuccess = function(): void { + getReq.onsuccess = function (): void { try { func(getReq.result || null); } catch (e) { @@ -391,7 +381,7 @@ export class Backend implements CryptoStore { ): void { const objectStore = txn.objectStore("account"); const getReq = objectStore.get("crossSigningKeys"); - getReq.onsuccess = function(): void { + getReq.onsuccess = function (): void { try { func(getReq.result || null); } catch (e) { @@ -407,7 +397,7 @@ export class Backend implements CryptoStore { ): void { const objectStore = txn.objectStore("account"); const getReq = objectStore.get(`ssss_cache:${type}`); - getReq.onsuccess = function(): void { + getReq.onsuccess = function (): void { try { func(getReq.result || null); } catch (e) { @@ -435,7 +425,7 @@ export class Backend implements CryptoStore { public countEndToEndSessions(txn: IDBTransaction, func: (count: number) => void): void { const objectStore = txn.objectStore("sessions"); const countReq = objectStore.count(); - countReq.onsuccess = function(): void { + countReq.onsuccess = function (): void { try { func(countReq.result); } catch (e) { @@ -453,7 +443,7 @@ export class Backend implements CryptoStore { const idx = objectStore.index("deviceKey"); const getReq = idx.openCursor(deviceKey); const results: Parameters[2]>[0] = {}; - getReq.onsuccess = function(): void { + getReq.onsuccess = function (): void { const cursor = getReq.result; if (cursor) { results[cursor.value.sessionId] = { @@ -479,7 +469,7 @@ export class Backend implements CryptoStore { ): void { const objectStore = txn.objectStore("sessions"); const getReq = objectStore.get([deviceKey, sessionId]); - getReq.onsuccess = function(): void { + getReq.onsuccess = function (): void { try { if (getReq.result) { func({ @@ -498,7 +488,7 @@ export class Backend implements CryptoStore { public getAllEndToEndSessions(txn: IDBTransaction, func: (session: ISessionInfo | null) => void): void { const objectStore = txn.objectStore("sessions"); const getReq = objectStore.openCursor(); - getReq.onsuccess = function(): void { + getReq.onsuccess = function (): void { try { const cursor = getReq.result; if (cursor) { @@ -579,19 +569,21 @@ export class Backend implements CryptoStore { const ret: IOlmDevice[] = []; - await Promise.all(devices.map((device) => { - return new Promise((resolve) => { - const { userId, deviceInfo } = device; - const getReq = objectStore.get([userId, deviceInfo.deviceId]); - getReq.onsuccess = function(): void { - if (!getReq.result) { - objectStore.put({ userId, deviceId: deviceInfo.deviceId }); - ret.push(device); - } - resolve(); - }; - }); - })); + await Promise.all( + devices.map((device) => { + return new Promise((resolve) => { + const { userId, deviceInfo } = device; + const getReq = objectStore.get([userId, deviceInfo.deviceId]); + getReq.onsuccess = function (): void { + if (!getReq.result) { + objectStore.put({ userId, deviceId: deviceInfo.deviceId }); + ret.push(device); + } + resolve(); + }; + }); + }), + ); return ret; } @@ -608,7 +600,7 @@ export class Backend implements CryptoStore { let withheld: IWithheld | null | boolean = false; const objectStore = txn.objectStore("inbound_group_sessions"); const getReq = objectStore.get([senderCurve25519Key, sessionId]); - getReq.onsuccess = function(): void { + getReq.onsuccess = function (): void { try { if (getReq.result) { session = getReq.result.session; @@ -625,7 +617,7 @@ export class Backend implements CryptoStore { const withheldObjectStore = txn.objectStore("inbound_group_sessions_withheld"); const withheldGetReq = withheldObjectStore.get([senderCurve25519Key, sessionId]); - withheldGetReq.onsuccess = function(): void { + withheldGetReq.onsuccess = function (): void { try { if (withheldGetReq.result) { withheld = withheldGetReq.result.session; @@ -644,7 +636,7 @@ export class Backend implements CryptoStore { public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void { const objectStore = txn.objectStore("inbound_group_sessions"); const getReq = objectStore.openCursor(); - getReq.onsuccess = function(): void { + getReq.onsuccess = function (): void { const cursor = getReq.result; if (cursor) { try { @@ -675,22 +667,19 @@ export class Backend implements CryptoStore { ): void { const objectStore = txn.objectStore("inbound_group_sessions"); const addReq = objectStore.add({ - senderCurve25519Key, sessionId, session: sessionData, + senderCurve25519Key, + sessionId, + session: sessionData, }); addReq.onerror = (ev): void => { - if (addReq.error?.name === 'ConstraintError') { + if (addReq.error?.name === "ConstraintError") { // This stops the error from triggering the txn's onerror ev.stopPropagation(); // ...and this stops it from aborting the transaction ev.preventDefault(); - logger.log( - "Ignoring duplicate inbound group session: " + - senderCurve25519Key + " / " + sessionId, - ); + logger.log("Ignoring duplicate inbound group session: " + senderCurve25519Key + " / " + sessionId); } else { - abortWithException(txn, new Error( - "Failed to add inbound group session: " + addReq.error, - )); + abortWithException(txn, new Error("Failed to add inbound group session: " + addReq.error)); } }; } @@ -703,7 +692,9 @@ export class Backend implements CryptoStore { ): void { const objectStore = txn.objectStore("inbound_group_sessions"); objectStore.put({ - senderCurve25519Key, sessionId, session: sessionData, + senderCurve25519Key, + sessionId, + session: sessionData, }); } @@ -715,14 +706,16 @@ export class Backend implements CryptoStore { ): void { const objectStore = txn.objectStore("inbound_group_sessions_withheld"); objectStore.put({ - senderCurve25519Key, sessionId, session: sessionData, + senderCurve25519Key, + sessionId, + session: sessionData, }); } public getEndToEndDeviceData(txn: IDBTransaction, func: (deviceData: IDeviceData | null) => void): void { const objectStore = txn.objectStore("device_data"); const getReq = objectStore.get("-"); - getReq.onsuccess = function(): void { + getReq.onsuccess = function (): void { try { func(getReq.result || null); } catch (e) { @@ -745,7 +738,7 @@ export class Backend implements CryptoStore { const rooms: Parameters[1]>[0] = {}; const objectStore = txn.objectStore("rooms"); const getReq = objectStore.openCursor(); - getReq.onsuccess = function(): void { + getReq.onsuccess = function (): void { const cursor = getReq.result; if (cursor) { rooms[cursor.key as string] = cursor.value; @@ -766,22 +759,19 @@ export class Backend implements CryptoStore { return new Promise((resolve, reject) => { const sessions: ISession[] = []; - const txn = this.db.transaction( - ["sessions_needing_backup", "inbound_group_sessions"], - "readonly", - ); + const txn = this.db.transaction(["sessions_needing_backup", "inbound_group_sessions"], "readonly"); txn.onerror = reject; - txn.oncomplete = function(): void { + txn.oncomplete = function (): void { resolve(sessions); }; const objectStore = txn.objectStore("sessions_needing_backup"); const sessionStore = txn.objectStore("inbound_group_sessions"); const getReq = objectStore.openCursor(); - getReq.onsuccess = function(): void { + getReq.onsuccess = function (): void { const cursor = getReq.result; if (cursor) { const sessionGetReq = sessionStore.get(cursor.key); - sessionGetReq.onsuccess = function(): void { + sessionGetReq.onsuccess = function (): void { sessions.push({ senderKey: sessionGetReq.result.senderCurve25519Key, sessionId: sessionGetReq.result.sessionId, @@ -813,13 +803,15 @@ export class Backend implements CryptoStore { txn = this.db.transaction("sessions_needing_backup", "readwrite"); } const objectStore = txn.objectStore("sessions_needing_backup"); - await Promise.all(sessions.map((session) => { - return new Promise((resolve, reject) => { - const req = objectStore.delete([session.senderKey, session.sessionId]); - req.onsuccess = resolve; - req.onerror = reject; - }); - })); + await Promise.all( + sessions.map((session) => { + return new Promise((resolve, reject) => { + const req = objectStore.delete([session.senderKey, session.sessionId]); + req.onsuccess = resolve; + req.onerror = reject; + }); + }), + ); } public async markSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise { @@ -827,16 +819,18 @@ export class Backend implements CryptoStore { txn = this.db.transaction("sessions_needing_backup", "readwrite"); } const objectStore = txn.objectStore("sessions_needing_backup"); - await Promise.all(sessions.map((session) => { - return new Promise((resolve, reject) => { - const req = objectStore.put({ - senderCurve25519Key: session.senderKey, - sessionId: session.sessionId, + await Promise.all( + sessions.map((session) => { + return new Promise((resolve, reject) => { + const req = objectStore.put({ + senderCurve25519Key: session.senderKey, + sessionId: session.sessionId, + }); + req.onsuccess = resolve; + req.onerror = reject; }); - req.onsuccess = resolve; - req.onerror = reject; - }); - })); + }), + ); } public addSharedHistoryInboundGroupSession( @@ -846,9 +840,7 @@ export class Backend implements CryptoStore { txn?: IDBTransaction, ): void { if (!txn) { - txn = this.db.transaction( - "shared_history_inbound_group_sessions", "readwrite", - ); + txn = this.db.transaction("shared_history_inbound_group_sessions", "readwrite"); } const objectStore = txn.objectStore("shared_history_inbound_group_sessions"); const req = objectStore.get([roomId]); @@ -864,9 +856,7 @@ export class Backend implements CryptoStore { txn?: IDBTransaction, ): Promise<[senderKey: string, sessionId: string][]> { if (!txn) { - txn = this.db.transaction( - "shared_history_inbound_group_sessions", "readonly", - ); + txn = this.db.transaction("shared_history_inbound_group_sessions", "readonly"); } const objectStore = txn.objectStore("shared_history_inbound_group_sessions"); const req = objectStore.get([roomId]); @@ -879,15 +869,9 @@ export class Backend implements CryptoStore { }); } - public addParkedSharedHistory( - roomId: string, - parkedData: ParkedSharedHistory, - txn?: IDBTransaction, - ): void { + public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory, txn?: IDBTransaction): void { if (!txn) { - txn = this.db.transaction( - "parked_shared_history", "readwrite", - ); + txn = this.db.transaction("parked_shared_history", "readwrite"); } const objectStore = txn.objectStore("parked_shared_history"); const req = objectStore.get([roomId]); @@ -898,14 +882,9 @@ export class Backend implements CryptoStore { }; } - public takeParkedSharedHistory( - roomId: string, - txn?: IDBTransaction, - ): Promise { + public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise { if (!txn) { - txn = this.db.transaction( - "parked_shared_history", "readwrite", - ); + txn = this.db.transaction("parked_shared_history", "readwrite"); } const cursorReq = txn.objectStore("parked_shared_history").openCursor(roomId); return new Promise((resolve, reject) => { @@ -941,13 +920,16 @@ export class Backend implements CryptoStore { const promise = promiseifyTxn(txn); const result = func(txn); if (PROFILE_TRANSACTIONS) { - promise.then(() => { - const elapsedTime = Date.now() - startTime; - log.debug(`Finished ${description}, took ${elapsedTime} ms`); - }, () => { - const elapsedTime = Date.now() - startTime; - log.error(`Failed ${description}, took ${elapsedTime} ms`); - }); + promise.then( + () => { + const elapsedTime = Date.now() - startTime; + log.debug(`Finished ${description}, took ${elapsedTime} ms`); + }, + () => { + const elapsedTime = Date.now() - startTime; + log.error(`Failed ${description}, took ${elapsedTime} ms`); + }, + ); } return promise.then(() => { return result; @@ -957,8 +939,12 @@ export class Backend implements CryptoStore { type DbMigration = (db: IDBDatabase) => void; const DB_MIGRATIONS: DbMigration[] = [ - (db): void => { createDatabase(db); }, - (db): void => { db.createObjectStore("account"); }, + (db): void => { + createDatabase(db); + }, + (db): void => { + db.createObjectStore("account"); + }, (db): void => { const sessionsStore = db.createObjectStore("sessions", { keyPath: ["deviceKey", "sessionId"], @@ -970,8 +956,12 @@ const DB_MIGRATIONS: DbMigration[] = [ keyPath: ["senderCurve25519Key", "sessionId"], }); }, - (db): void => { db.createObjectStore("device_data"); }, - (db): void => { db.createObjectStore("rooms"); }, + (db): void => { + db.createObjectStore("device_data"); + }, + (db): void => { + db.createObjectStore("rooms"); + }, (db): void => { db.createObjectStore("sessions_needing_backup", { keyPath: ["senderCurve25519Key", "sessionId"], @@ -1007,24 +997,18 @@ const DB_MIGRATIONS: DbMigration[] = [ export const VERSION = DB_MIGRATIONS.length; export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void { - logger.log( - `Upgrading IndexedDBCryptoStore from version ${oldVersion}` - + ` to ${VERSION}`, - ); + logger.log(`Upgrading IndexedDBCryptoStore from version ${oldVersion}` + ` to ${VERSION}`); DB_MIGRATIONS.forEach((migration, index) => { if (oldVersion <= index) migration(db); }); } function createDatabase(db: IDBDatabase): void { - const outgoingRoomKeyRequestsStore = - db.createObjectStore("outgoingRoomKeyRequests", { keyPath: "requestId" }); + const outgoingRoomKeyRequestsStore = db.createObjectStore("outgoingRoomKeyRequests", { keyPath: "requestId" }); // we assume that the RoomKeyRequestBody will have room_id and session_id // properties, to make the index efficient. - outgoingRoomKeyRequestsStore.createIndex("session", - ["requestBody.room_id", "requestBody.session_id"], - ); + outgoingRoomKeyRequestsStore.createIndex("session", ["requestBody.room_id", "requestBody.session_id"]); outgoingRoomKeyRequestsStore.createIndex("state", "state"); } diff --git a/src/crypto/store/indexeddb-crypto-store.ts b/src/crypto/store/indexeddb-crypto-store.ts index d743a95decc..320235fbf63 100644 --- a/src/crypto/store/indexeddb-crypto-store.ts +++ b/src/crypto/store/indexeddb-crypto-store.ts @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { logger, PrefixedLogger } from '../../logger'; -import { LocalStorageCryptoStore } from './localStorage-crypto-store'; -import { MemoryCryptoStore } from './memory-crypto-store'; -import * as IndexedDBCryptoStoreBackend from './indexeddb-crypto-store-backend'; -import { InvalidCryptoStoreError, InvalidCryptoStoreState } from '../../errors'; +import { logger, PrefixedLogger } from "../../logger"; +import { LocalStorageCryptoStore } from "./localStorage-crypto-store"; +import { MemoryCryptoStore } from "./memory-crypto-store"; +import * as IndexedDBCryptoStoreBackend from "./indexeddb-crypto-store-backend"; +import { InvalidCryptoStoreError, InvalidCryptoStoreState } from "../../errors"; import * as IndexedDBHelpers from "../../indexeddb-helpers"; import { CryptoStore, @@ -29,7 +29,8 @@ import { IWithheld, Mode, OutgoingRoomKeyRequest, - ParkedSharedHistory, SecretStorePrivateKeys, + ParkedSharedHistory, + SecretStorePrivateKeys, } from "./base"; import { IRoomKeyRequestBody } from "../index"; import { ICrossSigningKey } from "../../client"; @@ -39,26 +40,22 @@ import { InboundGroupSessionData } from "../OlmDevice"; /** * Internal module. indexeddb storage for e2e. - * - * @module */ /** * An implementation of CryptoStore, which is normally backed by an indexeddb, * but with fallback to MemoryCryptoStore. - * - * @implements {module:crypto/store/base~CryptoStore} */ export class IndexedDBCryptoStore implements CryptoStore { - public static STORE_ACCOUNT = 'account'; - public static STORE_SESSIONS = 'sessions'; - 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'; + public static STORE_ACCOUNT = "account"; + public static STORE_SESSIONS = "sessions"; + 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"; public static exists(indexedDB: IDBFactory, dbName: string): Promise { return IndexedDBHelpers.exists(indexedDB, dbName); @@ -70,8 +67,8 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Create a new IndexedDBCryptoStore * - * @param {IDBFactory} indexedDB global indexedDB instance - * @param {string} dbName name of db to connect to + * @param indexedDB - global indexedDB instance + * @param dbName - name of db to connect to */ public constructor(private readonly indexedDB: IDBFactory, private readonly dbName: string) {} @@ -81,7 +78,7 @@ export class IndexedDBCryptoStore implements CryptoStore { * * This must be called before the store can be used. * - * @return {Promise} resolves to either an IndexedDBCryptoStoreBackend.Backend, + * @returns resolves to either an IndexedDBCryptoStoreBackend.Backend, * or a MemoryCryptoStore */ public startup(): Promise { @@ -91,7 +88,7 @@ export class IndexedDBCryptoStore implements CryptoStore { this.backendPromise = new Promise((resolve, reject) => { if (!this.indexedDB) { - reject(new Error('no indexeddb support available')); + reject(new Error("no indexeddb support available")); return; } @@ -106,9 +103,7 @@ export class IndexedDBCryptoStore implements CryptoStore { }; req.onblocked = (): void => { - logger.log( - `can't yet open IndexedDBCryptoStore because it is open elsewhere`, - ); + logger.log(`can't yet open IndexedDBCryptoStore because it is open elsewhere`); }; req.onerror = (ev): void => { @@ -122,44 +117,46 @@ export class IndexedDBCryptoStore implements CryptoStore { logger.log(`connected to indexeddb ${this.dbName}`); resolve(new IndexedDBCryptoStoreBackend.Backend(db)); }; - }).then((backend) => { - // Edge has IndexedDB but doesn't support compund keys which we use fairly extensively. - // Try a dummy query which will fail if the browser doesn't support compund keys, so - // we can fall back to a different backend. - return backend.doTxn( - 'readonly', - [ - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, - ], - (txn) => { - backend.getEndToEndInboundGroupSession('', '', txn, () => {}); - }).then(() => backend, - ); - }).catch((e) => { - if (e.name === 'VersionError') { - logger.warn("Crypto DB is too new for us to use!", e); - // don't fall back to a different store: the user has crypto data - // in this db so we should use it or nothing at all. - throw new InvalidCryptoStoreError(InvalidCryptoStoreState.TooNew); - } - logger.warn( - `unable to connect to indexeddb ${this.dbName}` + - `: falling back to localStorage store: ${e}`, - ); - - try { - return new LocalStorageCryptoStore(global.localStorage); - } catch (e) { + }) + .then((backend) => { + // Edge has IndexedDB but doesn't support compund keys which we use fairly extensively. + // Try a dummy query which will fail if the browser doesn't support compund keys, so + // we can fall back to a different backend. + return backend + .doTxn( + "readonly", + [ + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, + ], + (txn) => { + backend.getEndToEndInboundGroupSession("", "", txn, () => {}); + }, + ) + .then(() => backend); + }) + .catch((e) => { + if (e.name === "VersionError") { + logger.warn("Crypto DB is too new for us to use!", e); + // don't fall back to a different store: the user has crypto data + // in this db so we should use it or nothing at all. + throw new InvalidCryptoStoreError(InvalidCryptoStoreState.TooNew); + } logger.warn( - `unable to open localStorage: falling back to in-memory store: ${e}`, + `unable to connect to indexeddb ${this.dbName}` + `: falling back to localStorage store: ${e}`, ); - return new MemoryCryptoStore(); - } - }).then(backend => { - this.backend = backend; - return backend; - }); + + try { + return new LocalStorageCryptoStore(global.localStorage); + } catch (e) { + logger.warn(`unable to open localStorage: falling back to in-memory store: ${e}`); + return new MemoryCryptoStore(); + } + }) + .then((backend) => { + this.backend = backend; + return backend; + }); return this.backendPromise; } @@ -167,12 +164,12 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Delete all data from this store. * - * @returns {Promise} resolves when the store has been cleared. + * @returns resolves when the store has been cleared. */ public deleteAllData(): Promise { return new Promise((resolve, reject) => { if (!this.indexedDB) { - reject(new Error('no indexeddb support available')); + reject(new Error("no indexeddb support available")); return; } @@ -180,9 +177,7 @@ export class IndexedDBCryptoStore implements CryptoStore { const req = this.indexedDB.deleteDatabase(this.dbName); req.onblocked = (): void => { - logger.log( - `can't yet delete IndexedDBCryptoStore because it is open elsewhere`, - ); + logger.log(`can't yet delete IndexedDBCryptoStore because it is open elsewhere`); }; req.onerror = (ev): void => { @@ -206,10 +201,9 @@ export class IndexedDBCryptoStore implements CryptoStore { * Look for an existing outgoing room key request, and if none is found, * add a new one * - * @param {module:crypto/store/base~OutgoingRoomKeyRequest} request * - * @returns {Promise} resolves to - * {@link module:crypto/store/base~OutgoingRoomKeyRequest}: either the + * @returns resolves to + * {@link OutgoingRoomKeyRequest}: either the * same instance as passed in, or the existing one. */ public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise { @@ -219,11 +213,10 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Look for an existing room key request * - * @param {module:crypto~RoomKeyRequestBody} requestBody - * existing request to look for + * @param requestBody - existing request to look for * - * @return {Promise} resolves to the matching - * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * @returns resolves to the matching + * {@link OutgoingRoomKeyRequest}, or null if * not found */ public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise { @@ -233,10 +226,10 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Look for room key requests by state * - * @param {Array} wantedStates list of acceptable states + * @param wantedStates - list of acceptable states * - * @return {Promise} resolves to the a - * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * @returns resolves to the a + * {@link OutgoingRoomKeyRequest}, or null if * there are no pending requests in those states. If there are multiple * requests in those states, an arbitrary one is chosen. */ @@ -248,8 +241,7 @@ export class IndexedDBCryptoStore implements CryptoStore { * Look for room key requests by state – * unlike above, return a list of all entries in one state. * - * @param {Number} wantedState - * @return {Promise>} Returns an array of requests in the given state + * @returns Returns an array of requests in the given state */ public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise { return this.backend!.getAllOutgoingRoomKeyRequestsByState(wantedState); @@ -258,33 +250,31 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Look for room key requests by target device and state * - * @param {string} userId Target user ID - * @param {string} deviceId Target device ID - * @param {Array} wantedStates list of acceptable states + * @param userId - Target user ID + * @param deviceId - Target device ID + * @param wantedStates - list of acceptable states * - * @return {Promise} resolves to a list of all the - * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + * @returns resolves to a list of all the + * {@link OutgoingRoomKeyRequest} */ public getOutgoingRoomKeyRequestsByTarget( userId: string, deviceId: string, wantedStates: number[], ): Promise { - return this.backend!.getOutgoingRoomKeyRequestsByTarget( - userId, deviceId, wantedStates, - ); + return this.backend!.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates); } /** * Look for an existing room key request by id and state, and update it if * found * - * @param {string} requestId ID of request to update - * @param {number} expectedState state we expect to find the request in - * @param {Object} updates name/value map of updates to apply + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in + * @param updates - name/value map of updates to apply * - * @returns {Promise} resolves to - * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + * @returns resolves to + * {@link OutgoingRoomKeyRequest} * updated request, or null if no matching row was found */ public updateOutgoingRoomKeyRequest( @@ -292,19 +282,17 @@ export class IndexedDBCryptoStore implements CryptoStore { expectedState: number, updates: Partial, ): Promise { - return this.backend!.updateOutgoingRoomKeyRequest( - requestId, expectedState, updates, - ); + return this.backend!.updateOutgoingRoomKeyRequest(requestId, expectedState, updates); } /** * Look for an existing room key request by id and state, and delete it if * found * - * @param {string} requestId ID of request to update - * @param {number} expectedState state we expect to find the request in + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in * - * @returns {Promise} resolves once the operation is completed + * @returns resolves once the operation is completed */ public deleteOutgoingRoomKeyRequest( requestId: string, @@ -319,8 +307,8 @@ export class IndexedDBCryptoStore implements CryptoStore { * Get the account pickle from the store. * This requires an active transaction. See doTxn(). * - * @param {*} txn An active transaction. See doTxn(). - * @param {function(string)} func Called with the account pickle + * @param txn - An active transaction. See doTxn(). + * @param func - Called with the account pickle */ public getAccount(txn: IDBTransaction, func: (accountPickle: string | null) => void): void { this.backend!.getAccount(txn, func); @@ -330,8 +318,8 @@ export class IndexedDBCryptoStore implements CryptoStore { * Write the account pickle to the store. * This requires an active transaction. See doTxn(). * - * @param {*} txn An active transaction. See doTxn(). - * @param {string} accountPickle The new account pickle to store. + * @param txn - An active transaction. See doTxn(). + * @param accountPickle - The new account pickle to store. */ public storeAccount(txn: IDBTransaction, accountPickle: string): void { this.backend!.storeAccount(txn, accountPickle); @@ -341,9 +329,9 @@ export class IndexedDBCryptoStore implements CryptoStore { * Get the public part of the cross-signing keys (eg. self-signing key, * user signing key). * - * @param {*} txn An active transaction. See doTxn(). - * @param {function(string)} func Called with the account keys object: - * { key_type: base64 encoded seed } where key type = user_signing_key_seed or self_signing_key_seed + * @param txn - An active transaction. See doTxn(). + * @param func - Called with the account keys object: + * `{ key_type: base64 encoded seed }` where key type = user_signing_key_seed or self_signing_key_seed */ public getCrossSigningKeys( txn: IDBTransaction, @@ -353,9 +341,9 @@ export class IndexedDBCryptoStore implements CryptoStore { } /** - * @param {*} txn An active transaction. See doTxn(). - * @param {function(string)} func Called with the private key - * @param {string} type A key type + * @param txn - An active transaction. See doTxn(). + * @param func - Called with the private key + * @param type - A key type */ public getSecretStorePrivateKey( txn: IDBTransaction, @@ -368,8 +356,8 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Write the cross-signing keys back to the store * - * @param {*} txn An active transaction. See doTxn(). - * @param {string} keys keys object as getCrossSigningKeys() + * @param txn - An active transaction. See doTxn(). + * @param keys - keys object as getCrossSigningKeys() */ public storeCrossSigningKeys(txn: IDBTransaction, keys: Record): void { this.backend!.storeCrossSigningKeys(txn, keys); @@ -378,9 +366,9 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Write the cross-signing private keys back to the store * - * @param {*} txn An active transaction. See doTxn(). - * @param {string} type The type of cross-signing private key to store - * @param {string} key keys object as getCrossSigningKeys() + * @param txn - An active transaction. See doTxn(). + * @param type - The type of cross-signing private key to store + * @param key - keys object as getCrossSigningKeys() */ public storeSecretStorePrivateKey( txn: IDBTransaction, @@ -394,8 +382,8 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Returns the number of end-to-end sessions in the store - * @param {*} txn An active transaction. See doTxn(). - * @param {function(int)} func Called with the count of sessions + * @param txn - An active transaction. See doTxn(). + * @param func - Called with the count of sessions */ public countEndToEndSessions(txn: IDBTransaction, func: (count: number) => void): void { this.backend!.countEndToEndSessions(txn, func); @@ -404,10 +392,10 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Retrieve a specific end-to-end session between the logged-in user * and another device. - * @param {string} deviceKey The public key of the other device. - * @param {string} sessionId The ID of the session to retrieve - * @param {*} txn An active transaction. See doTxn(). - * @param {function(object)} func Called with A map from sessionId + * @param deviceKey - The public key of the other device. + * @param sessionId - The ID of the session to retrieve + * @param txn - An active transaction. See doTxn(). + * @param func - Called with A map from sessionId * to session information object with 'session' key being the * Base64 end-to-end session and lastReceivedMessageTs being the * timestamp in milliseconds at which the session last received @@ -425,9 +413,9 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Retrieve the end-to-end sessions between the logged-in user and another * device. - * @param {string} deviceKey The public key of the other device. - * @param {*} txn An active transaction. See doTxn(). - * @param {function(object)} func Called with A map from sessionId + * @param deviceKey - The public key of the other device. + * @param txn - An active transaction. See doTxn(). + * @param func - Called with A map from sessionId * to session information object with 'session' key being the * Base64 end-to-end session and lastReceivedMessageTs being the * timestamp in milliseconds at which the session last received @@ -443,8 +431,8 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Retrieve all end-to-end sessions - * @param {*} txn An active transaction. See doTxn(). - * @param {function(object)} func Called one for each session with + * @param txn - An active transaction. See doTxn(). + * @param func - Called one for each session with * an object with, deviceKey, lastReceivedMessageTs, sessionId * and session keys. */ @@ -454,10 +442,10 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Store a session between the logged-in user and another device - * @param {string} deviceKey The public key of the other device. - * @param {string} sessionId The ID for this end-to-end session. - * @param {string} sessionInfo Session information object - * @param {*} txn An active transaction. See doTxn(). + * @param deviceKey - The public key of the other device. + * @param sessionId - The ID for this end-to-end session. + * @param sessionInfo - Session information object + * @param txn - An active transaction. See doTxn(). */ public storeEndToEndSession( deviceKey: string, @@ -485,10 +473,10 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Retrieve the end-to-end inbound group session for a given * server key and session ID - * @param {string} senderCurve25519Key The sender's curve 25519 key - * @param {string} sessionId The ID of the session - * @param {*} txn An active transaction. See doTxn(). - * @param {function(object)} func Called with A map from sessionId + * @param senderCurve25519Key - The sender's curve 25519 key + * @param sessionId - The ID of the session + * @param txn - An active transaction. See doTxn(). + * @param func - Called with A map from sessionId * to Base64 end-to-end session. */ public getEndToEndInboundGroupSession( @@ -502,15 +490,12 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Fetches all inbound group sessions in the store - * @param {*} txn An active transaction. See doTxn(). - * @param {function(object)} func Called once for each group session - * in the store with an object having keys {senderKey, sessionId, - * sessionData}, then once with null to indicate the end of the list. + * @param txn - An active transaction. See doTxn(). + * @param func - Called once for each group session + * in the store with an object having keys `{senderKey, sessionId, sessionData}`, + * then once with null to indicate the end of the list. */ - public getAllEndToEndInboundGroupSessions( - txn: IDBTransaction, - func: (session: ISession | null) => void, - ): void { + public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void { this.backend!.getAllEndToEndInboundGroupSessions(txn, func); } @@ -518,10 +503,10 @@ export class IndexedDBCryptoStore implements CryptoStore { * Adds an end-to-end inbound group session to the store. * If there already exists an inbound group session with the same * senderCurve25519Key and sessionID, the session will not be added. - * @param {string} senderCurve25519Key The sender's curve 25519 key - * @param {string} sessionId The ID of the session - * @param {object} sessionData The session data structure - * @param {*} txn An active transaction. See doTxn(). + * @param senderCurve25519Key - The sender's curve 25519 key + * @param sessionId - The ID of the session + * @param sessionData - The session data structure + * @param txn - An active transaction. See doTxn(). */ public addEndToEndInboundGroupSession( senderCurve25519Key: string, @@ -536,10 +521,10 @@ export class IndexedDBCryptoStore implements CryptoStore { * Writes an end-to-end inbound group session to the store. * If there already exists an inbound group session with the same * senderCurve25519Key and sessionID, it will be overwritten. - * @param {string} senderCurve25519Key The sender's curve 25519 key - * @param {string} sessionId The ID of the session - * @param {object} sessionData The session data structure - * @param {*} txn An active transaction. See doTxn(). + * @param senderCurve25519Key - The sender's curve 25519 key + * @param sessionId - The ID of the session + * @param sessionData - The session data structure + * @param txn - An active transaction. See doTxn(). */ public storeEndToEndInboundGroupSession( senderCurve25519Key: string, @@ -568,8 +553,7 @@ export class IndexedDBCryptoStore implements CryptoStore { * These all need to be written out in full each time such that the snapshot * is always consistent, so they are stored in one object. * - * @param {Object} deviceData - * @param {*} txn An active transaction. See doTxn(). + * @param txn - An active transaction. See doTxn(). */ public storeEndToEndDeviceData(deviceData: IDeviceData, txn: IDBTransaction): void { this.backend!.storeEndToEndDeviceData(deviceData, txn); @@ -578,8 +562,8 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Get the state of all tracked devices * - * @param {*} txn An active transaction. See doTxn(). - * @param {function(Object)} func Function called with the + * @param txn - An active transaction. See doTxn(). + * @param func - Function called with the * device data */ public getEndToEndDeviceData(txn: IDBTransaction, func: (deviceData: IDeviceData | null) => void): void { @@ -590,18 +574,18 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Store the end-to-end state for a room. - * @param {string} roomId The room's ID. - * @param {object} roomInfo The end-to-end info for the room. - * @param {*} txn An active transaction. See doTxn(). + * @param roomId - The room's ID. + * @param roomInfo - The end-to-end info for the room. + * @param txn - An active transaction. See doTxn(). */ public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: IDBTransaction): void { this.backend!.storeEndToEndRoom(roomId, roomInfo, txn); } /** - * Get an object of roomId->roomInfo for all e2e rooms in the store - * @param {*} txn An active transaction. See doTxn(). - * @param {function(Object)} func Function called with the end to end encrypted rooms + * Get an object of `roomId->roomInfo` for all e2e rooms in the store + * @param txn - An active transaction. See doTxn(). + * @param func - Function called with the end-to-end encrypted rooms */ public getEndToEndRooms(txn: IDBTransaction, func: (rooms: Record) => void): void { this.backend!.getEndToEndRooms(txn, func); @@ -611,9 +595,9 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Get the inbound group sessions that need to be backed up. - * @param {number} limit The maximum number of sessions to retrieve. 0 + * @param limit - The maximum number of sessions to retrieve. 0 * for no limit. - * @returns {Promise} resolves to an array of inbound group sessions + * @returns resolves to an array of inbound group sessions */ public getSessionsNeedingBackup(limit: number): Promise { return this.backend!.getSessionsNeedingBackup(limit); @@ -621,8 +605,8 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Count the inbound group sessions that need to be backed up. - * @param {*} txn An active transaction. See doTxn(). (optional) - * @returns {Promise} resolves to the number of sessions + * @param txn - An active transaction. See doTxn(). (optional) + * @returns resolves to the number of sessions */ public countSessionsNeedingBackup(txn?: IDBTransaction): Promise { return this.backend!.countSessionsNeedingBackup(txn); @@ -630,9 +614,9 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Unmark sessions as needing to be backed up. - * @param {Array} sessions The sessions that need to be backed up. - * @param {*} txn An active transaction. See doTxn(). (optional) - * @returns {Promise} resolves when the sessions are unmarked + * @param sessions - The sessions that need to be backed up. + * @param txn - An active transaction. See doTxn(). (optional) + * @returns resolves when the sessions are unmarked */ public unmarkSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise { return this.backend!.unmarkSessionsNeedingBackup(sessions, txn); @@ -640,9 +624,9 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Mark sessions as needing to be backed up. - * @param {Array} sessions The sessions that need to be backed up. - * @param {*} txn An active transaction. See doTxn(). (optional) - * @returns {Promise} resolves when the sessions are marked + * @param sessions - The sessions that need to be backed up. + * @param txn - An active transaction. See doTxn(). (optional) + * @returns resolves when the sessions are marked */ public markSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise { return this.backend!.markSessionsNeedingBackup(sessions, txn); @@ -650,10 +634,10 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Add a shared-history group session for a room. - * @param {string} roomId The room that the key belongs to - * @param {string} senderKey The sender's curve 25519 key - * @param {string} sessionId The ID of the session - * @param {*} txn An active transaction. See doTxn(). (optional) + * @param roomId - The room that the key belongs to + * @param senderKey - The sender's curve 25519 key + * @param sessionId - The ID of the session + * @param txn - An active transaction. See doTxn(). (optional) */ public addSharedHistoryInboundGroupSession( roomId: string, @@ -666,9 +650,9 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Get the shared-history group session for a room. - * @param {string} roomId The room that the key belongs to - * @param {*} txn An active transaction. See doTxn(). (optional) - * @returns {Promise} Resolves to an array of [senderKey, sessionId] + * @param roomId - The room that the key belongs to + * @param txn - An active transaction. See doTxn(). (optional) + * @returns Promise which resolves to an array of [senderKey, sessionId] */ public getSharedHistoryInboundGroupSessions( roomId: string, @@ -680,21 +664,14 @@ export class IndexedDBCryptoStore implements CryptoStore { /** * Park a shared-history group session for a room we may be invited to later. */ - public addParkedSharedHistory( - roomId: string, - parkedData: ParkedSharedHistory, - txn?: IDBTransaction, - ): void { + 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 { + public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise { return this.backend!.takeParkedSharedHistory(roomId, txn); } @@ -704,16 +681,16 @@ export class IndexedDBCryptoStore implements CryptoStore { * only be called within a callback of either this function or * one of the store functions operating on the same transaction. * - * @param {string} mode 'readwrite' if you need to call setter + * @param mode - 'readwrite' if you need to call setter * functions with this transaction. Otherwise, 'readonly'. - * @param {string[]} stores List IndexedDBCryptoStore.STORE_* + * @param stores - List IndexedDBCryptoStore.STORE_* * options representing all types of object that will be * accessed or written to with this transaction. - * @param {function(*)} func Function called with the + * @param func - Function called with the * transaction object: an opaque object that should be passed * to store functions. - * @param {Logger} [log] A possibly customised log - * @return {Promise} Promise that resolves with the result of the `func` + * @param log - A possibly customised log + * @returns Promise that resolves with the result of the `func` * when the transaction is complete. If the backend is * async (ie. the indexeddb backend) any of the callback * functions throwing an exception will cause this promise to diff --git a/src/crypto/store/localStorage-crypto-store.ts b/src/crypto/store/localStorage-crypto-store.ts index 977236ef93f..1a9adfb25d1 100644 --- a/src/crypto/store/localStorage-crypto-store.ts +++ b/src/crypto/store/localStorage-crypto-store.ts @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { logger } from '../../logger'; -import { MemoryCryptoStore } from './memory-crypto-store'; +import { logger } from "../../logger"; +import { MemoryCryptoStore } from "./memory-crypto-store"; import { IDeviceData, IProblem, ISession, ISessionInfo, IWithheld, Mode, SecretStorePrivateKeys } from "./base"; import { IOlmDevice } from "../algorithms/megolm"; import { IRoomEncryption } from "../RoomList"; @@ -28,8 +28,6 @@ import { InboundGroupSessionData } from "../OlmDevice"; * some things backed by localStorage. It exists because indexedDB * is broken in Firefox private mode or set to, "will not remember * history". - * - * @module */ const E2E_PREFIX = "crypto."; @@ -62,9 +60,6 @@ function keyEndToEndRoomsPrefix(roomId: string): string { return KEY_ROOMS_PREFIX + roomId; } -/** - * @implements {module:crypto/store/base~CryptoStore} - */ export class LocalStorageCryptoStore extends MemoryCryptoStore { public static exists(store: Storage): boolean { const length = store.length; @@ -85,7 +80,7 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore { public countEndToEndSessions(txn: unknown, func: (count: number) => void): void { let count = 0; for (let i = 0; i < this.store.length; ++i) { - if (this.store.key(i)?.startsWith(keyEndToEndSessions(''))) ++count; + if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) ++count; } func(count); } @@ -97,7 +92,7 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore { // fix up any old sessions to be objects rather than just the base64 pickle for (const [sid, val] of Object.entries(sessions || {})) { - if (typeof val === 'string') { + if (typeof val === "string") { fixedSessions[sid] = { session: val, }; @@ -129,8 +124,8 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore { public getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo) => void): void { for (let i = 0; i < this.store.length; ++i) { - if (this.store.key(i)?.startsWith(keyEndToEndSessions(''))) { - const deviceKey = this.store.key(i)!.split('/')[1]; + if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) { + const deviceKey = this.store.key(i)!.split("/")[1]; for (const sess of Object.values(this._getEndToEndSessions(deviceKey))) { func(sess); } @@ -174,9 +169,8 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore { } public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise { - const notifiedErrorDevices = getJsonItem( - this.store, KEY_NOTIFIED_ERROR_DEVICES, - ) || {}; + const notifiedErrorDevices = + getJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES) || {}; const ret: IOlmDevice[] = []; for (const device of devices) { @@ -206,14 +200,8 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore { func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void, ): void { func( - getJsonItem( - this.store, - keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), - ), - getJsonItem( - this.store, - keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), - ), + getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId)), + getJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId)), ); } @@ -242,14 +230,9 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore { sessionData: InboundGroupSessionData, txn: unknown, ): void { - const existing = getJsonItem( - this.store, - keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), - ); + const existing = getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId)); if (!existing) { - this.storeEndToEndInboundGroupSession( - senderCurve25519Key, sessionId, sessionData, txn, - ); + this.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn); } } @@ -259,11 +242,7 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore { sessionData: InboundGroupSessionData, txn: unknown, ): void { - setJsonItem( - this.store, - keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), - sessionData, - ); + setJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), sessionData); } public storeEndToEndInboundGroupSessionWithheld( @@ -272,11 +251,7 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore { sessionData: IWithheld, txn: unknown, ): void { - setJsonItem( - this.store, - keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), - sessionData, - ); + setJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), sessionData); } public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void { @@ -293,7 +268,7 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore { public getEndToEndRooms(txn: unknown, func: (rooms: Record) => void): void { const result: Record = {}; - const prefix = keyEndToEndRoomsPrefix(''); + const prefix = keyEndToEndRoomsPrefix(""); for (let i = 0; i < this.store.length; ++i) { const key = this.store.key(i); @@ -314,16 +289,13 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore { // see getAllEndToEndInboundGroupSessions for the magic number explanations const senderKey = session.slice(0, 43); const sessionId = session.slice(44); - this.getEndToEndInboundGroupSession( - senderKey, sessionId, null, - (sessionData) => { - sessions.push({ - senderKey: senderKey, - sessionId: sessionId, - sessionData: sessionData!, - }); - }, - ); + this.getEndToEndInboundGroupSession(senderKey, sessionId, null, (sessionData) => { + sessions.push({ + senderKey: senderKey, + sessionId: sessionId, + sessionData: sessionData!, + }); + }); if (limit && sessions.length >= limit) { break; } @@ -338,33 +310,33 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore { } public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise { - const sessionsNeedingBackup - = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + const sessionsNeedingBackup = + getJsonItem<{ + [senderKeySessionId: string]: string; + }>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; for (const session of sessions) { - delete sessionsNeedingBackup[session.senderKey + '/' + session.sessionId]; + delete sessionsNeedingBackup[session.senderKey + "/" + session.sessionId]; } - setJsonItem( - this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup, - ); + setJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup); return Promise.resolve(); } public markSessionsNeedingBackup(sessions: ISession[]): Promise { - const sessionsNeedingBackup - = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + const sessionsNeedingBackup = + getJsonItem<{ + [senderKeySessionId: string]: boolean; + }>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; for (const session of sessions) { - sessionsNeedingBackup[session.senderKey + '/' + session.sessionId] = true; + sessionsNeedingBackup[session.senderKey + "/" + session.sessionId] = true; } - setJsonItem( - this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup, - ); + setJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup); return Promise.resolve(); } /** * Delete all data from this store. * - * @returns {Promise} Promise which resolves when the store has been cleared. + * @returns Promise which resolves when the store has been cleared. */ public deleteAllData(): Promise { this.store.removeItem(KEY_END_TO_END_ACCOUNT); diff --git a/src/crypto/store/memory-crypto-store.ts b/src/crypto/store/memory-crypto-store.ts index f22379ee81e..ad779ca993b 100644 --- a/src/crypto/store/memory-crypto-store.ts +++ b/src/crypto/store/memory-crypto-store.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { logger } from '../../logger'; +import { logger } from "../../logger"; import * as utils from "../../utils"; import { CryptoStore, @@ -25,7 +25,8 @@ import { IWithheld, Mode, OutgoingRoomKeyRequest, - ParkedSharedHistory, SecretStorePrivateKeys, + ParkedSharedHistory, + SecretStorePrivateKeys, } from "./base"; import { IRoomKeyRequestBody } from "../index"; import { ICrossSigningKey } from "../../client"; @@ -35,13 +36,8 @@ import { InboundGroupSessionData } from "../OlmDevice"; /** * Internal module. in-memory storage for e2e. - * - * @module */ -/** - * @implements {module:crypto/store/base~CryptoStore} - */ export class MemoryCryptoStore implements CryptoStore { private outgoingRoomKeyRequests: OutgoingRoomKeyRequest[] = []; private account: string | null = null; @@ -65,7 +61,7 @@ export class MemoryCryptoStore implements CryptoStore { * * This must be called before the store can be used. * - * @return {Promise} resolves to the store. + * @returns resolves to the store. */ public async startup(): Promise { // No startup work to do for the memory store. @@ -75,7 +71,7 @@ export class MemoryCryptoStore implements CryptoStore { /** * Delete all data from this store. * - * @returns {Promise} Promise which resolves when the store has been cleared. + * @returns Promise which resolves when the store has been cleared. */ public deleteAllData(): Promise { return Promise.resolve(); @@ -85,10 +81,9 @@ export class MemoryCryptoStore implements CryptoStore { * Look for an existing outgoing room key request, and if none is found, * add a new one * - * @param {module:crypto/store/base~OutgoingRoomKeyRequest} request * - * @returns {Promise} resolves to - * {@link module:crypto/store/base~OutgoingRoomKeyRequest}: either the + * @returns resolves to + * {@link OutgoingRoomKeyRequest}: either the * same instance as passed in, or the existing one. */ public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise { @@ -102,18 +97,15 @@ export class MemoryCryptoStore implements CryptoStore { // this entry matches the request - return it. logger.log( `already have key request outstanding for ` + - `${requestBody.room_id} / ${requestBody.session_id}: ` + - `not sending another`, + `${requestBody.room_id} / ${requestBody.session_id}: ` + + `not sending another`, ); return existing; } // we got to the end of the list without finding a match // - add the new request. - logger.log( - `enqueueing key request for ${requestBody.room_id} / ` + - requestBody.session_id, - ); + logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id); this.outgoingRoomKeyRequests.push(request); return request; }); @@ -122,11 +114,10 @@ export class MemoryCryptoStore implements CryptoStore { /** * Look for an existing room key request * - * @param {module:crypto~RoomKeyRequestBody} requestBody - * existing request to look for + * @param requestBody - existing request to look for * - * @return {Promise} resolves to the matching - * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * @returns resolves to the matching + * {@link OutgoingRoomKeyRequest}, or null if * not found */ public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise { @@ -138,10 +129,9 @@ export class MemoryCryptoStore implements CryptoStore { * * @internal * - * @param {module:crypto~RoomKeyRequestBody} requestBody - * existing request to look for + * @param requestBody - existing request to look for * - * @return {module:crypto/store/base~OutgoingRoomKeyRequest?} + * @returns * the matching request, or null if not found */ // eslint-disable-next-line @typescript-eslint/naming-convention @@ -157,10 +147,10 @@ export class MemoryCryptoStore implements CryptoStore { /** * Look for room key requests by state * - * @param {Array} wantedStates list of acceptable states + * @param wantedStates - list of acceptable states * - * @return {Promise} resolves to the a - * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * @returns resolves to the a + * {@link OutgoingRoomKeyRequest}, or null if * there are no pending requests in those states */ public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise { @@ -176,15 +166,10 @@ export class MemoryCryptoStore implements CryptoStore { /** * - * @param {Number} wantedState - * @return {Promise>} All OutgoingRoomKeyRequests in state + * @returns All OutgoingRoomKeyRequests in state */ public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise { - return Promise.resolve( - this.outgoingRoomKeyRequests.filter( - (r) => r.state == wantedState, - ), - ); + return Promise.resolve(this.outgoingRoomKeyRequests.filter((r) => r.state == wantedState)); } public getOutgoingRoomKeyRequestsByTarget( @@ -196,9 +181,10 @@ export class MemoryCryptoStore implements CryptoStore { for (const req of this.outgoingRoomKeyRequests) { for (const state of wantedStates) { - if (req.state === state && req.recipients.some( - (recipient) => recipient.userId === userId && recipient.deviceId === deviceId, - )) { + if ( + req.state === state && + req.recipients.some((recipient) => recipient.userId === userId && recipient.deviceId === deviceId) + ) { results.push(req); } } @@ -210,12 +196,12 @@ export class MemoryCryptoStore implements CryptoStore { * Look for an existing room key request by id and state, and update it if * found * - * @param {string} requestId ID of request to update - * @param {number} expectedState state we expect to find the request in - * @param {Object} updates name/value map of updates to apply + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in + * @param updates - name/value map of updates to apply * - * @returns {Promise} resolves to - * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + * @returns resolves to + * {@link OutgoingRoomKeyRequest} * updated request, or null if no matching row was found */ public updateOutgoingRoomKeyRequest( @@ -231,7 +217,7 @@ export class MemoryCryptoStore implements CryptoStore { if (req.state !== expectedState) { logger.warn( `Cannot update room key request from ${expectedState} ` + - `as it was already updated to ${req.state}`, + `as it was already updated to ${req.state}`, ); return Promise.resolve(null); } @@ -246,10 +232,10 @@ export class MemoryCryptoStore implements CryptoStore { * Look for an existing room key request by id and state, and delete it if * found * - * @param {string} requestId ID of request to update - * @param {number} expectedState state we expect to find the request in + * @param requestId - ID of request to update + * @param expectedState - state we expect to find the request in * - * @returns {Promise} resolves once the operation is completed + * @returns resolves once the operation is completed */ public deleteOutgoingRoomKeyRequest( requestId: string, @@ -263,10 +249,7 @@ export class MemoryCryptoStore implements CryptoStore { } if (req.state != expectedState) { - logger.warn( - `Cannot delete room key request in state ${req.state} ` - + `(expected ${expectedState})`, - ); + logger.warn(`Cannot delete room key request in state ${req.state} ` + `(expected ${expectedState})`); return Promise.resolve(null); } @@ -358,7 +341,7 @@ export class MemoryCryptoStore implements CryptoStore { } public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise { - const problems = this.sessionProblems[deviceKey] = this.sessionProblems[deviceKey] || []; + const problems = (this.sessionProblems[deviceKey] = this.sessionProblems[deviceKey] || []); problems.push({ type, fixed, time: Date.now() }); problems.sort((a, b) => { return a.time - b.time; @@ -411,17 +394,11 @@ export class MemoryCryptoStore implements CryptoStore { txn: unknown, func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void, ): void { - const k = senderCurve25519Key+'/'+sessionId; - func( - this.inboundGroupSessions[k] || null, - this.inboundGroupSessionsWithheld[k] || null, - ); + const k = senderCurve25519Key + "/" + sessionId; + func(this.inboundGroupSessions[k] || null, this.inboundGroupSessionsWithheld[k] || null); } - public getAllEndToEndInboundGroupSessions( - txn: unknown, - func: (session: ISession | null) => void, - ): void { + public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void { for (const key of Object.keys(this.inboundGroupSessions)) { // we can't use split, as the components we are trying to split out // might themselves contain '/' characters. We rely on the @@ -443,7 +420,7 @@ export class MemoryCryptoStore implements CryptoStore { sessionData: InboundGroupSessionData, txn: unknown, ): void { - const k = senderCurve25519Key+'/'+sessionId; + const k = senderCurve25519Key + "/" + sessionId; if (this.inboundGroupSessions[k] === undefined) { this.inboundGroupSessions[k] = sessionData; } @@ -455,7 +432,7 @@ export class MemoryCryptoStore implements CryptoStore { sessionData: InboundGroupSessionData, txn: unknown, ): void { - this.inboundGroupSessions[senderCurve25519Key+'/'+sessionId] = sessionData; + this.inboundGroupSessions[senderCurve25519Key + "/" + sessionId] = sessionData; } public storeEndToEndInboundGroupSessionWithheld( @@ -464,7 +441,7 @@ export class MemoryCryptoStore implements CryptoStore { sessionData: IWithheld, txn: unknown, ): void { - const k = senderCurve25519Key+'/'+sessionId; + const k = senderCurve25519Key + "/" + sessionId; this.inboundGroupSessionsWithheld[k] = sessionData; } @@ -511,7 +488,7 @@ export class MemoryCryptoStore implements CryptoStore { public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise { for (const session of sessions) { - const sessionKey = session.senderKey + '/' + session.sessionId; + const sessionKey = session.senderKey + "/" + session.sessionId; delete this.sessionsNeedingBackup[sessionKey]; } return Promise.resolve(); @@ -519,7 +496,7 @@ export class MemoryCryptoStore implements CryptoStore { public markSessionsNeedingBackup(sessions: ISession[]): Promise { for (const session of sessions) { - const sessionKey = session.senderKey + '/' + session.sessionId; + const sessionKey = session.senderKey + "/" + session.sessionId; this.sessionsNeedingBackup[sessionKey] = true; } return Promise.resolve(); diff --git a/src/crypto/verification/Base.ts b/src/crypto/verification/Base.ts index 55b349e99c9..89c700c231c 100644 --- a/src/crypto/verification/Base.ts +++ b/src/crypto/verification/Base.ts @@ -17,13 +17,12 @@ limitations under the License. /** * Base class for verification methods. - * @module crypto/verification/Base */ -import { MatrixEvent } from '../../models/event'; -import { EventType } from '../../@types/event'; -import { logger } from '../../logger'; -import { DeviceInfo } from '../deviceinfo'; +import { MatrixEvent } from "../../models/event"; +import { EventType } from "../../@types/event"; +import { logger } from "../../logger"; +import { DeviceInfo } from "../deviceinfo"; import { newTimeoutError } from "./Error"; import { KeysDuringVerification, requestKeysDuringVerification } from "../CrossSigning"; import { IVerificationChannel } from "./request/Channel"; @@ -74,21 +73,19 @@ export class VerificationBase< * *

Subclasses must have a NAME class property.

* - * @class - * - * @param {Object} channel the verification channel to send verification messages over. + * @param channel - the verification channel to send verification messages over. * TODO: Channel types * - * @param {MatrixClient} baseApis base matrix api interface + * @param baseApis - base matrix api interface * - * @param {string} userId the user ID that is being verified + * @param userId - the user ID that is being verified * - * @param {string} deviceId the device ID that is being verified + * @param deviceId - the device ID that is being verified * - * @param {object} [startEvent] the m.key.verification.start event that + * @param startEvent - the m.key.verification.start event that * initiated this verification, if any * - * @param {object} [request] the key verification request object related to + * @param request - the key verification request object related to * this verification, if any */ public constructor( @@ -111,8 +108,7 @@ export class VerificationBase< } const sender = this.startEvent.getSender(); const content = this.startEvent.getContent(); - return sender === this.baseApis.getUserId() && - content.from_device === this.baseApis.getDeviceId(); + return sender === this.baseApis.getUserId() && content.from_device === this.baseApis.getDeviceId(); } public get hasBeenCancelled(): boolean { @@ -165,8 +161,7 @@ export class VerificationBase< public switchStartEvent(event: MatrixEvent): void { if (this.canSwitchStartEvent(event)) { - logger.log("Verification Base: switching verification start event", - { restartingFlow: !!this.rejectEvent }); + logger.log("Verification Base: switching verification start event", { restartingFlow: !!this.rejectEvent }); if (this.rejectEvent) { const reject = this.rejectEvent; this.rejectEvent = undefined; @@ -196,8 +191,7 @@ export class VerificationBase< if (reject) { const content = e.getContent(); const { reason, code } = content; - reject(new Error(`Other side cancelled verification ` + - `because ${reason} (${code})`)); + reject(new Error(`Other side cancelled verification ` + `because ${reason} (${code})`)); } } else if (this.expectedEvent) { // only cancel if there is an event expected. @@ -205,8 +199,7 @@ export class VerificationBase< // and we're just replaying the timeline events when syncing // after a refresh when the events haven't been stored in the cache yet. const exception = new Error( - "Unexpected message: expecting " + this.expectedEvent - + " but got " + e.getType(), + "Unexpected message: expecting " + this.expectedEvent + " but got " + e.getType(), ); this.expectedEvent = undefined; if (this.rejectEvent) { @@ -244,8 +237,7 @@ export class VerificationBase< const content = e.getContent(); if (e.getType() === EventType.KeyVerificationCancel) { content.code = content.code || "m.unknown"; - content.reason = content.reason || content.body - || "Unknown reason"; + content.reason = content.reason || content.body || "Unknown reason"; this.send(EventType.KeyVerificationCancel, content); } else { this.send(EventType.KeyVerificationCancel, { @@ -279,7 +271,7 @@ export class VerificationBase< /** * Begin the key verification * - * @returns {Promise} Promise which resolves when the verification has + * @returns Promise which resolves when the verification has * completed. */ public verify(): Promise { @@ -306,7 +298,9 @@ export class VerificationBase< 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)); + }) + .then(() => this.doVerification!()) + .then(this.done.bind(this), this.cancel.bind(this)); } return this.promise; } @@ -320,7 +314,7 @@ export class VerificationBase< const verifiedDevices: [string, string, string][] = []; for (const [keyId, keyInfo] of Object.entries(keys)) { - const deviceId = keyId.split(':', 2)[1]; + const deviceId = keyId.split(":", 2)[1]; const device = this.baseApis.getStoredDevice(userId, deviceId); if (device) { verifier(keyId, device, keyInfo); @@ -328,16 +322,21 @@ export class VerificationBase< } else { const crossSigningInfo = this.baseApis.crypto!.deviceList.getStoredCrossSigningForUser(userId); if (crossSigningInfo && crossSigningInfo.getId() === deviceId) { - verifier(keyId, DeviceInfo.fromStorage({ - keys: { - [keyId]: deviceId, - }, - }, deviceId), keyInfo); + verifier( + keyId, + DeviceInfo.fromStorage( + { + keys: { + [keyId]: deviceId, + }, + }, + deviceId, + ), + keyInfo, + ); verifiedDevices.push([deviceId, keyId, deviceId]); } else { - logger.warn( - `verification: Could not find device ${deviceId} to verify`, - ); + logger.warn(`verification: Could not find device ${deviceId} to verify`); } } } @@ -348,10 +347,7 @@ export class VerificationBase< throw new Error("No devices could be verified"); } - logger.info( - "Verification completed! Marking devices verified: ", - verifiedDevices, - ); + logger.info("Verification completed! Marking devices verified: ", verifiedDevices); // 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. diff --git a/src/crypto/verification/Error.ts b/src/crypto/verification/Error.ts index ca0fb20b575..da73ebb847e 100644 --- a/src/crypto/verification/Error.ts +++ b/src/crypto/verification/Error.ts @@ -16,12 +16,10 @@ limitations under the License. /** * Error messages. - * - * @module crypto/verification/Error */ import { MatrixEvent } from "../../models/event"; -import { EventType } from '../../@types/event'; +import { EventType } from "../../@types/event"; export function newVerificationError(code: string, reason: string, extraData?: Record): MatrixEvent { const content = Object.assign({}, { code, reason }, extraData); @@ -32,7 +30,7 @@ export function newVerificationError(code: string, reason: string, extraData?: R } export function errorFactory(code: string, reason: string): (extraData?: Record) => MatrixEvent { - return function(extraData?: Record) { + return function (extraData?: Record) { return newVerificationError(code, reason, extraData); }; } @@ -55,25 +53,19 @@ export const newUnknownMethodError = errorFactory("m.unknown_method", "Unknown m /** * An unexpected message was sent. */ -export const newUnexpectedMessageError = errorFactory( - "m.unexpected_message", "Unexpected message", -); +export const newUnexpectedMessageError = errorFactory("m.unexpected_message", "Unexpected message"); /** * The key does not match. */ -export const newKeyMismatchError = errorFactory( - "m.key_mismatch", "Key mismatch", -); +export const newKeyMismatchError = errorFactory("m.key_mismatch", "Key mismatch"); /** * An invalid message was sent. */ -export const newInvalidMessageError = errorFactory( - "m.invalid_message", "Invalid message", -); +export const newInvalidMessageError = errorFactory("m.invalid_message", "Invalid message"); -export function errorFromEvent(event: MatrixEvent): { code: string, reason: string } { +export function errorFromEvent(event: MatrixEvent): { code: string; reason: string } { const content = event.getContent(); if (content) { const { code, reason } = content; diff --git a/src/crypto/verification/IllegalMethod.ts b/src/crypto/verification/IllegalMethod.ts index f01364a212f..c437e0cd2a9 100644 --- a/src/crypto/verification/IllegalMethod.ts +++ b/src/crypto/verification/IllegalMethod.ts @@ -17,7 +17,6 @@ limitations under the License. /** * Verification method that is illegal to have (cannot possibly * do verification with this method). - * @module crypto/verification/IllegalMethod */ import { VerificationBase as Base, VerificationEvent, VerificationEventHandlerMap } from "./Base"; @@ -26,10 +25,6 @@ import { MatrixClient } from "../../client"; import { MatrixEvent } from "../../models/event"; import { VerificationRequest } from "./request/VerificationRequest"; -/** - * @class crypto/verification/IllegalMethod/IllegalMethod - * @extends {module:crypto/verification/Base} - */ export class IllegalMethod extends Base { public static factory( channel: IVerificationChannel, diff --git a/src/crypto/verification/QRCode.ts b/src/crypto/verification/QRCode.ts index f6bdda17e1a..bfb532e4223 100644 --- a/src/crypto/verification/QRCode.ts +++ b/src/crypto/verification/QRCode.ts @@ -16,13 +16,12 @@ limitations under the License. /** * QR code key verification. - * @module crypto/verification/QRCode */ import { VerificationBase as Base, VerificationEventHandlerMap } from "./Base"; -import { newKeyMismatchError, newUserCancelledError } from './Error'; +import { newKeyMismatchError, newUserCancelledError } from "./Error"; import { decodeBase64, encodeUnpaddedBase64 } from "../olmlib"; -import { logger } from '../../logger'; +import { logger } from "../../logger"; import { VerificationRequest } from "./request/VerificationRequest"; import { MatrixClient } from "../../client"; import { IVerificationChannel } from "./request/Channel"; @@ -44,10 +43,6 @@ type EventHandlerMap = { [QrCodeEvent.ShowReciprocateQr]: (qr: IReciprocateQr) => void; } & VerificationEventHandlerMap; -/** - * @class crypto/verification/QRCode/ReciprocateQRCode - * @extends {module:crypto/verification/Base} - */ export class ReciprocateQRCode extends Base { public reciprocateQREvent?: IReciprocateQr; @@ -70,13 +65,12 @@ export class ReciprocateQRCode extends Base { protected doVerification = async (): Promise => { if (!this.startEvent) { // TODO: Support scanning QR codes - throw new Error("It is not currently possible to start verification" + - "with this method yet."); + throw new Error("It is not currently possible to start verification" + "with this method yet."); } const { qrCodeData } = this.request; // 1. check the secret - if (this.startEvent.getContent()['secret'] !== qrCodeData?.encodedSharedSecret) { + if (this.startEvent.getContent()["secret"] !== qrCodeData?.encodedSharedSecret) { throw newKeyMismatchError(); } @@ -255,8 +249,8 @@ export class QRCodeData { version: CODE_VERSION, mode, transactionId, - firstKeyB64: '', // worked out shortly - secondKeyB64: '', // worked out shortly + firstKeyB64: "", // worked out shortly + secondKeyB64: "", // worked out shortly secretB64: encodedSharedSecret, }; @@ -283,21 +277,21 @@ export class QRCodeData { private static generateBuffer(qrData: IQrData): Buffer { let buf = Buffer.alloc(0); // we'll concat our way through life - const appendByte = (b): void => { + const appendByte = (b: number): void => { const tmpBuf = Buffer.from([b]); buf = Buffer.concat([buf, tmpBuf]); }; - const appendInt = (i): void => { + const appendInt = (i: number): void => { const tmpBuf = Buffer.alloc(2); tmpBuf.writeInt16BE(i, 0); buf = Buffer.concat([buf, tmpBuf]); }; - const appendStr = (s, enc, withLengthPrefix = true): void => { + const appendStr = (s: string, enc: BufferEncoding, withLengthPrefix = true): void => { const tmpBuf = Buffer.from(s, enc); if (withLengthPrefix) appendInt(tmpBuf.byteLength); buf = Buffer.concat([buf, tmpBuf]); }; - const appendEncBase64 = (b64): void => { + const appendEncBase64 = (b64: string): void => { const b = decodeBase64(b64); const tmpBuf = Buffer.from(b); buf = Buffer.concat([buf, tmpBuf]); @@ -307,7 +301,7 @@ export class QRCodeData { appendStr(qrData.prefix, "ascii", false); appendByte(qrData.version); appendByte(qrData.mode); - appendStr(qrData.transactionId, "utf-8"); + appendStr(qrData.transactionId!, "utf-8"); appendEncBase64(qrData.firstKeyB64); appendEncBase64(qrData.secondKeyB64); appendEncBase64(qrData.secretB64); diff --git a/src/crypto/verification/SAS.ts b/src/crypto/verification/SAS.ts index 5df6d48f6c8..4c05f53549d 100644 --- a/src/crypto/verification/SAS.ts +++ b/src/crypto/verification/SAS.ts @@ -16,10 +16,9 @@ limitations under the License. /** * Short Authentication String (SAS) verification. - * @module crypto/verification/SAS */ -import anotherjson from 'another-json'; +import anotherjson from "another-json"; import { Utility, SAS as OlmSAS } from "@matrix-org/olm"; import { VerificationBase as Base, SwitchStartEventError, VerificationEventHandlerMap } from "./Base"; @@ -29,109 +28,101 @@ import { newKeyMismatchError, newUnknownMethodError, newUserCancelledError, -} from './Error'; -import { logger } from '../../logger'; +} from "./Error"; +import { logger } from "../../logger"; import { IContent, MatrixEvent } from "../../models/event"; -import { generateDecimalSas } from './SASDecimal'; -import { EventType } from '../../@types/event'; +import { generateDecimalSas } from "./SASDecimal"; +import { EventType } from "../../@types/event"; const START_TYPE = EventType.KeyVerificationStart; -const EVENTS = [ - EventType.KeyVerificationAccept, - EventType.KeyVerificationKey, - EventType.KeyVerificationMac, -]; +const EVENTS = [EventType.KeyVerificationAccept, EventType.KeyVerificationKey, EventType.KeyVerificationMac]; let olmutil: Utility; -const newMismatchedSASError = errorFactory( - "m.mismatched_sas", "Mismatched short authentication string", -); +const newMismatchedSASError = errorFactory("m.mismatched_sas", "Mismatched short authentication string"); -const newMismatchedCommitmentError = errorFactory( - "m.mismatched_commitment", "Mismatched commitment", -); +const newMismatchedCommitmentError = errorFactory("m.mismatched_commitment", "Mismatched commitment"); type EmojiMapping = [emoji: string, name: string]; const emojiMapping: EmojiMapping[] = [ - ["🐶", "dog"], // 0 - ["🐱", "cat"], // 1 - ["🦁", "lion"], // 2 - ["🐎", "horse"], // 3 - ["🦄", "unicorn"], // 4 - ["🐷", "pig"], // 5 - ["🐘", "elephant"], // 6 - ["🐰", "rabbit"], // 7 - ["🐼", "panda"], // 8 - ["🐓", "rooster"], // 9 - ["🐧", "penguin"], // 10 - ["🐢", "turtle"], // 11 - ["🐟", "fish"], // 12 - ["🐙", "octopus"], // 13 - ["🦋", "butterfly"], // 14 - ["🌷", "flower"], // 15 - ["🌳", "tree"], // 16 - ["🌵", "cactus"], // 17 - ["🍄", "mushroom"], // 18 - ["🌏", "globe"], // 19 - ["🌙", "moon"], // 20 - ["☁️", "cloud"], // 21 - ["🔥", "fire"], // 22 - ["🍌", "banana"], // 23 - ["🍎", "apple"], // 24 + ["🐶", "dog"], // 0 + ["🐱", "cat"], // 1 + ["🦁", "lion"], // 2 + ["🐎", "horse"], // 3 + ["🦄", "unicorn"], // 4 + ["🐷", "pig"], // 5 + ["🐘", "elephant"], // 6 + ["🐰", "rabbit"], // 7 + ["🐼", "panda"], // 8 + ["🐓", "rooster"], // 9 + ["🐧", "penguin"], // 10 + ["🐢", "turtle"], // 11 + ["🐟", "fish"], // 12 + ["🐙", "octopus"], // 13 + ["🦋", "butterfly"], // 14 + ["🌷", "flower"], // 15 + ["🌳", "tree"], // 16 + ["🌵", "cactus"], // 17 + ["🍄", "mushroom"], // 18 + ["🌏", "globe"], // 19 + ["🌙", "moon"], // 20 + ["☁️", "cloud"], // 21 + ["🔥", "fire"], // 22 + ["🍌", "banana"], // 23 + ["🍎", "apple"], // 24 ["🍓", "strawberry"], // 25 - ["🌽", "corn"], // 26 - ["🍕", "pizza"], // 27 - ["🎂", "cake"], // 28 - ["❤️", "heart"], // 29 - ["🙂", "smiley"], // 30 - ["🤖", "robot"], // 31 - ["🎩", "hat"], // 32 - ["👓", "glasses"], // 33 - ["🔧", "spanner"], // 34 - ["🎅", "santa"], // 35 - ["👍", "thumbs up"], // 36 - ["☂️", "umbrella"], // 37 - ["⌛", "hourglass"], // 38 - ["⏰", "clock"], // 39 - ["🎁", "gift"], // 40 + ["🌽", "corn"], // 26 + ["🍕", "pizza"], // 27 + ["🎂", "cake"], // 28 + ["❤️", "heart"], // 29 + ["🙂", "smiley"], // 30 + ["🤖", "robot"], // 31 + ["🎩", "hat"], // 32 + ["👓", "glasses"], // 33 + ["🔧", "spanner"], // 34 + ["🎅", "santa"], // 35 + ["👍", "thumbs up"], // 36 + ["☂️", "umbrella"], // 37 + ["⌛", "hourglass"], // 38 + ["⏰", "clock"], // 39 + ["🎁", "gift"], // 40 ["💡", "light bulb"], // 41 - ["📕", "book"], // 42 - ["✏️", "pencil"], // 43 - ["📎", "paperclip"], // 44 - ["✂️", "scissors"], // 45 - ["🔒", "lock"], // 46 - ["🔑", "key"], // 47 - ["🔨", "hammer"], // 48 - ["☎️", "telephone"], // 49 - ["🏁", "flag"], // 50 - ["🚂", "train"], // 51 - ["🚲", "bicycle"], // 52 - ["✈️", "aeroplane"], // 53 - ["🚀", "rocket"], // 54 - ["🏆", "trophy"], // 55 - ["⚽", "ball"], // 56 - ["🎸", "guitar"], // 57 - ["🎺", "trumpet"], // 58 - ["🔔", "bell"], // 59 - ["⚓️", "anchor"], // 60 + ["📕", "book"], // 42 + ["✏️", "pencil"], // 43 + ["📎", "paperclip"], // 44 + ["✂️", "scissors"], // 45 + ["🔒", "lock"], // 46 + ["🔑", "key"], // 47 + ["🔨", "hammer"], // 48 + ["☎️", "telephone"], // 49 + ["🏁", "flag"], // 50 + ["🚂", "train"], // 51 + ["🚲", "bicycle"], // 52 + ["✈️", "aeroplane"], // 53 + ["🚀", "rocket"], // 54 + ["🏆", "trophy"], // 55 + ["⚽", "ball"], // 56 + ["🎸", "guitar"], // 57 + ["🎺", "trumpet"], // 58 + ["🔔", "bell"], // 59 + ["⚓️", "anchor"], // 60 ["🎧", "headphones"], // 61 - ["📁", "folder"], // 62 - ["📌", "pin"], // 63 + ["📁", "folder"], // 62 + ["📌", "pin"], // 63 ]; function generateEmojiSas(sasBytes: number[]): EmojiMapping[] { const emojis = [ // just like base64 encoding sasBytes[0] >> 2, - (sasBytes[0] & 0x3) << 4 | sasBytes[1] >> 4, - (sasBytes[1] & 0xf) << 2 | sasBytes[2] >> 6, + ((sasBytes[0] & 0x3) << 4) | (sasBytes[1] >> 4), + ((sasBytes[1] & 0xf) << 2) | (sasBytes[2] >> 6), sasBytes[2] & 0x3f, sasBytes[3] >> 2, - (sasBytes[3] & 0x3) << 4 | sasBytes[4] >> 4, - (sasBytes[4] & 0xf) << 2 | sasBytes[5] >> 6, + ((sasBytes[3] & 0x3) << 4) | (sasBytes[4] >> 4), + ((sasBytes[4] & 0xf) << 2) | (sasBytes[5] >> 6), ]; return emojis.map((num) => emojiMapping[num]); @@ -140,7 +131,7 @@ function generateEmojiSas(sasBytes: number[]): EmojiMapping[] { const sasGenerators = { decimal: generateDecimalSas, emoji: generateEmojiSas, -}; +} as const; export interface IGeneratedSas { decimal?: [number, number, number]; @@ -154,11 +145,12 @@ export interface ISasEvent { mismatch(): void; } -function generateSas(sasBytes: number[], methods: string[]): IGeneratedSas { +function generateSas(sasBytes: Uint8Array, methods: string[]): IGeneratedSas { const sas: IGeneratedSas = {}; for (const method of methods) { if (method in sasGenerators) { - sas[method] = sasGenerators[method](sasBytes); + // @ts-ignore - ts doesn't like us mixing types like this + sas[method] = sasGenerators[method](Array.from(sasBytes)); } } return sas; @@ -168,49 +160,49 @@ const macMethods = { "hkdf-hmac-sha256": "calculate_mac", "org.matrix.msc3783.hkdf-hmac-sha256": "calculate_mac_fixed_base64", "hmac-sha256": "calculate_mac_long_kdf", -}; +} as const; -type Method = keyof typeof macMethods; +type MacMethod = keyof typeof macMethods; -function calculateMAC(olmSAS: OlmSAS, method: Method) { - return function(...args): string { - const macFunction = olmSAS[macMethods[method]]; - const mac: string = macFunction.apply(olmSAS, args); - logger.log("SAS calculateMAC:", method, args, mac); +function calculateMAC(olmSAS: OlmSAS, method: MacMethod) { + return function (input: string, info: string): string { + const mac = olmSAS[macMethods[method]](input, info); + logger.log("SAS calculateMAC:", method, [input, info], mac); return mac; }; } const calculateKeyAgreement = { // eslint-disable-next-line @typescript-eslint/naming-convention - "curve25519-hkdf-sha256": function(sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array { - const ourInfo = `${sas.baseApis.getUserId()}|${sas.baseApis.deviceId}|` - + `${sas.ourSASPubKey}|`; + "curve25519-hkdf-sha256": function (sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array { + const ourInfo = `${sas.baseApis.getUserId()}|${sas.baseApis.deviceId}|` + `${sas.ourSASPubKey}|`; const theirInfo = `${sas.userId}|${sas.deviceId}|${sas.theirSASPubKey}|`; const sasInfo = - "MATRIX_KEY_VERIFICATION_SAS|" - + (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) - + sas.channel.transactionId; + "MATRIX_KEY_VERIFICATION_SAS|" + + (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) + + sas.channel.transactionId; return olmSAS.generate_bytes(sasInfo, bytes); }, - "curve25519": function(sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array { + "curve25519": function (sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array { const ourInfo = `${sas.baseApis.getUserId()}${sas.baseApis.deviceId}`; const theirInfo = `${sas.userId}${sas.deviceId}`; const sasInfo = - "MATRIX_KEY_VERIFICATION_SAS" - + (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) - + sas.channel.transactionId; + "MATRIX_KEY_VERIFICATION_SAS" + + (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) + + sas.channel.transactionId; return olmSAS.generate_bytes(sasInfo, bytes); }, -}; +} as const; + +type KeyAgreement = keyof typeof calculateKeyAgreement; /* lists of algorithms/methods that are supported. The key agreement, hashes, * and MAC lists should be sorted in order of preference (most preferred * first). */ -const KEY_AGREEMENT_LIST = ["curve25519-hkdf-sha256", "curve25519"]; +const KEY_AGREEMENT_LIST: KeyAgreement[] = ["curve25519-hkdf-sha256", "curve25519"]; const HASHES_LIST = ["sha256"]; -const MAC_LIST: Method[] = ["org.matrix.msc3783.hkdf-hmac-sha256", "hkdf-hmac-sha256", "hmac-sha256"]; +const MAC_LIST: MacMethod[] = ["org.matrix.msc3783.hkdf-hmac-sha256", "hkdf-hmac-sha256", "hmac-sha256"]; const SAS_LIST = Object.keys(sasGenerators); const KEY_AGREEMENT_SET = new Set(KEY_AGREEMENT_LIST); @@ -219,7 +211,7 @@ const MAC_SET = new Set(MAC_LIST); const SAS_SET = new Set(SAS_LIST); function intersection(anArray: T[], aSet: Set): T[] { - return Array.isArray(anArray) ? anArray.filter(x => aSet.has(x)) : []; + return Array.isArray(anArray) ? anArray.filter((x) => aSet.has(x)) : []; } export enum SasEvent { @@ -230,10 +222,6 @@ type EventHandlerMap = { [SasEvent.ShowSas]: (sas: ISasEvent) => void; } & VerificationEventHandlerMap; -/** - * @alias module:crypto/verification/SAS - * @extends {module:crypto/verification/Base} - */ export class SAS extends Base { private waitingForAccept?: boolean; public ourSASPubKey?: string; @@ -299,10 +287,10 @@ export class SAS extends Base { } private async verifyAndCheckMAC( - keyAgreement: string, + keyAgreement: KeyAgreement, sasMethods: string[], olmSAS: OlmSAS, - macMethod: Method, + macMethod: MacMethod, ): Promise { const sasBytes = calculateKeyAgreement[keyAgreement](this, olmSAS, 6); const verifySAS = new Promise((resolve, reject) => { @@ -323,14 +311,13 @@ export class SAS extends Base { }); const [e] = await Promise.all([ - this.waitForEvent(EventType.KeyVerificationMac) - .then((e) => { - // we don't expect any more messages from the other - // party, and they may send a m.key.verification.done - // when they're done on their end - this.expectedEvent = EventType.KeyVerificationDone; - return e; - }), + this.waitForEvent(EventType.KeyVerificationMac).then((e) => { + // we don't expect any more messages from the other + // party, and they may send a m.key.verification.done + // when they're done on their end + this.expectedEvent = EventType.KeyVerificationDone; + return e; + }), verifySAS, ]); const content = e.getContent(); @@ -354,19 +341,22 @@ export class SAS extends Base { throw new SwitchStartEventError(this.startEvent); } - let e; + let e: MatrixEvent; try { e = await this.waitForEvent(EventType.KeyVerificationAccept); } finally { this.waitingForAccept = false; } let content = e.getContent(); - const sasMethods - = intersection(content.short_authentication_string, SAS_SET); - if (!(KEY_AGREEMENT_SET.has(content.key_agreement_protocol) - && HASHES_SET.has(content.hash) - && MAC_SET.has(content.message_authentication_code) - && sasMethods.length)) { + const sasMethods = intersection(content.short_authentication_string, SAS_SET); + if ( + !( + KEY_AGREEMENT_SET.has(content.key_agreement_protocol) && + HASHES_SET.has(content.hash) && + MAC_SET.has(content.message_authentication_code) && + sasMethods.length + ) + ) { throw newUnknownMethodError(); } if (typeof content.commitment !== "string") { @@ -445,56 +435,50 @@ export class SAS extends Base { } } - private sendMAC(olmSAS: OlmSAS, method: Method): Promise { - const mac = {}; + private sendMAC(olmSAS: OlmSAS, method: MacMethod): Promise { + const mac: Record = {}; const keyList: string[] = []; - const baseInfo = "MATRIX_KEY_VERIFICATION_MAC" - + this.baseApis.getUserId() + this.baseApis.deviceId - + this.userId + this.deviceId - + this.channel.transactionId; + const baseInfo = + "MATRIX_KEY_VERIFICATION_MAC" + + this.baseApis.getUserId() + + this.baseApis.deviceId + + this.userId + + this.deviceId + + this.channel.transactionId; const deviceKeyId = `ed25519:${this.baseApis.deviceId}`; - mac[deviceKeyId] = calculateMAC(olmSAS, method)( - this.baseApis.getDeviceEd25519Key(), - baseInfo + deviceKeyId, - ); + mac[deviceKeyId] = calculateMAC(olmSAS, method)(this.baseApis.getDeviceEd25519Key()!, baseInfo + deviceKeyId); keyList.push(deviceKeyId); const crossSigningId = this.baseApis.getCrossSigningId(); if (crossSigningId) { const crossSigningKeyId = `ed25519:${crossSigningId}`; - mac[crossSigningKeyId] = calculateMAC(olmSAS, method)( - crossSigningId, - baseInfo + crossSigningKeyId, - ); + mac[crossSigningKeyId] = calculateMAC(olmSAS, method)(crossSigningId, baseInfo + crossSigningKeyId); keyList.push(crossSigningKeyId); } - const keys = calculateMAC(olmSAS, method)( - keyList.sort().join(","), - baseInfo + "KEY_IDS", - ); + const keys = calculateMAC(olmSAS, method)(keyList.sort().join(","), baseInfo + "KEY_IDS"); return this.send(EventType.KeyVerificationMac, { mac, keys }); } - private async checkMAC(olmSAS: OlmSAS, content: IContent, method: Method): Promise { - const baseInfo = "MATRIX_KEY_VERIFICATION_MAC" - + this.userId + this.deviceId - + this.baseApis.getUserId() + this.baseApis.deviceId - + this.channel.transactionId; - - if (content.keys !== calculateMAC(olmSAS, method)( - Object.keys(content.mac).sort().join(","), - baseInfo + "KEY_IDS", - )) { + private async checkMAC(olmSAS: OlmSAS, content: IContent, method: MacMethod): Promise { + const baseInfo = + "MATRIX_KEY_VERIFICATION_MAC" + + this.userId + + this.deviceId + + this.baseApis.getUserId() + + this.baseApis.deviceId + + this.channel.transactionId; + + if ( + content.keys !== + calculateMAC(olmSAS, method)(Object.keys(content.mac).sort().join(","), baseInfo + "KEY_IDS") + ) { throw newKeyMismatchError(); } await this.verifyKeys(this.userId, content.mac, (keyId, device, keyInfo) => { - if (keyInfo !== calculateMAC(olmSAS, method)( - device.keys[keyId], - baseInfo + keyId, - )) { + if (keyInfo !== calculateMAC(olmSAS, method)(device.keys[keyId], baseInfo + keyId)) { throw newKeyMismatchError(); } }); diff --git a/src/crypto/verification/SASDecimal.ts b/src/crypto/verification/SASDecimal.ts index c8fa73100e6..0cb4630c2a8 100644 --- a/src/crypto/verification/SASDecimal.ts +++ b/src/crypto/verification/SASDecimal.ts @@ -17,11 +17,11 @@ limitations under the License. /** * Implementation of decimal encoding of SAS as per: * https://spec.matrix.org/v1.4/client-server-api/#sas-method-decimal - * @param sasBytes the five bytes generated by HKDF + * @param sasBytes - the five bytes generated by HKDF * @returns the derived three numbers between 1000 and 9191 inclusive */ export function generateDecimalSas(sasBytes: number[]): [number, number, number] { - /** + /* * +--------+--------+--------+--------+--------+ * | Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | * +--------+--------+--------+--------+--------+ @@ -30,8 +30,8 @@ export function generateDecimalSas(sasBytes: number[]): [number, number, number] * 1st number 2nd number 3rd number */ return [ - (sasBytes[0] << 5 | sasBytes[1] >> 3) + 1000, - ((sasBytes[1] & 0x7) << 10 | sasBytes[2] << 2 | sasBytes[3] >> 6) + 1000, - ((sasBytes[3] & 0x3f) << 7 | sasBytes[4] >> 1) + 1000, + ((sasBytes[0] << 5) | (sasBytes[1] >> 3)) + 1000, + (((sasBytes[1] & 0x7) << 10) | (sasBytes[2] << 2) | (sasBytes[3] >> 6)) + 1000, + (((sasBytes[3] & 0x3f) << 7) | (sasBytes[4] >> 1)) + 1000, ]; } diff --git a/src/crypto/verification/request/InRoomChannel.ts b/src/crypto/verification/request/InRoomChannel.ts index 664a2dad6a8..ff11bf192bb 100644 --- a/src/crypto/verification/request/InRoomChannel.ts +++ b/src/crypto/verification/request/InRoomChannel.ts @@ -15,13 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { - VerificationRequest, - REQUEST_TYPE, - READY_TYPE, - START_TYPE, -} from "./VerificationRequest"; -import { logger } from '../../../logger'; +import { VerificationRequest, REQUEST_TYPE, READY_TYPE, START_TYPE } from "./VerificationRequest"; +import { logger } from "../../../logger"; import { IVerificationChannel } from "./Channel"; import { EventType } from "../../../@types/event"; import { MatrixClient } from "../../../client"; @@ -40,16 +35,11 @@ export class InRoomChannel implements IVerificationChannel { private requestEventId?: string; /** - * @param {MatrixClient} client the matrix client, to send messages with and get current user & device from. - * @param {string} roomId id of the room where verification events should be posted in, should be a DM with the given user. - * @param {string} userId id of user that the verification request is directed at, should be present in the room. + * @param client - the matrix client, to send messages with and get current user & device from. + * @param roomId - id of the room where verification events should be posted in, should be a DM with the given user. + * @param userId - id of user that the verification request is directed at, should be present in the room. */ - public constructor( - private readonly client: MatrixClient, - public readonly roomId: string, - public userId?: string, - ) { - } + public constructor(private readonly client: MatrixClient, public readonly roomId: string, public userId?: string) {} public get receiveStartFromOtherDevices(): boolean { return true; @@ -78,8 +68,8 @@ export class InRoomChannel implements IVerificationChannel { } /** - * @param {MatrixEvent} event the event to get the timestamp of - * @return {number} the timestamp when the event was sent + * @param event - the event to get the timestamp of + * @returns the timestamp when the event was sent */ public getTimestamp(event: MatrixEvent): number { return event.getTs(); @@ -87,8 +77,8 @@ export class InRoomChannel implements IVerificationChannel { /** * Checks whether the given event type should be allowed to initiate a new VerificationRequest over this channel - * @param {string} type the event type to check - * @returns {boolean} boolean flag + * @param type - the event type to check + * @returns boolean flag */ public static canCreateRequest(type: string): boolean { return type === REQUEST_TYPE; @@ -100,8 +90,8 @@ export class InRoomChannel implements IVerificationChannel { /** * Extract the transaction id used by a given key verification event, if any - * @param {MatrixEvent} event the event - * @returns {string} the transaction id + * @param event - the event + * @returns the transaction id */ public static getTransactionId(event: MatrixEvent): string | undefined { if (InRoomChannel.getEventType(event) === REQUEST_TYPE) { @@ -119,9 +109,9 @@ export class InRoomChannel implements IVerificationChannel { * This only does checks that don't rely on the current state of a potentially already channel * so we can prevent channels being created by invalid events. * `handleEvent` can do more checks and choose to ignore invalid events. - * @param {MatrixEvent} event the event to validate - * @param {MatrixClient} client the client to get the current user and device id from - * @returns {boolean} whether the event is valid and should be passed to handleEvent + * @param event - the event to validate + * @param client - the client to get the current user and device id from + * @returns whether the event is valid and should be passed to handleEvent */ public static validateEvent(event: MatrixEvent, client: MatrixClient): boolean { const txnId = InRoomChannel.getTransactionId(event); @@ -135,16 +125,17 @@ export class InRoomChannel implements IVerificationChannel { // part of a verification request, so be noisy when rejecting something if (type === REQUEST_TYPE) { if (!content || typeof content.to !== "string" || !content.to.length) { - logger.log("InRoomChannel: validateEvent: " + - "no valid to " + (content && content.to)); + logger.log("InRoomChannel: validateEvent: " + "no valid to " + (content && content.to)); return false; } // ignore requests that are not direct to or sent by the syncing user if (!InRoomChannel.getOtherPartyUserId(event, client)) { - logger.log("InRoomChannel: validateEvent: " + - `not directed to or sent by me: ${event.getSender()}` + - `, ${content && content.to}`); + logger.log( + "InRoomChannel: validateEvent: " + + `not directed to or sent by me: ${event.getSender()}` + + `, ${content && content.to}`, + ); return false; } } @@ -156,8 +147,8 @@ export class InRoomChannel implements IVerificationChannel { * As m.key.verification.request events are as m.room.message events with the InRoomChannel * to have a fallback message in non-supporting clients, we map the real event type * to the symbolic one to keep things in unison with ToDeviceChannel - * @param {MatrixEvent} event the event to get the type of - * @returns {string} the "symbolic" event type + * @param event - the event to get the type of + * @returns the "symbolic" event type */ public static getEventType(event: MatrixEvent): string { const type = event.getType(); @@ -179,10 +170,10 @@ export class InRoomChannel implements IVerificationChannel { /** * Changes the state of the channel, request, and verifier in response to a key verification event. - * @param {MatrixEvent} event to handle - * @param {VerificationRequest} request the request to forward handling to - * @param {boolean} isLiveEvent whether this is an even received through sync or not - * @returns {Promise} a promise that resolves when any requests as an answer to the passed-in event are sent. + * @param event - to handle + * @param request - the request to forward handling to + * @param isLiveEvent - whether this is an even received through sync or not + * @returns a promise that resolves when any requests as an answer to the passed-in event are sent. */ public async handleEvent(event: MatrixEvent, request: VerificationRequest, isLiveEvent = false): Promise { // prevent processing the same event multiple times, as under @@ -228,8 +219,8 @@ export class InRoomChannel implements IVerificationChannel { * so it has the same format as returned by `completeContent` before sending. * The relation can not appear on the event content because of encryption, * relations are excluded from encryption. - * @param {MatrixEvent} event the received event - * @returns {Object} the content object with the relation added again + * @param event - the received event + * @returns the content object with the relation added again */ public completedContentFromEvent(event: MatrixEvent): Record { // ensure m.related_to is included in e2ee rooms @@ -244,9 +235,9 @@ export class InRoomChannel implements IVerificationChannel { * This is public so verification methods (SAS uses this) can get the exact * content that will be sent independent of the used channel, * as they need to calculate the hash of it. - * @param {string} type the event type - * @param {object} content the (incomplete) content - * @returns {object} the complete content, as it will be sent. + * @param type - the event type + * @param content - the (incomplete) content + * @returns the complete content, as it will be sent. */ public completeContent(type: string, content: Record): Record { content = Object.assign({}, content); @@ -256,7 +247,9 @@ export class InRoomChannel implements IVerificationChannel { if (type === REQUEST_TYPE) { // type is mapped to m.room.message in the send method content = { - body: this.client.getUserId() + " is requesting to verify " + + body: + this.client.getUserId() + + " is requesting to verify " + "your key, but your client does not support in-chat key " + "verification. You will need to use legacy key " + "verification to verify keys.", @@ -276,9 +269,9 @@ export class InRoomChannel implements IVerificationChannel { /** * Send an event over the channel with the content not having gone through `completeContent`. - * @param {string} type the event type - * @param {object} uncompletedContent the (incomplete) content - * @returns {Promise} the promise of the request + * @param type - the event type + * @param uncompletedContent - the (incomplete) content + * @returns the promise of the request */ public send(type: string, uncompletedContent: Record): Promise { const content = this.completeContent(type, uncompletedContent); @@ -287,9 +280,8 @@ export class InRoomChannel implements IVerificationChannel { /** * Send an event over the channel with the content having gone through `completeContent` already. - * @param {string} type the event type - * @param {object} content - * @returns {Promise} the promise of the request + * @param type - the event type + * @returns the promise of the request */ public async sendCompleted(type: string, content: Record): Promise { let sendType = type; diff --git a/src/crypto/verification/request/ToDeviceChannel.ts b/src/crypto/verification/request/ToDeviceChannel.ts index 30d61525139..cb9066457e6 100644 --- a/src/crypto/verification/request/ToDeviceChannel.ts +++ b/src/crypto/verification/request/ToDeviceChannel.ts @@ -15,8 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { randomString } from '../../../randomstring'; -import { logger } from '../../../logger'; +import { randomString } from "../../../randomstring"; +import { logger } from "../../../logger"; import { CANCEL_TYPE, PHASE_STARTED, @@ -30,7 +30,7 @@ import { errorFromEvent, newUnexpectedMessageError } from "../Error"; import { MatrixEvent } from "../../../models/event"; import { IVerificationChannel } from "./Channel"; import { MatrixClient } from "../../../client"; -import { IRequestsMap } from '../..'; +import { IRequestsMap } from "../.."; export type Request = VerificationRequest; @@ -69,8 +69,8 @@ export class ToDeviceChannel implements IVerificationChannel { /** * Extract the transaction id used by a given key verification event, if any - * @param {MatrixEvent} event the event - * @returns {string} the transaction id + * @param event - the event + * @returns the transaction id */ public static getTransactionId(event: MatrixEvent): string { const content = event.getContent(); @@ -79,8 +79,8 @@ export class ToDeviceChannel implements IVerificationChannel { /** * Checks whether the given event type should be allowed to initiate a new VerificationRequest over this channel - * @param {string} type the event type to check - * @returns {boolean} boolean flag + * @param type - the event type to check + * @returns boolean flag */ public static canCreateRequest(type: string): boolean { return type === REQUEST_TYPE || type === START_TYPE; @@ -95,14 +95,13 @@ export class ToDeviceChannel implements IVerificationChannel { * This only does checks that don't rely on the current state of a potentially already channel * so we can prevent channels being created by invalid events. * `handleEvent` can do more checks and choose to ignore invalid events. - * @param {MatrixEvent} event the event to validate - * @param {MatrixClient} client the client to get the current user and device id from - * @returns {boolean} whether the event is valid and should be passed to handleEvent + * @param event - the event to validate + * @param client - the client to get the current user and device id from + * @returns whether the event is valid and should be passed to handleEvent */ public static validateEvent(event: MatrixEvent, client: MatrixClient): boolean { if (event.isCancelled()) { - logger.warn("Ignoring flagged verification request from " - + event.getSender()); + logger.warn("Ignoring flagged verification request from " + event.getSender()); return false; } const content = event.getContent(); @@ -123,9 +122,7 @@ export class ToDeviceChannel implements IVerificationChannel { logger.warn("ToDeviceChannel.validateEvent: invalid: no timestamp"); return false; } - if (event.getSender() === client.getUserId() && - content.from_device == client.getDeviceId() - ) { + if (event.getSender() === client.getUserId() && content.from_device == client.getDeviceId()) { // ignore requests from ourselves, because it doesn't make sense for a // device to verify itself logger.warn("ToDeviceChannel.validateEvent: invalid: from own device"); @@ -137,8 +134,8 @@ export class ToDeviceChannel implements IVerificationChannel { } /** - * @param {MatrixEvent} event the event to get the timestamp of - * @return {number} the timestamp when the event was sent + * @param event - the event to get the timestamp of + * @returns the timestamp when the event was sent */ public getTimestamp(event: MatrixEvent): number { const content = event.getContent(); @@ -147,10 +144,10 @@ export class ToDeviceChannel implements IVerificationChannel { /** * Changes the state of the channel, request, and verifier in response to a key verification event. - * @param {MatrixEvent} event to handle - * @param {VerificationRequest} request the request to forward handling to - * @param {boolean} isLiveEvent whether this is an even received through sync or not - * @returns {Promise} a promise that resolves when any requests as an answer to the passed-in event are sent. + * @param event - to handle + * @param request - the request to forward handling to + * @param isLiveEvent - whether this is an even received through sync or not + * @returns a promise that resolves when any requests as an answer to the passed-in event are sent. */ public async handleEvent(event: MatrixEvent, request: Request, isLiveEvent = false): Promise { const type = event.getType(); @@ -182,9 +179,7 @@ export class ToDeviceChannel implements IVerificationChannel { const isAcceptingEvent = type === START_TYPE || type === READY_TYPE; // the request has picked a ready or start event, tell the other devices about it if (isAcceptingEvent && !wasStarted && isStarted && this.deviceId) { - const nonChosenDevices = this.devices.filter( - d => d !== this.deviceId && d !== this.client.getDeviceId(), - ); + const nonChosenDevices = this.devices.filter((d) => d !== this.deviceId && d !== this.client.getDeviceId()); if (nonChosenDevices.length) { const message = this.completeContent(CANCEL_TYPE, { code: "m.accepted", @@ -196,9 +191,9 @@ export class ToDeviceChannel implements IVerificationChannel { } /** - * See {InRoomChannel.completedContentFromEvent} why this is needed. - * @param {MatrixEvent} event the received event - * @returns {Object} the content object + * See {@link InRoomChannel#completedContentFromEvent} for why this is needed. + * @param event - the received event + * @returns the content object */ public completedContentFromEvent(event: MatrixEvent): Record { return event.getContent(); @@ -209,9 +204,9 @@ export class ToDeviceChannel implements IVerificationChannel { * This is public so verification methods (SAS uses this) can get the exact * content that will be sent independent of the used channel, * as they need to calculate the hash of it. - * @param {string} type the event type - * @param {object} content the (incomplete) content - * @returns {object} the complete content, as it will be sent. + * @param type - the event type + * @param content - the (incomplete) content + * @returns the complete content, as it will be sent. */ public completeContent(type: string, content: Record): Record { // make a copy @@ -230,9 +225,9 @@ export class ToDeviceChannel implements IVerificationChannel { /** * Send an event over the channel with the content not having gone through `completeContent`. - * @param {string} type the event type - * @param {object} uncompletedContent the (incomplete) content - * @returns {Promise} the promise of the request + * @param type - the event type + * @param uncompletedContent - the (incomplete) content + * @returns the promise of the request */ public send(type: string, uncompletedContent: Record = {}): Promise { // create transaction id when sending request @@ -245,9 +240,8 @@ export class ToDeviceChannel implements IVerificationChannel { /** * Send an event over the channel with the content having gone through `completeContent` already. - * @param {string} type the event type - * @param {object} content - * @returns {Promise} the promise of the request + * @param type - the event type + * @returns the promise of the request */ public async sendCompleted(type: string, content: Record): Promise { let result; @@ -266,9 +260,9 @@ export class ToDeviceChannel implements IVerificationChannel { await this.request!.handleEvent( type, remoteEchoEvent, - /*isLiveEvent=*/true, - /*isRemoteEcho=*/true, - /*isSentByUs=*/true, + /*isLiveEvent=*/ true, + /*isRemoteEcho=*/ true, + /*isSentByUs=*/ true, ); return result; } @@ -286,7 +280,7 @@ export class ToDeviceChannel implements IVerificationChannel { /** * Allow Crypto module to create and know the transaction id before the .start event gets sent. - * @returns {string} the transaction id + * @returns the transaction id */ public static makeTransactionId(): string { return randomString(32); @@ -297,10 +291,7 @@ export class ToDeviceRequests implements IRequestsMap { private requestsByUserId = new Map>(); public getRequest(event: MatrixEvent): Request | undefined { - return this.getRequestBySenderAndTxnId( - event.getSender()!, - ToDeviceChannel.getTransactionId(event), - ); + return this.getRequestBySenderAndTxnId(event.getSender()!, ToDeviceChannel.getTransactionId(event)); } public getRequestByChannel(channel: ToDeviceChannel): Request | undefined { @@ -356,7 +347,7 @@ export class ToDeviceRequests implements IRequestsMap { public getRequestsInProgress(userId: string): Request[] { const requestsByTxnId = this.requestsByUserId.get(userId); if (requestsByTxnId) { - return Array.from(requestsByTxnId.values()).filter(r => r.pending); + return Array.from(requestsByTxnId.values()).filter((r) => r.pending); } return []; } diff --git a/src/crypto/verification/request/VerificationRequest.ts b/src/crypto/verification/request/VerificationRequest.ts index 58de4b9a2b0..617432ecb78 100644 --- a/src/crypto/verification/request/VerificationRequest.ts +++ b/src/crypto/verification/request/VerificationRequest.ts @@ -14,18 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { logger } from '../../../logger'; -import { - errorFactory, - errorFromEvent, - newUnexpectedMessageError, - newUnknownMethodError, -} from "../Error"; +import { logger } from "../../../logger"; +import { errorFactory, errorFromEvent, newUnexpectedMessageError, newUnknownMethodError } from "../Error"; import { QRCodeData, SCAN_QR_CODE_METHOD } from "../QRCode"; import { IVerificationChannel } from "./Channel"; import { MatrixClient } from "../../../client"; import { MatrixEvent } from "../../../models/event"; -import { EventType } from '../../../@types/event'; +import { EventType } from "../../../@types/event"; import { VerificationBase } from "../Base"; import { VerificationMethod } from "../../index"; import { TypedEventEmitter } from "../../../models/typed-event-emitter"; @@ -81,6 +76,9 @@ export enum VerificationRequestEvent { } type EventHandlerMap = { + /** + * Fires whenever the state of the request object has changed. + */ [VerificationRequestEvent.Change]: () => void; }; @@ -88,11 +86,11 @@ type EventHandlerMap = { * State machine for verification requests. * Things that differ based on what channel is used to * send and receive verification events are put in `InRoomChannel` or `ToDeviceChannel`. - * @event "change" whenever the state of the request object has changed. */ -export class VerificationRequest< - C extends IVerificationChannel = IVerificationChannel, -> extends TypedEventEmitter { +export class VerificationRequest extends TypedEventEmitter< + VerificationRequestEvent, + EventHandlerMap +> { private eventsByUs = new Map(); private eventsByThem = new Map(); private _observeOnly = false; @@ -129,10 +127,10 @@ export class VerificationRequest< /** * Stateless validation logic not specific to the channel. * Invoked by the same static method in either channel. - * @param {string} type the "symbolic" event type, as returned by the `getEventType` function on the channel. - * @param {MatrixEvent} event the event to validate. Don't call getType() on it but use the `type` parameter instead. - * @param {MatrixClient} client the client to get the current user and device id from - * @returns {boolean} whether the event is valid and should be passed to handleEvent + * @param type - the "symbolic" event type, as returned by the `getEventType` function on the channel. + * @param event - the event to validate. Don't call getType() on it but use the `type` parameter instead. + * @param client - the client to get the current user and device id from + * @returns whether the event is valid and should be passed to handleEvent */ public static validateEvent(type: string, event: MatrixEvent, client: MatrixClient): boolean { const content = event.getContent(); @@ -150,18 +148,14 @@ export class VerificationRequest< if (type === REQUEST_TYPE || type === READY_TYPE) { if (!Array.isArray(content.methods)) { - logger.log("VerificationRequest: validateEvent: " + - "fail because methods"); + logger.log("VerificationRequest: validateEvent: " + "fail because methods"); return false; } } if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { - if (typeof content.from_device !== "string" || - content.from_device.length === 0 - ) { - logger.log("VerificationRequest: validateEvent: "+ - "fail because from_device"); + if (typeof content.from_device !== "string" || content.from_device.length === 0) { + logger.log("VerificationRequest: validateEvent: " + "fail because from_device"); return false; } } @@ -209,14 +203,10 @@ export class VerificationRequest< } public calculateEventTimeout(event: MatrixEvent): number { - let effectiveExpiresAt = this.channel.getTimestamp(event) - + TIMEOUT_FROM_EVENT_TS; - - if (this.requestReceivedAt && !this.initiatedByMe && - this.phase <= PHASE_REQUESTED - ) { - const expiresAtByReceipt = this.requestReceivedAt - + TIMEOUT_FROM_EVENT_RECEIPT; + let effectiveExpiresAt = this.channel.getTimestamp(event) + TIMEOUT_FROM_EVENT_TS; + + if (this.requestReceivedAt && !this.initiatedByMe && this.phase <= PHASE_REQUESTED) { + const expiresAtByReceipt = this.requestReceivedAt + TIMEOUT_FROM_EVENT_RECEIPT; effectiveExpiresAt = Math.min(effectiveExpiresAt, expiresAtByReceipt); } @@ -234,7 +224,7 @@ export class VerificationRequest< /** * The key verification request event. - * @returns {MatrixEvent} The request event, or falsey if not found. + * @returns The request event, or falsey if not found. */ public get requestEvent(): MatrixEvent | undefined { return this.getEventByEither(REQUEST_TYPE); @@ -264,9 +254,7 @@ export class VerificationRequest< /** whether this request has sent it's initial event and needs more events to complete */ public get pending(): boolean { - return !this.observeOnly && - this._phase !== PHASE_DONE && - this._phase !== PHASE_CANCELLED; + return !this.observeOnly && this._phase !== PHASE_DONE && this._phase !== PHASE_CANCELLED; } /** Only set after a .ready if the other party can scan a QR code */ @@ -278,15 +266,14 @@ export class VerificationRequest< * This is useful when setting up the QR code UI, as it is somewhat asymmetrical: * if the other party supports SCAN_QR, we should show a QR code in the UI, and vice versa. * For methods that need to be supported by both ends, use the `methods` property. - * @param {string} method the method to check - * @param {boolean} force to check even if the phase is not ready or started yet, internal usage - * @return {boolean} whether or not the other party said the supported the method */ + * @param method - the method to check + * @param force - to check even if the phase is not ready or started yet, internal usage + * @returns whether or not the other party said the supported the method */ public otherPartySupportsMethod(method: string, force = false): boolean { if (!force && !this.ready && !this.started) { return false; } - const theirMethodEvent = this.eventsByThem.get(REQUEST_TYPE) || - this.eventsByThem.get(READY_TYPE); + const theirMethodEvent = this.eventsByThem.get(REQUEST_TYPE) || this.eventsByThem.get(READY_TYPE); if (!theirMethodEvent) { // if we started straight away with .start event, // we are assuming that the other side will support the @@ -317,7 +304,7 @@ export class VerificationRequest< */ public get initiatedByMe(): boolean { // event created by us but no remote echo has been received yet - const noEventsYet = (this.eventsByUs.size + this.eventsByThem.size) === 0; + const noEventsYet = this.eventsByUs.size + this.eventsByThem.size === 0; if (this._phase === PHASE_UNSENT && noEventsYet) { return true; } @@ -398,7 +385,7 @@ export class VerificationRequest< * given the events sent so far in the verification. This is the * same algorithm used to determine which device to send the * verification to when no specific device is specified. - * @returns {{userId: *, deviceId: *}} The device information + * @returns The device information */ public get targetDevice(): ITargetDevice { const theirFirstEvent = @@ -415,10 +402,10 @@ export class VerificationRequest< /* Start the key verification, creating a verifier and sending a .start event. * If no previous events have been sent, pass in `targetDevice` to set who to direct this request to. - * @param {string} method the name of the verification method to use. - * @param {string?} targetDevice.userId the id of the user to direct this request to - * @param {string?} targetDevice.deviceId the id of the device to direct this request to - * @returns {VerifierBase} the verifier of the given method + * @param method - the name of the verification method to use. + * @param targetDevice.userId the id of the user to direct this request to + * @param targetDevice.deviceId the id of the device to direct this request to + * @returns the verifier of the given method */ public beginKeyVerification( method: VerificationMethod, @@ -448,7 +435,7 @@ export class VerificationRequest< /** * sends the initial .request event. - * @returns {Promise} resolves when the event has been sent. + * @returns resolves when the event has been sent. */ public async sendRequest(): Promise { if (!this.observeOnly && this._phase === PHASE_UNSENT) { @@ -459,9 +446,9 @@ export class VerificationRequest< /** * Cancels the request, sending a cancellation to the other party - * @param {string?} error.reason the error reason to send the cancellation with - * @param {string?} error.code the error code to send the cancellation with - * @returns {Promise} resolves when the event has been sent. + * @param reason - the error reason to send the cancellation with + * @param code - the error code to send the cancellation with + * @returns resolves when the event has been sent. */ public async cancel({ reason = "User declined", code = "m.user" } = {}): Promise { if (!this.observeOnly && this._phase !== PHASE_CANCELLED) { @@ -478,7 +465,7 @@ export class VerificationRequest< /** * Accepts the request, sending a .ready event to the other party - * @returns {Promise} resolves when the event has been sent. + * @returns resolves when the event has been sent. */ public async accept(): Promise { if (!this.observeOnly && this.phase === PHASE_REQUESTED && !this.initiatedByMe) { @@ -491,10 +478,10 @@ export class VerificationRequest< /** * Can be used to listen for state changes until the callback returns true. - * @param {Function} fn callback to evaluate whether the request is in the desired state. + * @param fn - callback to evaluate whether the request is in the desired state. * Takes the request as an argument. - * @returns {Promise} that resolves once the callback returns true - * @throws {Error} when the request is cancelled + * @returns that resolves once the callback returns true + * @throws Error when the request is cancelled */ public waitFor(fn: (request: VerificationRequest) => boolean): Promise { return new Promise((resolve, reject) => { @@ -559,9 +546,8 @@ export class VerificationRequest< const ourStartEvent = this.eventsByUs.get(START_TYPE); // any party can send .start after a .ready or unsent if (theirStartEvent && ourStartEvent) { - startEvent = theirStartEvent.getSender()! < ourStartEvent.getSender()! - ? theirStartEvent - : ourStartEvent; + startEvent = + theirStartEvent.getSender()! < ourStartEvent.getSender()! ? theirStartEvent : ourStartEvent; } else { startEvent = theirStartEvent ? theirStartEvent : ourStartEvent; } @@ -569,9 +555,8 @@ export class VerificationRequest< startEvent = this.getEventBy(START_TYPE, !hasRequestByThem); } if (startEvent) { - const fromRequestPhase = ( - phase() === PHASE_REQUESTED && requestEvent?.getSender() !== startEvent.getSender() - ); + const fromRequestPhase = + phase() === PHASE_REQUESTED && requestEvent?.getSender() !== startEvent.getSender(); const fromUnsentPhase = phase() === PHASE_UNSENT && this.channel.canCreateRequest(START_TYPE); if (fromRequestPhase || phase() === PHASE_READY || fromUnsentPhase) { transitions.push({ phase: PHASE_STARTED, event: startEvent }); @@ -600,17 +585,13 @@ export class VerificationRequest< const content = event!.getContent<{ methods: string[]; }>(); - this.commonMethods = - content.methods.filter(m => this.verificationMethods.has(m)); + this.commonMethods = content.methods.filter((m) => this.verificationMethods.has(m)); } } // detect if we're not a party in the request, and we should just observe if (!this.observeOnly) { // if requested or accepted by one of my other devices - if (phase === PHASE_REQUESTED || - phase === PHASE_STARTED || - phase === PHASE_READY - ) { + if (phase === PHASE_REQUESTED || phase === PHASE_STARTED || phase === PHASE_READY) { if ( this.channel.receiveStartFromOtherDevices && this.wasSentByOwnUser(event) && @@ -639,7 +620,7 @@ export class VerificationRequest< private applyPhaseTransitions(): ITransition[] { const transitions = this.calculatePhaseTransitions(); - const existingIdx = transitions.findIndex(t => t.phase === this.phase); + const existingIdx = transitions.findIndex((t) => t.phase === this.phase); // trim off phases we already went through, if any const newTransitions = transitions.slice(existingIdx + 1); // transition to all new phases @@ -701,13 +682,13 @@ export class VerificationRequest< /** * Changes the state of the request and verifier in response to a key verification event. - * @param {string} type the "symbolic" event type, as returned by the `getEventType` function on the channel. - * @param {MatrixEvent} event the event to handle. Don't call getType() on it but use the `type` parameter instead. - * @param {boolean} isLiveEvent whether this is an even received through sync or not - * @param {boolean} isRemoteEcho whether this is the remote echo of an event sent by the same device - * @param {boolean} isSentByUs whether this event is sent by a party that can accept and/or observe the request like one of our peers. + * @param type - the "symbolic" event type, as returned by the `getEventType` function on the channel. + * @param event - the event to handle. Don't call getType() on it but use the `type` parameter instead. + * @param isLiveEvent - whether this is an even received through sync or not + * @param isRemoteEcho - whether this is the remote echo of an event sent by the same device + * @param isSentByUs - whether this event is sent by a party that can accept and/or observe the request like one of our peers. * For InRoomChannel this means any device for the syncing user. For ToDeviceChannel, just the syncing device. - * @returns {Promise} a promise that resolves when any requests as an answer to the passed-in event are sent. + * @returns a promise that resolves when any requests as an answer to the passed-in event are sent. */ public async handleEvent( type: string, @@ -735,9 +716,7 @@ export class VerificationRequest< // This is true for QR and SAS verification, and was // added here to prevent verification getting cancelled // when the server duplicates an event (https://github.com/matrix-org/synapse/issues/3365) - const isDuplicateEvent = isSentByUs ? - this.eventsByUs.has(type) : - this.eventsByThem.has(type); + const isDuplicateEvent = isSentByUs ? this.eventsByUs.has(type) : this.eventsByThem.has(type); if (isDuplicateEvent) { return; } @@ -768,9 +747,8 @@ export class VerificationRequest< // We only do this for live events because it is important that // we sign the keys that were in the QR code, and not the keys // we happen to have at some later point in time. - if (isLiveEvent && newTransitions.some(t => t.phase === PHASE_READY)) { - const shouldGenerateQrCode = - this.otherPartySupportsMethod(SCAN_QR_CODE_METHOD, true); + if (isLiveEvent && newTransitions.some((t) => t.phase === PHASE_READY)) { + const shouldGenerateQrCode = this.otherPartySupportsMethod(SCAN_QR_CODE_METHOD, true); if (shouldGenerateQrCode) { this._qrCodeData = await QRCodeData.create(this, this.client); } @@ -787,14 +765,16 @@ export class VerificationRequest< } } finally { // log events we processed so we can see from rageshakes what events were added to a request - logger.log(`Verification request ${this.channel.transactionId}: ` + - `${type} event with id:${event.getId()}, ` + - `content:${JSON.stringify(event.getContent())} ` + - `deviceId:${this.channel.deviceId}, ` + - `sender:${event.getSender()}, isSentByUs:${isSentByUs}, ` + - `isLiveEvent:${isLiveEvent}, isRemoteEcho:${isRemoteEcho}, ` + - `phase:${oldPhase}=>${this.phase}, ` + - `observeOnly:${wasObserveOnly}=>${this._observeOnly}`); + logger.log( + `Verification request ${this.channel.transactionId}: ` + + `${type} event with id:${event.getId()}, ` + + `content:${JSON.stringify(event.getContent())} ` + + `deviceId:${this.channel.deviceId}, ` + + `sender:${event.getSender()}, isSentByUs:${isSentByUs}, ` + + `isLiveEvent:${isLiveEvent}, isRemoteEcho:${isRemoteEcho}, ` + + `phase:${oldPhase}=>${this.phase}, ` + + `observeOnly:${wasObserveOnly}=>${this._observeOnly}`, + ); } } @@ -805,10 +785,8 @@ export class VerificationRequest< this.timeoutTimer = setTimeout(this.cancelOnTimeout, this.timeout); } if (this.timeoutTimer) { - const shouldClear = phase === PHASE_STARTED || - phase === PHASE_READY || - phase === PHASE_DONE || - phase === PHASE_CANCELLED; + const shouldClear = + phase === PHASE_STARTED || phase === PHASE_READY || phase === PHASE_DONE || phase === PHASE_CANCELLED; if (shouldClear) { clearTimeout(this.timeoutTimer); this.timeoutTimer = null; @@ -851,8 +829,7 @@ export class VerificationRequest< // Before that, we could be looking at somebody else's verification request and we just // happen to be in the room if (this.phase !== PHASE_UNSENT && (isUnexpectedRequest || isUnexpectedReady)) { - logger.warn(`Cancelling, unexpected ${type} verification ` + - `event from ${event.getSender()}`); + logger.warn(`Cancelling, unexpected ${type} verification ` + `event from ${event.getSender()}`); const reason = `Unexpected ${type} event in phase ${this.phase}`; await this.cancel(errorFromEvent(newUnexpectedMessageError({ reason }))); return true; diff --git a/src/embedded.ts b/src/embedded.ts index 27cf564a670..2c1617ecbaf 100644 --- a/src/embedded.ts +++ b/src/embedded.ts @@ -85,7 +85,7 @@ export interface ICapabilities { /** * Whether this client needs access to TURN servers. - * @default false + * @defaultValue false */ turnServers?: boolean; } @@ -97,7 +97,7 @@ export interface ICapabilities { */ export class RoomWidgetClient extends MatrixClient { private room?: Room; - private widgetApiReady = new Promise(resolve => this.widgetApi.once("ready", resolve)); + private widgetApiReady = new Promise((resolve) => this.widgetApi.once("ready", resolve)); private lifecycle?: AbortController; private syncState: SyncState | null = null; @@ -111,36 +111,28 @@ export class RoomWidgetClient extends MatrixClient { // Request capabilities for the functionality this client needs to support if ( - capabilities.sendEvent?.length - || capabilities.receiveEvent?.length - || capabilities.sendMessage === true - || (Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length) - || capabilities.receiveMessage === true - || (Array.isArray(capabilities.receiveMessage) && capabilities.receiveMessage.length) - || capabilities.sendState?.length - || capabilities.receiveState?.length + capabilities.sendEvent?.length || + capabilities.receiveEvent?.length || + capabilities.sendMessage === true || + (Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length) || + capabilities.receiveMessage === true || + (Array.isArray(capabilities.receiveMessage) && capabilities.receiveMessage.length) || + capabilities.sendState?.length || + capabilities.receiveState?.length ) { widgetApi.requestCapabilityForRoomTimeline(roomId); } - capabilities.sendEvent?.forEach(eventType => - widgetApi.requestCapabilityToSendEvent(eventType), - ); - capabilities.receiveEvent?.forEach(eventType => - widgetApi.requestCapabilityToReceiveEvent(eventType), - ); + capabilities.sendEvent?.forEach((eventType) => widgetApi.requestCapabilityToSendEvent(eventType)); + capabilities.receiveEvent?.forEach((eventType) => widgetApi.requestCapabilityToReceiveEvent(eventType)); if (capabilities.sendMessage === true) { widgetApi.requestCapabilityToSendMessage(); } else if (Array.isArray(capabilities.sendMessage)) { - capabilities.sendMessage.forEach(msgType => - widgetApi.requestCapabilityToSendMessage(msgType), - ); + capabilities.sendMessage.forEach((msgType) => widgetApi.requestCapabilityToSendMessage(msgType)); } if (capabilities.receiveMessage === true) { widgetApi.requestCapabilityToReceiveMessage(); } else if (Array.isArray(capabilities.receiveMessage)) { - capabilities.receiveMessage.forEach(msgType => - widgetApi.requestCapabilityToReceiveMessage(msgType), - ); + capabilities.receiveMessage.forEach((msgType) => widgetApi.requestCapabilityToReceiveMessage(msgType)); } capabilities.sendState?.forEach(({ eventType, stateKey }) => widgetApi.requestCapabilityToSendState(eventType, stateKey), @@ -148,12 +140,8 @@ export class RoomWidgetClient extends MatrixClient { capabilities.receiveState?.forEach(({ eventType, stateKey }) => widgetApi.requestCapabilityToReceiveState(eventType, stateKey), ); - capabilities.sendToDevice?.forEach(eventType => - widgetApi.requestCapabilityToSendToDevice(eventType), - ); - capabilities.receiveToDevice?.forEach(eventType => - widgetApi.requestCapabilityToReceiveToDevice(eventType), - ); + capabilities.sendToDevice?.forEach((eventType) => widgetApi.requestCapabilityToSendToDevice(eventType)); + capabilities.receiveToDevice?.forEach((eventType) => widgetApi.requestCapabilityToReceiveToDevice(eventType)); if (capabilities.turnServers) { widgetApi.requestCapability(MatrixCapabilities.MSC3846TurnServers); } @@ -195,10 +183,10 @@ export class RoomWidgetClient extends MatrixClient { await Promise.all( this.capabilities.receiveState?.map(async ({ eventType, stateKey }) => { const rawEvents = await this.widgetApi.readStateEvents(eventType, undefined, stateKey, [this.roomId]); - const events = rawEvents.map(rawEvent => new MatrixEvent(rawEvent as Partial)); + const events = rawEvents.map((rawEvent) => new MatrixEvent(rawEvent as Partial)); await this.syncApi!.injectRoomEvents(this.room!, [], events); - events.forEach(event => { + events.forEach((event) => { this.emit(ClientEvent.Event, event); logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`); }); @@ -264,12 +252,12 @@ export class RoomWidgetClient extends MatrixClient { await this.widgetApi.sendToDevice(eventType, false, contentMap); } - public async encryptAndSendToDevices( - userDeviceInfoArr: IOlmDevice[], - payload: object, - ): Promise { + public async encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice[], payload: object): Promise { const contentMap: { [userId: string]: { [deviceId: string]: object } } = {}; - for (const { userId, deviceInfo: { deviceId } } of userDeviceInfoArr) { + for (const { + userId, + deviceInfo: { deviceId }, + } of userDeviceInfoArr) { if (!contentMap[userId]) contentMap[userId] = {}; contentMap[userId][deviceId] = payload; } @@ -342,11 +330,13 @@ export class RoomWidgetClient extends MatrixClient { try { for await (const server of servers) { - this.turnServers = [{ - urls: server.uris, - username: server.username, - credential: server.password, - }]; + this.turnServers = [ + { + urls: server.uris, + username: server.username, + credential: server.password, + }, + ]; this.emit(ClientEvent.TurnServers, this.turnServers); logger.log(`Received TURN server: ${server.uris}`); } diff --git a/src/errors.ts b/src/errors.ts index f58c839ce60..9d2465131f7 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -22,7 +22,8 @@ export class InvalidStoreError extends Error { public static TOGGLED_LAZY_LOADING = InvalidStoreState.ToggledLazyLoading; public constructor(public readonly reason: InvalidStoreState, public readonly value: any) { - const message = `Store is invalid because ${reason}, ` + + const message = + `Store is invalid because ${reason}, ` + `please stop the client, delete all data and start the client again`; super(message); this.name = "InvalidStoreError"; @@ -37,10 +38,11 @@ export class InvalidCryptoStoreError extends Error { public static TOO_NEW = InvalidCryptoStoreState.TooNew; public constructor(public readonly reason: InvalidCryptoStoreState) { - const message = `Crypto store is invalid because ${reason}, ` + + const message = + `Crypto store is invalid because ${reason}, ` + `please stop the client, delete all data and start the client again`; super(message); - this.name = 'InvalidCryptoStoreError'; + this.name = "InvalidCryptoStoreError"; } } diff --git a/src/event-mapper.ts b/src/event-mapper.ts index 6f2e25c1bdd..81d3d772a59 100644 --- a/src/event-mapper.ts +++ b/src/event-mapper.ts @@ -20,8 +20,11 @@ import { IEvent, MatrixEvent, MatrixEventEvent } from "./models/event"; export type EventMapper = (obj: Partial) => MatrixEvent; export interface MapperOpts { + // don't re-emit events emitted on an event mapped by this mapper on the client preventReEmit?: boolean; + // decrypt event proactively decrypt?: boolean; + // the event is a to_device event toDevice?: boolean; } @@ -59,9 +62,7 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event if (event.isEncrypted()) { if (!preventReEmit) { - client.reEmitter.reEmit(event, [ - MatrixEventEvent.Decrypted, - ]); + client.reEmitter.reEmit(event, [MatrixEventEvent.Decrypted]); } if (decrypt) { client.decryptEventIfNeeded(event); @@ -69,13 +70,8 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event } if (!preventReEmit) { - client.reEmitter.reEmit(event, [ - MatrixEventEvent.Replaced, - MatrixEventEvent.VisibilityChange, - ]); - room?.reEmitter.reEmit(event, [ - MatrixEventEvent.BeforeRedaction, - ]); + client.reEmitter.reEmit(event, [MatrixEventEvent.Replaced, MatrixEventEvent.VisibilityChange]); + room?.reEmitter.reEmit(event, [MatrixEventEvent.BeforeRedaction]); } return event; } diff --git a/src/feature.ts b/src/feature.ts index d617093961e..d555a860bd9 100644 --- a/src/feature.ts +++ b/src/feature.ts @@ -19,7 +19,7 @@ import { IServerVersions } from "./client"; export enum ServerSupport { Stable, Unstable, - Unsupported + Unsupported, } export enum Feature { @@ -51,9 +51,10 @@ 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; + 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) { diff --git a/src/filter-component.ts b/src/filter-component.ts index 85afb0ea707..e28571d640d 100644 --- a/src/filter-component.ts +++ b/src/filter-component.ts @@ -16,22 +16,14 @@ limitations under the License. import { RelationType } from "./@types/event"; import { MatrixEvent } from "./models/event"; -import { - FILTER_RELATED_BY_REL_TYPES, - FILTER_RELATED_BY_SENDERS, - THREAD_RELATION_TYPE, -} from "./models/thread"; - -/** - * @module filter-component - */ +import { FILTER_RELATED_BY_REL_TYPES, FILTER_RELATED_BY_SENDERS, THREAD_RELATION_TYPE } from "./models/thread"; /** * Checks if a value matches a given field value, which may be a * terminated * wildcard pattern. - * @param {String} actualValue The value to be compared - * @param {String} filterValue The filter pattern to be compared - * @return {boolean} true if the actualValue matches the filterValue + * @param actualValue - The value to be compared + * @param filterValue - The filter pattern to be compared + * @returns true if the actualValue matches the filterValue */ function matchesWildcard(actualValue: string, filterValue: string): boolean { if (filterValue.endsWith("*")) { @@ -44,16 +36,16 @@ function matchesWildcard(actualValue: string, filterValue: string): boolean { /* eslint-disable camelcase */ export interface IFilterComponent { - types?: string[]; - not_types?: string[]; - rooms?: string[]; - not_rooms?: string[]; - senders?: string[]; - not_senders?: string[]; - contains_url?: boolean; - limit?: number; - related_by_senders?: Array; - related_by_rel_types?: string[]; + "types"?: string[]; + "not_types"?: string[]; + "rooms"?: string[]; + "not_rooms"?: string[]; + "senders"?: string[]; + "not_senders"?: string[]; + "contains_url"?: boolean; + "limit"?: number; + "related_by_senders"?: Array; + "related_by_rel_types"?: string[]; // Unstable values "io.element.relation_senders"?: Array; @@ -68,17 +60,14 @@ export interface IFilterComponent { * * N.B. that synapse refers to these as 'Filters', and what js-sdk refers to as * 'Filters' are referred to as 'FilterCollections'. - * - * @constructor - * @param {Object} filterJson the definition of this filter JSON, e.g. { 'contains_url': true } */ export class FilterComponent { public constructor(private filterJson: IFilterComponent, public readonly userId?: string | undefined | null) {} /** * Checks with the filter component matches the given event - * @param {MatrixEvent} event event to be checked against the filter - * @return {boolean} true if the event matches the filter + * @param event - event to be checked against the filter + * @returns true if the event matches the filter */ public check(event: MatrixEvent): boolean { const bundledRelationships = event.getUnsigned()?.["m.relations"] || {}; @@ -108,13 +97,13 @@ export class FilterComponent { */ public toJSON(): object { return { - "types": this.filterJson.types || null, - "not_types": this.filterJson.not_types || [], - "rooms": this.filterJson.rooms || null, - "not_rooms": this.filterJson.not_rooms || [], - "senders": this.filterJson.senders || null, - "not_senders": this.filterJson.not_senders || [], - "contains_url": this.filterJson.contains_url || null, + types: this.filterJson.types || null, + not_types: this.filterJson.not_types || [], + rooms: this.filterJson.rooms || null, + not_rooms: this.filterJson.not_rooms || [], + senders: this.filterJson.senders || null, + not_senders: this.filterJson.not_senders || [], + contains_url: this.filterJson.contains_url || null, [FILTER_RELATED_BY_SENDERS.name]: this.filterJson[FILTER_RELATED_BY_SENDERS.name] || [], [FILTER_RELATED_BY_REL_TYPES.name]: this.filterJson[FILTER_RELATED_BY_REL_TYPES.name] || [], }; @@ -122,13 +111,13 @@ export class FilterComponent { /** * Checks whether the filter component matches the given event fields. - * @param {String} roomId the roomId for the event being checked - * @param {String} sender the sender of the event being checked - * @param {String} eventType the type of the event being checked - * @param {boolean} containsUrl whether the event contains a content.url field - * @param {boolean} relationTypes whether has aggregated relation of the given type - * @param {boolean} relationSenders whether one of the relation is sent by the user listed - * @return {boolean} true if the event fields match the filter + * @param roomId - the roomId for the event being checked + * @param sender - the sender of the event being checked + * @param eventType - the type of the event being checked + * @param containsUrl - whether the event contains a content.url field + * @param relationTypes - whether has aggregated relation of the given type + * @param relationSenders - whether one of the relation is sent by the user listed + * @returns true if the event fields match the filter */ private checkFields( roomId: string | undefined, @@ -139,26 +128,26 @@ export class FilterComponent { relationSenders: string[], ): boolean { const literalKeys = { - "rooms": function(v: string): boolean { + rooms: function (v: string): boolean { return roomId === v; }, - "senders": function(v: string): boolean { + senders: function (v: string): boolean { return sender === v; }, - "types": function(v: string): boolean { + types: function (v: string): boolean { return matchesWildcard(eventType, v); }, - }; + } as const; for (const name in literalKeys) { - const matchFunc = literalKeys[name]; + const matchFunc = literalKeys[name]; const notName = "not_" + name; - const disallowedValues: string[] = this.filterJson[notName]; + const disallowedValues = this.filterJson[<`not_${keyof typeof literalKeys}`>notName]; if (disallowedValues?.some(matchFunc)) { return false; } - const allowedValues: string[] = this.filterJson[name]; + const allowedValues = this.filterJson[name as keyof typeof literalKeys]; if (allowedValues && !allowedValues.some(matchFunc)) { return false; } @@ -187,15 +176,18 @@ export class FilterComponent { } private arrayMatchesFilter(filter: any[], values: any[]): boolean { - return values.length > 0 && filter.every(value => { - return values.includes(value); - }); + return ( + values.length > 0 && + filter.every((value) => { + return values.includes(value); + }) + ); } /** * Filters a list of events down to those which match this filter component - * @param {MatrixEvent[]} events Events to be checked against the filter component - * @return {MatrixEvent[]} events which matched the filter component + * @param events - Events to be checked against the filter component + * @returns events which matched the filter component */ public filter(events: MatrixEvent[]): MatrixEvent[] { return events.filter(this.check, this); @@ -204,7 +196,7 @@ export class FilterComponent { /** * Returns the limit field for a given filter component, providing a default of * 10 if none is otherwise specified. Cargo-culted from Synapse. - * @return {Number} the limit for this filter component. + * @returns the limit for this filter component. */ public limit(): number { return this.filterJson.limit !== undefined ? this.filterJson.limit : 10; diff --git a/src/filter.ts b/src/filter.ts index 57bd0540d87..4d74c8c3e08 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -14,27 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -/** - * @module filter - */ - -import { - EventType, - RelationType, -} from "./@types/event"; +import { EventType, RelationType } from "./@types/event"; import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync"; import { FilterComponent, IFilterComponent } from "./filter-component"; import { MatrixEvent } from "./models/event"; /** - * @param {Object} obj - * @param {string} keyNesting - * @param {*} val */ -function setProp(obj: object, keyNesting: string, val: any): void { - const nestedKeys = keyNesting.split("."); +function setProp(obj: Record, keyNesting: string, val: any): void { + const nestedKeys = keyNesting.split(".") as [keyof typeof obj]; let currentObj = obj; - for (let i = 0; i < (nestedKeys.length - 1); i++) { + for (let i = 0; i < nestedKeys.length - 1; i++) { if (!currentObj[nestedKeys[i]]) { currentObj[nestedKeys[i]] = {}; } @@ -53,12 +43,12 @@ export interface IFilterDefinition { } export interface IRoomEventFilter extends IFilterComponent { - lazy_load_members?: boolean; - include_redundant_members?: boolean; - types?: Array; - related_by_senders?: Array; - related_by_rel_types?: string[]; - unread_thread_notifications?: boolean; + "lazy_load_members"?: boolean; + "include_redundant_members"?: boolean; + "types"?: Array; + "related_by_senders"?: Array; + "related_by_rel_types"?: string[]; + "unread_thread_notifications"?: boolean; "org.matrix.msc3773.unread_thread_notifications"?: boolean; // Unstable values @@ -79,14 +69,6 @@ interface IRoomFilter { } /* eslint-enable camelcase */ -/** - * Construct a new Filter. - * @constructor - * @param {string} userId The user ID for this filter. - * @param {string=} filterId The filter ID if known. - * @prop {string} userId The user ID of the filter - * @prop {?string} filterId The filter ID - */ export class Filter { public static LAZY_LOADING_MESSAGES_FILTER = { lazy_load_members: true, @@ -94,11 +76,6 @@ export class Filter { /** * Create a filter from existing data. - * @static - * @param {string} userId - * @param {string} filterId - * @param {Object} jsonObj - * @return {Filter} */ public static fromJson(userId: string | undefined | null, filterId: string, jsonObj: IFilterDefinition): Filter { const filter = new Filter(userId, filterId); @@ -110,11 +87,16 @@ export class Filter { private roomFilter?: FilterComponent; private roomTimelineFilter?: FilterComponent; + /** + * Construct a new Filter. + * @param userId - The user ID for this filter. + * @param filterId - The filter ID if known. + */ public constructor(public readonly userId: string | undefined | null, public filterId?: string) {} /** * Get the ID of this filter on your homeserver (if known) - * @return {?string} The filter ID + * @returns The filter ID */ public getFilterId(): string | undefined { return this.filterId; @@ -122,7 +104,7 @@ export class Filter { /** * Get the JSON body of the filter. - * @return {Object} The filter definition + * @returns The filter definition */ public getDefinition(): IFilterDefinition { return this.definition; @@ -130,7 +112,7 @@ export class Filter { /** * Set the JSON body of the filter - * @param {Object} definition The filter definition + * @param definition - The filter definition */ public setDefinition(definition: IFilterDefinition): void { this.definition = definition; @@ -199,7 +181,7 @@ export class Filter { /** * Get the room.timeline filter component of the filter - * @return {FilterComponent} room timeline filter component + * @returns room timeline filter component */ public getRoomTimelineFilterComponent(): FilterComponent | undefined { return this.roomTimelineFilter; @@ -208,8 +190,8 @@ export class Filter { /** * Filter the list of events based on whether they are allowed in a timeline * based on this filter - * @param {MatrixEvent[]} events the list of events being filtered - * @return {MatrixEvent[]} the list of events which match the filter + * @param events - the list of events being filtered + * @returns the list of events which match the filter */ public filterRoomTimeline(events: MatrixEvent[]): MatrixEvent[] { if (this.roomFilter) { @@ -223,7 +205,7 @@ export class Filter { /** * Set the max number of events to return for each room's timeline. - * @param {Number} limit The max number of events to return for each room. + * @param limit - The max number of events to return for each room. */ public setTimelineLimit(limit: number): void { setProp(this.definition, "room.timeline.limit", limit); @@ -231,7 +213,6 @@ export class Filter { /** * Enable threads unread notification - * @param {boolean} enabled */ public setUnreadThreadNotifications(enabled: boolean): void { this.definition = { @@ -252,7 +233,7 @@ export class Filter { /** * Control whether left rooms should be included in responses. - * @param {boolean} includeLeave True to make rooms the user has left appear + * @param includeLeave - True to make rooms the user has left appear * in responses. */ public setIncludeLeaveRooms(includeLeave: boolean): void { diff --git a/src/http-api/errors.ts b/src/http-api/errors.ts index 5ae0a0f5099..e48fc029c7d 100644 --- a/src/http-api/errors.ts +++ b/src/http-api/errors.ts @@ -26,9 +26,8 @@ interface IErrorJson extends Partial { /** * Construct a generic HTTP error. This is a JavaScript Error with additional information * specific to HTTP responses. - * @constructor - * @param {string} msg The error message to include. - * @param {number} httpStatus The HTTP response status code. + * @param msg - The error message to include. + * @param httpStatus - The HTTP response status code. */ export class HTTPError extends Error { public constructor(msg: string, public readonly httpStatus?: number) { @@ -36,21 +35,18 @@ export class HTTPError extends Error { } } -/** - * Construct a Matrix error. This is a JavaScript Error with additional - * information specific to the standard Matrix error response. - * @constructor - * @param {Object} errorJson The Matrix error JSON returned from the homeserver. - * @prop {string} errcode The Matrix 'errcode' value, e.g. "M_FORBIDDEN". - * @prop {string} name Same as MatrixError.errcode but with a default unknown string. - * @prop {string} message The Matrix 'error' value, e.g. "Missing token." - * @prop {Object} data The raw Matrix error JSON used to construct this object. - * @prop {number} httpStatus The numeric HTTP status code given - */ export class MatrixError extends HTTPError { + // The Matrix 'errcode' value, e.g. "M_FORBIDDEN". public readonly errcode?: string; + // The raw Matrix error JSON used to construct this object. public data: IErrorJson; + /** + * Construct a Matrix error. This is a JavaScript Error with additional + * information specific to the standard Matrix error response. + * @param errorJson - The Matrix error JSON returned from the homeserver. + * @param httpStatus - The numeric HTTP status code given + */ public constructor( errorJson: IErrorJson = {}, public readonly httpStatus?: number, @@ -76,7 +72,6 @@ export class MatrixError extends HTTPError { * that a request failed because of some error with the connection, either * CORS was not correctly configured on the server, the server didn't response, * the request timed out, or the internet connection on the client side went down. - * @constructor */ export class ConnectionError extends Error { public constructor(message: string, cause?: Error) { diff --git a/src/http-api/fetch.ts b/src/http-api/fetch.ts index 71ba098e303..1267fcc7611 100644 --- a/src/http-api/fetch.ts +++ b/src/http-api/fetch.ts @@ -16,7 +16,6 @@ limitations under the License. /** * This is an internal module. See {@link MatrixHttpApi} for the public class. - * @module http-api */ import * as utils from "../utils"; @@ -33,10 +32,11 @@ interface TypedResponse extends Response { json(): Promise; } -export type ResponseType = - O extends undefined ? T : - O extends { onlyData: true } ? T : - TypedResponse; +export type ResponseType = O extends undefined + ? T + : O extends { onlyData: true } + ? T + : TypedResponse; export class FetchHttpApi { private abortController = new AbortController(); @@ -64,13 +64,13 @@ export class FetchHttpApi { /** * Sets the base URL for the identity server - * @param {string} url The new base url + * @param url - The new base url */ public setIdBaseUrl(url: string): void { this.opts.idBaseUrl = url; } - public idServerRequest( + public idServerRequest>( method: Method, path: string, params: Record | undefined, @@ -104,35 +104,29 @@ export class FetchHttpApi { /** * Perform an authorised request to the homeserver. - * @param {string} method The HTTP method e.g. "GET". - * @param {string} path The HTTP path after the supplied prefix e.g. + * @param method - The HTTP method e.g. "GET". + * @param path - The HTTP path after the supplied prefix e.g. * "/createRoom". * - * @param {Object=} queryParams A dict of query params (these will NOT be + * @param queryParams - A dict of query params (these will NOT be * urlencoded). If unspecified, there will be no query params. * - * @param {Object} [body] The HTTP JSON body. + * @param body - The HTTP JSON body. * - * @param {Object|Number=} opts additional options. If a number is specified, + * @param opts - additional options. If a number is specified, * this is treated as `opts.localTimeoutMs`. * - * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before - * timing out the request. If not specified, there is no timeout. - * - * @param {string=} opts.prefix The full prefix to use e.g. - * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix. - * - * @param {string=} opts.baseUrl The alternative base url to use. - * If not specified, uses this.opts.baseUrl - * - * @param {Object=} opts.headers map of additional request headers - * - * @return {Promise} Resolves to {data: {Object}, - * headers: {Object}, code: {Number}}. - * If onlyData is set, this will resolve to the data - * object only. - * @return {module:http-api.MatrixError} Rejects with an error if a problem - * occurred. This includes network problems and Matrix-specific error JSON. + * @returns Promise which resolves to + * ``` + * { + * data: {Object}, + * headers: {Object}, + * code: {Number}, + * } + * ``` + * If `onlyData` is set, this will resolve to the `data` object only. + * @returns Rejects with an error if a problem occurred. + * This includes network problems and Matrix-specific error JSON. */ public authedRequest( method: Method, @@ -162,9 +156,9 @@ export class FetchHttpApi { const requestPromise = this.request(method, path, queryParams, body, opts); requestPromise.catch((err: MatrixError) => { - if (err.errcode == 'M_UNKNOWN_TOKEN' && !opts?.inhibitLogoutEmit) { + if (err.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) { this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, err); - } else if (err.errcode == 'M_CONSENT_NOT_GIVEN') { + } else if (err.errcode == "M_CONSENT_NOT_GIVEN") { this.eventEmitter.emit(HttpApiEvent.NoConsent, err.message, err.data.consent_uri); } }); @@ -176,30 +170,28 @@ export class FetchHttpApi { /** * Perform a request to the homeserver without any credentials. - * @param {string} method The HTTP method e.g. "GET". - * @param {string} path The HTTP path after the supplied prefix e.g. + * @param method - The HTTP method e.g. "GET". + * @param path - The HTTP path after the supplied prefix e.g. * "/createRoom". * - * @param {Object=} queryParams A dict of query params (these will NOT be + * @param queryParams - A dict of query params (these will NOT be * urlencoded). If unspecified, there will be no query params. * - * @param {Object} [body] The HTTP JSON body. - * - * @param {Object=} opts additional options + * @param body - The HTTP JSON body. * - * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before - * timing out the request. If not specified, there is no timeout. + * @param opts - additional options * - * @param {string=} opts.prefix The full prefix to use e.g. - * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix. - * - * @param {Object=} opts.headers map of additional request headers - * - * @return {Promise} Resolves to {data: {Object}, - * headers: {Object}, code: {Number}}. - * If onlyData is set, this will resolve to the data + * @returns Promise which resolves to + * ``` + * { + * data: {Object}, + * headers: {Object}, + * code: {Number}, + * } + * ``` + * If `onlyData is set, this will resolve to the data` * object only. - * @return {module:http-api.MatrixError} Rejects with an error if a problem + * @returns Rejects with an error if a problem * occurred. This includes network problems and Matrix-specific error JSON. */ public request( @@ -215,21 +207,16 @@ export class FetchHttpApi { /** * Perform a request to an arbitrary URL. - * @param {string} method The HTTP method e.g. "GET". - * @param {string} url The HTTP URL object. - * - * @param {Object} [body] The HTTP JSON body. + * @param method - The HTTP method e.g. "GET". + * @param url - The HTTP URL object. * - * @param {Object=} opts additional options + * @param body - The HTTP JSON body. * - * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before - * timing out the request. If not specified, there is no timeout. + * @param opts - additional options * - * @param {Object=} opts.headers map of additional request headers - * - * @return {Promise} Resolves to data unless `onlyData` is specified as false, + * @returns Promise which resolves to data unless `onlyData` is specified as false, * where the resolved value will be a fetch Response object. - * @return {module:http-api.MatrixError} Rejects with an error if a problem + * @returns Rejects with an error if a problem * occurred. This includes network problems and Matrix-specific error JSON. */ public async requestOtherUrl( @@ -255,9 +242,7 @@ export class FetchHttpApi { const timeout = opts.localTimeoutMs ?? this.opts.localTimeoutMs; const keepAlive = opts.keepAlive ?? false; - const signals = [ - this.abortController.signal, - ]; + const signals = [this.abortController.signal]; if (timeout !== undefined) { signals.push(timeoutSignal(timeout)); } @@ -310,18 +295,13 @@ export class FetchHttpApi { /** * Form and return a homeserver request URL based on the given path params and prefix. - * @param {string} path The HTTP path after the supplied prefix e.g. "/createRoom". - * @param {Object} queryParams A dict of query params (these will NOT be urlencoded). - * @param {string} prefix The full prefix to use e.g. "/_matrix/client/v2_alpha", defaulting to this.opts.prefix. - * @param {string} baseUrl The baseUrl to use e.g. "https://matrix.org/", defaulting to this.opts.baseUrl. - * @return {string} URL + * @param path - The HTTP path after the supplied prefix e.g. "/createRoom". + * @param queryParams - A dict of query params (these will NOT be urlencoded). + * @param prefix - The full prefix to use e.g. "/_matrix/client/v2_alpha", defaulting to this.opts.prefix. + * @param baseUrl - The baseUrl to use e.g. "https://matrix.org/", defaulting to this.opts.baseUrl. + * @returns URL */ - public getUrl( - path: string, - queryParams?: QueryDict, - prefix?: string, - baseUrl?: string, - ): URL { + public getUrl(path: string, queryParams?: QueryDict, prefix?: string, baseUrl?: string): URL { const url = new URL((baseUrl ?? this.opts.baseUrl) + (prefix ?? this.opts.prefix) + path); if (queryParams) { utils.encodeParams(queryParams, url.searchParams); diff --git a/src/http-api/index.ts b/src/http-api/index.ts index c7f782d8972..c5e8e2a3a9a 100644 --- a/src/http-api/index.ts +++ b/src/http-api/index.ts @@ -35,27 +35,13 @@ export class MatrixHttpApi extends FetchHttpApi { /** * Upload content to the homeserver * - * @param {object} file The object to upload. On a browser, something that + * @param file - The object to upload. On a browser, something that * can be sent to XMLHttpRequest.send (typically a File). Under node.js, * a Buffer, String or ReadStream. * - * @param {object} opts options object + * @param opts - options object * - * @param {string=} opts.name Name to give the file on the server. Defaults - * to file.name. - * - * @param {boolean=} opts.includeFilename if false will not send the filename, - * e.g for encrypted file uploads where filename leaks are undesirable. - * Defaults to true. - * - * @param {string=} opts.type Content-type for the upload. Defaults to - * file.type, or application/octet-stream. - * - * @param {Function=} opts.progressHandler Optional. Called when a chunk of - * data has been uploaded, with an object containing the fields `loaded` - * (number of bytes transferred) and `total` (total size, if known). - * - * @return {Promise} Resolves to response object, as + * @returns Promise which resolves to response object, as * determined by this.opts.onlyData, opts.rawResponse, and * opts.onlyContentUri. Rejects with an error (usually a MatrixError). */ @@ -64,7 +50,7 @@ export class MatrixHttpApi extends FetchHttpApi { const abortController = opts.abortController ?? new AbortController(); // If the file doesn't have a mime type, use a default since the HS errors if we don't supply one. - const contentType = opts.type ?? (file as File).type ?? 'application/octet-stream'; + const contentType = opts.type ?? (file as File).type ?? "application/octet-stream"; const fileName = opts.name ?? (file as File).name; const upload = { @@ -77,7 +63,7 @@ export class MatrixHttpApi extends FetchHttpApi { if (global.XMLHttpRequest) { const xhr = new global.XMLHttpRequest(); - const timeoutFn = function(): void { + const timeoutFn = function (): void { xhr.abort(); defer.reject(new Error("Timeout")); }; @@ -85,7 +71,7 @@ export class MatrixHttpApi extends FetchHttpApi { // set an initial timeout of 30s; we'll advance it each time we get a progress notification let timeoutTimer = callbacks.setTimeout(timeoutFn, 30000); - xhr.onreadystatechange = function(): void { + xhr.onreadystatechange = function (): void { switch (xhr.readyState) { case global.XMLHttpRequest.DONE: callbacks.clearTimeout(timeoutTimer); @@ -94,7 +80,7 @@ export class MatrixHttpApi extends FetchHttpApi { throw new DOMException(xhr.statusText, "AbortError"); // mimic fetch API } if (!xhr.responseText) { - throw new Error('No response body.'); + throw new Error("No response body."); } if (xhr.status >= 400) { @@ -152,23 +138,23 @@ export class MatrixHttpApi extends FetchHttpApi { const headers: Record = { "Content-Type": contentType }; - this.authedRequest( - Method.Post, "/upload", queryParams, file, { - prefix: MediaPrefix.R0, - headers, - abortSignal: abortController.signal, - }, - ).then(response => { - return this.opts.onlyData ? response : response.json(); - }).then(defer.resolve, defer.reject); + this.authedRequest(Method.Post, "/upload", queryParams, file, { + prefix: MediaPrefix.R0, + headers, + abortSignal: abortController.signal, + }) + .then((response) => { + return this.opts.onlyData ? response : response.json(); + }) + .then(defer.resolve, defer.reject); } // remove the upload from the list on completion upload.promise = defer.promise.finally(() => { - utils.removeElement(this.uploads, elem => elem === upload); + utils.removeElement(this.uploads, (elem) => elem === upload); }); abortController.signal.addEventListener("abort", () => { - utils.removeElement(this.uploads, elem => elem === upload); + utils.removeElement(this.uploads, (elem) => elem === upload); defer.reject(new DOMException("Aborted", "AbortError")); }); this.uploads.push(upload); @@ -176,7 +162,7 @@ export class MatrixHttpApi extends FetchHttpApi { } public cancelUpload(promise: Promise): boolean { - const upload = this.uploads.find(u => u.promise === promise); + const upload = this.uploads.find((u) => u.promise === promise); if (upload) { upload.abortController.abort(); return true; @@ -190,7 +176,7 @@ export class MatrixHttpApi extends FetchHttpApi { /** * Get the content repository url with query parameters. - * @return {Object} An object with a 'base', 'path' and 'params' for base URL, + * @returns An object with a 'base', 'path' and 'params' for base URL, * path and query parameters respectively. */ public getContentUri(): IContentUri { diff --git a/src/http-api/interface.ts b/src/http-api/interface.ts index 37217947027..9946aa37bf2 100644 --- a/src/http-api/interface.ts +++ b/src/http-api/interface.ts @@ -32,11 +32,25 @@ export interface IHttpOpts { } export interface IRequestOpts { + /** + * The alternative base url to use. + * If not specified, uses this.opts.baseUrl + */ baseUrl?: string; + /** + * The full prefix to use e.g. + * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix. + */ prefix?: string; - + /** + * map of additional request headers + */ headers?: Record; abortSignal?: AbortSignal; + /** + * The maximum amount of time to wait before + * timing out the request. If not specified, there is no timeout. + */ localTimeoutMs?: number; keepAlive?: boolean; // defaults to false json?: boolean; // defaults to true @@ -62,7 +76,29 @@ export enum HttpApiEvent { } export type HttpApiEventHandlerMap = { + /** + * Fires whenever the login session the JS SDK is using is no + * longer valid and the user must log in again. + * NB. This only fires when action is required from the user, not + * when then login session can be renewed by using a refresh token. + * @example + * ``` + * matrixClient.on("Session.logged_out", function(errorObj){ + * // show the login screen + * }); + * ``` + */ [HttpApiEvent.SessionLoggedOut]: (err: MatrixError) => void; + /** + * Fires when the JS SDK receives a M_CONSENT_NOT_GIVEN error in response + * to a HTTP request. + * @example + * ``` + * matrixClient.on("no_consent", function(message, contentUri) { + * console.info(message + ' Go to ' + contentUri); + * }); + * ``` + */ [HttpApiEvent.NoConsent]: (message: string, consentUri: string) => void; }; @@ -72,9 +108,26 @@ export interface UploadProgress { } export interface UploadOpts { + /** + * Name to give the file on the server. Defaults to file.name. + */ name?: string; + /** + * Content-type for the upload. Defaults to + * file.type, or applicaton/octet-stream. + */ type?: string; + /** + * if false will not send the filename, + * e.g for encrypted file uploads where filename leaks are undesirable. + * Defaults to true. + */ includeFilename?: boolean; + /** + * Optional. Called when a chunk of + * data has been uploaded, with an object containing the fields `loaded` + * (number of bytes transferred) and `total` (total size, if known). + */ progressHandler?(progress: UploadProgress): void; abortController?: AbortController; } diff --git a/src/http-api/utils.ts b/src/http-api/utils.ts index 50466095002..c49be740ef6 100644 --- a/src/http-api/utils.ts +++ b/src/http-api/utils.ts @@ -67,9 +67,9 @@ export function anySignal(signals: AbortSignal[]): { * If it is a JSON response, we will parse it into a MatrixError. Otherwise * we return a generic Error. * - * @param {XMLHttpRequest|Response} response response object - * @param {String} body raw body of the response - * @returns {Error} + * @param response - response object + * @param body - raw body of the response + * @returns */ export function parseErrorResponse(response: XMLHttpRequest | Response, body?: string): Error { let contentType: ParsedMediaType | null; @@ -102,8 +102,8 @@ function isXhr(response: XMLHttpRequest | Response): response is XMLHttpRequest * * returns null if no content-type header could be found. * - * @param {XMLHttpRequest|Response} response response object - * @returns {{type: String, parameters: Object}?} parsed content-type header, or null if not found + * @param response - response object + * @returns parsed content-type header, or null if not found */ function getResponseContentType(response: XMLHttpRequest | Response): ParsedMediaType | null { let contentType: string | null; @@ -124,10 +124,10 @@ function getResponseContentType(response: XMLHttpRequest | Response): ParsedMedi /** * Retries a network operation run in a callback. - * @param {number} maxAttempts maximum attempts to try - * @param {Function} callback callback that returns a promise of the network operation. If rejected with ConnectionError, it will be retried by calling the callback again. - * @return {any} the result of the network operation - * @throws {ConnectionError} If after maxAttempts the callback still throws ConnectionError + * @param maxAttempts - maximum attempts to try + * @param callback - callback that returns a promise of the network operation. If rejected with ConnectionError, it will be retried by calling the callback again. + * @returns the result of the network operation + * @throws {@link ConnectionError} If after maxAttempts the callback still throws ConnectionError */ export async function retryNetworkOperation(maxAttempts: number, callback: () => Promise): Promise { let attempts = 0; diff --git a/src/index.ts b/src/index.ts index a93c2714dfa..c9a5dcf65c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,4 +23,3 @@ global.__js_sdk_entrypoint = true; export * from "./matrix"; export default matrixcs; - diff --git a/src/indexeddb-helpers.ts b/src/indexeddb-helpers.ts index 695a3145ff2..6f99ae54b8b 100644 --- a/src/indexeddb-helpers.ts +++ b/src/indexeddb-helpers.ts @@ -18,9 +18,9 @@ limitations under the License. * Check if an IndexedDB database exists. The only way to do so is to try opening it, so * we do that and then delete it did not exist before. * - * @param {Object} indexedDB The `indexedDB` interface - * @param {string} dbName The database name to test for - * @returns {boolean} Whether the database exists + * @param indexedDB - The `indexedDB` interface + * @param dbName - The database name to test for + * @returns Whether the database exists */ export function exists(indexedDB: IDBFactory, dbName: string): Promise { return new Promise((resolve, reject) => { diff --git a/src/indexeddb-worker.ts b/src/indexeddb-worker.ts index 45facc485a4..68dcf0f8006 100644 --- a/src/indexeddb-worker.ts +++ b/src/indexeddb-worker.ts @@ -20,6 +20,5 @@ limitations under the License. * to be used separately */ -/** The {@link module:indexeddb-store-worker~IndexedDBStoreWorker} class. */ +/** The {@link IndexedDBStoreWorker} class. */ export { IndexedDBStoreWorker } from "./store/indexeddb-store-worker"; - diff --git a/src/interactive-auth.ts b/src/interactive-auth.ts index df4a42c2726..e4baefa6df2 100644 --- a/src/interactive-auth.ts +++ b/src/interactive-auth.ts @@ -16,9 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -/** @module interactive-auth */ - -import { logger } from './logger'; +import { logger } from "./logger"; import { MatrixClient } from "./client"; import { defer, IDeferred } from "./utils"; import { MatrixError } from "./http-api"; @@ -31,8 +29,11 @@ interface IFlow { } export interface IInputs { + // An email address. If supplied, a flow using email verification will be chosen. emailAddress?: string; + // An ISO two letter country code. Gives the country that opts.phoneNumber should be resolved relative to. phoneCountry?: string; + // A phone number. If supplied, a flow using phone number validation will be chosen. phoneNumber?: string; registrationToken?: string; } @@ -106,15 +107,66 @@ class NoAuthFlowFoundError extends Error { } interface IOpts { + /** + * A matrix client to use for the auth process + */ matrixClient: MatrixClient; + /** + * Error response from the last request. If null, a request will be made with no auth before starting. + */ authData?: IAuthData; + /** + * Inputs provided by the user and used by different stages of the auto process. + * The inputs provided will affect what flow is chosen. + */ inputs?: IInputs; + /** + * If resuming an existing interactive auth session, the sessionId of that session. + */ sessionId?: string; + /** + * If resuming an existing interactive auth session, the client secret for that session + */ clientSecret?: string; + /** + * If returning from having completed m.login.email.identity auth, the sid for the email verification session. + */ emailSid?: string; + + /** + * Called with the new auth dict to submit the request. + * Also passes a second deprecated arg which is a flag set to true if this request is a background request. + * The busyChanged callback should be used instead of the background flag. + * Should return a promise which resolves to the successful response or rejects with a MatrixError. + */ doRequest(auth: IAuthData | null, background: boolean): Promise; + /** + * Called when the status of the UI auth changes, + * ie. when the state of an auth stage changes of when the auth flow moves to a new stage. + * The arguments are: the login type (eg m.login.password); and an object which is either an error or an + * informational object specific to the login type. + * If the 'errcode' key is defined, the object is an error, and has keys: + * errcode: string, the textual error code, eg. M_UNKNOWN + * error: string, human readable string describing the error + * + * The login type specific objects are as follows: + * m.login.email.identity: + * * emailSid: string, the sid of the active email auth session + */ stateUpdated(nextStage: AuthType, status: IStageStatus): void; + + /** + * A function that takes the email address (string), clientSecret (string), attempt number (int) and + * sessionId (string) and calls the relevant requestToken function and returns the promise returned by that + * function. + * If the resulting promise rejects, the rejection will propagate through to the attemptAuth promise. + */ requestEmailToken(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>; + /** + * Called whenever the interactive auth logic becomes busy submitting information provided by the user or finishes. + * After this has been called with true the UI should indicate that a request is in progress + * until it is called again with false. + */ busyChanged?(busy: boolean): void; startAuthStage?(nextStage: string): Promise; // LEGACY } @@ -131,70 +183,7 @@ interface IOpts { * callbacks, and information gathered from the user can be submitted with * submitAuthDict. * - * @constructor - * @alias module:interactive-auth - * - * @param {object} opts options object - * - * @param {object} opts.matrixClient A matrix client to use for the auth process - * - * @param {object?} opts.authData error response from the last request. If - * null, a request will be made with no auth before starting. - * - * @param {function(object?): Promise} opts.doRequest - * called with the new auth dict to submit the request. Also passes a - * second deprecated arg which is a flag set to true if this request - * is a background request. The busyChanged callback should be used - * instead of the background flag. Should return a promise which resolves - * to the successful response or rejects with a MatrixError. - * - * @param {function(boolean): Promise} opts.busyChanged - * called whenever the interactive auth logic becomes busy submitting - * information provided by the user or finishes. After this has been - * called with true the UI should indicate that a request is in progress - * until it is called again with false. - * - * @param {function(string, object?)} opts.stateUpdated - * called when the status of the UI auth changes, ie. when the state of - * an auth stage changes of when the auth flow moves to a new stage. - * The arguments are: the login type (eg m.login.password); and an object - * which is either an error or an informational object specific to the - * login type. If the 'errcode' key is defined, the object is an error, - * and has keys: - * errcode: string, the textual error code, eg. M_UNKNOWN - * error: string, human readable string describing the error - * - * The login type specific objects are as follows: - * m.login.email.identity: - * * emailSid: string, the sid of the active email auth session - * - * @param {object?} opts.inputs Inputs provided by the user and used by different - * stages of the auto process. The inputs provided will affect what flow is chosen. - * - * @param {string?} opts.inputs.emailAddress An email address. If supplied, a flow - * using email verification will be chosen. - * - * @param {string?} opts.inputs.phoneCountry An ISO two letter country code. Gives - * the country that opts.phoneNumber should be resolved relative to. - * - * @param {string?} opts.inputs.phoneNumber A phone number. If supplied, a flow - * using phone number validation will be chosen. - * - * @param {string?} opts.sessionId If resuming an existing interactive auth session, - * the sessionId of that session. - * - * @param {string?} opts.clientSecret If resuming an existing interactive auth session, - * the client secret for that session - * - * @param {string?} opts.emailSid If returning from having completed m.login.email.identity - * auth, the sid for the email verification session. - * - * @param {function?} opts.requestEmailToken A function that takes the email address (string), - * clientSecret (string), attempt number (int) and sessionId (string) and calls the - * relevant requestToken function and returns the promise returned by that function. - * If the resulting promise rejects, the rejection will propagate through to the - * attemptAuth promise. - * + * @param opts - options object */ export class InteractiveAuth { private readonly matrixClient: MatrixClient; @@ -236,7 +225,7 @@ export class InteractiveAuth { /** * begin the authentication process. * - * @return {Promise} which resolves to the response on success, + * @returns which resolves to the response on success, * or rejects with the error on failure. Rejects with NoAuthFlowFoundError if * no suitable authentication flow can be found */ @@ -251,9 +240,7 @@ export class InteractiveAuth { if (!this.data?.flows) { this.busyChangedCallback?.(true); // use the existing sessionId, if one is present. - const auth = this.data.session - ? { session: this.data.session } - : null; + const auth = this.data.session ? { session: this.data.session } : null; this.doRequest(auth).finally(() => { this.busyChangedCallback?.(false); }); @@ -307,7 +294,7 @@ export class InteractiveAuth { /** * get the auth session ID * - * @return {string} session id + * @returns session id */ public getSessionId(): string | undefined { return this.data?.session; @@ -317,7 +304,7 @@ export class InteractiveAuth { * get the client secret used for validation sessions * with the identity server. * - * @return {string} client secret + * @returns client secret */ public getClientSecret(): string { return this.clientSecret; @@ -326,8 +313,8 @@ export class InteractiveAuth { /** * get the server params for a given stage * - * @param {string} loginType login type for the stage - * @return {object?} any parameters from the server for this stage + * @param loginType - login type for the stage + * @returns any parameters from the server for this stage */ public getStageParams(loginType: string): Record | undefined { return this.data.params?.[loginType]; @@ -342,10 +329,10 @@ export class InteractiveAuth { * make attemptAuth resolve/reject, or cause the startAuthStage callback * to be called for a new stage. * - * @param {object} authData new auth dict to send to the server. Should + * @param authData - new auth dict to send to the server. Should * include a `type` property denoting the login type, as well as any * other params for that stage. - * @param {boolean} background If true, this request failing will not result + * @param background - If true, this request failing will not result * in the attemptAuth promise being rejected. This can be set to true * for requests that just poll to see if auth has been completed elsewhere. */ @@ -366,8 +353,7 @@ export class InteractiveAuth { while (this.submitPromise) { try { await this.submitPromise; - } catch (e) { - } + } catch (e) {} } // use the sessionid from the last request, if one is present. @@ -398,7 +384,7 @@ export class InteractiveAuth { * Gets the sid for the email validation session * Specific to m.login.email.identity * - * @returns {string} The sid of the email auth session + * @returns The sid of the email auth session */ public getEmailSid(): string | undefined { return this.emailSid; @@ -410,7 +396,7 @@ export class InteractiveAuth { * of the email validation. * Specific to m.login.email.identity * - * @param {string} sid The sid for the email validation session + * @param sid - The sid for the email validation session */ public setEmailSid(sid: string): void { this.emailSid = sid; @@ -448,9 +434,9 @@ export class InteractiveAuth { * Fire off a request, and either resolve the promise, or call * startAuthStage. * - * @private - * @param {object?} auth new auth dict, including session id - * @param {boolean?} background If true, this request is a background poll, so it + * @internal + * @param auth - new auth dict, including session id + * @param background - If true, this request is a background poll, so it * failing will not result in the attemptAuth promise being rejected. * This can be set to true for requests that just poll to see if auth has * been completed elsewhere. @@ -483,7 +469,8 @@ export class InteractiveAuth { // any UI auth data (eg. when polling for email validation, if the email // has not yet been validated). This appears to be a Synapse bug, which // we workaround here. - if (!(error).data.flows && + if ( + !(error).data.flows && !(error).data.completed && !(error).data.session ) { @@ -526,8 +513,8 @@ export class InteractiveAuth { /** * Pick the next stage and call the callback * - * @private - * @throws {NoAuthFlowFoundError} If no suitable authentication flow can be found + * @internal + * @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found */ private startNextAuthStage(): void { const nextStage = this.chooseStage(); @@ -538,7 +525,7 @@ export class InteractiveAuth { if (nextStage === AuthType.Dummy) { this.submitAuthDict({ - type: 'm.login.dummy', + type: "m.login.dummy", }); return; } @@ -551,17 +538,15 @@ export class InteractiveAuth { return; } - this.stateUpdatedCallback(nextStage, nextStage === EMAIL_STAGE_TYPE - ? { emailSid: this.emailSid } - : {}); + this.stateUpdatedCallback(nextStage, nextStage === EMAIL_STAGE_TYPE ? { emailSid: this.emailSid } : {}); } /** * Pick the next auth stage * - * @private - * @return {string?} login type - * @throws {NoAuthFlowFoundError} If no suitable authentication flow can be found + * @internal + * @returns login type + * @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found */ private chooseStage(): AuthType | undefined { if (this.chosenFlow === null) { @@ -584,19 +569,16 @@ export class InteractiveAuth { * this could result in the email not being used which would leave * the account with no means to reset a password. * - * @private - * @return {object} flow - * @throws {NoAuthFlowFoundError} If no suitable authentication flow can be found + * @internal + * @returns flow + * @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found */ private chooseFlow(): IFlow { const flows = this.data.flows || []; // we've been given an email or we've already done an email part const haveEmail = Boolean(this.inputs.emailAddress) || Boolean(this.emailSid); - const haveMsisdn = ( - Boolean(this.inputs.phoneCountry) && - Boolean(this.inputs.phoneNumber) - ); + const haveMsisdn = Boolean(this.inputs.phoneCountry) && Boolean(this.inputs.phoneNumber); for (const flow of flows) { let flowHasEmail = false; @@ -625,12 +607,11 @@ export class InteractiveAuth { /** * Get the first uncompleted stage in the given flow * - * @private - * @param {object} flow - * @return {string} login type + * @internal + * @returns login type */ private firstUncompletedStage(flow: IFlow): AuthType | undefined { const completed = this.data.completed || []; - return flow.stages.find(stageType => !completed.includes(stageType)); + return flow.stages.find((stageType) => !completed.includes(stageType)); } } diff --git a/src/logger.ts b/src/logger.ts index de1ca6619c1..ba7f7421039 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -15,10 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -/** - * @module logger - */ - import log, { Logger } from "loglevel"; // This is to demonstrate, that you can use any namespace you want. @@ -34,17 +30,15 @@ const DEFAULT_NAMESPACE = "matrix"; // to avoid the issue, we override the methodFactory of loglevel that binds to the // console methods at initialization time by a factory that looks up the console methods // when logging so we always get the current value of console methods. -log.methodFactory = function(methodName, logLevel, loggerName) { - return function(this: PrefixedLogger, ...args): void { +log.methodFactory = function (methodName, logLevel, loggerName) { + return function (this: PrefixedLogger, ...args): void { /* eslint-disable @typescript-eslint/no-invalid-this */ if (this.prefix) { args.unshift(this.prefix); } /* eslint-enable @typescript-eslint/no-invalid-this */ - const supportedByConsole = methodName === "error" || - methodName === "warn" || - methodName === "trace" || - methodName === "info"; + const supportedByConsole = + methodName === "error" || methodName === "warn" || methodName === "trace" || methodName === "info"; /* eslint-disable no-console */ if (supportedByConsole) { return console[methodName](...args); @@ -56,7 +50,7 @@ log.methodFactory = function(methodName, logLevel, loggerName) { }; /** - * Drop-in replacement for console using {@link https://www.npmjs.com/package/loglevel|loglevel}. + * Drop-in replacement for `console` using {@link https://www.npmjs.com/package/loglevel|loglevel}. * Can be tailored down to specific use cases if needed. */ export const logger = log.getLogger(DEFAULT_NAMESPACE) as PrefixedLogger; @@ -68,7 +62,7 @@ export interface PrefixedLogger extends Logger { } function extendLogger(logger: Logger): void { - (logger).withPrefix = function(prefix: string): PrefixedLogger { + (logger).withPrefix = function (prefix: string): PrefixedLogger { const existingPrefix = this.prefix || ""; return getPrefixedLogger(existingPrefix + prefix); }; diff --git a/src/matrix.ts b/src/matrix.ts index 421e0e6ed87..6e8b8bdace2 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -47,40 +47,36 @@ export * from "./store/indexeddb"; export * from "./crypto/store/memory-crypto-store"; export * from "./crypto/store/indexeddb-crypto-store"; export * from "./content-repo"; -export * from './@types/event'; -export * from './@types/PushRules'; -export * from './@types/partials'; -export * from './@types/requests'; -export * from './@types/search'; -export * from './models/room-summary'; +export * from "./@types/event"; +export * from "./@types/PushRules"; +export * from "./@types/partials"; +export * from "./@types/requests"; +export * from "./@types/search"; +export * from "./models/room-summary"; export * as ContentHelpers from "./content-helpers"; export type { ICryptoCallbacks } from "./crypto"; // used to be located here export { createNewMatrixCall } from "./webrtc/call"; export type { MatrixCall } from "./webrtc/call"; -export { - GroupCallEvent, - GroupCallIntent, - GroupCallState, - GroupCallType, -} from "./webrtc/groupCall"; +export { GroupCallEvent, GroupCallIntent, GroupCallState, GroupCallType } from "./webrtc/groupCall"; export type { GroupCall } from "./webrtc/groupCall"; -let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore; +let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore(); /** * Configure a different factory to be used for creating crypto stores * - * @param {Function} fac a function which will return a new - * {@link module:crypto.store.base~CryptoStore}. + * @param fac - a function which will return a new {@link CryptoStore} */ export function setCryptoStoreFactory(fac: () => CryptoStore): void { cryptoStoreFactory = fac; } function amendClientOpts(opts: ICreateClientOpts): ICreateClientOpts { - opts.store = opts.store ?? new MemoryStore({ - localStorage: global.localStorage, - }); + opts.store = + opts.store ?? + new MemoryStore({ + localStorage: global.localStorage, + }); opts.scheduler = opts.scheduler ?? new MatrixScheduler(); opts.cryptoStore = opts.cryptoStore ?? cryptoStoreFactory(); @@ -88,24 +84,14 @@ function amendClientOpts(opts: ICreateClientOpts): ICreateClientOpts { } /** - * Construct a Matrix Client. Similar to {@link module:client.MatrixClient} + * Construct a Matrix Client. Similar to {@link MatrixClient} * except that the 'request', 'store' and 'scheduler' dependencies are satisfied. - * @param {Object} opts The configuration options for this client. These configuration - * options will be passed directly to {@link module:client.MatrixClient}. - * @param {Object} opts.store If not set, defaults to - * {@link module:store/memory.MemoryStore}. - * @param {Object} opts.scheduler If not set, defaults to - * {@link module:scheduler~MatrixScheduler}. + * @param opts - The configuration options for this client. These configuration + * options will be passed directly to {@link MatrixClient}. * - * @param {module:crypto.store.base~CryptoStore=} opts.cryptoStore - * crypto store implementation. Calls the factory supplied to - * {@link setCryptoStoreFactory} if unspecified; or if no factory has been - * specified, uses a default implementation (indexeddb in the browser, - * in-memory otherwise). - * - * @return {MatrixClient} A new matrix client. - * @see {@link module:client.MatrixClient} for the full list of options for - * opts. + * @returns A new matrix client. + * @see {@link MatrixClient} for the full list of options for + * `opts`. */ export function createClient(opts: ICreateClientOpts): MatrixClient { return new MatrixClient(amendClientOpts(opts)); diff --git a/src/models/MSC3089Branch.ts b/src/models/MSC3089Branch.ts index 25ce51a20a1..27be4b87bc1 100644 --- a/src/models/MSC3089Branch.ts +++ b/src/models/MSC3089Branch.ts @@ -67,7 +67,7 @@ export class MSC3089Branch { /** * Deletes the file from the tree, including all prior edits/versions. - * @returns {Promise} Resolves when complete. + * @returns Promise which resolves when complete. */ public async delete(): Promise { await this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, {}, this.id); @@ -79,56 +79,66 @@ export class MSC3089Branch { /** * Gets the name for this file. - * @returns {string} The name, or "Unnamed File" if unknown. + * @returns The name, or "Unnamed File" if unknown. */ public getName(): string { - return this.indexEvent.getContent()['name'] || "Unnamed File"; + return this.indexEvent.getContent()["name"] || "Unnamed File"; } /** * Sets the name for this file. - * @param {string} name The new name for this file. - * @returns {Promise} Resolves when complete. + * @param name - The new name for this file. + * @returns Promise which resolves when complete. */ public async setName(name: string): Promise { - await this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, { - ...this.indexEvent.getContent(), - name: name, - }, this.id); + await this.client.sendStateEvent( + this.roomId, + UNSTABLE_MSC3089_BRANCH.name, + { + ...this.indexEvent.getContent(), + name: name, + }, + this.id, + ); } /** * Gets whether or not a file is locked. - * @returns {boolean} True if locked, false otherwise. + * @returns True if locked, false otherwise. */ public isLocked(): boolean { - return this.indexEvent.getContent()['locked'] || false; + return this.indexEvent.getContent()["locked"] || false; } /** * Sets a file as locked or unlocked. - * @param {boolean} locked True to lock the file, false otherwise. - * @returns {Promise} Resolves when complete. + * @param locked - True to lock the file, false otherwise. + * @returns Promise which resolves when complete. */ public async setLocked(locked: boolean): Promise { - await this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, { - ...this.indexEvent.getContent(), - locked: locked, - }, this.id); + await this.client.sendStateEvent( + this.roomId, + UNSTABLE_MSC3089_BRANCH.name, + { + ...this.indexEvent.getContent(), + locked: locked, + }, + this.id, + ); } /** * Gets information about the file needed to download it. - * @returns {Promise<{info: IEncryptedFile, httpUrl: string}>} Information about the file. + * @returns Information about the file. */ - public async getFileInfo(): Promise<{ info: IEncryptedFile, httpUrl: string }> { + public async getFileInfo(): Promise<{ info: IEncryptedFile; httpUrl: string }> { const event = await this.getFileEvent(); - const file = event.getOriginalContent()['file']; - const httpUrl = this.client.mxcUrlToHttp(file['url']); + const file = event.getOriginalContent()["file"]; + const httpUrl = this.client.mxcUrlToHttp(file["url"]); if (!httpUrl) { - throw new Error(`No HTTP URL available for ${file['url']}`); + throw new Error(`No HTTP URL available for ${file["url"]}`); } return { info: file, httpUrl: httpUrl }; @@ -136,7 +146,7 @@ export class MSC3089Branch { /** * Gets the event the file points to. - * @returns {Promise} Resolves to the file's event. + * @returns Promise which resolves to the file's event. */ public async getFileEvent(): Promise { const room = this.client.getRoom(this.roomId); @@ -161,11 +171,11 @@ export class MSC3089Branch { /** * Creates a new version of this file with contents in a type that is compatible with MatrixClient.uploadContent(). - * @param {string} name The name of the file. - * @param {File | String | Buffer | ReadStream | Blob} encryptedContents The encrypted contents. - * @param {Partial} info The encrypted file information. - * @param {IContent} additionalContent Optional event content fields to include in the message. - * @returns {Promise} Resolves to the file event's sent response. + * @param name - The name of the file. + * @param encryptedContents - The encrypted contents. + * @param info - The encrypted file information. + * @param additionalContent - Optional event content fields to include in the message. + * @returns Promise which resolves to the file event's sent response. */ public async createNewVersion( name: string, @@ -177,30 +187,40 @@ export class MSC3089Branch { ...(additionalContent ?? {}), "m.new_content": true, "m.relates_to": { - "rel_type": RelationType.Replace, - "event_id": this.id, + rel_type: RelationType.Replace, + event_id: this.id, }, }); // Update the version of the new event - await this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, { - active: true, - name: name, - version: this.version + 1, - }, fileEventResponse['event_id']); + await this.client.sendStateEvent( + this.roomId, + UNSTABLE_MSC3089_BRANCH.name, + { + active: true, + name: name, + version: this.version + 1, + }, + fileEventResponse["event_id"], + ); // Deprecate ourselves - await this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, { - ...(this.indexEvent.getContent()), - active: false, - }, this.id); + await this.client.sendStateEvent( + this.roomId, + UNSTABLE_MSC3089_BRANCH.name, + { + ...this.indexEvent.getContent(), + active: false, + }, + this.id, + ); return fileEventResponse; } /** * Gets the file's version history, starting at this file. - * @returns {Promise} Resolves to the file's version history, with the + * @returns Promise which resolves to the file's version history, with the * first element being the current version and the last element being the first version. */ public async getVersionHistory(): Promise { @@ -221,7 +241,7 @@ export class MSC3089Branch { let childEvent: MatrixEvent | undefined; let parentEvent = await this.getFileEvent(); do { - childEvent = timelineEvents.find(e => e.replacingEventId() === parentEvent.getId()); + childEvent = timelineEvents.find((e) => e.replacingEventId() === parentEvent.getId()); if (childEvent) { const branch = this.directory.getFile(childEvent.getId()!); if (branch) { diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index f437eab84da..b0e71d97123 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -106,13 +106,13 @@ export class MSC3089TreeSpace { // but is safe for a managed usecase like we offer in the SDK. const parentEvents = this.room.currentState.getStateEvents(EventType.SpaceParent); if (!parentEvents?.length) return true; - return parentEvents.every(e => !e.getContent()?.['via']); + return parentEvents.every((e) => !e.getContent()?.["via"]); } /** * Sets the name of the tree space. - * @param {string} name The new name for the space. - * @returns {Promise} Resolves when complete. + * @param name - The new name for the space. + * @returns Promise which resolves when complete. */ public async setName(name: string): Promise { await this.client.sendStateEvent(this.roomId, EventType.RoomName, { name }, ""); @@ -121,20 +121,20 @@ export class MSC3089TreeSpace { /** * Invites a user to the tree space. They will be given the default Viewer * permission level unless specified elsewhere. - * @param {string} userId The user ID to invite. - * @param {boolean} andSubspaces True (default) to invite the user to all + * @param userId - The user ID to invite. + * @param andSubspaces - True (default) to invite the user to all * directories/subspaces too, recursively. - * @param {boolean} shareHistoryKeys True (default) to share encryption keys + * @param shareHistoryKeys - True (default) to share encryption keys * with the invited user. This will allow them to decrypt the events (files) * in the tree. Keys will not be shared if the room is lacking appropriate * history visibility (by default, history visibility is "shared" in trees, * which is an appropriate visibility for these purposes). - * @returns {Promise} Resolves when complete. + * @returns Promise which resolves when complete. */ public async invite(userId: string, andSubspaces = true, shareHistoryKeys = true): Promise { const promises: Promise[] = [this.retryInvite(userId)]; if (andSubspaces) { - promises.push(...this.getDirectories().map(d => d.invite(userId, andSubspaces, shareHistoryKeys))); + promises.push(...this.getDirectories().map((d) => d.invite(userId, andSubspaces, shareHistoryKeys))); } return Promise.all(promises).then(() => { // Note: key sharing is default on because for file trees it is relatively important that the invite @@ -150,7 +150,7 @@ export class MSC3089TreeSpace { private retryInvite(userId: string): Promise { return simpleRetryOperation(async () => { - await this.client.invite(this.roomId, userId).catch(e => { + await this.client.invite(this.roomId, userId).catch((e) => { // We don't want to retry permission errors forever... if (e?.errcode === "M_FORBIDDEN") { throw new promiseRetry.AbortError(e); @@ -164,20 +164,20 @@ export class MSC3089TreeSpace { * Sets the permissions of a user to the given role. Note that if setting a user * to Owner then they will NOT be able to be demoted. If the user does not have * permission to change the power level of the target, an error will be thrown. - * @param {string} userId The user ID to change the role of. - * @param {TreePermissions} role The role to assign. - * @returns {Promise} Resolves when complete. + * @param userId - The user ID to change the role of. + * @param role - The role to assign. + * @returns Promise which resolves when complete. */ public async setPermissions(userId: string, role: TreePermissions): Promise { const currentPls = this.room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); if (Array.isArray(currentPls)) throw new Error("Unexpected return type for power levels"); const pls = currentPls?.getContent() || {}; - const viewLevel = pls['users_default'] || 0; - const editLevel = pls['events_default'] || 50; - const adminLevel = pls['events']?.[EventType.RoomPowerLevels] || 100; + const viewLevel = pls["users_default"] || 0; + const editLevel = pls["events_default"] || 50; + const adminLevel = pls["events"]?.[EventType.RoomPowerLevels] || 100; - const users = pls['users'] || {}; + const users = pls["users"] || {}; switch (role) { case TreePermissions.Viewer: users[userId] = viewLevel; @@ -191,7 +191,7 @@ export class MSC3089TreeSpace { default: throw new Error("Invalid role: " + role); } - pls['users'] = users; + pls["users"] = users; await this.client.sendStateEvent(this.roomId, EventType.RoomPowerLevels, pls, ""); } @@ -200,19 +200,19 @@ export class MSC3089TreeSpace { * Gets the current permissions of a user. Note that any users missing explicit permissions (or not * in the space) will be considered Viewers. Appropriate membership checks need to be performed * elsewhere. - * @param {string} userId The user ID to check permissions of. - * @returns {TreePermissions} The permissions for the user, defaulting to Viewer. + * @param userId - The user ID to check permissions of. + * @returns The permissions for the user, defaulting to Viewer. */ public getPermissions(userId: string): TreePermissions { const currentPls = this.room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); if (Array.isArray(currentPls)) throw new Error("Unexpected return type for power levels"); const pls = currentPls?.getContent() || {}; - const viewLevel = pls['users_default'] || 0; - const editLevel = pls['events_default'] || 50; - const adminLevel = pls['events']?.[EventType.RoomPowerLevels] || 100; + const viewLevel = pls["users_default"] || 0; + const editLevel = pls["events_default"] || 50; + const adminLevel = pls["events"]?.[EventType.RoomPowerLevels] || 100; - const userLevel = pls['users']?.[userId] || viewLevel; + const userLevel = pls["users"]?.[userId] || viewLevel; if (userLevel >= adminLevel) return TreePermissions.Owner; if (userLevel >= editLevel) return TreePermissions.Editor; return TreePermissions.Viewer; @@ -220,26 +220,36 @@ export class MSC3089TreeSpace { /** * Creates a directory under this tree space, represented as another tree space. - * @param {string} name The name for the directory. - * @returns {Promise} Resolves to the created directory. + * @param name - The name for the directory. + * @returns Promise which resolves to the created directory. */ public async createDirectory(name: string): Promise { const directory = await this.client.unstableCreateFileTree(name); - await this.client.sendStateEvent(this.roomId, EventType.SpaceChild, { - via: [this.client.getDomain()], - }, directory.roomId); - - await this.client.sendStateEvent(directory.roomId, EventType.SpaceParent, { - via: [this.client.getDomain()], - }, this.roomId); + await this.client.sendStateEvent( + this.roomId, + EventType.SpaceChild, + { + via: [this.client.getDomain()], + }, + directory.roomId, + ); + + await this.client.sendStateEvent( + directory.roomId, + EventType.SpaceParent, + { + via: [this.client.getDomain()], + }, + this.roomId, + ); return directory; } /** * Gets a list of all known immediate subdirectories to this tree space. - * @returns {MSC3089TreeSpace[]} The tree spaces (directories). May be empty, but not null. + * @returns The tree spaces (directories). May be empty, but not null. */ public getDirectories(): MSC3089TreeSpace[] { const trees: MSC3089TreeSpace[] = []; @@ -261,16 +271,16 @@ export class MSC3089TreeSpace { /** * Gets a subdirectory of a given ID under this tree space. Note that this will not recurse * into children and instead only look one level deep. - * @param {string} roomId The room ID (directory ID) to find. - * @returns {MSC3089TreeSpace | undefined} The directory, or undefined if not found. + * @param roomId - The room ID (directory ID) to find. + * @returns The directory, or undefined if not found. */ public getDirectory(roomId: string): MSC3089TreeSpace | undefined { - return this.getDirectories().find(r => r.roomId === roomId); + return this.getDirectories().find((r) => r.roomId === roomId); } /** * Deletes the tree, kicking all members and deleting **all subdirectories**. - * @returns {Promise} Resolves when complete. + * @returns Promise which resolves when complete. */ public async delete(): Promise { const subdirectories = this.getDirectories(); @@ -294,10 +304,10 @@ export class MSC3089TreeSpace { await this.client.leave(this.roomId); } - private getOrderedChildren(children: MatrixEvent[]): { roomId: string, order: string }[] { - const ordered: { roomId: string, order: string }[] = children - .map(c => ({ roomId: c.getStateKey(), order: c.getContent()['order'] })) - .filter(c => c.roomId) as { roomId: string, order: string }[]; + private getOrderedChildren(children: MatrixEvent[]): { roomId: string; order: string }[] { + const ordered: { roomId: string; order: string }[] = children + .map((c) => ({ roomId: c.getStateKey(), order: c.getContent()["order"] })) + .filter((c) => c.roomId) as { roomId: string; order: string }[]; ordered.sort((a, b) => { if (a.order && !b.order) { return -1; @@ -306,7 +316,8 @@ export class MSC3089TreeSpace { } else if (!a.order && !b.order) { const roomA = this.client.getRoom(a.roomId); const roomB = this.client.getRoom(b.roomId); - if (!roomA || !roomB) { // just don't bother trying to do more partial sorting + if (!roomA || !roomB) { + // just don't bother trying to do more partial sorting return lexicographicCompare(a.roomId, b.roomId); } @@ -316,7 +327,8 @@ export class MSC3089TreeSpace { return lexicographicCompare(a.roomId, b.roomId); } return createTsA - createTsB; - } else { // both not-null orders + } else { + // both not-null orders return lexicographicCompare(a.order, b.order); } }); @@ -341,7 +353,7 @@ export class MSC3089TreeSpace { /** * Gets the current order index for this directory. Note that if this is the top level space * then -1 will be returned. - * @returns {number} The order index of this space. + * @returns The order index of this space. */ public getOrder(): number { if (this.isTopLevel) return -1; @@ -350,15 +362,15 @@ export class MSC3089TreeSpace { const children = parentRoom.currentState.getStateEvents(EventType.SpaceChild); const ordered = this.getOrderedChildren(children); - return ordered.findIndex(c => c.roomId === this.roomId); + return ordered.findIndex((c) => c.roomId === this.roomId); } /** * Sets the order index for this directory within its parent. Note that if this is a top level * space then an error will be thrown. -1 can be used to move the child to the start, and numbers * larger than the number of children can be used to move the child to the end. - * @param {number} index The new order index for this space. - * @returns {Promise} Resolves when complete. + * @param index - The new order index for this space. + * @returns Promise which resolves when complete. * @throws Throws if this is a top level space. */ public async setOrder(index: number): Promise { @@ -371,14 +383,14 @@ export class MSC3089TreeSpace { const currentIndex = this.getOrder(); const movingUp = currentIndex < index; - if (movingUp && index === (ordered.length - 1)) { + if (movingUp && index === ordered.length - 1) { index--; } else if (!movingUp && index === 0) { index++; } - const prev = ordered[movingUp ? index : (index - 1)]; - const next = ordered[movingUp ? (index + 1) : index]; + const prev = ordered[movingUp ? index : index - 1]; + const next = ordered[movingUp ? index + 1 : index]; let newOrder = DEFAULT_ALPHABET[0]; let ensureBeforeIsSane = false; @@ -387,7 +399,7 @@ export class MSC3089TreeSpace { if (next?.order) { newOrder = prevString(next.order); } - } else if (index === (ordered.length - 1)) { + } else if (index === ordered.length - 1) { // Move to back if (next?.order) { newOrder = nextString(next.order); @@ -435,10 +447,15 @@ export class MSC3089TreeSpace { lastOrder = lastOrder ? nextString(lastOrder) : DEFAULT_ALPHABET[0]; const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, target.roomId); const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] }; - await this.client.sendStateEvent(parentRoom.roomId, EventType.SpaceChild, { - ...content, - order: lastOrder, - }, target.roomId); + await this.client.sendStateEvent( + parentRoom.roomId, + EventType.SpaceChild, + { + ...content, + order: lastOrder, + }, + target.roomId, + ); } else { lastOrder = target.order; } @@ -453,22 +470,27 @@ export class MSC3089TreeSpace { // Now we can finally update our own order state const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, this.roomId); const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] }; - await this.client.sendStateEvent(parentRoom.roomId, EventType.SpaceChild, { - ...content, - - // TODO: Safely constrain to 50 character limit required by spaces. - order: newOrder, - }, this.roomId); + await this.client.sendStateEvent( + parentRoom.roomId, + EventType.SpaceChild, + { + ...content, + + // TODO: Safely constrain to 50 character limit required by spaces. + order: newOrder, + }, + this.roomId, + ); } /** * Creates (uploads) a new file to this tree. The file must have already been encrypted for the room. * The file contents are in a type that is compatible with MatrixClient.uploadContent(). - * @param {string} name The name of the file. - * @param {File | String | Buffer | ReadStream | Blob} encryptedContents The encrypted contents. - * @param {Partial} info The encrypted file information. - * @param {IContent} additionalContent Optional event content fields to include in the message. - * @returns {Promise} Resolves to the file event's sent response. + * @param name - The name of the file. + * @param encryptedContents - The encrypted contents. + * @param info - The encrypted file information. + * @param additionalContent - Optional event content fields to include in the message. + * @returns Promise which resolves to the file event's sent response. */ public async createFile( name: string, @@ -502,18 +524,23 @@ export class MSC3089TreeSpace { [UNSTABLE_MSC3089_LEAF.name]: {}, }); - await this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, { - active: true, - name: name, - }, res['event_id']); + await this.client.sendStateEvent( + this.roomId, + UNSTABLE_MSC3089_BRANCH.name, + { + active: true, + name: name, + }, + res["event_id"], + ); return res; } /** * Retrieves a file from the tree. - * @param {string} fileEventId The event ID of the file. - * @returns {MSC3089Branch | null} The file, or null if not found. + * @param fileEventId - The event ID of the file. + * @returns The file, or null if not found. */ public getFile(fileEventId: string): MSC3089Branch | null { const branch = this.room.currentState.getStateEvents(UNSTABLE_MSC3089_BRANCH.name, fileEventId); @@ -522,18 +549,18 @@ export class MSC3089TreeSpace { /** * Gets an array of all known files for the tree. - * @returns {MSC3089Branch[]} The known files. May be empty, but not null. + * @returns The known files. May be empty, but not null. */ public listFiles(): MSC3089Branch[] { - return this.listAllFiles().filter(b => b.isActive); + return this.listAllFiles().filter((b) => b.isActive); } /** * Gets an array of all known files for the tree, including inactive/invalid ones. - * @returns {MSC3089Branch[]} The known files. May be empty, but not null. + * @returns The known files. May be empty, but not null. */ public listAllFiles(): MSC3089Branch[] { const branches = this.room.currentState.getStateEvents(UNSTABLE_MSC3089_BRANCH.name) ?? []; - return branches.map(e => new MSC3089Branch(this.client, e, this)); + return branches.map((e) => new MSC3089Branch(this.client, e, this)); } } diff --git a/src/models/beacon.ts b/src/models/beacon.ts index 6e20b229bd5..6a1bb57c766 100644 --- a/src/models/beacon.ts +++ b/src/models/beacon.ts @@ -36,11 +36,8 @@ export type BeaconEventHandlerMap = { [BeaconEvent.Destroy]: (beaconIdentifier: string) => void; }; -export const isTimestampInDuration = ( - startTimestamp: number, - durationMs: number, - timestamp: number, -): boolean => timestamp >= startTimestamp && startTimestamp + durationMs >= timestamp; +export const isTimestampInDuration = (startTimestamp: number, durationMs: number, timestamp: number): boolean => + timestamp >= startTimestamp && startTimestamp + durationMs >= timestamp; // beacon info events are uniquely identified by // `_` @@ -56,9 +53,7 @@ export class Beacon extends TypedEventEmitter; private _latestLocationEvent?: MatrixEvent; - public constructor( - private rootEvent: MatrixEvent, - ) { + public constructor(private rootEvent: MatrixEvent) { super(); this.setBeaconInfo(this.rootEvent); this.roomId = this.rootEvent.getRoomId()!; @@ -98,7 +93,7 @@ export class Beacon extends TypedEventEmitter 1) { - this.livenessWatchTimeout = setTimeout( - () => { this.monitorLiveness(); }, - expiryInMs, - ); + this.livenessWatchTimeout = setTimeout(() => { + this.monitorLiveness(); + }, expiryInMs); } } else if (this.beaconInfo.timestamp! > Date.now()) { // beacon start timestamp is in the future // check liveness again then - this.livenessWatchTimeout = setTimeout( - () => { this.monitorLiveness(); }, - this.beaconInfo.timestamp! - Date.now(), - ); + this.livenessWatchTimeout = setTimeout(() => { + this.monitorLiveness(); + }, this.beaconInfo.timestamp! - Date.now()); } } @@ -159,7 +152,7 @@ export class Beacon extends TypedEventEmitter { + const validLocationEvents = beaconLocationEvents.filter((event) => { const content = event.getContent(); const parsed = parseBeaconContent(content); if (!parsed.uri || !parsed.timestamp) return false; // we won't be able to process these @@ -198,10 +191,13 @@ export class Beacon extends TypedEventEmitter Date.now() ? - this.beaconInfo.timestamp! - 360000 /* 6min */ : - this.beaconInfo.timestamp; - this._isLive = !!this._beaconInfo?.live && !!startTimestamp && + const startTimestamp = + this.beaconInfo.timestamp! > Date.now() + ? this.beaconInfo.timestamp! - 360000 /* 6min */ + : this.beaconInfo.timestamp; + this._isLive = + !!this._beaconInfo?.live && + !!startTimestamp && isTimestampInDuration(startTimestamp, this._beaconInfo?.timeout, Date.now()); if (prevLiveness !== this.isLive) { diff --git a/src/models/event-context.ts b/src/models/event-context.ts index 60252627bde..0401cd53007 100644 --- a/src/models/event-context.ts +++ b/src/models/event-context.ts @@ -17,9 +17,6 @@ limitations under the License. import { MatrixEvent } from "./event"; import { Direction } from "./event-timeline"; -/** - * @module models/event-context - */ export class EventContext { private timeline: MatrixEvent[]; private ourEventIndex = 0; @@ -38,9 +35,7 @@ export class EventContext { * It also stores pagination tokens for going backwards and forwards in the * timeline. * - * @param {MatrixEvent} ourEvent the event at the centre of this context - * - * @constructor + * @param ourEvent - the event at the centre of this context */ public constructor(public readonly ourEvent: MatrixEvent) { this.timeline = [ourEvent]; @@ -51,7 +46,7 @@ export class EventContext { * * This is a convenience function for getTimeline()[getOurEventIndex()]. * - * @return {MatrixEvent} The event at the centre of this context. + * @returns The event at the centre of this context. */ public getEvent(): MatrixEvent { return this.timeline[this.ourEventIndex]; @@ -60,7 +55,7 @@ export class EventContext { /** * Get the list of events in this context * - * @return {Array} An array of MatrixEvents + * @returns An array of MatrixEvents */ public getTimeline(): MatrixEvent[] { return this.timeline; @@ -68,8 +63,6 @@ export class EventContext { /** * Get the index in the timeline of our event - * - * @return {Number} */ public getOurEventIndex(): number { return this.ourEventIndex; @@ -78,9 +71,7 @@ export class EventContext { /** * Get a pagination token. * - * @param {boolean} backwards true to get the pagination token for going - * backwards in time - * @return {string} + * @param backwards - true to get the pagination token for going */ public getPaginateToken(backwards = false): string | null { return this.paginateTokens[backwards ? Direction.Backward : Direction.Forward]; @@ -91,8 +82,8 @@ export class EventContext { * * Generally this will be used only by the matrix js sdk. * - * @param {string} token pagination token - * @param {boolean} backwards true to set the pagination token for going + * @param token - pagination token + * @param backwards - true to set the pagination token for going * backwards in time */ public setPaginateToken(token?: string, backwards = false): void { @@ -102,8 +93,8 @@ export class EventContext { /** * Add more events to the timeline * - * @param {Array} events new events, in timeline order - * @param {boolean} atStart true to insert new events at the start + * @param events - new events, in timeline order + * @param atStart - true to insert new events at the start */ public addEvents(events: MatrixEvent[], atStart = false): void { // TODO: should we share logic with Room.addEventsToTimeline? diff --git a/src/models/event-status.ts b/src/models/event-status.ts index faca97186c9..a5113e0b790 100644 --- a/src/models/event-status.ts +++ b/src/models/event-status.ts @@ -17,7 +17,6 @@ limitations under the License. /** * Enum for event statuses. * @readonly - * @enum {string} */ export enum EventStatus { /** The event was not sent and will no longer be retried. */ diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index 6dd2a0e7740..5cb04997e8b 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -14,13 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -/** - * @module models/event-timeline-set - */ - import { EventTimeline, IAddEventOptions } from "./event-timeline"; import { MatrixEvent } from "./event"; -import { logger } from '../logger'; +import { logger } from "../logger"; import { Room, RoomEvent } from "./room"; import { Filter } from "../filter"; import { RoomState } from "./room-state"; @@ -31,16 +27,20 @@ import { Thread, ThreadFilterType } from "./thread"; const DEBUG = true; +/* istanbul ignore next */ let debuglog: (...args: any[]) => void; if (DEBUG) { // using bind means that we get to keep useful line numbers in the console debuglog = logger.log.bind(logger); } else { - debuglog = function(): void {}; + /* istanbul ignore next */ + debuglog = function (): void {}; } interface IOpts { + // Set to true to enable improved timeline support. timelineSupport?: boolean; + // The filter object, if any, for this timelineSet. filter?: Filter; pendingEvents?: boolean; } @@ -51,18 +51,20 @@ export enum DuplicateStrategy { } export interface IRoomTimelineData { + // the timeline the event was added to/removed from timeline: EventTimeline; + // true if the event was a real-time event added to the end of the live timeline liveEvent?: boolean; } export interface IAddEventToTimelineOptions - extends Pick { + extends Pick { /** Whether the sync response came from cache */ fromCache?: boolean; } export interface IAddLiveEventOptions - extends Pick { + extends Pick { /** Applies to events in the timeline only. If this is 'replace' then if a * duplicate is encountered, the event passed to this function will replace * the existing event in the timeline. If this is not specified, or is @@ -75,6 +77,26 @@ export interface IAddLiveEventOptions type EmittedEvents = RoomEvent.Timeline | RoomEvent.TimelineReset; export type EventTimelineSetHandlerMap = { + /** + * Fires whenever the timeline in a room is updated. + * @param event - The matrix event which caused this event to fire. + * @param room - The room, if any, whose timeline was updated. + * @param toStartOfTimeline - True if this event was added to the start + * @param removed - True if this event has just been removed from the timeline + * (beginning; oldest) of the timeline e.g. due to pagination. + * + * @param data - more data about the event + * + * @example + * ``` + * matrixClient.on("Room.timeline", + * function(event, room, toStartOfTimeline, removed, data) { + * if (!toStartOfTimeline && data.liveEvent) { + * var messageToAppend = room.timeline.[room.timeline.length - 1]; + * } + * }); + * ``` + */ [RoomEvent.Timeline]: ( event: MatrixEvent, room: Room | undefined, @@ -82,6 +104,18 @@ export type EventTimelineSetHandlerMap = { removed: boolean, data: IRoomTimelineData, ) => void; + /** + * Fires whenever the live timeline in a room is reset. + * + * When we get a 'limited' sync (for example, after a network outage), we reset + * the live timeline to be empty before adding the recent events to the new + * timeline. This event is fired after the timeline is reset, and before the + * new events are added. + * + * @param room - The room whose live timeline was reset, if any + * @param timelineSet - timelineSet room whose live timeline was reset + * @param resetAllTimelines - True if all timelines were reset. + */ [RoomEvent.TimelineReset]: ( room: Room | undefined, eventTimelineSet: EventTimelineSet, @@ -119,20 +153,13 @@ export class EventTimelineSet extends TypedEventEmitterIn order that we can find events from their ids later, we also maintain a * map from event_id to timeline and index. * - * @constructor - * @param {Room=} room - * Room for this timelineSet. May be null for non-room cases, such as the + * @param room - Room for this timelineSet. May be null for non-room cases, such as the * notification timeline. - * @param {Object} opts Options inherited from Room. - * - * @param {boolean} [opts.timelineSupport = false] - * Set to true to enable improved timeline support. - * @param {Object} [opts.filter = null] - * The filter object, if any, for this timelineSet. - * @param {MatrixClient=} client the Matrix client which owns this EventTimelineSet, + * @param opts - Options inherited from Room. + * @param client - the Matrix client which owns this EventTimelineSet, * can be omitted if room is specified. - * @param {Thread=} thread the thread to which this timeline set relates. - * @param {boolean} isThreadTimeline Whether this timeline set relates to a thread list timeline + * @param thread - the thread to which this timeline set relates. + * @param isThreadTimeline - Whether this timeline set relates to a thread list timeline * (e.g., All threads or My threads) */ public constructor( @@ -159,7 +186,7 @@ export class EventTimelineSet extends TypedEventEmitteropts.pendingEventOrdering was not 'detached' + * @throws If `opts.pendingEventOrdering` was not 'detached' */ public getPendingEvents(): MatrixEvent[] { if (!this.room || !this.displayPendingEvents) { @@ -201,7 +228,7 @@ export class EventTimelineSet extends TypedEventEmitterThis is used when /sync returns a 'limited' timeline. * - * @param {string=} backPaginationToken token for back-paginating the new timeline - * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline, + * @param backPaginationToken - token for back-paginating the new timeline + * @param forwardPaginationToken - token for forward-paginating the old live timeline, * if absent or null, all timelines are reset. * - * @fires module:client~MatrixClient#event:"Room.timelineReset" + * @remarks + * Fires {@link RoomEvent.TimelineReset} */ public resetLiveTimeline(backPaginationToken?: string, forwardPaginationToken?: string): void { // Each EventTimeline has RoomState objects tracking the state at the start @@ -263,9 +291,9 @@ export class EventTimelineSet extends TypedEventEmitterWill fire "Room.timeline" for each event added. * - * @param {MatrixEvent[]} events A list of events to add. + * @param events - A list of events to add. * - * @param {boolean} toStartOfTimeline True to add these events to the start + * @param toStartOfTimeline - True to add these events to the start * (oldest) instead of the end (newest) of the timeline. If true, the oldest * event will be the last element of 'events'. * - * @param {module:models/event-timeline~EventTimeline} timeline timeline to + * @param timeline - timeline to * add events to. * - * @param {string=} paginationToken token for the next batch of events + * @param paginationToken - token for the next batch of events * - * @fires module:client~MatrixClient#event:"Room.timeline" + * @remarks + * Fires {@link RoomEvent.Timeline} * */ public addEventsToTimeline( @@ -362,15 +395,13 @@ export class EventTimelineSet extends TypedEventEmitter { +export interface IInitialiseStateOptions extends Pick { // This is a separate interface without any extra stuff currently added on // top of `IMarkerFoundOptions` just because it feels like they have // different concerns. One shouldn't necessarily look to add to @@ -33,7 +29,7 @@ export interface IInitialiseStateOptions extends Pick { +export interface IAddEventOptions extends Pick { /** Whether to insert the new event at the start of the timeline where the * oldest events are (timeline is in chronological order, oldest to most * recent) */ @@ -63,9 +59,9 @@ export class EventTimeline { /** * Static helper method to set sender and target properties * - * @param {MatrixEvent} event the event whose metadata is to be set - * @param {RoomState} stateContext the room state to be queried - * @param {boolean} toStartOfTimeline if true the event's forwardLooking flag is set false + * @param event - the event whose metadata is to be set + * @param stateContext - the room state to be queried + * @param toStartOfTimeline - if true the event's forwardLooking flag is set false */ public static setEventMetadata(event: MatrixEvent, stateContext: RoomState, toStartOfTimeline: boolean): void { // When we try to generate a sentinel member before we have that member @@ -127,8 +123,7 @@ export class EventTimeline { *

Once a timeline joins up with its neighbour, they are linked together into a * doubly-linked list. * - * @param {EventTimelineSet} eventTimelineSet the set of timelines this is part of - * @constructor + * @param eventTimelineSet - the set of timelines this is part of */ public constructor(private readonly eventTimelineSet: EventTimelineSet) { this.roomId = eventTimelineSet.room?.roomId ?? null; @@ -138,7 +133,7 @@ export class EventTimeline { } // this is used by client.js - this.paginationRequests = { 'b': null, 'f': null }; + this.paginationRequests = { b: null, f: null }; this.name = this.roomId + ":" + new Date().toISOString(); } @@ -148,9 +143,9 @@ export class EventTimeline { * *

This can only be called before any events are added. * - * @param {MatrixEvent[]} stateEvents list of state events to initialise the + * @param stateEvents - list of state events to initialise the * state with. - * @throws {Error} if an attempt is made to call this after addEvent is called. + * @throws Error if an attempt is made to call this after addEvent is called. */ public initialiseState(stateEvents: MatrixEvent[], { timelineWasEmpty }: IInitialiseStateOptions = {}): void { if (this.events.length > 0) { @@ -167,11 +162,11 @@ export class EventTimeline { * The end state of this timeline gets replaced with an independent copy of the current RoomState, * and will need a new pagination token if it ever needs to paginate forwards. - * @param {string} direction EventTimeline.BACKWARDS to get the state at the + * @param direction - EventTimeline.BACKWARDS to get the state at the * start of the timeline; EventTimeline.FORWARDS to get the state at the end * of the timeline. * - * @return {EventTimeline} the new timeline + * @returns the new timeline */ public forkLive(direction: Direction): EventTimeline { const forkState = this.getState(direction); @@ -191,11 +186,11 @@ export class EventTimeline { /** * Creates an independent timeline, inheriting the directional state from this timeline. * - * @param {string} direction EventTimeline.BACKWARDS to get the state at the + * @param direction - EventTimeline.BACKWARDS to get the state at the * start of the timeline; EventTimeline.FORWARDS to get the state at the end * of the timeline. * - * @return {EventTimeline} the new timeline + * @returns the new timeline */ public fork(direction: Direction): EventTimeline { const forkState = this.getState(direction); @@ -207,7 +202,7 @@ export class EventTimeline { /** * Get the ID of the room for this timeline - * @return {string} room ID + * @returns room ID */ public getRoomId(): string | null { return this.roomId; @@ -215,7 +210,7 @@ export class EventTimeline { /** * Get the filter for this timeline's timelineSet (if any) - * @return {Filter} filter + * @returns filter */ public getFilter(): Filter | undefined { return this.eventTimelineSet.getFilter(); @@ -223,7 +218,7 @@ export class EventTimeline { /** * Get the timelineSet for this timeline - * @return {EventTimelineSet} timelineSet + * @returns timelineSet */ public getTimelineSet(): EventTimelineSet { return this.eventTimelineSet; @@ -237,8 +232,6 @@ export class EventTimeline { * relative to the base index (although note that a given event's index may * well be less than the base index, thus giving that event a negative relative * index). - * - * @return {number} */ public getBaseIndex(): number { return this.baseIndex; @@ -247,7 +240,7 @@ export class EventTimeline { /** * Get the list of events in this context * - * @return {MatrixEvent[]} An array of MatrixEvents + * @returns An array of MatrixEvents */ public getEvents(): MatrixEvent[] { return this.events; @@ -256,11 +249,11 @@ export class EventTimeline { /** * Get the room state at the start/end of the timeline * - * @param {string} direction EventTimeline.BACKWARDS to get the state at the + * @param direction - EventTimeline.BACKWARDS to get the state at the * start of the timeline; EventTimeline.FORWARDS to get the state at the end * of the timeline. * - * @return {RoomState} state at the start/end of the timeline + * @returns state at the start/end of the timeline */ public getState(direction: Direction): RoomState | undefined { if (direction == EventTimeline.BACKWARDS) { @@ -275,11 +268,11 @@ export class EventTimeline { /** * Get a pagination token * - * @param {string} direction EventTimeline.BACKWARDS to get the pagination + * @param direction - EventTimeline.BACKWARDS to get the pagination * token for going backwards in time; EventTimeline.FORWARDS to get the * pagination token for going forwards in time. * - * @return {?string} pagination token + * @returns pagination token */ public getPaginationToken(direction: Direction): string | null { if (this.roomId) { @@ -294,9 +287,9 @@ export class EventTimeline { /** * Set a pagination token * - * @param {?string} token pagination token + * @param token - pagination token * - * @param {string} direction EventTimeline.BACKWARDS to set the pagination + * @param direction - EventTimeline.BACKWARDS to set the pagination * token for going backwards in time; EventTimeline.FORWARDS to set the * pagination token for going forwards in time. */ @@ -313,10 +306,10 @@ export class EventTimeline { /** * Get the next timeline in the series * - * @param {string} direction EventTimeline.BACKWARDS to get the previous + * @param direction - EventTimeline.BACKWARDS to get the previous * timeline; EventTimeline.FORWARDS to get the next timeline. * - * @return {?EventTimeline} previous or following timeline, if they have been + * @returns previous or following timeline, if they have been * joined up. */ public getNeighbouringTimeline(direction: Direction): EventTimeline | null { @@ -332,18 +325,22 @@ export class EventTimeline { /** * Set the next timeline in the series * - * @param {EventTimeline} neighbour previous/following timeline + * @param neighbour - previous/following timeline * - * @param {string} direction EventTimeline.BACKWARDS to set the previous + * @param direction - EventTimeline.BACKWARDS to set the previous * timeline; EventTimeline.FORWARDS to set the next timeline. * - * @throws {Error} if an attempt is made to set the neighbouring timeline when + * @throws Error if an attempt is made to set the neighbouring timeline when * it is already set. */ public setNeighbouringTimeline(neighbour: EventTimeline, direction: Direction): void { if (this.getNeighbouringTimeline(direction)) { - throw new Error("timeline already has a neighbouring timeline - " + - "cannot reset neighbour (direction: " + direction + ")"); + throw new Error( + "timeline already has a neighbouring timeline - " + + "cannot reset neighbour (direction: " + + direction + + ")", + ); } if (direction == EventTimeline.BACKWARDS) { @@ -361,25 +358,14 @@ export class EventTimeline { /** * Add a new event to the timeline, and update the state * - * @param {MatrixEvent} event new event - * @param {IAddEventOptions} options addEvent options + * @param event - new event + * @param options - addEvent options */ - public addEvent( - event: MatrixEvent, - { - toStartOfTimeline, - roomState, - timelineWasEmpty, - }: IAddEventOptions, - ): void; + public addEvent(event: MatrixEvent, { toStartOfTimeline, roomState, timelineWasEmpty }: IAddEventOptions): void; /** * @deprecated In favor of the overload with `IAddEventOptions` */ - public addEvent( - event: MatrixEvent, - toStartOfTimeline: boolean, - roomState?: RoomState - ): void; + public addEvent(event: MatrixEvent, toStartOfTimeline: boolean, roomState?: RoomState): void; public addEvent( event: MatrixEvent, toStartOfTimelineOrOpts: boolean | IAddEventOptions, @@ -387,15 +373,15 @@ export class EventTimeline { ): void { let toStartOfTimeline = !!toStartOfTimelineOrOpts; let timelineWasEmpty: boolean | undefined; - if (typeof (toStartOfTimelineOrOpts) === 'object') { + if (typeof toStartOfTimelineOrOpts === "object") { ({ toStartOfTimeline, roomState, timelineWasEmpty } = toStartOfTimelineOrOpts); } else if (toStartOfTimelineOrOpts !== undefined) { // Deprecation warning // FIXME: Remove after 2023-06-01 (technical debt) logger.warn( - 'Overload deprecated: ' + - '`EventTimeline.addEvent(event, toStartOfTimeline, roomState?)` ' + - 'is deprecated in favor of the overload with `EventTimeline.addEvent(event, IAddEventOptions)`', + "Overload deprecated: " + + "`EventTimeline.addEvent(event, toStartOfTimeline, roomState?)` " + + "is deprecated in favor of the overload with `EventTimeline.addEvent(event, IAddEventOptions)`", ); } @@ -409,10 +395,7 @@ export class EventTimeline { EventTimeline.setEventMetadata(event, roomState!, toStartOfTimeline); // modify state but only on unfiltered timelineSets - if ( - event.isState() && - timelineSet.room.getUnfilteredTimelineSet() === timelineSet - ) { + if (event.isState() && timelineSet.room.getUnfilteredTimelineSet() === timelineSet) { roomState?.setStateEvents([event], { timelineWasEmpty }); // it is possible that the act of setting the state event means we // can set more metadata (specifically sender/target props), so try @@ -447,8 +430,8 @@ export class EventTimeline { /** * Remove an event from the timeline * - * @param {string} eventId ID of event to be removed - * @return {?MatrixEvent} removed event, or null if not found + * @param eventId - ID of event to be removed + * @returns removed event, or null if not found */ public removeEvent(eventId: string): MatrixEvent | null { for (let i = this.events.length - 1; i >= 0; i--) { @@ -467,7 +450,7 @@ export class EventTimeline { /** * Return a string to identify this timeline, for debugging * - * @return {string} name for this timeline + * @returns name for this timeline */ public toString(): string { return this.name; diff --git a/src/models/event.ts b/src/models/event.ts index c233307d4eb..1f1c3cfa366 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -17,46 +17,47 @@ limitations under the License. /** * This is an internal module. See {@link MatrixEvent} and {@link RoomEvent} for * the public classes. - * @module models/event */ import { ExtensibleEvent, ExtensibleEvents, Optional } from "matrix-events-sdk"; -import { logger } from '../logger'; +import type { IEventDecryptionResult } from "../@types/crypto"; +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 { Crypto } from "../crypto"; 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'; -import { TypedReEmitter } from '../ReEmitter'; +import { IActionsObject } from "../pushprocessor"; +import { TypedReEmitter } from "../ReEmitter"; import { MatrixError } from "../http-api"; import { TypedEventEmitter } from "./typed-event-emitter"; import { EventStatus } from "./event-status"; import { DecryptionError } from "../crypto/algorithms"; +import { CryptoBackend } from "../common-crypto/CryptoBackend"; export { EventStatus } from "./event-status"; /* eslint-disable camelcase */ export interface IContent { [key: string]: any; - msgtype?: MsgType | string; - membership?: string; - avatar_url?: string; - displayname?: string; + "msgtype"?: MsgType | string; + "membership"?: string; + "avatar_url"?: string; + "displayname"?: string; "m.relates_to"?: IEventRelation; } type StrippedState = Required>; export interface IUnsigned { - age?: number; - prev_sender?: string; - prev_content?: IContent; - redacted_because?: IEvent; - transaction_id?: string; - invite_room_state?: StrippedState[]; + "age"?: number; + "prev_sender"?: string; + "prev_content"?: IContent; + "redacted_because"?: IEvent; + "transaction_id"?: string; + "invite_room_state"?: StrippedState[]; "m.relations"?: Record; // No common pattern for aggregated relations } @@ -80,15 +81,15 @@ export interface IEvent { redacts?: string; /** - * @deprecated + * @deprecated in favour of `sender` */ user_id?: string; /** - * @deprecated + * @deprecated in favour of `unsigned.prev_content` */ prev_content?: IContent; /** - * @deprecated + * @deprecated in favour of `origin_server_ts` */ age?: number; } @@ -103,13 +104,13 @@ export interface IAggregatedRelation { } export interface IEventRelation { - rel_type?: RelationType | string; - event_id?: string; - is_falling_back?: boolean; + "rel_type"?: RelationType | string; + "event_id"?: string; + "is_falling_back"?: boolean; "m.in_reply_to"?: { event_id?: string; }; - key?: string; + "key"?: string; } /** @@ -150,8 +151,11 @@ interface IKeyRequestRecipient { } export interface IDecryptOptions { + // Emits "event.decrypted" if set to true emit?: boolean; + // True if this is a retry (enables more logging) isRetry?: boolean; + // whether the message should be re-decrypted if it was previously successfully decrypted with an untrusted key forceRedecryptIfUntrusted?: boolean; } @@ -192,6 +196,12 @@ export enum MatrixEventEvent { export type MatrixEventEmittedEvents = MatrixEventEvent | ThreadEvent.Update; export type MatrixEventHandlerMap = { + /** + * Fires when an event is decrypted + * + * @param event - The matrix event which has been decrypted + * @param err - The error that occurred during decryption, or `undefined` if no error occurred. + */ [MatrixEventEvent.Decrypted]: (event: MatrixEvent, err?: Error) => void; [MatrixEventEvent.BeforeRedaction]: (event: MatrixEvent, redactionEvent: MatrixEvent) => void; [MatrixEventEvent.VisibilityChange]: (event: MatrixEvent, visible: boolean) => void; @@ -271,12 +281,43 @@ export class MatrixEvent extends TypedEventEmitterThis property is experimental and may change. + * @privateRemarks + * Should be read-only + */ + public forwardLooking = true; /* If the event is a `m.key.verification.request` (or to_device `m.key.verification.start`) event, * `Crypto` will set this the `VerificationRequest` for the event @@ -288,26 +329,11 @@ export class MatrixEvent extends TypedEventEmitterDo not access + * @param event - The raw (possibly encrypted) event. Do not access * this property directly unless you absolutely have to. Prefer the getter * methods defined on this class. Using the getter methods shields your app * from changes to event JSON between Matrix versions. - * - * @prop {RoomMember} sender The room member who sent this event, or null e.g. - * this is a presence event. This is only guaranteed to be set for events that - * appear in a timeline, ie. do not guarantee that it will be set on state - * events. - * @prop {RoomMember} target The room member who is the target of this event, e.g. - * the invitee, the person being banned, etc. - * @prop {EventStatus} status The sending status of the event. - * @prop {Error} error most recent error associated with sending the event, if any - * @prop {boolean} forwardLooking True if this event is 'forward looking', meaning - * that getDirectionalContent() will return event.content and not event.prev_content. - * Default: true. This property is experimental and may change. */ public constructor(public event: Partial = {}) { super(); @@ -318,19 +344,19 @@ export class MatrixEvent extends TypedEventEmitter { + (["state_key", "type", "sender", "room_id", "membership"] as const).forEach((prop) => { if (typeof event[prop] !== "string") return; - event[prop] = internaliseString(event[prop]); + event[prop] = internaliseString(event[prop]!); }); - ["membership", "avatar_url", "displayname"].forEach((prop) => { + (["membership", "avatar_url", "displayname"] as const).forEach((prop) => { if (typeof event.content?.[prop] !== "string") return; - event.content[prop] = internaliseString(event.content[prop]); + event.content[prop] = internaliseString(event.content[prop]!); }); - ["rel_type"].forEach((prop) => { + (["rel_type"] as const).forEach((prop) => { if (typeof event.content?.["m.relates_to"]?.[prop] !== "string") return; - event.content["m.relates_to"][prop] = internaliseString(event.content["m.relates_to"][prop]); + event.content["m.relates_to"][prop] = internaliseString(event.content["m.relates_to"][prop]!); }); this.txnId = event.txn_id; @@ -360,7 +386,7 @@ export class MatrixEvent extends TypedEventEmitter$143350589368169JsLZx:localhost + * @returns The event ID, e.g. $143350589368169JsLZx:localhost * */ public getId(): string | undefined { @@ -398,7 +424,7 @@ export class MatrixEvent extends TypedEventEmitter@alice:matrix.org + * @returns The user ID, e.g. `@alice:matrix.org` */ public getSender(): string | undefined { return this.event.sender || this.event.user_id; // v2 / v1 @@ -407,7 +433,7 @@ export class MatrixEvent extends TypedEventEmitterm.room.message + * @returns The event type, e.g. `m.room.message` */ public getType(): EventType | string { if (this.clearEvent) { @@ -420,16 +446,16 @@ export class MatrixEvent extends TypedEventEmitterundefined - * for m.presence events. - * @return {string?} The room ID, e.g. !cURbafjkfsMDVwdRDQ:matrix.org + * Get the room_id for this event. This will return `undefined` + * for `m.presence` events. + * @returns The room ID, e.g. !cURbafjkfsMDVwdRDQ:matrix.org * */ public getRoomId(): string | undefined { @@ -438,7 +464,7 @@ export class MatrixEvent extends TypedEventEmitter1433502692297 + * @returns The event timestamp, e.g. `1433502692297` */ public getTs(): number { return this.event.origin_server_ts!; @@ -446,7 +472,7 @@ export class MatrixEvent extends TypedEventEmitternew Date(1433502692297) + * @returns The event date, e.g. `new Date(1433502692297)` */ public getDate(): Date | null { return this.event.origin_server_ts ? new Date(this.event.origin_server_ts) : null; @@ -457,7 +483,11 @@ export class MatrixEvent extends TypedEventEmitter(): T { if (this._localRedactionEvent) { @@ -493,7 +523,7 @@ export class MatrixEvent extends TypedEventEmitter(): T { if (this._localRedactionEvent) { @@ -509,7 +539,7 @@ export class MatrixEvent extends TypedEventEmitter(THREAD_RELATION_TYPE.name); + const threadDetails = this.getServerAggregatedRelation(THREAD_RELATION_TYPE.name); // Bundled relationships only returned when the sync response is limited // hence us having to check both bundled relation and inspect the thread // model - return !!threadDetails || (this.getThread()?.id === this.getId()); + return !!threadDetails || this.getThread()?.id === this.getId(); } public get replyEventId(): string | undefined { @@ -547,20 +576,18 @@ export class MatrixEvent extends TypedEventEmitterThis method is experimental and may change. - * @return {Object} event.content if this event is forward-looking, else + * @returns event.content if this event is forward-looking, else * event.prev_content. */ public getDirectionalContent(): IContent { @@ -585,7 +612,7 @@ export class MatrixEvent extends TypedEventEmitterundefined * for message events. - * @return {string} The event's state_key. + * @returns The event's `state_key`. */ public getStateKey(): string | undefined { return this.event.state_key; @@ -612,7 +639,7 @@ export class MatrixEvent extends TypedEventEmitter"m.room.encrypted" * - * @param {object} cryptoContent raw 'content' for the encrypted event. + * @param cryptoContent - raw 'content' for the encrypted event. * - * @param {string} senderCurve25519Key curve25519 key to record for the + * @param senderCurve25519Key - curve25519 key to record for the * sender of this event. - * See {@link module:models/event.MatrixEvent#getSenderKey}. + * See {@link MatrixEvent#getSenderKey}. * - * @param {string} claimedEd25519Key claimed ed25519 key to record for the + * @param claimedEd25519Key - claimed ed25519 key to record for the * sender if this event. - * See {@link module:models/event.MatrixEvent#getClaimedEd25519Key} + * See {@link MatrixEvent#getClaimedEd25519Key} */ public makeEncrypted( cryptoType: string, @@ -657,7 +684,7 @@ export class MatrixEvent extends TypedEventEmitter { + public async attemptDecryption(crypto: CryptoBackend, options: IDecryptOptions = {}): Promise { // start with a couple of sanity checks. if (!this.isEncrypted()) { throw new Error("Attempt to decrypt event which isn't encrypted"); @@ -715,9 +737,7 @@ export class MatrixEvent extends TypedEventEmitter { const wireContent = this.getWireContent(); - return crypto.requestRoomKey({ - algorithm: wireContent.algorithm, - room_id: this.getRoomId()!, - session_id: wireContent.session_id, - sender_key: wireContent.sender_key, - }, this.getKeyRequestRecipients(userId), true); + return crypto.requestRoomKey( + { + algorithm: wireContent.algorithm, + room_id: this.getRoomId()!, + session_id: wireContent.session_id, + sender_key: wireContent.sender_key, + }, + this.getKeyRequestRecipients(userId), + true, + ); } /** * Calculate the recipients for keyshare requests. * - * @param {string} userId the user who received this event. + * @param userId - the user who received this event. * - * @returns {Array} array of recipients + * @returns array of recipients */ public getKeyRequestRecipients(userId: string): IKeyRequestRecipient[] { // send the request to all of our own devices, and the // original sending device if it wasn't us. const wireContent = this.getWireContent(); - const recipients = [{ - userId, - deviceId: '*', - }]; + const recipients = [ + { + userId, + deviceId: "*", + }, + ]; const sender = this.getSender(); if (sender !== userId) { recipients.push({ @@ -781,7 +805,7 @@ export class MatrixEvent extends TypedEventEmitter { + private async decryptionLoop(crypto: CryptoBackend, options: IDecryptOptions = {}): Promise { // make sure that this method never runs completely synchronously. // (doing so would mean that we would clear decryptionPromise *before* // it is set in attemptDecryption - and hence end up with a stuck @@ -807,7 +831,7 @@ export class MatrixEvent extends TypedEventEmittere).name !== "DecryptionError") { // not a decryption error: log the whole exception as an error // (and don't bother with a retry) - const re = options.isRetry ? 're' : ''; + const re = options.isRetry ? "re" : ""; // For find results: this can produce "Error decrypting event (id=$ev)" and // "Error redecrypting event (id=$ev)". logger.error(`Error ${re}decrypting event (${this.getDetails()})`, e); @@ -836,7 +860,7 @@ export class MatrixEvent extends TypedEventEmittere).detailedString, + (e).detailedString, ); continue; } @@ -846,10 +870,7 @@ export class MatrixEvent extends TypedEventEmittere).detailedString, - ); + logger.warn(`Error decrypting event (${this.getDetails()}): ` + (e).detailedString); res = this.badEncryptedMessage((e).message); } @@ -903,17 +924,16 @@ export class MatrixEvent extends TypedEventEmitter} */ public getKeysClaimed(): Partial> { if (!this.claimedEd25519Key) return {}; @@ -982,8 +998,6 @@ export class MatrixEvent extends TypedEventEmitter

When an event is first transmitted, a temporary copy of the event is + * inserted into the timeline, with a temporary event id, and a status of + * 'SENDING'. + * + *

Once the echo comes back from the server, the content of the event + * (MatrixEvent.event) is replaced by the complete event from the homeserver, + * thus updating its event id, as well as server-generated fields such as the + * timestamp. Its status is set to null. + * + *

Once the /send request completes, if the remote echo has not already + * arrived, the event is updated with a new event id and the status is set to + * 'SENT'. The server-generated fields are of course not updated yet. + * + *

If the /send fails, In this case, the event's status is set to + * 'NOT_SENT'. If it is later resent, the process starts again, setting the + * status to 'SENDING'. Alternatively, the message may be cancelled, which + * removes the event from the room, and sets the status to 'CANCELLED'. + * + *

This event is raised to reflect each of the transitions above. + * + * @param event - The matrix event which has been updated + * + * @param room - The room containing the redacted event + * + * @param oldEventId - The previous event id (the temporary event id, + * except when updating a successfully-sent event when its echo arrives) + * + * @param oldStatus - The previous event status. + */ [RoomEvent.LocalEchoUpdated]: ( event: MatrixEvent, room: Room, @@ -166,41 +279,30 @@ export type RoomEventHandlerMap = { ) => void; [RoomEvent.OldStateUpdated]: (room: Room, previousRoomState: RoomState, roomState: RoomState) => void; [RoomEvent.CurrentStateUpdated]: (room: Room, previousRoomState: RoomState, roomState: RoomState) => void; - [RoomEvent.HistoryImportedWithinTimeline]: ( - markerEvent: MatrixEvent, - room: Room, - ) => void; - [RoomEvent.UnreadNotifications]: ( - unreadNotifications?: NotificationCount, - threadId?: string, - ) => void; + [RoomEvent.HistoryImportedWithinTimeline]: (markerEvent: MatrixEvent, room: Room) => void; + [RoomEvent.UnreadNotifications]: (unreadNotifications?: NotificationCount, threadId?: string) => void; [RoomEvent.TimelineRefresh]: (room: Room, eventTimelineSet: EventTimelineSet) => void; [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; -} & Pick< - ThreadHandlerMap, - ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete - > - & EventTimelineSetHandlerMap - & Pick - & Pick< +} & Pick & + EventTimelineSetHandlerMap & + Pick & + Pick< RoomStateEventHandlerMap, - RoomStateEvent.Events - | RoomStateEvent.Members - | RoomStateEvent.NewMember - | RoomStateEvent.Update - | RoomStateEvent.Marker - | BeaconEvent.New - > - & Pick< - BeaconEventHandlerMap, - BeaconEvent.Update | BeaconEvent.Destroy | BeaconEvent.LivenessChange - >; + | RoomStateEvent.Events + | RoomStateEvent.Members + | RoomStateEvent.NewMember + | RoomStateEvent.Update + | RoomStateEvent.Marker + | BeaconEvent.New + > & + Pick; export class Room extends ReadReceipt { public readonly reEmitter: TypedReEmitter; private txnToEvent: Record = {}; // Pending in-flight requests { string: MatrixEvent } private notificationCounts: NotificationCount = {}; private readonly threadNotifications = new Map(); + public readonly cachedThreadReadReceipts = new Map(); private readonly timelineSets: EventTimelineSet[]; public readonly threadsTimelineSets: EventTimelineSet[] = []; // any filtered timeline sets we're maintaining for this room @@ -227,7 +329,7 @@ export class Room extends ReadReceipt { public normalizedName: string; /** * Dict of room tags; the keys are the tag name and the values - * are any metadata associated with the tag - e.g. { "fav" : { order: 1 } } + * are any metadata associated with the tag - e.g. `{ "fav" : { order: 1 } }` */ public tags: Record> = {}; // $tagName: { $metadata: $value } /** @@ -301,21 +403,10 @@ export class Room extends ReadReceipt { *

In order that we can find events from their ids later, we also maintain a * map from event_id to timeline and index. * - * @constructor - * @alias module:models/room - * @param {string} roomId Required. The ID of this room. - * @param {MatrixClient} client Required. The client, used to lazy load members. - * @param {string} myUserId Required. The ID of the syncing user. - * @param {Object=} opts Configuration options - * - * @param {String=} opts.pendingEventOrdering Controls where pending messages - * appear in a room's timeline. If "chronological", messages will appear - * in the timeline when the call to sendEvent was made. If - * "detached", pending messages will appear in a separate list, - * accessible via {@link module:models/room#getPendingEvents}. Default: - * "chronological". - * @param {boolean} [opts.timelineSupport = false] Set to true to enable improved - * timeline support. + * @param roomId - Required. The ID of this room. + * @param client - Required. The client, used to lazy load members. + * @param myUserId - Required. The ID of the syncing user. + * @param opts - Configuration options */ public constructor( public readonly roomId: string, @@ -337,16 +428,13 @@ export class Room extends ReadReceipt { // all our per-room timeline sets. the first one is the unfiltered ones; // the subsequent ones are the filtered ones in no particular order. this.timelineSets = [new EventTimelineSet(this, opts)]; - this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), [ - RoomEvent.Timeline, - RoomEvent.TimelineReset, - ]); + this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), [RoomEvent.Timeline, RoomEvent.TimelineReset]); this.fixUpLegacyTimelineFields(); if (this.opts.pendingEventOrdering === PendingEventOrdering.Detached) { this.pendingEventList = []; - this.client.store.getPendingEvents(this.roomId).then(events => { + this.client.store.getPendingEvents(this.roomId).then((events) => { const mapper = this.client.getEventMapper({ toDevice: false, decrypt: false, @@ -402,22 +490,22 @@ export class Room extends ReadReceipt { * - Last event of every room (to generate likely message preview) * - All events up to the read receipt (to calculate an accurate notification count) * - * @returns {Promise} Signals when all events have been decrypted + * @returns Signals when all events have been decrypted */ public async decryptCriticalEvents(): Promise { if (!this.client.isCryptoEnabled()) return; const readReceiptEventId = this.getEventReadUpTo(this.client.getUserId()!, true); const events = this.getLiveTimeline().getEvents(); - const readReceiptTimelineIndex = events.findIndex(matrixEvent => { + const readReceiptTimelineIndex = events.findIndex((matrixEvent) => { return matrixEvent.event.event_id === readReceiptEventId; }); const decryptionPromises = events .slice(readReceiptTimelineIndex) - .filter(event => event.shouldAttemptDecryption()) + .filter((event) => event.shouldAttemptDecryption()) .reverse() - .map(event => event.attemptDecryption(this.client.crypto!, { isRetry: true })); + .map((event) => event.attemptDecryption(this.client.crypto!, { isRetry: true })); await Promise.allSettled(decryptionPromises); } @@ -425,34 +513,33 @@ export class Room extends ReadReceipt { /** * Bulk decrypt events in a room * - * @returns {Promise} Signals when all events have been decrypted + * @returns Signals when all events have been decrypted */ public async decryptAllEvents(): Promise { if (!this.client.isCryptoEnabled()) return; - const decryptionPromises = this - .getUnfilteredTimelineSet() + const decryptionPromises = this.getUnfilteredTimelineSet() .getLiveTimeline() .getEvents() - .filter(event => event.shouldAttemptDecryption()) + .filter((event) => event.shouldAttemptDecryption()) .reverse() - .map(event => event.attemptDecryption(this.client.crypto!, { isRetry: true })); + .map((event) => event.attemptDecryption(this.client.crypto!, { isRetry: true })); await Promise.allSettled(decryptionPromises); } /** * Gets the creator of the room - * @returns {string} The creator of the room, or null if it could not be determined + * @returns The creator of the room, or null if it could not be determined */ public getCreator(): string | null { const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); - return createEvent?.getContent()['creator'] ?? null; + return createEvent?.getContent()["creator"] ?? null; } /** * Gets the version of the room - * @returns {string} The version of the room, or null if it could not be determined + * @returns The version of the room, or null if it could not be determined */ public getVersion(): string { const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); @@ -461,14 +548,14 @@ export class Room extends ReadReceipt { logger.warn("[getVersion] Room " + this.roomId + " does not have an m.room.create event"); this.getVersionWarning = true; } - return '1'; + return "1"; } - return createEvent.getContent()['room_version'] ?? '1'; + return createEvent.getContent()["room_version"] ?? "1"; } /** * Determines whether this room needs to be upgraded to a new version - * @returns {string?} What version the room should be upgraded to, or null if + * @returns What version the room should be upgraded to, or null if * the room does not require upgrading at this time. * @deprecated Use #getRecommendedVersion() instead */ @@ -489,13 +576,13 @@ export class Room extends ReadReceipt { /** * Determines the recommended room version for the room. This returns an - * object with 3 properties: version as the new version the + * object with 3 properties: `version` as the new version the * room should be upgraded to (may be the same as the current version); - * needsUpgrade to indicate if the room actually can be - * upgraded (ie: does the current version not match?); and urgent + * `needsUpgrade` to indicate if the room actually can be + * upgraded (ie: does the current version not match?); and `urgent` * to indicate if the new version patches a vulnerability in a previous * version. - * @returns {Promise<{version: string, needsUpgrade: boolean, urgent: boolean}>} + * @returns * Resolves to the version the room should be upgraded to. */ public async getRecommendedVersion(): Promise { @@ -521,7 +608,7 @@ export class Room extends ReadReceipt { // the capability we're using to determine this. logger.warn( "Refreshing room version capability because the server looks " + - "to be supporting a newer room version we don't know about.", + "to be supporting a newer room version we don't know about.", ); const caps = await this.client.getCapabilities(true); @@ -551,8 +638,7 @@ export class Room extends ReadReceipt { // If the room is on the default version then nothing needs to change if (currentVersion === versionCap.default) return result; - const stableVersions = Object.keys(versionCap.available) - .filter((v) => versionCap.available[v] === 'stable'); + const stableVersions = Object.keys(versionCap.available).filter((v) => versionCap.available[v] === "stable"); // Check if the room is on an unstable version. We determine urgency based // off the version being in the Matrix spec namespace or not (if the version @@ -576,8 +662,8 @@ export class Room extends ReadReceipt { /** * Determines whether the given user is permitted to perform a room upgrade - * @param {String} userId The ID of the user to test against - * @returns {boolean} True if the given user is permitted to upgrade the room + * @param userId - The ID of the user to test against + * @returns True if the given user is permitted to upgrade the room */ public userMayUpgradeRoom(userId: string): boolean { return this.currentState.maySendStateEvent(EventType.RoomTombstone, userId); @@ -586,16 +672,16 @@ export class Room extends ReadReceipt { /** * Get the list of pending sent events for this room * - * @return {module:models/event.MatrixEvent[]} A list of the sent events + * @returns A list of the sent events * waiting for remote echo. * - * @throws If opts.pendingEventOrdering was not 'detached' + * @throws If `opts.pendingEventOrdering` was not 'detached' */ public getPendingEvents(): MatrixEvent[] { if (!this.pendingEventList) { throw new Error( - "Cannot call getPendingEvents with pendingEventOrdering == " + - this.opts.pendingEventOrdering); + "Cannot call getPendingEvents with pendingEventOrdering == " + this.opts.pendingEventOrdering, + ); } return this.pendingEventList; @@ -604,21 +690,21 @@ export class Room extends ReadReceipt { /** * Removes a pending event for this room * - * @param {string} eventId - * @return {boolean} True if an element was removed. + * @returns True if an element was removed. */ public removePendingEvent(eventId: string): boolean { if (!this.pendingEventList) { throw new Error( - "Cannot call removePendingEvent with pendingEventOrdering == " + - this.opts.pendingEventOrdering); + "Cannot call removePendingEvent with pendingEventOrdering == " + this.opts.pendingEventOrdering, + ); } const removed = utils.removeElement( this.pendingEventList, - function(ev) { + function (ev) { return ev.getId() == eventId; - }, false, + }, + false, ); this.savePendingEvents(); @@ -630,27 +716,25 @@ export class Room extends ReadReceipt { * Check whether the pending event list contains a given event by ID. * If pending event ordering is not "detached" then this returns false. * - * @param {string} eventId The event ID to check for. - * @return {boolean} + * @param eventId - The event ID to check for. */ public hasPendingEvent(eventId: string): boolean { - return this.pendingEventList?.some(event => event.getId() === eventId) ?? false; + return this.pendingEventList?.some((event) => event.getId() === eventId) ?? false; } /** * Get a specific event from the pending event list, if configured, null otherwise. * - * @param {string} eventId The event ID to check for. - * @return {MatrixEvent} + * @param eventId - The event ID to check for. */ public getPendingEvent(eventId: string): MatrixEvent | null { - return this.pendingEventList?.find(event => event.getId() === eventId) ?? null; + return this.pendingEventList?.find((event) => event.getId() === eventId) ?? null; } /** * Get the live unfiltered timeline for this room. * - * @return {module:models/event-timeline~EventTimeline} live timeline + * @returns live timeline */ public getLiveTimeline(): EventTimeline { return this.getUnfilteredTimelineSet().getLiveTimeline(); @@ -659,7 +743,7 @@ export class Room extends ReadReceipt { /** * Get the timestamp of the last message in the room * - * @return {number} the timestamp of the last message in the room + * @returns the timestamp of the last message in the room */ public getLastActiveTimestamp(): number { const timeline = this.getLiveTimeline(); @@ -673,7 +757,7 @@ export class Room extends ReadReceipt { } /** - * @return {string} the membership type (join | leave | invite) for the logged in user + * @returns the membership type (join | leave | invite) for the logged in user */ public getMyMembership(): string { return this.selfMembership ?? "leave"; @@ -682,7 +766,7 @@ export class Room extends ReadReceipt { /** * If this room is a DM we're invited to, * try to find out who invited us - * @return {string} user id of the inviter + * @returns user id of the inviter */ public getDMInviter(): string | undefined { const me = this.getMember(this.myUserId); @@ -701,7 +785,7 @@ export class Room extends ReadReceipt { /** * Assuming this room is a DM room, tries to guess with which user. - * @return {string} user id of the other member (could be syncing user) + * @returns user id of the other member (could be syncing user) */ public guessDMUserId(): string { const me = this.getMember(this.myUserId); @@ -758,8 +842,7 @@ export class Room extends ReadReceipt { return this.client.getUser(userId); }).find((user) => !!user); if (availableUser) { - const member = new RoomMember( - this.roomId, availableUser.userId); + const member = new RoomMember(this.roomId, availableUser.userId); member.user = availableUser; return member; } @@ -768,7 +851,7 @@ export class Room extends ReadReceipt { /** * Sets the membership this room was received as during sync - * @param {string} membership join | leave | invite + * @param membership - join | leave | invite */ public updateMyMembership(membership: string): void { const prevMembership = this.selfMembership; @@ -787,7 +870,7 @@ export class Room extends ReadReceipt { return response.chunk; } - private async loadMembers(): Promise<{ memberEvents: MatrixEvent[], fromServer: boolean }> { + private async loadMembers(): Promise<{ memberEvents: MatrixEvent[]; fromServer: boolean }> { // were the members loaded from the server? let fromServer = false; let rawMembersEvents = await this.client.store.getOutOfBandMembers(this.roomId); @@ -796,13 +879,10 @@ export class Room extends ReadReceipt { // that this function is only called once (unless loading the members // fails), since loadMembersIfNeeded always returns this.membersPromise // if set, which will be the result of the first (successful) call. - if (rawMembersEvents === null || - (this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId)) - ) { + if (rawMembersEvents === null || (this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId))) { fromServer = true; rawMembersEvents = await this.loadMembersFromServer(); - logger.log(`LL: got ${rawMembersEvents.length} ` + - `members from server for room ${this.roomId}`); + logger.log(`LL: got ${rawMembersEvents.length} ` + `members from server for room ${this.roomId}`); } const memberEvents = rawMembersEvents.map(this.client.getEventMapper()); return { memberEvents, fromServer }; @@ -812,7 +892,7 @@ export class Room extends ReadReceipt { * Preloads the member list in case lazy loading * of memberships is in use. Can be called multiple times, * it will only preload once. - * @return {Promise} when preloading is done and + * @returns when preloading is done and * accessing the members on the room will take * all members in the room into account */ @@ -826,41 +906,47 @@ export class Room extends ReadReceipt { // the OOB members this.currentState.markOutOfBandMembersStarted(); - const inMemoryUpdate = this.loadMembers().then((result) => { - this.currentState.setOutOfBandMembers(result.memberEvents); - // now the members are loaded, start to track the e2e devices if needed - if (this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId)) { - this.client.crypto!.trackRoomDevices(this.roomId); - } - return result.fromServer; - }).catch((err) => { - // allow retries on fail - this.membersPromise = undefined; - this.currentState.markOutOfBandMembersFailed(); - throw err; - }); + const inMemoryUpdate = this.loadMembers() + .then((result) => { + this.currentState.setOutOfBandMembers(result.memberEvents); + // now the members are loaded, start to track the e2e devices if needed + if (this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId)) { + this.client.crypto!.trackRoomDevices(this.roomId); + } + return result.fromServer; + }) + .catch((err) => { + // allow retries on fail + this.membersPromise = undefined; + this.currentState.markOutOfBandMembersFailed(); + throw err; + }); // update members in storage, but don't wait for it - inMemoryUpdate.then((fromServer) => { - if (fromServer) { - const oobMembers = this.currentState.getMembers() - .filter((m) => m.isOutOfBand()) - .map((m) => m.events.member?.event as IStateEventWithRoomId); - logger.log(`LL: telling store to write ${oobMembers.length}` - + ` members for room ${this.roomId}`); - const store = this.client.store; - return store.setOutOfBandMembers(this.roomId, oobMembers) - // swallow any IDB error as we don't want to fail - // because of this - .catch((err) => { - logger.log("LL: storing OOB room members failed, oh well", - err); - }); - } - }).catch((err) => { - // as this is not awaited anywhere, - // at least show the error in the console - logger.error(err); - }); + inMemoryUpdate + .then((fromServer) => { + if (fromServer) { + const oobMembers = this.currentState + .getMembers() + .filter((m) => m.isOutOfBand()) + .map((m) => m.events.member?.event as IStateEventWithRoomId); + logger.log(`LL: telling store to write ${oobMembers.length}` + ` members for room ${this.roomId}`); + const store = this.client.store; + return ( + store + .setOutOfBandMembers(this.roomId, oobMembers) + // swallow any IDB error as we don't want to fail + // because of this + .catch((err) => { + logger.log("LL: storing OOB room members failed, oh well", err); + }) + ); + } + }) + .catch((err) => { + // as this is not awaited anywhere, + // at least show the error in the console + logger.error(err); + }); this.membersPromise = inMemoryUpdate; @@ -885,15 +971,14 @@ export class Room extends ReadReceipt { */ private cleanupAfterLeaving(): void { this.clearLoadedMembersIfNeeded().catch((err) => { - logger.error(`error after clearing loaded members from ` + - `room ${this.roomId} after leaving`); + logger.error(`error after clearing loaded members from ` + `room ${this.roomId} after leaving`); logger.log(err); }); } /** * Empty out the current live timeline and re-request it. This is used when - * historical messages are imported into the room via MSC2716 `/batch_send + * historical messages are imported into the room via MSC2716 `/batch_send` * because the client may already have that section of the timeline loaded. * We need to force the client to throw away their current timeline so that * when they back paginate over the area again with the historical messages @@ -912,10 +997,10 @@ export class Room extends ReadReceipt { const mostRecentEventInTimeline = eventsBefore[eventsBefore.length - 1]; logger.log( `[refreshLiveTimeline for ${this.roomId}] at ` + - `mostRecentEventInTimeline=${mostRecentEventInTimeline && mostRecentEventInTimeline.getId()} ` + - `liveTimelineBefore=${liveTimelineBefore.toString()} ` + - `forwardPaginationToken=${forwardPaginationToken} ` + - `backwardPaginationToken=${backwardPaginationToken}`, + `mostRecentEventInTimeline=${mostRecentEventInTimeline && mostRecentEventInTimeline.getId()} ` + + `liveTimelineBefore=${liveTimelineBefore.toString()} ` + + `forwardPaginationToken=${forwardPaginationToken} ` + + `backwardPaginationToken=${backwardPaginationToken}`, ); // Get the main TimelineSet @@ -959,11 +1044,12 @@ export class Room extends ReadReceipt { // instead because it's the latest in the room and any new messages in // the scrollback will include the history. const liveTimeline = timelineSet.getLiveTimeline(); - if (!liveTimeline || ( - liveTimeline.getPaginationToken(Direction.Forward) === null && - liveTimeline.getPaginationToken(Direction.Backward) === null && - liveTimeline.getEvents().length === 0 - )) { + if ( + !liveTimeline || + (liveTimeline.getPaginationToken(Direction.Forward) === null && + liveTimeline.getPaginationToken(Direction.Backward) === null && + liveTimeline.getEvents().length === 0) + ) { logger.log(`[refreshLiveTimeline for ${this.roomId}] using our new live timeline`); // Set the pagination token back to the live sync token (`null`) instead // of using the `/context` historical token (ex. `t12-13_0_0_0_0_0_0_0_0`) @@ -980,8 +1066,8 @@ export class Room extends ReadReceipt { } else { logger.log( `[refreshLiveTimeline for ${this.roomId}] \`/sync\` or some other request beat us to creating a new ` + - `live timeline after we reset it. We'll use that instead since any events in the scrollback from ` + - `this timeline will include the history.`, + `live timeline after we reset it. We'll use that instead since any events in the scrollback from ` + + `this timeline will include the history.`, ); } @@ -998,18 +1084,15 @@ export class Room extends ReadReceipt { * *

This is used when /sync returns a 'limited' timeline. * - * @param {string=} backPaginationToken token for back-paginating the new timeline - * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline, + * @param backPaginationToken - token for back-paginating the new timeline + * @param forwardPaginationToken - token for forward-paginating the old live timeline, * if absent or null, all timelines are reset, removing old ones (including the previous live * timeline which would otherwise be unable to paginate forwards without this token). * Removing just the old live timeline whilst preserving previous ones is not supported. */ public resetLiveTimeline(backPaginationToken?: string | null, forwardPaginationToken?: string | null): void { for (const timelineSet of this.timelineSets) { - timelineSet.resetLiveTimeline( - backPaginationToken ?? undefined, - forwardPaginationToken ?? undefined, - ); + timelineSet.resetLiveTimeline(backPaginationToken ?? undefined, forwardPaginationToken ?? undefined); } this.fixUpLegacyTimelineFields(); @@ -1018,7 +1101,7 @@ export class Room extends ReadReceipt { /** * Fix up this.timeline, this.oldState and this.currentState * - * @private + * @internal */ private fixUpLegacyTimelineFields(): void { const previousOldState = this.oldState; @@ -1077,7 +1160,7 @@ export class Room extends ReadReceipt { * disabled, then we aren't tracking room devices at all, so we can't answer this, and an * error will be thrown. * - * @return {boolean} the result + * @returns the result */ public async hasUnverifiedDevices(): Promise { if (!this.client.isRoomEncrypted(this.roomId)) { @@ -1095,7 +1178,7 @@ export class Room extends ReadReceipt { /** * Return the timeline sets for this room. - * @return {EventTimelineSet[]} array of timeline sets for this room + * @returns array of timeline sets for this room */ public getTimelineSets(): EventTimelineSet[] { return this.timelineSets; @@ -1103,7 +1186,7 @@ export class Room extends ReadReceipt { /** * Helper to return the main unfiltered timeline set for this room - * @return {EventTimelineSet} room's unfiltered timeline set + * @returns room's unfiltered timeline set */ public getUnfilteredTimelineSet(): EventTimelineSet { return this.timelineSets[0]; @@ -1112,8 +1195,8 @@ export class Room extends ReadReceipt { /** * Get the timeline which contains the given event from the unfiltered set, if any * - * @param {string} eventId event ID to look for - * @return {?module:models/event-timeline~EventTimeline} timeline containing + * @param eventId - event ID to look for + * @returns timeline containing * the given event, or null if unknown */ public getTimelineForEvent(eventId: string): EventTimeline | null { @@ -1129,7 +1212,7 @@ export class Room extends ReadReceipt { /** * Add a new timeline to this room's unfiltered timeline set * - * @return {module:models/event-timeline~EventTimeline} newly-created timeline + * @returns newly-created timeline */ public addTimeline(): EventTimeline { return this.getUnfilteredTimelineSet().addTimeline(); @@ -1138,7 +1221,7 @@ export class Room extends ReadReceipt { /** * Whether the timeline needs to be refreshed in order to pull in new * historical messages that were imported. - * @param {Boolean} value The value to set + * @param value - The value to set */ public setTimelineNeedsRefresh(value: boolean): void { this.timelineNeedsRefresh = value; @@ -1147,7 +1230,7 @@ export class Room extends ReadReceipt { /** * Whether the timeline needs to be refreshed in order to pull in new * historical messages that were imported. - * @return {Boolean} . + * @returns . */ public getTimelineNeedsRefresh(): boolean { return this.timelineNeedsRefresh; @@ -1156,8 +1239,8 @@ export class Room extends ReadReceipt { /** * Get an event which is stored in our unfiltered timeline set, or in a thread * - * @param {string} eventId event ID to look for - * @return {?module:models/event.MatrixEvent} the given event, or undefined if unknown + * @param eventId - event ID to look for + * @returns the given event, or undefined if unknown */ public findEventById(eventId: string): MatrixEvent | undefined { let event = this.getUnfilteredTimelineSet().findEventById(eventId); @@ -1178,8 +1261,8 @@ export class Room extends ReadReceipt { /** * Get one of the notification counts for this room - * @param {String} type The type of notification count to get. default: 'total' - * @return {Number} The notification count, or undefined if there is no count + * @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 getUnreadNotificationCount(type = NotificationCountType.Total): number { @@ -1198,15 +1281,17 @@ export class Room extends ReadReceipt { public getUnreadCountForEventContext(type = NotificationCountType.Total, event: MatrixEvent): number { const isThreadEvent = !!event.threadRootId && !event.isThreadRoot; - return (isThreadEvent - ? this.getThreadUnreadNotificationCount(event.threadRootId, type) - : this.getRoomUnreadNotificationCount(type)) ?? 0; + return ( + (isThreadEvent + ? this.getThreadUnreadNotificationCount(event.threadRootId, type) + : this.getRoomUnreadNotificationCount(type)) ?? 0 + ); } /** * Get one of the notification counts for this room - * @param {String} type The type of notification count to get. default: 'total' - * @return {Number} The notification count, or undefined if there is no count + * @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 getRoomUnreadNotificationCount(type = NotificationCountType.Total): number { @@ -1216,8 +1301,8 @@ export class Room extends ReadReceipt { /** * @experimental * 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' + * @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. */ @@ -1228,7 +1313,7 @@ export class Room extends ReadReceipt { /** * @experimental * Checks if the current room has unread thread notifications - * @returns {boolean} + * @returns */ public hasThreadUnreadNotification(): boolean { for (const notification of this.threadNotifications.values()) { @@ -1242,9 +1327,9 @@ export class Room extends ReadReceipt { /** * @experimental * 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} + * @param threadId - the root event ID + * @param type - The type of notification count to get. default: 'total' + * @returns */ public setThreadUnreadNotificationCount(threadId: string, type: NotificationCountType, count: number): void { const notification: NotificationCount = { @@ -1257,11 +1342,7 @@ export class Room extends ReadReceipt { this.threadNotifications.set(threadId, notification); - this.emit( - RoomEvent.UnreadNotifications, - notification, - threadId, - ); + this.emit(RoomEvent.UnreadNotifications, notification, threadId); } /** @@ -1299,8 +1380,8 @@ export class Room extends ReadReceipt { /** * Set one of the notification counts for this room - * @param {String} type The type of notification count to set. - * @param {Number} count The new count + * @param type - The type of notification count to set. + * @param count - The new count */ public setUnreadNotificationCount(type: NotificationCountType, count: number): void { this.notificationCounts[type] = count; @@ -1312,10 +1393,10 @@ export class Room extends ReadReceipt { const joinedCount = summary["m.joined_member_count"]; const invitedCount = summary["m.invited_member_count"]; if (Number.isInteger(joinedCount)) { - this.currentState.setJoinedMemberCount(joinedCount); + this.currentState.setJoinedMemberCount(joinedCount!); } if (Number.isInteger(invitedCount)) { - this.currentState.setInvitedMemberCount(invitedCount); + this.currentState.setInvitedMemberCount(invitedCount!); } if (Array.isArray(heroes)) { // be cautious about trusting server values, @@ -1329,7 +1410,7 @@ export class Room extends ReadReceipt { /** * Whether to send encrypted messages to devices within this room. - * @param {Boolean} value true to blacklist unverified devices, null + * @param value - true to blacklist unverified devices, null * to use the global value for this room. */ public setBlacklistUnverifiedDevices(value: boolean): void { @@ -1338,7 +1419,7 @@ export class Room extends ReadReceipt { /** * Whether to send encrypted messages to devices within this room. - * @return {Boolean} true if blacklisting unverified devices, null + * @returns true if blacklisting unverified devices, null * if the global value should be used for this room. */ public getBlacklistUnverifiedDevices(): boolean | null { @@ -1348,15 +1429,15 @@ export class Room extends ReadReceipt { /** * Get the avatar URL for a room if one was set. - * @param {String} baseUrl The homeserver base URL. See - * {@link module:client~MatrixClient#getHomeserverUrl}. - * @param {Number} width The desired width of the thumbnail. - * @param {Number} height The desired height of the thumbnail. - * @param {string} resizeMethod The thumbnail resize method to use, either + * @param baseUrl - The homeserver base URL. See + * {@link MatrixClient#getHomeserverUrl}. + * @param width - The desired width of the thumbnail. + * @param height - The desired height of the thumbnail. + * @param resizeMethod - The thumbnail resize method to use, either * "crop" or "scale". - * @param {boolean} allowDefault True to allow an identicon for this room if an + * @param allowDefault - True to allow an identicon for this room if an * avatar URL wasn't explicitly set. Default: true. (Deprecated) - * @return {?string} the avatar URL or null. + * @returns the avatar URL or null. */ public getAvatarUrl( baseUrl: string, @@ -1380,7 +1461,7 @@ export class Room extends ReadReceipt { /** * Get the mxc avatar url for the room, if one was set. - * @return {string} the mxc avatar url or falsy + * @returns the mxc avatar url or falsy */ public getMxcAvatarUrl(): string | null { return this.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url || null; @@ -1390,7 +1471,7 @@ export class Room extends ReadReceipt { * Get this room's canonical alias * The alias returned by this function may not necessarily * still point to this room. - * @return {?string} The room's canonical alias, or null if there is none + * @returns The room's canonical alias, or null if there is none */ public getCanonicalAlias(): string | null { const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, ""); @@ -1402,7 +1483,7 @@ export class Room extends ReadReceipt { /** * Get this room's alternative aliases - * @return {array} The room's alternative aliases, or an empty array + * @returns The room's alternative aliases, or an empty array */ public getAltAliases(): string[] { const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, ""); @@ -1417,19 +1498,19 @@ export class Room extends ReadReceipt { * *

Will fire "Room.timeline" for each event added. * - * @param {MatrixEvent[]} events A list of events to add. + * @param events - A list of events to add. * - * @param {boolean} toStartOfTimeline True to add these events to the start + * @param toStartOfTimeline - True to add these events to the start * (oldest) instead of the end (newest) of the timeline. If true, the oldest * event will be the last element of 'events'. * - * @param {module:models/event-timeline~EventTimeline} timeline timeline to + * @param timeline - timeline to * add events to. * - * @param {string=} paginationToken token for the next batch of events - * - * @fires module:client~MatrixClient#event:"Room.timeline" + * @param paginationToken - token for the next batch of events * + * @remarks + * Fires {@link RoomEvent.Timeline} */ public addEventsToTimeline( events: MatrixEvent[], @@ -1456,8 +1537,8 @@ export class Room extends ReadReceipt { /** * Get a member from the current room state. - * @param {string} userId The user ID of the member. - * @return {RoomMember} The member or null. + * @param userId - The user ID of the member. + * @returns The member or `null`. */ public getMember(userId: string): RoomMember | null { return this.currentState.getMember(userId); @@ -1466,7 +1547,7 @@ export class Room extends ReadReceipt { /** * Get all currently loaded members from the current * room state. - * @returns {RoomMember[]} Room members + * @returns Room members */ public getMembers(): RoomMember[] { return this.currentState.getMembers(); @@ -1474,7 +1555,7 @@ export class Room extends ReadReceipt { /** * Get a list of members whose membership state is "join". - * @return {RoomMember[]} A list of currently joined members. + * @returns A list of currently joined members. */ public getJoinedMembers(): RoomMember[] { return this.getMembersWithMembership("join"); @@ -1485,7 +1566,7 @@ export class Room extends ReadReceipt { * This method caches the result. * This is a wrapper around the method of the same name in roomState, returning * its result for the room's current state. - * @return {number} The number of members in this room whose membership is 'join' + * @returns The number of members in this room whose membership is 'join' */ public getJoinedMemberCount(): number { return this.currentState.getJoinedMemberCount(); @@ -1493,7 +1574,7 @@ export class Room extends ReadReceipt { /** * Returns the number of invited members in this room - * @return {number} The number of members in this room whose membership is 'invite' + * @returns The number of members in this room whose membership is 'invite' */ public getInvitedMemberCount(): number { return this.currentState.getInvitedMemberCount(); @@ -1501,7 +1582,7 @@ export class Room extends ReadReceipt { /** * Returns the number of invited + joined members in this room - * @return {number} The number of members in this room whose membership is 'invite' or 'join' + * @returns The number of members in this room whose membership is 'invite' or 'join' */ public getInvitedAndJoinedMemberCount(): number { return this.getInvitedMemberCount() + this.getJoinedMemberCount(); @@ -1509,18 +1590,18 @@ export class Room extends ReadReceipt { /** * Get a list of members with given membership state. - * @param {string} membership The membership state. - * @return {RoomMember[]} A list of members with the given membership state. + * @param membership - The membership state. + * @returns A list of members with the given membership state. */ public getMembersWithMembership(membership: string): RoomMember[] { - return this.currentState.getMembers().filter(function(m) { + return this.currentState.getMembers().filter(function (m) { return m.membership === membership; }); } /** * Get a list of members we should be encrypting for in this room - * @return {Promise} A list of members who + * @returns A list of members who * we should encrypt messages for in this room. */ public async getEncryptionTargetMembers(): Promise { @@ -1534,7 +1615,7 @@ export class Room extends ReadReceipt { /** * Determine whether we should encrypt messages for invited users in this room - * @return {boolean} if we should encrypt messages for invited users + * @returns if we should encrypt messages for invited users */ public shouldEncryptForInvitedMembers(): boolean { const ev = this.currentState.getStateEvents(EventType.RoomHistoryVisibility, ""); @@ -1544,9 +1625,9 @@ export class Room extends ReadReceipt { /** * Get the default room name (i.e. what a given user would see if the * room had no m.room.name) - * @param {string} userId The userId from whose perspective we want + * @param userId - The userId from whose perspective we want * to calculate the default name - * @return {string} The default room name + * @returns The default room name */ public getDefaultRoomName(userId: string): string { return this.calculateRoomName(userId, true); @@ -1554,9 +1635,9 @@ export class Room extends ReadReceipt { /** * Check if the given user_id has the given membership state. - * @param {string} userId The user ID to check. - * @param {string} membership The membership e.g. 'join' - * @return {boolean} True if this user_id has the given membership state. + * @param userId - The user ID to check. + * @param membership - The membership e.g. `'join'` + * @returns True if this user_id has the given membership state. */ public hasMembershipState(userId: string, membership: string): boolean { const member = this.getMember(userId); @@ -1568,27 +1649,20 @@ export class Room extends ReadReceipt { /** * Add a timelineSet for this room with the given filter - * @param {Filter} filter The filter to be applied to this timelineSet - * @param {Object=} opts Configuration options - * @return {EventTimelineSet} The timelineSet + * @param filter - The filter to be applied to this timelineSet + * @param opts - Configuration options + * @returns The timelineSet */ public getOrCreateFilteredTimelineSet( filter: Filter, - { - prepopulateTimeline = true, - useSyncEvents = true, - pendingEvents = true, - }: ICreateFilterOpts = {}, + { prepopulateTimeline = true, useSyncEvents = true, pendingEvents = true }: ICreateFilterOpts = {}, ): EventTimelineSet { if (this.filteredTimelineSets[filter.filterId!]) { return this.filteredTimelineSets[filter.filterId!]; } const opts = Object.assign({ filter, pendingEvents }, this.opts); const timelineSet = new EventTimelineSet(this, opts); - this.reEmitter.reEmit(timelineSet, [ - RoomEvent.Timeline, - RoomEvent.TimelineReset, - ]); + this.reEmitter.reEmit(timelineSet, [RoomEvent.Timeline, RoomEvent.TimelineReset]); if (useSyncEvents) { this.filteredTimelineSets[filter.filterId!] = timelineSet; this.timelineSets.push(timelineSet); @@ -1606,7 +1680,7 @@ export class Room extends ReadReceipt { // may have grown huge and so take a long time to filter. // see https://github.com/vector-im/vector-web/issues/2109 - unfilteredLiveTimeline.getEvents().forEach(function(event) { + unfilteredLiveTimeline.getEvents().forEach(function (event) { timelineSet.addLiveEvent(event); }); @@ -1616,15 +1690,12 @@ export class Room extends ReadReceipt { timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)!; } - timelineSet.getLiveTimeline().setPaginationToken( - timeline.getPaginationToken(EventTimeline.BACKWARDS), - EventTimeline.BACKWARDS, - ); - } else if (useSyncEvents) { - const livePaginationToken = unfilteredLiveTimeline.getPaginationToken(Direction.Forward); timelineSet .getLiveTimeline() - .setPaginationToken(livePaginationToken, Direction.Backward); + .setPaginationToken(timeline.getPaginationToken(EventTimeline.BACKWARDS), EventTimeline.BACKWARDS); + } else if (useSyncEvents) { + const livePaginationToken = unfilteredLiveTimeline.getPaginationToken(Direction.Forward); + timelineSet.getLiveTimeline().setPaginationToken(livePaginationToken, Direction.Backward); } // alternatively, we could try to do something like this to try and re-paginate @@ -1643,8 +1714,8 @@ export class Room extends ReadReceipt { const filter = new Filter(myUserId); const definition: IFilterDefinition = { - "room": { - "timeline": { + room: { + timeline: { [FILTER_RELATED_BY_REL_TYPES.name]: [THREAD_RELATION_TYPE.name], }, }, @@ -1655,10 +1726,7 @@ export class Room extends ReadReceipt { } filter.setDefinition(definition); - const filterId = await this.client.getOrCreateFilter( - `THREAD_PANEL_${this.roomId}_${filterType}`, - filter, - ); + const filterId = await this.client.getOrCreateFilter(`THREAD_PANEL_${this.roomId}_${filterType}`, filter); filter.filterId = filterId; @@ -1668,43 +1736,41 @@ export class Room extends ReadReceipt { private async createThreadTimelineSet(filterType?: ThreadFilterType): Promise { let timelineSet: EventTimelineSet; if (Thread.hasServerSideListSupport) { - timelineSet = - new EventTimelineSet(this, { + timelineSet = new EventTimelineSet( + this, + { ...this.opts, pendingEvents: false, - }, undefined, undefined, filterType ?? ThreadFilterType.All); - this.reEmitter.reEmit(timelineSet, [ - RoomEvent.Timeline, - RoomEvent.TimelineReset, - ]); + }, + undefined, + undefined, + filterType ?? ThreadFilterType.All, + ); + this.reEmitter.reEmit(timelineSet, [RoomEvent.Timeline, RoomEvent.TimelineReset]); } else if (Thread.hasServerSideSupport) { const filter = await this.getThreadListFilter(filterType); - timelineSet = this.getOrCreateFilteredTimelineSet( - filter, - { - prepopulateTimeline: false, - useSyncEvents: false, - pendingEvents: false, - }, - ); + timelineSet = this.getOrCreateFilteredTimelineSet(filter, { + prepopulateTimeline: false, + useSyncEvents: false, + pendingEvents: false, + }); } else { timelineSet = new EventTimelineSet(this, { pendingEvents: false, }); - Array.from(this.threads) - .forEach(([, thread]) => { - if (thread.length === 0) return; - const currentUserParticipated = thread.timeline.some(event => { - return event.getSender() === this.client.getUserId(); - }); - if (filterType !== ThreadFilterType.My || currentUserParticipated) { - timelineSet.getLiveTimeline().addEvent(thread.rootEvent!, { - toStartOfTimeline: false, - }); - } + Array.from(this.threads).forEach(([, thread]) => { + if (thread.length === 0) return; + const currentUserParticipated = thread.timeline.some((event) => { + return event.getSender() === this.client.getUserId(); }); + if (filterType !== ThreadFilterType.My || currentUserParticipated) { + timelineSet.getLiveTimeline().addEvent(thread.rootEvent!, { + toStartOfTimeline: false, + }); + } + }); } return timelineSet; @@ -1714,16 +1780,10 @@ export class Room extends ReadReceipt { /** * 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, - ); + EventTimeline.setEventMetadata(rootEvent, this.currentState, toStartOfTimeline); if (!this.getThread(rootEvent.getId()!)) { this.createThread(rootEvent.getId()!, rootEvent, [], toStartOfTimeline); } @@ -1759,22 +1819,21 @@ export class Room extends ReadReceipt { 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(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; - }); + 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 | undefined; const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); @@ -1786,8 +1845,9 @@ export class Room extends ReadReceipt { }; this.threadsTimelineSets[0]?.addLiveEvent(rootEvent, opts); - const threadRelationship = rootEvent - .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); + const threadRelationship = rootEvent.getServerAggregatedRelation( + THREAD_RELATION_TYPE.name, + ); if (threadRelationship?.current_user_participated) { this.threadsTimelineSets[1]?.addLiveEvent(rootEvent, opts); latestMyThreadsRootEvent = rootEvent; @@ -1796,7 +1856,7 @@ export class Room extends ReadReceipt { this.processThreadRoots(threadRoots, true); - this.client.decryptEventIfNeeded(threadRoots[threadRoots.length -1]); + this.client.decryptEventIfNeeded(threadRoots[threadRoots.length - 1]); if (latestMyThreadsRootEvent) { this.client.decryptEventIfNeeded(latestMyThreadsRootEvent); } @@ -1809,13 +1869,10 @@ export class Room extends ReadReceipt { /** * Fetch a single page of threadlist messages for the specific thread filter - * @param filter - * @private + * @internal */ private async fetchRoomThreadList(filter?: ThreadFilterType): Promise { - const timelineSet = filter === ThreadFilterType.My - ? this.threadsTimelineSets[1] - : this.threadsTimelineSets[0]; + const timelineSet = filter === ThreadFilterType.My ? this.threadsTimelineSets[1] : this.threadsTimelineSets[0]; const { chunk: events, end } = await this.client.createThreadListMessagesRequest( this.roomId, @@ -1826,7 +1883,7 @@ export class Room extends ReadReceipt { timelineSet.getFilter(), ); - timelineSet.getLiveTimeline().setPaginationToken(end, Direction.Backward); + timelineSet.getLiveTimeline().setPaginationToken(end ?? null, Direction.Backward); if (!events.length) return; @@ -1843,14 +1900,14 @@ export class Room extends ReadReceipt { } private onThreadNewReply(thread: Thread): void { - this.updateThreadRootEvents(thread, false); + this.updateThreadRootEvents(thread, false, true); } private onThreadDelete(thread: Thread): void { this.threads.delete(thread.id); const timeline = this.getTimelineForEvent(thread.id); - const roomEvent = timeline?.getEvents()?.find(it => it.getId() === thread.id); + const roomEvent = timeline?.getEvents()?.find((it) => it.getId() === thread.id); if (roomEvent) { thread.clearEventMetadata(roomEvent); } else { @@ -1864,7 +1921,7 @@ export class Room extends ReadReceipt { /** * Forget the timelineSet for this room with the given filter * - * @param {Filter} filter the filter whose timelineSet is to be forgotten + * @param filter - the filter whose timelineSet is to be forgotten */ public removeFilteredTimelineSet(filter: Filter): void { const timelineSet = this.filteredTimelineSets[filter.filterId!]; @@ -1875,7 +1932,11 @@ export class Room extends ReadReceipt { } } - public eventShouldLiveIn(event: MatrixEvent, events?: MatrixEvent[], roots?: Set): { + public eventShouldLiveIn( + event: MatrixEvent, + events?: MatrixEvent[], + roots?: Set, + ): { shouldLiveInRoom: boolean; shouldLiveInThread: boolean; threadId?: string; @@ -1906,7 +1967,7 @@ export class Room extends ReadReceipt { } const parentEventId = event.getAssociatedId()!; - const parentEvent = this.findEventById(parentEventId) ?? events?.find(e => e.getId() === parentEventId); + const parentEvent = this.findEventById(parentEventId) ?? events?.find((e) => e.getId() === parentEventId); // Treat relations and redactions as extensions of their parents so evaluate parentEvent instead if (parentEvent && (event.isRelation() || event.isRedaction())) { @@ -1940,7 +2001,7 @@ export class Room extends ReadReceipt { let thread = this.getThread(threadId); if (!thread) { - const rootEvent = this.findEventById(threadId) ?? events.find(e => e.getId() === threadId); + const rootEvent = this.findEventById(threadId) ?? events.find((e) => e.getId() === threadId); thread = this.createThread(threadId, rootEvent, events, toStartOfTimeline); } @@ -1963,16 +2024,16 @@ export class Room extends ReadReceipt { eventsByThread[threadId!]?.push(event); } - Object.entries(eventsByThread).map(([threadId, threadEvents]) => ( - this.addThreadedEvents(threadId, threadEvents, toStartOfTimeline) - )); + Object.entries(eventsByThread).map(([threadId, threadEvents]) => + this.addThreadedEvents(threadId, threadEvents, toStartOfTimeline), + ); } - private updateThreadRootEvents = (thread: Thread, toStartOfTimeline: boolean): void => { + private updateThreadRootEvents = (thread: Thread, toStartOfTimeline: boolean, recreateEvent: boolean): void => { if (thread.length) { - this.updateThreadRootEvent(this.threadsTimelineSets?.[0], thread, toStartOfTimeline); + this.updateThreadRootEvent(this.threadsTimelineSets?.[0], thread, toStartOfTimeline, recreateEvent); if (thread.hasCurrentUserParticipated) { - this.updateThreadRootEvent(this.threadsTimelineSets?.[1], thread, toStartOfTimeline); + this.updateThreadRootEvent(this.threadsTimelineSets?.[1], thread, toStartOfTimeline, recreateEvent); } } }; @@ -1981,8 +2042,12 @@ export class Room extends ReadReceipt { timelineSet: Optional, thread: Thread, toStartOfTimeline: boolean, + recreateEvent: boolean, ): void => { if (timelineSet && thread.rootEvent) { + if (recreateEvent) { + timelineSet.removeEvent(thread.id); + } if (Thread.hasServerSideSupport) { timelineSet.addLiveEvent(thread.rootEvent, { duplicateStrategy: DuplicateStrategy.Replace, @@ -1990,11 +2055,7 @@ export class Room extends ReadReceipt { roomState: this.currentState, }); } else { - timelineSet.addEventToTimeline( - thread.rootEvent, - timelineSet.getLiveTimeline(), - { toStartOfTimeline }, - ); + timelineSet.addEventToTimeline(thread.rootEvent, timelineSet.getLiveTimeline(), { toStartOfTimeline }); } } }; @@ -2005,27 +2066,35 @@ export class Room extends ReadReceipt { events: MatrixEvent[] = [], toStartOfTimeline: boolean, ): Thread { + if (this.threads.has(threadId)) { + return this.threads.get(threadId)!; + } + if (rootEvent) { const relatedEvents = this.relations.getAllChildEventsForEvent(rootEvent.getId()!); if (relatedEvents?.length) { // Include all relations of the root event, given it'll be visible in both timelines, // except `m.replace` as that will already be applied atop the event using `MatrixEvent::makeReplaced` - events = events.concat(relatedEvents.filter(e => !e.isRelation(RelationType.Replace))); + events = events.concat(relatedEvents.filter((e) => !e.isRelation(RelationType.Replace))); } } const thread = new Thread(threadId, rootEvent, { room: this, client: this.client, + pendingEventOrdering: this.opts.pendingEventOrdering, + receipts: this.cachedThreadReadReceipts.get(threadId) ?? [], }); + // All read receipts should now come down from sync, we do not need to keep + // a reference to the cached receipts anymore. + this.cachedThreadReadReceipts.delete(threadId); + // This is necessary to be able to jump to events in threads: // If we jump to an event in a thread where neither the event, nor the root, // nor any thread event are loaded yet, we'll load the event as well as the thread root, create the thread, // and pass the event through this. - for (const event of events) { - thread.setEventMetadata(event); - } + thread.addEvents(events, false); // If we managed to create a thread and figure out its `id` then we can use it this.threads.set(thread.id, thread); @@ -2036,18 +2105,18 @@ export class Room extends ReadReceipt { RoomEvent.Timeline, RoomEvent.TimelineReset, ]); - const isNewer = this.lastThread?.rootEvent - && rootEvent?.localTimestamp - && this.lastThread.rootEvent?.localTimestamp < rootEvent?.localTimestamp; + const isNewer = + this.lastThread?.rootEvent && + rootEvent?.localTimestamp && + this.lastThread.rootEvent?.localTimestamp < rootEvent?.localTimestamp; if (!this.lastThread || isNewer) { this.lastThread = thread; } if (this.threadsReady) { - this.updateThreadRootEvents(thread, toStartOfTimeline); + this.updateThreadRootEvents(thread, toStartOfTimeline, false); } - this.emit(ThreadEvent.New, thread, toStartOfTimeline); return thread; @@ -2137,10 +2206,12 @@ export class Room extends ReadReceipt { * Add an event to the end of this room's live timelines. Will fire * "Room.timeline". * - * @param {MatrixEvent} event Event to be added - * @param {IAddLiveEventOptions} addLiveEventOptions addLiveEvent options - * @fires module:client~MatrixClient#event:"Room.timeline" - * @private + * @param event - Event to be added + * @param addLiveEventOptions - addLiveEvent options + * @internal + * + * @remarks + * Fires {@link RoomEvent.Timeline} */ private addLiveEvent(event: MatrixEvent, addLiveEventOptions: IAddLiveEventOptions): void { const { duplicateStrategy, timelineWasEmpty, fromCache } = addLiveEventOptions; @@ -2159,9 +2230,7 @@ export class Room extends ReadReceipt { // pointing to an event that wasn't yet in the timeline // Don't synthesize RR for m.room.redaction as this causes the RR to go missing. if (event.sender && event.getType() !== EventType.RoomRedaction) { - this.addReceipt(synthesizeReceipt( - event.sender.userId, event, ReceiptType.Read, - ), true); + this.addReceipt(synthesizeReceipt(event.sender.userId, event, ReceiptType.Read), true); // Any live events from a user could be taken as implicit // presence information: evidence that they are currently active. @@ -2180,24 +2249,23 @@ export class Room extends ReadReceipt { * *

This is an internal method, intended for use by MatrixClient. * - * @param {module:models/event.MatrixEvent} event The event to add. - * - * @param {string} txnId Transaction id for this outgoing event + * @param event - The event to add. * - * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" + * @param txnId - Transaction id for this outgoing event * * @throws if the event doesn't have status SENDING, or we aren't given a * unique transaction id. + * + * @remarks + * Fires {@link RoomEvent.LocalEchoUpdated} */ public addPendingEvent(event: MatrixEvent, txnId: string): void { if (event.status !== EventStatus.SENDING && event.status !== EventStatus.NOT_SENT) { - throw new Error("addPendingEvent called on an event with status " + - event.status); + throw new Error("addPendingEvent called on an event with status " + event.status); } if (this.txnToEvent[txnId]) { - throw new Error("addPendingEvent called on an event with known txnId " + - txnId); + throw new Error("addPendingEvent called on an event with known txnId " + txnId); } // call setEventMetadata to set up event.sender etc @@ -2222,7 +2290,7 @@ export class Room extends ReadReceipt { if (event.isRedaction()) { const redactId = event.event.redacts; - let redactedEvent = this.pendingEventList.find(e => e.getId() === redactId); + let redactedEvent = this.pendingEventList.find((e) => e.getId() === redactId); if (!redactedEvent && redactId) { redactedEvent = this.findEventById(redactId); } @@ -2235,16 +2303,14 @@ export class Room extends ReadReceipt { for (const timelineSet of this.timelineSets) { if (timelineSet.getFilter()) { if (timelineSet.getFilter()!.filterRoomTimeline([event]).length) { - timelineSet.addEventToTimeline(event, - timelineSet.getLiveTimeline(), { - toStartOfTimeline: false, - }); - } - } else { - timelineSet.addEventToTimeline(event, - timelineSet.getLiveTimeline(), { + timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), { toStartOfTimeline: false, }); + } + } else { + timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), { + toStartOfTimeline: false, + }); } } } @@ -2259,7 +2325,7 @@ export class Room extends ReadReceipt { * all messages that are not yet encrypted will be discarded * * This is because the flow of EVENT_STATUS transition is - * queued => sending => encrypting => sending => sent + * `queued => sending => encrypting => sending => sent` * * Steps 3 and 4 are skipped for unencrypted room. * It is better to discard an unencrypted message rather than persisting @@ -2267,17 +2333,19 @@ export class Room extends ReadReceipt { */ private savePendingEvents(): void { if (this.pendingEventList) { - const pendingEvents = this.pendingEventList.map(event => { - return { - ...event.event, - txn_id: event.getTxnId(), - }; - }).filter(event => { - // Filter out the unencrypted messages if the room is encrypted - const isEventEncrypted = event.type === EventType.RoomMessageEncrypted; - const isRoomEncrypted = this.client.isRoomEncrypted(this.roomId); - return isEventEncrypted || !isRoomEncrypted; - }); + const pendingEvents = this.pendingEventList + .map((event) => { + return { + ...event.event, + txn_id: event.getTxnId(), + }; + }) + .filter((event) => { + // Filter out the unencrypted messages if the room is encrypted + const isEventEncrypted = event.type === EventType.RoomMessageEncrypted; + const isRoomEncrypted = this.client.isRoomEncrypted(this.roomId); + return isEventEncrypted || !isRoomEncrypted; + }); this.client.store.setPendingEvents(this.roomId, pendingEvents); } @@ -2291,7 +2359,7 @@ export class Room extends ReadReceipt { * which are just kept detached for their local echo. * * Also note that live events are aggregated in the live EventTimelineSet. - * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated. + * @param event - the relation event that needs to be aggregated. */ private aggregateNonLiveRelation(event: MatrixEvent): void { this.relations.aggregateChildEvent(event); @@ -2307,13 +2375,15 @@ export class Room extends ReadReceipt { *

We move the event to the live timeline if it isn't there already, and * update it. * - * @param {module:models/event.MatrixEvent} remoteEvent The event received from + * @param remoteEvent - The event received from * /sync - * @param {module:models/event.MatrixEvent} localEvent The local echo, which + * @param localEvent - The local echo, which * should be either in the pendingEventList or the timeline. * - * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" - * @private + * @internal + * + * @remarks + * Fires {@link RoomEvent.LocalEchoUpdated} */ public handleRemoteEcho(remoteEvent: MatrixEvent, localEvent: MatrixEvent): void { const oldEventId = localEvent.getId()!; @@ -2354,16 +2424,17 @@ export class Room extends ReadReceipt { * *

This is an internal method. * - * @param {MatrixEvent} event local echo event - * @param {EventStatus} newStatus status to assign - * @param {string} newEventId new event id to assign. Ignored unless - * newStatus == EventStatus.SENT. - * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" + * @param event - local echo event + * @param newStatus - status to assign + * @param newEventId - new event id to assign. Ignored unless newStatus == EventStatus.SENT. + * + * @remarks + * Fires {@link RoomEvent.LocalEchoUpdated} */ public updatePendingEvent(event: MatrixEvent, newStatus: EventStatus, newEventId?: string): void { logger.log( `setting pendingEvent status to ${newStatus} in ${event.getRoomId()} ` + - `event ID ${event.getId()} -> ${newEventId}`, + `event ID ${event.getId()} -> ${newEventId}`, ); // if the message was sent, we expect an event id @@ -2447,8 +2518,7 @@ export class Room extends ReadReceipt { if (!redactId) { return; } - const redactedEvent = this.getUnfilteredTimelineSet() - .findEventById(redactId); + const redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); if (redactedEvent) { redactedEvent.unmarkLocallyRedacted(); // re-render after undoing redaction @@ -2465,9 +2535,9 @@ export class Room extends ReadReceipt { * events and typing notifications. These events are treated as "live" so * they will go to the end of the timeline. * - * @param {MatrixEvent[]} events A list of events to add. - * @param {IAddLiveEventOptions} addLiveEventOptions addLiveEvent options - * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. + * @param events - A list of events to add. + * @param addLiveEventOptions - addLiveEvent options + * @throws If `duplicateStrategy` is not falsey, 'replace' or 'ignore'. */ public addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): void; /** @@ -2481,7 +2551,7 @@ export class Room extends ReadReceipt { ): void { let duplicateStrategy: DuplicateStrategy | undefined = duplicateStrategyOrOpts as DuplicateStrategy; let timelineWasEmpty: boolean | undefined = false; - if (typeof (duplicateStrategyOrOpts) === 'object') { + if (typeof duplicateStrategyOrOpts === "object") { ({ duplicateStrategy, fromCache = false, @@ -2492,9 +2562,9 @@ export class Room extends ReadReceipt { // Deprecation warning // FIXME: Remove after 2023-06-01 (technical debt) logger.warn( - 'Overload deprecated: ' + - '`Room.addLiveEvents(events, duplicateStrategy?, fromCache?)` ' + - 'is deprecated in favor of the overload with `Room.addLiveEvents(events, IAddLiveEventOptions)`', + "Overload deprecated: " + + "`Room.addLiveEvents(events, duplicateStrategy?, fromCache?)` " + + "is deprecated in favor of the overload with `Room.addLiveEvents(events, IAddLiveEventOptions)`", ); } @@ -2507,8 +2577,12 @@ export class Room extends ReadReceipt { const liveTimeline = this.timelineSets[i].getLiveTimeline(); if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) { throw new Error( - "live timeline " + i + " is no longer live - it has a pagination token " + - "(" + liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + ")", + "live timeline " + + i + + " is no longer live - it has a pagination token " + + "(" + + liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + + ")", ); } if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { @@ -2538,11 +2612,11 @@ export class Room extends ReadReceipt { } } - const { - shouldLiveInRoom, - shouldLiveInThread, - threadId, - } = this.eventShouldLiveIn(event, events, threadRoots); + const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn( + event, + events, + threadRoots, + ); if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) { eventsByThread[threadId ?? ""] = []; @@ -2559,39 +2633,38 @@ export class Room extends ReadReceipt { }); } - public partitionThreadedEvents(events: MatrixEvent[]): [ - timelineEvents: MatrixEvent[], - threadedEvents: MatrixEvent[], - ] { + public partitionThreadedEvents( + events: MatrixEvent[], + ): [timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[]] { // Indices to the events array, for readability const ROOM = 0; const THREAD = 1; if (this.client.supportsExperimentalThreads()) { const threadRoots = this.findThreadRoots(events); - return events.reduce((memo, event: MatrixEvent) => { - const { - shouldLiveInRoom, - shouldLiveInThread, - threadId, - } = this.eventShouldLiveIn(event, events, threadRoots); - - if (shouldLiveInRoom) { - memo[ROOM].push(event); - } + return events.reduce( + (memo, event: MatrixEvent) => { + const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn( + event, + events, + threadRoots, + ); - if (shouldLiveInThread) { - event.setThreadId(threadId ?? ""); - memo[THREAD].push(event); - } + if (shouldLiveInRoom) { + memo[ROOM].push(event); + } + + if (shouldLiveInThread) { + event.setThreadId(threadId ?? ""); + memo[THREAD].push(event); + } - return memo; - }, [[] as MatrixEvent[], [] as MatrixEvent[]]); + return memo; + }, + [[] as MatrixEvent[], [] as MatrixEvent[]], + ); } else { // When `experimentalThreadSupport` is disabled treat all events as timelineEvents - return [ - events as MatrixEvent[], - [] as MatrixEvent[], - ]; + return [events as MatrixEvent[], [] as MatrixEvent[]]; } } @@ -2610,8 +2683,8 @@ export class Room extends ReadReceipt { /** * Add a receipt event to the room. - * @param {MatrixEvent} event The m.receipt event. - * @param {Boolean} synthetic True if this event is implicit. + * @param event - The m.receipt event. + * @param synthetic - True if this event is implicit. */ public addReceipt(event: MatrixEvent, synthetic = false): void { const content = event.getContent(); @@ -2623,13 +2696,24 @@ export class Room extends ReadReceipt { const receiptDestination: Thread | this | undefined = receiptForMainTimeline ? this : this.threads.get(receipt.thread_id ?? ""); - receiptDestination?.addReceiptToStructure( - eventId, - receiptType as ReceiptType, - userId, - receipt, - synthetic, - ); + + if (receiptDestination) { + receiptDestination.addReceiptToStructure( + eventId, + receiptType as ReceiptType, + userId, + receipt, + synthetic, + ); + } else { + // The thread does not exist locally, keep the read receipt + // in a cache locally, and re-apply the `addReceipt` logic + // when the thread is created + this.cachedThreadReadReceipts.set(receipt.thread_id!, [ + ...(this.cachedThreadReadReceipts.get(receipt.thread_id!) ?? []), + { event, synthetic }, + ]); + } }); }); }); @@ -2641,7 +2725,7 @@ export class Room extends ReadReceipt { /** * Adds/handles ephemeral events such as typing notifications and read receipts. - * @param {MatrixEvent[]} events A list of events to process + * @param events - A list of events to process */ public addEphemeralEvents(events: MatrixEvent[]): void { for (const event of events) { @@ -2655,7 +2739,7 @@ export class Room extends ReadReceipt { /** * Removes events from this room. - * @param {String[]} eventIds A list of eventIds to remove. + * @param eventIds - A list of eventIds to remove. */ public removeEvents(eventIds: string[]): void { for (const eventId of eventIds) { @@ -2666,9 +2750,9 @@ export class Room extends ReadReceipt { /** * Removes a single event from this room. * - * @param {String} eventId The id of the event to remove + * @param eventId - The id of the event to remove * - * @return {boolean} true if the event was removed from any of the room's timeline sets + * @returns true if the event was removed from any of the room's timeline sets */ public removeEvent(eventId: string): boolean { let removedAny = false; @@ -2688,7 +2772,9 @@ export class Room extends ReadReceipt { * Recalculate various aspects of the room, including the room name and * room summary. Call this any time the room's current state is modified. * May fire "Room.name" if the room name is updated. - * @fires module:client~MatrixClient#event:"Room.name" + * + * @remarks + * Fires {@link RoomEvent.Name} */ public recalculate(): void { // set fake stripped state events if this is an invite room so logic remains @@ -2704,14 +2790,16 @@ export class Room extends ReadReceipt { const existingEvent = this.currentState.getStateEvents(strippedEvent.type, strippedEvent.state_key); if (!existingEvent) { // set the fake stripped event instead - this.currentState.setStateEvents([new MatrixEvent({ - type: strippedEvent.type, - state_key: strippedEvent.state_key, - content: strippedEvent.content, - event_id: "$fake" + Date.now(), - room_id: this.roomId, - user_id: this.myUserId, // technically a lie - })]); + this.currentState.setStateEvents([ + new MatrixEvent({ + type: strippedEvent.type, + state_key: strippedEvent.state_key, + content: strippedEvent.content, + event_id: "$fake" + Date.now(), + room_id: this.roomId, + user_id: this.myUserId, // technically a lie + }), + ]); } }); } @@ -2731,7 +2819,7 @@ export class Room extends ReadReceipt { /** * Update the room-tag event for the room. The previous one is overwritten. - * @param {MatrixEvent} event the m.tag event + * @param event - the m.tag event */ public addTags(event: MatrixEvent): void { // event content looks like: @@ -2752,7 +2840,7 @@ export class Room extends ReadReceipt { /** * Update the account_data events for this room, overwriting events of the same type. - * @param {Array} events an array of account_data events to add + * @param events - an array of account_data events to add */ public addAccountData(events: MatrixEvent[]): void { for (const event of events) { @@ -2767,8 +2855,8 @@ export class Room extends ReadReceipt { /** * Access account_data event of given event type for this room - * @param {string} type the type of account_data event to be accessed - * @return {?MatrixEvent} the account_data event in question + * @param type - the type of account_data event to be accessed + * @returns the account_data event in question */ public getAccountData(type: EventType | string): MatrixEvent | undefined { return this.accountData[type]; @@ -2776,19 +2864,22 @@ export class Room extends ReadReceipt { /** * Returns whether the syncing user has permission to send a message in the room - * @return {boolean} true if the user should be permitted to send + * @returns true if the user should be permitted to send * message events into the room. */ public maySendMessage(): boolean { - return this.getMyMembership() === 'join' && (this.client.isRoomEncrypted(this.roomId) - ? this.currentState.maySendEvent(EventType.RoomMessageEncrypted, this.myUserId) - : this.currentState.maySendEvent(EventType.RoomMessage, this.myUserId)); + return ( + this.getMyMembership() === "join" && + (this.client.isRoomEncrypted(this.roomId) + ? this.currentState.maySendEvent(EventType.RoomMessageEncrypted, this.myUserId) + : this.currentState.maySendEvent(EventType.RoomMessage, this.myUserId)) + ); } /** * Returns whether the given user has permissions to issue an invite for this room. - * @param {string} userId the ID of the Matrix user to check permissions for - * @returns {boolean} true if the user should be permitted to issue invites for this room. + * @param userId - the ID of the Matrix user to check permissions for + * @returns true if the user should be permitted to issue invites for this room. */ public canInvite(userId: string): boolean { let canInvite = this.getMyMembership() === "join"; @@ -2803,7 +2894,7 @@ export class Room extends ReadReceipt { /** * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. - * @returns {string} the join_rule applied to this room + * @returns the join_rule applied to this room */ public getJoinRule(): JoinRule { return this.currentState.getJoinRule(); @@ -2811,7 +2902,7 @@ export class Room extends ReadReceipt { /** * Returns the history visibility based on the m.room.history_visibility state event, defaulting to `shared`. - * @returns {HistoryVisibility} the history_visibility applied to this room + * @returns the history_visibility applied to this room */ public getHistoryVisibility(): HistoryVisibility { return this.currentState.getHistoryVisibility(); @@ -2819,7 +2910,7 @@ export class Room extends ReadReceipt { /** * Returns the history visibility based on the m.room.history_visibility state event, defaulting to `shared`. - * @returns {HistoryVisibility} the history_visibility applied to this room + * @returns the history_visibility applied to this room */ public getGuestAccess(): GuestAccess { return this.currentState.getGuestAccess(); @@ -2827,7 +2918,7 @@ export class Room extends ReadReceipt { /** * Returns the type of the room from the `m.room.create` event content or undefined if none is set - * @returns {?string} the type of the room. + * @returns the type of the room. */ public getType(): RoomType | string | undefined { const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); @@ -2843,7 +2934,7 @@ export class Room extends ReadReceipt { /** * Returns whether the room is a space-room as defined by MSC1772. - * @returns {boolean} true if the room's type is RoomType.Space + * @returns true if the room's type is RoomType.Space */ public isSpaceRoom(): boolean { return this.getType() === RoomType.Space; @@ -2851,7 +2942,7 @@ export class Room extends ReadReceipt { /** * Returns whether the room is a call-room as defined by MSC3417. - * @returns {boolean} true if the room's type is RoomType.UnstableCall + * @returns true if the room's type is RoomType.UnstableCall */ public isCallRoom(): boolean { return this.getType() === RoomType.UnstableCall; @@ -2859,7 +2950,7 @@ export class Room extends ReadReceipt { /** * Returns whether the room is a video room. - * @returns {boolean} true if the room's type is RoomType.ElementVideo + * @returns true if the room's type is RoomType.ElementVideo */ public isElementVideoRoom(): boolean { return this.getType() === RoomType.ElementVideo; @@ -2895,11 +2986,11 @@ export class Room extends ReadReceipt { /** * This is an internal method. Calculates the name of the room from the current * room state. - * @param {string} userId The client's user ID. Used to filter room members + * @param userId - The client's user ID. Used to filter room members * correctly. - * @param {boolean} ignoreRoomNameEvent Return the implicit room name that we'd see if there + * @param ignoreRoomNameEvent - Return the implicit room name that we'd see if there * was no m.room.name event. - * @return {string} The calculated room name. + * @returns The calculated room name. */ private calculateRoomName(userId: string, ignoreRoomNameEvent = false): string { if (!ignoreRoomNameEvent) { @@ -2977,7 +3068,7 @@ export class Room extends ReadReceipt { const myMembership = this.getMyMembership(); // if I have created a room and invited people through // 3rd party invites - if (myMembership == 'join') { + if (myMembership == "join") { const thirdPartyInvites = this.currentState.getStateEvents(EventType.RoomThirdPartyInvite); if (thirdPartyInvites?.length) { @@ -2998,11 +3089,12 @@ export class Room extends ReadReceipt { let leftNames = otherNames; // if we didn't have heroes, try finding them in the room state if (!leftNames.length) { - leftNames = this.currentState.getMembers().filter((m) => { - return m.userId !== userId && - m.membership !== "invite" && - m.membership !== "join"; - }).map((m) => m.name); + leftNames = this.currentState + .getMembers() + .filter((m) => { + return m.userId !== userId && m.membership !== "invite" && m.membership !== "join"; + }) + .map((m) => m.name); } let oldName: string | undefined; @@ -3041,14 +3133,10 @@ export class Room extends ReadReceipt { return; } const isPowerSufficient = - ( - EVENT_VISIBILITY_CHANGE_TYPE.name - && this.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.name, userId) - ) - || ( - EVENT_VISIBILITY_CHANGE_TYPE.altName - && this.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.altName, userId) - ); + (EVENT_VISIBILITY_CHANGE_TYPE.name && + this.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.name, userId)) || + (EVENT_VISIBILITY_CHANGE_TYPE.altName && + this.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.altName, userId)); if (!isPowerSufficient) { // Powerlevel is insufficient. return; @@ -3068,8 +3156,10 @@ export class Room extends ReadReceipt { // However, to protect against a potential DoS attack, we limit the // number of iterations in this loop. let index = visibilityEventsOnOriginalEvent.length - 1; - const min = Math.max(0, - visibilityEventsOnOriginalEvent.length - MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH); + const min = Math.max( + 0, + visibilityEventsOnOriginalEvent.length - MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH, + ); for (; index >= min; --index) { const target = visibilityEventsOnOriginalEvent[index]; if (target.getTs() < event.getTs()) { @@ -3109,7 +3199,7 @@ export class Room extends ReadReceipt { // most likely because it was ill-formed. return; } - const index = visibilityEventsOnOriginalEvent.findIndex(change => change.getId() === event.getId()); + const index = visibilityEventsOnOriginalEvent.findIndex((change) => change.getId() === event.getId()); if (index === -1) { // This change event was not recorded, most likely because // it was ill-formed. @@ -3146,7 +3236,7 @@ export class Room extends ReadReceipt { * a (more recent) visibility change event, patch the event in * place so that clients now not to display it. * - * @param event Any matrix event. If this event has at least one a + * @param event - Any matrix event. If this event has at least one a * pending visibility change event, apply the latest visibility * change event. */ @@ -3178,27 +3268,11 @@ export class Room extends ReadReceipt { // a map from current event status to a list of allowed next statuses const ALLOWED_TRANSITIONS: Record = { - [EventStatus.ENCRYPTING]: [ - EventStatus.SENDING, - EventStatus.NOT_SENT, - EventStatus.CANCELLED, - ], - [EventStatus.SENDING]: [ - EventStatus.ENCRYPTING, - EventStatus.QUEUED, - EventStatus.NOT_SENT, - EventStatus.SENT, - ], - [EventStatus.QUEUED]: [ - EventStatus.SENDING, - EventStatus.CANCELLED, - ], + [EventStatus.ENCRYPTING]: [EventStatus.SENDING, EventStatus.NOT_SENT, EventStatus.CANCELLED], + [EventStatus.SENDING]: [EventStatus.ENCRYPTING, EventStatus.QUEUED, EventStatus.NOT_SENT, EventStatus.SENT], + [EventStatus.QUEUED]: [EventStatus.SENDING, EventStatus.CANCELLED], [EventStatus.SENT]: [], - [EventStatus.NOT_SENT]: [ - EventStatus.SENDING, - EventStatus.QUEUED, - EventStatus.CANCELLED, - ], + [EventStatus.NOT_SENT]: [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.CANCELLED], [EventStatus.CANCELLED]: [], }; @@ -3245,118 +3319,3 @@ function memberNamesToRoomName(names: string[], count: number): string { } } } - -/** - * Fires when an event we had previously received is redacted. - * - * (Note this is *not* fired when the redaction happens before we receive the - * event). - * - * @event module:client~MatrixClient#"Room.redaction" - * @param {MatrixEvent} event The matrix redaction event - * @param {Room} room The room containing the redacted event - */ - -/** - * Fires when an event that was previously redacted isn't anymore. - * This happens when the redaction couldn't be sent and - * was subsequently cancelled by the user. Redactions have a local echo - * which is undone in this scenario. - * - * @event module:client~MatrixClient#"Room.redactionCancelled" - * @param {MatrixEvent} event The matrix redaction event that was cancelled. - * @param {Room} room The room containing the unredacted event - */ - -/** - * Fires whenever the name of a room is updated. - * @event module:client~MatrixClient#"Room.name" - * @param {Room} room The room whose Room.name was updated. - * @example - * matrixClient.on("Room.name", function(room){ - * var newName = room.name; - * }); - */ - -/** - * Fires whenever a receipt is received for a room - * @event module:client~MatrixClient#"Room.receipt" - * @param {event} event The receipt event - * @param {Room} room The room whose receipts was updated. - * @example - * matrixClient.on("Room.receipt", function(event, room){ - * var receiptContent = event.getContent(); - * }); - */ - -/** - * Fires whenever a room's tags are updated. - * @event module:client~MatrixClient#"Room.tags" - * @param {event} event The tags event - * @param {Room} room The room whose Room.tags was updated. - * @example - * matrixClient.on("Room.tags", function(event, room){ - * var newTags = event.getContent().tags; - * if (newTags["favourite"]) showStar(room); - * }); - */ - -/** - * Fires whenever a room's account_data is updated. - * @event module:client~MatrixClient#"Room.accountData" - * @param {event} event The account_data event - * @param {Room} room The room whose account_data was updated. - * @param {MatrixEvent} prevEvent The event being replaced by - * the new account data, if known. - * @example - * matrixClient.on("Room.accountData", function(event, room, oldEvent){ - * if (event.getType() === "m.room.colorscheme") { - * applyColorScheme(event.getContents()); - * } - * }); - */ - -/** - * Fires when the status of a transmitted event is updated. - * - *

When an event is first transmitted, a temporary copy of the event is - * inserted into the timeline, with a temporary event id, and a status of - * 'SENDING'. - * - *

Once the echo comes back from the server, the content of the event - * (MatrixEvent.event) is replaced by the complete event from the homeserver, - * thus updating its event id, as well as server-generated fields such as the - * timestamp. Its status is set to null. - * - *

Once the /send request completes, if the remote echo has not already - * arrived, the event is updated with a new event id and the status is set to - * 'SENT'. The server-generated fields are of course not updated yet. - * - *

If the /send fails, In this case, the event's status is set to - * 'NOT_SENT'. If it is later resent, the process starts again, setting the - * status to 'SENDING'. Alternatively, the message may be cancelled, which - * removes the event from the room, and sets the status to 'CANCELLED'. - * - *

This event is raised to reflect each of the transitions above. - * - * @event module:client~MatrixClient#"Room.localEchoUpdated" - * - * @param {MatrixEvent} event The matrix event which has been updated - * - * @param {Room} room The room containing the redacted event - * - * @param {string} oldEventId The previous event id (the temporary event id, - * except when updating a successfully-sent event when its echo arrives) - * - * @param {EventStatus} oldStatus The previous event status. - */ - -/** - * Fires when the logged in user's membership in the room is updated. - * - * @event module:models/room~Room#"Room.myMembership" - * @param {Room} room The room in which the membership has been updated - * @param {string} membership The new membership value - * @param {string} prevMembership The previous membership value - */ - diff --git a/src/models/search-result.ts b/src/models/search-result.ts index f598301d785..21192a6ea04 100644 --- a/src/models/search-result.ts +++ b/src/models/search-result.ts @@ -14,10 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -/** - * @module models/search-result - */ - import { EventContext } from "./event-context"; import { EventMapper } from "../event-mapper"; import { IResultContext, ISearchResult } from "../@types/search"; @@ -25,14 +21,10 @@ import { IResultContext, ISearchResult } from "../@types/search"; export class SearchResult { /** * Create a SearchResponse from the response to /search - * @static - * @param {Object} jsonObj - * @param {function} eventMapper - * @return {SearchResult} */ public static fromJson(jsonObj: ISearchResult, eventMapper: EventMapper): SearchResult { - const jsonContext = jsonObj.context || {} as IResultContext; + const jsonContext = jsonObj.context || ({} as IResultContext); let eventsBefore = (jsonContext.events_before || []).map(eventMapper); let eventsAfter = (jsonContext.events_after || []).map(eventMapper); @@ -40,8 +32,8 @@ export class SearchResult { // Filter out any contextual events which do not correspond to the same timeline (thread or room) const threadRootId = context.ourEvent.threadRootId; - eventsBefore = eventsBefore.filter(e => e.threadRootId === threadRootId); - eventsAfter = eventsAfter.filter(e => e.threadRootId === threadRootId); + eventsBefore = eventsBefore.filter((e) => e.threadRootId === threadRootId); + eventsAfter = eventsAfter.filter((e) => e.threadRootId === threadRootId); context.setPaginateToken(jsonContext.start, true); context.addEvents(eventsBefore, true); @@ -54,11 +46,9 @@ export class SearchResult { /** * Construct a new SearchResult * - * @param {number} rank where this SearchResult ranks in the results - * @param {event-context.EventContext} context the matching event and its + * @param rank - where this SearchResult ranks in the results + * @param context - the matching event and its * context - * - * @constructor */ public constructor(public readonly rank: number, public readonly context: EventContext) {} } diff --git a/src/models/thread.ts b/src/models/thread.ts index 412cccad8dd..bca2d92b427 100644 --- a/src/models/thread.ts +++ b/src/models/thread.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. @@ -16,30 +16,28 @@ limitations under the License. import { Optional } from "matrix-events-sdk"; -import { MatrixClient } from "../client"; +import { MatrixClient, PendingEventOrdering } from "../client"; import { TypedReEmitter } from "../ReEmitter"; import { RelationType } from "../@types/event"; import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "./event"; -import { EventTimeline } from "./event-timeline"; -import { EventTimelineSet, EventTimelineSetHandlerMap } from './event-timeline-set'; -import { NotificationCountType, Room, RoomEvent } from './room'; +import { Direction, EventTimeline } from "./event-timeline"; +import { EventTimelineSet, EventTimelineSetHandlerMap } from "./event-timeline-set"; +import { NotificationCountType, Room, RoomEvent } from "./room"; import { RoomState } from "./room-state"; import { ServerControlledNamespacedValue } from "../NamespacedValue"; import { logger } from "../logger"; import { ReadReceipt } from "./read-receipt"; -import { ReceiptType } from "../@types/read_receipts"; +import { Receipt, ReceiptContent, ReceiptType } from "../@types/read_receipts"; export enum ThreadEvent { New = "Thread.new", Update = "Thread.update", NewReply = "Thread.newReply", ViewThread = "Thread.viewThread", - Delete = "Thread.delete" + Delete = "Thread.delete", } -type EmittedEvents = Exclude - | RoomEvent.Timeline - | RoomEvent.TimelineReset; +type EmittedEvents = Exclude | RoomEvent.Timeline | RoomEvent.TimelineReset; export type EventHandlerMap = { [ThreadEvent.Update]: (thread: Thread) => void; @@ -51,12 +49,14 @@ export type EventHandlerMap = { interface IThreadOpts { room: Room; client: MatrixClient; + pendingEventOrdering?: PendingEventOrdering; + receipts?: { event: MatrixEvent; synthetic: boolean }[]; } export enum FeatureSupport { None = 0, Experimental = 1, - Stable = 2 + Stable = 2, } export function determineFeatureSupport(stable: boolean, unstable: boolean): FeatureSupport { @@ -89,17 +89,16 @@ export class Thread extends ReadReceipt { private lastEvent: MatrixEvent | undefined; private replyCount = 0; + private lastPendingEvent: MatrixEvent | undefined; + private pendingReplyCount = 0; public readonly room: Room; public readonly client: MatrixClient; + private readonly pendingEventOrdering: PendingEventOrdering; public initialEventsFetched = !Thread.hasServerSideSupport; - public constructor( - public readonly id: string, - public rootEvent: MatrixEvent | undefined, - opts: IThreadOpts, - ) { + public constructor(public readonly id: string, public rootEvent: MatrixEvent | undefined, opts: IThreadOpts) { super(); if (!opts?.room) { @@ -110,25 +109,30 @@ export class Thread extends ReadReceipt { this.room = opts.room; this.client = opts.client; - this.timelineSet = new EventTimelineSet(this.room, { - timelineSupport: true, - pendingEvents: true, - }, this.client, this); + this.pendingEventOrdering = opts.pendingEventOrdering ?? PendingEventOrdering.Chronological; + this.timelineSet = new EventTimelineSet( + this.room, + { + timelineSupport: true, + pendingEvents: true, + }, + this.client, + this, + ); this.reEmitter = new TypedReEmitter(this); - this.reEmitter.reEmit(this.timelineSet, [ - RoomEvent.Timeline, - RoomEvent.TimelineReset, - ]); + this.reEmitter.reEmit(this.timelineSet, [RoomEvent.Timeline, RoomEvent.TimelineReset]); this.room.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); this.room.on(RoomEvent.Redaction, this.onRedaction); - this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho); + this.room.on(RoomEvent.LocalEchoUpdated, this.onLocalEcho); this.timelineSet.on(RoomEvent.Timeline, this.onTimelineEvent); + this.processReceipts(opts.receipts); + // even if this thread is thought to be originating from this client, we initialise it as we may be in a // gappy sync and a thread around this event may already exist. - this.initialiseThread(); + this.updateThreadMetadata(); this.setEventMetadata(this.rootEvent); } @@ -145,9 +149,7 @@ export class Thread extends ReadReceipt { await this.processEvent(this.rootEvent); } - public static setServerSideSupport( - status: FeatureSupport, - ): void { + public static setServerSideSupport(status: FeatureSupport): void { Thread.hasServerSideSupport = status; if (status !== FeatureSupport.Stable) { FILTER_RELATED_BY_SENDERS.setPreferUnstable(true); @@ -156,25 +158,23 @@ export class Thread extends ReadReceipt { } } - public static setServerSideListSupport( - status: FeatureSupport, - ): void { + public static setServerSideListSupport(status: FeatureSupport): void { Thread.hasServerSideListSupport = status; } - public static setServerSideFwdPaginationSupport( - status: FeatureSupport, - ): void { + public static setServerSideFwdPaginationSupport(status: FeatureSupport): void { Thread.hasServerSideFwdPaginationSupport = status; } private onBeforeRedaction = (event: MatrixEvent, redaction: MatrixEvent): void => { - if (event?.isRelation(THREAD_RELATION_TYPE.name) && + if ( + event?.isRelation(THREAD_RELATION_TYPE.name) && this.room.eventShouldLiveIn(event).threadId === this.id && event.getId() !== this.id && // the root event isn't counted in the length so ignore this redaction !redaction.status // only respect it when it succeeds ) { this.replyCount--; + this.updatePendingReplyCount(); this.emit(ThreadEvent.Update, this); } }; @@ -189,7 +189,7 @@ export class Thread extends ReadReceipt { this._currentUserParticipated = false; this.emit(ThreadEvent.Delete, this); } else { - await this.initialiseThread(); + await this.updateThreadMetadata(); } }; @@ -202,15 +202,19 @@ export class Thread extends ReadReceipt { if (!toStartOfTimeline) { room!.addLocalEchoReceipt(event.getSender()!, event, ReceiptType.Read); } - this.onEcho(event); + this.onEcho(event, toStartOfTimeline ?? false); }; - private onEcho = async (event: MatrixEvent): Promise => { - if (event.threadRootId !== this.id) return; // ignore echoes for other timelines - if (this.lastEvent === event) return; - if (!event.isRelation(THREAD_RELATION_TYPE.name)) return; + private onLocalEcho = (event: MatrixEvent): void => { + this.onEcho(event, false); + }; - await this.initialiseThread(); + private onEcho = async (event: MatrixEvent, toStartOfTimeline: boolean): Promise => { + if (event.threadRootId !== this.id) return; // ignore echoes for other timelines + if (this.lastEvent === event) return; // ignore duplicate events + await this.updateThreadMetadata(); + if (!event.isRelation(THREAD_RELATION_TYPE.name)) return; // don't send a new reply event for reactions or edits + if (toStartOfTimeline) return; // ignore messages added to the start of the timeline this.emit(ThreadEvent.NewReply, this, event); }; @@ -220,32 +224,28 @@ export class Thread extends ReadReceipt { private addEventToTimeline(event: MatrixEvent, toStartOfTimeline: boolean): void { if (!this.findEventById(event.getId()!)) { - this.timelineSet.addEventToTimeline( - event, - this.liveTimeline, - { - toStartOfTimeline, - fromCache: false, - roomState: this.roomState, - }, - ); + this.timelineSet.addEventToTimeline(event, this.liveTimeline, { + toStartOfTimeline, + fromCache: false, + roomState: this.roomState, + }); this.timeline = this.events; } } public addEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void { - events.forEach(ev => this.addEvent(ev, toStartOfTimeline, false)); - this.initialiseThread(); + events.forEach((ev) => this.addEvent(ev, toStartOfTimeline, false)); + this.updateThreadMetadata(); } /** * Add an event to the thread and updates * the tail/root references if needed * Will fire "Thread.update" - * @param event The event to add - * @param {boolean} toStartOfTimeline whether the event is being added + * @param event - The event to add + * @param toStartOfTimeline - whether the event is being added * to the start (and not the end) of the timeline. - * @param {boolean} emit whether to emit the Update event if the thread was updated or not. + * @param emit - whether to emit the Update event if the thread was updated or not. */ public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): Promise { this.setEventMetadata(event); @@ -263,8 +263,8 @@ export class Thread extends ReadReceipt { this.client.decryptEventIfNeeded(event, {}); } else if (!toStartOfTimeline && this.initialEventsFetched && isNewestReply) { - await this.fetchEditsWhereNeeded(event); this.addEventToTimeline(event, false); + this.fetchEditsWhereNeeded(event); } else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) { // Apply annotations and replace relations to the relations of the timeline only this.timelineSet.relations?.aggregateParentEvent(event); @@ -280,7 +280,7 @@ export class Thread extends ReadReceipt { if (emit) { this.emit(ThreadEvent.NewReply, this, event); - this.initialiseThread(); + this.updateThreadMetadata(); } } @@ -292,25 +292,73 @@ export class Thread extends ReadReceipt { this.timeline = this.events; } + /** + * Processes the receipts that were caught during initial sync + * When clients become aware of a thread, they try to retrieve those read receipts + * and apply them to the current thread + * @param receipts - A collection of the receipts cached from initial sync + */ + private processReceipts(receipts: { event: MatrixEvent; synthetic: boolean }[] = []): void { + for (const { event, synthetic } of receipts) { + 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; + this.addReceiptToStructure(eventId, receiptType as ReceiptType, userId, receipt, synthetic); + }); + }); + }); + } + } + private getRootEventBundledRelationship(rootEvent = this.rootEvent): IThreadBundledRelationship | undefined { return rootEvent?.getServerAggregatedRelation(THREAD_RELATION_TYPE.name); } - public async initialiseThread(): Promise { - let bundledRelationship = this.getRootEventBundledRelationship(); - if (Thread.hasServerSideSupport) { - await this.fetchRootEvent(); - bundledRelationship = this.getRootEventBundledRelationship(); - } - + private async processRootEvent(): Promise { + const bundledRelationship = this.getRootEventBundledRelationship(); if (Thread.hasServerSideSupport && bundledRelationship) { this.replyCount = bundledRelationship.count; this._currentUserParticipated = !!bundledRelationship.current_user_participated; const mapper = this.client.getEventMapper(); - this.lastEvent = mapper(bundledRelationship.latest_event); + // re-insert roomId + this.lastEvent = mapper({ + ...bundledRelationship.latest_event, + room_id: this.roomId, + }); + this.updatePendingReplyCount(); await this.processEvent(this.lastEvent); } + } + + private updatePendingReplyCount(): void { + const unfilteredPendingEvents = + this.pendingEventOrdering === PendingEventOrdering.Detached ? this.room.getPendingEvents() : this.events; + const pendingEvents = unfilteredPendingEvents.filter( + (ev) => + ev.threadRootId === this.id && + ev.isRelation(THREAD_RELATION_TYPE.name) && + ev.status !== null && + ev.getId() !== this.lastEvent?.getId(), + ); + this.lastPendingEvent = pendingEvents.length ? pendingEvents[pendingEvents.length - 1] : undefined; + this.pendingReplyCount = pendingEvents.length; + } + + private async updateThreadMetadata(): Promise { + this.updatePendingReplyCount(); + + if (Thread.hasServerSideSupport) { + // Ensure we show *something* as soon as possible, we'll update it as soon as we get better data, but we + // don't want the thread preview to be empty if we can avoid it + if (!this.initialEventsFetched) { + await this.processRootEvent(); + } + await this.fetchRootEvent(); + } + await this.processRootEvent(); if (!this.initialEventsFetched) { this.initialEventsFetched = true; @@ -318,7 +366,15 @@ export class Thread extends ReadReceipt { try { // if the thread has regular events, this will just load the last reply. // if the thread is newly created, this will load the root event. - await this.client.paginateEventTimeline(this.liveTimeline, { backwards: true, limit: 1 }); + if (this.replyCount === 0 && this.rootEvent) { + this.timelineSet.addEventsToTimeline([this.rootEvent], true, this.liveTimeline, null); + this.liveTimeline.setPaginationToken(null, Direction.Backward); + } else { + await this.client.paginateEventTimeline(this.liveTimeline, { + backwards: true, + limit: Math.max(1, this.length), + }); + } // just to make sure that, if we've created a timeline window for this thread before the thread itself // existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly. this.emit(RoomEvent.TimelineReset, this.room, this.timelineSet, true); @@ -333,18 +389,25 @@ export class Thread extends ReadReceipt { // XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084 private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise { - return Promise.all(events.filter(e => e.isEncrypted()).map((event: MatrixEvent) => { - if (event.isRelation()) return; // skip - relations don't get edits - return this.client.relations(this.roomId, event.getId()!, RelationType.Replace, event.getType(), { - limit: 1, - }).then(relations => { - if (relations.events.length) { - event.makeReplaced(relations.events[0]); - } - }).catch(e => { - logger.error("Failed to load edits for encrypted thread event", e); - }); - })); + return Promise.all( + events + .filter((e) => e.isEncrypted()) + .map((event: MatrixEvent) => { + if (event.isRelation()) return; // skip - relations don't get edits + return this.client + .relations(this.roomId, event.getId()!, RelationType.Replace, event.getType(), { + limit: 1, + }) + .then((relations) => { + if (relations.events.length) { + event.makeReplaced(relations.events[0]); + } + }) + .catch((e) => { + logger.error("Failed to load edits for encrypted thread event", e); + }); + }), + ); } public setEventMetadata(event: Optional): void { @@ -365,11 +428,6 @@ export class Thread extends ReadReceipt { * Finds an event by ID in the current thread */ public findEventById(eventId: string): MatrixEvent | undefined { - // Check the lastEvent as it may have been created based on a bundled relationship and not in a timeline - if (this.lastEvent?.getId() === eventId) { - return this.lastEvent; - } - return this.timelineSet.findEventById(eventId); } @@ -396,14 +454,15 @@ export class Thread extends ReadReceipt { * exclude annotations from that number */ public get length(): number { - return this.replyCount; + return this.replyCount + this.pendingReplyCount; } /** - * A getter for the last event added to the thread, if known. + * A getter for the last event of the thread. + * This might be a synthesized event, if so, it will not emit any events to listeners. */ public get replyToEvent(): Optional { - return this.lastEvent ?? this.lastReply(); + return this.lastPendingEvent ?? this.lastEvent ?? this.lastReply(); } public get events(): MatrixEvent[] { @@ -457,21 +516,18 @@ export const FILTER_RELATED_BY_REL_TYPES = new ServerControlledNamespacedValue( "related_by_rel_types", "io.element.relation_types", ); -export const THREAD_RELATION_TYPE = new ServerControlledNamespacedValue( - "m.thread", - "io.element.thread", -); +export const THREAD_RELATION_TYPE = new ServerControlledNamespacedValue("m.thread", "io.element.thread"); export enum ThreadFilterType { "My", - "All" + "All", } -export function threadFilterTypeToFilter(type: ThreadFilterType | null): 'all' | 'participated' { +export function threadFilterTypeToFilter(type: ThreadFilterType | null): "all" | "participated" { switch (type) { case ThreadFilterType.My: - return 'participated'; + return "participated"; default: - return 'all'; + return "all"; } } diff --git a/src/models/typed-event-emitter.ts b/src/models/typed-event-emitter.ts index 691ec5ec350..3cfe602f8fb 100644 --- a/src/models/typed-event-emitter.ts +++ b/src/models/typed-event-emitter.ts @@ -28,12 +28,10 @@ export type ListenerMap = { [eventName in E]: AnyListener }; type EventEmitterEventListener = (eventName: string, listener: AnyListener) => void; type EventEmitterErrorListener = (error: Error) => void; -export type Listener< - E extends string, - A extends ListenerMap, - T extends E | EventEmitterEvents, -> = T extends E ? A[T] - : T extends EventEmitterEvents ? EventEmitterErrorListener +export type Listener, T extends E | EventEmitterEvents> = T extends E + ? A[T] + : T extends EventEmitterEvents + ? EventEmitterErrorListener : EventEmitterEventListener; /** @@ -69,28 +67,19 @@ export class TypedEventEmitter< return super.listenerCount(event); } - public listeners(event: Events | EventEmitterEvents): Function[] { + public listeners(event: Events | EventEmitterEvents): ReturnType { return super.listeners(event); } - public off( - event: T, - listener: Listener, - ): this { + public off(event: T, listener: Listener): this { return super.off(event, listener); } - public on( - event: T, - listener: Listener, - ): this { + public on(event: T, listener: Listener): this { return super.on(event, listener); } - public once( - event: T, - listener: Listener, - ): this { + public once(event: T, listener: Listener): this { return super.once(event, listener); } @@ -119,7 +108,7 @@ export class TypedEventEmitter< return super.removeListener(event, listener); } - public rawListeners(event: Events | EventEmitterEvents): Function[] { + public rawListeners(event: Events | EventEmitterEvents): ReturnType { return super.rawListeners(event); } } diff --git a/src/models/user.ts b/src/models/user.ts index 5d92ca494cb..054a17438f2 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -14,10 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -/** - * @module models/user - */ - import { MatrixEvent } from "./event"; import { TypedEventEmitter } from "./typed-event-emitter"; @@ -30,51 +26,132 @@ export enum UserEvent { } export type UserEventHandlerMap = { + /** + * Fires whenever any user's display name changes. + * @param event - The matrix event which caused this event to fire. + * @param user - The user whose User.displayName changed. + * @example + * ``` + * matrixClient.on("User.displayName", function(event, user){ + * var newName = user.displayName; + * }); + * ``` + */ [UserEvent.DisplayName]: (event: MatrixEvent | undefined, user: User) => void; + /** + * Fires whenever any user's avatar URL changes. + * @param event - The matrix event which caused this event to fire. + * @param user - The user whose User.avatarUrl changed. + * @example + * ``` + * matrixClient.on("User.avatarUrl", function(event, user){ + * var newUrl = user.avatarUrl; + * }); + * ``` + */ [UserEvent.AvatarUrl]: (event: MatrixEvent | undefined, user: User) => void; + /** + * Fires whenever any user's presence changes. + * @param event - The matrix event which caused this event to fire. + * @param user - The user whose User.presence changed. + * @example + * ``` + * matrixClient.on("User.presence", function(event, user){ + * var newPresence = user.presence; + * }); + * ``` + */ [UserEvent.Presence]: (event: MatrixEvent | undefined, user: User) => void; + /** + * Fires whenever any user's currentlyActive changes. + * @param event - The matrix event which caused this event to fire. + * @param user - The user whose User.currentlyActive changed. + * @example + * ``` + * matrixClient.on("User.currentlyActive", function(event, user){ + * var newCurrentlyActive = user.currentlyActive; + * }); + * ``` + */ [UserEvent.CurrentlyActive]: (event: MatrixEvent | undefined, user: User) => void; + /** + * Fires whenever any user's lastPresenceTs changes, + * ie. whenever any presence event is received for a user. + * @param event - The matrix event which caused this event to fire. + * @param user - The user whose User.lastPresenceTs changed. + * @example + * ``` + * matrixClient.on("User.lastPresenceTs", function(event, user){ + * var newlastPresenceTs = user.lastPresenceTs; + * }); + * ``` + */ [UserEvent.LastPresenceTs]: (event: MatrixEvent | undefined, user: User) => void; }; export class User extends TypedEventEmitter { private modified = -1; - // XXX these should be read-only + /** + * The 'displayname' of the user if known. + * @privateRemarks + * Should be read-only + */ public displayName?: string; public rawDisplayName?: string; + /** + * The 'avatar_url' of the user if known. + * @privateRemarks + * Should be read-only + */ public avatarUrl?: string; + /** + * The presence status message if known. + * @privateRemarks + * Should be read-only + */ public presenceStatusMsg?: string; + /** + * The presence enum if known. + * @privateRemarks + * Should be read-only + */ public presence = "offline"; + /** + * Timestamp (ms since the epoch) for when we last received presence data for this user. + * We can subtract lastActiveAgo from this to approximate an absolute value for when a user was last active. + * @privateRemarks + * Should be read-only + */ public lastActiveAgo = 0; + /** + * The time elapsed in ms since the user interacted proactively with the server, + * or we saw a message from the user + * @privateRemarks + * Should be read-only + */ public lastPresenceTs = 0; + /** + * Whether we should consider lastActiveAgo to be an approximation + * and that the user should be seen as active 'now' + * @privateRemarks + * Should be read-only + */ public currentlyActive = false; + /** + * The events describing this user. + * @privateRemarks + * Should be read-only + */ public events: { + /** The m.presence event for this user. */ presence?: MatrixEvent; profile?: MatrixEvent; } = {}; /** - * Construct a new User. A User must have an ID and can optionally have extra - * information associated with it. - * @constructor - * @param {string} userId Required. The ID of this user. - * @prop {string} userId The ID of the user. - * @prop {Object} info The info object supplied in the constructor. - * @prop {string} displayName The 'displayname' of the user if known. - * @prop {string} avatarUrl The 'avatar_url' of the user if known. - * @prop {string} presence The presence enum if known. - * @prop {string} presenceStatusMsg The presence status message if known. - * @prop {Number} lastActiveAgo The time elapsed in ms since the user interacted - * proactively with the server, or we saw a message from the user - * @prop {Number} lastPresenceTs Timestamp (ms since the epoch) for when we last - * received presence data for this user. We can subtract - * lastActiveAgo from this to approximate an absolute value for - * when a user was last active. - * @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be - * an approximation and that the user should be seen as active 'now' - * @prop {Object} events The events describing this user. - * @prop {MatrixEvent} events.presence The m.presence event for this user. + * Construct a new User. A User must have an ID and can optionally have extra information associated with it. + * @param userId - Required. The ID of this user. */ public constructor(public readonly userId: string) { super(); @@ -87,10 +164,12 @@ export class User extends TypedEventEmitter { * Update this User with the given presence event. May fire "User.presence", * "User.avatarUrl" and/or "User.displayName" if this event updates this user's * properties. - * @param {MatrixEvent} event The m.presence event. - * @fires module:client~MatrixClient#event:"User.presence" - * @fires module:client~MatrixClient#event:"User.displayName" - * @fires module:client~MatrixClient#event:"User.avatarUrl" + * @param event - The `m.presence` event. + * + * @remarks + * Fires {@link UserEvent.Presence} + * Fires {@link UserEvent.DisplayName} + * Fires {@link UserEvent.AvatarUrl} */ public setPresenceEvent(event: MatrixEvent): void { if (event.getType() !== "m.presence") { @@ -103,16 +182,16 @@ export class User extends TypedEventEmitter { if (event.getContent().presence !== this.presence || firstFire) { eventsToFire.push(UserEvent.Presence); } - if (event.getContent().avatar_url && - event.getContent().avatar_url !== this.avatarUrl) { + if (event.getContent().avatar_url && event.getContent().avatar_url !== this.avatarUrl) { eventsToFire.push(UserEvent.AvatarUrl); } - if (event.getContent().displayname && - event.getContent().displayname !== this.displayName) { + if (event.getContent().displayname && event.getContent().displayname !== this.displayName) { eventsToFire.push(UserEvent.DisplayName); } - if (event.getContent().currently_active !== undefined && - event.getContent().currently_active !== this.currentlyActive) { + if ( + event.getContent().currently_active !== undefined && + event.getContent().currently_active !== this.currentlyActive + ) { eventsToFire.push(UserEvent.CurrentlyActive); } @@ -142,7 +221,7 @@ export class User extends TypedEventEmitter { /** * Manually set this user's display name. No event is emitted in response to this * as there is no underlying MatrixEvent to emit with. - * @param {string} name The new display name. + * @param name - The new display name. */ public setDisplayName(name: string): void { const oldName = this.displayName; @@ -155,7 +234,7 @@ export class User extends TypedEventEmitter { /** * Manually set this user's non-disambiguated display name. No event is emitted * in response to this as there is no underlying MatrixEvent to emit with. - * @param {string} name The new display name. + * @param name - The new display name. */ public setRawDisplayName(name?: string): void { this.rawDisplayName = name; @@ -164,7 +243,7 @@ export class User extends TypedEventEmitter { /** * Manually set this user's avatar URL. No event is emitted in response to this * as there is no underlying MatrixEvent to emit with. - * @param {string} url The new avatar URL. + * @param url - The new avatar URL. */ public setAvatarUrl(url?: string): void { const oldUrl = this.avatarUrl; @@ -185,7 +264,7 @@ export class User extends TypedEventEmitter { * Get the timestamp when this User was last updated. This timestamp is * updated when this User receives a new Presence event which has updated a * property on this object. It is updated before firing events. - * @return {number} The timestamp + * @returns The timestamp */ public getLastModifiedTime(): number { return this.modified; @@ -194,65 +273,9 @@ export class User extends TypedEventEmitter { /** * Get the absolute timestamp when this User was last known active on the server. * It is *NOT* accurate if this.currentlyActive is true. - * @return {number} The timestamp + * @returns The timestamp */ public getLastActiveTs(): number { return this.lastPresenceTs - this.lastActiveAgo; } } - -/** - * Fires whenever any user's lastPresenceTs changes, - * ie. whenever any presence event is received for a user. - * @event module:client~MatrixClient#"User.lastPresenceTs" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.lastPresenceTs changed. - * @example - * matrixClient.on("User.lastPresenceTs", function(event, user){ - * var newlastPresenceTs = user.lastPresenceTs; - * }); - */ - -/** - * Fires whenever any user's presence changes. - * @event module:client~MatrixClient#"User.presence" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.presence changed. - * @example - * matrixClient.on("User.presence", function(event, user){ - * var newPresence = user.presence; - * }); - */ - -/** - * Fires whenever any user's currentlyActive changes. - * @event module:client~MatrixClient#"User.currentlyActive" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.currentlyActive changed. - * @example - * matrixClient.on("User.currentlyActive", function(event, user){ - * var newCurrentlyActive = user.currentlyActive; - * }); - */ - -/** - * Fires whenever any user's display name changes. - * @event module:client~MatrixClient#"User.displayName" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.displayName changed. - * @example - * matrixClient.on("User.displayName", function(event, user){ - * var newName = user.displayName; - * }); - */ - -/** - * Fires whenever any user's avatar URL changes. - * @event module:client~MatrixClient#"User.avatarUrl" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.avatarUrl changed. - * @example - * matrixClient.on("User.avatarUrl", function(event, user){ - * var newUrl = user.avatarUrl; - * }); - */ diff --git a/src/pushprocessor.ts b/src/pushprocessor.ts index 4538b97214e..0d7338f8fb1 100644 --- a/src/pushprocessor.ts +++ b/src/pushprocessor.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { deepCompare, escapeRegExp, globToRegexp, isNullOrUndefined } from "./utils"; -import { logger } from './logger'; +import { logger } from "./logger"; import { MatrixClient } from "./client"; import { MatrixEvent } from "./models/event"; import { @@ -38,10 +38,6 @@ import { } from "./@types/PushRules"; import { EventType } from "./@types/event"; -/** - * @module pushprocessor - */ - const RULEKINDS_IN_ORDER = [ PushRuleKind.Override, PushRuleKind.ContentSpecific, @@ -70,9 +66,7 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [ pattern: "m.reaction", }, ], - actions: [ - PushRuleActionName.DontNotify, - ], + actions: [PushRuleActionName.DontNotify], }, { // For homeservers which don't support MSC3786 yet @@ -116,32 +110,34 @@ const DEFAULT_UNDERRIDE_RULES: IPushRule[] = [ ]; export interface IActionsObject { + /** Whether this event should notify the user or not. */ notify: boolean; + /** How this event should be notified. */ tweaks: Partial>; } export class PushProcessor { /** * Construct a Push Processor. - * @constructor - * @param {Object} client The Matrix client object to use + * @param client - The Matrix client object to use */ public constructor(private readonly client: MatrixClient) {} /** * Convert a list of actions into a object with the actions as keys and their values - * eg. [ 'notify', { set_tweak: 'sound', value: 'default' } ] - * becomes { notify: true, tweaks: { sound: 'default' } } - * @param {array} actionList The actions list + * @example + * eg. `[ 'notify', { set_tweak: 'sound', value: 'default' } ]` + * becomes `{ notify: true, tweaks: { sound: 'default' } }` + * @param actionList - The actions list * - * @return {object} A object with key 'notify' (true or false) and an object of actions + * @returns A object with key 'notify' (true or false) and an object of actions */ public static actionListToActionsObject(actionList: PushRuleAction[]): IActionsObject { const actionObj: IActionsObject = { notify: false, tweaks: {} }; for (const action of actionList) { if (action === PushRuleActionName.Notify) { actionObj.notify = true; - } else if (typeof action === 'object') { + } else if (typeof action === "object") { if (action.value === undefined) { action.value = true; } @@ -155,8 +151,8 @@ export class PushProcessor { * Rewrites conditions on a client's push rules to match the defaults * where applicable. Useful for upgrading push rules to more strict * conditions when the server is falling behind on defaults. - * @param {object} incomingRules The client's existing push rules - * @returns {object} The rewritten rules + * @param incomingRules - The client's existing push rules + * @returns The rewritten rules */ public static rewriteDefaultRules(incomingRules: IPushRules): IPushRules { let newRules: IPushRules = JSON.parse(JSON.stringify(incomingRules)); // deep clone @@ -171,8 +167,7 @@ export class PushProcessor { // Merge the client-level defaults with the ones from the server const globalOverrides = newRules.global.override; for (const override of DEFAULT_OVERRIDE_RULES) { - const existingRule = globalOverrides - .find((r) => r.rule_id === override.rule_id); + const existingRule = globalOverrides.find((r) => r.rule_id === override.rule_id); if (existingRule) { // Copy over the actions, default, and conditions. Don't touch the user's preference. @@ -189,8 +184,7 @@ export class PushProcessor { const globalUnderrides = newRules.global.underride ?? []; for (const underride of DEFAULT_UNDERRIDE_RULES) { - const existingRule = globalUnderrides - .find((r) => r.rule_id === underride.rule_id); + const existingRule = globalUnderrides.find((r) => r.rule_id === underride.rule_id); if (existingRule) { // Copy over the actions, default, and conditions. Don't touch the user's preference. @@ -243,9 +237,9 @@ export class PushProcessor { tprule: IPushRule, ): Pick | null { const rawrule: Pick = { - 'rule_id': tprule.rule_id, - 'actions': tprule.actions, - 'conditions': [], + rule_id: tprule.rule_id, + actions: tprule.actions, + conditions: [], }; switch (kind) { case PushRuleKind.Underride: @@ -257,9 +251,9 @@ export class PushProcessor { return null; } rawrule.conditions!.push({ - 'kind': ConditionKind.EventMatch, - 'key': 'room_id', - 'value': tprule.rule_id, + kind: ConditionKind.EventMatch, + key: "room_id", + value: tprule.rule_id, }); break; case PushRuleKind.SenderSpecific: @@ -267,9 +261,9 @@ export class PushProcessor { return null; } rawrule.conditions!.push({ - 'kind': ConditionKind.EventMatch, - 'key': 'user_id', - 'value': tprule.rule_id, + kind: ConditionKind.EventMatch, + key: "user_id", + value: tprule.rule_id, }); break; case PushRuleKind.ContentSpecific: @@ -277,9 +271,9 @@ export class PushProcessor { return null; } rawrule.conditions!.push({ - 'kind': ConditionKind.EventMatch, - 'key': 'content.body', - 'pattern': tprule.pattern, + kind: ConditionKind.EventMatch, + key: "content.body", + pattern: tprule.pattern, }); break; } @@ -311,7 +305,7 @@ export class PushProcessor { cond: ISenderNotificationPermissionCondition, ev: MatrixEvent, ): boolean { - const notifLevelKey = cond['key']; + const notifLevelKey = cond["key"]; if (!notifLevelKey) { return false; } @@ -349,16 +343,16 @@ export class PushProcessor { return false; } switch (ineq) { - case '': - case '==': + case "": + case "==": return memberCount == rhs; - case '<': + case "<": return memberCount < rhs; - case '>': + case ">": return memberCount > rhs; - case '<=': + case "<=": return memberCount <= rhs; - case '>=': + case ">=": return memberCount >= rhs; default: return false; @@ -370,7 +364,7 @@ export class PushProcessor { if (ev.isEncrypted() && ev.getClearContent()) { content = ev.getClearContent()!; } - if (!content || !content.body || typeof content.body != 'string') { + if (!content || !content.body || typeof content.body != "string") { return false; } @@ -384,7 +378,7 @@ export class PushProcessor { // N.B. we can't use \b as it chokes on unicode. however \W seems to be okay // as shorthand for [^0-9A-Za-z_]. - const pat = new RegExp("(^|\\W)" + escapeRegExp(displayName) + "(\\W|$)", 'i'); + const pat = new RegExp("(^|\\W)" + escapeRegExp(displayName) + "(\\W|$)", "i"); return content.body.search(pat) > -1; } @@ -394,7 +388,7 @@ export class PushProcessor { } const val = this.valueForDottedKey(cond.key, ev); - if (typeof val !== 'string') { + if (typeof val !== "string") { return false; } @@ -402,13 +396,14 @@ export class PushProcessor { return cond.value === val; } - if (typeof cond.pattern !== 'string') { + if (typeof cond.pattern !== "string") { return false; } - const regex = cond.key === 'content.body' - ? this.createCachedRegex('(^|\\W)', cond.pattern, '(\\W|$)') - : this.createCachedRegex('^', cond.pattern, '$'); + const regex = + cond.key === "content.body" + ? this.createCachedRegex("(^|\\W)", cond.pattern, "(\\W|$)") + : this.createCachedRegex("^", cond.pattern, "$"); return !!val.match(regex); } @@ -420,12 +415,10 @@ export class PushProcessor { // Since servers don't support properly sending push notification // about MSC3401 call events, we do the handling ourselves return ( - ["m.ring", "m.prompt"].includes(ev.getContent()["m.intent"]) - && !("m.terminated" in ev.getContent()) - && ( - (ev.getPrevContent()["m.terminated"] !== ev.getContent()["m.terminated"]) - || deepCompare(ev.getPrevContent(), {}) - ) + ["m.ring", "m.prompt"].includes(ev.getContent()["m.intent"]) && + !("m.terminated" in ev.getContent()) && + (ev.getPrevContent()["m.terminated"] !== ev.getContent()["m.terminated"] || + deepCompare(ev.getPrevContent(), {})) ); } @@ -435,21 +428,21 @@ export class PushProcessor { } PushProcessor.cachedGlobToRegex[glob] = new RegExp( prefix + globToRegexp(glob) + suffix, - 'i', // Case insensitive + "i", // Case insensitive ); return PushProcessor.cachedGlobToRegex[glob]; } private valueForDottedKey(key: string, ev: MatrixEvent): any { - const parts = key.split('.'); + const parts = key.split("."); let val: any; // special-case the first component to deal with encrypted messages const firstPart = parts[0]; - if (firstPart === 'content') { + if (firstPart === "content") { val = ev.getContent(); parts.shift(); - } else if (firstPart === 'type') { + } else if (firstPart === "type") { val = ev.getType(); parts.shift(); } else { @@ -490,22 +483,18 @@ export class PushProcessor { if (actionObj.tweaks.highlight === undefined) { // if it isn't specified, highlight if it's a content // rule but otherwise not - actionObj.tweaks.highlight = (rule.kind == PushRuleKind.ContentSpecific); + actionObj.tweaks.highlight = rule.kind == PushRuleKind.ContentSpecific; } return actionObj; } public ruleMatchesEvent(rule: Partial & Pick, ev: MatrixEvent): boolean { - return !rule.conditions?.some(cond => !this.eventFulfillsCondition(cond, ev)); + return !rule.conditions?.some((cond) => !this.eventFulfillsCondition(cond, ev)); } /** * Get the user's push actions for the given event - * - * @param {module:models/event.MatrixEvent} ev - * - * @return {PushAction} */ public actionsForEvent(ev: MatrixEvent): IActionsObject { return this.pushActionsForEventAndRulesets(ev, this.client.pushRules); @@ -514,17 +503,17 @@ export class PushProcessor { /** * Get one of the users push rules by its ID * - * @param {string} ruleId The ID of the rule to search for - * @return {object} The push rule, or null if no such rule was found + * @param ruleId - The ID of the rule to search for + * @returns The push rule, or null if no such rule was found */ public getPushRuleById(ruleId: string): IPushRule | null { - for (const scope of ['global']) { + for (const scope of ["global"] as const) { if (this.client.pushRules?.[scope] === undefined) continue; for (const kind of RULEKINDS_IN_ORDER) { if (this.client.pushRules[scope][kind] === undefined) continue; - for (const rule of this.client.pushRules[scope][kind]) { + for (const rule of this.client.pushRules[scope][kind]!) { if (rule.rule_id === ruleId) return rule; } } @@ -532,15 +521,3 @@ export class PushProcessor { return null; } } - -/** - * @typedef {Object} PushAction - * @type {Object} - * @property {boolean} notify Whether this event should notify the user or not. - * @property {Object} tweaks How this event should be notified. - * @property {boolean} tweaks.highlight Whether this event should be highlighted - * on the UI. - * @property {boolean} tweaks.sound Whether this notification should produce a - * noise. - */ - diff --git a/src/realtime-callbacks.ts b/src/realtime-callbacks.ts index 4677b0c1ed1..1b03a579a28 100644 --- a/src/realtime-callbacks.ts +++ b/src/realtime-callbacks.ts @@ -24,7 +24,7 @@ limitations under the License. * it will instead fire as soon as possible after resume. */ -import { logger } from './logger'; +import { logger } from "./logger"; // we schedule a callback at least this often, to check if we've missed out on // some wall-clock time due to being suspended. @@ -48,16 +48,17 @@ type Callback = { const callbackList: Callback[] = []; // var debuglog = logger.log.bind(logger); -const debuglog = function(...params: any[]): void {}; +/* istanbul ignore next */ +const debuglog = function (...params: any[]): void {}; /** * reimplementation of window.setTimeout, which will call the callback if * the wallclock time goes past the deadline. * - * @param {function} func callback to be called after a delay - * @param {Number} delayMs number of milliseconds to delay by + * @param func - callback to be called after a delay + * @param delayMs - number of milliseconds to delay by * - * @return {Number} an identifier for this callback, which may be passed into + * @returns an identifier for this callback, which may be passed into * clearTimeout later. */ export function setTimeout(func: (...params: any[]) => void, delayMs: number, ...params: any[]): number { @@ -68,8 +69,7 @@ export function setTimeout(func: (...params: any[]) => void, delayMs: number, .. const runAt = Date.now() + delayMs; const key = count++; - debuglog("setTimeout: scheduling cb", key, "at", runAt, - "(delay", delayMs, ")"); + debuglog("setTimeout: scheduling cb", key, "at", runAt, "(delay", delayMs, ")"); const data = { runAt: runAt, func: func, @@ -78,11 +78,9 @@ export function setTimeout(func: (...params: any[]) => void, delayMs: number, .. }; // figure out where it goes in the list - const idx = binarySearch( - callbackList, function(el) { - return el.runAt - runAt; - }, - ); + const idx = binarySearch(callbackList, function (el) { + return el.runAt - runAt; + }); callbackList.splice(idx, 0, data); scheduleRealCallback(); @@ -93,7 +91,7 @@ export function setTimeout(func: (...params: any[]) => void, delayMs: number, .. /** * reimplementation of window.clearTimeout, which mirrors setTimeout * - * @param {Number} key result from an earlier setTimeout call + * @param key - result from an earlier setTimeout call */ export function clearTimeout(key: number): void { if (callbackList.length === 0) { diff --git a/src/rendezvous/MSC3906Rendezvous.ts b/src/rendezvous/MSC3906Rendezvous.ts index a93d4c476ac..f431c8358d0 100644 --- a/src/rendezvous/MSC3906Rendezvous.ts +++ b/src/rendezvous/MSC3906Rendezvous.ts @@ -25,17 +25,17 @@ import { logger } from "../logger"; import { sleep } from "../utils"; enum PayloadType { - Start = 'm.login.start', - Finish = 'm.login.finish', - Progress = 'm.login.progress', + Start = "m.login.start", + Finish = "m.login.finish", + Progress = "m.login.progress", } enum Outcome { - Success = 'success', - Failure = 'failure', - Verified = 'verified', - Declined = 'declined', - Unsupported = 'unsupported', + Success = "success", + Failure = "failure", + Verified = "verified", + Declined = "declined", + Unsupported = "unsupported", } export interface MSC3906RendezvousPayload { @@ -67,9 +67,9 @@ export class MSC3906Rendezvous { private _code?: string; /** - * @param channel The secure channel used for communication - * @param client The Matrix client in used on the device already logged in - * @param onFailure Callback for when the rendezvous fails + * @param channel - The secure channel used for communication + * @param client - The Matrix client in used on the device already logged in + * @param onFailure - Callback for when the rendezvous fails */ public constructor( private channel: RendezvousChannel, @@ -111,13 +111,13 @@ export class MSC3906Rendezvous { await this.send({ type: PayloadType.Progress, protocols: [LOGIN_TOKEN_PROTOCOL.name] }); - logger.info('Waiting for other device to chose protocol'); + logger.info("Waiting for other device to chose protocol"); const { type, protocol, outcome } = await this.receive(); if (type === PayloadType.Finish) { // new device decided not to complete - switch (outcome ?? '') { - case 'unsupported': + switch (outcome ?? "") { + case "unsupported": await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm); break; default: @@ -140,7 +140,7 @@ export class MSC3906Rendezvous { } private async receive(): Promise { - return await this.channel.receive() as MSC3906RendezvousPayload; + return (await this.channel.receive()) as MSC3906RendezvousPayload; } private async send(payload: MSC3906RendezvousPayload): Promise { @@ -148,7 +148,7 @@ export class MSC3906Rendezvous { } public async declineLoginOnExistingDevice(): Promise { - logger.info('User declined sign in'); + logger.info("User declined sign in"); await this.send({ type: PayloadType.Finish, outcome: Outcome.Declined }); } @@ -156,15 +156,15 @@ export class MSC3906Rendezvous { // eslint-disable-next-line camelcase await this.send({ type: PayloadType.Progress, login_token: loginToken, homeserver: this.client.baseUrl }); - logger.info('Waiting for outcome'); + logger.info("Waiting for outcome"); const res = await this.receive(); if (!res) { return undefined; } const { outcome, device_id: deviceId, device_key: deviceKey } = res; - if (outcome !== 'success') { - throw new Error('Linking failed'); + if (outcome !== "success") { + throw new Error("Linking failed"); } this.newDeviceId = deviceId; @@ -175,11 +175,11 @@ export class MSC3906Rendezvous { private async verifyAndCrossSignDevice(deviceInfo: DeviceInfo): Promise { if (!this.client.crypto) { - throw new Error('Crypto not available on client'); + throw new Error("Crypto not available on client"); } if (!this.newDeviceId) { - throw new Error('No new device ID set'); + throw new Error("No new device ID set"); } // check that keys received from the server for the new device match those received from the device itself @@ -192,17 +192,13 @@ export class MSC3906Rendezvous { const userId = this.client.getUserId(); if (!userId) { - throw new Error('No user ID set'); + throw new Error("No user ID set"); } // mark the device as verified locally + cross sign logger.info(`Marking device ${this.newDeviceId} as verified`); - const info = await this.client.crypto.setDeviceVerification( - userId, - this.newDeviceId, - true, false, true, - ); + const info = await this.client.crypto.setDeviceVerification(userId, this.newDeviceId, true, false, true); - const masterPublicKey = this.client.crypto.crossSigningInfo.getId('master')!; + const masterPublicKey = this.client.crypto.crossSigningInfo.getId("master")!; await this.send({ type: PayloadType.Finish, @@ -217,14 +213,14 @@ export class MSC3906Rendezvous { /** * Verify the device and cross-sign it. - * @param timeout time in milliseconds to wait for device to come online + * @param timeout - time in milliseconds to wait for device to come online * @returns the new device info if the device was verified */ public async verifyNewDeviceOnExistingDevice( timeout = 10 * 1000, ): Promise { if (!this.newDeviceId) { - throw new Error('No new device to sign'); + throw new Error("No new device to sign"); } if (!this.newDeviceKey) { @@ -233,13 +229,13 @@ export class MSC3906Rendezvous { } if (!this.client.crypto) { - throw new Error('Crypto not available on client'); + throw new Error("Crypto not available on client"); } const userId = this.client.getUserId(); if (!userId) { - throw new Error('No user ID set'); + throw new Error("No user ID set"); } let deviceInfo = this.client.crypto.getStoredDevice(userId, this.newDeviceId); @@ -254,7 +250,7 @@ export class MSC3906Rendezvous { return await this.verifyAndCrossSignDevice(deviceInfo); } - throw new Error('Device not online within timeout'); + throw new Error("Device not online within timeout"); } public async cancel(reason: RendezvousFailureReason): Promise { diff --git a/src/rendezvous/RendezvousChannel.ts b/src/rendezvous/RendezvousChannel.ts index 9b1060304c9..549ebc83f51 100644 --- a/src/rendezvous/RendezvousChannel.ts +++ b/src/rendezvous/RendezvousChannel.ts @@ -14,11 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { - RendezvousCode, - RendezvousIntent, - RendezvousFailureReason, -} from "."; +import { RendezvousCode, RendezvousIntent, RendezvousFailureReason } from "."; export interface RendezvousChannel { /** @@ -28,7 +24,7 @@ export interface RendezvousChannel { /** * Send a payload via the channel. - * @param data payload to send + * @param data - payload to send */ send(data: T): Promise; diff --git a/src/rendezvous/RendezvousFailureReason.ts b/src/rendezvous/RendezvousFailureReason.ts index c23168dd154..b19a91cec0b 100644 --- a/src/rendezvous/RendezvousFailureReason.ts +++ b/src/rendezvous/RendezvousFailureReason.ts @@ -17,15 +17,15 @@ limitations under the License. export type RendezvousFailureListener = (reason: RendezvousFailureReason) => void; export enum RendezvousFailureReason { - UserDeclined = 'user_declined', - OtherDeviceNotSignedIn = 'other_device_not_signed_in', - OtherDeviceAlreadySignedIn = 'other_device_already_signed_in', - Unknown = 'unknown', - Expired = 'expired', - UserCancelled = 'user_cancelled', - InvalidCode = 'invalid_code', - UnsupportedAlgorithm = 'unsupported_algorithm', - DataMismatch = 'data_mismatch', - UnsupportedTransport = 'unsupported_transport', - HomeserverLacksSupport = 'homeserver_lacks_support', + UserDeclined = "user_declined", + OtherDeviceNotSignedIn = "other_device_not_signed_in", + OtherDeviceAlreadySignedIn = "other_device_already_signed_in", + Unknown = "unknown", + Expired = "expired", + UserCancelled = "user_cancelled", + InvalidCode = "invalid_code", + UnsupportedAlgorithm = "unsupported_algorithm", + DataMismatch = "data_mismatch", + UnsupportedTransport = "unsupported_transport", + HomeserverLacksSupport = "homeserver_lacks_support", } diff --git a/src/rendezvous/RendezvousIntent.ts b/src/rendezvous/RendezvousIntent.ts index 98b64ecb0ad..db53ef9cb86 100644 --- a/src/rendezvous/RendezvousIntent.ts +++ b/src/rendezvous/RendezvousIntent.ts @@ -15,6 +15,6 @@ limitations under the License. */ export enum RendezvousIntent { - LOGIN_ON_NEW_DEVICE = "login.start", - RECIPROCATE_LOGIN_ON_EXISTING_DEVICE = "login.reciprocate", + LOGIN_ON_NEW_DEVICE = "login.start", + RECIPROCATE_LOGIN_ON_EXISTING_DEVICE = "login.reciprocate", } diff --git a/src/rendezvous/RendezvousTransport.ts b/src/rendezvous/RendezvousTransport.ts index 231a0f1c48a..08905be65e8 100644 --- a/src/rendezvous/RendezvousTransport.ts +++ b/src/rendezvous/RendezvousTransport.ts @@ -17,42 +17,42 @@ limitations under the License. import { RendezvousFailureListener, RendezvousFailureReason } from "."; export interface RendezvousTransportDetails { - type: string; + type: string; } /** * Interface representing a generic rendezvous transport. */ export interface RendezvousTransport { - /** - * Ready state of the transport. This is set to true when the transport is ready to be used. - */ - readonly ready: boolean; - - /** - * Listener for cancellation events. This is called when the rendezvous is cancelled or fails. - */ - onFailure?: RendezvousFailureListener; - - /** - * @returns the transport details that can be encoded in a QR or similar - */ - details(): Promise; - - /** - * Send data via the transport. - * @param data the data itself - */ - send(data: T): Promise; - - /** - * Receive data from the transport. - */ - receive(): Promise | undefined>; - - /** - * Cancel the rendezvous. This will call `onCancelled()` if it is set. - * @param reason the reason for the cancellation/failure - */ - cancel(reason: RendezvousFailureReason): Promise; + /** + * Ready state of the transport. This is set to true when the transport is ready to be used. + */ + readonly ready: boolean; + + /** + * Listener for cancellation events. This is called when the rendezvous is cancelled or fails. + */ + onFailure?: RendezvousFailureListener; + + /** + * @returns the transport details that can be encoded in a QR or similar + */ + details(): Promise; + + /** + * Send data via the transport. + * @param data - the data itself + */ + send(data: T): Promise; + + /** + * Receive data from the transport. + */ + receive(): Promise | undefined>; + + /** + * Cancel the rendezvous. This will call `onCancelled()` if it is set. + * @param reason - the reason for the cancellation/failure + */ + cancel(reason: RendezvousFailureReason): Promise; } diff --git a/src/rendezvous/channels/MSC3903ECDHv1RendezvousChannel.ts b/src/rendezvous/channels/MSC3903ECDHv1RendezvousChannel.ts index d3d5fc6807b..24ebcbe4c2e 100644 --- a/src/rendezvous/channels/MSC3903ECDHv1RendezvousChannel.ts +++ b/src/rendezvous/channels/MSC3903ECDHv1RendezvousChannel.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { SAS } from '@matrix-org/olm'; +import { SAS } from "@matrix-org/olm"; import { RendezvousError, @@ -24,11 +24,11 @@ import { RendezvousTransportDetails, RendezvousTransport, RendezvousFailureReason, -} from '..'; -import { encodeBase64, decodeBase64 } from '../../crypto/olmlib'; -import { crypto, subtleCrypto, TextEncoder } from '../../crypto/crypto'; -import { generateDecimalSas } from '../../crypto/verification/SASDecimal'; -import { UnstableValue } from '../../NamespacedValue'; +} from ".."; +import { encodeBase64, decodeBase64 } from "../../crypto/olmlib"; +import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto"; +import { generateDecimalSas } from "../../crypto/verification/SASDecimal"; +import { UnstableValue } from "../../NamespacedValue"; const ECDH_V1 = new UnstableValue( "m.rendezvous.v1.curve25519-aes-sha256", @@ -57,16 +57,10 @@ export interface EncryptedPayload { async function importKey(key: Uint8Array): Promise { if (!subtleCrypto) { - throw new Error('Web Crypto is not available'); + throw new Error("Web Crypto is not available"); } - const imported = subtleCrypto.importKey( - 'raw', - key, - { name: 'AES-GCM' }, - false, - ['encrypt', 'decrypt'], - ); + const imported = subtleCrypto.importKey("raw", key, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); return imported; } @@ -93,13 +87,13 @@ export class MSC3903ECDHv1RendezvousChannel implements RendezvousChannel { public async generateCode(intent: RendezvousIntent): Promise { if (this.transport.ready) { - throw new Error('Code already generated'); + throw new Error("Code already generated"); } await this.transport.send({ algorithm: ECDH_V1.name }); const rendezvous: ECDHv1RendezvousCode = { - "rendezvous": { + rendezvous: { algorithm: ECDH_V1.name, key: encodeBase64(this.ourPublicKey), transport: await this.transport.details(), @@ -112,11 +106,11 @@ export class MSC3903ECDHv1RendezvousChannel implements RendezvousChannel { public async connect(): Promise { if (this.connected) { - throw new Error('Channel already connected'); + throw new Error("Channel already connected"); } if (!this.olmSAS) { - throw new Error('Channel closed'); + throw new Error("Channel closed"); } const isInitiator = !this.theirPublicKey; @@ -125,13 +119,13 @@ export class MSC3903ECDHv1RendezvousChannel implements RendezvousChannel { // wait for the other side to send us their public key const rawRes = await this.transport.receive(); if (!rawRes) { - throw new Error('No response from other device'); + throw new Error("No response from other device"); } const res = rawRes as Partial; const { key, algorithm } = res; if (!algorithm || !ECDH_V1.matches(algorithm) || !key) { throw new RendezvousError( - 'Unsupported algorithm: ' + algorithm, + "Unsupported algorithm: " + algorithm, RendezvousFailureReason.UnsupportedAlgorithm, ); } @@ -163,12 +157,12 @@ export class MSC3903ECDHv1RendezvousChannel implements RendezvousChannel { aesKeyBytes.fill(0); const rawChecksum = this.olmSAS.generate_bytes(aesInfo, 5); - return generateDecimalSas(Array.from(rawChecksum)).join('-'); + return generateDecimalSas(Array.from(rawChecksum)).join("-"); } private async encrypt(data: T): Promise { if (!subtleCrypto) { - throw new Error('Web Crypto is not available'); + throw new Error("Web Crypto is not available"); } const iv = new Uint8Array(32); @@ -194,25 +188,25 @@ export class MSC3903ECDHv1RendezvousChannel implements RendezvousChannel { public async send(payload: T): Promise { if (!this.olmSAS) { - throw new Error('Channel closed'); + throw new Error("Channel closed"); } if (!this.aesKey) { - throw new Error('Shared secret not set up'); + throw new Error("Shared secret not set up"); } - return this.transport.send((await this.encrypt(payload))); + return this.transport.send(await this.encrypt(payload)); } private async decrypt({ iv, ciphertext }: EncryptedPayload): Promise> { if (!ciphertext || !iv) { - throw new Error('Missing ciphertext and/or iv'); + throw new Error("Missing ciphertext and/or iv"); } const ciphertextBytes = decodeBase64(ciphertext); if (!subtleCrypto) { - throw new Error('Web Crypto is not available'); + throw new Error("Web Crypto is not available"); } const plaintext = await subtleCrypto.decrypt( @@ -230,10 +224,10 @@ export class MSC3903ECDHv1RendezvousChannel implements RendezvousChannel { public async receive(): Promise | undefined> { if (!this.olmSAS) { - throw new Error('Channel closed'); + throw new Error("Channel closed"); } if (!this.aesKey) { - throw new Error('Shared secret not set up'); + throw new Error("Shared secret not set up"); } const rawData = await this.transport.receive(); @@ -245,7 +239,7 @@ export class MSC3903ECDHv1RendezvousChannel implements RendezvousChannel { return this.decrypt(data as EncryptedPayload); } - throw new Error('Data received but no ciphertext'); + throw new Error("Data received but no ciphertext"); } public async close(): Promise { diff --git a/src/rendezvous/channels/index.ts b/src/rendezvous/channels/index.ts index f1139c0629a..072a1014694 100644 --- a/src/rendezvous/channels/index.ts +++ b/src/rendezvous/channels/index.ts @@ -14,5 +14,4 @@ See the License for the specific language governing permissions and limitations under the License. */ -export * from './MSC3903ECDHv1RendezvousChannel'; - +export * from "./MSC3903ECDHv1RendezvousChannel"; diff --git a/src/rendezvous/index.ts b/src/rendezvous/index.ts index 7e506b45052..379b13351b8 100644 --- a/src/rendezvous/index.ts +++ b/src/rendezvous/index.ts @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -export * from './MSC3906Rendezvous'; -export * from './RendezvousChannel'; -export * from './RendezvousCode'; -export * from './RendezvousError'; -export * from './RendezvousFailureReason'; -export * from './RendezvousIntent'; -export * from './RendezvousTransport'; +export * from "./MSC3906Rendezvous"; +export * from "./RendezvousChannel"; +export * from "./RendezvousCode"; +export * from "./RendezvousError"; +export * from "./RendezvousFailureReason"; +export * from "./RendezvousIntent"; +export * from "./RendezvousTransport"; diff --git a/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts b/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts index cffbefe5a26..430ee92d1c7 100644 --- a/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts +++ b/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts @@ -14,18 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { UnstableValue } from 'matrix-events-sdk'; +import { UnstableValue } from "matrix-events-sdk"; -import { logger } from '../../logger'; -import { sleep } from '../../utils'; +import { logger } from "../../logger"; +import { sleep } from "../../utils"; import { RendezvousFailureListener, RendezvousFailureReason, RendezvousTransport, RendezvousTransportDetails, -} from '..'; -import { MatrixClient } from '../../matrix'; -import { ClientPrefix } from '../../http-api'; +} from ".."; +import { MatrixClient } from "../../matrix"; +import { ClientPrefix } from "../../http-api"; const TYPE = new UnstableValue("http.v1", "org.matrix.msc3886.http.v1"); @@ -72,7 +72,7 @@ export class MSC3886SimpleHttpRendezvousTransport implements Rende public async details(): Promise { if (!this.uri) { - throw new Error('Rendezvous not set up'); + throw new Error("Rendezvous not set up"); } return { @@ -90,11 +90,11 @@ export class MSC3886SimpleHttpRendezvousTransport implements Rende private async getPostEndpoint(): Promise { try { - if (await this.client.doesServerSupportUnstableFeature('org.matrix.msc3886')) { + if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc3886")) { return `${this.client.baseUrl}${ClientPrefix.Unstable}/org.matrix.msc3886/rendezvous`; } } catch (err) { - logger.warn('Failed to get unstable features', err); + logger.warn("Failed to get unstable features", err); } return this.fallbackRzServer; @@ -105,32 +105,29 @@ export class MSC3886SimpleHttpRendezvousTransport implements Rende return; } const method = this.uri ? "PUT" : "POST"; - const uri = this.uri ?? await this.getPostEndpoint(); + const uri = this.uri ?? (await this.getPostEndpoint()); if (!uri) { - throw new Error('Invalid rendezvous URI'); + throw new Error("Invalid rendezvous URI"); } - const headers: Record = { 'content-type': 'application/json' }; + const headers: Record = { "content-type": "application/json" }; if (this.etag) { - headers['if-match'] = this.etag; + headers["if-match"] = this.etag; } - const res = await this.fetch(uri, { method, - headers, - body: JSON.stringify(data), - }); + const res = await this.fetch(uri, { method, headers, body: JSON.stringify(data) }); if (res.status === 404) { return this.cancel(RendezvousFailureReason.Unknown); } this.etag = res.headers.get("etag") ?? undefined; - if (method === 'POST') { - const location = res.headers.get('location'); + if (method === "POST") { + const location = res.headers.get("location"); if (!location) { - throw new Error('No rendezvous URI given'); + throw new Error("No rendezvous URI given"); } - const expires = res.headers.get('expires'); + const expires = res.headers.get("expires"); if (expires) { this.expiresAt = new Date(expires); } @@ -138,14 +135,14 @@ export class MSC3886SimpleHttpRendezvousTransport implements Rende // however, if a polyfill based on XHR is used it won't be set, we we use existing URI as fallback const baseUrl = res.url ?? uri; // resolve location header which could be relative or absolute - this.uri = new URL(location, `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}`).href; + this.uri = new URL(location, `${baseUrl}${baseUrl.endsWith("/") ? "" : "/"}`).href; this._ready = true; } } public async receive(): Promise | undefined> { if (!this.uri) { - throw new Error('Rendezvous not set up'); + throw new Error("Rendezvous not set up"); } // eslint-disable-next-line no-constant-condition while (true) { @@ -155,7 +152,7 @@ export class MSC3886SimpleHttpRendezvousTransport implements Rende const headers: Record = {}; if (this.etag) { - headers['if-none-match'] = this.etag; + headers["if-none-match"] = this.etag; } const poll = await this.fetch(this.uri, { method: "GET", headers }); @@ -166,7 +163,7 @@ export class MSC3886SimpleHttpRendezvousTransport implements Rende // rely on server expiring the channel rather than checking ourselves - if (poll.headers.get('content-type') !== 'application/json') { + if (poll.headers.get("content-type") !== "application/json") { this.etag = poll.headers.get("etag") ?? undefined; } else if (poll.status === 200) { this.etag = poll.headers.get("etag") ?? undefined; @@ -177,8 +174,7 @@ export class MSC3886SimpleHttpRendezvousTransport implements Rende } public async cancel(reason: RendezvousFailureReason): Promise { - if (reason === RendezvousFailureReason.Unknown && - this.expiresAt && this.expiresAt.getTime() < Date.now()) { + if (reason === RendezvousFailureReason.Unknown && this.expiresAt && this.expiresAt.getTime() < Date.now()) { reason = RendezvousFailureReason.Expired; } diff --git a/src/rendezvous/transports/index.ts b/src/rendezvous/transports/index.ts index 05594da4543..6d8d64245e4 100644 --- a/src/rendezvous/transports/index.ts +++ b/src/rendezvous/transports/index.ts @@ -14,4 +14,4 @@ See the License for the specific language governing permissions and limitations under the License. */ -export * from './MSC3886SimpleHttpRendezvousTransport'; +export * from "./MSC3886SimpleHttpRendezvousTransport"; diff --git a/src/room-hierarchy.ts b/src/room-hierarchy.ts index c15f2ac56dc..5c0b61d743f 100644 --- a/src/room-hierarchy.ts +++ b/src/room-hierarchy.ts @@ -14,10 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -/** - * @module room-hierarchy - */ - import { Room } from "./models/room"; import { IHierarchyRoom, IHierarchyRelation } from "./@types/spaces"; import { MatrixClient } from "./client"; @@ -41,11 +37,10 @@ export class RoomHierarchy { * * A RoomHierarchy instance allows you to easily make use of the /hierarchy API and paginate it. * - * @param {Room} root the root of this hierarchy - * @param {number} pageSize the maximum number of rooms to return per page, can be overridden per load request. - * @param {number} maxDepth the maximum depth to traverse the hierarchy to - * @param {boolean} suggestedOnly whether to only return rooms with suggested=true. - * @constructor + * @param root - the root of this hierarchy + * @param pageSize - the maximum number of rooms to return per page, can be overridden per load request. + * @param maxDepth - the maximum depth to traverse the hierarchy to + * @param suggestedOnly - whether to only return rooms with suggested=true. */ public constructor( public readonly root: Room, @@ -71,7 +66,7 @@ export class RoomHierarchy { } public async load(pageSize = this.pageSize): Promise { - if (this.loadRequest) return this.loadRequest.then(r => r.rooms); + if (this.loadRequest) return this.loadRequest.then((r) => r.rooms); this.loadRequest = this.root.client.getRoomHierarchy( this.root.roomId, @@ -102,10 +97,10 @@ export class RoomHierarchy { this._rooms = rooms; } - rooms.forEach(room => { + rooms.forEach((room) => { this.roomMap.set(room.room_id, room); - room.children_state.forEach(ev => { + room.children_state.forEach((ev) => { if (ev.type !== EventType.SpaceChild) return; const childRoomId = ev.state_key; @@ -121,7 +116,7 @@ export class RoomHierarchy { this.viaMap.set(childRoomId, new Set()); } const vias = this.viaMap.get(childRoomId)!; - ev.content.via.forEach(via => vias.add(via)); + ev.content.via.forEach((via) => vias.add(via)); } }); }); @@ -130,7 +125,7 @@ export class RoomHierarchy { } public getRelation(parentId: string, childId: string): IHierarchyRelation | undefined { - return this.roomMap.get(parentId)?.children_state.find(e => e.state_key === childId); + return this.roomMap.get(parentId)?.children_state.find((e) => e.state_key === childId); } public isSuggested(parentId: string, childId: string): boolean | undefined { @@ -143,12 +138,15 @@ export class RoomHierarchy { if (backRefs?.length === 1) { this.backRefs.delete(childId); } else if (backRefs?.length) { - this.backRefs.set(childId, backRefs.filter(ref => ref !== parentId)); + this.backRefs.set( + childId, + backRefs.filter((ref) => ref !== parentId), + ); } const room = this.roomMap.get(parentId); if (room) { - room.children_state = room.children_state.filter(ev => ev.state_key !== childId); + room.children_state = room.children_state.filter((ev) => ev.state_key !== childId); } } } diff --git a/src/scheduler.ts b/src/scheduler.ts index 0c15032bcac..16c06665eee 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -17,17 +17,16 @@ limitations under the License. /** * This is an internal module which manages queuing, scheduling and retrying * of requests. - * @module scheduler */ import * as utils from "./utils"; -import { logger } from './logger'; +import { logger } from "./logger"; import { MatrixEvent } from "./models/event"; import { EventType } from "./@types/event"; import { IDeferred } from "./utils"; import { ConnectionError, MatrixError } from "./http-api"; import { ISendEventResponse } from "./@types/requests"; -const DEBUG = false; // set true to enable console logging. +const DEBUG = false; // set true to enable console logging. interface IQueueEntry { event: MatrixEvent; @@ -35,20 +34,13 @@ interface IQueueEntry { attempts: number; } -type ProcessFunction = (event: MatrixEvent) => Promise; - /** - * Construct a scheduler for Matrix. Requires - * {@link module:scheduler~MatrixScheduler#setProcessFunction} to be provided - * with a way of processing events. - * @constructor - * @param {module:scheduler~retryAlgorithm} retryAlgorithm Optional. The retry - * algorithm to apply when determining when to try to send an event again. - * Defaults to {@link module:scheduler~MatrixScheduler.RETRY_BACKOFF_RATELIMIT}. - * @param {module:scheduler~queueAlgorithm} queueAlgorithm Optional. The queuing - * algorithm to apply when determining which events should be sent before the - * given event. Defaults to {@link module:scheduler~MatrixScheduler.QUEUE_MESSAGES}. + * The function to invoke to process (send) events in the queue. + * @param event - The event to send. + * @returns Resolved/rejected depending on the outcome of the request. */ +type ProcessFunction = (event: MatrixEvent) => Promise; + // eslint-disable-next-line camelcase export class MatrixScheduler { /** @@ -56,11 +48,8 @@ export class MatrixScheduler { * times of 2, 4, 8, and 16 seconds (30s total) after which we give up. If the * failure was due to a rate limited request, the time specified in the error is * waited before being retried. - * @param {MatrixEvent} event - * @param {Number} attempts Number of attempts that have been made, including the one that just failed (ie. starting at 1) - * @param {MatrixError} err - * @return {Number} - * @see module:scheduler~retryAlgorithm + * @param attempts - Number of attempts that have been made, including the one that just failed (ie. starting at 1) + * @see retryAlgorithm */ // eslint-disable-next-line @typescript-eslint/naming-convention public static RETRY_BACKOFF_RATELIMIT(event: MatrixEvent | null, attempts: number, err: MatrixError): number { @@ -86,18 +75,16 @@ export class MatrixScheduler { if (attempts > 4) { return -1; // give up } - return (1000 * Math.pow(2, attempts)); + return 1000 * Math.pow(2, attempts); } /** - * Queues m.room.message events and lets other events continue + * Queues `m.room.message` events and lets other events continue * concurrently. - * @param {MatrixEvent} event - * @return {string} - * @see module:scheduler~queueAlgorithm + * @see queueAlgorithm */ // eslint-disable-next-line @typescript-eslint/naming-convention - public static QUEUE_MESSAGES(event: MatrixEvent): "message" | null { + public static QUEUE_MESSAGES(event: MatrixEvent): string | null { // enqueue messages or events that associate with another event (redactions and relations) if (event.getType() === EventType.RoomMessage || event.hasAssociation()) { // put these events in the 'message' queue. @@ -116,16 +103,52 @@ export class MatrixScheduler { private activeQueues: string[] = []; private procFn: ProcessFunction | null = null; + /** + * Construct a scheduler for Matrix. Requires + * {@link MatrixScheduler#setProcessFunction} to be provided + * with a way of processing events. + * @param retryAlgorithm - Optional. The retry + * algorithm to apply when determining when to try to send an event again. + * Defaults to {@link MatrixScheduler.RETRY_BACKOFF_RATELIMIT}. + * @param queueAlgorithm - Optional. The queuing + * algorithm to apply when determining which events should be sent before the + * given event. Defaults to {@link MatrixScheduler.QUEUE_MESSAGES}. + */ public constructor( + /** + * The retry algorithm to apply when retrying events. To stop retrying, return + * `-1`. If this event was part of a queue, it will be removed from + * the queue. + * @param event - The event being retried. + * @param attempts - The number of failed attempts. This will always be \>= 1. + * @param err - The most recent error message received when trying + * to send this event. + * @returns The number of milliseconds to wait before trying again. If + * this is 0, the request will be immediately retried. If this is + * `-1`, the event will be marked as + * {@link EventStatus.NOT_SENT} and will not be retried. + */ public readonly retryAlgorithm = MatrixScheduler.RETRY_BACKOFF_RATELIMIT, + /** + * The queuing algorithm to apply to events. This function must be idempotent as + * it may be called multiple times with the same event. All queues created are + * serviced in a FIFO manner. To send the event ASAP, return `null` + * which will not put this event in a queue. Events that fail to send that form + * part of a queue will be removed from the queue and the next event in the + * queue will be sent. + * @param event - The event to be sent. + * @returns The name of the queue to put the event into. If a queue with + * this name does not exist, it will be created. If this is `null`, + * the event is not put into a queue and will be sent concurrently. + */ public readonly queueAlgorithm = MatrixScheduler.QUEUE_MESSAGES, ) {} /** * Retrieve a queue based on an event. The event provided does not need to be in * the queue. - * @param {MatrixEvent} event An event to get the queue for. - * @return {?Array} A shallow copy of events in the queue or null. + * @param event - An event to get the queue for. + * @returns A shallow copy of events in the queue or null. * Modifying this array will not modify the list itself. Modifying events in * this array will modify the underlying event in the queue. * @see MatrixScheduler.removeEventFromQueue To remove an event from the queue. @@ -135,7 +158,7 @@ export class MatrixScheduler { if (!name || !this.queues[name]) { return null; } - return this.queues[name].map(function(obj) { + return this.queues[name].map(function (obj) { return obj.event; }); } @@ -143,8 +166,8 @@ export class MatrixScheduler { /** * Remove this event from the queue. The event is equal to another event if they * have the same ID returned from event.getId(). - * @param {MatrixEvent} event The event to remove. - * @return {boolean} True if this event was removed. + * @param event - The event to remove. + * @returns True if this event was removed. */ public removeEventFromQueue(event: MatrixEvent): boolean { const name = this.queueAlgorithm(event); @@ -168,7 +191,7 @@ export class MatrixScheduler { * Set the process function. Required for events in the queue to be processed. * If set after events have been added to the queue, this will immediately start * processing them. - * @param {module:scheduler~processFn} fn The function that can process events + * @param fn - The function that can process events * in the queue. */ public setProcessFunction(fn: ProcessFunction): void { @@ -178,8 +201,8 @@ export class MatrixScheduler { /** * Queue an event if it is required and start processing queues. - * @param {MatrixEvent} event The event that may be queued. - * @return {?Promise} A promise if the event was queued, which will be + * @param event - The event that may be queued. + * @returns A promise if the event was queued, which will be * resolved or rejected in due time, else null. */ public queueEvent(event: MatrixEvent): Promise | null { @@ -207,8 +230,7 @@ export class MatrixScheduler { // for each inactive queue with events in them Object.keys(this.queues) .filter((queueName) => { - return this.activeQueues.indexOf(queueName) === -1 && - this.queues[queueName].length > 0; + return this.activeQueues.indexOf(queueName) === -1 && this.queues[queueName].length > 0; }) .forEach((queueName) => { // mark the queue as active @@ -239,31 +261,43 @@ export class MatrixScheduler { // the deferred of the previously sent event can run. // This way enqueued relations/redactions to enqueued events can receive // the remove id of their target before being sent. - Promise.resolve().then(() => { - return this.procFn!(obj.event); - }).then((res) => { - // remove this from the queue - this.removeNextEvent(queueName); - debuglog("Queue '%s' sent event %s", queueName, obj.event.getId()); - obj.defer.resolve(res); - // keep processing - this.processQueue(queueName); - }, (err) => { - obj.attempts += 1; - // ask the retry algorithm when/if we should try again - const waitTimeMs = this.retryAlgorithm(obj.event, obj.attempts, err); - debuglog("retry(%s) err=%s event_id=%s waitTime=%s", obj.attempts, err, obj.event.getId(), waitTimeMs); - if (waitTimeMs === -1) { // give up (you quitter!) - debuglog("Queue '%s' giving up on event %s", queueName, obj.event.getId()); - // remove this from the queue - this.removeNextEvent(queueName); - obj.defer.reject(err); - // process next event - this.processQueue(queueName); - } else { - setTimeout(this.processQueue, waitTimeMs, queueName); - } - }); + Promise.resolve() + .then(() => { + return this.procFn!(obj.event); + }) + .then( + (res) => { + // remove this from the queue + this.removeNextEvent(queueName); + debuglog("Queue '%s' sent event %s", queueName, obj.event.getId()); + obj.defer.resolve(res); + // keep processing + this.processQueue(queueName); + }, + (err) => { + obj.attempts += 1; + // ask the retry algorithm when/if we should try again + const waitTimeMs = this.retryAlgorithm(obj.event, obj.attempts, err); + debuglog( + "retry(%s) err=%s event_id=%s waitTime=%s", + obj.attempts, + err, + obj.event.getId(), + waitTimeMs, + ); + if (waitTimeMs === -1) { + // give up (you quitter!) + debuglog("Queue '%s' giving up on event %s", queueName, obj.event.getId()); + // remove this from the queue + this.removeNextEvent(queueName); + obj.defer.reject(err); + // process next event + this.processQueue(queueName); + } else { + setTimeout(this.processQueue, waitTimeMs, queueName); + } + }, + ); }; private peekNextEvent(queueName: string): IQueueEntry | undefined { @@ -283,46 +317,9 @@ export class MatrixScheduler { } } +/* istanbul ignore next */ function debuglog(...args: any[]): void { if (DEBUG) { logger.log(...args); } } - -/** - * The retry algorithm to apply when retrying events. To stop retrying, return - * -1. If this event was part of a queue, it will be removed from - * the queue. - * @callback retryAlgorithm - * @param {MatrixEvent} event The event being retried. - * @param {Number} attempts The number of failed attempts. This will always be - * >= 1. - * @param {MatrixError} err The most recent error message received when trying - * to send this event. - * @return {Number} The number of milliseconds to wait before trying again. If - * this is 0, the request will be immediately retried. If this is - * -1, the event will be marked as - * {@link module:models/event.EventStatus.NOT_SENT} and will not be retried. - */ - -/** - * The queuing algorithm to apply to events. This function must be idempotent as - * it may be called multiple times with the same event. All queues created are - * serviced in a FIFO manner. To send the event ASAP, return null - * which will not put this event in a queue. Events that fail to send that form - * part of a queue will be removed from the queue and the next event in the - * queue will be sent. - * @callback queueAlgorithm - * @param {MatrixEvent} event The event to be sent. - * @return {string} The name of the queue to put the event into. If a queue with - * this name does not exist, it will be created. If this is null, - * the event is not put into a queue and will be sent concurrently. - */ - -/** - * The function to invoke to process (send) events in the queue. - * @callback processFn - * @param {MatrixEvent} event The event to send. - * @return {Promise} Resolved/rejected depending on the outcome of the request. - */ - diff --git a/src/service-types.ts b/src/service-types.ts index 80eeed74e88..3ed08bb27b0 100644 --- a/src/service-types.ts +++ b/src/service-types.ts @@ -15,6 +15,6 @@ limitations under the License. */ export enum SERVICE_TYPES { - IS = 'SERVICE_TYPE_IS', // An identity server - IM = 'SERVICE_TYPE_IM', // An integration manager + IS = "SERVICE_TYPE_IS", // An identity server + IM = "SERVICE_TYPE_IM", // An integration manager } diff --git a/src/sliding-sync-sdk.ts b/src/sliding-sync-sdk.ts index a787b4e3465..c6d5f98a518 100644 --- a/src/sliding-sync-sdk.ts +++ b/src/sliding-sync-sdk.ts @@ -15,14 +15,14 @@ limitations under the License. */ import { NotificationCountType, Room, RoomEvent } from "./models/room"; -import { logger } from './logger'; +import { logger } from "./logger"; import * as utils from "./utils"; import { EventTimeline } from "./models/event-timeline"; import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering } from "./client"; import { ISyncStateData, SyncState, _createAndReEmitRoom } from "./sync"; import { MatrixEvent } from "./models/event"; import { Crypto } from "./crypto"; -import { IMinimalEvent, IRoomEvent, IStateEvent, IStrippedState } from "./sync-accumulator"; +import { IMinimalEvent, IRoomEvent, IStateEvent, IStrippedState, ISyncResponse } from "./sync-accumulator"; import { MatrixError } from "./http-api"; import { Extension, @@ -44,7 +44,19 @@ import { RoomMemberEvent } from "./models/room-member"; // keepAlive is successful but the server /sync fails. const FAILED_SYNC_ERROR_THRESHOLD = 3; -class ExtensionE2EE implements Extension { +type ExtensionE2EERequest = { + enabled: boolean; +}; + +type ExtensionE2EEResponse = Pick< + ISyncResponse, + | "device_lists" + | "device_one_time_keys_count" + | "device_unused_fallback_key_types" + | "org.matrix.msc2732.device_unused_fallback_key_types" +>; + +class ExtensionE2EE implements Extension { public constructor(private readonly crypto: Crypto) {} public name(): string { @@ -55,7 +67,7 @@ class ExtensionE2EE implements Extension { return ExtensionState.PreProcess; } - public onRequest(isInitial: boolean): object | undefined { + public onRequest(isInitial: boolean): ExtensionE2EERequest | undefined { if (!isInitial) { return undefined; } @@ -64,12 +76,15 @@ class ExtensionE2EE implements Extension { }; } - public async onResponse(data: object): Promise { + public async onResponse(data: ExtensionE2EEResponse): Promise { // Handle device list updates if (data["device_lists"]) { - await this.crypto.handleDeviceListChanges({ - oldSyncToken: "yep", // XXX need to do this so the device list changes get processed :( - }, data["device_lists"]); + await this.crypto.handleDeviceListChanges( + { + oldSyncToken: "yep", // XXX need to do this so the device list changes get processed :( + }, + data["device_lists"], + ); } // Handle one_time_keys_count @@ -77,22 +92,31 @@ class ExtensionE2EE implements Extension { const currentCount = data["device_one_time_keys_count"].signed_curve25519 || 0; this.crypto.updateOneTimeKeyCount(currentCount); } - if (data["device_unused_fallback_key_types"] || - data["org.matrix.msc2732.device_unused_fallback_key_types"]) { + if (data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"]) { // The presence of device_unused_fallback_key_types indicates that the // server supports fallback keys. If there's no unused // signed_curve25519 fallback key we need a new one. - const unusedFallbackKeys = data["device_unused_fallback_key_types"] || - data["org.matrix.msc2732.device_unused_fallback_key_types"]; + const unusedFallbackKeys = + data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"]; this.crypto.setNeedsNewFallback( - Array.isArray(unusedFallbackKeys) && - !unusedFallbackKeys.includes("signed_curve25519"), + Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"), ); } } } -class ExtensionToDevice implements Extension { +type ExtensionToDeviceRequest = { + since?: string; + limit?: number; + enabled?: boolean; +}; + +type ExtensionToDeviceResponse = { + events: Required["to_device"]["events"]; + next_batch: string | null; +}; + +class ExtensionToDevice implements Extension { private nextBatch: string | null = null; public constructor(private readonly client: MatrixClient) {} @@ -105,8 +129,8 @@ class ExtensionToDevice implements Extension { return ExtensionState.PreProcess; } - public onRequest(isInitial: boolean): object { - const extReq = { + public onRequest(isInitial: boolean): ExtensionToDeviceRequest { + const extReq: ExtensionToDeviceRequest = { since: this.nextBatch !== null ? this.nextBatch : undefined, }; if (isInitial) { @@ -116,19 +140,19 @@ class ExtensionToDevice implements Extension { return extReq; } - public async onResponse(data: object): Promise { + public async onResponse(data: ExtensionToDeviceResponse): Promise { const cancelledKeyVerificationTxns: string[] = []; - data["events"] = data["events"] || []; - data["events"] - .map(this.client.getEventMapper()) - .map((toDeviceEvent) => { // map is a cheap inline forEach + data.events + ?.map(this.client.getEventMapper()) + .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 // we pull out the transaction IDs from the cancellation events // so we can flag the verification events as cancelled in the loop // below. if (toDeviceEvent.getType() === "m.key.verification.cancel") { - const txnId: string | undefined = toDeviceEvent.getContent()['transaction_id']; + const txnId: string | undefined = toDeviceEvent.getContent()["transaction_id"]; if (txnId) { cancelledKeyVerificationTxns.push(txnId); } @@ -138,38 +162,41 @@ class ExtensionToDevice implements Extension { // the unmodified event. return toDeviceEvent; }) - .forEach( - (toDeviceEvent) => { - const content = toDeviceEvent.getContent(); - if ( - toDeviceEvent.getType() == "m.room.message" && - content.msgtype == "m.bad.encrypted" - ) { - // the mapper already logged a warning. - logger.log( - 'Ignoring undecryptable to-device event from ' + - toDeviceEvent.getSender(), - ); - return; - } + .forEach((toDeviceEvent) => { + const content = toDeviceEvent.getContent(); + if (toDeviceEvent.getType() == "m.room.message" && content.msgtype == "m.bad.encrypted") { + // the mapper already logged a warning. + logger.log("Ignoring undecryptable to-device event from " + toDeviceEvent.getSender()); + return; + } - if (toDeviceEvent.getType() === "m.key.verification.start" - || toDeviceEvent.getType() === "m.key.verification.request") { - const txnId = content['transaction_id']; - if (cancelledKeyVerificationTxns.includes(txnId)) { - toDeviceEvent.flagCancelled(); - } + if ( + toDeviceEvent.getType() === "m.key.verification.start" || + toDeviceEvent.getType() === "m.key.verification.request" + ) { + const txnId = content["transaction_id"]; + if (cancelledKeyVerificationTxns.includes(txnId)) { + toDeviceEvent.flagCancelled(); } + } - this.client.emit(ClientEvent.ToDeviceEvent, toDeviceEvent); - }, - ); + this.client.emit(ClientEvent.ToDeviceEvent, toDeviceEvent); + }); - this.nextBatch = data["next_batch"]; + this.nextBatch = data.next_batch; } } -class ExtensionAccountData implements Extension { +type ExtensionAccountDataRequest = { + enabled: boolean; +}; + +type ExtensionAccountDataResponse = { + global: IMinimalEvent[]; + rooms: Record; +}; + +class ExtensionAccountData implements Extension { public constructor(private readonly client: MatrixClient) {} public name(): string { @@ -180,7 +207,7 @@ class ExtensionAccountData implements Extension { return ExtensionState.PostProcess; } - public onRequest(isInitial: boolean): object | undefined { + public onRequest(isInitial: boolean): ExtensionAccountDataRequest | undefined { if (!isInitial) { return undefined; } @@ -189,7 +216,7 @@ class ExtensionAccountData implements Extension { }; } - public onResponse(data: {global: object[], rooms: Record}): void { + public onResponse(data: ExtensionAccountDataResponse): void { if (data.global && data.global.length > 0) { this.processGlobalAccountData(data.global); } @@ -208,32 +235,38 @@ class ExtensionAccountData implements Extension { } } - private processGlobalAccountData(globalAccountData: object[]): void { + private processGlobalAccountData(globalAccountData: IMinimalEvent[]): void { const events = mapEvents(this.client, undefined, globalAccountData); - const prevEventsMap = events.reduce((m, c) => { + const prevEventsMap = events.reduce>((m, c) => { m[c.getType()] = this.client.store.getAccountData(c.getType()); return m; }, {}); this.client.store.storeAccountDataEvents(events); - events.forEach( - (accountDataEvent) => { - // Honour push rules that come down the sync stream but also - // honour push rules that were previously cached. Base rules - // will be updated when we receive push rules via getPushRules - // (see sync) before syncing over the network. - if (accountDataEvent.getType() === EventType.PushRules) { - const rules = accountDataEvent.getContent(); - this.client.pushRules = PushProcessor.rewriteDefaultRules(rules); - } - const prevEvent = prevEventsMap[accountDataEvent.getType()]; - this.client.emit(ClientEvent.AccountData, accountDataEvent, prevEvent); - return accountDataEvent; - }, - ); + events.forEach((accountDataEvent) => { + // Honour push rules that come down the sync stream but also + // honour push rules that were previously cached. Base rules + // will be updated when we receive push rules via getPushRules + // (see sync) before syncing over the network. + if (accountDataEvent.getType() === EventType.PushRules) { + const rules = accountDataEvent.getContent(); + this.client.pushRules = PushProcessor.rewriteDefaultRules(rules); + } + const prevEvent = prevEventsMap[accountDataEvent.getType()]; + this.client.emit(ClientEvent.AccountData, accountDataEvent, prevEvent); + return accountDataEvent; + }); } } -class ExtensionTyping implements Extension { +type ExtensionTypingRequest = { + enabled: boolean; +}; + +type ExtensionTypingResponse = { + rooms: Record; +}; + +class ExtensionTyping implements Extension { public constructor(private readonly client: MatrixClient) {} public name(): string { @@ -244,7 +277,7 @@ class ExtensionTyping implements Extension { return ExtensionState.PostProcess; } - public onRequest(isInitial: boolean): object | undefined { + public onRequest(isInitial: boolean): ExtensionTypingRequest | undefined { if (!isInitial) { return undefined; // don't send a JSON object for subsequent requests, we don't need to. } @@ -253,20 +286,26 @@ class ExtensionTyping implements Extension { }; } - public onResponse(data: {rooms: Record}): void { - if (!data || !data.rooms) { + public onResponse(data: ExtensionTypingResponse): void { + if (!data?.rooms) { return; } for (const roomId in data.rooms) { - processEphemeralEvents( - this.client, roomId, [data.rooms[roomId]], - ); + processEphemeralEvents(this.client, roomId, [data.rooms[roomId]]); } } } -class ExtensionReceipts implements Extension { +type ExtensionReceiptsRequest = { + enabled: boolean; +}; + +type ExtensionReceiptsResponse = { + rooms: Record; +}; + +class ExtensionReceipts implements Extension { public constructor(private readonly client: MatrixClient) {} public name(): string { @@ -277,7 +316,7 @@ class ExtensionReceipts implements Extension { return ExtensionState.PostProcess; } - public onRequest(isInitial: boolean): object | undefined { + public onRequest(isInitial: boolean): ExtensionReceiptsRequest | undefined { if (isInitial) { return { enabled: true, @@ -286,15 +325,13 @@ class ExtensionReceipts implements Extension { return undefined; // don't send a JSON object for subsequent requests, we don't need to. } - public onResponse(data: {rooms: Record}): void { - if (!data || !data.rooms) { + public onResponse(data: ExtensionReceiptsResponse): void { + if (!data?.rooms) { return; } for (const roomId in data.rooms) { - processEphemeralEvents( - this.client, roomId, [data.rooms[roomId]], - ); + processEphemeralEvents(this.client, roomId, [data.rooms[roomId]]); } } } @@ -317,7 +354,7 @@ export class SlidingSyncSdk { ) { this.opts.initialSyncLimit = this.opts.initialSyncLimit ?? 8; this.opts.resolveInvitesToProfiles = this.opts.resolveInvitesToProfiles || false; - this.opts.pollTimeout = this.opts.pollTimeout || (30 * 1000); + this.opts.pollTimeout = this.opts.pollTimeout || 30 * 1000; this.opts.pendingEventOrdering = this.opts.pendingEventOrdering || PendingEventOrdering.Chronological; this.opts.experimentalThreadSupport = this.opts.experimentalThreadSupport === true; @@ -328,24 +365,19 @@ export class SlidingSyncSdk { } if (client.getNotifTimelineSet()) { - client.reEmitter.reEmit(client.getNotifTimelineSet()!, [ - RoomEvent.Timeline, - RoomEvent.TimelineReset, - ]); + client.reEmitter.reEmit(client.getNotifTimelineSet()!, [RoomEvent.Timeline, RoomEvent.TimelineReset]); } this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle.bind(this)); this.slidingSync.on(SlidingSyncEvent.RoomData, this.onRoomData.bind(this)); - const extensions: Extension[] = [ + const extensions: Extension[] = [ new ExtensionToDevice(this.client), new ExtensionAccountData(this.client), new ExtensionTyping(this.client), new ExtensionReceipts(this.client), ]; if (this.opts.crypto) { - extensions.push( - new ExtensionE2EE(this.opts.crypto), - ); + extensions.push(new ExtensionE2EE(this.opts.crypto)); } extensions.forEach((ext) => { this.slidingSync.registerExtension(ext); @@ -414,7 +446,7 @@ export class SlidingSyncSdk { /** * Sync rooms the user has left. - * @return {Promise} Resolved when they've been added to the store. + * @returns Resolved when they've been added to the store. */ public async syncLeftRooms(): Promise { return []; // TODO @@ -423,8 +455,8 @@ export class SlidingSyncSdk { /** * Peek into a room. This will result in the room in question being synced so it * is accessible via getRooms(). Live updates for the room will be provided. - * @param {string} roomId The room ID to peek into. - * @return {Promise} A promise which resolves once the room has been added to the + * @param roomId - The room ID to peek into. + * @returns A promise which resolves once the room has been added to the * store. */ public async peek(_roomId: string): Promise { @@ -441,8 +473,7 @@ export class SlidingSyncSdk { /** * Returns the current state of this sync object - * @see module:client~MatrixClient#event:"sync" - * @return {?String} + * @see MatrixClient#event:"sync" */ public getSyncState(): SyncState | null { return this.syncState; @@ -454,7 +485,6 @@ export class SlidingSyncSdk { * such data. * Sync errors, if available, are put in the 'error' key of * this object. - * @return {?Object} */ public getSyncStateData(): ISyncStateData | null { return this.syncStateData ?? null; @@ -462,7 +492,8 @@ export class SlidingSyncSdk { // Helper functions which set up JS SDK structs are below and are identical to the sync v2 counterparts - public createRoom(roomId: string): Room { // XXX cargoculted from sync.ts + public createRoom(roomId: string): Room { + // XXX cargoculted from sync.ts const { timelineSupport } = this.client; const room = new Room(roomId, this.client, this.client.getUserId()!, { lazyLoadMembers: this.opts.lazyLoadMembers, @@ -485,7 +516,8 @@ export class SlidingSyncSdk { return room; } - private registerStateListeners(room: Room): void { // XXX cargoculted from sync.ts + private registerStateListeners(room: Room): void { + // XXX cargoculted from sync.ts // we need to also re-emit room state and room member events, so hook it up // to the client now. We need to add a listener for RoomState.members in // order to hook them correctly. @@ -533,7 +565,7 @@ export class SlidingSyncSdk { // room::decryptCriticalEvent is in charge of decrypting all the events // required for a client to function properly let timelineEvents = mapEvents(this.client, room.roomId, roomData.timeline, false); - const ephemeralEvents = []; // TODO this.mapSyncEventsFormat(joinObj.ephemeral); + const ephemeralEvents: MatrixEvent[] = []; // TODO this.mapSyncEventsFormat(joinObj.ephemeral); // TODO: handle threaded / beacon events @@ -542,9 +574,11 @@ export class SlidingSyncSdk { // If we do, then we've effectively done scrollback (e.g requesting timeline_limit: 1 for // this room, then timeline_limit: 50). const knownEvents = new Set(); - room.getLiveTimeline().getEvents().forEach((e) => { - knownEvents.add(e.getId()!); - }); + room.getLiveTimeline() + .getEvents() + .forEach((e) => { + knownEvents.add(e.getId()!); + }); // all unknown events BEFORE a known event must be scrollback e.g: // D E <-- what we know // A B C D E F <-- what we just received @@ -556,7 +590,7 @@ export class SlidingSyncSdk { const oldEvents: MatrixEvent[] = []; const newEvents: MatrixEvent[] = []; let seenKnownEvent = false; - for (let i = timelineEvents.length-1; i >= 0; i--) { + for (let i = timelineEvents.length - 1; i >= 0; i--) { const recvEvent = timelineEvents[i]; if (knownEvents.has(recvEvent.getId()!)) { seenKnownEvent = true; @@ -580,10 +614,7 @@ export class SlidingSyncSdk { const encrypted = this.client.isRoomEncrypted(room.roomId); // we do this first so it's correct when any of the events fire if (roomData.notification_count != null) { - room.setUnreadNotificationCount( - NotificationCountType.Total, - roomData.notification_count, - ); + room.setUnreadNotificationCount(NotificationCountType.Total, roomData.notification_count); } if (roomData.highlight_count != null) { @@ -591,12 +622,8 @@ export class SlidingSyncSdk { // 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)) { - room.setUnreadNotificationCount( - NotificationCountType.Highlight, - roomData.highlight_count, - ); + if (!encrypted || (encrypted && room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0)) { + room.setUnreadNotificationCount(NotificationCountType.Highlight, roomData.highlight_count); } } @@ -681,7 +708,7 @@ export class SlidingSyncSdk { } } */ - this.injectRoomEvents(room, stateEvents, timelineEvents, false); + this.injectRoomEvents(room, stateEvents, timelineEvents, roomData.num_live); // we deliberately don't add ephemeral events to the timeline room.addEphemeralEvents(ephemeralEvents); @@ -703,13 +730,13 @@ export class SlidingSyncSdk { const processRoomEvent = async (e: MatrixEvent): Promise => { client.emit(ClientEvent.Event, e); if (e.isState() && e.getType() == EventType.RoomEncryption && this.opts.crypto) { - await this.opts.crypto.onCryptoEvent(e); + await this.opts.crypto.onCryptoEvent(room, e); } }; await utils.promiseMapSeries(stateEvents, processRoomEvent); await utils.promiseMapSeries(timelineEvents, processRoomEvent); - ephemeralEvents.forEach(function(e) { + ephemeralEvents.forEach(function (e) { client.emit(ClientEvent.Event, e); }); @@ -721,21 +748,22 @@ export class SlidingSyncSdk { /** * Injects events into a room's model. - * @param {Room} room - * @param {MatrixEvent[]} stateEventList A list of state events. This is the state + * @param stateEventList - A list of state events. This is the state * at the *START* of the timeline list if it is supplied. - * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index - * @param {boolean} fromCache whether the sync response came from cache + * @param timelineEventList - A list of timeline events. Lower index * is earlier in time. Higher index is later. + * @param numLive - the number of events in timelineEventList which just happened, + * supplied from the server. */ public injectRoomEvents( room: Room, stateEventList: MatrixEvent[], timelineEventList?: MatrixEvent[], - fromCache = false, + numLive?: number, ): void { timelineEventList = timelineEventList || []; stateEventList = stateEventList || []; + numLive = numLive || 0; // If there are no events in the timeline yet, initialise it with // the given state events @@ -774,13 +802,31 @@ export class SlidingSyncSdk { room.currentState.setStateEvents(stateEventList); } + // the timeline is broken into 'live' events which just happened and normal timeline events + // which are still to be appended to the end of the live timeline but happened a while ago. + // The live events are marked as fromCache=false to ensure that downstream components know + // this is a live event, not historical (from a remote server cache). + + let liveTimelineEvents: MatrixEvent[] = []; + if (numLive > 0) { + // last numLive events are live + liveTimelineEvents = timelineEventList.slice(-1 * numLive); + // everything else is not live + timelineEventList = timelineEventList.slice(0, -1 * liveTimelineEvents.length); + } + // execute the timeline events. This will continue to diverge the current state // if the timeline has any state events in it. // This also needs to be done before running push rules on the events as they need // to be decorated with sender etc. room.addLiveEvents(timelineEventList, { - fromCache: fromCache, + fromCache: true, }); + if (liveTimelineEvents.length > 0) { + room.addLiveEvents(liveTimelineEvents, { + fromCache: false, + }); + } room.recalculate(); @@ -795,7 +841,7 @@ export class SlidingSyncSdk { const client = this.client; // For each invited room member we want to give them a displayname/avatar url // if they have one (the m.room.member invites don't contain this). - room.getMembersWithMembership("invite").forEach(function(member) { + room.getMembersWithMembership("invite").forEach(function (member) { if (member.requestedProfileInfo) return; member.requestedProfileInfo = true; // try to get a cached copy first. @@ -809,22 +855,25 @@ export class SlidingSyncSdk { } else { promise = client.getProfileInfo(member.userId); } - promise.then(function(info) { - // slightly naughty by doctoring the invite event but this means all - // the code paths remain the same between invite/join display name stuff - // which is a worthy trade-off for some minor pollution. - const inviteEvent = member.events.member!; - if (inviteEvent.getContent().membership !== "invite") { - // between resolving and now they have since joined, so don't clobber - return; - } - inviteEvent.getContent().avatar_url = info.avatar_url; - inviteEvent.getContent().displayname = info.displayname; - // fire listeners - member.setMembershipEvent(inviteEvent, room.currentState); - }, function(_err) { - // OH WELL. - }); + promise.then( + function (info) { + // slightly naughty by doctoring the invite event but this means all + // the code paths remain the same between invite/join display name stuff + // which is a worthy trade-off for some minor pollution. + const inviteEvent = member.events.member!; + if (inviteEvent.getContent().membership !== "invite") { + // between resolving and now they have since joined, so don't clobber + return; + } + inviteEvent.getContent().avatar_url = info.avatar_url; + inviteEvent.getContent().displayname = info.displayname; + // fire listeners + member.setMembershipEvent(inviteEvent, room.currentState); + }, + function (_err) { + // OH WELL. + }, + ); }); } @@ -869,8 +918,8 @@ export class SlidingSyncSdk { /** * Sets the sync state and emits an event to say so - * @param {String} newState The new state string - * @param {Object} data Object of additional data to emit in the event + * @param newState - The new state string + * @param data - Object of additional data to emit in the event */ private updateSyncState(newState: SyncState, data?: ISyncStateData): void { const old = this.syncState; @@ -884,7 +933,7 @@ export class SlidingSyncSdk { * as appropriate. * This must be called after the room the events belong to has been stored. * - * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index + * @param timelineEventList - A list of timeline events. Lower index * is earlier in time. Higher index is later. */ private addNotifications(timelineEventList: MatrixEvent[]): void { @@ -894,8 +943,7 @@ export class SlidingSyncSdk { } for (const timelineEvent of timelineEventList) { const pushActions = this.client.getPushActionsForEvent(timelineEvent); - if (pushActions && pushActions.notify && - pushActions.tweaks && pushActions.tweaks.highlight) { + if (pushActions && pushActions.notify && pushActions.tweaks && pushActions.tweaks.highlight) { this.notifEvents.push(timelineEvent); } } @@ -909,7 +957,7 @@ export class SlidingSyncSdk { * room B appearing earlier in the notifications timeline, even though it has the higher origin_server_ts. */ private purgeNotifications(): void { - this.notifEvents.sort(function(a, b) { + this.notifEvents.sort(function (a, b) { return a.getTs() - b.getTs(); }); this.notifEvents.forEach((event) => { @@ -947,12 +995,14 @@ function ensureNameEvent(client: MatrixClient, roomId: string, roomData: MSC3575 return roomData; } +type TaggedEvent = (IStrippedState | IRoomEvent | IStateEvent | IMinimalEvent) & { room_id?: string }; + // Helper functions which set up JS SDK structs are below and are identical to the sync v2 counterparts, // just outside the class. function mapEvents(client: MatrixClient, roomId: string | undefined, events: object[], decrypt = true): MatrixEvent[] { const mapper = client.getEventMapper({ decrypt }); - return (events as Array).map(function(e) { - e["room_id"] = roomId; + return (events as TaggedEvent[]).map(function (e) { + e.room_id = roomId; return mapper(e); }); } diff --git a/src/sliding-sync.ts b/src/sliding-sync.ts index 171bf55c32d..b219dad1159 100644 --- a/src/sliding-sync.ts +++ b/src/sliding-sync.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { logger } from './logger'; +import { logger } from "./logger"; import { MatrixClient } from "./client"; import { IRoomEvent, IStateEvent } from "./sync-accumulator"; import { TypedEventEmitter } from "./models/typed-event-emitter"; @@ -95,6 +95,7 @@ export interface MSC3575RoomData { limited?: boolean; is_dm?: boolean; prev_batch?: string; + num_live?: number; } interface ListResponse { @@ -138,7 +139,7 @@ export interface MSC3575SlidingSyncResponse { txn_id?: string; lists: ListResponse[]; rooms: Record; - extensions: object; + extensions: Record; } export enum SlidingSyncState { @@ -155,7 +156,7 @@ export enum SlidingSyncState { /** * Internal Class. SlidingList represents a single list in sliding sync. The list can have filters, - * multiple sliding windows, and maintains the index->room_id mapping. + * multiple sliding windows, and maintains the index-\>room_id mapping. */ class SlidingList { private list!: MSC3575List; @@ -167,7 +168,7 @@ class SlidingList { /** * Construct a new sliding list. - * @param {MSC3575List} list The range, sort and filter values to use for this list. + * @param list - The range, sort and filter values to use for this list. */ public constructor(list: MSC3575List) { this.replaceList(list); @@ -176,7 +177,7 @@ class SlidingList { /** * Mark this list as modified or not. Modified lists will return sticky params with calls to getList. * This is useful for the first time the list is sent, or if the list has changed in some way. - * @param modified True to mark this list as modified so all sticky parameters will be re-sent. + * @param modified - True to mark this list as modified so all sticky parameters will be re-sent. */ public setModified(modified: boolean): void { this.isModified = modified; @@ -184,7 +185,7 @@ class SlidingList { /** * Update the list range for this list. Does not affect modified status as list ranges are non-sticky. - * @param newRanges The new ranges for the list + * @param newRanges - The new ranges for the list */ public updateListRange(newRanges: number[][]): void { this.list.ranges = JSON.parse(JSON.stringify(newRanges)); @@ -192,7 +193,7 @@ class SlidingList { /** * Replace list parameters. All fields will be replaced with the new list parameters. - * @param list The new list parameters + * @param list - The new list parameters */ public replaceList(list: MSC3575List): void { list.filters = list.filters || {}; @@ -212,7 +213,7 @@ class SlidingList { /** * Return a copy of the list suitable for a request body. - * @param {boolean} forceIncludeAllParams True to forcibly include all params even if the list + * @param forceIncludeAllParams - True to forcibly include all params even if the list * hasn't been modified. Callers may want to do this if they are modifying the list prior to calling * updateList. */ @@ -234,7 +235,7 @@ class SlidingList { * a b c d _ f COMMAND: DELETE 7; * e a b c d f COMMAND: INSERT 0 e; * c=3 is wrong as we are not tracking it, ergo we need to see if `i` is in range else drop it - * @param i The index to check + * @param i - The index to check * @returns True if the index is within a sliding window */ public isIndexInRange(i: number): boolean { @@ -264,7 +265,7 @@ export enum ExtensionState { /** * An interface that must be satisfied to register extensions */ -export interface Extension { +export interface Extension { /** * The extension name to go under 'extensions' in the request body. * @returns The JSON key. @@ -273,15 +274,15 @@ export interface Extension { /** * A function which is called when the request JSON is being formed. * Returns the data to insert under this key. - * @param isInitial True when this is part of the initial request (send sticky params) + * @param isInitial - True when this is part of the initial request (send sticky params) * @returns The request JSON to send. */ - onRequest(isInitial: boolean): object | undefined; + onRequest(isInitial: boolean): Req | undefined; /** * A function which is called when there is response JSON under this extension. - * @param data The response JSON under the extension name. + * @param data - The response JSON under the extension name. */ - onResponse(data: object); + onResponse(data: Res): void; /** * Controls when onResponse should be called. * @returns The state when it should be called. @@ -327,10 +328,14 @@ export enum SlidingSyncEvent { export type SlidingSyncEventHandlerMap = { [SlidingSyncEvent.RoomData]: (roomId: string, roomData: MSC3575RoomData) => void; [SlidingSyncEvent.Lifecycle]: ( - state: SlidingSyncState, resp: MSC3575SlidingSyncResponse | null, err?: Error, + state: SlidingSyncState, + resp: MSC3575SlidingSyncResponse | null, + err?: Error, ) => void; [SlidingSyncEvent.List]: ( - listIndex: number, joinedCount: number, roomIndexToRoomId: Record, + listIndex: number, + joinedCount: number, + roomIndexToRoomId: Record, ) => void; }; @@ -350,9 +355,9 @@ export class SlidingSync extends TypedEventEmitter & { txnId: string})[] = []; + private txnIdDefers: (IDeferred & { txnId: string })[] = []; // map of extension name to req/resp handler - private extensions: Record = {}; + private extensions: Record> = {}; private desiredRoomSubscriptions = new Set(); // the *desired* room subscriptions private confirmedRoomSubscriptions = new Set(); @@ -367,11 +372,11 @@ export class SlidingSync extends TypedEventEmitter} | null { + public getListData(index: number): { joinedCount: number; roomIndexToRoomId: Record } | null { if (!this.lists[index]) { return null; } @@ -434,7 +439,7 @@ export class SlidingSync extends TypedEventEmitter): void { if (this.extensions[ext.name()]) { throw new Error(`registerExtension: ${ext.name()} already exists as an extension`); } this.extensions[ext.name()] = ext; } - private getExtensionRequest(isInitial: boolean): object { - const ext = {}; + private getExtensionRequest(isInitial: boolean): Record { + const ext: Record = {}; Object.keys(this.extensions).forEach((extName) => { ext[extName] = this.extensions[extName].onRequest(isInitial); }); return ext; } - private onPreExtensionsResponse(ext: object): void { + private onPreExtensionsResponse(ext: Record): void { Object.keys(ext).forEach((extName) => { if (this.extensions[extName].when() == ExtensionState.PreProcess) { this.extensions[extName].onResponse(ext[extName]); @@ -541,7 +546,7 @@ export class SlidingSync extends TypedEventEmitter): void { Object.keys(ext).forEach((extName) => { if (this.extensions[extName].when() == ExtensionState.PostProcess) { this.extensions[extName].onResponse(ext[extName]); @@ -551,20 +556,24 @@ export class SlidingSync extends TypedEventEmitter low; i--) { if (this.lists[listIndex].isIndexInRange(i)) { - this.lists[listIndex].roomIndexToRoomId[i] = - this.lists[listIndex].roomIndexToRoomId[ - i - 1 - ]; + this.lists[listIndex].roomIndexToRoomId[i] = this.lists[listIndex].roomIndexToRoomId[i - 1]; } } } @@ -594,10 +600,7 @@ export class SlidingSync extends TypedEventEmitter { if (this.needsResend && this.txnIdDefers.length > 0) { // we already have a resend queued, so just return the same promise - return this.txnIdDefers[this.txnIdDefers.length-1].promise; + return this.txnIdDefers[this.txnIdDefers.length - 1].promise; } this.needsResend = true; this.txnId = this.client.makeTxnId(); @@ -772,7 +756,7 @@ export class SlidingSync extends TypedEventEmitter { this.lists[i].joinedCount = val.count; }); - this.invokeLifecycleListeners( - SlidingSyncState.RequestFinished, - resp, - ); + this.invokeLifecycleListeners(SlidingSyncState.RequestFinished, resp); } catch (err) { if ((err).httpStatus) { - this.invokeLifecycleListeners( - SlidingSyncState.RequestFinished, - null, - err, - ); + this.invokeLifecycleListeners(SlidingSyncState.RequestFinished, null, err); if ((err).httpStatus === 400) { // session probably expired TODO: assign an errcode // so drop state and re-request @@ -910,10 +887,7 @@ export class SlidingSync extends TypedEventEmitter { - this.invokeRoomDataListeners( - roomId, - resp!.rooms[roomId], - ); + this.invokeRoomDataListeners(roomId, resp!.rooms[roomId]); }); const listIndexesWithUpdates: Set = new Set(); @@ -931,7 +905,9 @@ export class SlidingSync extends TypedEventEmitter { this.emit( SlidingSyncEvent.List, - i, this.lists[i].joinedCount, Object.assign({}, this.lists[i].roomIndexToRoomId), + i, + this.lists[i].joinedCount, + Object.assign({}, this.lists[i].roomIndexToRoomId), ); }); diff --git a/src/store/index.ts b/src/store/index.ts index bed7aa7a69c..84f613cf4dd 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -42,141 +42,133 @@ export interface IStore { // for when it falls back to being a memory store due to errors. on?: (event: EventEmitterEvents | "degraded", handler: (...args: any[]) => void) => void; - /** @return {Promise} whether or not the database was newly created in this session. */ + /** @returns whether or not the database was newly created in this session. */ isNewlyCreated(): Promise; /** * Get the sync token. - * @return {string} */ getSyncToken(): string | null; /** * Set the sync token. - * @param {string} token */ setSyncToken(token: string): void; /** * Store the given room. - * @param {Room} room The room to be stored. All properties must be stored. + * @param room - The room to be stored. All properties must be stored. */ storeRoom(room: Room): void; /** * Retrieve a room by its' room ID. - * @param {string} roomId The room ID. - * @return {Room} The room or null. + * @param roomId - The room ID. + * @returns The room or null. */ getRoom(roomId: string): Room | null; /** * Retrieve all known rooms. - * @return {Room[]} A list of rooms, which may be empty. + * @returns A list of rooms, which may be empty. */ getRooms(): Room[]; /** * Permanently delete a room. - * @param {string} roomId */ removeRoom(roomId: string): void; /** * Retrieve a summary of all the rooms. - * @return {RoomSummary[]} A summary of each room. + * @returns A summary of each room. */ getRoomSummaries(): RoomSummary[]; /** * Store a User. - * @param {User} user The user to store. + * @param user - The user to store. */ storeUser(user: User): void; /** * Retrieve a User by its' user ID. - * @param {string} userId The user ID. - * @return {User} The user or null. + * @param userId - The user ID. + * @returns The user or null. */ getUser(userId: string): User | null; /** * Retrieve all known users. - * @return {User[]} A list of users, which may be empty. + * @returns A list of users, which may be empty. */ getUsers(): User[]; /** * Retrieve scrollback for this room. - * @param {Room} room The matrix room - * @param {number} limit The max number of old events to retrieve. - * @return {Array} An array of objects which will be at most 'limit' + * @param room - The matrix room + * @param limit - The max number of old events to retrieve. + * @returns An array of objects which will be at most 'limit' * length and at least 0. The objects are the raw event JSON. */ scrollback(room: Room, limit: number): MatrixEvent[]; /** * Store events for a room. - * @param {Room} room The room to store events for. - * @param {Array} events The events to store. - * @param {string} token The token associated with these events. - * @param {boolean} toStart True if these are paginated results. + * @param room - The room to store events for. + * @param events - The events to store. + * @param token - The token associated with these events. + * @param toStart - True if these are paginated results. */ - storeEvents(room: Room, events: MatrixEvent[], token: string, toStart: boolean): void; + storeEvents(room: Room, events: MatrixEvent[], token: string | null, toStart: boolean): void; /** * Store a filter. - * @param {Filter} filter */ storeFilter(filter: Filter): void; /** * Retrieve a filter. - * @param {string} userId - * @param {string} filterId - * @return {?Filter} A filter or null. + * @returns A filter or null. */ getFilter(userId: string, filterId: string): Filter | null; /** * Retrieve a filter ID with the given name. - * @param {string} filterName The filter name. - * @return {?string} The filter ID or null. + * @param filterName - The filter name. + * @returns The filter ID or null. */ getFilterIdByName(filterName: string): string | null; /** * Set a filter name to ID mapping. - * @param {string} filterName - * @param {string} filterId */ setFilterIdByName(filterName: string, filterId?: string): void; /** * Store user-scoped account data events - * @param {Array} events The events to store. + * @param events - The events to store. */ storeAccountDataEvents(events: MatrixEvent[]): void; /** * Get account data event by event type - * @param {string} eventType The event type being queried + * @param eventType - The event type being queried */ getAccountData(eventType: EventType | string): MatrixEvent | undefined; /** * setSyncData does nothing as there is no backing data store. * - * @param {Object} syncData The sync data - * @return {Promise} An immediately resolved promise. + * @param syncData - The sync data + * @returns An immediately resolved promise. */ setSyncData(syncData: ISyncResponse): Promise; /** * We never want to save because we have nothing to save to. * - * @return {boolean} If the store wants to save + * @returns If the store wants to save */ wantsSave(): boolean; @@ -187,19 +179,19 @@ export interface IStore { /** * Startup does nothing. - * @return {Promise} An immediately resolved promise. + * @returns An immediately resolved promise. */ startup(): Promise; /** - * @return {Promise} Resolves with a sync response to restore the + * @returns Promise which resolves with a sync response to restore the * client state to where it was at the last save, or null if there * is no saved sync data. */ getSavedSync(): Promise; /** - * @return {Promise} If there is a saved sync, the nextBatch token + * @returns If there is a saved sync, the nextBatch token * for this sync, otherwise null. */ getSavedSyncToken(): Promise; @@ -207,16 +199,15 @@ export interface IStore { /** * Delete all data from this store. Does nothing since this store * doesn't store anything. - * @return {Promise} An immediately resolved promise. + * @returns An immediately resolved promise. */ deleteAllData(): Promise; /** * Returns the out-of-band membership events for this room that * were previously loaded. - * @param {string} roomId - * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members - * @returns {null} in case the members for this room haven't been stored yet + * @returns the events, potentially an empty array if OOB loading didn't yield any new members + * @returns in case the members for this room haven't been stored yet */ getOutOfBandMembers(roomId: string): Promise; @@ -224,9 +215,8 @@ export interface IStore { * Stores the out-of-band membership events for this room. Note that * it still makes sense to store an empty array as the OOB status for the room is * marked as fetched, and getOutOfBandMembers will return an empty array instead of null - * @param {string} roomId - * @param {event[]} membershipEvents the membership events to store - * @returns {Promise} when all members have been stored + * @param membershipEvents - the membership events to store + * @returns when all members have been stored */ setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise; @@ -243,15 +233,15 @@ export interface IStore { /** * Stores batches of outgoing to-device messages */ - saveToDeviceBatches(batch: ToDeviceBatchWithTxnId[]): Promise; + saveToDeviceBatches(batch: ToDeviceBatchWithTxnId[]): Promise; - /** - * Fetches the oldest batch of to-device messages in the queue - */ - getOldestToDeviceBatch(): Promise; + /** + * Fetches the oldest batch of to-device messages in the queue + */ + getOldestToDeviceBatch(): Promise; - /** - * Removes a specific batch of to-device messages from the queue - */ - removeToDeviceBatch(id: number): Promise; + /** + * Removes a specific batch of to-device messages from the queue + */ + removeToDeviceBatch(id: number): Promise; } diff --git a/src/store/indexeddb-local-backend.ts b/src/store/indexeddb-local-backend.ts index af4b5fc6519..7e84ad621a5 100644 --- a/src/store/indexeddb-local-backend.ts +++ b/src/store/indexeddb-local-backend.ts @@ -17,7 +17,7 @@ limitations under the License. import { IMinimalEvent, ISyncData, ISyncResponse, SyncAccumulator } from "../sync-accumulator"; import * as utils from "../utils"; import * as IndexedDBHelpers from "../indexeddb-helpers"; -import { logger } from '../logger'; +import { logger } from "../logger"; import { IStateEventWithRoomId, IStoredClientOpts } from "../matrix"; import { ISavedSync } from "./index"; import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend"; @@ -37,26 +37,29 @@ const DB_MIGRATIONS: DbMigration[] = [ db.createObjectStore("sync", { keyPath: ["clobber"] }); }, (db): void => { - const oobMembersStore = db.createObjectStore( - "oob_membership_events", { - keyPath: ["room_id", "state_key"], - }); + const oobMembersStore = db.createObjectStore("oob_membership_events", { + keyPath: ["room_id", "state_key"], + }); oobMembersStore.createIndex("room", "room_id"); }, - (db): void => { db.createObjectStore("client_options", { keyPath: ["clobber"] }); }, - (db): void => { db.createObjectStore("to_device_queue", { autoIncrement: true }); }, + (db): void => { + db.createObjectStore("client_options", { keyPath: ["clobber"] }); + }, + (db): void => { + 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. - * @param {ObjectStore|Index} store The store to perform openCursor on. - * @param {IDBKeyRange=} keyRange Optional key range to apply on the cursor. - * @param {Function} resultMapper A function which is repeatedly called with a + * @param store - The store to perform openCursor on. + * @param keyRange - Optional key range to apply on the cursor. + * @param resultMapper - A function which is repeatedly called with a * Cursor. * Return the data you want to keep. - * @return {Promise} Resolves to an array of whatever you returned from + * @returns Promise which resolves to an array of whatever you returned from * resultMapper. */ function selectQuery( @@ -85,10 +88,10 @@ function selectQuery( function txnAsPromise(txn: IDBTransaction): Promise { return new Promise((resolve, reject) => { - txn.oncomplete = function(event): void { + txn.oncomplete = function (event): void { resolve(event); }; - txn.onerror = function(): void { + txn.onerror = function (): void { reject(txn.error); }; }); @@ -96,10 +99,10 @@ function txnAsPromise(txn: IDBTransaction): Promise { function reqAsEventPromise(req: IDBRequest): Promise { return new Promise((resolve, reject) => { - req.onsuccess = function(event): void { + req.onsuccess = function (event): void { resolve(event); }; - req.onerror = function(): void { + req.onerror = function (): void { reject(req.error); }; }); @@ -134,11 +137,10 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { * Does the actual reading from and writing to the indexeddb * * Construct a new Indexed Database store backend. This requires a call to - * connect() before this store can be used. - * @constructor - * @param {Object} indexedDB The Indexed DB interface e.g - * window.indexedDB - * @param {string=} dbName Optional database name. The same name must be used + * `connect()` before this store can be used. + * @param indexedDB - The Indexed DB interface e.g + * `window.indexedDB` + * @param dbName - Optional database name. The same name must be used * to open the same database. */ public constructor(private readonly indexedDB: IDBFactory, dbName = "default") { @@ -149,7 +151,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { /** * Attempt to connect to the database. This can fail if the user does not * grant permission. - * @return {Promise} Resolves if successfully connected. + * @returns Promise which resolves if successfully connected. */ public connect(): Promise { if (!this.disconnected) { @@ -164,9 +166,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { req.onupgradeneeded = (ev): void => { const db = req.result; const oldVersion = ev.oldVersion; - logger.log( - `LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`, - ); + logger.log(`LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`); if (oldVersion < 1) { // The database did not previously exist this._isNewlyCreated = true; @@ -195,37 +195,36 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { }); } - /** @return {boolean} whether or not the database was newly created in this session. */ + /** @returns whether or not the database was newly created in this session. */ public isNewlyCreated(): Promise { return Promise.resolve(this._isNewlyCreated); } /** * Having connected, load initial data from the database and prepare for use - * @return {Promise} Resolves on success + * @returns Promise which resolves on success */ private init(): Promise { - return Promise.all([ - this.loadAccountData(), - this.loadSyncData(), - ]).then(([accountData, syncData]) => { + return Promise.all([this.loadAccountData(), this.loadSyncData()]).then(([accountData, syncData]) => { logger.log(`LocalIndexedDBStoreBackend: loaded initial data`); - this.syncAccumulator.accumulate({ - next_batch: syncData.nextBatch, - rooms: syncData.roomsData, - account_data: { - events: accountData, + this.syncAccumulator.accumulate( + { + next_batch: syncData.nextBatch, + rooms: syncData.roomsData, + account_data: { + events: accountData, + }, }, - }, true); + true, + ); }); } /** * Returns the out-of-band membership events for this room that * were previously loaded. - * @param {string} roomId - * @returns {Promise} the events, potentially an empty array if OOB loading didn't yield any new members - * @returns {null} in case the members for this room haven't been stored yet + * @returns the events, potentially an empty array if OOB loading didn't yield any new members + * @returns in case the members for this room haven't been stored yet */ public getOutOfBandMembers(roomId: string): Promise { return new Promise((resolve, reject) => { @@ -273,12 +272,10 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { * Stores the out-of-band membership events for this room. Note that * it still makes sense to store an empty array as the OOB status for the room is * marked as fetched, and getOutOfBandMembers will return an empty array instead of null - * @param {string} roomId - * @param {event[]} membershipEvents the membership events to store + * @param membershipEvents - the membership events to store */ public async setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise { - logger.log(`LL: backend about to store ${membershipEvents.length}` + - ` members for ${roomId}`); + logger.log(`LL: backend about to store ${membershipEvents.length}` + ` members for ${roomId}`); const tx = this.db!.transaction(["oob_membership_events"], "readwrite"); const store = tx.objectStore("oob_membership_events"); membershipEvents.forEach((e) => { @@ -306,40 +303,35 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { // keys in the store. // this should be way faster than deleting every member // individually for a large room. - const readTx = this.db!.transaction( - ["oob_membership_events"], - "readonly"); + const readTx = this.db!.transaction(["oob_membership_events"], "readonly"); const store = readTx.objectStore("oob_membership_events"); const roomIndex = store.index("room"); const roomRange = IDBKeyRange.only(roomId); - const minStateKeyProm = reqAsCursorPromise( - roomIndex.openKeyCursor(roomRange, "next"), - ).then((cursor) => cursor && cursor.primaryKey[1]); - const maxStateKeyProm = reqAsCursorPromise( - roomIndex.openKeyCursor(roomRange, "prev"), - ).then((cursor) => cursor && cursor.primaryKey[1]); - const [minStateKey, maxStateKey] = await Promise.all( - [minStateKeyProm, maxStateKeyProm]); - - const writeTx = this.db!.transaction( - ["oob_membership_events"], - "readwrite"); + const minStateKeyProm = reqAsCursorPromise(roomIndex.openKeyCursor(roomRange, "next")).then( + (cursor) => (cursor?.primaryKey)[1], + ); + const maxStateKeyProm = reqAsCursorPromise(roomIndex.openKeyCursor(roomRange, "prev")).then( + (cursor) => (cursor?.primaryKey)[1], + ); + const [minStateKey, maxStateKey] = await Promise.all([minStateKeyProm, maxStateKeyProm]); + + const writeTx = this.db!.transaction(["oob_membership_events"], "readwrite"); const writeStore = writeTx.objectStore("oob_membership_events"); - const membersKeyRange = IDBKeyRange.bound( + const membersKeyRange = IDBKeyRange.bound([roomId, minStateKey], [roomId, maxStateKey]); + + logger.log( + `LL: Deleting all users + marker in storage for room ${roomId}, with key range:`, [roomId, minStateKey], [roomId, maxStateKey], ); - - logger.log(`LL: Deleting all users + marker in storage for room ${roomId}, with key range:`, - [roomId, minStateKey], [roomId, maxStateKey]); await reqAsPromise(writeStore.delete(membersKeyRange)); } /** * Clear the entire database. This should be used when logging out of a client * to prevent mixing data between accounts. - * @return {Promise} Resolved when the database is cleared. + * @returns Resolved when the database is cleared. */ public clearDatabase(): Promise { return new Promise((resolve) => { @@ -366,11 +358,11 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { } /** - * @param {boolean=} copy If false, the data returned is from internal + * @param copy - If false, the data returned is from internal * buffers and must not be mutated. Otherwise, a copy is made before * returning such that the data can be safely mutated. Default: true. * - * @return {Promise} Resolves with a sync response to restore the + * @returns Promise which resolves with a sync response to restore the * client state to where it was at the last save, or null if there * is no saved sync data. */ @@ -421,14 +413,11 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { /** * Persist rooms /sync data along with the next batch token. - * @param {string} nextBatch The next_batch /sync value. - * @param {Object} roomsData The 'rooms' /sync data from a SyncAccumulator - * @return {Promise} Resolves if the data was persisted. + * @param nextBatch - The next_batch /sync value. + * @param roomsData - The 'rooms' /sync data from a SyncAccumulator + * @returns Promise which resolves if the data was persisted. */ - private persistSyncData( - nextBatch: string, - roomsData: ISyncResponse["rooms"], - ): Promise { + private persistSyncData(nextBatch: string, roomsData: ISyncResponse["rooms"]): Promise { logger.log("Persisting sync data up to", nextBatch); return utils.promiseTry(() => { const txn = this.db!.transaction(["sync"], "readwrite"); @@ -447,8 +436,8 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { /** * Persist a list of account data events. Events with the same 'type' will * be replaced. - * @param {Object[]} accountData An array of raw user-scoped account data events - * @return {Promise} Resolves if the events were persisted. + * @param accountData - An array of raw user-scoped account data events + * @returns Promise which resolves if the events were persisted. */ private persistAccountData(accountData: IMinimalEvent[]): Promise { return utils.promiseTry(() => { @@ -466,8 +455,8 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { * Users with the same 'userId' will be replaced. * Presence events should be the event in its raw form (not the Event * object) - * @param {Object[]} tuples An array of [userid, event] tuples - * @return {Promise} Resolves if the users were persisted. + * @param tuples - An array of [userid, event] tuples + * @returns Promise which resolves if the users were persisted. */ private persistUserPresenceEvents(tuples: UserTuple[]): Promise { return utils.promiseTry(() => { @@ -487,7 +476,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { * Load all user presence events from the database. This is not cached. * FIXME: It would probably be more sensible to store the events in the * sync. - * @return {Promise} A list of presence events in their raw form. + * @returns A list of presence events in their raw form. */ public getUserPresenceEvents(): Promise { return utils.promiseTry(() => { @@ -501,7 +490,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { /** * Load all the account data events from the database. This is not cached. - * @return {Promise} A list of raw global account events. + * @returns A list of raw global account events. */ private loadAccountData(): Promise { logger.log(`LocalIndexedDBStoreBackend: loading account data...`); @@ -519,7 +508,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { /** * Load the sync data from the database. - * @return {Promise} An object with "roomsData" and "nextBatch" keys. + * @returns An object with "roomsData" and "nextBatch" keys. */ private loadSyncData(): Promise { logger.log(`LocalIndexedDBStoreBackend: loading sync data...`); @@ -533,7 +522,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { if (results.length > 1) { logger.warn("loadSyncData: More than 1 sync row found."); } - return results.length > 0 ? results[0] : {} as ISyncData; + return results.length > 0 ? results[0] : ({} as ISyncData); }); }); } diff --git a/src/store/indexeddb-remote-backend.ts b/src/store/indexeddb-remote-backend.ts index 1ca6fc03a38..7406d3a6198 100644 --- a/src/store/indexeddb-remote-backend.ts +++ b/src/store/indexeddb-remote-backend.ts @@ -36,114 +36,108 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend { * worker. * * Construct a new Indexed Database store backend. This requires a call to - * connect() before this store can be used. - * @constructor - * @param {Function} workerFactory Factory which produces a Worker - * @param {string=} dbName Optional database name. The same name must be used + * `connect()` before this store can be used. + * @param workerFactory - Factory which produces a Worker + * @param dbName - Optional database name. The same name must be used * to open the same database. */ - public constructor( - private readonly workerFactory: () => Worker, - private readonly dbName?: string, - ) {} + public constructor(private readonly workerFactory: () => Worker, private readonly dbName?: string) {} /** * Attempt to connect to the database. This can fail if the user does not * grant permission. - * @return {Promise} Resolves if successfully connected. + * @returns Promise which resolves if successfully connected. */ public connect(): Promise { - return this.ensureStarted().then(() => this.doCmd('connect')); + return this.ensureStarted().then(() => this.doCmd("connect")); } /** * Clear the entire database. This should be used when logging out of a client * to prevent mixing data between accounts. - * @return {Promise} Resolved when the database is cleared. + * @returns Resolved when the database is cleared. */ public clearDatabase(): Promise { - return this.ensureStarted().then(() => this.doCmd('clearDatabase')); + return this.ensureStarted().then(() => this.doCmd("clearDatabase")); } - /** @return {Promise} whether or not the database was newly created in this session. */ + /** @returns whether or not the database was newly created in this session. */ public isNewlyCreated(): Promise { - return this.doCmd('isNewlyCreated'); + return this.doCmd("isNewlyCreated"); } /** - * @return {Promise} Resolves with a sync response to restore the + * @returns Promise which resolves with a sync response to restore the * client state to where it was at the last save, or null if there * is no saved sync data. */ public getSavedSync(): Promise { - return this.doCmd('getSavedSync'); + return this.doCmd("getSavedSync"); } public getNextBatchToken(): Promise { - return this.doCmd('getNextBatchToken'); + return this.doCmd("getNextBatchToken"); } public setSyncData(syncData: ISyncResponse): Promise { - return this.doCmd('setSyncData', [syncData]); + return this.doCmd("setSyncData", [syncData]); } public syncToDatabase(userTuples: UserTuple[]): Promise { - return this.doCmd('syncToDatabase', [userTuples]); + return this.doCmd("syncToDatabase", [userTuples]); } /** * Returns the out-of-band membership events for this room that * were previously loaded. - * @param {string} roomId - * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members - * @returns {null} in case the members for this room haven't been stored yet + * @returns the events, potentially an empty array if OOB loading didn't yield any new members + * @returns in case the members for this room haven't been stored yet */ public getOutOfBandMembers(roomId: string): Promise { - return this.doCmd('getOutOfBandMembers', [roomId]); + return this.doCmd("getOutOfBandMembers", [roomId]); } /** * Stores the out-of-band membership events for this room. Note that * it still makes sense to store an empty array as the OOB status for the room is * marked as fetched, and getOutOfBandMembers will return an empty array instead of null - * @param {string} roomId - * @param {event[]} membershipEvents the membership events to store - * @returns {Promise} when all members have been stored + * @param membershipEvents - the membership events to store + * @returns when all members have been stored */ public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise { - return this.doCmd('setOutOfBandMembers', [roomId, membershipEvents]); + return this.doCmd("setOutOfBandMembers", [roomId, membershipEvents]); } public clearOutOfBandMembers(roomId: string): Promise { - return this.doCmd('clearOutOfBandMembers', [roomId]); + return this.doCmd("clearOutOfBandMembers", [roomId]); } public getClientOptions(): Promise { - return this.doCmd('getClientOptions'); + return this.doCmd("getClientOptions"); } public storeClientOptions(options: IStoredClientOpts): Promise { - return this.doCmd('storeClientOptions', [options]); + return this.doCmd("storeClientOptions", [options]); } /** * Load all user presence events from the database. This is not cached. - * @return {Promise} A list of presence events in their raw form. + * @returns A list of presence events in their raw form. */ public getUserPresenceEvents(): Promise { - return this.doCmd('getUserPresenceEvents'); + return this.doCmd("getUserPresenceEvents"); } public async saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise { - return this.doCmd('saveToDeviceBatches', [batches]); + return this.doCmd("saveToDeviceBatches", [batches]); } public async getOldestToDeviceBatch(): Promise { - return this.doCmd('getOldestToDeviceBatch'); + return this.doCmd("getOldestToDeviceBatch"); } public async removeToDeviceBatch(id: number): Promise { - return this.doCmd('removeToDeviceBatch', [id]); + return this.doCmd("removeToDeviceBatch", [id]); } private ensureStarted(): Promise { @@ -152,7 +146,7 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend { this.worker.onmessage = this.onWorkerMessage; // tell the worker the db name. - this.startPromise = this.doCmd('setupWorker', [this.dbName]).then(() => { + this.startPromise = this.doCmd("setupWorker", [this.dbName]).then(() => { logger.log("IndexedDB worker is ready"); }); } @@ -177,7 +171,7 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend { private onWorkerMessage = (ev: MessageEvent): void => { const msg = ev.data; - if (msg.command == 'cmd_success' || msg.command == 'cmd_fail') { + if (msg.command == "cmd_success" || msg.command == "cmd_fail") { if (msg.seq === undefined) { logger.error("Got reply from worker with no seq"); return; @@ -190,7 +184,7 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend { } delete this.inFlight[msg.seq]; - if (msg.command == 'cmd_success') { + if (msg.command == "cmd_success") { def.resolve(msg.result); } else { const error = new Error(msg.error.message); @@ -202,4 +196,3 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend { } }; } - diff --git a/src/store/indexeddb-store-worker.ts b/src/store/indexeddb-store-worker.ts index 57e7da983be..df508ffa7ba 100644 --- a/src/store/indexeddb-store-worker.ts +++ b/src/store/indexeddb-store-worker.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend"; -import { logger } from '../logger'; +import { logger } from "../logger"; interface ICmd { command: string; @@ -27,12 +27,14 @@ interface ICmd { * This class lives in the webworker and drives a LocalIndexedDBStoreBackend * controlled by messages from the main process. * + * @example * It should be instantiated by a web worker script provided by the application * in a script, for example: - * + * ``` * import {IndexedDBStoreWorker} from 'matrix-js-sdk/lib/indexeddb-worker.js'; * const remoteWorker = new IndexedDBStoreWorker(postMessage); * onmessage = remoteWorker.onMessage; + * ``` * * Note that it is advisable to import this class by referencing the file directly to * avoid a dependency on the whole js-sdk. @@ -42,7 +44,7 @@ export class IndexedDBStoreWorker { private backend?: LocalIndexedDBStoreBackend; /** - * @param {function} postMessage The web worker postMessage function that + * @param postMessage - The web worker postMessage function that * should be used to communicate back to the main script. */ public constructor(private readonly postMessage: InstanceType["postMessage"]) {} @@ -51,72 +53,72 @@ export class IndexedDBStoreWorker { * Passes a message event from the main script into the class. This method * can be directly assigned to the web worker `onmessage` variable. * - * @param {Object} ev The message event + * @param ev - The message event */ public onMessage = (ev: MessageEvent): void => { const msg: ICmd = ev.data; let prom; switch (msg.command) { - case 'setupWorker': + case "setupWorker": // this is the 'indexedDB' global (where global != window // because it's a web worker and there is no window). this.backend = new LocalIndexedDBStoreBackend(indexedDB, msg.args[0]); prom = Promise.resolve(); break; - case 'connect': + case "connect": prom = this.backend?.connect(); break; - case 'isNewlyCreated': + case "isNewlyCreated": prom = this.backend?.isNewlyCreated(); break; - case 'clearDatabase': + case "clearDatabase": prom = this.backend?.clearDatabase(); break; - case 'getSavedSync': + case "getSavedSync": prom = this.backend?.getSavedSync(false); break; - case 'setSyncData': + case "setSyncData": prom = this.backend?.setSyncData(msg.args[0]); break; - case 'syncToDatabase': + case "syncToDatabase": prom = this.backend?.syncToDatabase(msg.args[0]); break; - case 'getUserPresenceEvents': + case "getUserPresenceEvents": prom = this.backend?.getUserPresenceEvents(); break; - case 'getNextBatchToken': + case "getNextBatchToken": prom = this.backend?.getNextBatchToken(); break; - case 'getOutOfBandMembers': + case "getOutOfBandMembers": prom = this.backend?.getOutOfBandMembers(msg.args[0]); break; - case 'clearOutOfBandMembers': + case "clearOutOfBandMembers": prom = this.backend?.clearOutOfBandMembers(msg.args[0]); break; - case 'setOutOfBandMembers': + case "setOutOfBandMembers": prom = this.backend?.setOutOfBandMembers(msg.args[0], msg.args[1]); break; - case 'getClientOptions': + case "getClientOptions": prom = this.backend?.getClientOptions(); break; - case 'storeClientOptions': + case "storeClientOptions": prom = this.backend?.storeClientOptions(msg.args[0]); break; - case 'saveToDeviceBatches': + case "saveToDeviceBatches": prom = this.backend?.saveToDeviceBatches(msg.args[0]); break; - case 'getOldestToDeviceBatch': + case "getOldestToDeviceBatch": prom = this.backend?.getOldestToDeviceBatch(); break; - case 'removeToDeviceBatch': + case "removeToDeviceBatch": prom = this.backend?.removeToDeviceBatch(msg.args[0]); break; } if (prom === undefined) { this.postMessage({ - command: 'cmd_fail', + command: "cmd_fail", seq: msg.seq, // Can't be an Error because they're not structured cloneable error: "Unrecognised command", @@ -124,23 +126,26 @@ export class IndexedDBStoreWorker { return; } - prom.then((ret) => { - this.postMessage.call(null, { - command: 'cmd_success', - seq: msg.seq, - result: ret, - }); - }, (err) => { - logger.error("Error running command: " + msg.command, err); - this.postMessage.call(null, { - command: 'cmd_fail', - seq: msg.seq, - // Just send a string because Error objects aren't cloneable - error: { - message: err.message, - name: err.name, - }, - }); - }); + prom.then( + (ret) => { + this.postMessage.call(null, { + command: "cmd_success", + seq: msg.seq, + result: ret, + }); + }, + (err) => { + logger.error("Error running command: " + msg.command, err); + this.postMessage.call(null, { + command: "cmd_fail", + seq: msg.seq, + // Just send a string because Error objects aren't cloneable + error: { + message: err.message, + name: err.name, + }, + }); + }, + ); }; } diff --git a/src/store/indexeddb.ts b/src/store/indexeddb.ts index 55f8261faa8..1f0961e2bc0 100644 --- a/src/store/indexeddb.ts +++ b/src/store/indexeddb.ts @@ -21,7 +21,7 @@ import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend"; import { RemoteIndexedDBStoreBackend } from "./indexeddb-remote-backend"; import { User } from "../models/user"; import { IEvent, MatrixEvent } from "../models/event"; -import { logger } from '../logger'; +import { logger } from "../logger"; import { ISavedSync } from "./index"; import { IIndexedDBBackend } from "./indexeddb-backend"; import { ISyncResponse } from "../sync-accumulator"; @@ -32,7 +32,6 @@ import { IStoredClientOpts } from "../client"; /** * This is an internal module. See {@link IndexedDBStore} for the public class. - * @module store/indexeddb */ // If this value is too small we'll be writing very often which will cause @@ -43,13 +42,16 @@ import { IStoredClientOpts } from "../client"; const WRITE_DELAY_MS = 1000 * 60 * 5; // once every 5 minutes interface IOpts extends IBaseOpts { + /** The Indexed DB interface e.g. `window.indexedDB` */ indexedDB: IDBFactory; + /** Optional database name. The same name must be used to open the same database. */ dbName?: string; + /** Optional factory to spin up a Worker to execute the IDB transactions within. */ workerFactory?: () => Worker; } type EventHandlerMap = { - "degraded": (e: Error) => void; + degraded: (e: Error) => void; }; export class IndexedDBStore extends MemoryStore { @@ -57,6 +59,10 @@ export class IndexedDBStore extends MemoryStore { return LocalIndexedDBStoreBackend.exists(indexedDB, dbName); } + /** + * The backend instance. + * Call through to this API if you need to perform specific indexeddb actions like deleting the database. + */ public readonly backend: IIndexedDBBackend; private startedUp = false; @@ -74,10 +80,10 @@ export class IndexedDBStore extends MemoryStore { * the contents of the store to an IndexedDB backend. * * All data is still kept in-memory but can be loaded from disk by calling - * startup(). This can make startup times quicker as a complete + * `startup()`. This can make startup times quicker as a complete * sync from the server is not required. This does not reduce memory usage as all - * the data is eagerly fetched when startup() is called. - *
+     * the data is eagerly fetched when `startup()` is called.
+     * ```
      * let opts = { indexedDB: window.indexedDB, localStorage: window.localStorage };
      * let store = new IndexedDBStore(opts);
      * await store.startup(); // load from indexed db
@@ -90,30 +96,15 @@ export class IndexedDBStore extends MemoryStore {
      *         console.log("Started up, now with go faster stripes!");
      *     }
      * });
-     * 
+ * ``` * - * @constructor - * @extends MemoryStore - * @param {Object} opts Options object. - * @param {Object} opts.indexedDB The Indexed DB interface e.g. - * window.indexedDB - * @param {string=} opts.dbName Optional database name. The same name must be used - * to open the same database. - * @param {string=} opts.workerScript Optional URL to a script to invoke a web - * worker with to run IndexedDB queries on the web worker. The IndexedDbStoreWorker - * class is provided for this purpose and requires the application to provide a - * trivial wrapper script around it. - * @param {Object=} opts.workerApi The webWorker API object. If omitted, the global Worker - * object will be used if it exists. - * @prop {IndexedDBStoreBackend} backend The backend instance. Call through to - * this API if you need to perform specific indexeddb actions like deleting the - * database. + * @param opts - Options object. */ public constructor(opts: IOpts) { super(opts); if (!opts.indexedDB) { - throw new Error('Missing required option: indexedDB'); + throw new Error("Missing required option: indexedDB"); } if (opts.workerFactory) { @@ -126,7 +117,7 @@ export class IndexedDBStore extends MemoryStore { public on = this.emitter.on.bind(this.emitter); /** - * @return {Promise} Resolved when loaded from indexed db. + * @returns Resolved when loaded from indexed db. */ public startup(): Promise { if (this.startedUp) { @@ -135,24 +126,27 @@ export class IndexedDBStore extends MemoryStore { } logger.log(`IndexedDBStore.startup: connecting to backend`); - return this.backend.connect().then(() => { - logger.log(`IndexedDBStore.startup: loading presence events`); - return this.backend.getUserPresenceEvents(); - }).then((userPresenceEvents) => { - logger.log(`IndexedDBStore.startup: processing presence events`); - userPresenceEvents.forEach(([userId, rawEvent]) => { - const u = new User(userId); - if (rawEvent) { - u.setPresenceEvent(new MatrixEvent(rawEvent)); - } - this.userModifiedMap[u.userId] = u.getLastModifiedTime(); - this.storeUser(u); + return this.backend + .connect() + .then(() => { + logger.log(`IndexedDBStore.startup: loading presence events`); + return this.backend.getUserPresenceEvents(); + }) + .then((userPresenceEvents) => { + logger.log(`IndexedDBStore.startup: processing presence events`); + userPresenceEvents.forEach(([userId, rawEvent]) => { + const u = new User(userId); + if (rawEvent) { + u.setPresenceEvent(new MatrixEvent(rawEvent)); + } + this.userModifiedMap[u.userId] = u.getLastModifiedTime(); + this.storeUser(u); + }); }); - }); } /** - * @return {Promise} Resolves with a sync response to restore the + * @returns Promise which resolves with a sync response to restore the * client state to where it was at the last save, or null if there * is no saved sync data. */ @@ -160,13 +154,13 @@ export class IndexedDBStore extends MemoryStore { return this.backend.getSavedSync(); }, "getSavedSync"); - /** @return {Promise} whether or not the database was newly created in this session. */ + /** @returns whether or not the database was newly created in this session. */ public isNewlyCreated = this.degradable((): Promise => { return this.backend.isNewlyCreated(); }, "isNewlyCreated"); /** - * @return {Promise} If there is a saved sync, the nextBatch token + * @returns If there is a saved sync, the nextBatch token * for this sync, otherwise null. */ public getSavedSyncToken = this.degradable((): Promise => { @@ -175,16 +169,19 @@ export class IndexedDBStore extends MemoryStore { /** * Delete all data from this store. - * @return {Promise} Resolves if the data was deleted from the database. + * @returns Promise which resolves if the data was deleted from the database. */ public deleteAllData = this.degradable((): Promise => { super.deleteAllData(); - return this.backend.clearDatabase().then(() => { - logger.log("Deleted indexeddb data."); - }, (err) => { - logger.error(`Failed to delete indexeddb data: ${err}`); - throw err; - }); + return this.backend.clearDatabase().then( + () => { + logger.log("Deleted indexeddb data."); + }, + (err) => { + logger.error(`Failed to delete indexeddb data: ${err}`); + throw err; + }, + ); }); /** @@ -193,7 +190,7 @@ export class IndexedDBStore extends MemoryStore { * not could change between calling this function and calling * save(). * - * @return {boolean} True if calling save() will actually save + * @returns True if calling save() will actually save * (at the time this function is called). */ public wantsSave(): boolean { @@ -204,8 +201,8 @@ export class IndexedDBStore extends MemoryStore { /** * Possibly write data to the database. * - * @param {boolean} force True to force a save to happen - * @return {Promise} Promise resolves after the write completes + * @param force - True to force a save to happen + * @returns Promise resolves after the write completes * (or immediately if no write is performed) */ public save(force = false): Promise { @@ -241,9 +238,8 @@ export class IndexedDBStore extends MemoryStore { /** * Returns the out-of-band membership events for this room that * were previously loaded. - * @param {string} roomId - * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members - * @returns {null} in case the members for this room haven't been stored yet + * @returns the events, potentially an empty array if OOB loading didn't yield any new members + * @returns in case the members for this room haven't been stored yet */ public getOutOfBandMembers = this.degradable((roomId: string): Promise => { return this.backend.getOutOfBandMembers(roomId); @@ -253,9 +249,8 @@ export class IndexedDBStore extends MemoryStore { * Stores the out-of-band membership events for this room. Note that * it still makes sense to store an empty array as the OOB status for the room is * marked as fetched, and getOutOfBandMembers will return an empty array instead of null - * @param {string} roomId - * @param {event[]} membershipEvents the membership events to store - * @returns {Promise} when all members have been stored + * @param membershipEvents - the membership events to store + * @returns when all members have been stored */ public setOutOfBandMembers = this.degradable( (roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise => { @@ -287,15 +282,15 @@ export class IndexedDBStore extends MemoryStore { * When IndexedDB fails via any of these paths, we degrade this back to a `MemoryStore` * in place so that the current operation and all future ones are in-memory only. * - * @param {Function} func The degradable work to do. - * @param {String} fallback The method name for fallback. - * @returns {Function} A wrapped member function. + * @param func - The degradable work to do. + * @param fallback - The method name for fallback. + * @returns A wrapped member function. */ private degradable, R = void>( func: DegradableFn, fallback?: keyof MemoryStore, ): DegradableFn { - const fallbackFn = fallback ? super[fallback] as Function : null; + const fallbackFn = fallback ? (super[fallback] as Function) : null; return async (...args) => { try { @@ -368,8 +363,8 @@ export class IndexedDBStore extends MemoryStore { } /** - * @param {string} roomId ID of the current room - * @returns {string} Storage key to retrieve pending events + * @param roomId - ID of the current room + * @returns Storage key to retrieve pending events */ function pendingEventsKey(roomId: string): string { return `mx_pending_events_${roomId}`; diff --git a/src/store/local-storage-events-emitter.ts b/src/store/local-storage-events-emitter.ts index 24524c63438..adb70cb541d 100644 --- a/src/store/local-storage-events-emitter.ts +++ b/src/store/local-storage-events-emitter.ts @@ -17,12 +17,12 @@ limitations under the License. import { TypedEventEmitter } from "../models/typed-event-emitter"; export enum LocalStorageErrors { - Global = 'Global', - SetItemError = 'setItem', - GetItemError = 'getItem', - RemoveItemError = 'removeItem', - ClearError = 'clear', - QuotaExceededError = 'QuotaExceededError' + Global = "Global", + SetItemError = "setItem", + GetItemError = "getItem", + RemoveItemError = "removeItem", + ClearError = "clear", + QuotaExceededError = "QuotaExceededError", } type EventHandlerMap = { diff --git a/src/store/memory.ts b/src/store/memory.ts index f24ab2d976d..782d7edef77 100644 --- a/src/store/memory.ts +++ b/src/store/memory.ts @@ -16,7 +16,6 @@ limitations under the License. /** * This is an internal module. See {@link MemoryStore} for the public class. - * @module store/memory */ import { EventType } from "../@types/event"; @@ -34,7 +33,8 @@ import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDevice import { IStoredClientOpts } from "../client"; function isValidFilterId(filterId?: string | number | null): boolean { - const isValidStr = typeof filterId === "string" && + const isValidStr = + typeof filterId === "string" && !!filterId && filterId !== "undefined" && // exclude these as we've serialized undefined in localStorage before filterId !== "null"; @@ -43,16 +43,10 @@ function isValidFilterId(filterId?: string | number | null): boolean { } export interface IOpts { + /** The local storage instance to persist some forms of data such as tokens. Rooms will NOT be stored. */ localStorage?: Storage; } -/** - * Construct a new in-memory data store for the Matrix Client. - * @constructor - * @param {Object=} opts Config options - * @param {Storage} opts.localStorage The local storage instance to persist - * some forms of data such as tokens. Rooms will NOT be stored. - */ export class MemoryStore implements IStore { private rooms: Record = {}; // roomId: Room private users: Record = {}; // userId: User @@ -69,26 +63,30 @@ export class MemoryStore implements IStore { private pendingToDeviceBatches: IndexedToDeviceBatch[] = []; private nextToDeviceBatchId = 0; + /** + * Construct a new in-memory data store for the Matrix Client. + * @param opts - Config options + */ public constructor(opts: IOpts = {}) { this.localStorage = opts.localStorage; } /** * Retrieve the token to stream from. - * @return {string} The token or null. + * @returns The token or null. */ public getSyncToken(): string | null { return this.syncToken; } - /** @return {Promise} whether or not the database was newly created in this session. */ + /** @returns whether or not the database was newly created in this session. */ public isNewlyCreated(): Promise { return Promise.resolve(true); } /** * Set the token to stream from. - * @param {string} token The token to stream from. + * @param token - The token to stream from. */ public setSyncToken(token: string): void { this.syncToken = token; @@ -96,7 +94,7 @@ export class MemoryStore implements IStore { /** * Store the given room. - * @param {Room} room The room to be stored. All properties must be stored. + * @param room - The room to be stored. All properties must be stored. */ public storeRoom(room: Room): void { this.rooms[room.roomId] = room; @@ -112,9 +110,6 @@ export class MemoryStore implements IStore { /** * Called when a room member in a room being tracked by this store has been * updated. - * @param {MatrixEvent} event - * @param {RoomState} state - * @param {RoomMember} member */ private onRoomMember = (event: MatrixEvent | null, state: RoomState, member: RoomMember): void => { if (member.membership === "invite") { @@ -138,8 +133,8 @@ export class MemoryStore implements IStore { /** * Retrieve a room by its' room ID. - * @param {string} roomId The room ID. - * @return {Room} The room or null. + * @param roomId - The room ID. + * @returns The room or null. */ public getRoom(roomId: string): Room | null { return this.rooms[roomId] || null; @@ -147,7 +142,7 @@ export class MemoryStore implements IStore { /** * Retrieve all known rooms. - * @return {Room[]} A list of rooms, which may be empty. + * @returns A list of rooms, which may be empty. */ public getRooms(): Room[] { return Object.values(this.rooms); @@ -155,7 +150,6 @@ export class MemoryStore implements IStore { /** * Permanently delete a room. - * @param {string} roomId */ public removeRoom(roomId: string): void { if (this.rooms[roomId]) { @@ -166,17 +160,17 @@ export class MemoryStore implements IStore { /** * Retrieve a summary of all the rooms. - * @return {RoomSummary[]} A summary of each room. + * @returns A summary of each room. */ public getRoomSummaries(): RoomSummary[] { - return Object.values(this.rooms).map(function(room) { + return Object.values(this.rooms).map(function (room) { return room.summary!; }); } /** * Store a User. - * @param {User} user The user to store. + * @param user - The user to store. */ public storeUser(user: User): void { this.users[user.userId] = user; @@ -184,8 +178,8 @@ export class MemoryStore implements IStore { /** * Retrieve a User by its' user ID. - * @param {string} userId The user ID. - * @return {User} The user or null. + * @param userId - The user ID. + * @returns The user or null. */ public getUser(userId: string): User | null { return this.users[userId] || null; @@ -193,7 +187,7 @@ export class MemoryStore implements IStore { /** * Retrieve all known users. - * @return {User[]} A list of users, which may be empty. + * @returns A list of users, which may be empty. */ public getUsers(): User[] { return Object.values(this.users); @@ -201,9 +195,9 @@ export class MemoryStore implements IStore { /** * Retrieve scrollback for this room. - * @param {Room} room The matrix room - * @param {number} limit The max number of old events to retrieve. - * @return {Array} An array of objects which will be at most 'limit' + * @param room - The matrix room + * @param limit - The max number of old events to retrieve. + * @returns An array of objects which will be at most 'limit' * length and at least 0. The objects are the raw event JSON. */ public scrollback(room: Room, limit: number): MatrixEvent[] { @@ -212,18 +206,17 @@ export class MemoryStore implements IStore { /** * Store events for a room. The events have already been added to the timeline - * @param {Room} room The room to store events for. - * @param {Array} events The events to store. - * @param {string} token The token associated with these events. - * @param {boolean} toStart True if these are paginated results. + * @param room - The room to store events for. + * @param events - The events to store. + * @param token - The token associated with these events. + * @param toStart - True if these are paginated results. */ - public storeEvents(room: Room, events: MatrixEvent[], token: string, toStart: boolean): void { + public storeEvents(room: Room, events: MatrixEvent[], token: string | null, toStart: boolean): void { // no-op because they've already been added to the room instance. } /** * Store a filter. - * @param {Filter} filter */ public storeFilter(filter: Filter): void { if (!filter?.userId || !filter?.filterId) return; @@ -235,9 +228,7 @@ export class MemoryStore implements IStore { /** * Retrieve a filter. - * @param {string} userId - * @param {string} filterId - * @return {?Filter} A filter or null. + * @returns A filter or null. */ public getFilter(userId: string, filterId: string): Filter | null { if (!this.filters[userId] || !this.filters[userId][filterId]) { @@ -248,8 +239,8 @@ export class MemoryStore implements IStore { /** * Retrieve a filter ID with the given name. - * @param {string} filterName The filter name. - * @return {?string} The filter ID or null. + * @param filterName - The filter name. + * @returns The filter ID or null. */ public getFilterIdByName(filterName: string): string | null { if (!this.localStorage) { @@ -272,8 +263,6 @@ export class MemoryStore implements IStore { /** * Set a filter name to ID mapping. - * @param {string} filterName - * @param {string} filterId */ public setFilterIdByName(filterName: string, filterId?: string): void { if (!this.localStorage) { @@ -293,7 +282,7 @@ export class MemoryStore implements IStore { * Store user-scoped account data events. * N.B. that account data only allows a single event per type, so multiple * events with the same type will replace each other. - * @param {Array} events The events to store. + * @param events - The events to store. */ public storeAccountDataEvents(events: MatrixEvent[]): void { events.forEach((event) => { @@ -303,8 +292,8 @@ export class MemoryStore implements IStore { /** * Get account data event by event type - * @param {string} eventType The event type being queried - * @return {?MatrixEvent} the user account_data event of given type, if any + * @param eventType - The event type being queried + * @returns the user account_data event of given type, if any */ public getAccountData(eventType: EventType | string): MatrixEvent | undefined { return this.accountData[eventType]; @@ -313,8 +302,8 @@ export class MemoryStore implements IStore { /** * setSyncData does nothing as there is no backing data store. * - * @param {Object} syncData The sync data - * @return {Promise} An immediately resolved promise. + * @param syncData - The sync data + * @returns An immediately resolved promise. */ public setSyncData(syncData: ISyncResponse): Promise { return Promise.resolve(); @@ -323,7 +312,7 @@ export class MemoryStore implements IStore { /** * We never want to save becase we have nothing to save to. * - * @return {boolean} If the store wants to save + * @returns If the store wants to save */ public wantsSave(): boolean { return false; @@ -331,21 +320,21 @@ export class MemoryStore implements IStore { /** * Save does nothing as there is no backing data store. - * @param {bool} force True to force a save (but the memory + * @param force - True to force a save (but the memory * store still can't save anything) */ public save(force: boolean): void {} /** * Startup does nothing as this store doesn't require starting up. - * @return {Promise} An immediately resolved promise. + * @returns An immediately resolved promise. */ public startup(): Promise { return Promise.resolve(); } /** - * @return {Promise} Resolves with a sync response to restore the + * @returns Promise which resolves with a sync response to restore the * client state to where it was at the last save, or null if there * is no saved sync data. */ @@ -354,7 +343,7 @@ export class MemoryStore implements IStore { } /** - * @return {Promise} If there is a saved sync, the nextBatch token + * @returns If there is a saved sync, the nextBatch token * for this sync, otherwise null. */ public getSavedSyncToken(): Promise { @@ -363,7 +352,7 @@ export class MemoryStore implements IStore { /** * Delete all data from this store. - * @return {Promise} An immediately resolved promise. + * @returns An immediately resolved promise. */ public deleteAllData(): Promise { this.rooms = { @@ -387,9 +376,8 @@ export class MemoryStore implements IStore { /** * Returns the out-of-band membership events for this room that * were previously loaded. - * @param {string} roomId - * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members - * @returns {null} in case the members for this room haven't been stored yet + * @returns the events, potentially an empty array if OOB loading didn't yield any new members + * @returns in case the members for this room haven't been stored yet */ public getOutOfBandMembers(roomId: string): Promise { return Promise.resolve(this.oobMembers[roomId] || null); @@ -399,9 +387,8 @@ export class MemoryStore implements IStore { * Stores the out-of-band membership events for this room. Note that * it still makes sense to store an empty array as the OOB status for the room is * marked as fetched, and getOutOfBandMembers will return an empty array instead of null - * @param {string} roomId - * @param {event[]} membershipEvents the membership events to store - * @returns {Promise} when all members have been stored + * @param membershipEvents - the membership events to store + * @returns when all members have been stored */ public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise { this.oobMembers[roomId] = membershipEvents; @@ -448,7 +435,7 @@ export class MemoryStore implements IStore { } public removeToDeviceBatch(id: number): Promise { - this.pendingToDeviceBatches = this.pendingToDeviceBatches.filter(batch => batch.id !== id); + this.pendingToDeviceBatches = this.pendingToDeviceBatches.filter((batch) => batch.id !== id); return Promise.resolve(); } } diff --git a/src/store/stub.ts b/src/store/stub.ts index 746bc521ffb..445f9e8ff27 100644 --- a/src/store/stub.ts +++ b/src/store/stub.ts @@ -16,7 +16,6 @@ limitations under the License. /** * This is an internal module. - * @module store/stub */ import { EventType } from "../@types/event"; @@ -33,20 +32,18 @@ import { IStoredClientOpts } from "../client"; /** * Construct a stub store. This does no-ops on most store methods. - * @constructor */ export class StubStore implements IStore { public readonly accountData = {}; // stub private fromToken: string | null = null; - /** @return {Promise} whether or not the database was newly created in this session. */ + /** @returns whether or not the database was newly created in this session. */ public isNewlyCreated(): Promise { return Promise.resolve(true); } /** * Get the sync token. - * @return {string} */ public getSyncToken(): string | null { return this.fromToken; @@ -54,7 +51,6 @@ export class StubStore implements IStore { /** * Set the sync token. - * @param {string} token */ public setSyncToken(token: string): void { this.fromToken = token; @@ -62,14 +58,11 @@ export class StubStore implements IStore { /** * No-op. - * @param {Room} room */ public storeRoom(room: Room): void {} /** * No-op. - * @param {string} roomId - * @return {null} */ public getRoom(roomId: string): Room | null { return null; @@ -77,7 +70,7 @@ export class StubStore implements IStore { /** * No-op. - * @return {Array} An empty array. + * @returns An empty array. */ public getRooms(): Room[] { return []; @@ -85,7 +78,6 @@ export class StubStore implements IStore { /** * Permanently delete a room. - * @param {string} roomId */ public removeRoom(roomId: string): void { return; @@ -93,7 +85,7 @@ export class StubStore implements IStore { /** * No-op. - * @return {Array} An empty array. + * @returns An empty array. */ public getRoomSummaries(): RoomSummary[] { return []; @@ -101,14 +93,11 @@ export class StubStore implements IStore { /** * No-op. - * @param {User} user */ public storeUser(user: User): void {} /** * No-op. - * @param {string} userId - * @return {null} */ public getUser(userId: string): User | null { return null; @@ -116,7 +105,6 @@ export class StubStore implements IStore { /** * No-op. - * @return {User[]} */ public getUsers(): User[] { return []; @@ -124,9 +112,6 @@ export class StubStore implements IStore { /** * No-op. - * @param {Room} room - * @param {number} limit - * @return {Array} */ public scrollback(room: Room, limit: number): MatrixEvent[] { return []; @@ -134,24 +119,21 @@ export class StubStore implements IStore { /** * Store events for a room. - * @param {Room} room The room to store events for. - * @param {Array} events The events to store. - * @param {string} token The token associated with these events. - * @param {boolean} toStart True if these are paginated results. + * @param room - The room to store events for. + * @param events - The events to store. + * @param token - The token associated with these events. + * @param toStart - True if these are paginated results. */ - public storeEvents(room: Room, events: MatrixEvent[], token: string, toStart: boolean): void {} + public storeEvents(room: Room, events: MatrixEvent[], token: string | null, toStart: boolean): void {} /** * Store a filter. - * @param {Filter} filter */ public storeFilter(filter: Filter): void {} /** * Retrieve a filter. - * @param {string} userId - * @param {string} filterId - * @return {?Filter} A filter or null. + * @returns A filter or null. */ public getFilter(userId: string, filterId: string): Filter | null { return null; @@ -159,8 +141,8 @@ export class StubStore implements IStore { /** * Retrieve a filter ID with the given name. - * @param {string} filterName The filter name. - * @return {?string} The filter ID or null. + * @param filterName - The filter name. + * @returns The filter ID or null. */ public getFilterIdByName(filterName: string): string | null { return null; @@ -168,20 +150,18 @@ export class StubStore implements IStore { /** * Set a filter name to ID mapping. - * @param {string} filterName - * @param {string} filterId */ public setFilterIdByName(filterName: string, filterId?: string): void {} /** * Store user-scoped account data events - * @param {Array} events The events to store. + * @param events - The events to store. */ public storeAccountDataEvents(events: MatrixEvent[]): void {} /** * Get account data event by event type - * @param {string} eventType The event type being queried + * @param eventType - The event type being queried */ public getAccountData(eventType: EventType | string): MatrixEvent | undefined { return undefined; @@ -190,8 +170,8 @@ export class StubStore implements IStore { /** * setSyncData does nothing as there is no backing data store. * - * @param {Object} syncData The sync data - * @return {Promise} An immediately resolved promise. + * @param syncData - The sync data + * @returns An immediately resolved promise. */ public setSyncData(syncData: ISyncResponse): Promise { return Promise.resolve(); @@ -200,7 +180,7 @@ export class StubStore implements IStore { /** * We never want to save because we have nothing to save to. * - * @return {boolean} If the store wants to save + * @returns If the store wants to save */ public wantsSave(): boolean { return false; @@ -213,14 +193,14 @@ export class StubStore implements IStore { /** * Startup does nothing. - * @return {Promise} An immediately resolved promise. + * @returns An immediately resolved promise. */ public startup(): Promise { return Promise.resolve(); } /** - * @return {Promise} Resolves with a sync response to restore the + * @returns Promise which resolves with a sync response to restore the * client state to where it was at the last save, or null if there * is no saved sync data. */ @@ -229,7 +209,7 @@ export class StubStore implements IStore { } /** - * @return {Promise} If there is a saved sync, the nextBatch token + * @returns If there is a saved sync, the nextBatch token * for this sync, otherwise null. */ public getSavedSyncToken(): Promise { @@ -239,7 +219,7 @@ export class StubStore implements IStore { /** * Delete all data from this store. Does nothing since this store * doesn't store anything. - * @return {Promise} An immediately resolved promise. + * @returns An immediately resolved promise. */ public deleteAllData(): Promise { return Promise.resolve(); diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index 19d5cf1312e..be1e8a16ffa 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -16,18 +16,24 @@ limitations under the License. /** * This is an internal module. See {@link SyncAccumulator} for the public class. - * @module sync-accumulator */ -import { logger } from './logger'; +import { logger } from "./logger"; import { deepCopy, isSupportedReceiptType } from "./utils"; import { IContent, IUnsigned } from "./models/event"; import { IRoomSummary } from "./models/room-summary"; import { EventType } from "./@types/event"; import { MAIN_ROOM_TIMELINE, ReceiptContent, ReceiptType } from "./@types/read_receipts"; -import { UNREAD_THREAD_NOTIFICATIONS } from './@types/sync'; +import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync"; interface IOpts { + /** + * The ideal maximum number of timeline entries to keep in the sync response. + * This is best-effort, as clients do not always have a back-pagination token for each event, + * so it's possible there may be slightly *less* than this value. There will never be more. + * This cannot be 0 or else it makes it impossible to scroll back in a room. + * Default: 50. + */ maxTimelineEntries?: number; } @@ -71,13 +77,13 @@ export interface ITimeline { } export interface IJoinedRoom { - summary: IRoomSummary; - state: IState; - timeline: ITimeline; - ephemeral: IEphemeral; - account_data: IAccountData; - unread_notifications: UnreadNotificationCounts; - unread_thread_notifications?: Record; + "summary": IRoomSummary; + "state": IState; + "timeline": ITimeline; + "ephemeral": IEphemeral; + "account_data": IAccountData; + "unread_notifications": UnreadNotificationCounts; + "unread_thread_notifications"?: Record; "org.matrix.msc3773.unread_thread_notifications"?: Record; } @@ -116,7 +122,7 @@ interface IAccountData { events: IMinimalEvent[]; } -interface IToDeviceEvent { +export interface IToDeviceEvent { content: IContent; sender: string; type: string; @@ -127,18 +133,21 @@ interface IToDevice { } interface IDeviceLists { - changed: string[]; - left: string[]; + changed?: string[]; + left?: string[]; } export interface ISyncResponse { - next_batch: string; - rooms: IRooms; - presence?: IPresence; - account_data: IAccountData; - to_device?: IToDevice; - device_lists?: IDeviceLists; - device_one_time_keys_count?: Record; + "next_batch": string; + "rooms": IRooms; + "presence"?: IPresence; + "account_data": IAccountData; + "to_device"?: IToDevice; + "device_lists"?: IDeviceLists; + "device_one_time_keys_count"?: Record; + + "device_unused_fallback_key_types"?: string[]; + "org.matrix.msc2732.device_unused_fallback_key_types"?: string[]; } /* eslint-enable camelcase */ @@ -182,6 +191,12 @@ export interface ISyncData { roomsData: IRooms; } +type TaggedEvent = IRoomEvent & { _localTs?: number }; + +function isTaggedEvent(event: IRoomEvent): event is TaggedEvent { + return "_localTs" in event && event["_localTs"] !== undefined; +} + /** * The purpose of this class is to accumulate /sync responses such that a * complete "initial" JSON response can be returned which accurately represents @@ -202,15 +217,6 @@ export class SyncAccumulator { // streaming from without losing events. private nextBatch: string | null = null; - /** - * @param {Object} opts - * @param {Number=} opts.maxTimelineEntries The ideal maximum number of - * timeline entries to keep in the sync response. This is best-effort, as - * clients do not always have a back-pagination token for each event, so - * it's possible there may be slightly *less* than this value. There will - * never be more. This cannot be 0 or else it makes it impossible to scroll - * back in a room. Default: 50. - */ public constructor(private readonly opts: IOpts = {}) { this.opts.maxTimelineEntries = this.opts.maxTimelineEntries || 50; } @@ -233,8 +239,8 @@ export class SyncAccumulator { /** * Accumulate incremental /sync room data. - * @param {Object} syncResponse the complete /sync JSON - * @param {boolean} fromDatabase True if the sync response is one saved to the database + * @param syncResponse - the complete /sync JSON + * @param fromDatabase - True if the sync response is one saved to the database */ private accumulateRooms(syncResponse: ISyncResponse, fromDatabase = false): void { if (!syncResponse.rooms) { @@ -278,7 +284,8 @@ export class SyncAccumulator { break; case Category.Join: - if (this.inviteRooms[roomId]) { // (1) + if (this.inviteRooms[roomId]) { + // (1) // was previously invite, now join. We expect /sync to give // the entire state and timeline on 'join', so delete previous // invite state @@ -289,9 +296,11 @@ export class SyncAccumulator { break; case Category.Leave: - if (this.inviteRooms[roomId]) { // (4) + if (this.inviteRooms[roomId]) { + // (4) delete this.inviteRooms[roomId]; - } else { // (2) + } else { + // (2) delete this.joinRooms[roomId]; } break; @@ -302,7 +311,8 @@ export class SyncAccumulator { } private accumulateInviteState(roomId: string, data: IInvitedRoom): void { - if (!data.invite_state || !data.invite_state.events) { // no new data + if (!data.invite_state || !data.invite_state.events) { + // no new data return; } if (!this.inviteRooms[roomId]) { @@ -394,9 +404,8 @@ 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; + currentData._unreadThreadNotifications = + data[UNREAD_THREAD_NOTIFICATIONS.stable!] ?? data[UNREAD_THREAD_NOTIFICATIONS.unstable!] ?? undefined; if (data.summary) { const HEROES_KEY = "m.heroes"; @@ -473,48 +482,40 @@ export class SyncAccumulator { // - existing state which didn't come down /sync. // - State events under the 'state' key. // - State events in the 'timeline'. - if (data.state && data.state.events) { - data.state.events.forEach((e) => { - setState(currentData._currentState, e); - }); - } - if (data.timeline && data.timeline.events) { - data.timeline.events.forEach((e, index) => { - // this nops if 'e' isn't a state event - setState(currentData._currentState, e); - // append the event to the timeline. The back-pagination token - // corresponds to the first event in the timeline - let transformedEvent: IRoomEvent & { _localTs?: number }; - if (!fromDatabase) { - transformedEvent = Object.assign({}, e); - if (transformedEvent.unsigned !== undefined) { - transformedEvent.unsigned = Object.assign({}, transformedEvent.unsigned); - } - const age = e.unsigned ? e.unsigned.age : e.age; - if (age !== undefined) transformedEvent._localTs = Date.now() - age; - } else { - transformedEvent = e; + data.state?.events?.forEach((e) => { + setState(currentData._currentState, e); + }); + data.timeline?.events?.forEach((e, index) => { + // this nops if 'e' isn't a state event + setState(currentData._currentState, e); + // append the event to the timeline. The back-pagination token + // corresponds to the first event in the timeline + let transformedEvent: TaggedEvent; + if (!fromDatabase) { + transformedEvent = Object.assign({}, e); + if (transformedEvent.unsigned !== undefined) { + transformedEvent.unsigned = Object.assign({}, transformedEvent.unsigned); } + const age = e.unsigned ? e.unsigned.age : e.age; + if (age !== undefined) transformedEvent._localTs = Date.now() - age; + } else { + transformedEvent = e; + } - currentData._timeline.push({ - event: transformedEvent, - token: index === 0 ? (data.timeline.prev_batch ?? null) : null, - }); + currentData._timeline.push({ + event: transformedEvent, + token: index === 0 ? data.timeline.prev_batch ?? null : null, }); - } + }); // attempt to prune the timeline by jumping between events which have // pagination tokens. if (currentData._timeline.length > this.opts.maxTimelineEntries!) { - const startIndex = ( - currentData._timeline.length - this.opts.maxTimelineEntries! - ); + const startIndex = currentData._timeline.length - this.opts.maxTimelineEntries!; for (let i = startIndex; i < currentData._timeline.length; i++) { if (currentData._timeline[i].token) { // keep all events after this, including this one - currentData._timeline = currentData._timeline.slice( - i, currentData._timeline.length, - ); + currentData._timeline = currentData._timeline.slice(i, currentData._timeline.length); break; } } @@ -526,8 +527,8 @@ export class SyncAccumulator { * represents all room data that should be stored. This should be paired * with the sync token which represents the most recent /sync response * provided to accumulate(). - * @param {boolean} forDatabase True to generate a sync to be saved to storage - * @return {Object} An object with a "nextBatch", "roomsData" and "accountData" + * @param forDatabase - True to generate a sync to be saved to storage + * @returns An object with a "nextBatch", "roomsData" and "accountData" * keys. * The "nextBatch" key is a string which represents at what point in the * /sync stream the accumulator reached. This token should be used when @@ -581,7 +582,7 @@ export class SyncAccumulator { room_id: roomId, content: { // $event_id: { "m.read": { $user_id: $json } } - }, + } as IContent, }; for (const [userId, receiptData] of Object.entries(roomData._readReceipts)) { @@ -591,9 +592,7 @@ export class SyncAccumulator { if (!receiptEvent.content[receiptData.eventId][receiptData.type]) { receiptEvent.content[receiptData.eventId][receiptData.type] = {}; } - receiptEvent.content[receiptData.eventId][receiptData.type][userId] = ( - receiptData.data - ); + receiptEvent.content[receiptData.eventId][receiptData.type][userId] = receiptData.data; } for (const threadReceipts of Object.values(roomData._threadReadReceipts)) { @@ -604,9 +603,7 @@ export class SyncAccumulator { if (!receiptEvent.content[receiptData.eventId][receiptData.type]) { receiptEvent.content[receiptData.eventId][receiptData.type] = {}; } - receiptEvent.content[receiptData.eventId][receiptData.type][userId] = ( - receiptData.data - ); + receiptEvent.content[receiptData.eventId][receiptData.type][userId] = receiptData.data; } } // add only if we have some receipt data @@ -626,8 +623,8 @@ export class SyncAccumulator { } let transformedEvent: (IRoomEvent | IStateEvent) & { _localTs?: number }; - if (!forDatabase && msgData.event["_localTs"]) { - // This means we have to copy each event so we can fix it up to + if (!forDatabase && isTaggedEvent(msgData.event)) { + // This means we have to copy each event, so we can fix it up to // set a correct 'age' parameter whilst keeping the local timestamp // on our stored event. If this turns out to be a bottleneck, it could // be optimised either by doing this in the main process after the data @@ -641,7 +638,7 @@ export class SyncAccumulator { } delete transformedEvent._localTs; transformedEvent.unsigned = transformedEvent.unsigned || {}; - transformedEvent.unsigned.age = Date.now() - msgData.event["_localTs"]; + transformedEvent.unsigned.age = Date.now() - msgData.event._localTs!; } else { transformedEvent = msgData.event; } @@ -652,10 +649,12 @@ export class SyncAccumulator { // by "reverse clobbering" from the end of the timeline to the start. // Convert maps back into arrays. const rollBackState = Object.create(null); - for (let i = roomJson.timeline.events.length - 1; i >=0; i--) { + for (let i = roomJson.timeline.events.length - 1; i >= 0; i--) { const timelineEvent = roomJson.timeline.events[i]; - if ((timelineEvent as IStateEvent).state_key === null || - (timelineEvent as IStateEvent).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 f32ccf7d239..0169ff51186 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -32,8 +32,8 @@ import { IDeferred } from "./utils"; import { Filter } from "./filter"; import { EventTimeline } from "./models/event-timeline"; import { PushProcessor } from "./pushprocessor"; -import { logger } from './logger'; -import { InvalidStoreError, InvalidStoreState } from './errors'; +import { logger } from "./logger"; +import { InvalidStoreError, InvalidStoreState } from "./errors"; import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering } from "./client"; import { IEphemeral, @@ -96,9 +96,7 @@ export enum SyncState { // Room versions where "insertion", "batch", and "marker" events are controlled // by power-levels. MSC2716 is supported in existing room versions but they // should only have special meaning when the room creator sends them. -const MSC2716_ROOM_VERSIONS = [ - 'org.matrix.msc2716v3', -]; +const MSC2716_ROOM_VERSIONS = ["org.matrix.msc2716v3"]; function getFilterName(userId: string, suffix?: string): string { // scope this on the user ID because people may login on many accounts @@ -106,7 +104,8 @@ function getFilterName(userId: string, suffix?: string): string { return `FILTER_SYNC_${userId}` + (suffix ? "_" + suffix : ""); } -function debuglog(...params): void { +/* istanbul ignore next */ +function debuglog(...params: any[]): void { if (!DEBUG) return; logger.log(...params); } @@ -117,9 +116,27 @@ interface ISyncOptions { } export interface ISyncStateData { + /** + * The matrix error if `state=ERROR`. + */ error?: Error; + /** + * The 'since' token passed to /sync. + * `null` for the first successful sync since this client was + * started. Only present if `state=PREPARED` or + * `state=SYNCING`. + */ oldSyncToken?: string; + /** + * The 'next_batch' result from /sync, which + * will become the 'since' token for the next call to /sync. Only present if + * `state=PREPARED or state=SYNCING`. + */ nextSyncToken?: string; + /** + * True if we are working our way through a + * backlog of events after connecting. Only present if `state=SYNCING`. + */ catchingUp?: boolean; fromCache?: boolean; } @@ -146,21 +163,6 @@ type WrappedRoom = T & { isBrandNewRoom: boolean; }; -/** - * Internal class - unstable. - * Construct an entity which is able to sync with a homeserver. - * @constructor - * @param {MatrixClient} client The matrix client instance to use. - * @param {Object} opts Config options - * @param {module:crypto=} opts.crypto Crypto manager - * @param {Function=} opts.canResetEntireTimeline A function which is called - * with a room ID and returns a boolean. It should return 'true' if the SDK can - * SAFELY remove events from this room. It may not be safe to remove events if - * there are other references to the timelines for this room. - * Default: returns false. - * @param {Boolean=} opts.disablePresence True to perform syncing without automatically - * updating presence. - */ export class SyncApi { private _peekRoom: Optional = null; private currentSyncRequest?: Promise; @@ -175,10 +177,16 @@ export class SyncApi { 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 + /** + * Construct an entity which is able to sync with a homeserver. + * @param client - The matrix client instance to use. + * @param opts - Config options + * @internal + */ public constructor(private readonly client: MatrixClient, private readonly opts: Partial = {}) { this.opts.initialSyncLimit = this.opts.initialSyncLimit ?? 8; this.opts.resolveInvitesToProfiles = this.opts.resolveInvitesToProfiles || false; - this.opts.pollTimeout = this.opts.pollTimeout || (30 * 1000); + this.opts.pollTimeout = this.opts.pollTimeout || 30 * 1000; this.opts.pendingEventOrdering = this.opts.pendingEventOrdering || PendingEventOrdering.Chronological; this.opts.experimentalThreadSupport = this.opts.experimentalThreadSupport === true; @@ -189,17 +197,10 @@ export class SyncApi { } if (client.getNotifTimelineSet()) { - client.reEmitter.reEmit(client.getNotifTimelineSet()!, [ - RoomEvent.Timeline, - RoomEvent.TimelineReset, - ]); + client.reEmitter.reEmit(client.getNotifTimelineSet()!, [RoomEvent.Timeline, RoomEvent.TimelineReset]); } } - /** - * @param {string} roomId - * @return {Room} - */ public createRoom(roomId: string): Room { const room = _createAndReEmitRoom(this.client, roomId, this.opts); @@ -214,11 +215,11 @@ export class SyncApi { * new historical messages imported by MSC2716 `/batch_send` somewhere in * the room and we need to throw away the timeline to make sure the * 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 {IMarkerFoundOptions} setStateOptions When `timelineWasEmpty` is set + * @param room - The room where the marker event was sent + * @param markerEvent - The new marker event + * @param setStateOptions - When `timelineWasEmpty` is set * as `true`, the given marker event will be ignored - */ + */ private onMarkerStateEvent( room: Room, markerEvent: MatrixEvent, @@ -232,7 +233,7 @@ export class SyncApi { if (timelineWasEmpty) { logger.debug( `MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} ` + - `because the timeline was empty before the marker arrived which means there is nothing to refresh.`, + `because the timeline was empty before the marker arrived which means there is nothing to refresh.`, ); return; } @@ -264,22 +265,22 @@ export class SyncApi { // refresh the timeline. logger.debug( `MarkerState: Timeline needs to be refreshed because ` + - `a new markerEventId=${markerEvent.getId()} was sent in roomId=${room.roomId}`, + `a new markerEventId=${markerEvent.getId()} was sent in roomId=${room.roomId}`, ); room.setTimelineNeedsRefresh(true); room.emit(RoomEvent.HistoryImportedWithinTimeline, markerEvent, room); } else { logger.debug( `MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} because ` + - `MSC2716 is not supported in the room version or for any room version, the marker wasn't sent ` + - `by the room creator.`, + `MSC2716 is not supported in the room version or for any room version, the marker wasn't sent ` + + `by the room creator.`, ); } } /** * Sync rooms the user has left. - * @return {Promise} Resolved when they've been added to the store. + * @returns Resolved when they've been added to the store. */ public async syncLeftRooms(): Promise { const client = this.client; @@ -292,7 +293,8 @@ export class SyncApi { const localTimeoutMs = this.opts.pollTimeout! + BUFFER_PERIOD_MS; const filterId = await client.getOrCreateFilter( - getFilterName(client.credentials.userId!, "LEFT_ROOMS"), filter, + getFilterName(client.credentials.userId!, "LEFT_ROOMS"), + filter, ); const qps: ISyncParams = { @@ -309,40 +311,42 @@ export class SyncApi { leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave); } - const rooms = await Promise.all(leaveRooms.map(async (leaveObj) => { - const room = leaveObj.room; - if (!leaveObj.isBrandNewRoom) { - // the intention behind syncLeftRooms is to add in rooms which were - // *omitted* from the initial /sync. Rooms the user were joined to - // but then left whilst the app is running will appear in this list - // and we do not want to bother with them since they will have the - // current state already (and may get dupe messages if we add - // yet more timeline events!), so skip them. - // NB: When we persist rooms to localStorage this will be more - // complicated... - return; - } - leaveObj.timeline = leaveObj.timeline || { - prev_batch: null, - events: [], - }; - const events = this.mapSyncEventsFormat(leaveObj.timeline, room); + const rooms = await Promise.all( + leaveRooms.map(async (leaveObj) => { + const room = leaveObj.room; + if (!leaveObj.isBrandNewRoom) { + // the intention behind syncLeftRooms is to add in rooms which were + // *omitted* from the initial /sync. Rooms the user were joined to + // but then left whilst the app is running will appear in this list + // and we do not want to bother with them since they will have the + // current state already (and may get dupe messages if we add + // yet more timeline events!), so skip them. + // NB: When we persist rooms to localStorage this will be more + // complicated... + return; + } + leaveObj.timeline = leaveObj.timeline || { + prev_batch: null, + events: [], + }; + const events = this.mapSyncEventsFormat(leaveObj.timeline, room); - const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); + const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); - // set the back-pagination token. Do this *before* adding any - // events so that clients can start back-paginating. - room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, EventTimeline.BACKWARDS); + // set the back-pagination token. Do this *before* adding any + // events so that clients can start back-paginating. + room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, EventTimeline.BACKWARDS); - await this.injectRoomEvents(room, stateEvents, events); + await this.injectRoomEvents(room, stateEvents, events); - room.recalculate(); - client.store.storeRoom(room); - client.emit(ClientEvent.Room, room); + room.recalculate(); + client.store.storeRoom(room); + client.emit(ClientEvent.Room, room); - this.processEventsForNotifs(room, events); - return room; - })); + this.processEventsForNotifs(room, events); + return room; + }), + ); return rooms.filter(Boolean) as Room[]; } @@ -350,8 +354,8 @@ export class SyncApi { /** * Peek into a room. This will result in the room in question being synced so it * is accessible via getRooms(). Live updates for the room will be provided. - * @param {string} roomId The room ID to peek into. - * @return {Promise} A promise which resolves once the room has been added to the + * @param roomId - The room ID to peek into. + * @returns A promise which resolves once the room has been added to the * store. */ public peek(roomId: string): Promise { @@ -369,26 +373,24 @@ export class SyncApi { // FIXME: Mostly duplicated from injectRoomEvents but not entirely // because "state" in this API is at the BEGINNING of the chunk - const oldStateEvents = utils.deepCopy(response.state) - .map(client.getEventMapper()); + const oldStateEvents = utils.deepCopy(response.state).map(client.getEventMapper()); const stateEvents = response.state.map(client.getEventMapper()); const messages = response.messages.chunk.map(client.getEventMapper()); // XXX: copypasted from /sync until we kill off this minging v1 API stuff) // handle presence events (User objects) if (Array.isArray(response.presence)) { - response.presence.map(client.getEventMapper()).forEach( - function(presenceEvent) { - let user = client.store.getUser(presenceEvent.getContent().user_id); - if (user) { - user.setPresenceEvent(presenceEvent); - } else { - user = createNewUser(client, presenceEvent.getContent().user_id); - user.setPresenceEvent(presenceEvent); - client.store.storeUser(user); - } - client.emit(ClientEvent.Event, presenceEvent); - }); + response.presence.map(client.getEventMapper()).forEach(function (presenceEvent) { + let user = client.store.getUser(presenceEvent.getContent().user_id); + if (user) { + user.setPresenceEvent(presenceEvent); + } else { + user = createNewUser(client, presenceEvent.getContent().user_id); + user.setPresenceEvent(presenceEvent); + client.store.storeUser(user); + } + client.emit(ClientEvent.Event, presenceEvent); + }); } // set the pagination token before adding the events in case people @@ -408,9 +410,12 @@ export class SyncApi { // roll backwards to diverge old state. addEventsToTimeline // will overwrite the pagination token, so make sure it overwrites // it with the right thing. - this._peekRoom!.addEventsToTimeline(messages.reverse(), true, + this._peekRoom!.addEventsToTimeline( + messages.reverse(), + true, this._peekRoom!.getLiveTimeline(), - response.messages.start); + response.messages.start, + ); client.store.storeRoom(this._peekRoom!); client.emit(ClientEvent.Room, this._peekRoom!); @@ -430,8 +435,7 @@ export class SyncApi { /** * Do a peek room poll. - * @param {Room} peekRoom - * @param {string?} token from= token + * @param token - from= token */ private peekPoll(peekRoom: Room, token?: string): void { if (this._peekRoom !== peekRoom) { @@ -440,62 +444,77 @@ export class SyncApi { } // FIXME: gut wrenching; hard-coded timeout values - this.client.http.authedRequest(Method.Get, "/events", { - room_id: peekRoom.roomId, - timeout: String(30 * 1000), - from: token, - }, undefined, { - localTimeoutMs: 50 * 1000, - abortSignal: this.abortController?.signal, - }).then((res) => { - if (this._peekRoom !== peekRoom) { - debuglog("Stopped peeking in room %s", peekRoom.roomId); - return; - } - // We have a problem that we get presence both from /events and /sync - // however, /sync only returns presence for users in rooms - // you're actually joined to. - // in order to be sure to get presence for all of the users in the - // peeked room, we handle presence explicitly here. This may result - // in duplicate presence events firing for some users, which is a - // performance drain, but such is life. - // XXX: copypasted from /sync until we can kill this minging v1 stuff. - - res.chunk.filter(function(e) { - return e.type === "m.presence"; - }).map(this.client.getEventMapper()).forEach((presenceEvent) => { - let user = this.client.store.getUser(presenceEvent.getContent().user_id); - if (user) { - user.setPresenceEvent(presenceEvent); - } else { - user = createNewUser(this.client, presenceEvent.getContent().user_id); - user.setPresenceEvent(presenceEvent); - this.client.store.storeUser(user); - } - this.client.emit(ClientEvent.Event, presenceEvent); - }); - - // strip out events which aren't for the given room_id (e.g presence) - // and also ephemeral events (which we're assuming is anything without - // and event ID because the /events API doesn't separate them). - const events = res.chunk.filter(function(e) { - return e.room_id === peekRoom.roomId && e.event_id; - }).map(this.client.getEventMapper()); - - peekRoom.addLiveEvents(events); - this.peekPoll(peekRoom, res.end); - }, (err) => { - logger.error("[%s] Peek poll failed: %s", peekRoom.roomId, err); - setTimeout(() => { - this.peekPoll(peekRoom, token); - }, 30 * 1000); - }); + this.client.http + .authedRequest( + Method.Get, + "/events", + { + room_id: peekRoom.roomId, + timeout: String(30 * 1000), + from: token, + }, + undefined, + { + localTimeoutMs: 50 * 1000, + abortSignal: this.abortController?.signal, + }, + ) + .then( + (res) => { + if (this._peekRoom !== peekRoom) { + debuglog("Stopped peeking in room %s", peekRoom.roomId); + return; + } + // We have a problem that we get presence both from /events and /sync + // however, /sync only returns presence for users in rooms + // you're actually joined to. + // in order to be sure to get presence for all of the users in the + // peeked room, we handle presence explicitly here. This may result + // in duplicate presence events firing for some users, which is a + // performance drain, but such is life. + // XXX: copypasted from /sync until we can kill this minging v1 stuff. + + res.chunk + .filter(function (e) { + return e.type === "m.presence"; + }) + .map(this.client.getEventMapper()) + .forEach((presenceEvent) => { + let user = this.client.store.getUser(presenceEvent.getContent().user_id); + if (user) { + user.setPresenceEvent(presenceEvent); + } else { + user = createNewUser(this.client, presenceEvent.getContent().user_id); + user.setPresenceEvent(presenceEvent); + this.client.store.storeUser(user); + } + this.client.emit(ClientEvent.Event, presenceEvent); + }); + + // strip out events which aren't for the given room_id (e.g presence) + // and also ephemeral events (which we're assuming is anything without + // and event ID because the /events API doesn't separate them). + const events = res.chunk + .filter(function (e) { + return e.room_id === peekRoom.roomId && e.event_id; + }) + .map(this.client.getEventMapper()); + + peekRoom.addLiveEvents(events); + this.peekPoll(peekRoom, res.end); + }, + (err) => { + logger.error("[%s] Peek poll failed: %s", peekRoom.roomId, err); + setTimeout(() => { + this.peekPoll(peekRoom, token); + }, 30 * 1000); + }, + ); } /** * Returns the current state of this sync object - * @see module:client~MatrixClient#event:"sync" - * @return {?String} + * @see MatrixClient#event:"sync" */ public getSyncState(): SyncState | null { return this.syncState; @@ -507,7 +526,6 @@ export class SyncApi { * such data. * Sync errors, if available, are put in the 'error' key of * this object. - * @return {?Object} */ public getSyncStateData(): ISyncStateData | null { return this.syncStateData ?? null; @@ -526,8 +544,8 @@ export class SyncApi { /** * Is the lazy loading option different than in previous session? - * @param {boolean} lazyLoadMembers current options for lazy loading - * @return {boolean} whether or not the option has changed compared to the previous session */ + * @param lazyLoadMembers - current options for lazy loading + * @returns whether or not the option has changed compared to the previous session */ private async wasLazyLoadingToggled(lazyLoadMembers = false): Promise { // assume it was turned off before // if we don't know any better @@ -595,8 +613,7 @@ export class SyncApi { } this.opts.filter.setLazyLoadMembers(true); } else { - debuglog("LL: lazy loading requested but not supported " + - "by server, so disabling"); + debuglog("LL: lazy loading requested but not supported " + "by server, so disabling"); this.opts.lazyLoadMembers = false; } } @@ -674,19 +691,22 @@ export class SyncApi { // 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 => { + 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); - }); + 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: @@ -758,7 +778,7 @@ export class SyncApi { /** * Retry a backed off syncing request immediately. This should only be used when * the user explicitly attempts to retry their lost connection. - * @return {boolean} True if this resulted in a request being retried. + * @returns True if this resulted in a request being retried. */ public retryImmediately(): boolean { if (!this.connectionReturnedDefer) { @@ -769,7 +789,7 @@ export class SyncApi { } /** * Process a single set of cached sync data. - * @param {Object} savedSync a saved sync that was persisted by a store. This + * @param savedSync - a saved sync that was persisted by a store. This * should have been acquired via client.store.getSavedSync(). */ private async syncFromCache(savedSync: ISavedSync): Promise { @@ -811,9 +831,6 @@ export class SyncApi { /** * Invoke me to do /sync calls - * @param {Object} syncOptions - * @param {string} syncOptions.filterId - * @param {boolean} syncOptions.hasSyncedBefore */ private async doSync(syncOptions: ISyncOptions): Promise { while (this.running) { @@ -985,7 +1002,7 @@ export class SyncApi { } this.failedSyncCount++; - logger.log('Number of consecutive failed sync requests:', this.failedSyncCount); + logger.log("Number of consecutive failed sync requests:", this.failedSyncCount); debuglog("Starting keep-alive"); // Note that we do *not* mark the sync connection as @@ -1000,8 +1017,7 @@ export class SyncApi { this.currentSyncRequest = undefined; // Transition from RECONNECTING to ERROR after a given number of failed syncs this.updateSyncState( - this.failedSyncCount >= FAILED_SYNC_ERROR_THRESHOLD ? - SyncState.Error : SyncState.Reconnecting, + this.failedSyncCount >= FAILED_SYNC_ERROR_THRESHOLD ? SyncState.Error : SyncState.Reconnecting, { error: err }, ); @@ -1023,8 +1039,8 @@ export class SyncApi { * Process data returned from a sync response and propagate it * into the model objects * - * @param {Object} syncEventData Object containing sync tokens associated with this sync - * @param {Object} data The response from /sync + * @param syncEventData - Object containing sync tokens associated with this sync + * @param data - The response from /sync */ private async processSyncResponse(syncEventData: ISyncStateData, data: ISyncResponse): Promise { const client = this.client; @@ -1076,71 +1092,66 @@ export class SyncApi { // handle presence events (User objects) if (Array.isArray(data.presence?.events)) { - data.presence!.events.map(client.getEventMapper()).forEach( - function(presenceEvent) { - let user = client.store.getUser(presenceEvent.getSender()!); - if (user) { - user.setPresenceEvent(presenceEvent); - } else { - user = createNewUser(client, presenceEvent.getSender()!); - user.setPresenceEvent(presenceEvent); - client.store.storeUser(user); - } - client.emit(ClientEvent.Event, presenceEvent); - }); + data.presence!.events.map(client.getEventMapper()).forEach(function (presenceEvent) { + let user = client.store.getUser(presenceEvent.getSender()!); + if (user) { + user.setPresenceEvent(presenceEvent); + } else { + user = createNewUser(client, presenceEvent.getSender()!); + user.setPresenceEvent(presenceEvent); + client.store.storeUser(user); + } + client.emit(ClientEvent.Event, presenceEvent); + }); } // handle non-room account_data if (Array.isArray(data.account_data?.events)) { const events = data.account_data.events.map(client.getEventMapper()); - const prevEventsMap = events.reduce((m, c) => { + const prevEventsMap = events.reduce>((m, c) => { m[c.getType()!] = client.store.getAccountData(c.getType()); return m; }, {}); client.store.storeAccountDataEvents(events); - events.forEach( - function(accountDataEvent) { - // Honour push rules that come down the sync stream but also - // honour push rules that were previously cached. Base rules - // will be updated when we receive push rules via getPushRules - // (see sync) before syncing over the network. - if (accountDataEvent.getType() === EventType.PushRules) { - const rules = accountDataEvent.getContent(); - client.pushRules = PushProcessor.rewriteDefaultRules(rules); - } - const prevEvent = prevEventsMap[accountDataEvent.getType()!]; - client.emit(ClientEvent.AccountData, accountDataEvent, prevEvent); - return accountDataEvent; - }, - ); + events.forEach(function (accountDataEvent) { + // Honour push rules that come down the sync stream but also + // honour push rules that were previously cached. Base rules + // will be updated when we receive push rules via getPushRules + // (see sync) before syncing over the network. + if (accountDataEvent.getType() === EventType.PushRules) { + const rules = accountDataEvent.getContent(); + client.pushRules = PushProcessor.rewriteDefaultRules(rules); + } + const prevEvent = prevEventsMap[accountDataEvent.getType()!]; + client.emit(ClientEvent.AccountData, accountDataEvent, prevEvent); + return accountDataEvent; + }); } // handle to-device events if (Array.isArray(data.to_device?.events) && data.to_device!.events.length > 0) { const cancelledKeyVerificationTxns: string[] = []; - data.to_device!.events - .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; - } + data.to_device!.events.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; - }) + return true; + }) .map(client.getEventMapper({ toDevice: true })) - .map((toDeviceEvent) => { // map is a cheap inline forEach + .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 // we pull out the transaction IDs from the cancellation events // so we can flag the verification events as cancelled in the loop // below. if (toDeviceEvent.getType() === "m.key.verification.cancel") { - const txnId: string = toDeviceEvent.getContent()['transaction_id']; + const txnId: string = toDeviceEvent.getContent()["transaction_id"]; if (txnId) { cancelledKeyVerificationTxns.push(txnId); } @@ -1150,32 +1161,26 @@ export class SyncApi { // the unmodified event. return toDeviceEvent; }) - .forEach( - function(toDeviceEvent) { - const content = toDeviceEvent.getContent(); - if ( - toDeviceEvent.getType() == "m.room.message" && - content.msgtype == "m.bad.encrypted" - ) { - // the mapper already logged a warning. - logger.log( - 'Ignoring undecryptable to-device event from ' + - toDeviceEvent.getSender(), - ); - return; - } + .forEach(function (toDeviceEvent) { + const content = toDeviceEvent.getContent(); + if (toDeviceEvent.getType() == "m.room.message" && content.msgtype == "m.bad.encrypted") { + // the mapper already logged a warning. + logger.log("Ignoring undecryptable to-device event from " + toDeviceEvent.getSender()); + return; + } - if (toDeviceEvent.getType() === "m.key.verification.start" - || toDeviceEvent.getType() === "m.key.verification.request") { - const txnId = content['transaction_id']; - if (cancelledKeyVerificationTxns.includes(txnId)) { - toDeviceEvent.flagCancelled(); - } + if ( + toDeviceEvent.getType() === "m.key.verification.start" || + toDeviceEvent.getType() === "m.key.verification.request" + ) { + const txnId = content["transaction_id"]; + if (cancelledKeyVerificationTxns.includes(txnId)) { + toDeviceEvent.flagCancelled(); } + } - client.emit(ClientEvent.ToDeviceEvent, toDeviceEvent); - }, - ); + client.emit(ClientEvent.ToDeviceEvent, toDeviceEvent); + }); } else { // no more to-device events: we can stop polling with a short timeout. this.catchingUp = false; @@ -1237,7 +1242,7 @@ export class SyncApi { // Update room state for invite->reject->invite cycles room.recalculate(); } - stateEvents.forEach(function(e) { + stateEvents.forEach(function (e) { client.emit(ClientEvent.Event, e); }); }); @@ -1274,8 +1279,8 @@ export class SyncApi { } } - const unreadThreadNotifications = joinObj[UNREAD_THREAD_NOTIFICATIONS.name] - ?? joinObj[UNREAD_THREAD_NOTIFICATIONS.altName!]; + const unreadThreadNotifications = + joinObj[UNREAD_THREAD_NOTIFICATIONS.name] ?? joinObj[UNREAD_THREAD_NOTIFICATIONS.altName!]; if (unreadThreadNotifications) { // Only partially reset unread notification // We want to keep the client-generated count. Particularly important @@ -1303,7 +1308,7 @@ export class SyncApi { room.resetThreadUnreadNotificationCount(); } - joinObj.timeline = joinObj.timeline || {} as ITimeline; + joinObj.timeline = joinObj.timeline || ({} as ITimeline); if (joinObj.isBrandNewRoom) { // set the back-pagination token. Do this *before* adding any @@ -1351,8 +1356,7 @@ export class SyncApi { if (limited) { room.resetLiveTimeline( joinObj.timeline.prev_batch, - this.opts.canResetEntireTimeline!(room.roomId) ? - null : (syncEventData.oldSyncToken ?? null), + this.opts.canResetEntireTimeline!(room.roomId) ? null : syncEventData.oldSyncToken ?? null, ); // We have to assume any gap in any timeline is @@ -1362,6 +1366,18 @@ export class SyncApi { } } + // process any crypto events *before* emitting the RoomStateEvent events. This + // avoids a race condition if the application tries to send a message after the + // state event is processed, but before crypto is enabled, which then causes the + // crypto layer to complain. + if (this.opts.crypto) { + for (const e of stateEvents.concat(events)) { + if (e.isState() && e.getType() === EventType.RoomEncryption && e.getStateKey() === "") { + await this.opts.crypto.onCryptoEvent(room, e); + } + } + } + try { await this.injectRoomEvents(room, stateEvents, events, syncEventData.fromCache); } catch (e) { @@ -1389,21 +1405,11 @@ export class SyncApi { this.processEventsForNotifs(room, events); - const processRoomEvent = async (e): Promise => { - client.emit(ClientEvent.Event, e); - if (e.isState() && e.getType() == "m.room.encryption" && this.opts.crypto) { - await this.opts.crypto.onCryptoEvent(e); - } - }; - - await utils.promiseMapSeries(stateEvents, processRoomEvent); - await utils.promiseMapSeries(events, processRoomEvent); - ephemeralEvents.forEach(function(e) { - client.emit(ClientEvent.Event, e); - }); - accountDataEvents.forEach(function(e) { - client.emit(ClientEvent.Event, e); - }); + const emitEvent = (e: MatrixEvent): boolean => client.emit(ClientEvent.Event, e); + stateEvents.forEach(emitEvent); + events.forEach(emitEvent); + ephemeralEvents.forEach(emitEvent); + accountDataEvents.forEach(emitEvent); // 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 @@ -1429,13 +1435,13 @@ export class SyncApi { this.processEventsForNotifs(room, events); - stateEvents.forEach(function(e) { + stateEvents.forEach(function (e) { client.emit(ClientEvent.Event, e); }); - events.forEach(function(e) { + events.forEach(function (e) { client.emit(ClientEvent.Event, e); }); - accountDataEvents.forEach(function(e) { + accountDataEvents.forEach(function (e) { client.emit(ClientEvent.Event, e); }); }); @@ -1446,10 +1452,10 @@ export class SyncApi { // XXX: we could fix this by making EventTimeline support chronological // ordering... but it doesn't, right now. if (syncEventData.oldSyncToken && this.notifEvents.length) { - this.notifEvents.sort(function(a, b) { + this.notifEvents.sort(function (a, b) { return a.getTs() - b.getTs(); }); - this.notifEvents.forEach(function(event) { + this.notifEvents.forEach(function (event) { client.getNotifTimelineSet()?.addLiveEvent(event); }); } @@ -1470,27 +1476,27 @@ export class SyncApi { const currentCount = data.device_one_time_keys_count.signed_curve25519 || 0; this.opts.crypto.updateOneTimeKeyCount(currentCount); } - if (this.opts.crypto && - (data["device_unused_fallback_key_types"] || - data["org.matrix.msc2732.device_unused_fallback_key_types"])) { + if ( + this.opts.crypto && + (data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"]) + ) { // The presence of device_unused_fallback_key_types indicates that the // server supports fallback keys. If there's no unused // signed_curve25519 fallback key we need a new one. - const unusedFallbackKeys = data["device_unused_fallback_key_types"] || - data["org.matrix.msc2732.device_unused_fallback_key_types"]; + const unusedFallbackKeys = + data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"]; this.opts.crypto.setNeedsNewFallback( - Array.isArray(unusedFallbackKeys) && - !unusedFallbackKeys.includes("signed_curve25519"), + Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"), ); } } /** * Starts polling the connectivity check endpoint - * @param {number} delay How long to delay until the first poll. + * @param delay - How long to delay until the first poll. * defaults to a short, randomised interval (to prevent * tight-looping if /versions succeeds but /sync etc. fail). - * @return {promise} which resolves once the connection returns + * @returns which resolves once the connection returns */ private startKeepAlives(delay?: number): Promise { if (delay === undefined) { @@ -1518,7 +1524,7 @@ export class SyncApi { * On failure, schedules a call back to itself. On success, resolves * this.connectionReturnedDefer. * - * @param {boolean} connDidFail True if a connectivity failure has been detected. Optional. + * @param connDidFail - True if a connectivity failure has been detected. Optional. */ private pokeKeepAlive(connDidFail = false): void { const success = (): void => { @@ -1529,46 +1535,48 @@ export class SyncApi { } }; - this.client.http.request( - Method.Get, "/_matrix/client/versions", - undefined, // queryParams - undefined, // data - { - prefix: '', - localTimeoutMs: 15 * 1000, - abortSignal: this.abortController?.signal, - }, - ).then(() => { - success(); - }, (err) => { - if (err.httpStatus == 400 || err.httpStatus == 404) { - // treat this as a success because the server probably just doesn't - // support /versions: point is, we're getting a response. - // We wait a short time though, just in case somehow the server - // is in a mode where it 400s /versions responses and sync etc. - // responses fail, this will mean we don't hammer in a loop. - this.keepAliveTimer = setTimeout(success, 2000); - } else { - connDidFail = true; - this.keepAliveTimer = setTimeout( - this.pokeKeepAlive.bind(this, connDidFail), - 5000 + Math.floor(Math.random() * 5000), - ); - // A keepalive has failed, so we emit the - // error state (whether or not this is the - // first failure). - // Note we do this after setting the timer: - // this lets the unit tests advance the mock - // clock when they get the error. - this.updateSyncState(SyncState.Error, { error: err }); - } - }); + this.client.http + .request( + Method.Get, + "/_matrix/client/versions", + undefined, // queryParams + undefined, // data + { + prefix: "", + localTimeoutMs: 15 * 1000, + abortSignal: this.abortController?.signal, + }, + ) + .then( + () => { + success(); + }, + (err) => { + if (err.httpStatus == 400 || err.httpStatus == 404) { + // treat this as a success because the server probably just doesn't + // support /versions: point is, we're getting a response. + // We wait a short time though, just in case somehow the server + // is in a mode where it 400s /versions responses and sync etc. + // responses fail, this will mean we don't hammer in a loop. + this.keepAliveTimer = setTimeout(success, 2000); + } else { + connDidFail = true; + this.keepAliveTimer = setTimeout( + this.pokeKeepAlive.bind(this, connDidFail), + 5000 + Math.floor(Math.random() * 5000), + ); + // A keepalive has failed, so we emit the + // error state (whether or not this is the + // first failure). + // Note we do this after setting the timer: + // this lets the unit tests advance the mock + // clock when they get the error. + this.updateSyncState(SyncState.Error, { error: err }); + } + }, + ); } - /** - * @param {Object} obj - * @return {Object[]} - */ private mapSyncResponseToRoomArray( obj: Record, ): Array> { @@ -1577,7 +1585,7 @@ export class SyncApi { // [{stuff+Room+isBrandNewRoom}, {stuff+Room+isBrandNewRoom}] const client = this.client; return Object.keys(obj).map((roomId) => { - const arrObj = obj[roomId] as T & { room: Room, isBrandNewRoom: boolean }; + const arrObj = obj[roomId] as T & { room: Room; isBrandNewRoom: boolean }; let room = client.store.getRoom(roomId); let isBrandNewRoom = false; if (!room) { @@ -1590,12 +1598,6 @@ export class SyncApi { }); } - /** - * @param {Object} obj - * @param {Room} room - * @param {boolean} decrypt - * @return {MatrixEvent[]} - */ private mapSyncEventsFormat( obj: IInviteState | ITimeline | IEphemeral, room?: Room, @@ -1605,16 +1607,16 @@ export class SyncApi { return []; } const mapper = this.client.getEventMapper({ decrypt }); - return (obj.events as Array).map(function(e) { + type TaggedEvent = (IStrippedState | IRoomEvent | IStateEvent | IMinimalEvent) & { room_id?: string }; + return (obj.events as TaggedEvent[]).map(function (e) { if (room) { - e["room_id"] = room.roomId; + e.room_id = room.roomId; } return mapper(e); }); } /** - * @param {Room} room */ private resolveInvites(room: Room): void { if (!room || !this.opts.resolveInvitesToProfiles) { @@ -1623,7 +1625,7 @@ export class SyncApi { const client = this.client; // For each invited room member we want to give them a displayname/avatar url // if they have one (the m.room.member invites don't contain this). - room.getMembersWithMembership("invite").forEach(function(member) { + room.getMembersWithMembership("invite").forEach(function (member) { if (member.requestedProfileInfo) return; member.requestedProfileInfo = true; // try to get a cached copy first. @@ -1637,33 +1639,35 @@ export class SyncApi { } else { promise = client.getProfileInfo(member.userId); } - promise.then(function(info) { - // slightly naughty by doctoring the invite event but this means all - // the code paths remain the same between invite/join display name stuff - // which is a worthy trade-off for some minor pollution. - const inviteEvent = member.events.member; - if (inviteEvent?.getContent().membership !== "invite") { - // between resolving and now they have since joined, so don't clobber - return; - } - inviteEvent.getContent().avatar_url = info.avatar_url; - inviteEvent.getContent().displayname = info.displayname; - // fire listeners - member.setMembershipEvent(inviteEvent, room.currentState); - }, function(err) { - // OH WELL. - }); + promise.then( + function (info) { + // slightly naughty by doctoring the invite event but this means all + // the code paths remain the same between invite/join display name stuff + // which is a worthy trade-off for some minor pollution. + const inviteEvent = member.events.member; + if (inviteEvent?.getContent().membership !== "invite") { + // between resolving and now they have since joined, so don't clobber + return; + } + inviteEvent.getContent().avatar_url = info.avatar_url; + inviteEvent.getContent().displayname = info.displayname; + // fire listeners + member.setMembershipEvent(inviteEvent, room.currentState); + }, + function (err) { + // OH WELL. + }, + ); }); } /** * Injects events into a room's model. - * @param {Room} room - * @param {MatrixEvent[]} stateEventList A list of state events. This is the state + * @param stateEventList - A list of state events. This is the state * at the *START* of the timeline list if it is supplied. - * @param {MatrixEvent[]} [timelineEventList] A list of timeline events, including threaded. Lower index + * @param timelineEventList - A list of timeline events, including threaded. Lower index * is earlier in time. Higher index is later. - * @param {boolean} fromCache whether the sync response came from cache + * @param fromCache - whether the sync response came from cache */ public async injectRoomEvents( room: Room, @@ -1739,8 +1743,7 @@ export class SyncApi { * as appropriate. * This must be called after the room the events belong to has been stored. * - * @param {Room} room - * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index + * @param timelineEventList - A list of timeline events. Lower index * is earlier in time. Higher index is later. */ private processEventsForNotifs(room: Room, timelineEventList: MatrixEvent[]): void { @@ -1755,9 +1758,6 @@ export class SyncApi { } } - /** - * @return {string} - */ private getGuestFilter(): string { // Dev note: This used to be conditional to return a filter of 20 events maximum, but // the condition never went to the other branch. This is now hardcoded. @@ -1766,8 +1766,8 @@ export class SyncApi { /** * Sets the sync state and emits an event to say so - * @param {String} newState The new state string - * @param {Object} data Object of additional data to emit in the event + * @param newState - The new state string + * @param data - Object of additional data to emit in the event */ private updateSyncState(newState: SyncState, data?: ISyncStateData): void { const old = this.syncState; diff --git a/src/timeline-window.ts b/src/timeline-window.ts index 5498600a99c..be64c3b8d7c 100644 --- a/src/timeline-window.ts +++ b/src/timeline-window.ts @@ -14,34 +14,37 @@ See the License for the specific language governing permissions and limitations under the License. */ -/** @module timeline-window */ - import { Optional } from "matrix-events-sdk"; -import { Direction, EventTimeline } from './models/event-timeline'; -import { logger } from './logger'; +import { Direction, EventTimeline } from "./models/event-timeline"; +import { logger } from "./logger"; import { MatrixClient } from "./client"; import { EventTimelineSet } from "./models/event-timeline-set"; import { MatrixEvent } from "./models/event"; /** - * @private + * @internal */ const DEBUG = false; /** - * @private + * @internal */ -const debuglog = DEBUG ? logger.log.bind(logger) : function(): void {}; +/* istanbul ignore next */ +const debuglog = DEBUG ? logger.log.bind(logger) : function (): void {}; /** * the number of times we ask the server for more events before giving up * - * @private + * @internal */ const DEFAULT_PAGINATE_LOOP_LIMIT = 5; interface IOpts { + /** + * Maximum number of events to keep in the window. If more events are retrieved via pagination requests, + * excess events will be dropped from the other end of the window. + */ windowLimit?: number; } @@ -58,31 +61,22 @@ export class TimelineWindow { /** * Construct a TimelineWindow. * - *

This abstracts the separate timelines in a Matrix {@link - * module:models/room|Room} into a single iterable thing. It keeps track of - * the start and endpoints of the window, which can be advanced with the help + *

This abstracts the separate timelines in a Matrix {@link Room} into a single iterable thing. + * It keeps track of the start and endpoints of the window, which can be advanced with the help * of pagination requests. * - *

Before the window is useful, it must be initialised by calling {@link - * module:timeline-window~TimelineWindow#load|load}. + *

Before the window is useful, it must be initialised by calling {@link TimelineWindow#load}. * *

Note that the window will not automatically extend itself when new events - * are received from /sync; you should arrange to call {@link - * module:timeline-window~TimelineWindow#paginate|paginate} on {@link - * module:client~MatrixClient.event:"Room.timeline"|Room.timeline} events. + * are received from /sync; you should arrange to call {@link TimelineWindow#paginate} + * on {@link RoomEvent.Timeline} events. * - * @param {MatrixClient} client MatrixClient to be used for context/pagination + * @param client - MatrixClient to be used for context/pagination * requests. * - * @param {EventTimelineSet} timelineSet The timelineSet to track + * @param timelineSet - The timelineSet to track * - * @param {Object} [opts] Configuration options for this window - * - * @param {number} [opts.windowLimit = 1000] maximum number of events to keep - * in the window. If more events are retrieved via pagination requests, - * excess events will be dropped from the other end of the window. - * - * @constructor + * @param opts - Configuration options for this window */ public constructor( private readonly client: MatrixClient, @@ -95,11 +89,9 @@ export class TimelineWindow { /** * Initialise the window to point at a given event, or the live timeline * - * @param {string} [initialEventId] If given, the window will contain the + * @param initialEventId - If given, the window will contain the * given event - * @param {number} [initialWindowSize = 20] Size of the initial window - * - * @return {Promise} + * @param initialWindowSize - Size of the initial window */ public load(initialEventId?: string, initialWindowSize = 20): Promise { // given an EventTimeline, find the event we were looking for, and initialise our @@ -117,7 +109,7 @@ export class TimelineWindow { // we were looking for the live timeline: initialise to the end eventIndex = events.length; } else { - eventIndex = events.findIndex(e => e.getId() === initialEventId); + eventIndex = events.findIndex((e) => e.getId() === initialEventId); if (eventIndex < 0) { throw new Error("getEventTimeline result didn't include requested event"); @@ -137,8 +129,7 @@ export class TimelineWindow { initFields(this.timelineSet.getTimelineForEvent(initialEventId)); return Promise.resolve(); } else if (initialEventId) { - return this.client.getEventTimeline(this.timelineSet, initialEventId) - .then(initFields); + return this.client.getEventTimeline(this.timelineSet, initialEventId).then(initFields); } else { initFields(this.timelineSet.getLiveTimeline()); return Promise.resolve(); @@ -148,11 +139,11 @@ export class TimelineWindow { /** * Get the TimelineIndex of the window in the given direction. * - * @param {string} direction EventTimeline.BACKWARDS to get the TimelineIndex + * @param direction - EventTimeline.BACKWARDS to get the TimelineIndex * at the start of the window; EventTimeline.FORWARDS to get the TimelineIndex at * the end. * - * @return {TimelineIndex} The requested timeline index if one exists, null + * @returns The requested timeline index if one exists, null * otherwise. */ public getTimelineIndex(direction: Direction): TimelineIndex | null { @@ -169,11 +160,11 @@ export class TimelineWindow { * Try to extend the window using events that are already in the underlying * TimelineIndex. * - * @param {string} direction EventTimeline.BACKWARDS to try extending it + * @param direction - EventTimeline.BACKWARDS to try extending it * backwards; EventTimeline.FORWARDS to try extending it forwards. - * @param {number} size number of events to try to extend by. + * @param size - number of events to try to extend by. * - * @return {boolean} true if the window was extended, false otherwise. + * @returns true if the window was extended, false otherwise. */ public extend(direction: Direction, size: number): boolean { const tl = this.getTimelineIndex(direction); @@ -183,13 +174,11 @@ export class TimelineWindow { return false; } - const count = (direction == EventTimeline.BACKWARDS) ? - tl.retreat(size) : tl.advance(size); + const count = direction == EventTimeline.BACKWARDS ? tl.retreat(size) : tl.advance(size); if (count) { this.eventCount += count; - debuglog("TimelineWindow: increased cap by " + count + - " (now " + this.eventCount + ")"); + debuglog("TimelineWindow: increased cap by " + count + " (now " + this.eventCount + ")"); // remove some events from the other end, if necessary const excess = this.eventCount - this.windowLimit; if (excess > 0) { @@ -209,10 +198,10 @@ export class TimelineWindow { * necessarily mean that there are more events available in that direction at * this time. * - * @param {string} direction EventTimeline.BACKWARDS to check if we can + * @param direction - EventTimeline.BACKWARDS to check if we can * paginate backwards; EventTimeline.FORWARDS to check if we can go forwards * - * @return {boolean} true if we can paginate in the given direction + * @returns true if we can paginate in the given direction */ public canPaginate(direction: Direction): boolean { const tl = this.getTimelineIndex(direction); @@ -240,23 +229,23 @@ export class TimelineWindow { /** * Attempt to extend the window * - * @param {string} direction EventTimeline.BACKWARDS to extend the window + * @param direction - EventTimeline.BACKWARDS to extend the window * backwards (towards older events); EventTimeline.FORWARDS to go forwards. * - * @param {number} size number of events to try to extend by. If fewer than this + * @param size - number of events to try to extend by. If fewer than this * number are immediately available, then we return immediately rather than * making an API call. * - * @param {boolean} [makeRequest = true] whether we should make API calls to + * @param makeRequest - whether we should make API calls to * fetch further events if we don't have any at all. (This has no effect if * the room already knows about additional events in the relevant direction, * even if there are fewer than 'size' of them, as we will just return those * we already know about.) * - * @param {number} [requestLimit = 5] limit for the number of API requests we + * @param requestLimit - limit for the number of API requests we * should make. * - * @return {Promise} Resolves to a boolean which is true if more events + * @returns Promise which resolves to a boolean which is true if more events * were successfully retrieved. */ public async paginate( @@ -298,31 +287,34 @@ export class TimelineWindow { debuglog("TimelineWindow: starting request"); - const prom = this.client.paginateEventTimeline(tl.timeline, { - backwards: direction == EventTimeline.BACKWARDS, - limit: size, - }).finally(function() { - tl.pendingPaginate = undefined; - }).then((r) => { - debuglog("TimelineWindow: request completed with result " + r); - if (!r) { - return this.paginate(direction, size, false, 0); - } + const prom = this.client + .paginateEventTimeline(tl.timeline, { + backwards: direction == EventTimeline.BACKWARDS, + limit: size, + }) + .finally(function () { + tl.pendingPaginate = undefined; + }) + .then((r) => { + debuglog("TimelineWindow: request completed with result " + r); + if (!r) { + return this.paginate(direction, size, false, 0); + } - // recurse to advance the index into the results. - // - // If we don't get any new events, we want to make sure we keep asking - // the server for events for as long as we have a valid pagination - // token. In particular, we want to know if we've actually hit the - // start of the timeline, or if we just happened to know about all of - // the events thanks to https://matrix.org/jira/browse/SYN-645. - // - // On the other hand, we necessarily want to wait forever for the - // server to make its mind up about whether there are other events, - // because it gives a bad user experience - // (https://github.com/vector-im/vector-web/issues/1204). - return this.paginate(direction, size, true, requestLimit - 1); - }); + // recurse to advance the index into the results. + // + // If we don't get any new events, we want to make sure we keep asking + // the server for events for as long as we have a valid pagination + // token. In particular, we want to know if we've actually hit the + // start of the timeline, or if we just happened to know about all of + // the events thanks to https://matrix.org/jira/browse/SYN-645. + // + // On the other hand, we necessarily want to wait forever for the + // server to make its mind up about whether there are other events, + // because it gives a bad user experience + // (https://github.com/vector-im/vector-web/issues/1204). + return this.paginate(direction, size, true, requestLimit - 1); + }); tl.pendingPaginate = prom; return prom; } @@ -330,8 +322,8 @@ export class TimelineWindow { /** * Remove `delta` events from the start or end of the timeline. * - * @param {number} delta number of events to remove from the timeline - * @param {boolean} startOfTimeline if events should be removed from the start + * @param delta - number of events to remove from the timeline + * @param startOfTimeline - if events should be removed from the start * of the timeline. */ public unpaginate(delta: number, startOfTimeline: boolean): void { @@ -353,22 +345,19 @@ export class TimelineWindow { const count = startOfTimeline ? tl.advance(delta) : tl.retreat(delta); if (count <= 0) { // sadness. This shouldn't be possible. - throw new Error( - "Unable to unpaginate any further, but still have " + - this.eventCount + " events"); + throw new Error("Unable to unpaginate any further, but still have " + this.eventCount + " events"); } delta -= count; this.eventCount -= count; - debuglog("TimelineWindow.unpaginate: dropped " + count + - " (now " + this.eventCount + ")"); + debuglog("TimelineWindow.unpaginate: dropped " + count + " (now " + this.eventCount + ")"); } } /** * Get a list of the events currently in the window * - * @return {MatrixEvent[]} the events in the window + * @returns the events in the window */ public getEvents(): MatrixEvent[] { if (!this.start) { @@ -419,12 +408,8 @@ export class TimelineWindow { } /** - * a thing which contains a timeline reference, and an index into it. - * - * @constructor - * @param {EventTimeline} timeline - * @param {number} index - * @private + * A thing which contains a timeline reference, and an index into it. + * @internal */ export class TimelineIndex { public pendingPaginate?: Promise; @@ -433,7 +418,7 @@ export class TimelineIndex { public constructor(public timeline: EventTimeline, public index: number) {} /** - * @return {number} the minimum possible value for the index in the current + * @returns the minimum possible value for the index in the current * timeline */ public minIndex(): number { @@ -441,7 +426,7 @@ export class TimelineIndex { } /** - * @return {number} the maximum possible value for the index in the current + * @returns the maximum possible value for the index in the current * timeline (exclusive - ie, it actually returns one more than the index * of the last element). */ @@ -452,8 +437,8 @@ export class TimelineIndex { /** * Try move the index forward, or into the neighbouring timeline * - * @param {number} delta number of events to advance by - * @return {number} number of events successfully advanced by + * @param delta - number of events to advance by + * @returns number of events successfully advanced by */ public advance(delta: number): number { if (!delta) { @@ -491,7 +476,8 @@ export class TimelineIndex { // // next see if there is a neighbouring timeline to switch to. const neighbour = this.timeline.getNeighbouringTimeline( - delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS); + delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS, + ); if (neighbour) { this.timeline = neighbour; if (delta < 0) { @@ -512,8 +498,8 @@ export class TimelineIndex { /** * Try move the index backwards, or into the neighbouring timeline * - * @param {number} delta number of events to retreat by - * @return {number} number of events successfully retreated by + * @param delta - number of events to retreat by + * @returns number of events successfully retreated by */ public retreat(delta: number): number { return this.advance(delta * -1) * -1; diff --git a/src/utils.ts b/src/utils.ts index 71871d3b737..5134c8a4d06 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -17,7 +17,6 @@ limitations under the License. /** * This is an internal module. - * @module utils */ import unhomoglyph from "unhomoglyph"; @@ -33,7 +32,7 @@ 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. + * @param str - The string to internalise. * @returns The internalised string. */ export function internaliseString(str: string): string { @@ -55,16 +54,16 @@ export function internaliseString(str: string): string { /** * Encode a dictionary of query parameters. * Omits any undefined/null values. - * @param {Object} params A dict of key/values to encode e.g. - * {"foo": "bar", "baz": "taz"} - * @return {string} The encoded string e.g. foo=bar&baz=taz + * @param params - A dict of key/values to encode e.g. + * `{"foo": "bar", "baz": "taz"}` + * @returns The encoded string e.g. foo=bar&baz=taz */ export function encodeParams(params: QueryDict, urlSearchParams?: URLSearchParams): URLSearchParams { const searchParams = urlSearchParams ?? new URLSearchParams(); for (const [key, val] of Object.entries(params)) { if (val !== undefined && val !== null) { if (Array.isArray(val)) { - val.forEach(v => { + val.forEach((v) => { searchParams.append(key, String(v)); }); } else { @@ -79,15 +78,8 @@ export type QueryDict = Record { /** * Encodes a URI according to a set of template variables. Variables will be * passed through encodeURIComponent. - * @param {string} pathTemplate The path with template variables e.g. '/foo/$bar'. - * @param {Object} variables The key/value pairs to replace the template - * variables with. E.g. { "$bar": "baz" }. - * @return {string} The result of replacing all template variables e.g. '/foo/baz'. + * @param pathTemplate - The path with template variables e.g. '/foo/$bar'. + * @param variables - The key/value pairs to replace the template + * variables with. E.g. `{ "$bar": "baz" }`. + * @returns The result of replacing all template variables e.g. '/foo/baz'. */ export function encodeUri(pathTemplate: string, variables: Record>): string { for (const key in variables) { @@ -132,9 +124,7 @@ export function encodeUri(pathTemplate: string, variables: Recordfn(element, index, array). Return true to + * @param array - The array. + * @param fn - Function to execute on each value in the array, with the + * function signature `fn(element, index, array)`. Return true to * remove this element and break. - * @param {boolean} reverse True to search in reverse order. - * @return {boolean} True if an element was removed. + * @param reverse - True to search in reverse order. + * @returns True if an element was removed. */ -export function removeElement( - array: T[], - fn: (t: T, i?: number, a?: T[]) => boolean, - reverse?: boolean, -): boolean { +export function removeElement(array: T[], fn: (t: T, i?: number, a?: T[]) => boolean, reverse?: boolean): boolean { let i: number; if (reverse) { for (i = array.length - 1; i >= 0; i--) { @@ -175,8 +161,8 @@ export function removeElement( /** * Checks if the given thing is a function. - * @param {*} value The thing to check. - * @return {boolean} True if it is a function. + * @param value - The thing to check. + * @returns True if it is a function. */ export function isFunction(value: any): boolean { return Object.prototype.toString.call(value) === "[object Function]"; @@ -184,8 +170,8 @@ export function isFunction(value: any): boolean { /** * Checks that the given object has the specified keys. - * @param {Object} obj The object to check. - * @param {string[]} keys The list of keys that 'obj' must have. + * @param obj - The object to check. + * @param keys - The list of keys that 'obj' must have. * @throws If the object is missing keys. */ // note using 'keys' here would shadow the 'keys' function defined above @@ -200,8 +186,8 @@ export function checkObjectHasKeys(obj: object, keys: string[]): void { /** * Deep copy the given object. The object MUST NOT have circular references and * MUST NOT have functions. - * @param {Object} obj The object to deep copy. - * @return {Object} A copy of the object without any references to the original. + * @param obj - The object to deep copy. + * @returns A copy of the object without any references to the original. */ export function deepCopy(obj: T): T { return JSON.parse(JSON.stringify(obj)); @@ -210,10 +196,10 @@ export function deepCopy(obj: T): T { /** * Compare two objects for equality. The objects MUST NOT have circular references. * - * @param {Object} x The first object to compare. - * @param {Object} y The second object to compare. + * @param x - The first object to compare. + * @param y - The second object to compare. * - * @return {boolean} true if the two objects are equal + * @returns true if the two objects are equal */ export function deepCompare(x: any, y: any): boolean { // Inspired by @@ -230,7 +216,7 @@ export function deepCompare(x: any, y: any): boolean { } // special-case NaN (since NaN !== NaN) - if (typeof x === 'number' && isNaN(x) && isNaN(y)) { + if (typeof x === "number" && isNaN(x) && isNaN(y)) { return true; } @@ -290,11 +276,11 @@ export function deepCompare(x: any, y: any): boolean { * sorts the result by key, recursively. The input object must * ensure it does not have loops. If the input is not an object * then it will be returned as-is. - * @param {*} obj The object to get entries of - * @returns {Array} The entries, sorted by key. + * @param obj - The object to get entries of + * @returns The entries, sorted by key. */ export function deepSortedObjectEntries(obj: any): [string, any][] { - if (typeof(obj) !== "object") return obj; + if (typeof obj !== "object") return obj; // Apparently these are object types... if (obj === null || obj === undefined || Array.isArray(obj)) return obj; @@ -313,34 +299,33 @@ export function deepSortedObjectEntries(obj: any): [string, any][] { /** * Returns whether the given value is a finite number without type-coercion * - * @param {*} value the value to test - * @return {boolean} whether or not value is a finite number without type-coercion + * @param value - the value to test + * @returns whether or not value is a finite number without type-coercion */ -export function isNumber(value: any): boolean { - return typeof value === 'number' && isFinite(value); +export function isNumber(value: any): value is number { + return typeof value === "number" && isFinite(value); } /** * Removes zero width chars, diacritics and whitespace from the string * Also applies an unhomoglyph on the string, to prevent similar looking chars - * @param {string} str the string to remove hidden characters from - * @return {string} a string with the hidden characters removed + * @param str - the string to remove hidden characters from + * @returns a string with the hidden characters removed */ export function removeHiddenChars(str: string): string { if (typeof str === "string") { - return unhomoglyph(str.normalize('NFD').replace(removeHiddenCharsRegex, '')); + return unhomoglyph(str.normalize("NFD").replace(removeHiddenCharsRegex, "")); } return ""; } /** * Removes the direction override characters from a string - * @param {string} input * @returns string with chars removed */ export function removeDirectionOverrideChars(str: string): string { if (typeof str === "string") { - return str.replace(/[\u202d-\u202e]/g, ''); + return str.replace(/[\u202d-\u202e]/g, ""); } return ""; } @@ -348,11 +333,13 @@ export function removeDirectionOverrideChars(str: string): string { export function normalize(str: string): string { // Note: we have to match the filter with the removeHiddenChars() because the // function strips spaces and other characters (M becomes RN for example, in lowercase). - return removeHiddenChars(str.toLowerCase()) - // Strip all punctuation - .replace(/[\\'!"#$%&()*+,\-./:;<=>?@[\]^_`{|}~\u2000-\u206f\u2e00-\u2e7f]/g, "") - // We also doubly convert to lowercase to work around oddities of the library. - .toLowerCase(); + return ( + removeHiddenChars(str.toLowerCase()) + // Strip all punctuation + .replace(/[\\'!"#$%&()*+,\-./:;<=>?@[\]^_`{|}~\u2000-\u206f\u2e00-\u2e7f]/g, "") + // We also doubly convert to lowercase to work around oddities of the library. + .toLowerCase() + ); } // Regex matching bunch of unicode control characters and otherwise misleading/invisible characters. @@ -376,24 +363,20 @@ export function globToRegexp(glob: string, extended = false): string { // https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132 // Because micromatch is about 130KB with dependencies, // and minimatch is not much better. - const replacements: ([RegExp, string | ((substring: string, ...args: any[]) => string) ])[] = [ - [/\\\*/g, '.*'], - [/\?/g, '.'], + const replacements: [RegExp, string | ((substring: string, ...args: any[]) => string)][] = [ + [/\\\*/g, ".*"], + [/\?/g, "."], ]; if (!extended) { replacements.push([ /\\\[(!|)(.*)\\]/g, - (_match: string, neg: string, pat: string): string => [ - '[', - neg ? '^' : '', - pat.replace(/\\-/, '-'), - ']', - ].join(''), + (_match: string, neg: string, pat: string): string => + ["[", neg ? "^" : "", pat.replace(/\\-/, "-"), "]"].join(""), ]); } return replacements.reduce( // https://github.com/microsoft/TypeScript/issues/30134 - (pat, args) => args ? pat.replace(args[0], args[1] as any) : pat, + (pat, args) => (args ? pat.replace(args[0], args[1] as any) : pat), escapeRegExp(glob), ); } @@ -411,9 +394,9 @@ export function ensureNoTrailingSlash(url?: string): string | undefined { // Returns a promise which resolves with a given value after the given number of ms export function sleep(ms: number, value?: T): Promise { - return new Promise((resolve => { + return new Promise((resolve) => { setTimeout(resolve, ms, value); - })); + }); } export function isNullOrUndefined(val: any): boolean { @@ -428,8 +411,8 @@ export interface IDeferred { // Returns a Deferred export function defer(): IDeferred { - let resolve; - let reject; + let resolve!: IDeferred["resolve"]; + let reject!: IDeferred["reject"]; const promise = new Promise((_resolve, _reject) => { resolve = _resolve; @@ -456,7 +439,7 @@ export function promiseTry(fn: () => T | Promise): Promise { export async function chunkPromises(fns: (() => Promise)[], chunkSize: number): Promise { const results: T[] = []; for (let i = 0; i < fns.length; i += chunkSize) { - results.push(...(await Promise.all(fns.slice(i, i + chunkSize).map(fn => fn())))); + results.push(...(await Promise.all(fns.slice(i, i + chunkSize).map((fn) => fn())))); } return results; } @@ -466,19 +449,22 @@ export async function chunkPromises(fns: (() => Promise)[], chunkSize: num * a promise which throws/rejects on error, otherwise the retry will assume the request * succeeded. The promise chain returned will contain the successful promise. The given function * should always return a new promise. - * @param {Function} promiseFn The function to call to get a fresh promise instance. Takes an + * @param promiseFn - The function to call to get a fresh promise instance. Takes an * attempt count as an argument, for logging/debugging purposes. - * @returns {Promise} The promise for the retried operation. + * @returns The promise for the retried operation. */ export function simpleRetryOperation(promiseFn: (attempt: number) => Promise): Promise { - return promiseRetry((attempt: number) => { - return promiseFn(attempt); - }, { - forever: true, - factor: 2, - minTimeout: 3000, // ms - maxTimeout: 15000, // ms - }); + return promiseRetry( + (attempt: number) => { + return promiseFn(attempt); + }, + { + forever: true, + factor: 2, + minTimeout: 3000, // ms + maxTimeout: 15000, // ms + }, + ); } // String averaging inspired by https://stackoverflow.com/a/2510816 @@ -492,7 +478,7 @@ export function simpleRetryOperation(promiseFn: (attempt: number) => Promise< */ export const DEFAULT_ALPHABET = ((): string => { let str = ""; - for (let c = 0x20; c <= 0x7E; c++) { + for (let c = 0x20; c <= 0x7e; c++) { str += String.fromCharCode(c); } return str; @@ -503,10 +489,10 @@ export const DEFAULT_ALPHABET = ((): string => { * padded at the end with the first character in the alphabet. * * This is intended for use with string averaging. - * @param {string} s The string to pad. - * @param {number} n The length to pad to. - * @param {string} alphabet The alphabet to use as a single string. - * @returns {string} The padded string. + * @param s - The string to pad. + * @param n - The length to pad to. + * @param alphabet - The alphabet to use as a single string. + * @returns The padded string. */ export function alphabetPad(s: string, n: number, alphabet = DEFAULT_ALPHABET): string { return s.padEnd(n, alphabet[0]); @@ -516,9 +502,9 @@ export function alphabetPad(s: string, n: number, alphabet = DEFAULT_ALPHABET): * Converts a baseN number to a string, where N is the alphabet's length. * * This is intended for use with string averaging. - * @param {bigint} n The baseN number. - * @param {string} alphabet The alphabet to use as a single string. - * @returns {string} The baseN number encoded as a string from the alphabet. + * @param n - The baseN number. + * @param alphabet - The alphabet to use as a single string. + * @returns The baseN number encoded as a string from the alphabet. */ export function baseToString(n: bigint, alphabet = DEFAULT_ALPHABET): string { // Developer note: the stringToBase() function offsets the character set by 1 so that repeated @@ -550,9 +536,9 @@ export function baseToString(n: bigint, alphabet = DEFAULT_ALPHABET): string { * Converts a string to a baseN number, where N is the alphabet's length. * * This is intended for use with string averaging. - * @param {string} s The string to convert to a number. - * @param {string} alphabet The alphabet to use as a single string. - * @returns {bigint} The baseN number. + * @param s - The string to convert to a number. + * @param alphabet - The alphabet to use as a single string. + * @returns The baseN number. */ export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): bigint { const len = BigInt(alphabet.length); @@ -575,7 +561,7 @@ export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): bigint { // We add 1 to the char index to offset the whole numbering scheme. We unpack this in // the baseToString() function. - result += BigInt(1 + charIndex) * (len ** j); + result += BigInt(1 + charIndex) * len ** j; } return result; } @@ -584,10 +570,10 @@ export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): bigint { * Averages two strings, returning the midpoint between them. This is accomplished by * converting both to baseN numbers (where N is the alphabet's length) then averaging * those before re-encoding as a string. - * @param {string} a The first string. - * @param {string} b The second string. - * @param {string} alphabet The alphabet to use as a single string. - * @returns {string} The midpoint between the strings, as a string. + * @param a - The first string. + * @param b - The second string. + * @param alphabet - The alphabet to use as a single string. + * @returns The midpoint between the strings, as a string. */ export function averageBetweenStrings(a: string, b: string, alphabet = DEFAULT_ALPHABET): string { const padN = Math.max(a.length, b.length); @@ -608,9 +594,9 @@ export function averageBetweenStrings(a: string, b: string, alphabet = DEFAULT_A * Finds the next string using the alphabet provided. This is done by converting the * string to a baseN number, where N is the alphabet's length, then adding 1 before * converting back to a string. - * @param {string} s The string to start at. - * @param {string} alphabet The alphabet to use as a single string. - * @returns {string} The string which follows the input string. + * @param s - The string to start at. + * @param alphabet - The alphabet to use as a single string. + * @returns The string which follows the input string. */ export function nextString(s: string, alphabet = DEFAULT_ALPHABET): string { return baseToString(stringToBase(s, alphabet) + BigInt(1), alphabet); @@ -620,9 +606,9 @@ export function nextString(s: string, alphabet = DEFAULT_ALPHABET): string { * Finds the previous string using the alphabet provided. This is done by converting the * string to a baseN number, where N is the alphabet's length, then subtracting 1 before * converting back to a string. - * @param {string} s The string to start at. - * @param {string} alphabet The alphabet to use as a single string. - * @returns {string} The string which precedes the input string. + * @param s - The string to start at. + * @param alphabet - The alphabet to use as a single string. + * @returns The string which precedes the input string. */ export function prevString(s: string, alphabet = DEFAULT_ALPHABET): string { return baseToString(stringToBase(s, alphabet) - BigInt(1), alphabet); @@ -630,9 +616,9 @@ export function prevString(s: string, alphabet = DEFAULT_ALPHABET): string { /** * Compares strings lexicographically as a sort-safe function. - * @param {string} a The first (reference) string. - * @param {string} b The second (compare) string. - * @returns {number} Negative if the reference string is before the compare string; + * @param a - The first (reference) string. + * @param b - The second (compare) string. + * @returns Negative if the reference string is before the compare string; * positive if the reference string is after; and zero if equal. */ export function lexicographicCompare(a: string, b: string): number { @@ -650,8 +636,8 @@ export function lexicographicCompare(a: string, b: string): number { const collator = new Intl.Collator(); /** * Performant language-sensitive string comparison - * @param a the first string to compare - * @param b the second string to compare + * @param a - the first string to compare + * @param b - the second string to compare */ export function compare(a: string, b: string): number { return collator.compare(a, b); @@ -661,22 +647,24 @@ export function compare(a: string, b: string): number { * This function is similar to Object.assign() but it assigns recursively and * allows you to ignore nullish values from the source * - * @param {Object} target - * @param {Object} source * @returns the target object */ -export function recursivelyAssign(target: Object, source: Object, ignoreNullish = false): any { +export function recursivelyAssign>( + target: T1, + source: T2, + ignoreNullish = false, +): T1 & T2 { for (const [sourceKey, sourceValue] of Object.entries(source)) { if (target[sourceKey] instanceof Object && sourceValue) { recursivelyAssign(target[sourceKey], sourceValue); continue; } if ((sourceValue !== null && sourceValue !== undefined) || !ignoreNullish) { - target[sourceKey] = sourceValue; + target[sourceKey as keyof T1] = sourceValue; continue; } } - return target; + return target as T1 & T2; } function getContentTimestampWithFallback(event: MatrixEvent): number { @@ -697,7 +685,7 @@ export function isSupportedReceiptType(receiptType: string): boolean { /** * Determines whether two maps are equal. - * @param eq The equivalence relation to compare values by. Defaults to strict equality. + * @param eq - The equivalence relation to compare values by. Defaults to strict equality. */ export function mapsEqual(x: Map, y: Map, eq = (v1: V, v2: V): boolean => v1 === v2): boolean { if (x.size !== y.size) return false; diff --git a/src/webrtc/audioContext.ts b/src/webrtc/audioContext.ts index 0e08574b8c8..7cf3ed3f66e 100644 --- a/src/webrtc/audioContext.ts +++ b/src/webrtc/audioContext.ts @@ -22,7 +22,7 @@ let refCount = 0; * It's highly recommended to reuse this AudioContext rather than creating your * own, because multiple AudioContexts can be problematic in some browsers. * Make sure to call releaseContext when you're done using it. - * @returns {AudioContext} The shared AudioContext + * @returns The shared AudioContext */ export const acquireContext = (): AudioContext => { if (audioContext === null) audioContext = new AudioContext(); diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index 8b4882c960a..e9276f44fc8 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -19,17 +19,17 @@ limitations under the License. /** * This is an internal module. See {@link createNewMatrixCall} for the public API. - * @module webrtc/call */ +import { v4 as uuidv4 } from "uuid"; import { parse as parseSdp, write as writeSdp } from "sdp-transform"; -import { logger } from '../logger'; -import * as utils from '../utils'; -import { MatrixEvent } from '../models/event'; -import { EventType } from '../@types/event'; -import { RoomMember } from '../models/room-member'; -import { randomString } from '../randomstring'; +import { logger } from "../logger"; +import * as utils from "../utils"; +import { IContent, MatrixEvent } from "../models/event"; +import { EventType, ToDeviceMessageId } from "../@types/event"; +import { RoomMember } from "../models/room-member"; +import { randomString } from "../randomstring"; import { MCallReplacesEvent, MCallAnswer, @@ -44,38 +44,28 @@ import { MCallCandidates, MCallBase, MCallHangupReject, -} from './callEventTypes'; -import { CallFeed } from './callFeed'; +} from "./callEventTypes"; +import { CallFeed } from "./callFeed"; import { MatrixClient } from "../client"; import { EventEmitterEvents, TypedEventEmitter } from "../models/typed-event-emitter"; -import { DeviceInfo } from '../crypto/deviceinfo'; -import { GroupCallUnknownDeviceError } from './groupCall'; +import { DeviceInfo } from "../crypto/deviceinfo"; +import { GroupCallUnknownDeviceError } from "./groupCall"; import { IScreensharingOpts } from "./mediaHandler"; import { MatrixError } from "../http-api"; -// events: hangup, error(err), replaced(call), state(state, oldState) - -/** - * Fires whenever an error occurs when call.js encounters an issue with setting up the call. - *

- * The error given will have a code equal to either `MatrixCall.ERR_LOCAL_OFFER_FAILED` or - * `MatrixCall.ERR_NO_USER_MEDIA`. `ERR_LOCAL_OFFER_FAILED` is emitted when the local client - * fails to create an offer. `ERR_NO_USER_MEDIA` is emitted when the user has denied access - * to their audio/video hardware. - * - * @event module:webrtc/call~MatrixCall#"error" - * @param {Error} err The error raised by MatrixCall. - * @example - * matrixCall.on("error", function(err){ - * console.error(err.code, err); - * }); - */ - interface CallOpts { + // The room ID for this call. roomId?: string; invitee?: string; + // The Matrix Client instance to send events to. client: MatrixClient; + /** + * Whether relay through TURN should be forced. + * @deprecated use opts.forceTURN when creating the matrix client + * since it's only possible to set this option on outbound calls. + */ forceTURN?: boolean; + // A list of TURN servers. turnServers?: Array; opponentDeviceId?: string; opponentSessionId?: string; @@ -113,145 +103,145 @@ interface CodecParamsMod { } export enum CallState { - Fledgling = 'fledgling', - InviteSent = 'invite_sent', - WaitLocalMedia = 'wait_local_media', - CreateOffer = 'create_offer', - CreateAnswer = 'create_answer', - Connecting = 'connecting', - Connected = 'connected', - Ringing = 'ringing', - Ended = 'ended', + Fledgling = "fledgling", + InviteSent = "invite_sent", + WaitLocalMedia = "wait_local_media", + CreateOffer = "create_offer", + CreateAnswer = "create_answer", + Connecting = "connecting", + Connected = "connected", + Ringing = "ringing", + Ended = "ended", } export enum CallType { - Voice = 'voice', - Video = 'video', + Voice = "voice", + Video = "video", } export enum CallDirection { - Inbound = 'inbound', - Outbound = 'outbound', + Inbound = "inbound", + Outbound = "outbound", } export enum CallParty { - Local = 'local', - Remote = 'remote', + Local = "local", + Remote = "remote", } export enum CallEvent { - Hangup = 'hangup', - State = 'state', - Error = 'error', - Replaced = 'replaced', + Hangup = "hangup", + State = "state", + Error = "error", + Replaced = "replaced", // The value of isLocalOnHold() has changed - LocalHoldUnhold = 'local_hold_unhold', + LocalHoldUnhold = "local_hold_unhold", // The value of isRemoteOnHold() has changed - RemoteHoldUnhold = 'remote_hold_unhold', + RemoteHoldUnhold = "remote_hold_unhold", // backwards compat alias for LocalHoldUnhold: remove in a major version bump - HoldUnhold = 'hold_unhold', + HoldUnhold = "hold_unhold", // Feeds have changed - FeedsChanged = 'feeds_changed', + FeedsChanged = "feeds_changed", - AssertedIdentityChanged = 'asserted_identity_changed', + AssertedIdentityChanged = "asserted_identity_changed", - LengthChanged = 'length_changed', + LengthChanged = "length_changed", - DataChannel = 'datachannel', + DataChannel = "datachannel", SendVoipEvent = "send_voip_event", } export enum CallErrorCode { /** The user chose to end the call */ - UserHangup = 'user_hangup', + UserHangup = "user_hangup", /** An error code when the local client failed to create an offer. */ - LocalOfferFailed = 'local_offer_failed', + LocalOfferFailed = "local_offer_failed", /** * An error code when there is no local mic/camera to use. This may be because * the hardware isn't plugged in, or the user has explicitly denied access. */ - NoUserMedia = 'no_user_media', + NoUserMedia = "no_user_media", /** * Error code used when a call event failed to send * because unknown devices were present in the room */ - UnknownDevices = 'unknown_devices', + UnknownDevices = "unknown_devices", /** * Error code used when we fail to send the invite * for some reason other than there being unknown devices */ - SendInvite = 'send_invite', + SendInvite = "send_invite", /** * An answer could not be created */ - CreateAnswer = 'create_answer', + CreateAnswer = "create_answer", /** * An offer could not be created */ - CreateOffer = 'create_offer', + CreateOffer = "create_offer", /** * Error code used when we fail to send the answer * for some reason other than there being unknown devices */ - SendAnswer = 'send_answer', + SendAnswer = "send_answer", /** * The session description from the other side could not be set */ - SetRemoteDescription = 'set_remote_description', + SetRemoteDescription = "set_remote_description", /** * The session description from this side could not be set */ - SetLocalDescription = 'set_local_description', + SetLocalDescription = "set_local_description", /** * A different device answered the call */ - AnsweredElsewhere = 'answered_elsewhere', + AnsweredElsewhere = "answered_elsewhere", /** * No media connection could be established to the other party */ - IceFailed = 'ice_failed', + IceFailed = "ice_failed", /** * The invite timed out whilst waiting for an answer */ - InviteTimeout = 'invite_timeout', + InviteTimeout = "invite_timeout", /** * The call was replaced by another call */ - Replaced = 'replaced', + Replaced = "replaced", /** * Signalling for the call could not be sent (other than the initial invite) */ - SignallingFailed = 'signalling_timeout', + SignallingFailed = "signalling_timeout", /** * The remote party is busy */ - UserBusy = 'user_busy', + UserBusy = "user_busy", /** * We transferred the call off to somewhere else */ - Transfered = 'transferred', + Transfered = "transferred", /** * A call from the same user was found with a new session id */ - NewSession = 'new_session', + NewSession = "new_session", } /** @@ -260,10 +250,14 @@ export enum CallErrorCode { const VOIP_PROTO_VERSION = "1"; /** The fallback ICE server to use for STUN or TURN protocols. */ -const FALLBACK_ICE_SERVER = 'stun:turn.matrix.org'; +const FALLBACK_ICE_SERVER = "stun:turn.matrix.org"; /** The length of time a call can be ringing for. */ -const CALL_TIMEOUT_MS = 60000; +const CALL_TIMEOUT_MS = 60 * 1000; // ms +/** The time after which we increment callLength */ +const CALL_LENGTH_INTERVAL = 1000; // ms +/** The time after which we end the call, if ICE got disconnected */ +const ICE_DISCONNECTED_TIMEOUT = 30 * 1000; // ms export class CallError extends Error { public readonly code: string; @@ -316,25 +310,13 @@ type TransceiverKey = string; // kind is unfortunately a string rather than MediaType as this is the type of // track.kind function getTransceiverKey(purpose: SDPStreamMetadataPurpose, kind: TransceiverKey): string { - return purpose + ':' + kind; + return purpose + ":" + kind; } -/** - * Construct a new Matrix Call. - * @constructor - * @param {Object} opts Config options. - * @param {string} opts.roomId The room ID for this call. - * @param {Object} opts.webRtc The WebRTC globals from the browser. - * @param {boolean} opts.forceTURN whether relay through TURN should be forced. - * @param {Object} opts.URL The URL global. - * @param {Array} opts.turnServers Optional. A list of TURN servers. - * @param {MatrixClient} opts.client The Matrix Client instance to send events to. - */ export class MatrixCall extends TypedEventEmitter { public roomId?: string; public callId: string; public invitee?: string; - public state = CallState.Fledgling; public hangupParty?: CallParty; public hangupReason?: string; public direction?: CallDirection; @@ -346,6 +328,7 @@ export class MatrixCall extends TypedEventEmitter; @@ -397,13 +380,17 @@ export class MatrixCall extends TypedEventEmitter; - private callLength = 0; + private callStartTime?: number; private opponentDeviceId?: string; private opponentDeviceInfo?: DeviceInfo; private opponentSessionId?: string; public groupCallId?: string; + /** + * Construct a new Matrix Call. + * @param opts - Config options. + */ public constructor(opts: CallOpts) { super(); @@ -449,8 +436,8 @@ export class MatrixCall extends TypedEventEmitter} CallFeeds + * @returns CallFeeds */ public getFeeds(): Array { return this.feeds; @@ -554,7 +549,7 @@ export class MatrixCall extends TypedEventEmitter} local CallFeeds + * @returns local CallFeeds */ public getLocalFeeds(): Array { return this.feeds.filter((feed) => feed.isLocal()); @@ -562,7 +557,7 @@ export class MatrixCall extends TypedEventEmitter} remote CallFeeds + * @returns remote CallFeeds */ public getRemoteFeeds(): Array { return this.feeds.filter((feed) => !feed.isLocal()); @@ -594,7 +589,7 @@ export class MatrixCall extends TypedEventEmitter !feed.isLocal()); @@ -634,8 +629,9 @@ export class MatrixCall extends TypedEventEmitter callFeed.stream.id === feed.stream.id)) { @@ -749,13 +752,13 @@ export class MatrixCall extends TypedEventEmitter t.sender === newSender); + const newTransciever = this.peerConn!.getTransceivers().find((t) => t.sender === newSender); if (newTransciever) { this.transceivers.set(tKey, newTransciever); } else { @@ -795,10 +798,10 @@ export class MatrixCall extends TypedEventEmitter { + statsReport.forEach((item) => { stats.push(item); }); @@ -881,7 +884,7 @@ export class MatrixCall extends TypedEventEmitter { const invite = event.getContent(); @@ -898,8 +901,9 @@ export class MatrixCall extends TypedEventEmitter feed.clone())); + newCall.queueGotCallFeedsForAnswer(this.getLocalFeeds().map((feed) => feed.clone())); } } this.successor = newCall; @@ -1078,8 +1079,8 @@ export class MatrixCall extends TypedEventEmitter { + private async upgradeCall(audio: boolean, video: boolean): Promise { // We don't do call downgrades if (!audio && !video) return; if (!this.opponentSupportsSDPStreamMetadata()) return; @@ -1143,7 +1141,8 @@ export class MatrixCall extends TypedEventEmittererror), ); } @@ -1151,7 +1150,7 @@ export class MatrixCall extends TypedEventEmitter { // Skip if there is nothing to do @@ -1198,12 +1197,12 @@ export class MatrixCall extends TypedEventEmitter { logger.debug(`Call ${this.callId} Set screensharing enabled? ${enabled} using replaceTrack()`); if (enabled) { @@ -1233,11 +1233,11 @@ export class MatrixCall extends TypedEventEmitter track.kind === "video"); + const track = stream.getTracks().find((track) => track.kind === "video"); - const sender = this.transceivers.get(getTransceiverKey( - SDPStreamMetadataPurpose.Usermedia, "video", - ))?.sender; + const sender = this.transceivers.get( + getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, "video"), + )?.sender; sender?.replaceTrack(track ?? null); @@ -1250,9 +1250,9 @@ export class MatrixCall extends TypedEventEmitter track.kind === "video"); - const sender = this.transceivers.get(getTransceiverKey( - SDPStreamMetadataPurpose.Usermedia, "video", - ))?.sender; + const sender = this.transceivers.get( + getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, "video"), + )?.sender; sender?.replaceTrack(track ?? null); this.client.getMediaHandler().stopScreensharingStream(this.localScreensharingStream!); @@ -1264,16 +1264,19 @@ export class MatrixCall extends TypedEventEmitter { const callFeed = this.localUsermediaFeed!; const audioEnabled = forceAudio || (!callFeed.isAudioMuted() && !this.remoteOnHold); const videoEnabled = forceVideo || (!callFeed.isVideoMuted() && !this.remoteOnHold); - logger.log(`call ${this.callId} updateLocalUsermediaStream stream ${ - stream.id} audioEnabled ${audioEnabled} videoEnabled ${videoEnabled}`); + logger.log( + `call ${this.callId} updateLocalUsermediaStream stream ${stream.id} audioEnabled ${audioEnabled} videoEnabled ${videoEnabled}`, + ); setTracksEnabled(stream.getAudioTracks(), audioEnabled); setTracksEnabled(stream.getVideoTracks(), videoEnabled); @@ -1294,13 +1297,13 @@ export class MatrixCall extends TypedEventEmitter t.sender === newSender); + const newTransciever = this.peerConn!.getTransceivers().find((t) => t.sender === newSender); if (newTransciever) { this.transceivers.set(tKey, newTransciever); } else { @@ -1333,12 +1336,12 @@ export class MatrixCall extends TypedEventEmitter { logger.log(`call ${this.callId} setLocalVideoMuted ${muted}`); - if (!await this.client.getMediaHandler().hasVideoDevice()) { + if (!(await this.client.getMediaHandler().hasVideoDevice())) { return this.isLocalVideoMuted(); } @@ -1358,7 +1361,7 @@ export class MatrixCall extends TypedEventEmitterall of the tracks need to be muted * for this to return true. This means if there are no video tracks, this will * return true. - * @return {Boolean} True if the local preview video is muted, else false + * @returns True if the local preview video is muted, else false * (including if the call is not set up yet). */ public isLocalVideoMuted(): boolean { @@ -1367,12 +1370,12 @@ export class MatrixCall extends TypedEventEmitter { logger.log(`call ${this.callId} setMicrophoneMuted ${muted}`); - if (!await this.client.getMediaHandler().hasAudioDevice()) { + if (!(await this.client.getMediaHandler().hasAudioDevice())) { return this.isMicrophoneMuted(); } @@ -1392,7 +1395,7 @@ export class MatrixCall extends TypedEventEmitterall of the tracks need to be muted * for this to return true. This means if there are no audio tracks, this will * return true. - * @return {Boolean} True if the mic is muted, else false (including if the call + * @returns True if the mic is muted, else false (including if the call * is not set up yet). */ public isMicrophoneMuted(): boolean { @@ -1415,7 +1418,7 @@ export class MatrixCall extends TypedEventEmittererror).name == 'UnknownDeviceError') { + if ((error).name == "UnknownDeviceError") { code = CallErrorCode.UnknownDevices; message = "Unknown devices present in the room"; } @@ -1551,8 +1557,7 @@ export class MatrixCall extends TypedEventEmitter this.gotCallFeedsForAnswer(callFeeds)); + this.responsePromiseChain = this.responsePromiseChain.then(() => this.gotCallFeedsForAnswer(callFeeds)); } else { this.responsePromiseChain = this.gotCallFeedsForAnswer(callFeeds); } @@ -1564,7 +1569,7 @@ export class MatrixCall extends TypedEventEmitter { + sdp.media.forEach((media) => { const payloadTypeToCodecMap = new Map(); const codecToPayloadTypeMap = new Map(); for (const rtp of media.rtp) { @@ -1582,7 +1587,7 @@ export class MatrixCall extends TypedEventEmitter { + await new Promise((resolve) => { setTimeout(resolve, 200); }); @@ -1665,7 +1670,6 @@ export class MatrixCall extends TypedEventEmitter { if (event.candidate) { @@ -1675,15 +1679,19 @@ export class MatrixCall extends TypedEventEmitter { logger.debug(`Call ${this.callId} ice gathering state changed to ${this.peerConn!.iceGatheringState}`); - if (this.peerConn?.iceGatheringState === 'complete') { + if (this.peerConn?.iceGatheringState === "complete") { this.queueCandidate(null); } }; @@ -1726,9 +1734,9 @@ export class MatrixCall extends TypedEventEmitter { const content = event.getContent(); @@ -1753,8 +1760,8 @@ export class MatrixCall extends TypedEventEmitter().selected_party_id; if (selectedPartyId === undefined || selectedPartyId === null) { - logger.warn(`Call ${ - this.callId} Got nonsensical select_answer with null/undefined selected_party_id: ignoring`); + logger.warn( + `Call ${this.callId} Got nonsensical select_answer with null/undefined selected_party_id: ignoring`, + ); return; } if (selectedPartyId !== this.ourPartyId) { - logger.info(`Call ${this.callId} Got select_answer for party ID ${ - selectedPartyId}: we are party ID ${this.ourPartyId}.`); + logger.info( + `Call ${this.callId} Got select_answer for party ID ${selectedPartyId}: we are party ID ${this.ourPartyId}.`, + ); // The other party has picked somebody else's answer await this.terminate(CallParty.Remote, CallErrorCode.AnsweredElsewhere, true); } @@ -1831,10 +1840,8 @@ export class MatrixCall extends TypedEventEmitter this.wrappedGotLocalOffer()); + this.responsePromiseChain = this.responsePromiseChain.then(() => this.wrappedGotLocalOffer()); } else { this.responsePromiseChain = this.wrappedGotLocalOffer(); } @@ -1945,8 +1951,7 @@ export class MatrixCall extends TypedEventEmitter { + await new Promise((resolve) => { setTimeout(resolve, 200); }); } @@ -1995,8 +2000,8 @@ export class MatrixCall extends TypedEventEmittererror).name == 'UnknownDeviceError') { + if ((error).name == "UnknownDeviceError") { code = CallErrorCode.UnknownDevices; message = "Unknown devices present in the room"; } @@ -2034,7 +2039,7 @@ export class MatrixCall extends TypedEventEmitter { this.inviteTimeout = undefined; if (this.state === CallState.InviteSent) { @@ -2047,13 +2052,7 @@ export class MatrixCall extends TypedEventEmitter { logger.error(`Call ${this.callId} Failed to get local offer`, err); - this.emit( - CallEvent.Error, - new CallError( - CallErrorCode.LocalOfferFailed, - "Failed to get local offer!", err, - ), - ); + this.emit(CallEvent.Error, new CallError(CallErrorCode.LocalOfferFailed, "Failed to get local offer!", err)); this.terminate(CallParty.Local, CallErrorCode.LocalOfferFailed, false); }; @@ -2069,8 +2068,8 @@ export class MatrixCall extends TypedEventEmitter { - this.callLength++; - this.emit(CallEvent.LengthChanged, this.callLength); - }, 1000); + this.emit(CallEvent.LengthChanged, Math.round((Date.now() - this.callStartTime!) / 1000)); + }, CALL_LENGTH_INTERVAL); } - } else if (this.peerConn?.iceConnectionState == 'failed') { + } else if (this.peerConn?.iceConnectionState == "failed") { // Firefox for Android does not yet have support for restartIce() // (the types say it's always defined though, so we have to cast // to prevent typescript from warning). @@ -2107,12 +2107,12 @@ export class MatrixCall extends TypedEventEmitter { logger.info(`Hanging up call ${this.callId} (ICE disconnected for too long)`); this.hangup(CallErrorCode.IceFailed, false); - }, 30 * 1000); - this.setState(CallState.Connecting); + }, ICE_DISCONNECTED_TIMEOUT); + this.state = CallState.Connecting; } // In PTT mode, override feed status to muted when we lose connection to @@ -2185,9 +2185,9 @@ export class MatrixCall extends TypedEventEmitter { const realContent = Object.assign({}, content, { @@ -2272,6 +2264,7 @@ export class MatrixCall extends TypedEventEmitter candidate.toJSON()) }; + const content = { candidates: candidates.map((candidate) => candidate.toJSON()) }; if (this.candidatesEnded) { // If there are no more candidates, signal this by adding an empty string candidate content.candidates.push({ - candidate: '', + candidate: "", }); } logger.debug(`Call ${this.callId} attempting to send ${candidates.length} candidates`); @@ -2546,8 +2535,7 @@ export class MatrixCall extends TypedEventEmitter 5) { logger.debug( - `Call ${this.callId} failed to send candidates on attempt ${ - this.candidateSendTries}. Giving up on this call.`, + `Call ${this.callId} failed to send candidates on attempt ${this.candidateSendTries}. Giving up on this call.`, error, ); @@ -2578,7 +2566,7 @@ export class MatrixCall extends TypedEventEmitter { const bufferedCandidates = this.remoteCandidateBuffer.get(this.opponentPartyId!); if (bufferedCandidates) { - logger.info(`Call ${this.callId} Adding ${ - bufferedCandidates.length} buffered candidates for opponent ${this.opponentPartyId}`); + logger.info( + `Call ${this.callId} Adding ${bufferedCandidates.length} buffered candidates for opponent ${this.opponentPartyId}`, + ); await this.addIceCandidates(bufferedCandidates); } this.remoteCandidateBuffer.clear(); @@ -2703,8 +2692,9 @@ export class MatrixCall extends TypedEventEmitter, enabled: boole export function supportsMatrixCall(): boolean { // typeof prevents Node from erroring on an undefined reference - if (typeof(window) === 'undefined' || typeof(document) === 'undefined') { + if (typeof window === "undefined" || typeof document === "undefined") { // NB. We don't log here as apps try to create a call object as a test for // whether calls are supported, so we shouldn't fill the logs up. return false; @@ -2741,8 +2731,10 @@ export function supportsMatrixCall(): boolean { // is that the browser throwing a SecurityError will brick the client creation process. try { const supported = Boolean( - window.RTCPeerConnection || window.RTCSessionDescription || - window.RTCIceCandidate || navigator.mediaDevices, + window.RTCPeerConnection || + window.RTCSessionDescription || + window.RTCIceCandidate || + navigator.mediaDevices, ); if (!supported) { /* istanbul ignore if */ // Adds a lot of noise to test runs, so disable logging there. @@ -2764,13 +2756,10 @@ export function supportsMatrixCall(): boolean { * Use client.createCall() * * Create a new Matrix call for the browser. - * @param {MatrixClient} client The client instance to use. - * @param {string} roomId The room the call is in. - * @param {Object?} options DEPRECATED optional options map. - * @param {boolean} options.forceTURN DEPRECATED whether relay through TURN should be - * forced. This option is deprecated - use opts.forceTURN when creating the matrix client - * since it's only possible to set this option on outbound calls. - * @return {MatrixCall} the call or null if the browser doesn't support calling. + * @param client - The client instance to use. + * @param roomId - The room the call is in. + * @param options - DEPRECATED optional options map. + * @returns the call or null if the browser doesn't support calling. */ export function createNewMatrixCall( client: MatrixClient, diff --git a/src/webrtc/callEventHandler.ts b/src/webrtc/callEventHandler.ts index 02cbe2d84d3..cb027349957 100644 --- a/src/webrtc/callEventHandler.ts +++ b/src/webrtc/callEventHandler.ts @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixEvent } from '../models/event'; -import { logger } from '../logger'; -import { CallDirection, CallError, CallErrorCode, CallState, createNewMatrixCall, MatrixCall } from './call'; -import { EventType } from '../@types/event'; -import { ClientEvent, MatrixClient } from '../client'; +import { MatrixEvent } from "../models/event"; +import { logger } from "../logger"; +import { CallDirection, CallError, CallErrorCode, CallState, createNewMatrixCall, MatrixCall } from "./call"; +import { EventType } from "../@types/event"; +import { ClientEvent, MatrixClient } from "../client"; import { MCallAnswer, MCallHangupReject } from "./callEventTypes"; -import { GroupCall, GroupCallErrorCode, GroupCallEvent, GroupCallUnknownDeviceError } from './groupCall'; +import { GroupCall, GroupCallErrorCode, GroupCallEvent, GroupCallUnknownDeviceError } from "./groupCall"; import { RoomEvent } from "../models/room"; // Don't ring unless we'd be ringing for at least 3 seconds: the user needs some @@ -32,6 +32,16 @@ export enum CallEventHandlerEvent { } export type CallEventHandlerEventHandlerMap = { + /** + * Fires whenever an incoming call arrives. + * @param call - The incoming call. + * @example + * ``` + * matrixClient.on("Call.incoming", function(call){ + * call.answer(); // auto-answer + * }); + * ``` + */ [CallEventHandlerEvent.Incoming]: (call: MatrixCall) => void; }; @@ -80,8 +90,9 @@ export class CallEventHandler { // Ensure correct ordering by only processing this queue after the previous one has finished processing if (this.eventBufferPromiseChain) { - this.eventBufferPromiseChain = - this.eventBufferPromiseChain.then(() => this.evaluateEventBuffer(currentEventBuffer)); + this.eventBufferPromiseChain = this.eventBufferPromiseChain.then(() => + this.evaluateEventBuffer(currentEventBuffer), + ); } else { this.eventBufferPromiseChain = this.evaluateEventBuffer(currentEventBuffer); } @@ -102,7 +113,7 @@ export class CallEventHandler { for (const event of callEvents) { const eventType = event.getType(); - if (eventType=== EventType.CallAnswer || eventType === EventType.CallHangup) { + if (eventType === EventType.CallAnswer || eventType === EventType.CallHangup) { ignoreCallIds.add(event.getContent().call_id); } } @@ -182,10 +193,8 @@ export class CallEventHandler { this.client.emit(ClientEvent.ReceivedVoipEvent, event); const content = event.getContent(); - const callRoomId = ( - event.getRoomId() || - this.client.groupCallEventHandler!.getGroupCallById(content.conf_id)?.room?.roomId - ); + const callRoomId = + event.getRoomId() || this.client.groupCallEventHandler!.getGroupCallById(content.conf_id)?.room?.roomId; const groupCallId = content.conf_id; const type = event.getType() as EventType; const senderId = event.getSender()!; @@ -206,10 +215,7 @@ export class CallEventHandler { if (!opponentDeviceId) { logger.warn(`Cannot find a device id for ${senderId}. Ignoring event.`); - groupCall.emit( - GroupCallEvent.Error, - new GroupCallUnknownDeviceError(senderId), - ); + groupCall.emit(GroupCallEvent.Error, new GroupCallUnknownDeviceError(senderId)); return; } @@ -219,8 +225,9 @@ export class CallEventHandler { } } - const weSentTheEvent = senderId === this.client.credentials.userId - && (opponentDeviceId === undefined || opponentDeviceId === this.client.getDeviceId()!); + const weSentTheEvent = + senderId === this.client.credentials.userId && + (opponentDeviceId === undefined || opponentDeviceId === this.client.getDeviceId()!); if (!callRoomId) return; @@ -234,8 +241,7 @@ export class CallEventHandler { if (call) { logger.log( - `WARN: Already have a MatrixCall with id ${content.call_id} but got an ` + - `invite. Clobbering.`, + `WARN: Already have a MatrixCall with id ${content.call_id} but got an ` + `invite. Clobbering.`, ); } @@ -245,20 +251,15 @@ export class CallEventHandler { const timeUntilTurnCresExpire = (this.client.getTurnServersExpiry() ?? 0) - Date.now(); logger.info("Current turn creds expire in " + timeUntilTurnCresExpire + " ms"); - call = createNewMatrixCall( - this.client, - callRoomId, - { - forceTURN: this.client.forceTURN, opponentDeviceId, + call = + createNewMatrixCall(this.client, callRoomId, { + forceTURN: this.client.forceTURN, + opponentDeviceId, groupCallId, opponentSessionId: content.sender_session_id, - }, - ) ?? undefined; + }) ?? undefined; if (!call) { - logger.log( - "Incoming call ID " + content.call_id + " but this client " + - "doesn't support WebRTC", - ); + logger.log("Incoming call ID " + content.call_id + " but this client " + "doesn't support WebRTC"); // don't hang up the call: there could be other clients // connected that do support WebRTC and declining the // the call on their behalf would be really annoying. @@ -307,14 +308,18 @@ export class CallEventHandler { if (existingCall) { if (existingCall.callId > call.callId) { logger.log( - "Glare detected: answering incoming call " + call.callId + - " and canceling outgoing call " + existingCall.callId, + "Glare detected: answering incoming call " + + call.callId + + " and canceling outgoing call " + + existingCall.callId, ); existingCall.replacedBy(call); } else { logger.log( - "Glare detected: rejecting incoming call " + call.callId + - " and keeping outgoing call " + existingCall.callId, + "Glare detected: rejecting incoming call " + + call.callId + + " and keeping outgoing call " + + existingCall.callId, ); call.hangup(CallErrorCode.Replaced, true); } @@ -342,14 +347,11 @@ export class CallEventHandler { // if not live, store the fact that the call has ended because // we're probably getting events backwards so // the hangup will come before the invite - call = createNewMatrixCall( - this.client, - callRoomId, - { + call = + createNewMatrixCall(this.client, callRoomId, { opponentDeviceId, opponentSessionId: content.sender_session_id, - }, - ) ?? undefined; + }) ?? undefined; if (call) { call.callId = content.call_id; call.initWithHangup(event); diff --git a/src/webrtc/callEventTypes.ts b/src/webrtc/callEventTypes.ts index 4f43a70a1d5..f06ed5b0db7 100644 --- a/src/webrtc/callEventTypes.ts +++ b/src/webrtc/callEventTypes.ts @@ -22,8 +22,8 @@ export interface SDPStreamMetadata { } export interface CallCapabilities { - 'm.call.transferee': boolean; - 'm.call.dtmf': boolean; + "m.call.transferee": boolean; + "m.call.dtmf": boolean; } export interface CallReplacesTarget { diff --git a/src/webrtc/callFeed.ts b/src/webrtc/callFeed.ts index bc1c344505c..fa02775d033 100644 --- a/src/webrtc/callFeed.ts +++ b/src/webrtc/callFeed.ts @@ -20,6 +20,7 @@ import { MatrixClient } from "../client"; import { RoomMember } from "../models/room-member"; import { logger } from "../logger"; import { TypedEventEmitter } from "../models/typed-event-emitter"; +import { CallEvent, CallState, MatrixCall } from "./call"; const POLLING_INTERVAL = 200; // ms export const SPEAKING_THRESHOLD = -60; // dB @@ -40,6 +41,10 @@ export interface ICallFeedOpts { * Whether or not the remote SDPStreamMetadata says video is muted */ videoMuted: boolean; + /** + * The MatrixCall which is the source of this CallFeed + */ + call?: MatrixCall; } export enum CallFeedEvent { @@ -47,6 +52,7 @@ export enum CallFeedEvent { MuteStateChanged = "mute_state_changed", LocalVolumeChanged = "local_volume_changed", VolumeChanged = "volume_changed", + ConnectedChanged = "connected_changed", Speaking = "speaking", Disposed = "disposed", } @@ -56,6 +62,7 @@ type EventHandlerMap = { [CallFeedEvent.MuteStateChanged]: (audioMuted: boolean, videoMuted: boolean) => void; [CallFeedEvent.LocalVolumeChanged]: (localVolume: number) => void; [CallFeedEvent.VolumeChanged]: (volume: number) => void; + [CallFeedEvent.ConnectedChanged]: (connected: boolean) => void; [CallFeedEvent.Speaking]: (speaking: boolean) => void; [CallFeedEvent.Disposed]: () => void; }; @@ -69,6 +76,7 @@ export class CallFeed extends TypedEventEmitter public speakingVolumeSamples: number[]; private client: MatrixClient; + private call?: MatrixCall; private roomId?: string; private audioMuted: boolean; private videoMuted: boolean; @@ -81,11 +89,13 @@ export class CallFeed extends TypedEventEmitter private speaking = false; private volumeLooperTimeout?: ReturnType; private _disposed = false; + private _connected = false; public constructor(opts: ICallFeedOpts) { super(); this.client = opts.client; + this.call = opts.call; this.roomId = opts.roomId; this.userId = opts.userId; this.deviceId = opts.deviceId; @@ -101,6 +111,21 @@ export class CallFeed extends TypedEventEmitter if (this.hasAudioTrack) { this.initVolumeMeasuring(); } + + if (opts.call) { + opts.call.addListener(CallEvent.State, this.onCallState); + this.onCallState(opts.call.state); + } + } + + public get connected(): boolean { + // Local feeds are always considered connected + return this.isLocal() || this._connected; + } + + private set connected(connected: boolean) { + this._connected = connected; + this.emit(CallFeedEvent.ConnectedChanged, this.connected); } private get hasAudioTrack(): boolean { @@ -145,6 +170,14 @@ export class CallFeed extends TypedEventEmitter this.emit(CallFeedEvent.NewStream, this.stream); }; + private onCallState = (state: CallState): void => { + if (state === CallState.Connected) { + this.connected = true; + } else if (state === CallState.Connecting) { + this.connected = false; + } + }; + /** * Returns callRoom member * @returns member of the callRoom @@ -156,17 +189,19 @@ export class CallFeed extends TypedEventEmitter /** * Returns true if CallFeed is local, otherwise returns false - * @returns {boolean} is local? + * @returns is local? */ public isLocal(): boolean { - return this.userId === this.client.getUserId() - && (this.deviceId === undefined || this.deviceId === this.client.getDeviceId()); + return ( + this.userId === this.client.getUserId() && + (this.deviceId === undefined || this.deviceId === this.client.getDeviceId()) + ); } /** * Returns true if audio is muted or if there are no audio * tracks, otherwise returns false - * @returns {boolean} is audio muted? + * @returns is audio muted? */ public isAudioMuted(): boolean { return this.stream.getAudioTracks().length === 0 || this.audioMuted; @@ -175,7 +210,7 @@ export class CallFeed extends TypedEventEmitter /** * Returns true video is muted or if there are no video * tracks, otherwise returns false - * @returns {boolean} is video muted? + * @returns is video muted? */ public isVideoMuted(): boolean { // We assume only one video track @@ -191,7 +226,7 @@ export class CallFeed extends TypedEventEmitter * The stream will be different and new stream as remore parties are * concerned, but this can be used for convenience locally to set up * volume listeners automatically on the new stream etc. - * @param newStream new stream with which to replace the current one + * @param newStream - new stream with which to replace the current one */ public setNewStream(newStream: MediaStream): void { this.updateStream(this.stream, newStream); @@ -200,8 +235,8 @@ export class CallFeed extends TypedEventEmitter /** * Set one or both of feed's internal audio and video video mute state * Either value may be null to leave it as-is - * @param audioMuted is the feed's audio muted? - * @param videoMuted is the feed's video muted? + * @param audioMuted - is the feed's audio muted? + * @param videoMuted - is the feed's video muted? */ public setAudioVideoMuted(audioMuted: boolean | null, videoMuted: boolean | null): void { if (audioMuted !== null) { @@ -216,7 +251,7 @@ export class CallFeed extends TypedEventEmitter /** * Starts emitting volume_changed events where the emitter value is in decibels - * @param enabled emit volume changes + * @param enabled - emit volume changes */ public measureVolumeActivity(enabled: boolean): void { if (enabled) { @@ -297,6 +332,7 @@ export class CallFeed extends TypedEventEmitter public dispose(): void { clearTimeout(this.volumeLooperTimeout); this.stream?.removeEventListener("addtrack", this.onAddTrack); + this.call?.removeListener(CallEvent.State, this.onCallState); if (this.audioContext) { this.audioContext = undefined; this.analyser = undefined; diff --git a/src/webrtc/groupCall.ts b/src/webrtc/groupCall.ts index fde46182bc1..34a35e5a686 100644 --- a/src/webrtc/groupCall.ts +++ b/src/webrtc/groupCall.ts @@ -59,17 +59,34 @@ export type GroupCallEventHandlerMap = { [GroupCallEvent.UserMediaFeedsChanged]: (feeds: CallFeed[]) => void; [GroupCallEvent.ScreenshareFeedsChanged]: (feeds: CallFeed[]) => void; [GroupCallEvent.LocalScreenshareStateChanged]: ( - isScreensharing: boolean, feed?: CallFeed, sourceId?: string, + isScreensharing: boolean, + feed?: CallFeed, + sourceId?: string, ) => void; [GroupCallEvent.LocalMuteStateChanged]: (audioMuted: boolean, videoMuted: boolean) => void; [GroupCallEvent.ParticipantsChanged]: (participants: Map>) => void; + /** + * Fires whenever an error occurs when call.js encounters an issue with setting up the call. + *

+ * The error given will have a code equal to either `MatrixCall.ERR_LOCAL_OFFER_FAILED` or + * `MatrixCall.ERR_NO_USER_MEDIA`. `ERR_LOCAL_OFFER_FAILED` is emitted when the local client + * fails to create an offer. `ERR_NO_USER_MEDIA` is emitted when the user has denied access + * to their audio/video hardware. + * @param err - The error raised by MatrixCall. + * @example + * ``` + * matrixCall.on("error", function(err){ + * console.error(err.code, err); + * }); + * ``` + */ [GroupCallEvent.Error]: (error: GroupCallError) => void; }; export enum GroupCallErrorCode { NoUserMedia = "no_user_media", UnknownDevice = "unknown_device", - PlaceCallFailed = "place_call_failed" + PlaceCallFailed = "place_call_failed", } export class GroupCallError extends Error { @@ -106,16 +123,24 @@ export interface IGroupCallDataChannelOptions { protocol: string; } +export interface IGroupCallRoomState { + "m.intent": GroupCallIntent; + "m.type": GroupCallType; + "io.element.ptt"?: boolean; + // TODO: Specify data-channels + "dataChannelsEnabled"?: boolean; + "dataChannelOptions"?: IGroupCallDataChannelOptions; +} + export interface IGroupCallRoomMemberFeed { purpose: SDPStreamMetadataPurpose; - // TODO: Sources for adaptive bitrate } export interface IGroupCallRoomMemberDevice { - "device_id": string; - "session_id": string; - "expires_ts": number; - "feeds": IGroupCallRoomMemberFeed[]; + device_id: string; + session_id: string; + expires_ts: number; + feeds: IGroupCallRoomMemberFeed[]; } export interface IGroupCallRoomMemberCallState { @@ -168,11 +193,11 @@ export class GroupCall extends TypedEventEmitter< public localCallFeed?: CallFeed; public localScreenshareFeed?: CallFeed; public localDesktopCapturerSourceId?: string; - public readonly calls = new Map>(); public readonly userMediaFeeds: CallFeed[] = []; public readonly screenshareFeeds: CallFeed[] = []; public groupCallId: string; + private readonly calls = new Map>(); // RoomMember -> device ID -> MatrixCall private callHandlers = new Map>(); // User ID -> device ID -> handlers private activeSpeakerLoopInterval?: ReturnType; private retryCallLoopInterval?: ReturnType; @@ -197,9 +222,8 @@ export class GroupCall extends TypedEventEmitter< super(); this.reEmitter = new ReEmitter(this); this.groupCallId = groupCallId ?? genCallID(); - this.creationTs = room.currentState.getStateEvents( - EventType.GroupCallPrefix, this.groupCallId, - )?.getTs() ?? null; + this.creationTs = + room.currentState.getStateEvents(EventType.GroupCallPrefix, this.groupCallId)?.getTs() ?? null; this.updateParticipants(); room.on(RoomStateEvent.Update, this.onRoomState); @@ -213,19 +237,16 @@ export class GroupCall extends TypedEventEmitter< this.client.groupCallEventHandler!.groupCalls.set(this.room.roomId, this); this.client.emit(GroupCallEventHandlerEvent.Outgoing, this); - await this.client.sendStateEvent( - this.room.roomId, - EventType.GroupCallPrefix, - { - "m.intent": this.intent, - "m.type": this.type, - "io.element.ptt": this.isPtt, - // TODO: Specify datachannels - "dataChannelsEnabled": this.dataChannelsEnabled, - "dataChannelOptions": this.dataChannelOptions, - }, - this.groupCallId, - ); + const groupCallState: IGroupCallRoomState = { + "m.intent": this.intent, + "m.type": this.type, + "io.element.ptt": this.isPtt, + // TODO: Specify data-channels better + "dataChannelsEnabled": this.dataChannelsEnabled, + "dataChannelOptions": this.dataChannelsEnabled ? this.dataChannelOptions : undefined, + }; + + await this.client.sendStateEvent(this.room.roomId, EventType.GroupCallPrefix, groupCallState, this.groupCallId); return this; } @@ -302,7 +323,7 @@ export class GroupCall extends TypedEventEmitter< /** * Executes the given callback on all calls in this group call. - * @param f The callback. + * @param f - The callback. */ public forEachCall(f: (call: MatrixCall) => void): void { for (const deviceMap of this.calls.values()) { @@ -320,9 +341,10 @@ export class GroupCall extends TypedEventEmitter< } public hasLocalParticipant(): boolean { - return this.participants.get( - this.room.getMember(this.client.getUserId()!)!, - )?.has(this.client.getDeviceId()!) ?? false; + return ( + this.participants.get(this.room.getMember(this.client.getUserId()!)!)?.has(this.client.getDeviceId()!) ?? + false + ); } public async initLocalCallFeed(): Promise { @@ -384,9 +406,9 @@ export class GroupCall extends TypedEventEmitter< this.localCallFeed.setNewStream(stream); const micShouldBeMuted = this.localCallFeed.isAudioMuted(); const vidShouldBeMuted = this.localCallFeed.isVideoMuted(); - logger.log(`groupCall ${this.groupCallId} updateLocalUsermediaStream oldStream ${ - oldStream.id} newStream ${stream.id} micShouldBeMuted ${ - micShouldBeMuted} vidShouldBeMuted ${vidShouldBeMuted}`); + logger.log( + `groupCall ${this.groupCallId} updateLocalUsermediaStream oldStream ${oldStream.id} newStream ${stream.id} micShouldBeMuted ${micShouldBeMuted} vidShouldBeMuted ${vidShouldBeMuted}`, + ); setTracksEnabled(stream.getAudioTracks(), !micShouldBeMuted); setTracksEnabled(stream.getVideoTracks(), !vidShouldBeMuted); this.client.getMediaHandler().stopUserMediaStream(oldStream); @@ -413,10 +435,7 @@ export class GroupCall extends TypedEventEmitter< this.activeSpeaker = undefined; this.onActiveSpeakerLoop(); - this.activeSpeakerLoopInterval = setInterval( - this.onActiveSpeakerLoop, - this.activeSpeakerInterval, - ); + this.activeSpeakerLoopInterval = setInterval(this.onActiveSpeakerLoop, this.activeSpeakerInterval); } private dispose(): void { @@ -448,7 +467,7 @@ export class GroupCall extends TypedEventEmitter< return; } - this.forEachCall(call => this.disposeCall(call, CallErrorCode.UserHangup)); + this.forEachCall((call) => this.disposeCall(call, CallErrorCode.UserHangup)); this.calls.clear(); this.activeSpeaker = undefined; @@ -475,7 +494,8 @@ export class GroupCall extends TypedEventEmitter< if (emitStateEvent) { const existingStateEvent = this.room.currentState.getStateEvents( - EventType.GroupCallPrefix, this.groupCallId, + EventType.GroupCallPrefix, + this.groupCallId, )!; await this.client.sendStateEvent( @@ -512,14 +532,14 @@ export class GroupCall extends TypedEventEmitter< /** * Sets the mute state of the local participants's microphone. - * @param {boolean} muted Whether to mute the microphone - * @returns {Promise} Whether muting/unmuting was successful + * @param muted - Whether to mute the microphone + * @returns Whether muting/unmuting was successful */ public async setMicrophoneMuted(muted: boolean): Promise { // hasAudioDevice can block indefinitely if the window has lost focus, // and it doesn't make much sense to keep a device from being muted, so // we always allow muted = true changes to go through - if (!muted && !await this.client.getMediaHandler().hasAudioDevice()) { + if (!muted && !(await this.client.getMediaHandler().hasAudioDevice())) { return false; } @@ -538,22 +558,21 @@ export class GroupCall extends TypedEventEmitter< } } - this.forEachCall(call => call.localUsermediaFeed?.setAudioVideoMuted(muted, null)); + this.forEachCall((call) => call.localUsermediaFeed?.setAudioVideoMuted(muted, null)); const sendUpdates = async (): Promise => { const updates: Promise[] = []; - this.forEachCall(call => updates.push(call.sendMetadataUpdate())); + this.forEachCall((call) => updates.push(call.sendMetadataUpdate())); - await Promise.all(updates).catch( - e => logger.info("Failed to send some metadata updates", e), - ); + await Promise.all(updates).catch((e) => logger.info("Failed to send some metadata updates", e)); }; if (sendUpdatesBefore) await sendUpdates(); if (this.localCallFeed) { - logger.log(`groupCall ${this.groupCallId} setMicrophoneMuted stream ${ - this.localCallFeed.stream.id} muted ${muted}`); + logger.log( + `groupCall ${this.groupCallId} setMicrophoneMuted stream ${this.localCallFeed.stream.id} muted ${muted}`, + ); this.localCallFeed.setAudioVideoMuted(muted, null); // I don't believe its actually necessary to enable these tracks: they // are the one on the groupcall's own CallFeed and are cloned before being @@ -565,7 +584,7 @@ export class GroupCall extends TypedEventEmitter< this.initWithAudioMuted = muted; } - this.forEachCall(call => setTracksEnabled(call.localUsermediaFeed!.stream.getAudioTracks(), !muted)); + this.forEachCall((call) => setTracksEnabled(call.localUsermediaFeed!.stream.getAudioTracks(), !muted)); this.emit(GroupCallEvent.LocalMuteStateChanged, muted, this.isLocalVideoMuted()); if (!sendUpdatesBefore) await sendUpdates(); @@ -575,20 +594,21 @@ export class GroupCall extends TypedEventEmitter< /** * Sets the mute state of the local participants's video. - * @param {boolean} muted Whether to mute the video - * @returns {Promise} Whether muting/unmuting was successful + * @param muted - Whether to mute the video + * @returns Whether muting/unmuting was successful */ public async setLocalVideoMuted(muted: boolean): Promise { // hasAudioDevice can block indefinitely if the window has lost focus, // and it doesn't make much sense to keep a device from being muted, so // we always allow muted = true changes to go through - if (!muted && !await this.client.getMediaHandler().hasVideoDevice()) { + if (!muted && !(await this.client.getMediaHandler().hasVideoDevice())) { return false; } if (this.localCallFeed) { - logger.log(`groupCall ${this.groupCallId} setLocalVideoMuted stream ${ - this.localCallFeed.stream.id} muted ${muted}`); + logger.log( + `groupCall ${this.groupCallId} setLocalVideoMuted stream ${this.localCallFeed.stream.id} muted ${muted}`, + ); this.localCallFeed.setAudioVideoMuted(null, muted); setTracksEnabled(this.localCallFeed.stream.getVideoTracks(), !muted); } else { @@ -597,7 +617,7 @@ export class GroupCall extends TypedEventEmitter< } const updates: Promise[] = []; - this.forEachCall(call => updates.push(call.setLocalVideoMuted(muted))); + this.forEachCall((call) => updates.push(call.setLocalVideoMuted(muted))); await Promise.all(updates); this.emit(GroupCallEvent.LocalMuteStateChanged, this.isMicrophoneMuted(), muted); @@ -605,9 +625,7 @@ export class GroupCall extends TypedEventEmitter< return true; } - public async setScreensharingEnabled( - enabled: boolean, opts: IScreensharingOpts = {}, - ): Promise { + public async setScreensharingEnabled(enabled: boolean, opts: IScreensharingOpts = {}): Promise { if (enabled === this.isScreensharing()) { return enabled; } @@ -649,22 +667,24 @@ export class GroupCall extends TypedEventEmitter< ); // TODO: handle errors - this.forEachCall(call => call.pushLocalFeed(this.localScreenshareFeed!.clone())); + this.forEachCall((call) => call.pushLocalFeed(this.localScreenshareFeed!.clone())); return true; } catch (error) { if (opts.throwOnFail) throw error; logger.error("Enabling screensharing error", error); - this.emit(GroupCallEvent.Error, + this.emit( + GroupCallEvent.Error, new GroupCallError( GroupCallErrorCode.NoUserMedia, - "Failed to get screen-sharing stream: ", error as Error, + "Failed to get screen-sharing stream: ", + error as Error, ), ); return false; } } else { - this.forEachCall(call => { + this.forEachCall((call) => { if (call.localScreensharingFeed) call.removeLocalFeed(call.localScreensharingFeed); }); this.client.getMediaHandler().stopScreensharingStream(this.localScreenshareFeed!.stream); @@ -701,8 +721,9 @@ export class GroupCall extends TypedEventEmitter< } if (!newCall.groupCallId || newCall.groupCallId !== this.groupCallId) { - logger.log(`Incoming call with groupCallId ${ - newCall.groupCallId} ignored because it doesn't match the current group call`); + logger.log( + `Incoming call with groupCallId ${newCall.groupCallId} ignored because it doesn't match the current group call`, + ); newCall.reject(); return; } @@ -733,8 +754,8 @@ export class GroupCall extends TypedEventEmitter< /** * Determines whether a given participant expects us to call them (versus * them calling us). - * @param userId The participant's user ID. - * @param deviceId The participant's device ID. + * @param userId - The participant's user ID. + * @param deviceId - The participant's device ID. * @returns Whether we need to place an outgoing call to the participant. */ private wantsOutgoingCall(userId: string, deviceId: string): boolean { @@ -742,9 +763,9 @@ export class GroupCall extends TypedEventEmitter< const localDeviceId = this.client.getDeviceId()!; return ( // If a user's ID is less than our own, they'll call us - userId >= localUserId + userId >= localUserId && // If this is another one of our devices, compare device IDs to tell whether it'll call us - && (userId !== localUserId || deviceId > localDeviceId) + (userId !== localUserId || deviceId > localDeviceId) ); } @@ -761,8 +782,8 @@ export class GroupCall extends TypedEventEmitter< const prevCall = callMap.get(deviceId); if ( - prevCall?.getOpponentSessionId() !== participant.sessionId - && this.wantsOutgoingCall(member.userId, deviceId) + prevCall?.getOpponentSessionId() !== participant.sessionId && + this.wantsOutgoingCall(member.userId, deviceId) ) { callsChanged = true; @@ -771,16 +792,12 @@ export class GroupCall extends TypedEventEmitter< this.disposeCall(prevCall, CallErrorCode.NewSession); } - const newCall = createNewMatrixCall( - this.client, - this.room.roomId, - { - invitee: member.userId, - opponentDeviceId: deviceId, - opponentSessionId: participant.sessionId, - groupCallId: this.groupCallId, - }, - ); + const newCall = createNewMatrixCall(this.client, this.room.roomId, { + invitee: member.userId, + opponentDeviceId: deviceId, + opponentSessionId: participant.sessionId, + groupCallId: this.groupCallId, + }); if (newCall === null) { logger.error(`Failed to create call with ${member.userId} ${deviceId}`); @@ -789,35 +806,36 @@ export class GroupCall extends TypedEventEmitter< this.initCall(newCall); callMap.set(deviceId, newCall); - logger.debug( - `Placing call to ${member.userId} ${deviceId} (session ${participant.sessionId})`, - ); - - newCall.placeCallWithCallFeeds( - this.getLocalFeeds().map(feed => feed.clone()), - participant.screensharing, - ).then(() => { - if (this.dataChannelsEnabled) { - newCall.createDataChannel("datachannel", this.dataChannelOptions); - } - }).catch(e => { - logger.warn(`Failed to place call to ${member.userId}`, e); - - if (e instanceof CallError && e.code === GroupCallErrorCode.UnknownDevice) { - this.emit(GroupCallEvent.Error, e); - } else { - this.emit( - GroupCallEvent.Error, - new GroupCallError( - GroupCallErrorCode.PlaceCallFailed, - `Failed to place call to ${member.userId}`, - ), - ); - } - - this.disposeCall(newCall, CallErrorCode.SignallingFailed); - if (callMap.get(deviceId) === newCall) callMap.delete(deviceId); - }); + logger.debug(`Placing call to ${member.userId} ${deviceId} (session ${participant.sessionId})`); + + newCall + .placeCallWithCallFeeds( + this.getLocalFeeds().map((feed) => feed.clone()), + participant.screensharing, + ) + .then(() => { + if (this.dataChannelsEnabled) { + newCall.createDataChannel("datachannel", this.dataChannelOptions); + } + }) + .catch((e) => { + logger.warn(`Failed to place call to ${member.userId}`, e); + + if (e instanceof CallError && e.code === GroupCallErrorCode.UnknownDevice) { + this.emit(GroupCallEvent.Error, e); + } else { + this.emit( + GroupCallEvent.Error, + new GroupCallError( + GroupCallErrorCode.PlaceCallFailed, + `Failed to place call to ${member.userId}`, + ), + ); + } + + this.disposeCall(newCall, CallErrorCode.SignallingFailed); + if (callMap.get(deviceId) === newCall) callMap.delete(deviceId); + }); } } } @@ -856,9 +874,9 @@ export class GroupCall extends TypedEventEmitter< const retries = retriesMap?.get(deviceId) ?? 0; if ( - call?.getOpponentSessionId() !== participant.sessionId - && this.wantsOutgoingCall(member.userId, deviceId) - && retries < 3 + call?.getOpponentSessionId() !== participant.sessionId && + this.wantsOutgoingCall(member.userId, deviceId) && + retries < 3 ) { if (retriesMap === undefined) { retriesMap = new Map(); @@ -881,10 +899,8 @@ export class GroupCall extends TypedEventEmitter< } const onCallFeedsChanged = (): void => this.onCallFeedsChanged(call); - const onCallStateChanged = ( - state: CallState, - oldState?: CallState, - ): void => this.onCallStateChanged(call, state, oldState); + const onCallStateChanged = (state: CallState, oldState?: CallState): void => + this.onCallStateChanged(call, state, oldState); const onCallHangup = this.onCallHangup; const onCallReplaced = (newCall: MatrixCall): void => this.onCallReplaced(call, newCall); @@ -922,12 +938,8 @@ export class GroupCall extends TypedEventEmitter< } const deviceMap = this.callHandlers.get(opponentMemberId)!; - const { - onCallFeedsChanged, - onCallStateChanged, - onCallHangup, - onCallReplaced, - } = deviceMap.get(opponentDeviceId)!; + const { onCallFeedsChanged, onCallStateChanged, onCallHangup, onCallReplaced } = + deviceMap.get(opponentDeviceId)!; call.removeListener(CallEvent.FeedsChanged, onCallFeedsChanged); call.removeListener(CallEvent.State, onCallStateChanged); @@ -998,19 +1010,13 @@ export class GroupCall extends TypedEventEmitter< private onCallStateChanged = (call: MatrixCall, state: CallState, _oldState: CallState | undefined): void => { const audioMuted = this.localCallFeed!.isAudioMuted(); - if ( - call.localUsermediaStream && - call.isMicrophoneMuted() !== audioMuted - ) { + if (call.localUsermediaStream && call.isMicrophoneMuted() !== audioMuted) { call.setMicrophoneMuted(audioMuted); } const videoMuted = this.localCallFeed!.isVideoMuted(); - if ( - call.localUsermediaStream && - call.isLocalVideoMuted() !== videoMuted - ) { + if (call.localUsermediaStream && call.isLocalVideoMuted() !== videoMuted) { call.setLocalVideoMuted(videoMuted); } @@ -1057,7 +1063,7 @@ export class GroupCall extends TypedEventEmitter< */ public getUserMediaFeed(userId: string, deviceId: string): CallFeed | undefined { - return this.userMediaFeeds.find(f => f.userId === userId && f.deviceId! === deviceId); + return this.userMediaFeeds.find((f) => f.userId === userId && f.deviceId! === deviceId); } private addUserMediaFeed(callFeed: CallFeed): void { @@ -1068,7 +1074,7 @@ export class GroupCall extends TypedEventEmitter< private replaceUserMediaFeed(existingFeed: CallFeed, replacementFeed: CallFeed): void { const feedIndex = this.userMediaFeeds.findIndex( - f => f.userId === existingFeed.userId && f.deviceId! === existingFeed.deviceId, + (f) => f.userId === existingFeed.userId && f.deviceId! === existingFeed.deviceId, ); if (feedIndex === -1) { @@ -1084,7 +1090,7 @@ export class GroupCall extends TypedEventEmitter< private removeUserMediaFeed(callFeed: CallFeed): void { const feedIndex = this.userMediaFeeds.findIndex( - f => f.userId === callFeed.userId && f.deviceId! === callFeed.deviceId, + (f) => f.userId === callFeed.userId && f.deviceId! === callFeed.deviceId, ); if (feedIndex === -1) { @@ -1131,7 +1137,7 @@ export class GroupCall extends TypedEventEmitter< */ public getScreenshareFeed(userId: string, deviceId: string): CallFeed | undefined { - return this.screenshareFeeds.find(f => f.userId === userId && f.deviceId! === deviceId); + return this.screenshareFeeds.find((f) => f.userId === userId && f.deviceId! === deviceId); } private addScreenshareFeed(callFeed: CallFeed): void { @@ -1141,7 +1147,7 @@ export class GroupCall extends TypedEventEmitter< private replaceScreenshareFeed(existingFeed: CallFeed, replacementFeed: CallFeed): void { const feedIndex = this.screenshareFeeds.findIndex( - f => f.userId === existingFeed.userId && f.deviceId! === existingFeed.deviceId, + (f) => f.userId === existingFeed.userId && f.deviceId! === existingFeed.deviceId, ); if (feedIndex === -1) { @@ -1156,7 +1162,7 @@ export class GroupCall extends TypedEventEmitter< private removeScreenshareFeed(callFeed: CallFeed): void { const feedIndex = this.screenshareFeeds.findIndex( - f => f.userId === callFeed.userId && f.deviceId! === callFeed.deviceId, + (f) => f.userId === callFeed.userId && f.deviceId! === callFeed.deviceId, ); if (feedIndex === -1) { @@ -1192,21 +1198,22 @@ export class GroupCall extends TypedEventEmitter< const member = this.room.getMember(e.getStateKey()!); const content = e.getContent>(); const calls: Record[] = Array.isArray(content["m.calls"]) ? content["m.calls"] : []; - const call = calls.find(call => call["m.call_id"] === this.groupCallId); + const call = calls.find((call) => call["m.call_id"] === this.groupCallId); const devices: Record[] = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : []; // Filter out invalid and expired devices - let validDevices = devices.filter(d => ( - typeof d.device_id === "string" - && typeof d.session_id === "string" - && typeof d.expires_ts === "number" - && d.expires_ts > now - && Array.isArray(d.feeds) - )) as unknown as IGroupCallRoomMemberDevice[]; + let validDevices = devices.filter( + (d) => + typeof d.device_id === "string" && + typeof d.session_id === "string" && + typeof d.expires_ts === "number" && + d.expires_ts > now && + Array.isArray(d.feeds), + ) as unknown as IGroupCallRoomMemberDevice[]; // Apply local echo for the unentered case if (!entered && member?.userId === this.client.getUserId()!) { - validDevices = validDevices.filter(d => d.device_id !== this.client.getDeviceId()!); + validDevices = validDevices.filter((d) => d.device_id !== this.client.getDeviceId()!); } // Must have a connected device and be joined to the room @@ -1217,7 +1224,7 @@ export class GroupCall extends TypedEventEmitter< for (const d of validDevices) { deviceMap.set(d.device_id, { sessionId: d.session_id, - screensharing: d.feeds.some(f => f.purpose === SDPStreamMetadataPurpose.Screenshare), + screensharing: d.feeds.some((f) => f.purpose === SDPStreamMetadataPurpose.Screenshare), }); if (d.expires_ts < nextExpiration) nextExpiration = d.expires_ts; } @@ -1236,7 +1243,7 @@ export class GroupCall extends TypedEventEmitter< if (!deviceMap.has(this.client.getDeviceId()!)) { deviceMap.set(this.client.getDeviceId()!, { sessionId: this.client.getSessionId(), - screensharing: this.getLocalFeeds().some(f => f.purpose === SDPStreamMetadataPurpose.Screenshare), + screensharing: this.getLocalFeeds().some((f) => f.purpose === SDPStreamMetadataPurpose.Screenshare), }); } } @@ -1249,9 +1256,9 @@ export class GroupCall extends TypedEventEmitter< /** * Updates the local user's member state with the devices returned by the given function. - * @param fn A function from the current devices to the new devices. If it + * @param fn - A function from the current devices to the new devices. If it * returns null, the update will be skipped. - * @param keepAlive Whether the request should outlive the window. + * @param keepAlive - Whether the request should outlive the window. */ private async updateDevices( fn: (devices: IGroupCallRoomMemberDevice[]) => IGroupCallRoomMemberDevice[] | null, @@ -1278,18 +1285,19 @@ export class GroupCall extends TypedEventEmitter< const devices: Record[] = Array.isArray(call["m.devices"]) ? call["m.devices"] : []; // Filter out invalid and expired devices - const validDevices = devices.filter(d => ( - typeof d.device_id === "string" - && typeof d.session_id === "string" - && typeof d.expires_ts === "number" - && d.expires_ts > now - && Array.isArray(d.feeds) - )) as unknown as IGroupCallRoomMemberDevice[]; + const validDevices = devices.filter( + (d) => + typeof d.device_id === "string" && + typeof d.session_id === "string" && + typeof d.expires_ts === "number" && + d.expires_ts > now && + Array.isArray(d.feeds), + ) as unknown as IGroupCallRoomMemberDevice[]; const newDevices = fn(validDevices); if (newDevices === null) return; - const newCalls = [...otherCalls as unknown as IGroupCallRoomMemberCallState[]]; + const newCalls = [...(otherCalls as unknown as IGroupCallRoomMemberCallState[])]; if (newDevices.length > 0) { newCalls.push({ ...call, @@ -1300,19 +1308,19 @@ export class GroupCall extends TypedEventEmitter< const newContent: IGroupCallRoomMemberState = { "m.calls": newCalls }; - await this.client.sendStateEvent( - this.room.roomId, EventType.GroupCallMemberPrefix, newContent, localUserId, { keepAlive }, - ); + await this.client.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, newContent, localUserId, { + keepAlive, + }); } private async addDeviceToMemberState(): Promise { - await this.updateDevices(devices => [ - ...devices.filter(d => d.device_id !== this.client.getDeviceId()!), + await this.updateDevices((devices) => [ + ...devices.filter((d) => d.device_id !== this.client.getDeviceId()!), { - "device_id": this.client.getDeviceId()!, - "session_id": this.client.getSessionId(), - "expires_ts": Date.now() + DEVICE_TIMEOUT, - "feeds": this.getLocalFeeds().map(feed => ({ purpose: feed.purpose })), + device_id: this.client.getDeviceId()!, + session_id: this.client.getSessionId(), + expires_ts: Date.now() + DEVICE_TIMEOUT, + feeds: this.getLocalFeeds().map((feed) => ({ purpose: feed.purpose })), // TODO: Add data channels }, ]); @@ -1337,11 +1345,11 @@ export class GroupCall extends TypedEventEmitter< } catch (e) { logger.error("Failed to resend call member state", e); } - }, DEVICE_TIMEOUT * 3 / 4); + }, (DEVICE_TIMEOUT * 3) / 4); } else { // Remove the local device await this.updateDevices( - devices => devices.filter(d => d.device_id !== this.client.getDeviceId()!), + (devices) => devices.filter((d) => d.device_id !== this.client.getDeviceId()!), true, ); } @@ -1353,16 +1361,19 @@ export class GroupCall extends TypedEventEmitter< */ public async cleanMemberState(): Promise { const { devices: myDevices } = await this.client.getDevices(); - const deviceMap = new Map(myDevices.map(d => [d.device_id, d])); + const deviceMap = new Map(myDevices.map((d) => [d.device_id, d])); // updateDevices takes care of filtering out inactive devices for us - await this.updateDevices(devices => { - const newDevices = devices.filter(d => { + await this.updateDevices((devices) => { + const newDevices = devices.filter((d) => { const device = deviceMap.get(d.device_id); - return device?.last_seen_ts !== undefined && !( - d.device_id === this.client.getDeviceId()! - && this.state !== GroupCallState.Entered - && !this.enteredViaAnotherSession + return ( + device?.last_seen_ts !== undefined && + !( + d.device_id === this.client.getDeviceId()! && + this.state !== GroupCallState.Entered && + !this.enteredViaAnotherSession + ) ); }); @@ -1379,19 +1390,19 @@ export class GroupCall extends TypedEventEmitter< private onStateChanged = (newState: GroupCallState, oldState: GroupCallState): void => { if ( - newState === GroupCallState.Entered - || oldState === GroupCallState.Entered - || newState === GroupCallState.Ended + newState === GroupCallState.Entered || + oldState === GroupCallState.Entered || + newState === GroupCallState.Ended ) { // We either entered, left, or ended the call this.updateParticipants(); - this.updateMemberState().catch(e => logger.error("Failed to update member state devices", e)); + this.updateMemberState().catch((e) => logger.error("Failed to update member state devices", e)); } }; private onLocalFeedsChanged = (): void => { if (this.state === GroupCallState.Entered) { - this.updateMemberState().catch(e => logger.error("Failed to update member state feeds", e)); + this.updateMemberState().catch((e) => logger.error("Failed to update member state feeds", e)); } }; } diff --git a/src/webrtc/groupCallEventHandler.ts b/src/webrtc/groupCallEventHandler.ts index b1768ee90dd..d96d5ef8686 100644 --- a/src/webrtc/groupCallEventHandler.ts +++ b/src/webrtc/groupCallEventHandler.ts @@ -14,20 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixEvent } from '../models/event'; -import { MatrixClient, ClientEvent } from '../client'; -import { - GroupCall, - GroupCallIntent, - GroupCallType, - IGroupCallDataChannelOptions, -} from "./groupCall"; +import { MatrixEvent } from "../models/event"; +import { MatrixClient, ClientEvent } from "../client"; +import { GroupCall, GroupCallIntent, GroupCallType, IGroupCallDataChannelOptions } from "./groupCall"; import { Room } from "../models/room"; import { RoomState, RoomStateEvent } from "../models/room-state"; import { RoomMember } from "../models/room-member"; -import { logger } from '../logger'; +import { logger } from "../logger"; import { EventType } from "../@types/event"; -import { SyncState } from '../sync'; +import { SyncState } from "../sync"; export enum GroupCallEventHandlerEvent { Incoming = "GroupCall.incoming", @@ -57,7 +52,7 @@ export class GroupCallEventHandler { // and get private roomDeferreds = new Map(); - public constructor(private client: MatrixClient) { } + public constructor(private client: MatrixClient) {} public async start(): Promise { // We wait until the client has started syncing for real. @@ -67,7 +62,7 @@ export class GroupCallEventHandler { // the group call we create is really the latest one. if (this.client.getSyncState() !== SyncState.Syncing) { logger.debug("Waiting for client to start syncing..."); - await new Promise(resolve => { + await new Promise((resolve) => { const onSync = (): void => { if (this.client.getSyncState() === SyncState.Syncing) { this.client.off(ClientEvent.Sync, onSync); @@ -97,7 +92,7 @@ export class GroupCallEventHandler { if (deferred === undefined) { let resolveFunc: () => void; deferred = { - prom: new Promise(resolve => { + prom: new Promise((resolve) => { resolveFunc = resolve; }), }; @@ -129,7 +124,7 @@ export class GroupCallEventHandler { logger.debug( `Choosing group call ${callEvent.getStateKey()} with TS ` + - `${callEvent.getTs()} for room ${room.roomId} from ${callEvents.length} possible calls.`, + `${callEvent.getTs()} for room ${room.roomId} from ${callEvents.length} possible calls.`, ); this.createGroupCallFromRoomStateEvent(callEvent); @@ -214,13 +209,15 @@ export class GroupCallEventHandler { currentGroupCall.terminate(false); } else if (content["m.type"] !== currentGroupCall.type) { // TODO: Handle the callType changing when the room state changes - logger.warn(`The group call type changed for room: ${ - state.roomId}. Changing the group call type is currently unsupported.`); + logger.warn( + `The group call type changed for room: ${state.roomId}. Changing the group call type is currently unsupported.`, + ); } } else if (currentGroupCall && currentGroupCall.groupCallId !== groupCallId) { // TODO: Handle new group calls and multiple group calls - logger.warn(`Multiple group calls detected for room: ${ - state.roomId}. Multiple group calls are currently unsupported.`); + logger.warn( + `Multiple group calls detected for room: ${state.roomId}. Multiple group calls are currently unsupported.`, + ); } } }; diff --git a/src/webrtc/mediaHandler.ts b/src/webrtc/mediaHandler.ts index c7c84876bf9..ca311e4a5a3 100644 --- a/src/webrtc/mediaHandler.ts +++ b/src/webrtc/mediaHandler.ts @@ -23,7 +23,7 @@ import { logger } from "../logger"; import { MatrixClient } from "../client"; export enum MediaHandlerEvent { - LocalStreamsChanged = "local_streams_changed" + LocalStreamsChanged = "local_streams_changed", } export type MediaHandlerEventHandlerMap = { @@ -47,7 +47,8 @@ export interface AudioSettings { } export class MediaHandler extends TypedEventEmitter< - MediaHandlerEvent.LocalStreamsChanged, MediaHandlerEventHandlerMap + MediaHandlerEvent.LocalStreamsChanged, + MediaHandlerEventHandlerMap > { private audioInput?: string; private audioSettings?: AudioSettings; @@ -67,7 +68,7 @@ export class MediaHandler extends TypedEventEmitter< /** * Set an audio input device to use for MatrixCalls - * @param {string} deviceId the identifier for the device + * @param deviceId - the identifier for the device * undefined treated as unset */ public async setAudioInput(deviceId: string): Promise { @@ -81,7 +82,7 @@ export class MediaHandler extends TypedEventEmitter< /** * Set audio settings for MatrixCalls - * @param {AudioSettings} opts audio options to set + * @param opts - audio options to set */ public async setAudioSettings(opts: AudioSettings): Promise { logger.info("Setting audio settings to", opts); @@ -92,7 +93,7 @@ export class MediaHandler extends TypedEventEmitter< /** * Set a video input device to use for MatrixCalls - * @param {string} deviceId the identifier for the device + * @param deviceId - the identifier for the device * undefined treated as unset */ public async setVideoInput(deviceId: string): Promise { @@ -106,8 +107,8 @@ export class MediaHandler extends TypedEventEmitter< /** * Set media input devices to use for MatrixCalls - * @param {string} audioInput the identifier for the audio device - * @param {string} videoInput the identifier for the video device + * @param audioInput - the identifier for the audio device + * @param videoInput - the identifier for the video device * undefined treated as unset */ public async setMediaInputs(audioInput: string, videoInput: string): Promise { @@ -123,7 +124,7 @@ export class MediaHandler extends TypedEventEmitter< public async updateLocalUsermediaStreams(): Promise { if (this.userMediaStreams.length === 0) return; - const callMediaStreamParams: Map = new Map(); + const callMediaStreamParams: Map = new Map(); for (const call of this.client.callEventHandler!.calls.values()) { callMediaStreamParams.set(call.callId, { audio: call.hasLocalUserMediaAudioTrack, @@ -163,12 +164,10 @@ export class MediaHandler extends TypedEventEmitter< continue; } - logger.log(`mediaHandler updateLocalUsermediaStreams getUserMediaStream groupCall ${ - groupCall.groupCallId}`); - const stream = await this.getUserMediaStream( - true, - groupCall.type === GroupCallType.Video, + logger.log( + `mediaHandler updateLocalUsermediaStreams getUserMediaStream groupCall ${groupCall.groupCallId}`, ); + const stream = await this.getUserMediaStream(true, groupCall.type === GroupCallType.Video); if (groupCall.state === GroupCallState.Ended) { continue; @@ -182,23 +181,23 @@ export class MediaHandler extends TypedEventEmitter< public async hasAudioDevice(): Promise { const devices = await navigator.mediaDevices.enumerateDevices(); - return devices.filter(device => device.kind === "audioinput").length > 0; + return devices.filter((device) => device.kind === "audioinput").length > 0; } public async hasVideoDevice(): Promise { const devices = await navigator.mediaDevices.enumerateDevices(); - return devices.filter(device => device.kind === "videoinput").length > 0; + return devices.filter((device) => device.kind === "videoinput").length > 0; } /** - * @param audio should have an audio track - * @param video should have a video track - * @param reusable is allowed to be reused by the MediaHandler - * @returns {MediaStream} based on passed parameters + * @param audio - should have an audio track + * @param video - should have a video track + * @param reusable - is allowed to be reused by the MediaHandler + * @returns based on passed parameters */ public async getUserMediaStream(audio: boolean, video: boolean, reusable = true): Promise { - const shouldRequestAudio = audio && await this.hasAudioDevice(); - const shouldRequestVideo = video && await this.hasVideoDevice(); + const shouldRequestAudio = audio && (await this.hasAudioDevice()); + const shouldRequestVideo = video && (await this.hasVideoDevice()); let stream: MediaStream; @@ -218,7 +217,8 @@ export class MediaHandler extends TypedEventEmitter< if (shouldRequestVideo) { if ( this.localUserMediaStream.getVideoTracks().length === 0 || - this.localUserMediaStream.getVideoTracks()[0]?.getSettings()?.deviceId !== this.videoInput) { + this.localUserMediaStream.getVideoTracks()[0]?.getSettings()?.deviceId !== this.videoInput + ) { canReuseStream = false; } } @@ -229,8 +229,10 @@ export class MediaHandler extends TypedEventEmitter< if (!canReuseStream) { const constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo); stream = await navigator.mediaDevices.getUserMedia(constraints); - logger.log(`mediaHandler getUserMediaStream streamId ${stream.id} shouldRequestAudio ${ - shouldRequestAudio} shouldRequestVideo ${shouldRequestVideo}`, constraints); + logger.log( + `mediaHandler getUserMediaStream streamId ${stream.id} shouldRequestAudio ${shouldRequestAudio} shouldRequestVideo ${shouldRequestVideo}`, + constraints, + ); for (const track of stream.getTracks()) { const settings = track.getSettings(); @@ -247,8 +249,9 @@ export class MediaHandler extends TypedEventEmitter< } } else { stream = this.localUserMediaStream!.clone(); - logger.log(`mediaHandler clone userMediaStream ${this.localUserMediaStream?.id} new stream ${ - stream.id} shouldRequestAudio ${shouldRequestAudio} shouldRequestVideo ${shouldRequestVideo}`); + logger.log( + `mediaHandler clone userMediaStream ${this.localUserMediaStream?.id} new stream ${stream.id} shouldRequestAudio ${shouldRequestAudio} shouldRequestVideo ${shouldRequestVideo}`, + ); if (!shouldRequestAudio) { for (const track of stream.getAudioTracks()) { @@ -296,9 +299,9 @@ export class MediaHandler extends TypedEventEmitter< } /** - * @param desktopCapturerSourceId sourceId for Electron DesktopCapturer - * @param reusable is allowed to be reused by the MediaHandler - * @returns {MediaStream} based on passed parameters + * @param desktopCapturerSourceId - sourceId for Electron DesktopCapturer + * @param reusable - is allowed to be reused by the MediaHandler + * @returns based on passed parameters */ public async getScreensharingStream(opts: IScreensharingOpts = {}, reusable = true): Promise { let stream: MediaStream; @@ -379,23 +382,23 @@ export class MediaHandler extends TypedEventEmitter< return { audio: audio ? { - deviceId: this.audioInput ? { ideal: this.audioInput } : undefined, - autoGainControl: this.audioSettings ? { ideal: this.audioSettings.autoGainControl } : undefined, - echoCancellation: this.audioSettings ? { ideal: this.audioSettings.echoCancellation } : undefined, - noiseSuppression: this.audioSettings ? { ideal: this.audioSettings.noiseSuppression } : undefined, - } + deviceId: this.audioInput ? { ideal: this.audioInput } : undefined, + autoGainControl: this.audioSettings ? { ideal: this.audioSettings.autoGainControl } : undefined, + echoCancellation: this.audioSettings ? { ideal: this.audioSettings.echoCancellation } : undefined, + noiseSuppression: this.audioSettings ? { ideal: this.audioSettings.noiseSuppression } : undefined, + } : false, video: video ? { - deviceId: this.videoInput ? { ideal: this.videoInput } : undefined, - /* We want 640x360. Chrome will give it only if we ask exactly, + deviceId: this.videoInput ? { ideal: this.videoInput } : undefined, + /* We want 640x360. Chrome will give it only if we ask exactly, FF refuses entirely if we ask exactly, so have to ask for ideal instead XXX: Is this still true? */ - width: isWebkit ? { exact: 640 } : { ideal: 640 }, - height: isWebkit ? { exact: 360 } : { ideal: 360 }, - } + width: isWebkit ? { exact: 640 } : { ideal: 640 }, + height: isWebkit ? { exact: 360 } : { ideal: 360 }, + } : false, }; } diff --git a/tsconfig-build.json b/tsconfig-build.json index 42a4cf697b0..3108314a4d6 100644 --- a/tsconfig-build.json +++ b/tsconfig-build.json @@ -1,14 +1,12 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "declarationMap": true, - "sourceMap": true, - "noEmit": false, - "emitDecoratorMetadata": true, - "outDir": "./lib", - "rootDir": "src" - }, - "exclude": [ - "./spec/**/*.ts" - ] + "extends": "./tsconfig.json", + "compilerOptions": { + "declarationMap": true, + "sourceMap": true, + "noEmit": false, + "emitDecoratorMetadata": true, + "outDir": "./lib", + "rootDir": "src" + }, + "exclude": ["./spec/**/*.ts"] } diff --git a/tsconfig.json b/tsconfig.json index 9644f5c5f77..839dba04aab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,23 +1,19 @@ { - "compilerOptions": { - "target": "es2016", - "experimentalDecorators": true, - "esModuleInterop": true, - "module": "commonjs", - "moduleResolution": "node", - "noImplicitAny": false, - "noUnusedLocals": true, - "noEmit": true, - "declaration": true, - "strict": true - }, - "include": [ - "./src/**/*.ts", - "./spec/**/*.ts" - ], - "typedocOptions": { - "entryPoints": ["src/index.ts"], - "excludeExternals": true, - "out": "_docs" - } + "compilerOptions": { + "target": "es2016", + "experimentalDecorators": true, + "esModuleInterop": true, + "module": "commonjs", + "moduleResolution": "node", + "noUnusedLocals": true, + "noEmit": true, + "declaration": true, + "strict": true + }, + "include": ["./src/**/*.ts", "./spec/**/*.ts"], + "typedocOptions": { + "entryPoints": ["src/index.ts"], + "excludeExternals": true, + "out": "_docs" + } } diff --git a/yarn.lock b/yarn.lock index 67c5baeb430..c4d494579ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -58,26 +58,31 @@ dependencies: "@babel/highlight" "^7.18.6" -"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.20.0", "@babel/compat-data@^7.20.1": +"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.20.1": version "7.20.1" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.20.1.tgz#f2e6ef7790d8c8dbf03d379502dcc246dcce0b30" integrity sha512-EWZ4mE2diW3QALKvDMiXnbZpRvlj+nayZ112nK93SnhqOtpdsbVD4W+2tEoT3YNBAG9RBR0ISY758ZkOgsn6pQ== +"@babel/compat-data@^7.20.0": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.20.5.tgz#86f172690b093373a933223b4745deeb6049e733" + integrity sha512-KZXo2t10+/jxmkhNXc7pZTqRvSOIvVv/+lJwHS+B2rErwOyjuVRh60yVpb7liQ1U5t7lLJ1bz+t8tSypUZdm0g== + "@babel/core@^7.11.6", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.7.5": - version "7.20.2" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.20.2.tgz#8dc9b1620a673f92d3624bd926dc49a52cf25b92" - integrity sha512-w7DbG8DtMrJcFOi4VrLm+8QM4az8Mo+PuLBKLp2zrYRCow8W/f9xiXm5sN53C8HksCyDQwCKha9JiDoIyPjT2g== + version "7.20.5" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.20.5.tgz#45e2114dc6cd4ab167f81daf7820e8fa1250d113" + integrity sha512-UdOWmk4pNWTm/4DlPUl/Pt4Gz4rcEMb7CY0Y3eJl5Yz1vI8ZJGmHWaVE55LoxRjdpx0z259GE9U5STA9atUinQ== dependencies: "@ampproject/remapping" "^2.1.0" "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.20.2" + "@babel/generator" "^7.20.5" "@babel/helper-compilation-targets" "^7.20.0" "@babel/helper-module-transforms" "^7.20.2" - "@babel/helpers" "^7.20.1" - "@babel/parser" "^7.20.2" + "@babel/helpers" "^7.20.5" + "@babel/parser" "^7.20.5" "@babel/template" "^7.18.10" - "@babel/traverse" "^7.20.1" - "@babel/types" "^7.20.2" + "@babel/traverse" "^7.20.5" + "@babel/types" "^7.20.5" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.2" @@ -109,7 +114,16 @@ "@jridgewell/gen-mapping" "^0.3.2" jsesc "^2.5.1" -"@babel/generator@^7.20.1", "@babel/generator@^7.20.2", "@babel/generator@^7.7.2": +"@babel/generator@^7.20.1", "@babel/generator@^7.20.5": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.5.tgz#cb25abee3178adf58d6814b68517c62bdbfdda95" + integrity sha512-jl7JY2Ykn9S0yj4DQP82sYvPU+T3g0HFcWTqDLqiuA9tGRNIj9VfbtXGAYTTkyNEnQk1jkMGOdYka8aG/lulCA== + dependencies: + "@babel/types" "^7.20.5" + "@jridgewell/gen-mapping" "^0.3.2" + jsesc "^2.5.1" + +"@babel/generator@^7.7.2": version "7.20.4" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.4.tgz#4d9f8f0c30be75fd90a0562099a26e5839602ab8" integrity sha512-luCf7yk/cm7yab6CAW1aiFnmEfBJplb/JojV56MYEK7ziWfGmFlTfmL9Ehwfy4gFhbjBfWO1wj7/TuSbVNEEtA== @@ -310,14 +324,14 @@ "@babel/traverse" "^7.19.0" "@babel/types" "^7.19.0" -"@babel/helpers@^7.20.1": - version "7.20.1" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.20.1.tgz#2ab7a0fcb0a03b5bf76629196ed63c2d7311f4c9" - integrity sha512-J77mUVaDTUJFZ5BpP6mMn6OIl3rEWymk2ZxDBQJUG3P+PbmyMcF3bYWvz0ma69Af1oobDqT/iAsvzhB58xhQUg== +"@babel/helpers@^7.20.5": + version "7.20.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.20.6.tgz#e64778046b70e04779dfbdf924e7ebb45992c763" + integrity sha512-Pf/OjgfgFRW5bApskEz5pvidpim7tEDPlFtKcNRXWmfHGn9IEI2W2flqRQXTFb7gIPTyK++N6rVHuwKut4XK6w== dependencies: "@babel/template" "^7.18.10" - "@babel/traverse" "^7.20.1" - "@babel/types" "^7.20.0" + "@babel/traverse" "^7.20.5" + "@babel/types" "^7.20.5" "@babel/highlight@^7.18.6": version "7.18.6" @@ -328,11 +342,16 @@ 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.2.3", "@babel/parser@^7.20.1", "@babel/parser@^7.20.2": +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.2.3": version "7.20.3" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.3.tgz#5358cf62e380cf69efcb87a7bb922ff88bfac6e2" integrity sha512-OP/s5a94frIPXwjzEcv5S/tpQfc6XhxYUnmWpgdqMWGgYCuErA3SzozaRAMQgSZWKeTJxht9aWAkUY+0UzvOFg== +"@babel/parser@^7.18.10", "@babel/parser@^7.20.1", "@babel/parser@^7.20.5": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.5.tgz#7f3c7335fe417665d929f34ae5dceae4c04015e8" + integrity sha512-r27t/cy/m9uKLXQNWWebeCUHgnAZq0CpG1OwKRxzJMP1vpSU4bSIK2hq+/cp0bQxetkXx38n09rNu8jVkcK/zA== + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz#da5b8f9a580acdfbe53494dba45ea389fb09a4d2" @@ -995,11 +1014,11 @@ source-map-support "^0.5.16" "@babel/runtime@^7.12.5", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4": - version "7.20.1" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.1.tgz#1148bb33ab252b165a06698fde7576092a78b4a9" - integrity sha512-mrzLkl6U9YLF8qpqI7TB82PESyEGjm/0Ly91jG575eVxMMlb8fYfOXFZIJ8XfLrJZQbm7dlKry2bJmXBUEkdFg== + version "7.20.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.6.tgz#facf4879bfed9b5326326273a64220f099b0fce3" + integrity sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA== dependencies: - regenerator-runtime "^0.13.10" + regenerator-runtime "^0.13.11" "@babel/template@^7.18.10", "@babel/template@^7.3.3": version "7.18.10" @@ -1010,7 +1029,7 @@ "@babel/parser" "^7.18.10" "@babel/types" "^7.18.10" -"@babel/traverse@^7.1.6", "@babel/traverse@^7.19.0", "@babel/traverse@^7.19.1", "@babel/traverse@^7.20.1", "@babel/traverse@^7.7.2": +"@babel/traverse@^7.1.6", "@babel/traverse@^7.19.0", "@babel/traverse@^7.19.1", "@babel/traverse@^7.7.2": version "7.20.1" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.20.1.tgz#9b15ccbf882f6d107eeeecf263fbcdd208777ec8" integrity sha512-d3tN8fkVJwFLkHkBN479SOsw4DMZnz8cdbL/gvuDuzy3TS6Nfw80HuQqhw1pITbIruHyh7d1fMA47kWzmcUEGA== @@ -1026,7 +1045,23 @@ 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.19.0", "@babel/types@^7.2.0", "@babel/types@^7.20.0", "@babel/types@^7.20.2", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": +"@babel/traverse@^7.20.1", "@babel/traverse@^7.20.5": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.20.5.tgz#78eb244bea8270fdda1ef9af22a5d5e5b7e57133" + integrity sha512-WM5ZNN3JITQIq9tFZaw1ojLU3WgWdtkxnhM1AegMS+PvHjkM5IXjmYEGY7yukz5XS4sJyEf2VzWjI8uAavhxBQ== + dependencies: + "@babel/code-frame" "^7.18.6" + "@babel/generator" "^7.20.5" + "@babel/helper-environment-visitor" "^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.20.5" + "@babel/types" "^7.20.5" + debug "^4.1.0" + globals "^11.1.0" + +"@babel/types@^7.0.0", "@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.20.2" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.20.2.tgz#67ac09266606190f496322dbaff360fdaa5e7842" integrity sha512-FnnvsNWgZCr232sqtXggapvlkk/tuwR/qhGzcmxI0GXLCjmPYQPzio2FbdlWuY6y1sHFfQKk+rRbUZ9VStQMog== @@ -1035,6 +1070,15 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" +"@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.19.0", "@babel/types@^7.20.0", "@babel/types@^7.20.2", "@babel/types@^7.20.5": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.20.5.tgz#e206ae370b5393d94dfd1d04cd687cace53efa84" + integrity sha512-c9fst/h2/dcF7H+MJKZ2T0KjEQ8hY/BNnDk/H3XY8C4Aw/eWQXWn/lWntHF9ooUBnGmEvbfGrTgLWc+um0YDUg== + dependencies: + "@babel/helper-string-parser" "^7.19.4" + "@babel/helper-validator-identifier" "^7.19.1" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1049,6 +1093,22 @@ uuid "8.3.2" xml "1.0.1" +"@es-joy/jsdoccomment@~0.36.1": + version "0.36.1" + resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.36.1.tgz#c37db40da36e4b848da5fd427a74bae3b004a30f" + integrity sha512-922xqFsTpHs6D0BUiG4toiyPOMc8/jafnWKxz1KWgS4XzKPy2qXf1Pe6UFuNSCQqt6tOuhAWXBNuuyUhJmw9Vg== + dependencies: + comment-parser "1.3.1" + esquery "^1.4.0" + jsdoc-type-pratt-parser "~3.1.0" + +"@eslint-community/eslint-utils@^4.1.0": + version "4.1.2" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.1.2.tgz#14ca568ddaa291dd19a4a54498badc18c6cfab78" + integrity sha512-7qELuQWWjVDdVsFQ5+beUl+KPczrEDA7S3zM4QUd/bJl7oXgsmpXaEVqrRTnOBqenOV4rWf2kVZk2Ot085zPWA== + dependencies: + eslint-visitor-keys "^3.3.0" + "@eslint/eslintrc@^1.3.3": version "1.3.3" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.3.tgz#2b044ab39fdfa75b4688184f9e573ce3c5b0ff95" @@ -1372,9 +1432,24 @@ dependencies: lodash "^4.17.21" -"@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" +"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz": + version "3.2.14" + resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz#acd96c00a881d0f462e1f97a56c73742c8dbc984" + +"@microsoft/tsdoc-config@0.16.2": + version "0.16.2" + resolved "https://registry.yarnpkg.com/@microsoft/tsdoc-config/-/tsdoc-config-0.16.2.tgz#b786bb4ead00d54f53839a458ce626c8548d3adf" + integrity sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw== + dependencies: + "@microsoft/tsdoc" "0.14.2" + ajv "~6.12.6" + jju "~1.4.0" + resolve "~1.19.0" + +"@microsoft/tsdoc@0.14.2": + version "0.14.2" + resolved "https://registry.yarnpkg.com/@microsoft/tsdoc/-/tsdoc-0.14.2.tgz#c3ec604a0b54b9a9b87e9735dfc59e1a5da6a5fb" + integrity sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug== "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3": version "2.1.8-no-fsevents.3" @@ -1708,6 +1783,11 @@ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397" integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw== +"@types/uuid@7": + version "7.0.5" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-7.0.5.tgz#b1d2f772142a301538fae9bdf9cf15b9f2573a29" + integrity sha512-hKB88y3YHL8oPOs/CNlaXtjWn93+Bs48sDQR37ZUqG2tLeCS7EA1cmnkKsuQsub9OKEB/y/Rw9zqJqqNSbqVlQ== + "@types/webidl-conversions@*": version "7.0.0" resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz#2b8e60e33906459219aa587e9d1a612ae994cfe7" @@ -1725,14 +1805,14 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/eslint-plugin@^5.6.0": - version "5.43.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.43.0.tgz#4a5248eb31b454715ddfbf8cfbf497529a0a78bc" - integrity sha512-wNPzG+eDR6+hhW4yobEmpR36jrqqQv1vxBq5LJO3fBAktjkvekfr4BRl+3Fn1CM/A+s8/EiGUbOMDoYqWdbtXA== +"@typescript-eslint/eslint-plugin@^5.45.0": + version "5.45.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.45.0.tgz#ffa505cf961d4844d38cfa19dcec4973a6039e41" + integrity sha512-CXXHNlf0oL+Yg021cxgOdMHNTXD17rHkq7iW6RFHoybdFgQBjU3yIXhhcPpGwr1CjZlo6ET8C6tzX5juQoXeGA== dependencies: - "@typescript-eslint/scope-manager" "5.43.0" - "@typescript-eslint/type-utils" "5.43.0" - "@typescript-eslint/utils" "5.43.0" + "@typescript-eslint/scope-manager" "5.45.0" + "@typescript-eslint/type-utils" "5.45.0" + "@typescript-eslint/utils" "5.45.0" debug "^4.3.4" ignore "^5.2.0" natural-compare-lite "^1.4.0" @@ -1740,72 +1820,72 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/parser@^5.6.0": - version "5.43.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.43.0.tgz#9c86581234b88f2ba406f0b99a274a91c11630fd" - integrity sha512-2iHUK2Lh7PwNUlhFxxLI2haSDNyXvebBO9izhjhMoDC+S3XI9qt2DGFUsiJ89m2k7gGYch2aEpYqV5F/+nwZug== +"@typescript-eslint/parser@^5.45.0": + version "5.45.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.45.0.tgz#b18a5f6b3cf1c2b3e399e9d2df4be40d6b0ddd0e" + integrity sha512-brvs/WSM4fKUmF5Ot/gEve6qYiCMjm6w4HkHPfS6ZNmxTS0m0iNN4yOChImaCkqc1hRwFGqUyanMXuGal6oyyQ== dependencies: - "@typescript-eslint/scope-manager" "5.43.0" - "@typescript-eslint/types" "5.43.0" - "@typescript-eslint/typescript-estree" "5.43.0" + "@typescript-eslint/scope-manager" "5.45.0" + "@typescript-eslint/types" "5.45.0" + "@typescript-eslint/typescript-estree" "5.45.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@5.43.0": - version "5.43.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.43.0.tgz#566e46303392014d5d163704724872e1f2dd3c15" - integrity sha512-XNWnGaqAtTJsUiZaoiGIrdJYHsUOd3BZ3Qj5zKp9w6km6HsrjPk/TGZv0qMTWyWj0+1QOqpHQ2gZOLXaGA9Ekw== +"@typescript-eslint/scope-manager@5.45.0": + version "5.45.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.45.0.tgz#7a4ac1bfa9544bff3f620ab85947945938319a96" + integrity sha512-noDMjr87Arp/PuVrtvN3dXiJstQR1+XlQ4R1EvzG+NMgXi8CuMCXpb8JqNtFHKceVSQ985BZhfRdowJzbv4yKw== dependencies: - "@typescript-eslint/types" "5.43.0" - "@typescript-eslint/visitor-keys" "5.43.0" + "@typescript-eslint/types" "5.45.0" + "@typescript-eslint/visitor-keys" "5.45.0" -"@typescript-eslint/type-utils@5.43.0": - version "5.43.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.43.0.tgz#91110fb827df5161209ecca06f70d19a96030be6" - integrity sha512-K21f+KY2/VvYggLf5Pk4tgBOPs2otTaIHy2zjclo7UZGLyFH86VfUOm5iq+OtDtxq/Zwu2I3ujDBykVW4Xtmtg== +"@typescript-eslint/type-utils@5.45.0": + version "5.45.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.45.0.tgz#aefbc954c40878fcebeabfb77d20d84a3da3a8b2" + integrity sha512-DY7BXVFSIGRGFZ574hTEyLPRiQIvI/9oGcN8t1A7f6zIs6ftbrU0nhyV26ZW//6f85avkwrLag424n+fkuoJ1Q== dependencies: - "@typescript-eslint/typescript-estree" "5.43.0" - "@typescript-eslint/utils" "5.43.0" + "@typescript-eslint/typescript-estree" "5.45.0" + "@typescript-eslint/utils" "5.45.0" debug "^4.3.4" tsutils "^3.21.0" -"@typescript-eslint/types@5.43.0": - version "5.43.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.43.0.tgz#e4ddd7846fcbc074325293515fa98e844d8d2578" - integrity sha512-jpsbcD0x6AUvV7tyOlyvon0aUsQpF8W+7TpJntfCUWU1qaIKu2K34pMwQKSzQH8ORgUrGYY6pVIh1Pi8TNeteg== +"@typescript-eslint/types@5.45.0": + version "5.45.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.45.0.tgz#794760b9037ee4154c09549ef5a96599621109c5" + integrity sha512-QQij+u/vgskA66azc9dCmx+rev79PzX8uDHpsqSjEFtfF2gBUTRCpvYMh2gw2ghkJabNkPlSUCimsyBEQZd1DA== -"@typescript-eslint/typescript-estree@5.43.0": - version "5.43.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.43.0.tgz#b6883e58ba236a602c334be116bfc00b58b3b9f2" - integrity sha512-BZ1WVe+QQ+igWal2tDbNg1j2HWUkAa+CVqdU79L4HP9izQY6CNhXfkNwd1SS4+sSZAP/EthI1uiCSY/+H0pROg== +"@typescript-eslint/typescript-estree@5.45.0": + version "5.45.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.45.0.tgz#f70a0d646d7f38c0dfd6936a5e171a77f1e5291d" + integrity sha512-maRhLGSzqUpFcZgXxg1qc/+H0bT36lHK4APhp0AEUVrpSwXiRAomm/JGjSG+kNUio5kAa3uekCYu/47cnGn5EQ== dependencies: - "@typescript-eslint/types" "5.43.0" - "@typescript-eslint/visitor-keys" "5.43.0" + "@typescript-eslint/types" "5.45.0" + "@typescript-eslint/visitor-keys" "5.45.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.43.0": - version "5.43.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.43.0.tgz#00fdeea07811dbdf68774a6f6eacfee17fcc669f" - integrity sha512-8nVpA6yX0sCjf7v/NDfeaOlyaIIqL7OaIGOWSPFqUKK59Gnumd3Wa+2l8oAaYO2lk0sO+SbWFWRSvhu8gLGv4A== +"@typescript-eslint/utils@5.45.0": + version "5.45.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.45.0.tgz#9cca2996eee1b8615485a6918a5c763629c7acf5" + integrity sha512-OUg2JvsVI1oIee/SwiejTot2OxwU8a7UfTFMOdlhD2y+Hl6memUSL4s98bpUTo8EpVEr0lmwlU7JSu/p2QpSvA== dependencies: "@types/json-schema" "^7.0.9" "@types/semver" "^7.3.12" - "@typescript-eslint/scope-manager" "5.43.0" - "@typescript-eslint/types" "5.43.0" - "@typescript-eslint/typescript-estree" "5.43.0" + "@typescript-eslint/scope-manager" "5.45.0" + "@typescript-eslint/types" "5.45.0" + "@typescript-eslint/typescript-estree" "5.45.0" eslint-scope "^5.1.1" eslint-utils "^3.0.0" semver "^7.3.7" -"@typescript-eslint/visitor-keys@5.43.0": - version "5.43.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.43.0.tgz#cbbdadfdfea385310a20a962afda728ea106befa" - integrity sha512-icl1jNH/d18OVHLfcwdL3bWUKsBeIiKYTGxMJCoGe7xFht+E4QgzOqoWYrU8XSLJWhVw8nTacbm03v23J/hFTg== +"@typescript-eslint/visitor-keys@5.45.0": + version "5.45.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.45.0.tgz#e0d160e9e7fdb7f8da697a5b78e7a14a22a70528" + integrity sha512-jc6Eccbn2RtQPr1s7th6jJWQHBHI6GBVQkCHoJFQ5UreaKm59Vxw+ynQUPPY2u2Amquc+7tmEoC2G52ApsGNNg== dependencies: - "@typescript-eslint/types" "5.43.0" + "@typescript-eslint/types" "5.45.0" eslint-visitor-keys "^3.3.0" JSONStream@^1.0.3: @@ -1892,7 +1972,7 @@ agent-base@6: dependencies: debug "4" -ajv@^6.10.0, ajv@^6.12.4: +ajv@^6.10.0, ajv@^6.12.4, ajv@~6.12.6: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -2500,9 +2580,9 @@ camelcase@^6.2.0: integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== caniuse-lite@^1.0.30001400: - version "1.0.30001431" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001431.tgz#e7c59bd1bc518fae03a4656be442ce6c4887a795" - integrity sha512-zBUoFU0ZcxpvSt9IU66dXVT/3ctO1cy4y9cscs1szkPlcWb6pasYM144GqrUygUbT+k7cmUCW61cvskjcv0enQ== + version "1.0.30001435" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001435.tgz#502c93dbd2f493bee73a408fe98e98fb1dad10b2" + integrity sha512-kdCkUTjR+v4YAJelyiDTqiu82BDr4W4CP5sgTA0ZBmqn30XfS2ZghPLMowik9TPhS+psWJiUNxsqLyurDbmutA== center-align@^0.1.1: version "0.1.3" @@ -2556,11 +2636,16 @@ chokidar@^3.4.0: optionalDependencies: fsevents "~2.3.2" -ci-info@^3.2.0, ci-info@^3.6.1: +ci-info@^3.2.0: version "3.6.1" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.6.1.tgz#7594f1c95cb7fdfddee7af95a13af7dbc67afdcf" integrity sha512-up5ggbaDqOqJ4UqLKZ2naVkyqSJQgJi5lwD6b6mM748ysrghDBX0bx/qJTUHzw7zu6Mq4gycviSF5hJnwceD8w== +ci-info@^3.6.1: + version "3.7.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.7.0.tgz#6d01b3696c59915b6ce057e4aa4adfc2fa25f5ef" + integrity sha512-2CpRNYmImPx+RXKLq6jko/L07phmS9I02TyqkcNU20GCF/GgaWvc58hPtjxDX8lPpkdwc9sNh72V9k00S7ezog== + cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" @@ -2696,6 +2781,11 @@ commander@^4.0.1: resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== +comment-parser@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.3.1.tgz#3d7ea3adaf9345594aedee6563f422348f165c1b" + integrity sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA== + commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" @@ -3230,6 +3320,11 @@ eslint-config-google@^0.14.0: resolved "https://registry.yarnpkg.com/eslint-config-google/-/eslint-config-google-0.14.0.tgz#4f5f8759ba6e11b424294a219dbfa18c508bcc1a" integrity sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw== +eslint-config-prettier@^8.5.0: + version "8.5.0" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz#5a81680ec934beca02c7b1a61cf8ca34b66feab1" + integrity sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q== + eslint-import-resolver-node@^0.3.6: version "0.3.6" resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz#4048b958395da89668252001dbd9eca6b83bacbd" @@ -3277,29 +3372,50 @@ eslint-plugin-import@^2.26.0: resolve "^1.22.0" tsconfig-paths "^3.14.1" -eslint-plugin-matrix-org@^0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-matrix-org/-/eslint-plugin-matrix-org-0.8.0.tgz#daa1396900a8cb1c1d88f1a370e45fc32482cd9e" - integrity sha512-/Poz/F8lXYDsmQa29iPSt+kO+Jn7ArvRdq10g0CCk8wbRS0sb2zb6fvd9xL1BgR5UDQL771V0l8X32etvY5yKA== +eslint-plugin-jsdoc@^39.6.4: + version "39.6.4" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.6.4.tgz#b940aebd3eea26884a0d341785d2dc3aba6a38a7" + integrity sha512-fskvdLCfwmPjHb6e+xNGDtGgbF8X7cDwMtVLAP2WwSf9Htrx68OAx31BESBM1FAwsN2HTQyYQq7m4aW4Q4Nlag== + dependencies: + "@es-joy/jsdoccomment" "~0.36.1" + comment-parser "1.3.1" + debug "^4.3.4" + escape-string-regexp "^4.0.0" + esquery "^1.4.0" + semver "^7.3.8" + spdx-expression-parse "^3.0.1" + +eslint-plugin-matrix-org@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-matrix-org/-/eslint-plugin-matrix-org-0.9.0.tgz#b2a5186052ddbfa7dc9878779bafa5d68681c7b4" + integrity sha512-+j6JuMnFH421Z2vOxc+0YMt5Su5vD76RSatviy3zHBaZpgd+sOeAWoCLBHD5E7mMz5oKae3Y3wewCt9LRzq2Nw== + +eslint-plugin-tsdoc@^0.2.17: + version "0.2.17" + resolved "https://registry.yarnpkg.com/eslint-plugin-tsdoc/-/eslint-plugin-tsdoc-0.2.17.tgz#27789495bbd8778abbf92db1707fec2ed3dfe281" + integrity sha512-xRmVi7Zx44lOBuYqG8vzTXuL6IdGOeF9nHX17bjJ8+VE6fsxpdGem0/SBTmAwgYMKYB1WBkqRJVQ+n8GK041pA== + dependencies: + "@microsoft/tsdoc" "0.14.2" + "@microsoft/tsdoc-config" "0.16.2" eslint-plugin-unicorn@^45.0.0: - version "45.0.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-45.0.0.tgz#a6650ff3000dc1a87cc2f6ac3a11edcde61712e2" - integrity sha512-iP8cMRxXKHonKioOhnCoCcqVhoqhAp6rB+nsoLjXFDxTHz3btWMAp8xwzjHA0B1K6YV/U/Yvqn1bUXZt8sJPuQ== + version "45.0.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-45.0.1.tgz#2307f4620502fd955c819733ce1276bed705b736" + integrity sha512-tLnIw5oDJJc3ILYtlKtqOxPP64FZLTkZkgeuoN6e7x6zw+rhBjOxyvq2c7577LGxXuIhBYrwisZuKNqOOHp3BA== dependencies: "@babel/helper-validator-identifier" "^7.19.1" + "@eslint-community/eslint-utils" "^4.1.0" ci-info "^3.6.1" clean-regexp "^1.0.0" - eslint-utils "^3.0.0" esquery "^1.4.0" indent-string "^4.0.0" is-builtin-module "^3.2.0" - jsesc "3.0.2" + jsesc "^3.0.2" lodash "^4.17.21" pluralize "^8.0.0" read-pkg-up "^7.0.1" regexp-tree "^0.1.24" - regjsparser "0.9.1" + regjsparser "^0.9.1" safe-regex "^2.1.1" semver "^7.3.8" strip-indent "^3.0.0" @@ -3520,9 +3636,9 @@ ext@^1.1.2: type "^2.7.2" fake-indexeddb@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-4.0.0.tgz#1dfb2023a3be175e35a6d84975218b432041934d" - integrity sha512-oCfWSJ/qvQn1XPZ8SHX6kY3zr1t+bN7faZ/lltGY0SBGhFOPXnWf0+pbO/MOAgfMx6khC2gK3S/bvAgQpuQHDQ== + version "4.0.1" + resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-4.0.1.tgz#09bb2468e21d0832b2177e894765fb109edac8fb" + integrity sha512-hFRyPmvEZILYgdcLBxVdHLik4Tj3gDTu/g7s9ZDOiU3sTNiGx+vEu1ri/AMsFJUZ/1sdRbAVrEcKndh3sViBcA== dependencies: realistic-structured-clone "^3.0.0" @@ -3963,9 +4079,9 @@ ieee754@^1.1.4: integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== ignore@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a" - integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ== + version "5.2.1" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.1.tgz#c2b1f76cb999ede1502f3a226a9310fdfe88d46c" + integrity sha512-d2qQLzTJ9WxQftPAuEQpSPmKqzxePjzVbpAVv62AQ64NTL+wR4JkrVqR/LqFsFEUsHDAiId52mJteHDFuDkElA== import-fresh@^3.0.0, import-fresh@^3.2.1: version "3.3.0" @@ -4095,7 +4211,7 @@ is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^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.10.0, is-core-module@^2.8.1, is-core-module@^2.9.0: +is-core-module@^2.1.0, is-core-module@^2.10.0, is-core-module@^2.8.1, is-core-module@^2.9.0: version "2.11.0" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144" integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw== @@ -4748,6 +4864,11 @@ jest@^29.0.0: import-local "^3.0.2" jest-cli "^29.3.1" +jju@~1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/jju/-/jju-1.4.0.tgz#a3abe2718af241a2b2904f84a625970f389ae32a" + integrity sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA== + js-sdsl@^4.1.4: version "4.1.5" resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.1.5.tgz#1ff1645e6b4d1b028cd3f862db88c9d887f26e2a" @@ -4778,6 +4899,11 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +jsdoc-type-pratt-parser@~3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-3.1.0.tgz#a4a56bdc6e82e5865ffd9febc5b1a227ff28e67e" + integrity sha512-MgtD0ZiCDk9B+eI73BextfRrVQl0oyzRG8B2BjORts6jbunj4ScKPcyXGTbB6eXL4y9TzxCm6hyeLq/2ASzNdw== + jsdom@^20.0.0: version "20.0.2" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-20.0.2.tgz#65ccbed81d5e877c433f353c58bb91ff374127db" @@ -4810,16 +4936,16 @@ jsdom@^20.0.0: ws "^8.9.0" xml-name-validator "^4.0.0" -jsesc@3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.0.2.tgz#bb8b09a6597ba426425f2e4a07245c3d00b9343e" - integrity sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g== - jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== +jsesc@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.0.2.tgz#bb8b09a6597ba426425f2e4a07245c3d00b9343e" + integrity sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g== + jsesc@~0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" @@ -5507,7 +5633,7 @@ path-key@^3.0.0, path-key@^3.1.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== -path-parse@^1.0.7: +path-parse@^1.0.6, path-parse@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== @@ -5582,6 +5708,11 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== +prettier@2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.0.tgz#c7df58393c9ba77d6fba3921ae01faf994fb9dc9" + integrity sha512-9Lmg8hTFZKG0Asr/kW9Bp8tJjRVluO8EJQVfY2T7FMw9T5jy4I/Uvx0Rca/XWf50QQ1/SS48+6IJWnrb+2yemA== + pretty-format@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-28.1.3.tgz#c9fba8cedf99ce50963a11b27d982a9ae90970d5" @@ -5957,10 +6088,10 @@ regenerator-runtime@^0.11.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== -regenerator-runtime@^0.13.10: - version "0.13.10" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee" - integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw== +regenerator-runtime@^0.13.11: + version "0.13.11" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" + integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== regenerator-transform@^0.15.0: version "0.15.0" @@ -6005,7 +6136,7 @@ regjsgen@^0.7.1: resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.7.1.tgz#ee5ef30e18d3f09b7c369b76e7c2373ed25546f6" integrity sha512-RAt+8H2ZEzHeYWxZ3H2z6tF18zyyOnlcdaafLrm21Bguj7uZy6ULibiAFdXEtKQY4Sy7wDTwDiOazasMLc4KPA== -regjsparser@0.9.1, regjsparser@^0.9.1: +regjsparser@^0.9.1: version "0.9.1" resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.9.1.tgz#272d05aa10c7c1f67095b1ff0addae8442fc5709" integrity sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ== @@ -6058,6 +6189,14 @@ resolve@^1.1.4, resolve@^1.1.6, resolve@^1.10.0, resolve@^1.14.2, resolve@^1.17. path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +resolve@~1.19.0: + version "1.19.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c" + integrity sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg== + dependencies: + is-core-module "^2.1.0" + path-parse "^1.0.6" + retry@^0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" @@ -6283,7 +6422,7 @@ spdx-exceptions@^2.1.0: resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== -spdx-expression-parse@^3.0.0: +spdx-expression-parse@^3.0.0, spdx-expression-parse@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== @@ -6496,9 +6635,9 @@ tapable@^2.2.0: integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== terser@^5.5.1: - version "5.15.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.15.1.tgz#8561af6e0fd6d839669c73b92bdd5777d870ed6c" - integrity sha512-K1faMUvpm/FBxjBXud0LWVAGxmvoPbZbfTCYbSgaaYQaIXI3/TdI7a7ZGA73Zrou6Q8Zmz3oeUTsp/dj+ag2Xw== + version "5.16.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.16.0.tgz#29362c6f5506e71545c73b069ccd199bb28f7f54" + integrity sha512-KjTV81QKStSfwbNiwlBXfcgMcOloyuRdb62/iLFPGBcVNF4EXjhdYBhYHmbJpiBrVxZhDvltE11j+LBQUxEEJg== dependencies: "@jridgewell/source-map" "^0.3.2" acorn "^8.5.0" @@ -6911,6 +7050,11 @@ util@~0.12.0: is-typed-array "^1.1.3" which-typed-array "^1.1.2" +uuid@7: + version "7.0.3" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" + integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== + uuid@8.3.2, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"