diff --git a/.eslintrc.js b/.eslintrc.js index 5ed62980e..91993d504 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,14 +1,22 @@ module.exports = { plugins: [ "matrix-org", + "import", ], extends: [ "plugin:matrix-org/babel", + "plugin:import/typescript", ], env: { browser: true, node: true, }, + settings: { + "import/resolver": { + typescript: true, + node: true, + }, + }, // NOTE: These rules are frozen and new rules should not be added here. // New changes belong in https://github.com/matrix-org/eslint-plugin-matrix-org/ rules: { @@ -35,7 +43,19 @@ module.exports = { "no-console": "error", // restrict EventEmitters to force callers to use TypedEventEmitter - "no-restricted-imports": ["error", "events"], + "no-restricted-imports": ["error", { + name: "events", + message: "Please use TypedEventEmitter instead" + }], + + "import/no-restricted-paths": ["error", { + "zones": [{ + "target": "./src/", + "from": "./src/index.ts", + "message": "The package index is dynamic between src and lib depending on " + + "whether release or development, target the specific module or matrix.ts instead", + }], + }], }, overrides: [{ files: [ diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index c70408fb0..84d88ea5e 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -36,5 +36,6 @@ jobs: package=$(cat package.json | jq -er .name) npm dist-tag add "$package@$release" latest env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # 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 10394ff79..44ccbf495 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -23,6 +23,16 @@ jobs: - name: Typecheck run: "yarn run lint:types" + - name: Switch js-sdk to release mode + run: | + scripts/switch_package_to_release.js + yarn install + yarn run build:compile + yarn run build:types + + - name: Typecheck (release mode) + run: "yarn run lint:types" + js_lint: name: "ESLint" runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eed6a209..ce41de1aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +Changes in [20.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.1.0) (2022-10-11) +============================================================================================================ + +## ✨ Features + * Add local notification settings capability ([\#2700](https://github.com/matrix-org/matrix-js-sdk/pull/2700)). + * Implementation of MSC3882 login token request ([\#2687](https://github.com/matrix-org/matrix-js-sdk/pull/2687)). Contributed by @hughns. + * Typings for MSC2965 OIDC provider discovery ([\#2424](https://github.com/matrix-org/matrix-js-sdk/pull/2424)). Contributed by @hughns. + * Support to remotely toggle push notifications ([\#2686](https://github.com/matrix-org/matrix-js-sdk/pull/2686)). + * Read receipts for threads ([\#2635](https://github.com/matrix-org/matrix-js-sdk/pull/2635)). + +## 🐛 Bug Fixes + * Use the correct sender key when checking shared secret ([\#2730](https://github.com/matrix-org/matrix-js-sdk/pull/2730)). Fixes vector-im/element-web#23374. + * Unexpected ignored self key request when it's not shared history ([\#2724](https://github.com/matrix-org/matrix-js-sdk/pull/2724)). Contributed by @mcalinghee. + * Fix IDB initial migration handling causing spurious lazy loading upgrade loops ([\#2718](https://github.com/matrix-org/matrix-js-sdk/pull/2718)). Fixes vector-im/element-web#23377. + * Fix backpagination at end logic being spec non-conforming ([\#2680](https://github.com/matrix-org/matrix-js-sdk/pull/2680)). Fixes vector-im/element-web#22784. + +Changes in [20.0.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.0.2) (2022-09-30) +================================================================================================== + +## 🐛 Bug Fixes + * Fix issue in sync when crypto is not supported by client ([\#2715](https://github.com/matrix-org/matrix-js-sdk/pull/2715)). Contributed by @stas-demydiuk. + Changes in [20.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.0.1) (2022-09-28) ================================================================================================== diff --git a/README.md b/README.md index 257337d2c..cb3b9f811 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,8 @@ In Node.js ---------- Ensure you have the latest LTS version of Node.js installed. - -This SDK targets Node 12 for compatibility, which translates to ES6. If you're using -a bundler like webpack you'll likely have to transpile dependencies, including this -SDK, to match your target browsers. +This library relies on `fetch` which is available in Node from v18.0.0 - it should work fine also with polyfills. +If you wish to use a ponyfill or adapter of some sort then pass it as `fetchFn` to the MatrixClient constructor options. Using `yarn` instead of `npm` is recommended. Please see the Yarn [install guide](https://classic.yarnpkg.com/en/docs/install) if you do not have it already. diff --git a/package.json b/package.json index 13f189ab7..05b103301 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "matrix-js-sdk", - "version": "20.0.1", + "version": "20.1.0", "description": "Matrix Client-Server SDK for Javascript", "engines": { - "node": ">=12.9.0" + "node": ">=16.0.0" }, "scripts": { "prepublishOnly": "yarn build", @@ -56,7 +56,6 @@ "@babel/runtime": "^7.12.5", "@types/sdp-transform": "^2.4.5", "another-json": "^0.2.0", - "browser-request": "^0.3.3", "bs58": "^5.0.0", "content-type": "^1.0.4", "loglevel": "^1.7.1", @@ -64,7 +63,6 @@ "matrix-widget-api": "^1.0.0", "p-retry": "4", "qs": "^6.9.6", - "request": "^2.88.2", "sdp-transform": "^2.14.1", "unhomoglyph": "^1.0.6" }, @@ -81,12 +79,12 @@ "@babel/preset-env": "^7.12.11", "@babel/preset-typescript": "^7.12.7", "@babel/register": "^7.12.10", - "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz", + "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.13.tgz", "@types/bs58": "^4.0.1", "@types/content-type": "^1.1.5", + "@types/domexception": "^4.0.0", "@types/jest": "^29.0.0", "@types/node": "16", - "@types/request": "^2.48.5", "@typescript-eslint/eslint-plugin": "^5.6.0", "@typescript-eslint/parser": "^5.6.0", "allchange": "^1.0.6", @@ -95,18 +93,21 @@ "better-docs": "^2.4.0-beta.9", "browserify": "^17.0.0", "docdash": "^1.2.0", - "eslint": "8.23.1", + "domexception": "^4.0.0", + "eslint": "8.24.0", "eslint-config-google": "^0.14.0", - "eslint-plugin-import": "^2.25.4", + "eslint-import-resolver-typescript": "^3.5.1", + "eslint-plugin-import": "^2.26.0", "eslint-plugin-matrix-org": "^0.6.0", "exorcist": "^2.0.0", "fake-indexeddb": "^4.0.0", "jest": "^29.0.0", "jest-environment-jsdom": "^28.1.3", "jest-localstorage-mock": "^2.4.6", + "jest-mock": "^27.5.1", "jest-sonar-reporter": "^2.0.0", "jsdoc": "^3.6.6", - "matrix-mock-request": "^2.1.2", + "matrix-mock-request": "^2.5.0", "rimraf": "^3.0.2", "terser": "^5.5.1", "tsify": "^5.0.2", @@ -117,6 +118,9 @@ "testMatch": [ "/spec/**/*.spec.{js,ts}" ], + "setupFilesAfterEnv": [ + "/spec/setupTests.ts" + ], "collectCoverageFrom": [ "/src/**/*.{js,ts}" ], diff --git a/scripts/switch_package_to_release.js b/scripts/switch_package_to_release.js new file mode 100755 index 000000000..830c92dc4 --- /dev/null +++ b/scripts/switch_package_to_release.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +const fsProm = require('fs/promises'); + +const PKGJSON = 'package.json'; + +async function main() { + const pkgJson = JSON.parse(await fsProm.readFile(PKGJSON, 'utf8')); + for (const field of ['main', 'typings']) { + if (pkgJson["matrix_lib_"+field] !== undefined) { + pkgJson[field] = pkgJson["matrix_lib_"+field]; + } + } + await fsProm.writeFile(PKGJSON, JSON.stringify(pkgJson, null, 2)); +} + +main(); diff --git a/spec/TestClient.ts b/spec/TestClient.ts index 0a6c4e0ee..6056884dd 100644 --- a/spec/TestClient.ts +++ b/spec/TestClient.ts @@ -30,7 +30,6 @@ import { MockStorageApi } from "./MockStorageApi"; import { encodeUri } from "../src/utils"; import { IDeviceKeys, IOneTimeKey } from "../src/crypto/dehydration"; import { IKeyBackupSession } from "../src/crypto/keybackup"; -import { IHttpOpts } from "../src/http-api"; import { IKeysUploadResponse, IUploadKeysRequest } from '../src/client'; /** @@ -56,11 +55,11 @@ export class TestClient { this.httpBackend = new MockHttpBackend(); const fullOptions: ICreateClientOpts = { - baseUrl: "http://" + userId + ".test.server", + baseUrl: "http://" + userId?.slice(1).replace(":", ".") + ".test.server", userId: userId, accessToken: accessToken, deviceId: deviceId, - request: this.httpBackend.requestFn as IHttpOpts["request"], + fetchFn: this.httpBackend.fetchFn as typeof global.fetch, ...options, }; if (!fullOptions.cryptoStore) { diff --git a/spec/browserify/setupTests.js b/spec/browserify/setupTests.ts similarity index 95% rename from spec/browserify/setupTests.js rename to spec/browserify/setupTests.ts index 16120f78a..833d8591c 100644 --- a/spec/browserify/setupTests.js +++ b/spec/browserify/setupTests.ts @@ -15,9 +15,11 @@ limitations under the License. */ // stub for browser-matrix browserify tests +// @ts-ignore global.XMLHttpRequest = jest.fn(); afterAll(() => { // clean up XMLHttpRequest mock + // @ts-ignore global.XMLHttpRequest = undefined; }); diff --git a/spec/browserify/sync-browserify.spec.ts b/spec/browserify/sync-browserify.spec.ts index 8a087c807..648a72dc4 100644 --- a/spec/browserify/sync-browserify.spec.ts +++ b/spec/browserify/sync-browserify.spec.ts @@ -14,46 +14,66 @@ See the License for the specific language governing permissions and limitations under the License. */ -// load XmlHttpRequest mock +import HttpBackend from "matrix-mock-request"; + import "./setupTests"; import "../../dist/browser-matrix"; // uses browser-matrix instead of the src -import * as utils from "../test-utils/test-utils"; -import { TestClient } from "../TestClient"; +import type { MatrixClient, ClientEvent } from "../../src"; const USER_ID = "@user:test.server"; const DEVICE_ID = "device_id"; const ACCESS_TOKEN = "access_token"; const ROOM_ID = "!room_id:server.test"; +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace NodeJS { + interface Global { + matrixcs: { + MatrixClient: typeof MatrixClient; + ClientEvent: typeof ClientEvent; + }; + } + } +} + describe("Browserify Test", function() { - let client; - let httpBackend; + let client: MatrixClient; + let httpBackend: HttpBackend; beforeEach(() => { - const testClient = new TestClient(USER_ID, DEVICE_ID, ACCESS_TOKEN); - - client = testClient.client; - httpBackend = testClient.httpBackend; + httpBackend = new HttpBackend(); + client = new global.matrixcs.MatrixClient({ + baseUrl: "http://test.server", + userId: USER_ID, + accessToken: ACCESS_TOKEN, + deviceId: DEVICE_ID, + fetchFn: httpBackend.fetchFn as typeof global.fetch, + }); httpBackend.when("GET", "/versions").respond(200, {}); httpBackend.when("GET", "/pushrules").respond(200, {}); httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); - - client.startClient(); }); afterEach(async () => { client.stopClient(); - httpBackend.stop(); + client.http.abort(); + httpBackend.verifyNoOutstandingRequests(); + httpBackend.verifyNoOutstandingExpectation(); + await httpBackend.stop(); }); - it("Sync", function() { - const event = utils.mkMembership({ - room: ROOM_ID, - mship: "join", - user: "@other_user:server.test", - name: "Displayname", - }); + it("Sync", async () => { + const event = { + type: "m.room.member", + room_id: ROOM_ID, + content: { + membership: "join", + name: "Displayname", + }, + event_id: "$foobar", + }; const syncData = { next_batch: "batch1", @@ -71,11 +91,16 @@ describe("Browserify Test", function() { }; httpBackend.when("GET", "/sync").respond(200, syncData); - return Promise.race([ - httpBackend.flushAllExpected(), - new Promise((_, reject) => { - client.once("sync.unexpectedError", reject); - }), - ]); + httpBackend.when("GET", "/sync").respond(200, syncData); + + const syncPromise = new Promise(r => client.once(global.matrixcs.ClientEvent.Sync, r)); + const unexpectedErrorFn = jest.fn(); + client.once(global.matrixcs.ClientEvent.SyncUnexpectedError, unexpectedErrorFn); + + client.startClient(); + + await httpBackend.flushAllExpected(); + await syncPromise; + expect(unexpectedErrorFn).not.toHaveBeenCalled(); }, 20000); // additional timeout as this test can take quite a while }); diff --git a/spec/integ/devicelist-integ.spec.js b/spec/integ/devicelist-integ.spec.ts similarity index 95% rename from spec/integ/devicelist-integ.spec.js rename to spec/integ/devicelist-integ.spec.ts index 8be2ca59a..acd8f9c80 100644 --- a/spec/integ/devicelist-integ.spec.js +++ b/spec/integ/devicelist-integ.spec.ts @@ -122,7 +122,7 @@ describe("DeviceList management:", function() { aliceTestClient.httpBackend.when( 'PUT', '/send/', ).respond(200, { - event_id: '$event_id', + event_id: '$event_id', }); return Promise.all([ @@ -290,8 +290,9 @@ describe("DeviceList management:", function() { aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; + // Alice should be tracking bob's device list expect(bobStat).toBeGreaterThan( - 0, "Alice should be tracking bob's device list", + 0, ); }); }); @@ -326,8 +327,9 @@ describe("DeviceList management:", function() { aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; + // Alice should have marked bob's device list as untracked expect(bobStat).toEqual( - 0, "Alice should have marked bob's device list as untracked", + 0, ); }); }); @@ -362,8 +364,9 @@ describe("DeviceList management:", function() { aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; + // Alice should have marked bob's device list as untracked expect(bobStat).toEqual( - 0, "Alice should have marked bob's device list as untracked", + 0, ); }); }); @@ -378,13 +381,15 @@ describe("DeviceList management:", function() { anotherTestClient.httpBackend.when('GET', '/sync').respond( 200, getSyncResponse([])); await anotherTestClient.flushSync(); - await anotherTestClient.client.crypto.deviceList.saveIfDirty(); + await anotherTestClient.client?.crypto?.deviceList?.saveIfDirty(); + // @ts-ignore accessing private property anotherTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { - const bobStat = data.trackingStatus['@bob:xyz']; + const bobStat = data!.trackingStatus['@bob:xyz']; + // Alice should have marked bob's device list as untracked expect(bobStat).toEqual( - 0, "Alice should have marked bob's device list as untracked", + 0, ); }); } finally { diff --git a/spec/integ/matrix-client-crypto.spec.ts b/spec/integ/matrix-client-crypto.spec.ts index d0f46b9e5..f86394979 100644 --- a/spec/integ/matrix-client-crypto.spec.ts +++ b/spec/integ/matrix-client-crypto.spec.ts @@ -31,8 +31,9 @@ import '../olm-loader'; import { logger } from '../../src/logger'; import * as testUtils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; -import { CRYPTO_ENABLED } from "../../src/client"; +import { CRYPTO_ENABLED, IUploadKeysRequest } from "../../src/client"; import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../src/matrix"; +import { DeviceInfo } from '../../src/crypto/deviceinfo'; let aliTestClient: TestClient; const roomId = "!room:localhost"; @@ -71,12 +72,12 @@ function expectQueryKeys(querier: TestClient, uploader: TestClient): Promise { const keys = await bobTestClient.awaitOneTimeKeyUpload(); aliTestClient.httpBackend.when( "POST", "/keys/claim", - ).respond(200, function(_path, content) { - const claimType = content.one_time_keys[bobUserId][bobDeviceId]; + ).respond(200, function(_path, content: IUploadKeysRequest) { + const claimType = content.one_time_keys![bobUserId][bobDeviceId]; expect(claimType).toEqual("signed_curve25519"); - let keyId = null; + let keyId = ''; for (keyId in keys) { if (bobTestClient.oneTimeKeys.hasOwnProperty(keyId)) { if (keyId.indexOf(claimType + ":") === 0) { @@ -132,13 +133,13 @@ async function aliDownloadsKeys(): Promise { // check that the localStorage is updated as we expect (not sure this is // an integration test, but meh) await Promise.all([p1(), p2()]); - await aliTestClient.client.crypto.deviceList.saveIfDirty(); + await aliTestClient.client.crypto!.deviceList.saveIfDirty(); // @ts-ignore - protected aliTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { - const devices = data.devices[bobUserId]; + const devices = data!.devices[bobUserId]!; expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys.keys); expect(devices[bobDeviceId].verified). - toBe(0); // DeviceVerification.UNVERIFIED + toBe(DeviceInfo.DeviceVerification.UNVERIFIED); }); } @@ -237,7 +238,7 @@ function sendMessage(client: MatrixClient): Promise { async function expectSendMessageRequest(httpBackend: TestClient["httpBackend"]): Promise { const path = "/send/m.room.encrypted/"; - const prom = new Promise((resolve) => { + const prom = new Promise((resolve) => { httpBackend.when("PUT", path).respond(200, function(_path, content) { resolve(content); return { @@ -252,14 +253,14 @@ async function expectSendMessageRequest(httpBackend: TestClient["httpBackend"]): } function aliRecvMessage(): Promise { - const message = bobMessages.shift(); + const message = bobMessages.shift()!; return recvMessage( aliTestClient.httpBackend, aliTestClient.client, bobUserId, message, ); } function bobRecvMessage(): Promise { - const message = aliMessages.shift(); + const message = aliMessages.shift()!; return recvMessage( bobTestClient.httpBackend, bobTestClient.client, aliUserId, message, ); @@ -494,7 +495,7 @@ describe("MatrixClient crypto", () => { aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} }); await aliTestClient.start(); await bobTestClient.start(); - bobTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); await firstSync(aliTestClient); await aliEnablesEncryption(); await aliSendsFirstMessage(); @@ -505,11 +506,11 @@ describe("MatrixClient crypto", () => { aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} }); await aliTestClient.start(); await bobTestClient.start(); - bobTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); await firstSync(aliTestClient); await aliEnablesEncryption(); await aliSendsFirstMessage(); - const message = aliMessages.shift(); + const message = aliMessages.shift()!; const syncData = { next_batch: "x", rooms: { @@ -569,7 +570,7 @@ describe("MatrixClient crypto", () => { aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} }); await aliTestClient.start(); await bobTestClient.start(); - bobTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); await firstSync(aliTestClient); await aliEnablesEncryption(); await aliSendsFirstMessage(); @@ -664,11 +665,10 @@ describe("MatrixClient crypto", () => { ]); logger.log(aliTestClient + ': started'); httpBackend.when("POST", "/keys/upload") - .respond(200, (_path, content) => { + .respond(200, (_path, content: IUploadKeysRequest) => { expect(content.one_time_keys).toBeTruthy(); expect(content.one_time_keys).not.toEqual({}); - expect(Object.keys(content.one_time_keys).length).toBeGreaterThanOrEqual(1); - logger.log('received %i one-time keys', Object.keys(content.one_time_keys).length); + expect(Object.keys(content.one_time_keys!).length).toBeGreaterThanOrEqual(1); // cancel futher calls by telling the client // we have more than we need return { diff --git a/spec/integ/matrix-client-event-emitter.spec.js b/spec/integ/matrix-client-event-emitter.spec.ts similarity index 51% rename from spec/integ/matrix-client-event-emitter.spec.js rename to spec/integ/matrix-client-event-emitter.spec.ts index bb3c873b3..1ad244b54 100644 --- a/spec/integ/matrix-client-event-emitter.spec.js +++ b/spec/integ/matrix-client-event-emitter.spec.ts @@ -1,25 +1,59 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import HttpBackend from "matrix-mock-request"; + +import { + ClientEvent, + HttpApiEvent, + IEvent, + MatrixClient, + RoomEvent, + RoomMemberEvent, + RoomStateEvent, + UserEvent, +} from "../../src"; import * as utils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; describe("MatrixClient events", function() { - let client; - let httpBackend; const selfUserId = "@alice:localhost"; const selfAccessToken = "aseukfgwef"; + let client: MatrixClient | undefined; + let httpBackend: HttpBackend | undefined; + + const setupTests = (): [MatrixClient, HttpBackend] => { + const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken); + const client = testClient.client; + const httpBackend = testClient.httpBackend; + httpBackend!.when("GET", "/versions").respond(200, {}); + httpBackend!.when("GET", "/pushrules").respond(200, {}); + httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + + return [client!, httpBackend]; + }; beforeEach(function() { - const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken); - client = testClient.client; - httpBackend = testClient.httpBackend; - httpBackend.when("GET", "/versions").respond(200, {}); - httpBackend.when("GET", "/pushrules").respond(200, {}); - httpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + [client!, httpBackend] = setupTests(); }); afterEach(function() { - httpBackend.verifyNoOutstandingExpectation(); - client.stopClient(); - return httpBackend.stop(); + httpBackend?.verifyNoOutstandingExpectation(); + client?.stopClient(); + return httpBackend?.stop(); }); describe("emissions", function() { @@ -92,53 +126,49 @@ describe("MatrixClient events", function() { }; it("should emit events from both the first and subsequent /sync calls", - function() { - httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); - httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + function() { + httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); - let expectedEvents = []; - expectedEvents = expectedEvents.concat( - SYNC_DATA.presence.events, - SYNC_DATA.rooms.join["!erufh:bar"].timeline.events, - SYNC_DATA.rooms.join["!erufh:bar"].state.events, - NEXT_SYNC_DATA.rooms.join["!erufh:bar"].timeline.events, - NEXT_SYNC_DATA.rooms.join["!erufh:bar"].ephemeral.events, - ); + let expectedEvents: Partial[] = []; + expectedEvents = expectedEvents.concat( + SYNC_DATA.presence.events, + SYNC_DATA.rooms.join["!erufh:bar"].timeline.events, + SYNC_DATA.rooms.join["!erufh:bar"].state.events, + NEXT_SYNC_DATA.rooms.join["!erufh:bar"].timeline.events, + NEXT_SYNC_DATA.rooms.join["!erufh:bar"].ephemeral.events, + ); - client.on("event", function(event) { - let found = false; - for (let i = 0; i < expectedEvents.length; i++) { - if (expectedEvents[i].event_id === event.getId()) { - expectedEvents.splice(i, 1); - found = true; - break; + client!.on(ClientEvent.Event, function(event) { + let found = false; + for (let i = 0; i < expectedEvents.length; i++) { + if (expectedEvents[i].event_id === event.getId()) { + expectedEvents.splice(i, 1); + found = true; + break; + } } - } - expect(found).toBe( - true, "Unexpected 'event' emitted: " + event.getType(), - ); - }); + expect(found).toBe(true); + }); - client.startClient(); + client!.startClient(); - return Promise.all([ + return Promise.all([ // wait for two SYNCING events - utils.syncPromise(client).then(() => { - return utils.syncPromise(client); - }), - httpBackend.flushAllExpected(), - ]).then(() => { - expect(expectedEvents.length).toEqual( - 0, "Failed to see all events from /sync calls", - ); + utils.syncPromise(client!).then(() => { + return utils.syncPromise(client!); + }), + httpBackend!.flushAllExpected(), + ]).then(() => { + expect(expectedEvents.length).toEqual(0); + }); }); - }); it("should emit User events", function(done) { - httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); - httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); let fired = false; - client.on("User.presence", function(event, user) { + client!.on(UserEvent.Presence, function(event, user) { fired = true; expect(user).toBeTruthy(); expect(event).toBeTruthy(); @@ -146,58 +176,52 @@ describe("MatrixClient events", function() { return; } - expect(event.event).toMatch(SYNC_DATA.presence.events[0]); + expect(event.event).toEqual(SYNC_DATA.presence.events[0]); expect(user.presence).toEqual( - SYNC_DATA.presence.events[0].content.presence, + SYNC_DATA.presence.events[0]?.content?.presence, ); }); - client.startClient(); + client!.startClient(); - httpBackend.flushAllExpected().then(function() { - expect(fired).toBe(true, "User.presence didn't fire."); + httpBackend!.flushAllExpected().then(function() { + expect(fired).toBe(true); done(); }); }); it("should emit Room events", function() { - httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); - httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); let roomInvokeCount = 0; let roomNameInvokeCount = 0; let timelineFireCount = 0; - client.on("Room", function(room) { + client!.on(ClientEvent.Room, function(room) { roomInvokeCount++; expect(room.roomId).toEqual("!erufh:bar"); }); - client.on("Room.timeline", function(event, room) { + client!.on(RoomEvent.Timeline, function(event, room) { timelineFireCount++; expect(room.roomId).toEqual("!erufh:bar"); }); - client.on("Room.name", function(room) { + client!.on(RoomEvent.Name, function(room) { roomNameInvokeCount++; }); - client.startClient(); + client!.startClient(); return Promise.all([ - httpBackend.flushAllExpected(), - utils.syncPromise(client, 2), + httpBackend!.flushAllExpected(), + utils.syncPromise(client!, 2), ]).then(function() { - expect(roomInvokeCount).toEqual( - 1, "Room fired wrong number of times.", - ); - expect(roomNameInvokeCount).toEqual( - 1, "Room.name fired wrong number of times.", - ); - expect(timelineFireCount).toEqual( - 3, "Room.timeline fired the wrong number of times", - ); + expect(roomInvokeCount).toEqual(1); + expect(roomNameInvokeCount).toEqual(1); + expect(timelineFireCount).toEqual(3); }); }); it("should emit RoomState events", function() { - httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); - httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); const roomStateEventTypes = [ "m.room.member", "m.room.create", @@ -205,126 +229,106 @@ describe("MatrixClient events", function() { let eventsInvokeCount = 0; let membersInvokeCount = 0; let newMemberInvokeCount = 0; - client.on("RoomState.events", function(event, state) { + client!.on(RoomStateEvent.Events, function(event, state) { eventsInvokeCount++; const index = roomStateEventTypes.indexOf(event.getType()); - expect(index).not.toEqual( - -1, "Unexpected room state event type: " + event.getType(), - ); + expect(index).not.toEqual(-1); if (index >= 0) { roomStateEventTypes.splice(index, 1); } }); - client.on("RoomState.members", function(event, state, member) { + client!.on(RoomStateEvent.Members, function(event, state, member) { membersInvokeCount++; expect(member.roomId).toEqual("!erufh:bar"); expect(member.userId).toEqual("@foo:bar"); expect(member.membership).toEqual("join"); }); - client.on("RoomState.newMember", function(event, state, member) { + client!.on(RoomStateEvent.NewMember, function(event, state, member) { newMemberInvokeCount++; expect(member.roomId).toEqual("!erufh:bar"); expect(member.userId).toEqual("@foo:bar"); expect(member.membership).toBeFalsy(); }); - client.startClient(); + client!.startClient(); return Promise.all([ - httpBackend.flushAllExpected(), - utils.syncPromise(client, 2), + httpBackend!.flushAllExpected(), + utils.syncPromise(client!, 2), ]).then(function() { - expect(membersInvokeCount).toEqual( - 1, "RoomState.members fired wrong number of times", - ); - expect(newMemberInvokeCount).toEqual( - 1, "RoomState.newMember fired wrong number of times", - ); - expect(eventsInvokeCount).toEqual( - 2, "RoomState.events fired wrong number of times", - ); + expect(membersInvokeCount).toEqual(1); + expect(newMemberInvokeCount).toEqual(1); + expect(eventsInvokeCount).toEqual(2); }); }); it("should emit RoomMember events", function() { - httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); - httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); let typingInvokeCount = 0; let powerLevelInvokeCount = 0; let nameInvokeCount = 0; let membershipInvokeCount = 0; - client.on("RoomMember.name", function(event, member) { + client!.on(RoomMemberEvent.Name, function(event, member) { nameInvokeCount++; }); - client.on("RoomMember.typing", function(event, member) { + client!.on(RoomMemberEvent.Typing, function(event, member) { typingInvokeCount++; expect(member.typing).toBe(true); }); - client.on("RoomMember.powerLevel", function(event, member) { + client!.on(RoomMemberEvent.PowerLevel, function(event, member) { powerLevelInvokeCount++; }); - client.on("RoomMember.membership", function(event, member) { + client!.on(RoomMemberEvent.Membership, function(event, member) { membershipInvokeCount++; expect(member.membership).toEqual("join"); }); - client.startClient(); + client!.startClient(); return Promise.all([ - httpBackend.flushAllExpected(), - utils.syncPromise(client, 2), + httpBackend!.flushAllExpected(), + utils.syncPromise(client!, 2), ]).then(function() { - expect(typingInvokeCount).toEqual( - 1, "RoomMember.typing fired wrong number of times", - ); - expect(powerLevelInvokeCount).toEqual( - 0, "RoomMember.powerLevel fired wrong number of times", - ); - expect(nameInvokeCount).toEqual( - 0, "RoomMember.name fired wrong number of times", - ); - expect(membershipInvokeCount).toEqual( - 1, "RoomMember.membership fired wrong number of times", - ); + expect(typingInvokeCount).toEqual(1); + expect(powerLevelInvokeCount).toEqual(0); + expect(nameInvokeCount).toEqual(0); + expect(membershipInvokeCount).toEqual(1); }); }); it("should emit Session.logged_out on M_UNKNOWN_TOKEN", function() { const error = { errcode: 'M_UNKNOWN_TOKEN' }; - httpBackend.when("GET", "/sync").respond(401, error); + httpBackend!.when("GET", "/sync").respond(401, error); let sessionLoggedOutCount = 0; - client.on("Session.logged_out", function(errObj) { + client!.on(HttpApiEvent.SessionLoggedOut, function(errObj) { sessionLoggedOutCount++; expect(errObj.data).toEqual(error); }); - client.startClient(); + client!.startClient(); - return httpBackend.flushAllExpected().then(function() { - expect(sessionLoggedOutCount).toEqual( - 1, "Session.logged_out fired wrong number of times", - ); + return httpBackend!.flushAllExpected().then(function() { + expect(sessionLoggedOutCount).toEqual(1); }); }); it("should emit Session.logged_out on M_UNKNOWN_TOKEN (soft logout)", function() { const error = { errcode: 'M_UNKNOWN_TOKEN', soft_logout: true }; - httpBackend.when("GET", "/sync").respond(401, error); + httpBackend!.when("GET", "/sync").respond(401, error); let sessionLoggedOutCount = 0; - client.on("Session.logged_out", function(errObj) { + client!.on(HttpApiEvent.SessionLoggedOut, function(errObj) { sessionLoggedOutCount++; expect(errObj.data).toEqual(error); }); - client.startClient(); + client!.startClient(); - return httpBackend.flushAllExpected().then(function() { - expect(sessionLoggedOutCount).toEqual( - 1, "Session.logged_out fired wrong number of times", - ); + return httpBackend!.flushAllExpected().then(function() { + expect(sessionLoggedOutCount).toEqual(1); }); }); }); diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index c656f6447..f2bfa5f6a 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -15,10 +15,21 @@ limitations under the License. */ import * as utils from "../test-utils/test-utils"; -import { ClientEvent, EventTimeline, Filter, IEvent, MatrixClient, MatrixEvent, Room } from "../../src/matrix"; +import { + ClientEvent, + Direction, + EventTimeline, + EventTimelineSet, + Filter, + IEvent, + MatrixClient, + MatrixEvent, + Room, +} from "../../src/matrix"; import { logger } from "../../src/logger"; +import { encodeUri } from "../../src/utils"; import { TestClient } from "../TestClient"; -import { Thread, THREAD_RELATION_TYPE } from "../../src/models/thread"; +import { FeatureSupport, Thread, THREAD_RELATION_TYPE } from "../../src/models/thread"; const userId = "@alice:localhost"; const userName = "Alice"; @@ -131,6 +142,7 @@ const THREAD_REPLY = utils.mkEvent({ event: false, }); +// @ts-ignore we know this is a defined path for THREAD ROOT THREAD_ROOT.unsigned["m.relations"]["io.element.thread"].latest_event = THREAD_REPLY; const SYNC_THREAD_ROOT = withoutRoomId(THREAD_ROOT); @@ -145,8 +157,11 @@ SYNC_THREAD_ROOT.unsigned = { }, }; +type HttpBackend = TestClient["httpBackend"]; +type ExpectedHttpRequest = ReturnType; + // start the client, and wait for it to initialise -function startClient(httpBackend: TestClient["httpBackend"], client: MatrixClient) { +function startClient(httpBackend: HttpBackend, client: MatrixClient) { httpBackend.when("GET", "/versions").respond(200, {}); httpBackend.when("GET", "/pushrules").respond(200, {}); httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); @@ -172,7 +187,7 @@ function startClient(httpBackend: TestClient["httpBackend"], client: MatrixClien } describe("getEventTimeline support", function() { - let httpBackend: TestClient["httpBackend"]; + let httpBackend: HttpBackend; let client: MatrixClient; beforeEach(function() { @@ -189,9 +204,19 @@ describe("getEventTimeline support", function() { }); it("timeline support must be enabled to work", function() { + const testClient = new TestClient( + userId, + "DEVICE", + accessToken, + undefined, + { timelineSupport: false }, + ); + client = testClient.client; + httpBackend = testClient.httpBackend; + return startClient(httpBackend, client).then(function() { - const room = client.getRoom(roomId); - const timelineSet = room.getTimelineSets()[0]; + const room = client.getRoom(roomId)!; + const timelineSet = room!.getTimelineSets()[0]; expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy(); }); }); @@ -208,18 +233,35 @@ describe("getEventTimeline support", function() { httpBackend = testClient.httpBackend; return startClient(httpBackend, client).then(() => { - const room = client.getRoom(roomId); - const timelineSet = room.getTimelineSets()[0]; + const room = client.getRoom(roomId)!; + const timelineSet = room!.getTimelineSets()[0]; expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeFalsy(); }); }); + it("only works with room timelines", function() { + const testClient = new TestClient( + userId, + "DEVICE", + accessToken, + undefined, + { timelineSupport: true }, + ); + client = testClient.client; + httpBackend = testClient.httpBackend; + + return startClient(httpBackend, client).then(function() { + const timelineSet = new EventTimelineSet(undefined); + expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy(); + }); + }); + it("scrollback should be able to scroll back to before a gappy /sync", function() { // need a client with timelineSupport disabled to make this work - let room: Room; + let room: Room | undefined; return startClient(httpBackend, client).then(function() { - room = client.getRoom(roomId); + room = client.getRoom(roomId)!; httpBackend.when("GET", "/sync").respond(200, { next_batch: "s_5_4", @@ -259,8 +301,8 @@ describe("getEventTimeline support", function() { utils.syncPromise(client, 2), ]); }).then(function() { - expect(room.timeline.length).toEqual(1); - expect(room.timeline[0].event).toEqual(EVENTS[1]); + expect(room!.timeline.length).toEqual(1); + expect(room!.timeline[0].event).toEqual(EVENTS[1]); httpBackend.when("GET", "/messages").respond(200, { chunk: [EVENTS[0]], @@ -268,19 +310,19 @@ describe("getEventTimeline support", function() { end: "pagin_end", }); httpBackend.flush("/messages", 1); - return client.scrollback(room); + return client.scrollback(room!); }).then(function() { - expect(room.timeline.length).toEqual(2); - expect(room.timeline[0].event).toEqual(EVENTS[0]); - expect(room.timeline[1].event).toEqual(EVENTS[1]); - expect(room.oldState.paginationToken).toEqual("pagin_end"); + expect(room!.timeline.length).toEqual(2); + expect(room!.timeline[0].event).toEqual(EVENTS[0]); + expect(room!.timeline[1].event).toEqual(EVENTS[1]); + expect(room!.oldState.paginationToken).toEqual("pagin_end"); }); }); }); describe("MatrixClient event timelines", function() { let client: MatrixClient; - let httpBackend: TestClient["httpBackend"]; + let httpBackend: HttpBackend; beforeEach(function() { const testClient = new TestClient( @@ -299,12 +341,12 @@ describe("MatrixClient event timelines", function() { afterEach(function() { httpBackend.verifyNoOutstandingExpectation(); client.stopClient(); - Thread.setServerSideSupport(false, false); + Thread.setServerSideSupport(FeatureSupport.None); }); describe("getEventTimeline", function() { it("should create a new timeline for new events", function() { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/rooms/!foo%3Abar/context/event1%3Abar") .respond(200, function() { @@ -323,14 +365,14 @@ describe("MatrixClient event timelines", function() { return Promise.all([ client.getEventTimeline(timelineSet, "event1:bar").then(function(tl) { - expect(tl.getEvents().length).toEqual(4); + expect(tl!.getEvents().length).toEqual(4); for (let i = 0; i < 4; i++) { - expect(tl.getEvents()[i].event).toEqual(EVENTS[i]); - expect(tl.getEvents()[i].sender.name).toEqual(userName); + expect(tl!.getEvents()[i].event).toEqual(EVENTS[i]); + expect(tl!.getEvents()[i]?.sender.name).toEqual(userName); } - expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)) .toEqual("start_token"); - expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)) .toEqual("end_token"); }), httpBackend.flushAllExpected(), @@ -338,7 +380,7 @@ describe("MatrixClient event timelines", function() { }); it("should return existing timeline for known events", function() { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/sync").respond(200, { next_batch: "s_5_4", @@ -360,12 +402,12 @@ describe("MatrixClient event timelines", function() { httpBackend.flush("/sync"), utils.syncPromise(client), ]).then(function() { - return client.getEventTimeline(timelineSet, EVENTS[0].event_id); + return client.getEventTimeline(timelineSet, EVENTS[0].event_id!); }).then(function(tl) { - expect(tl.getEvents().length).toEqual(2); - expect(tl.getEvents()[1].event).toEqual(EVENTS[0]); - expect(tl.getEvents()[1].sender.name).toEqual(userName); - expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + expect(tl!.getEvents().length).toEqual(2); + expect(tl!.getEvents()[1].event).toEqual(EVENTS[0]); + expect(tl!.getEvents()[1]?.sender.name).toEqual(userName); + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)) .toEqual("f_1_1"); // expect(tl.getPaginationToken(EventTimeline.FORWARDS)) // .toEqual("s_5_4"); @@ -373,7 +415,7 @@ describe("MatrixClient event timelines", function() { }); it("should update timelines where they overlap a previous /sync", function() { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/sync").respond(200, { next_batch: "s_5_4", @@ -392,7 +434,7 @@ describe("MatrixClient event timelines", function() { }); httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[2].event_id)) + encodeURIComponent(EVENTS[2].event_id!)) .respond(200, function() { return { start: "start_token", @@ -406,13 +448,13 @@ describe("MatrixClient event timelines", function() { const prom = new Promise((resolve, reject) => { client.on(ClientEvent.Sync, function() { - client.getEventTimeline(timelineSet, EVENTS[2].event_id, + client.getEventTimeline(timelineSet, EVENTS[2].event_id!, ).then(function(tl) { - expect(tl.getEvents().length).toEqual(4); - expect(tl.getEvents()[0].event).toEqual(EVENTS[1]); - expect(tl.getEvents()[1].event).toEqual(EVENTS[2]); - expect(tl.getEvents()[3].event).toEqual(EVENTS[3]); - expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + expect(tl!.getEvents().length).toEqual(4); + expect(tl!.getEvents()[0].event).toEqual(EVENTS[1]); + expect(tl!.getEvents()[1].event).toEqual(EVENTS[2]); + expect(tl!.getEvents()[3].event).toEqual(EVENTS[3]); + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)) .toEqual("start_token"); // expect(tl.getPaginationToken(EventTimeline.FORWARDS)) // .toEqual("s_5_4"); @@ -427,13 +469,13 @@ describe("MatrixClient event timelines", function() { }); it("should join timelines where they overlap a previous /context", function() { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; // we fetch event 0, then 2, then 3, and finally 1. 1 is returned // with context which joins them all up. httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[0].event_id)) + encodeURIComponent(EVENTS[0].event_id!)) .respond(200, function() { return { start: "start_token0", @@ -446,7 +488,7 @@ describe("MatrixClient event timelines", function() { }); httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[2].event_id)) + encodeURIComponent(EVENTS[2].event_id!)) .respond(200, function() { return { start: "start_token2", @@ -459,7 +501,7 @@ describe("MatrixClient event timelines", function() { }); httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[3].event_id)) + encodeURIComponent(EVENTS[3].event_id!)) .respond(200, function() { return { start: "start_token3", @@ -472,7 +514,7 @@ describe("MatrixClient event timelines", function() { }); httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[1].event_id)) + encodeURIComponent(EVENTS[1].event_id!)) .respond(200, function() { return { start: "start_token4", @@ -487,26 +529,26 @@ describe("MatrixClient event timelines", function() { let tl0; let tl3; return Promise.all([ - client.getEventTimeline(timelineSet, EVENTS[0].event_id, + client.getEventTimeline(timelineSet, EVENTS[0].event_id!, ).then(function(tl) { - expect(tl.getEvents().length).toEqual(1); + expect(tl!.getEvents().length).toEqual(1); tl0 = tl; - return client.getEventTimeline(timelineSet, EVENTS[2].event_id); + return client.getEventTimeline(timelineSet, EVENTS[2].event_id!); }).then(function(tl) { - expect(tl.getEvents().length).toEqual(1); - return client.getEventTimeline(timelineSet, EVENTS[3].event_id); + expect(tl!.getEvents().length).toEqual(1); + return client.getEventTimeline(timelineSet, EVENTS[3].event_id!); }).then(function(tl) { - expect(tl.getEvents().length).toEqual(1); + expect(tl!.getEvents().length).toEqual(1); tl3 = tl; - return client.getEventTimeline(timelineSet, EVENTS[1].event_id); + return client.getEventTimeline(timelineSet, EVENTS[1].event_id!); }).then(function(tl) { // we expect it to get merged in with event 2 - expect(tl.getEvents().length).toEqual(2); - expect(tl.getEvents()[0].event).toEqual(EVENTS[1]); - expect(tl.getEvents()[1].event).toEqual(EVENTS[2]); - expect(tl.getNeighbouringTimeline(EventTimeline.BACKWARDS)) + expect(tl!.getEvents().length).toEqual(2); + expect(tl!.getEvents()[0].event).toEqual(EVENTS[1]); + expect(tl!.getEvents()[1].event).toEqual(EVENTS[2]); + expect(tl!.getNeighbouringTimeline(EventTimeline.BACKWARDS)) .toBe(tl0); - expect(tl.getNeighbouringTimeline(EventTimeline.FORWARDS)) + expect(tl!.getNeighbouringTimeline(EventTimeline.FORWARDS)) .toBe(tl3); expect(tl0.getPaginationToken(EventTimeline.BACKWARDS)) .toEqual("start_token0"); @@ -522,7 +564,7 @@ describe("MatrixClient event timelines", function() { }); it("should fail gracefully if there is no event field", function() { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; // we fetch event 0, then 2, then 3, and finally 1. 1 is returned // with context which joins them all up. @@ -552,13 +594,13 @@ describe("MatrixClient event timelines", function() { it("should handle thread replies with server support by fetching a contiguous thread timeline", async () => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; - Thread.setServerSideSupport(true, false); + Thread.setServerSideSupport(FeatureSupport.Experimental); client.stopClient(); // we don't need the client to be syncing at this time - const room = client.getRoom(roomId); - const thread = room.createThread(THREAD_ROOT.event_id, undefined, [], false); + const room = client.getRoom(roomId)!; + const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false); const timelineSet = thread.timelineSet; - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id)) + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id!)) .respond(200, function() { return { start: "start_token0", @@ -570,13 +612,13 @@ describe("MatrixClient event timelines", function() { }; }); - httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id)) + httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!)) .respond(200, function() { return THREAD_ROOT; }); httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + - encodeURIComponent(THREAD_ROOT.event_id) + "/" + + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20") .respond(200, function() { return { @@ -586,26 +628,26 @@ describe("MatrixClient event timelines", function() { }; }); - const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id); + const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!); await httpBackend.flushAllExpected(); const timeline = await timelinePromise; - expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)).toBeTruthy(); - expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.event_id)).toBeTruthy(); + expect(timeline!.getEvents().find(e => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy(); + expect(timeline!.getEvents().find(e => e.getId() === THREAD_REPLY.event_id!)).toBeTruthy(); }); it("should return relevant timeline from non-thread timelineSet when asking for the thread root", async () => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; - Thread.setServerSideSupport(true, false); + Thread.setServerSideSupport(FeatureSupport.Experimental); client.stopClient(); // we don't need the client to be syncing at this time - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const threadRoot = new MatrixEvent(THREAD_ROOT); - const thread = room.createThread(THREAD_ROOT.event_id, threadRoot, [threadRoot], false); - const timelineSet = room.getTimelineSets()[0]; + const thread = room.createThread(THREAD_ROOT.event_id!, threadRoot, [threadRoot], false)!; + const timelineSet = room.getTimelineSets()[0]!; - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id)) + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!)) .respond(200, function() { return { start: "start_token0", @@ -618,26 +660,26 @@ describe("MatrixClient event timelines", function() { }); const [timeline] = await Promise.all([ - client.getEventTimeline(timelineSet, THREAD_ROOT.event_id), + client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!), httpBackend.flushAllExpected(), ]); - expect(timeline).not.toBe(thread.liveTimeline); + expect(timeline!).not.toBe(thread.liveTimeline); expect(timelineSet.getTimelines()).toContain(timeline); - expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)).toBeTruthy(); + expect(timeline!.getEvents().find(e => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy(); }); it("should return undefined when event is not in the thread that the given timelineSet is representing", () => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; - Thread.setServerSideSupport(true, false); + Thread.setServerSideSupport(FeatureSupport.Experimental); client.stopClient(); // we don't need the client to be syncing at this time - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const threadRoot = new MatrixEvent(THREAD_ROOT); - const thread = room.createThread(THREAD_ROOT.event_id, threadRoot, [threadRoot], false); + const thread = room.createThread(THREAD_ROOT.event_id!, threadRoot, [threadRoot], false); const timelineSet = thread.timelineSet; - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id)) + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!)) .respond(200, function() { return { start: "start_token0", @@ -650,7 +692,7 @@ describe("MatrixClient event timelines", function() { }); return Promise.all([ - expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id)).resolves.toBeUndefined(), + expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id!)).resolves.toBeUndefined(), httpBackend.flushAllExpected(), ]); }); @@ -658,12 +700,12 @@ describe("MatrixClient event timelines", function() { it("should return undefined when event is within a thread but timelineSet is not", () => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; - Thread.setServerSideSupport(true, false); + Thread.setServerSideSupport(FeatureSupport.Experimental); client.stopClient(); // we don't need the client to be syncing at this time - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id)) + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id!)) .respond(200, function() { return { start: "start_token0", @@ -676,7 +718,7 @@ describe("MatrixClient event timelines", function() { }); return Promise.all([ - expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id)).resolves.toBeUndefined(), + expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!)).resolves.toBeUndefined(), httpBackend.flushAllExpected(), ]); }); @@ -685,10 +727,10 @@ describe("MatrixClient event timelines", function() { // @ts-ignore client.clientOpts.lazyLoadMembers = true; client.stopClient(); // we don't need the client to be syncing at this time - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; - const req = httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id)); + const req = httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!)); req.respond(200, function() { return { start: "start_token0", @@ -700,20 +742,77 @@ describe("MatrixClient event timelines", function() { }; }); req.check((request) => { - expect(request.queryParams.filter).toEqual(JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER)); + expect(request.queryParams?.filter).toEqual(JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER)); }); await Promise.all([ - client.getEventTimeline(timelineSet, EVENTS[0].event_id), + client.getEventTimeline(timelineSet, EVENTS[0].event_id!), httpBackend.flushAllExpected(), ]); }); }); describe("getLatestTimeline", function() { - it("should create a new timeline for new events", function() { + it("timeline support must be enabled to work", async function() { + await client.stopClient(); + + const testClient = new TestClient( + userId, + "DEVICE", + accessToken, + undefined, + { timelineSupport: false }, + ); + client = testClient.client; + httpBackend = testClient.httpBackend; + await startClient(httpBackend, client); + const room = client.getRoom(roomId); const timelineSet = room.getTimelineSets()[0]; + await expect(client.getLatestTimeline(timelineSet)).rejects.toBeTruthy(); + }); + + it("timeline support works when enabled", async function() { + await client.stopClient(); + + const testClient = new TestClient( + userId, + "DEVICE", + accessToken, + undefined, + { timelineSupport: true }, + ); + client = testClient.client; + httpBackend = testClient.httpBackend; + + return startClient(httpBackend, client).then(() => { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + expect(client.getLatestTimeline(timelineSet)).rejects.toBeFalsy(); + }); + }); + + it("only works with room timelines", async function() { + await client.stopClient(); + + const testClient = new TestClient( + userId, + "DEVICE", + accessToken, + undefined, + { timelineSupport: true }, + ); + client = testClient.client; + httpBackend = testClient.httpBackend; + await startClient(httpBackend, client); + + const timelineSet = new EventTimelineSet(undefined); + await expect(client.getLatestTimeline(timelineSet)).rejects.toBeTruthy(); + }); + + it("should create a new timeline for new events", function() { + const room = client.getRoom(roomId)!; + const timelineSet = room.getTimelineSets()[0]; const latestMessageId = 'event1:bar'; @@ -747,14 +846,14 @@ describe("MatrixClient event timelines", function() { // for `getEventTimeline` and make sure it's called with the // correct parameters. This doesn't feel too bad to make sure // `getLatestTimeline` is doing the right thing though. - expect(tl.getEvents().length).toEqual(4); + expect(tl!.getEvents().length).toEqual(4); for (let i = 0; i < 4; i++) { - expect(tl.getEvents()[i].event).toEqual(EVENTS[i]); - expect(tl.getEvents()[i].sender.name).toEqual(userName); + expect(tl!.getEvents()[i].event).toEqual(EVENTS[i]); + expect(tl!.getEvents()[i]?.sender.name).toEqual(userName); } - expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)) .toEqual("start_token"); - expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)) .toEqual("end_token"); }), httpBackend.flushAllExpected(), @@ -762,7 +861,7 @@ describe("MatrixClient event timelines", function() { }); it("should throw error when /messages does not return a message", () => { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/rooms/!foo%3Abar/messages") @@ -783,11 +882,11 @@ describe("MatrixClient event timelines", function() { describe("paginateEventTimeline", function() { it("should allow you to paginate backwards", function() { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[0].event_id)) + encodeURIComponent(EVENTS[0].event_id!)) .respond(200, function() { return { start: "start_token0", @@ -801,7 +900,7 @@ describe("MatrixClient event timelines", function() { httpBackend.when("GET", "/rooms/!foo%3Abar/messages") .check(function(req) { - const params = req.queryParams; + const params = req.queryParams!; expect(params.dir).toEqual("b"); expect(params.from).toEqual("start_token0"); expect(params.limit).toEqual("30"); @@ -814,19 +913,19 @@ describe("MatrixClient event timelines", function() { let tl; return Promise.all([ - client.getEventTimeline(timelineSet, EVENTS[0].event_id, + client.getEventTimeline(timelineSet, EVENTS[0].event_id!, ).then(function(tl0) { tl = tl0; return client.paginateEventTimeline(tl, { backwards: true }); }).then(function(success) { expect(success).toBeTruthy(); - expect(tl.getEvents().length).toEqual(3); - expect(tl.getEvents()[0].event).toEqual(EVENTS[2]); - expect(tl.getEvents()[1].event).toEqual(EVENTS[1]); - expect(tl.getEvents()[2].event).toEqual(EVENTS[0]); - expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + expect(tl!.getEvents().length).toEqual(3); + expect(tl!.getEvents()[0].event).toEqual(EVENTS[2]); + expect(tl!.getEvents()[1].event).toEqual(EVENTS[1]); + expect(tl!.getEvents()[2].event).toEqual(EVENTS[0]); + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)) .toEqual("start_token1"); - expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)) .toEqual("end_token0"); }), httpBackend.flushAllExpected(), @@ -834,11 +933,11 @@ describe("MatrixClient event timelines", function() { }); it("should stop paginating when it encounters no `end` token", () => { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[0].event_id)) + encodeURIComponent(EVENTS[0].event_id!)) .respond(200, () => ({ start: "start_token0", events_before: [], @@ -850,7 +949,7 @@ describe("MatrixClient event timelines", function() { httpBackend.when("GET", "/rooms/!foo%3Abar/messages") .check(function(req) { - const params = req.queryParams; + const params = req.queryParams!; expect(params.dir).toEqual("b"); expect(params.from).toEqual("start_token0"); expect(params.limit).toEqual("30"); @@ -861,23 +960,23 @@ describe("MatrixClient event timelines", function() { return Promise.all([ (async () => { - const tl = await client.getEventTimeline(timelineSet, EVENTS[0].event_id); - const success = await client.paginateEventTimeline(tl, { backwards: true }); + const tl = await client.getEventTimeline(timelineSet, EVENTS[0].event_id!); + const success = await client.paginateEventTimeline(tl!, { backwards: true }); expect(success).toBeFalsy(); - expect(tl.getEvents().length).toEqual(1); - expect(tl.getPaginationToken(EventTimeline.BACKWARDS)).toEqual(null); - expect(tl.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token0"); + expect(tl!.getEvents().length).toEqual(1); + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)).toEqual(null); + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token0"); })(), httpBackend.flushAllExpected(), ]); }); it("should allow you to paginate forwards", function() { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + - encodeURIComponent(EVENTS[0].event_id)) + encodeURIComponent(EVENTS[0].event_id!)) .respond(200, function() { return { start: "start_token0", @@ -891,7 +990,7 @@ describe("MatrixClient event timelines", function() { httpBackend.when("GET", "/rooms/!foo%3Abar/messages") .check(function(req) { - const params = req.queryParams; + const params = req.queryParams!; expect(params.dir).toEqual("f"); expect(params.from).toEqual("end_token0"); expect(params.limit).toEqual("20"); @@ -904,20 +1003,20 @@ describe("MatrixClient event timelines", function() { let tl; return Promise.all([ - client.getEventTimeline(timelineSet, EVENTS[0].event_id, + client.getEventTimeline(timelineSet, EVENTS[0].event_id!, ).then(function(tl0) { tl = tl0; return client.paginateEventTimeline( tl, { backwards: false, limit: 20 }); }).then(function(success) { expect(success).toBeTruthy(); - expect(tl.getEvents().length).toEqual(3); - expect(tl.getEvents()[0].event).toEqual(EVENTS[0]); - expect(tl.getEvents()[1].event).toEqual(EVENTS[1]); - expect(tl.getEvents()[2].event).toEqual(EVENTS[2]); - expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + expect(tl!.getEvents().length).toEqual(3); + expect(tl!.getEvents()[0].event).toEqual(EVENTS[0]); + expect(tl!.getEvents()[1].event).toEqual(EVENTS[1]); + expect(tl!.getEvents()[2].event).toEqual(EVENTS[2]); + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)) .toEqual("start_token0"); - expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)) .toEqual("end_token1"); }), httpBackend.flushAllExpected(), @@ -925,6 +1024,236 @@ describe("MatrixClient event timelines", function() { }); }); + describe("paginateEventTimeline for thread list timeline", function() { + async function flushHttp(promise: Promise): Promise { + return Promise.all([promise, httpBackend.flushAllExpected()]).then(([result]) => result); + } + + const RANDOM_TOKEN = "7280349c7bee430f91defe2a38a0a08c"; + + function respondToFilter(): ExpectedHttpRequest { + const request = httpBackend.when("POST", "/filter"); + request.respond(200, { filter_id: "fid" }); + return request; + } + + function respondToSync(): ExpectedHttpRequest { + const request = httpBackend.when("GET", "/sync"); + request.respond(200, INITIAL_SYNC_DATA); + return request; + } + + function respondToThreads( + response = { + chunk: [THREAD_ROOT], + state: [], + next_batch: RANDOM_TOKEN, + }, + ): ExpectedHttpRequest { + const request = httpBackend.when("GET", encodeUri("/_matrix/client/r0/rooms/$roomId/threads", { + $roomId: roomId, + })); + request.respond(200, response); + return request; + } + + function respondToContext(): ExpectedHttpRequest { + const request = httpBackend.when("GET", encodeUri("/_matrix/client/r0/rooms/$roomId/context/$eventId", { + $roomId: roomId, + $eventId: THREAD_ROOT.event_id!, + })); + request.respond(200, { + end: `${Direction.Forward}${RANDOM_TOKEN}1`, + start: `${Direction.Backward}${RANDOM_TOKEN}1`, + state: [], + events_before: [], + events_after: [], + event: THREAD_ROOT, + }); + return request; + } + function respondToMessagesRequest(): ExpectedHttpRequest { + const request = httpBackend.when("GET", encodeUri("/_matrix/client/r0/rooms/$roomId/messages", { + $roomId: roomId, + })); + request.respond(200, { + chunk: [THREAD_ROOT], + state: [], + start: `${Direction.Forward}${RANDOM_TOKEN}2`, + end: `${Direction.Backward}${RANDOM_TOKEN}2`, + }); + return request; + } + + describe("with server compatibility", function() { + beforeEach(() => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Experimental); + Thread.setServerSideListSupport(FeatureSupport.Stable); + }); + + async function testPagination(timelineSet: EventTimelineSet, direction: Direction) { + respondToContext(); + await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!)); + respondToThreads(); + const timeline = await flushHttp(client.getLatestTimeline(timelineSet)); + expect(timeline).not.toBeNull(); + + respondToThreads(); + const success = await flushHttp(client.paginateEventTimeline(timeline!, { + backwards: direction === Direction.Backward, + })); + expect(success).toBeTruthy(); + expect(timeline!.getEvents().map(it => it.event)).toEqual([THREAD_ROOT]); + expect(timeline!.getPaginationToken(direction)).toEqual(RANDOM_TOKEN); + } + + it("should allow you to paginate all threads backwards", async function() { + const room = client.getRoom(roomId); + const timelineSets = await (room?.createThreadsTimelineSets()); + expect(timelineSets).not.toBeNull(); + const [allThreads, myThreads] = timelineSets!; + await testPagination(allThreads, Direction.Backward); + await testPagination(myThreads, Direction.Backward); + }); + + it("should allow you to paginate all threads forwards", async function() { + const room = client.getRoom(roomId); + const timelineSets = await (room?.createThreadsTimelineSets()); + expect(timelineSets).not.toBeNull(); + const [allThreads, myThreads] = timelineSets!; + + await testPagination(allThreads, Direction.Forward); + await testPagination(myThreads, Direction.Forward); + }); + + it("should allow fetching all threads", async function() { + const room = client.getRoom(roomId); + const timelineSets = await room?.createThreadsTimelineSets(); + expect(timelineSets).not.toBeNull(); + respondToThreads(); + respondToThreads(); + httpBackend.when("GET", "/sync").respond(200, INITIAL_SYNC_DATA); + await flushHttp(room.fetchRoomThreads()); + }); + }); + + describe("without server compatibility", function() { + beforeEach(() => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Experimental); + Thread.setServerSideListSupport(FeatureSupport.None); + }); + + async function testPagination(timelineSet: EventTimelineSet, direction: Direction) { + respondToContext(); + respondToSync(); + await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!)); + + respondToMessagesRequest(); + const timeline = await flushHttp(client.getLatestTimeline(timelineSet)); + expect(timeline).not.toBeNull(); + + respondToMessagesRequest(); + const success = await flushHttp(client.paginateEventTimeline(timeline!, { + backwards: direction === Direction.Backward, + })); + + expect(success).toBeTruthy(); + expect(timeline!.getEvents().map(it => it.event)).toEqual([THREAD_ROOT]); + expect(timeline!.getPaginationToken(direction)).toEqual(`${direction}${RANDOM_TOKEN}2`); + } + + it("should allow you to paginate all threads", async function() { + const room = client.getRoom(roomId); + + respondToFilter(); + respondToSync(); + respondToFilter(); + respondToSync(); + + const timelineSetsPromise = room?.createThreadsTimelineSets(); + expect(timelineSetsPromise).not.toBeNull(); + const timelineSets = await flushHttp(timelineSetsPromise!); + expect(timelineSets).not.toBeNull(); + const [allThreads, myThreads] = timelineSets!; + + await testPagination(allThreads, Direction.Backward); + await testPagination(myThreads, Direction.Backward); + }); + + it("should allow fetching all threads", async function() { + const room = client.getRoom(roomId); + + respondToFilter(); + respondToSync(); + respondToFilter(); + respondToSync(); + + const timelineSetsPromise = room?.createThreadsTimelineSets(); + expect(timelineSetsPromise).not.toBeNull(); + await flushHttp(timelineSetsPromise!); + respondToFilter(); + respondToSync(); + respondToSync(); + respondToSync(); + respondToMessagesRequest(); + await flushHttp(room.fetchRoomThreads()); + }); + }); + + it("should add lazy loading filter", async () => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Experimental); + Thread.setServerSideListSupport(FeatureSupport.Stable); + // @ts-ignore + client.clientOpts.lazyLoadMembers = true; + + const room = client.getRoom(roomId); + const timelineSets = await room?.createThreadsTimelineSets(); + expect(timelineSets).not.toBeNull(); + const [allThreads] = timelineSets!; + + respondToThreads().check((request) => { + expect(request.queryParams.filter).toEqual(JSON.stringify({ + "lazy_load_members": true, + })); + }); + + await flushHttp(client.paginateEventTimeline(allThreads.getLiveTimeline(), { + backwards: true, + })); + }); + + it("should correctly pass pagination token", async () => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Experimental); + Thread.setServerSideListSupport(FeatureSupport.Stable); + + const room = client.getRoom(roomId); + const timelineSets = await room?.createThreadsTimelineSets(); + expect(timelineSets).not.toBeNull(); + const [allThreads] = timelineSets!; + + respondToThreads({ + chunk: [THREAD_ROOT], + state: [], + next_batch: null, + }).check((request) => { + expect(request.queryParams.from).toEqual(RANDOM_TOKEN); + }); + + allThreads.getLiveTimeline().setPaginationToken(RANDOM_TOKEN, Direction.Backward); + await flushHttp(client.paginateEventTimeline(allThreads.getLiveTimeline(), { + backwards: true, + })); + }); + }); + describe("event timeline for sent events", function() { const TXN_ID = "txn1"; const event = utils.mkMessage({ @@ -957,17 +1286,17 @@ describe("MatrixClient event timelines", function() { }); it("should work when /send returns before /sync", function() { - const room = client.getRoom(roomId); - const timelineSet = room.getTimelineSets()[0]; + const room = client.getRoom(roomId)!; + const timelineSet = room.getTimelineSets()[0]!; return Promise.all([ client.sendTextMessage(roomId, "a body", TXN_ID).then(function(res) { - expect(res.event_id).toEqual(event.event_id); - return client.getEventTimeline(timelineSet, event.event_id); + expect(res.event_id).toEqual(event.event_id!); + return client.getEventTimeline(timelineSet, event.event_id!); }).then(function(tl) { // 2 because the initial sync contained an event - expect(tl.getEvents().length).toEqual(2); - expect(tl.getEvents()[1].getContent().body).toEqual("a body"); + expect(tl!.getEvents().length).toEqual(2); + expect(tl!.getEvents()[1].getContent().body).toEqual("a body"); // now let the sync complete, and check it again return Promise.all([ @@ -975,10 +1304,10 @@ describe("MatrixClient event timelines", function() { utils.syncPromise(client), ]); }).then(function() { - return client.getEventTimeline(timelineSet, event.event_id); + return client.getEventTimeline(timelineSet, event.event_id!); }).then(function(tl) { - expect(tl.getEvents().length).toEqual(2); - expect(tl.getEvents()[1].event).toEqual(event); + expect(tl!.getEvents().length).toEqual(2); + expect(tl!.getEvents()[1].event).toEqual(event); }), httpBackend.flush("/send/m.room.message/" + TXN_ID, 1), @@ -986,7 +1315,7 @@ describe("MatrixClient event timelines", function() { }); it("should work when /send returns after /sync", function() { - const room = client.getRoom(roomId); + const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; return Promise.all([ @@ -994,23 +1323,23 @@ describe("MatrixClient event timelines", function() { // - but note that it won't complete until after the /sync does, below. client.sendTextMessage(roomId, "a body", TXN_ID).then(function(res) { logger.log("sendTextMessage completed"); - expect(res.event_id).toEqual(event.event_id); - return client.getEventTimeline(timelineSet, event.event_id); + expect(res.event_id).toEqual(event.event_id!); + return client.getEventTimeline(timelineSet, event.event_id!); }).then(function(tl) { logger.log("getEventTimeline completed (2)"); - expect(tl.getEvents().length).toEqual(2); - expect(tl.getEvents()[1].getContent().body).toEqual("a body"); + expect(tl!.getEvents().length).toEqual(2); + expect(tl!.getEvents()[1].getContent().body).toEqual("a body"); }), Promise.all([ httpBackend.flush("/sync", 1), utils.syncPromise(client), ]).then(function() { - return client.getEventTimeline(timelineSet, event.event_id); + return client.getEventTimeline(timelineSet, event.event_id!); }).then(function(tl) { logger.log("getEventTimeline completed (1)"); - expect(tl.getEvents().length).toEqual(2); - expect(tl.getEvents()[1].event).toEqual(event); + expect(tl!.getEvents().length).toEqual(2); + expect(tl!.getEvents()[1].event).toEqual(event); // now let the send complete. return httpBackend.flush("/send/m.room.message/" + TXN_ID, 1); @@ -1054,10 +1383,10 @@ describe("MatrixClient event timelines", function() { httpBackend.flushAllExpected(), utils.syncPromise(client), ]).then(function() { - const room = client.getRoom(roomId); - const tl = room.getLiveTimeline(); - expect(tl.getEvents().length).toEqual(3); - expect(tl.getEvents()[1].isRedacted()).toBe(true); + const room = client.getRoom(roomId)!; + const tl = room.getLiveTimeline()!; + expect(tl!.getEvents().length).toEqual(3); + expect(tl!.getEvents()[1].isRedacted()).toBe(true); const sync2 = { next_batch: "batch2", @@ -1083,8 +1412,8 @@ describe("MatrixClient event timelines", function() { utils.syncPromise(client), ]); }).then(function() { - const room = client.getRoom(roomId); - const tl = room.getLiveTimeline(); + const room = client.getRoom(roomId)!; + const tl = room.getLiveTimeline()!; expect(tl.getEvents().length).toEqual(1); }); }); @@ -1092,7 +1421,7 @@ describe("MatrixClient event timelines", function() { it("should re-insert room IDs for bundled thread relation events", async () => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; - Thread.setServerSideSupport(true, false); + Thread.setServerSideSupport(FeatureSupport.Experimental); httpBackend.when("GET", "/sync").respond(200, { next_batch: "s_5_4", @@ -1111,11 +1440,11 @@ describe("MatrixClient event timelines", function() { }); await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]); - const room = client.getRoom(roomId); - const thread = room.getThread(THREAD_ROOT.event_id); + const room = client.getRoom(roomId)!; + const thread = room.getThread(THREAD_ROOT.event_id!)!; const timelineSet = thread.timelineSet; - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id)) + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!)) .respond(200, { start: "start_token", events_before: [], @@ -1125,7 +1454,7 @@ describe("MatrixClient event timelines", function() { end: "end_token", }); httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + - encodeURIComponent(THREAD_ROOT.event_id) + "/" + + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20") .respond(200, function() { return { @@ -1135,7 +1464,7 @@ describe("MatrixClient event timelines", function() { }; }); await Promise.all([ - client.getEventTimeline(timelineSet, THREAD_ROOT.event_id), + client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!), httpBackend.flushAllExpected(), ]); diff --git a/spec/integ/matrix-client-methods.spec.js b/spec/integ/matrix-client-methods.spec.ts similarity index 67% rename from spec/integ/matrix-client-methods.spec.js rename to spec/integ/matrix-client-methods.spec.ts index 69bfa89ca..954df27fd 100644 --- a/spec/integ/matrix-client-methods.spec.js +++ b/spec/integ/matrix-client-methods.spec.ts @@ -13,173 +13,177 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +import HttpBackend from "matrix-mock-request"; import * as utils from "../test-utils/test-utils"; -import { CRYPTO_ENABLED } from "../../src/client"; +import { CRYPTO_ENABLED, IStoredClientOpts, MatrixClient } from "../../src/client"; import { MatrixEvent } from "../../src/models/event"; -import { Filter, MemoryStore, Room } from "../../src/matrix"; +import { Filter, MemoryStore, Method, Room, SERVICE_TYPES } from "../../src/matrix"; import { TestClient } from "../TestClient"; import { THREAD_RELATION_TYPE } from "../../src/models/thread"; +import { IFilterDefinition } from "../../src/filter"; +import { ISearchResults } from "../../src/@types/search"; +import { IStore } from "../../src/store"; describe("MatrixClient", function() { - let client = null; - let httpBackend = null; - let store = null; const userId = "@alice:localhost"; const accessToken = "aseukfgwef"; const idServerDomain = "identity.localhost"; // not a real server const identityAccessToken = "woop-i-am-a-secret"; + let client: MatrixClient | undefined; + let httpBackend: HttpBackend | undefined; + let store: MemoryStore | undefined; - beforeEach(function() { - store = new MemoryStore(); + const defaultClientOpts: IStoredClientOpts = { + canResetEntireTimeline: roomId => false, + experimentalThreadSupport: false, + crypto: {} as unknown as IStoredClientOpts['crypto'], + }; + const setupTests = (): [MatrixClient, HttpBackend, MemoryStore] => { + const store = new MemoryStore(); const testClient = new TestClient(userId, "aliceDevice", accessToken, undefined, { - store, + store: store as IStore, identityServer: { getAccessToken: () => Promise.resolve(identityAccessToken), }, idBaseUrl: `https://${idServerDomain}`, }); - httpBackend = testClient.httpBackend; - client = testClient.client; + + return [testClient.client, testClient.httpBackend, store]; + }; + + beforeEach(function() { + [client, httpBackend, store] = setupTests(); }); afterEach(function() { - httpBackend.verifyNoOutstandingExpectation(); - return httpBackend.stop(); + httpBackend!.verifyNoOutstandingExpectation(); + return httpBackend!.stop(); }); describe("uploadContent", function() { const buf = Buffer.from('hello world'); + const file = buf; + const opts = { + type: "text/plain", + name: "hi.txt", + }; + it("should upload the file", function() { - httpBackend.when( + httpBackend!.when( "POST", "/_matrix/media/r0/upload", ).check(function(req) { expect(req.rawData).toEqual(buf); - expect(req.queryParams.filename).toEqual("hi.txt"); - if (!(req.queryParams.access_token == accessToken || - req.headers["Authorization"] == "Bearer " + accessToken)) { - expect(true).toBe(false); - } + 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", true); + }).respond(200, '{"content_uri": "content"}', true); - const prom = client.uploadContent({ - stream: buf, - name: "hi.txt", - type: "text/plain", - }); + const prom = client!.uploadContent(file, opts); expect(prom).toBeTruthy(); - const uploads = client.getCurrentUploads(); + const uploads = client!.getCurrentUploads(); expect(uploads.length).toEqual(1); expect(uploads[0].promise).toBe(prom); expect(uploads[0].loaded).toEqual(0); const prom2 = prom.then(function(response) { - // for backwards compatibility, we return the raw JSON - expect(response).toEqual("content"); + expect(response.content_uri).toEqual("content"); - const uploads = client.getCurrentUploads(); + const uploads = client!.getCurrentUploads(); expect(uploads.length).toEqual(0); }); - httpBackend.flush(); + httpBackend!.flush(''); return prom2; }); - it("should parse the response if rawResponse=false", function() { - httpBackend.when( - "POST", "/_matrix/media/r0/upload", - ).check(function(req) { - expect(req.opts.json).toBeFalsy(); - }).respond(200, { "content_uri": "uri" }); - - const prom = client.uploadContent({ - stream: buf, - name: "hi.txt", - type: "text/plain", - }, { - rawResponse: false, - }).then(function(response) { - expect(response.content_uri).toEqual("uri"); - }); - - httpBackend.flush(); - return prom; - }); - it("should parse errors into a MatrixError", function() { - httpBackend.when( + httpBackend!.when( "POST", "/_matrix/media/r0/upload", ).check(function(req) { expect(req.rawData).toEqual(buf); + // @ts-ignore private property expect(req.opts.json).toBeFalsy(); }).respond(400, { "errcode": "M_SNAFU", "error": "broken", }); - const prom = client.uploadContent({ - stream: buf, - name: "hi.txt", - type: "text/plain", - }).then(function(response) { + 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("broken"); + expect(error.message).toEqual("MatrixError: [400] broken"); }); - httpBackend.flush(); + httpBackend!.flush(''); return prom; }); - it("should return a promise which can be cancelled", function() { - const prom = client.uploadContent({ - stream: buf, - name: "hi.txt", - type: "text/plain", - }); + it("should return a promise which can be cancelled", async () => { + const prom = client!.uploadContent(file, opts); - const uploads = client.getCurrentUploads(); + const uploads = client!.getCurrentUploads(); expect(uploads.length).toEqual(1); expect(uploads[0].promise).toBe(prom); expect(uploads[0].loaded).toEqual(0); - const prom2 = prom.then(function(response) { - throw Error("request not aborted"); - }, function(error) { - expect(error).toEqual("aborted"); - - const uploads = client.getCurrentUploads(); - expect(uploads.length).toEqual(0); - }); - - const r = client.cancelUpload(prom); + const r = client!.cancelUpload(prom); expect(r).toBe(true); - return prom2; + await expect(prom).rejects.toThrow("Aborted"); + expect(client.getCurrentUploads()).toHaveLength(0); }); }); describe("joinRoom", function() { it("should no-op if you've already joined a room", function() { const roomId = "!foo:bar"; - const room = new Room(roomId, client, userId); - client.fetchRoomEvent = () => Promise.resolve({}); + const room = new Room(roomId, client!, userId); + client!.fetchRoomEvent = () => Promise.resolve({ + type: 'test', + content: {}, + }); room.addLiveEvents([ utils.mkMembership({ user: userId, room: roomId, mship: "join", event: true, }), ]); - httpBackend.verifyNoOutstandingRequests(); - store.storeRoom(room); - client.joinRoom(roomId); - httpBackend.verifyNoOutstandingRequests(); + httpBackend!.verifyNoOutstandingRequests(); + store!.storeRoom(room); + client!.joinRoom(roomId); + httpBackend!.verifyNoOutstandingRequests(); + }); + + it("should send request to inviteSignUrl if specified", async () => { + const roomId = "!roomId:server"; + const inviteSignUrl = "https://id.server/sign/this/for/me"; + const viaServers = ["a", "b", "c"]; + const signature = { + sender: "sender", + mxid: "@sender:foo", + token: "token", + signatures: {}, + }; + + httpBackend!.when("POST", inviteSignUrl).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, + viaServers, + }); + await httpBackend!.flushAllExpected(); + expect((await prom).roomId).toBe(roomId); }); }); @@ -190,67 +194,67 @@ describe("MatrixClient", function() { const filter = Filter.fromJson(userId, filterId, { event_format: "client", }); - store.storeFilter(filter); - client.getFilter(userId, filterId, true).then(function(gotFilter) { + store!.storeFilter(filter); + client!.getFilter(userId, filterId, true).then(function(gotFilter) { expect(gotFilter).toEqual(filter); done(); }); - httpBackend.verifyNoOutstandingRequests(); + httpBackend!.verifyNoOutstandingRequests(); }); it("should do an HTTP request if !allowCached even if one exists", - function(done) { - const httpFilterDefinition = { - event_format: "federation", - }; + function(done) { + const httpFilterDefinition = { + event_format: "federation", + }; - httpBackend.when( - "GET", "/user/" + encodeURIComponent(userId) + "/filter/" + filterId, - ).respond(200, httpFilterDefinition); + httpBackend!.when( + "GET", "/user/" + encodeURIComponent(userId) + "/filter/" + filterId, + ).respond(200, httpFilterDefinition); - const storeFilter = Filter.fromJson(userId, filterId, { - event_format: "client", + const storeFilter = Filter.fromJson(userId, filterId, { + event_format: "client", + }); + store!.storeFilter(storeFilter); + client!.getFilter(userId, filterId, false).then(function(gotFilter) { + expect(gotFilter.getDefinition()).toEqual(httpFilterDefinition); + done(); + }); + + httpBackend!.flush(''); }); - store.storeFilter(storeFilter); - client.getFilter(userId, filterId, false).then(function(gotFilter) { - expect(gotFilter.getDefinition()).toEqual(httpFilterDefinition); - done(); - }); - - httpBackend.flush(); - }); it("should do an HTTP request if nothing is in the cache and then store it", - function(done) { - const httpFilterDefinition = { - event_format: "federation", - }; - expect(store.getFilter(userId, filterId)).toBe(null); + function(done) { + const httpFilterDefinition = { + event_format: "federation", + }; + expect(store!.getFilter(userId, filterId)).toBe(null); - httpBackend.when( - "GET", "/user/" + encodeURIComponent(userId) + "/filter/" + filterId, - ).respond(200, httpFilterDefinition); - client.getFilter(userId, filterId, true).then(function(gotFilter) { - expect(gotFilter.getDefinition()).toEqual(httpFilterDefinition); - expect(store.getFilter(userId, filterId)).toBeTruthy(); - done(); + httpBackend!.when( + "GET", "/user/" + encodeURIComponent(userId) + "/filter/" + filterId, + ).respond(200, httpFilterDefinition); + client!.getFilter(userId, filterId, true).then(function(gotFilter) { + expect(gotFilter.getDefinition()).toEqual(httpFilterDefinition); + expect(store!.getFilter(userId, filterId)).toBeTruthy(); + done(); + }); + + httpBackend!.flush(''); }); - - httpBackend.flush(); - }); }); describe("createFilter", function() { const filterId = "f1llllllerid"; it("should do an HTTP request and then store the filter", function(done) { - expect(store.getFilter(userId, filterId)).toBe(null); + expect(store!.getFilter(userId, filterId)).toBe(null); const filterDefinition = { - event_format: "client", + event_format: "client" as IFilterDefinition['event_format'], }; - httpBackend.when( + httpBackend!.when( "POST", "/user/" + encodeURIComponent(userId) + "/filter", ).check(function(req) { expect(req.data).toEqual(filterDefinition); @@ -258,13 +262,13 @@ describe("MatrixClient", function() { filter_id: filterId, }); - client.createFilter(filterDefinition).then(function(gotFilter) { + client!.createFilter(filterDefinition).then(function(gotFilter) { expect(gotFilter.getDefinition()).toEqual(filterDefinition); - expect(store.getFilter(userId, filterId)).toEqual(gotFilter); + expect(store!.getFilter(userId, filterId)).toEqual(gotFilter); done(); }); - httpBackend.flush(); + httpBackend!.flush(''); }); }); @@ -291,10 +295,10 @@ describe("MatrixClient", function() { }, }; - client.searchMessageText({ + client!.searchMessageText({ query: "monkeys", }); - httpBackend.when("POST", "/search").check(function(req) { + httpBackend!.when("POST", "/search").check(function(req) { expect(req.data).toEqual({ search_categories: { room_events: { @@ -304,7 +308,7 @@ describe("MatrixClient", function() { }); }).respond(200, response); - return httpBackend.flush(); + return httpBackend!.flush(''); }); describe("should filter out context from different timelines (threads)", () => { @@ -313,11 +317,14 @@ describe("MatrixClient", function() { search_categories: { room_events: { count: 24, + highlights: [], results: [{ rank: 0.1, result: { event_id: "$flibble:localhost", type: "m.room.message", + sender: '@test:locahost', + origin_server_ts: 123, user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", content: { @@ -326,9 +333,12 @@ describe("MatrixClient", function() { }, }, context: { + profile_info: {}, events_after: [{ event_id: "$ev-after:server", type: "m.room.message", + sender: '@test:locahost', + origin_server_ts: 123, user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", content: { @@ -343,6 +353,8 @@ describe("MatrixClient", function() { events_before: [{ event_id: "$ev-before:server", type: "m.room.message", + sender: '@test:locahost', + origin_server_ts: 123, user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", content: { @@ -356,15 +368,17 @@ describe("MatrixClient", function() { }, }; - const data = { + const data: ISearchResults = { results: [], highlights: [], }; - client.processRoomEventsSearch(data, response); + client!.processRoomEventsSearch(data, response); expect(data.results).toHaveLength(1); - expect(data.results[0].context.timeline).toHaveLength(2); - expect(data.results[0].context.timeline.find(e => e.getId() === "$ev-after:server")).toBeFalsy(); + expect(data.results[0].context.getTimeline()).toHaveLength(2); + expect( + data.results[0].context.getTimeline().find(e => e.getId() === "$ev-after:server"), + ).toBeFalsy(); }); it("filters out thread replies from threads other than the thread the result replied to", () => { @@ -372,11 +386,14 @@ describe("MatrixClient", function() { search_categories: { room_events: { count: 24, + highlights: [], results: [{ rank: 0.1, result: { event_id: "$flibble:localhost", type: "m.room.message", + sender: '@test:locahost', + origin_server_ts: 123, user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", content: { @@ -389,9 +406,12 @@ describe("MatrixClient", function() { }, }, context: { + profile_info: {}, events_after: [{ event_id: "$ev-after:server", type: "m.room.message", + sender: '@test:locahost', + origin_server_ts: 123, user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", content: { @@ -410,15 +430,17 @@ describe("MatrixClient", function() { }, }; - const data = { + const data: ISearchResults = { results: [], highlights: [], }; - client.processRoomEventsSearch(data, response); + client!.processRoomEventsSearch(data, response); expect(data.results).toHaveLength(1); - expect(data.results[0].context.timeline).toHaveLength(1); - expect(data.results[0].context.timeline.find(e => e.getId() === "$flibble:localhost")).toBeTruthy(); + expect(data.results[0].context.getTimeline()).toHaveLength(1); + expect( + data.results[0].context.getTimeline().find(e => e.getId() === "$flibble:localhost"), + ).toBeTruthy(); }); it("filters out main timeline events when result is a thread reply", () => { @@ -426,10 +448,13 @@ describe("MatrixClient", function() { search_categories: { room_events: { count: 24, + highlights: [], results: [{ rank: 0.1, result: { event_id: "$flibble:localhost", + sender: '@test:locahost', + origin_server_ts: 123, type: "m.room.message", user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", @@ -445,6 +470,8 @@ describe("MatrixClient", function() { context: { events_after: [{ event_id: "$ev-after:server", + sender: '@test:locahost', + origin_server_ts: 123, type: "m.room.message", user_id: "@alice:localhost", room_id: "!feuiwhf:localhost", @@ -454,21 +481,24 @@ describe("MatrixClient", function() { }, }], events_before: [], + profile_info: {}, }, }], }, }, }; - const data = { + const data: ISearchResults = { results: [], highlights: [], }; - client.processRoomEventsSearch(data, response); + client!.processRoomEventsSearch(data, response); expect(data.results).toHaveLength(1); - expect(data.results[0].context.timeline).toHaveLength(1); - expect(data.results[0].context.timeline.find(e => e.getId() === "$flibble:localhost")).toBeTruthy(); + expect(data.results[0].context.getTimeline()).toHaveLength(1); + expect( + data.results[0].context.getTimeline().find(e => e.getId() === "$flibble:localhost"), + ).toBeTruthy(); }); }); }); @@ -479,16 +509,16 @@ describe("MatrixClient", function() { } beforeEach(function() { - return client.initCrypto(); + return client!.initCrypto(); }); afterEach(() => { - client.stopClient(); + client!.stopClient(); }); it("should do an HTTP request and then store the keys", function() { const ed25519key = "7wG2lzAqbjcyEkOP7O4gU7ItYcn+chKzh5sT/5r2l78"; - // ed25519key = client.getDeviceEd25519Key(); + // ed25519key = client!.getDeviceEd25519Key(); const borisKeys = { dev1: { algorithms: ["1"], @@ -512,7 +542,7 @@ describe("MatrixClient", function() { keys: { "ed25519:dev2": ed25519key }, signatures: { chaz: { - "ed25519:dev2": + "ed25519:dev2": "FwslH/Q7EYSb7swDJbNB5PSzcbEO1xRRBF1riuijqvL" + "EkrK9/XVN8jl4h7thGuRITQ01siBQnNmMK9t45QfcCQ", }, @@ -528,7 +558,7 @@ describe("MatrixClient", function() { var b = JSON.parse(JSON.stringify(o)); delete(b.signatures); delete(b.unsigned); - return client.crypto.olmDevice.sign(anotherjson.stringify(b)); + return client!.crypto.olmDevice.sign(anotherjson.stringify(b)); }; logger.log("Ed25519: " + ed25519key); @@ -536,7 +566,7 @@ describe("MatrixClient", function() { logger.log("chaz:", sign(chazKeys.dev2)); */ - httpBackend.when("POST", "/keys/query").check(function(req) { + httpBackend!.when("POST", "/keys/query").check(function(req) { expect(req.data).toEqual({ device_keys: { 'boris': [], 'chaz': [], @@ -548,7 +578,7 @@ describe("MatrixClient", function() { }, }); - const prom = client.downloadKeys(["boris", "chaz"]).then(function(res) { + const prom = client!.downloadKeys(["boris", "chaz"]).then(function(res) { assertObjectContains(res.boris.dev1, { verified: 0, // DeviceVerification.UNVERIFIED keys: { "ed25519:dev1": ed25519key }, @@ -564,23 +594,23 @@ describe("MatrixClient", function() { }); }); - httpBackend.flush(); + httpBackend!.flush(''); return prom; }); }); describe("deleteDevice", function() { - const auth = { a: 1 }; + const auth = { identifier: 1 }; it("should pass through an auth dict", function() { - httpBackend.when( + httpBackend!.when( "DELETE", "/_matrix/client/r0/devices/my_device", ).check(function(req) { expect(req.data).toEqual({ auth: auth }); }).respond(200); - const prom = client.deleteDevice("my_device", auth); + const prom = client!.deleteDevice("my_device", auth); - httpBackend.flush(); + httpBackend!.flush(''); return prom; }); }); @@ -588,7 +618,7 @@ describe("MatrixClient", function() { describe("partitionThreadedEvents", function() { let room; beforeEach(() => { - room = new Room("!STrMRsukXHtqQdSeHa:matrix.org", client, userId); + room = new Room("!STrMRsukXHtqQdSeHa:matrix.org", client!, userId); }); it("returns empty arrays when given an empty arrays", function() { @@ -599,7 +629,11 @@ describe("MatrixClient", function() { }); it("copies pre-thread in-timeline vote events onto both timelines", function() { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; const eventPollResponseReference = buildEventPollResponseReference(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); @@ -611,6 +645,7 @@ describe("MatrixClient", function() { eventPollResponseReference, ]; // Vote has no threadId yet + // @ts-ignore private property expect(eventPollResponseReference.threadId).toBeFalsy(); const [timeline, threaded] = room.partitionThreadedEvents(events); @@ -624,7 +659,7 @@ describe("MatrixClient", function() { // The vote event has been copied into the thread const eventRefWithThreadId = withThreadId( eventPollResponseReference, eventPollStartThreadRoot.getId()); - expect(eventRefWithThreadId.threadId).toBeTruthy(); + expect(eventRefWithThreadId.threadRootId).toBeTruthy(); expect(threaded).toEqual([ eventPollStartThreadRoot, @@ -634,7 +669,11 @@ describe("MatrixClient", function() { }); it("copies pre-thread in-timeline reactions onto both timelines", function() { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); @@ -661,7 +700,11 @@ describe("MatrixClient", function() { }); it("copies post-thread in-timeline vote events onto both timelines", function() { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; const eventPollResponseReference = buildEventPollResponseReference(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); @@ -688,7 +731,11 @@ describe("MatrixClient", function() { }); it("copies post-thread in-timeline reactions onto both timelines", function() { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); @@ -715,7 +762,11 @@ describe("MatrixClient", function() { }); it("sends room state events to the main timeline only", function() { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; // This is based on recording the events in a real room: const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); @@ -768,7 +819,11 @@ describe("MatrixClient", function() { }); it("sends redactions of reactions to thread responses to thread timeline only", () => { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; const threadRootEvent = buildEventPollStartThreadRoot(); const eventMessageInThread = buildEventMessageInThread(threadRootEvent); @@ -797,7 +852,11 @@ describe("MatrixClient", function() { }); it("sends reply to reply to thread root outside of thread to main timeline only", () => { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; const threadRootEvent = buildEventPollStartThreadRoot(); const eventMessageInThread = buildEventMessageInThread(threadRootEvent); @@ -826,7 +885,11 @@ describe("MatrixClient", function() { }); it("sends reply to thread responses to main timeline only", () => { - client.clientOpts = { experimentalThreadSupport: true }; + // @ts-ignore setting private property + client!.clientOpts = { + ...defaultClientOpts, + experimentalThreadSupport: true, + }; const threadRootEvent = buildEventPollStartThreadRoot(); const eventMessageInThread = buildEventMessageInThread(threadRootEvent); @@ -860,9 +923,9 @@ describe("MatrixClient", function() { fields: {}, }]; - const prom = client.getThirdpartyUser("irc", {}); - httpBackend.when("GET", "/thirdparty/user/irc").respond(200, response); - await httpBackend.flush(); + const prom = client!.getThirdpartyUser("irc", {}); + httpBackend!.when("GET", "/thirdparty/user/irc").respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -875,9 +938,9 @@ describe("MatrixClient", function() { fields: {}, }]; - const prom = client.getThirdpartyLocation("irc", {}); - httpBackend.when("GET", "/thirdparty/location/irc").respond(200, response); - await httpBackend.flush(); + const prom = client!.getThirdpartyLocation("irc", {}); + httpBackend!.when("GET", "/thirdparty/location/irc").respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -888,10 +951,10 @@ describe("MatrixClient", function() { pushers: [], }; - const prom = client.getPushers(); - httpBackend.when("GET", "/_matrix/client/versions").respond(200, {}); - httpBackend.when("GET", "/pushers").respond(200, response); - await httpBackend.flush(); + const prom = client!.getPushers(); + httpBackend!.when("GET", "/_matrix/client/versions").respond(200, {}); + httpBackend!.when("GET", "/pushers").respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -903,12 +966,12 @@ describe("MatrixClient", function() { left: [], }; - const prom = client.getKeyChanges("old", "new"); - httpBackend.when("GET", "/keys/changes").check((req) => { - expect(req.queryParams.from).toEqual("old"); - expect(req.queryParams.to).toEqual("new"); + const prom = client!.getKeyChanges("old", "new"); + httpBackend!.when("GET", "/keys/changes").check((req) => { + expect(req.queryParams?.from).toEqual("old"); + expect(req.queryParams?.to).toEqual("new"); }).respond(200, response); - await httpBackend.flush(); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -919,9 +982,9 @@ describe("MatrixClient", function() { devices: [], }; - const prom = client.getDevices(); - httpBackend.when("GET", "/devices").respond(200, response); - await httpBackend.flush(); + const prom = client!.getDevices(); + httpBackend!.when("GET", "/devices").respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -935,9 +998,9 @@ describe("MatrixClient", function() { last_seen_ts: 1, }; - const prom = client.getDevice("DEADBEEF"); - httpBackend.when("GET", "/devices/DEADBEEF").respond(200, response); - await httpBackend.flush(); + const prom = client!.getDevice("DEADBEEF"); + httpBackend!.when("GET", "/devices/DEADBEEF").respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -948,9 +1011,9 @@ describe("MatrixClient", function() { threepids: [], }; - const prom = client.getThreePids(); - httpBackend.when("GET", "/account/3pid").respond(200, response); - await httpBackend.flush(); + const prom = client!.getThreePids(); + httpBackend!.when("GET", "/account/3pid").respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -958,9 +1021,9 @@ describe("MatrixClient", function() { describe("deleteAlias", () => { it("should hit the expected API endpoint", async () => { const response = {}; - const prom = client.deleteAlias("#foo:bar"); - httpBackend.when("DELETE", "/directory/room/" + encodeURIComponent("#foo:bar")).respond(200, response); - await httpBackend.flush(); + const prom = client!.deleteAlias("#foo:bar"); + httpBackend!.when("DELETE", "/directory/room/" + encodeURIComponent("#foo:bar")).respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -968,10 +1031,10 @@ describe("MatrixClient", function() { describe("deleteRoomTag", () => { it("should hit the expected API endpoint", async () => { const response = {}; - const prom = client.deleteRoomTag("!roomId:server", "u.tag"); + const prom = client!.deleteRoomTag("!roomId:server", "u.tag"); const url = `/user/${encodeURIComponent(userId)}/rooms/${encodeURIComponent("!roomId:server")}/tags/u.tag`; - httpBackend.when("DELETE", url).respond(200, response); - await httpBackend.flush(); + httpBackend!.when("DELETE", url).respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -986,10 +1049,10 @@ describe("MatrixClient", function() { }, }; - const prom = client.getRoomTags("!roomId:server"); + const prom = client!.getRoomTags("!roomId:server"); const url = `/user/${encodeURIComponent(userId)}/rooms/${encodeURIComponent("!roomId:server")}/tags`; - httpBackend.when("GET", url).respond(200, response); - await httpBackend.flush(); + httpBackend!.when("GET", url).respond(200, response); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -1001,19 +1064,19 @@ describe("MatrixClient", function() { submit_url: "https://foobar.matrix/_matrix/matrix", }; - httpBackend.when("GET", "/_matrix/client/versions").respond(200, { + httpBackend!.when("GET", "/_matrix/client/versions").respond(200, { versions: ["r0.6.0"], }); - const prom = client.requestRegisterEmailToken("bob@email", "secret", 1); - httpBackend.when("POST", "/register/email/requestToken").check(req => { + const prom = client!.requestRegisterEmailToken("bob@email", "secret", 1); + httpBackend!.when("POST", "/register/email/requestToken").check(req => { expect(req.data).toStrictEqual({ email: "bob@email", client_secret: "secret", send_attempt: 1, }); }).respond(200, response); - await httpBackend.flush(); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -1022,11 +1085,11 @@ describe("MatrixClient", function() { it("should supply an id_access_token", async () => { const targetEmail = "gerald@example.org"; - httpBackend.when("GET", "/_matrix/client/versions").respond(200, { + httpBackend!.when("GET", "/_matrix/client/versions").respond(200, { versions: ["r0.6.0"], }); - httpBackend.when("POST", "/invite").check(req => { + httpBackend!.when("POST", "/invite").check(req => { expect(req.data).toStrictEqual({ id_server: idServerDomain, id_access_token: identityAccessToken, @@ -1035,8 +1098,8 @@ describe("MatrixClient", function() { }); }).respond(200, {}); - const prom = client.inviteByThreePid("!room:example.org", "email", targetEmail); - await httpBackend.flush(); + const prom = client!.inviteByThreePid("!room:example.org", "email", targetEmail); + await httpBackend!.flush(''); await prom; // returns empty object, so no validation needed }); }); @@ -1056,11 +1119,11 @@ describe("MatrixClient", function() { }], }; - httpBackend.when("GET", "/_matrix/client/versions").respond(200, { + httpBackend!.when("GET", "/_matrix/client/versions").respond(200, { versions: ["r0.6.0"], }); - httpBackend.when("POST", "/createRoom").check(req => { + httpBackend!.when("POST", "/createRoom").check(req => { expect(req.data).toMatchObject({ invite_3pid: expect.arrayContaining([{ ...input.invite_3pid[0], @@ -1070,8 +1133,8 @@ describe("MatrixClient", function() { expect(req.data.invite_3pid.length).toBe(1); }).respond(200, response); - const prom = client.createRoom(input); - await httpBackend.flush(); + const prom = client!.createRoom(input); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); @@ -1079,34 +1142,169 @@ describe("MatrixClient", function() { describe("requestLoginToken", () => { it("should hit the expected API endpoint with UIA", async () => { const response = {}; - const uiaData = { foo: "baa" }; - const prom = client.requestLoginToken(uiaData); - httpBackend + const uiaData = {}; + const prom = client!.requestLoginToken(uiaData); + 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 + const prom = client!.requestLoginToken(); + httpBackend! .when("POST", "/unstable/org.matrix.msc3882/login/token", {}) .respond(200, response); - await httpBackend.flush(); + await httpBackend!.flush(''); expect(await prom).toStrictEqual(response); }); }); + + describe("logout", () => { + it("should abort pending requests when called with stopClient=true", async () => { + httpBackend.when("POST", "/logout").respond(200, {}); + const fn = jest.fn(); + client.http.request(Method.Get, "/test").catch(fn); + client.logout(true); + await httpBackend.flush(undefined); + expect(fn).toHaveBeenCalled(); + }); + }); + + 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" }); + const prom = client.sendHtmlEmote("!room:server", "Body", "

Body

"); + await httpBackend.flush(undefined); + await expect(prom).resolves.toStrictEqual({ event_id: "$foobar" }); + }); + }); + + 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" }); + const prom = client.sendHtmlMessage("!room:server", "Body", "

Body

"); + await httpBackend.flush(undefined); + await expect(prom).resolves.toStrictEqual({ event_id: "$foobar" }); + }); + }); + + describe("forget", () => { + it("should remove from store by default", async () => { + const room = new Room("!roomId:server", client, userId); + client.store.storeRoom(room); + expect(client.store.getRooms()).toContain(room); + + httpBackend.when("POST", "/forget").respond(200, {}); + await Promise.all([ + client.forget(room.roomId), + httpBackend.flushAllExpected(), + ]); + expect(client.store.getRooms()).not.toContain(room); + }); + }); + + describe("getCapabilities", () => { + it("should cache by default", async () => { + httpBackend!.when("GET", "/capabilities").respond(200, { + capabilities: { + "m.change_password": false, + }, + }); + const prom = httpBackend!.flushAllExpected(); + const capabilities1 = await client!.getCapabilities(); + const capabilities2 = await client!.getCapabilities(); + await prom; + + expect(capabilities1).toStrictEqual(capabilities2); + }); + }); + + describe("getTerms", () => { + it("should return Identity Server terms", async () => { + httpBackend!.when("GET", "/terms").respond(200, { foo: "bar" }); + const prom = client!.getTerms(SERVICE_TYPES.IS, "http://identity.server"); + await httpBackend!.flushAllExpected(); + await expect(prom).resolves.toEqual({ foo: "bar" }); + }); + + it("should return Integrations Manager terms", async () => { + httpBackend!.when("GET", "/terms").respond(200, { foo: "bar" }); + const prom = client!.getTerms(SERVICE_TYPES.IM, "http://im.server"); + await httpBackend!.flushAllExpected(); + await expect(prom).resolves.toEqual({ foo: "bar" }); + }); + }); + + describe("publicRooms", () => { + it("should use GET request if no server or filter is specified", () => { + httpBackend!.when("GET", "/publicRooms").respond(200, {}); + client!.publicRooms({}); + return httpBackend!.flushAllExpected(); + }); + + it("should use GET request if only server is specified", () => { + 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, {}); + client!.publicRooms({ filter: { generic_search_term: "foobar" } }); + return httpBackend!.flushAllExpected(); + }); + }); + + describe("login", () => { + it("should persist values to the client opts", async () => { + const token = "!token&"; + const userId = "@m:t"; + + httpBackend!.when("POST", "/login").respond(200, { + access_token: token, + user_id: userId, + }); + const prom = client!.login("fake.login", {}); + await httpBackend!.flushAllExpected(); + const resp = await prom; + expect(resp.access_token).toBe(token); + expect(resp.user_id).toBe(userId); + expect(client.getUserId()).toBe(userId); + expect(client.http.opts.accessToken).toBe(token); + }); + }); }); -function withThreadId(event, newThreadId) { +function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent { const ret = event.toSnapshot(); ret.setThreadId(newThreadId); return ret; } -const buildEventMessageInThread = (root) => new MatrixEvent({ +const buildEventMessageInThread = (root: MatrixEvent) => new MatrixEvent({ "age": 80098509, "content": { "algorithm": "m.megolm.v1.aes-sha2", @@ -1153,7 +1351,7 @@ const buildEventPollResponseReference = () => new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const buildEventReaction = (event) => new MatrixEvent({ +const buildEventReaction = (event: MatrixEvent) => new MatrixEvent({ "content": { "m.relates_to": { "event_id": event.getId(), @@ -1172,7 +1370,7 @@ const buildEventReaction = (event) => new MatrixEvent({ "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", }); -const buildEventRedaction = (event) => new MatrixEvent({ +const buildEventRedaction = (event: MatrixEvent) => new MatrixEvent({ "content": { }, @@ -1206,7 +1404,7 @@ const buildEventPollStartThreadRoot = () => new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const buildEventReply = (target) => new MatrixEvent({ +const buildEventReply = (target: MatrixEvent) => new MatrixEvent({ "age": 80098509, "content": { "algorithm": "m.megolm.v1.aes-sha2", @@ -1372,7 +1570,7 @@ const buildEventCreate = () => new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -function assertObjectContains(obj, expected) { +function assertObjectContains(obj: object, 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.js b/spec/integ/matrix-client-opts.spec.ts similarity index 93% rename from spec/integ/matrix-client-opts.spec.js rename to spec/integ/matrix-client-opts.spec.ts index 8e342b259..5ea4fba77 100644 --- a/spec/integ/matrix-client-opts.spec.js +++ b/spec/integ/matrix-client-opts.spec.ts @@ -5,10 +5,11 @@ import { 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() { const baseUrl = "http://localhost.or.something"; - let httpBackend = null; + let httpBackend = new HttpBackend(); const userId = "@alice:localhost"; const userB = "@bob:localhost"; const accessToken = "aseukfgwef"; @@ -67,7 +68,7 @@ describe("MatrixClient opts", function() { let client; beforeEach(function() { client = new MatrixClient({ - request: httpBackend.requestFn, + fetchFn: httpBackend.fetchFn as typeof global.fetch, store: undefined, baseUrl: baseUrl, userId: userId, @@ -99,7 +100,7 @@ describe("MatrixClient opts", function() { ]; client.on("event", function(event) { expect(expectedEventTypes.indexOf(event.getType())).not.toEqual( - -1, "Recv unexpected event type: " + event.getType(), + -1, ); expectedEventTypes.splice( expectedEventTypes.indexOf(event.getType()), 1, @@ -118,7 +119,7 @@ describe("MatrixClient opts", function() { utils.syncPromise(client), ]); expect(expectedEventTypes.length).toEqual( - 0, "Expected to see event types: " + expectedEventTypes, + 0, ); }); }); @@ -127,8 +128,8 @@ describe("MatrixClient opts", function() { let client; beforeEach(function() { client = new MatrixClient({ - request: httpBackend.requestFn, - store: new MemoryStore(), + fetchFn: httpBackend.fetchFn as typeof global.fetch, + store: new MemoryStore() as IStore, baseUrl: baseUrl, userId: userId, accessToken: accessToken, @@ -141,12 +142,12 @@ describe("MatrixClient opts", function() { }); it("shouldn't retry sending events", function(done) { - httpBackend.when("PUT", "/txn1").fail(500, new MatrixError({ + 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, "sendTextMessage resolved but shouldn't"); + expect(false).toBe(true); }, function(err) { expect(err.errcode).toEqual("M_SOMETHING"); done(); diff --git a/spec/integ/matrix-client-relations.spec.ts b/spec/integ/matrix-client-relations.spec.ts new file mode 100644 index 000000000..3a8a99fbf --- /dev/null +++ b/spec/integ/matrix-client-relations.spec.ts @@ -0,0 +1,127 @@ +/* +Copyright 2022 Dominik Henneke +Copyright 2022 Nordeck IT + Consulting GmbH. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import HttpBackend from "matrix-mock-request"; + +import { Direction, MatrixClient, MatrixScheduler } from "../../src/matrix"; +import { TestClient } from "../TestClient"; + +describe("MatrixClient relations", () => { + const userId = "@alice:localhost"; + const accessToken = "aseukfgwef"; + const roomId = "!room:here"; + let client: MatrixClient | undefined; + let httpBackend: HttpBackend | undefined; + + const setupTests = (): [MatrixClient, HttpBackend] => { + const scheduler = new MatrixScheduler(); + const testClient = new TestClient( + userId, + "DEVICE", + accessToken, + undefined, + { scheduler }, + ); + const httpBackend = testClient.httpBackend; + const client = testClient.client; + + return [client, httpBackend]; + }; + + beforeEach(() => { + [client, httpBackend] = setupTests(); + }); + + afterEach(() => { + httpBackend!.verifyNoOutstandingExpectation(); + return httpBackend!.stop(); + }); + + it("should read related events with the default options", async () => { + const response = client!.relations(roomId, '$event-0', null, null); + + httpBackend! + .when("GET", "/rooms/!room%3Ahere/relations/%24event-0?dir=b") + .respond(200, { chunk: [], next_batch: 'NEXT' }); + + await httpBackend!.flushAllExpected(); + + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + }); + + it("should read related events with relation type", async () => { + const response = client!.relations(roomId, '$event-0', 'm.reference', null); + + httpBackend! + .when("GET", "/rooms/!room%3Ahere/relations/%24event-0/m.reference?dir=b") + .respond(200, { chunk: [], next_batch: 'NEXT' }); + + await httpBackend!.flushAllExpected(); + + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + }); + + it("should read related events with relation type and event type", async () => { + const response = client!.relations(roomId, '$event-0', 'm.reference', 'm.room.message'); + + httpBackend! + .when( + "GET", + "/rooms/!room%3Ahere/relations/%24event-0/m.reference/m.room.message?dir=b", + ) + .respond(200, { chunk: [], next_batch: 'NEXT' }); + + await httpBackend!.flushAllExpected(); + + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + }); + + it("should read related events with custom options", async () => { + const response = client!.relations(roomId, '$event-0', null, null, { + dir: Direction.Forward, + from: 'FROM', + limit: 10, + to: 'TO', + }); + + httpBackend! + .when( + "GET", + "/rooms/!room%3Ahere/relations/%24event-0?dir=f&from=FROM&limit=10&to=TO", + ) + .respond(200, { chunk: [], next_batch: 'NEXT' }); + + await httpBackend!.flushAllExpected(); + + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + }); + + it('should use default direction in the fetchRelations endpoint', async () => { + const response = client!.fetchRelations(roomId, '$event-0', null, null); + + httpBackend! + .when( + "GET", + "/rooms/!room%3Ahere/relations/%24event-0?dir=b", + ) + .respond(200, { chunk: [], next_batch: 'NEXT' }); + + await httpBackend!.flushAllExpected(); + + expect(await response).toEqual({ "chunk": [], "next_batch": "NEXT" }); + }); +}); diff --git a/spec/integ/matrix-client-retrying.spec.ts b/spec/integ/matrix-client-retrying.spec.ts index 31354b89a..877e80ac9 100644 --- a/spec/integ/matrix-client-retrying.spec.ts +++ b/spec/integ/matrix-client-retrying.spec.ts @@ -14,22 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventStatus, RoomEvent, MatrixClient } from "../../src/matrix"; -import { MatrixScheduler } from "../../src/scheduler"; +import HttpBackend from "matrix-mock-request"; + +import { EventStatus, RoomEvent, MatrixClient, MatrixScheduler } from "../../src/matrix"; import { Room } from "../../src/models/room"; import { TestClient } from "../TestClient"; describe("MatrixClient retrying", function() { - let client: MatrixClient = null; - let httpBackend: TestClient["httpBackend"] = null; - let scheduler; const userId = "@alice:localhost"; const accessToken = "aseukfgwef"; const roomId = "!room:here"; - let room: Room; + let client: MatrixClient | undefined; + let httpBackend: HttpBackend | undefined; + let room: Room | undefined; - beforeEach(function() { - scheduler = new MatrixScheduler(); + const setupTests = (): [MatrixClient, HttpBackend, Room] => { + const scheduler = new MatrixScheduler(); const testClient = new TestClient( userId, "DEVICE", @@ -37,15 +37,21 @@ describe("MatrixClient retrying", function() { undefined, { scheduler }, ); - httpBackend = testClient.httpBackend; - client = testClient.client; - room = new Room(roomId, client, userId); - client.store.storeRoom(room); + const httpBackend = testClient.httpBackend; + const client = testClient.client; + const room = new Room(roomId, client, userId); + client!.store.storeRoom(room); + + return [client, httpBackend, room]; + }; + + beforeEach(function() { + [client, httpBackend, room] = setupTests(); }); afterEach(function() { - httpBackend.verifyNoOutstandingExpectation(); - return httpBackend.stop(); + httpBackend!.verifyNoOutstandingExpectation(); + return httpBackend!.stop(); }); xit("should retry according to MatrixScheduler.retryFn", function() { @@ -66,7 +72,7 @@ describe("MatrixClient retrying", function() { it("should mark events as EventStatus.CANCELLED when cancelled", function() { // send a couple of events; the second will be queued - const p1 = client.sendMessage(roomId, { + const p1 = client!.sendMessage(roomId, { "msgtype": "m.text", "body": "m1", }).then(function() { @@ -79,13 +85,13 @@ describe("MatrixClient retrying", function() { // XXX: it turns out that the promise returned by this message // never gets resolved. // https://github.com/matrix-org/matrix-js-sdk/issues/496 - client.sendMessage(roomId, { + client!.sendMessage(roomId, { "msgtype": "m.text", "body": "m2", }); // both events should be in the timeline at this point - const tl = room.getLiveTimeline().getEvents(); + const tl = room!.getLiveTimeline().getEvents(); expect(tl.length).toEqual(2); const ev1 = tl[0]; const ev2 = tl[1]; @@ -94,24 +100,24 @@ describe("MatrixClient retrying", function() { expect(ev2.status).toEqual(EventStatus.SENDING); // the first message should get sent, and the second should get queued - httpBackend.when("PUT", "/send/m.room.message/").check(function() { + httpBackend!.when("PUT", "/send/m.room.message/").check(function() { // ev2 should now have been queued expect(ev2.status).toEqual(EventStatus.QUEUED); // now we can cancel the second and check everything looks sane - client.cancelPendingEvent(ev2); + client!.cancelPendingEvent(ev2); expect(ev2.status).toEqual(EventStatus.CANCELLED); expect(tl.length).toEqual(1); // shouldn't be able to cancel the first message yet expect(function() { - client.cancelPendingEvent(ev1); + client!.cancelPendingEvent(ev1); }).toThrow(); }).respond(400); // fail the first message // wait for the localecho of ev1 to be updated const p3 = new Promise((resolve, reject) => { - room.on(RoomEvent.LocalEchoUpdated, (ev0) => { + room!.on(RoomEvent.LocalEchoUpdated, (ev0) => { if (ev0 === ev1) { resolve(); } @@ -121,7 +127,7 @@ describe("MatrixClient retrying", function() { expect(tl.length).toEqual(1); // cancel the first message - client.cancelPendingEvent(ev1); + client!.cancelPendingEvent(ev1); expect(ev1.status).toEqual(EventStatus.CANCELLED); expect(tl.length).toEqual(0); }); @@ -129,7 +135,7 @@ describe("MatrixClient retrying", function() { return Promise.all([ p1, p3, - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), ]); }); diff --git a/spec/integ/matrix-client-room-timeline.spec.js b/spec/integ/matrix-client-room-timeline.spec.ts similarity index 75% rename from spec/integ/matrix-client-room-timeline.spec.js rename to spec/integ/matrix-client-room-timeline.spec.ts index acf751a8c..42d90d91c 100644 --- a/spec/integ/matrix-client-room-timeline.spec.js +++ b/spec/integ/matrix-client-room-timeline.spec.ts @@ -1,16 +1,35 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import HttpBackend from "matrix-mock-request"; + import * as utils from "../test-utils/test-utils"; import { EventStatus } from "../../src/models/event"; -import { RoomEvent } from "../../src"; +import { MatrixError, ClientEvent, IEvent, MatrixClient, RoomEvent } from "../../src"; import { TestClient } from "../TestClient"; describe("MatrixClient room timelines", function() { - let client = null; - let httpBackend = null; const userId = "@alice:localhost"; const userName = "Alice"; const accessToken = "aseukfgwef"; const roomId = "!foo:bar"; const otherUserId = "@bob:localhost"; + let client: MatrixClient | undefined; + let httpBackend: HttpBackend | undefined; + const USER_MEMBERSHIP_EVENT = utils.mkMembership({ room: roomId, mship: "join", user: userId, name: userName, }); @@ -55,8 +74,7 @@ describe("MatrixClient room timelines", function() { }, }; - function setNextSyncData(events) { - events = events || []; + function setNextSyncData(events: Partial[] = []) { NEXT_SYNC_DATA = { next_batch: "n", presence: { events: [] }, @@ -77,19 +95,9 @@ describe("MatrixClient room timelines", function() { throw new Error("setNextSyncData only works with one room id"); } if (e.state_key) { - if (e.__prev_event === undefined) { - throw new Error( - "setNextSyncData needs the prev state set to '__prev_event' " + - "for " + e.type, - ); - } - if (e.__prev_event !== null) { - // push the previous state for this event type - NEXT_SYNC_DATA.rooms.join[roomId].state.events.push(e.__prev_event); - } // push the current NEXT_SYNC_DATA.rooms.join[roomId].timeline.events.push(e); - } else if (["m.typing", "m.receipt"].indexOf(e.type) !== -1) { + } else if (["m.typing", "m.receipt"].indexOf(e.type!) !== -1) { NEXT_SYNC_DATA.rooms.join[roomId].ephemeral.events.push(e); } else { NEXT_SYNC_DATA.rooms.join[roomId].timeline.events.push(e); @@ -97,7 +105,7 @@ describe("MatrixClient room timelines", function() { }); } - beforeEach(async function() { + const setupTestClient = (): [MatrixClient, HttpBackend] => { // these tests should work with or without timelineSupport const testClient = new TestClient( userId, @@ -106,41 +114,46 @@ describe("MatrixClient room timelines", function() { undefined, { timelineSupport: true }, ); - httpBackend = testClient.httpBackend; - client = testClient.client; + const httpBackend = testClient.httpBackend; + const client = testClient.client; setNextSyncData(); - httpBackend.when("GET", "/versions").respond(200, {}); - httpBackend.when("GET", "/pushrules").respond(200, {}); - httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); - httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); - httpBackend.when("GET", "/sync").respond(200, function() { + httpBackend!.when("GET", "/versions").respond(200, {}); + httpBackend!.when("GET", "/pushrules").respond(200, {}); + httpBackend!.when("POST", "/filter").respond(200, { filter_id: "fid" }); + httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, function() { return NEXT_SYNC_DATA; }); - client.startClient(); + client!.startClient(); + return [client!, httpBackend]; + }; + + beforeEach(async function() { + [client!, httpBackend] = setupTestClient(); await httpBackend.flush("/versions"); await httpBackend.flush("/pushrules"); await httpBackend.flush("/filter"); }); afterEach(function() { - httpBackend.verifyNoOutstandingExpectation(); - client.stopClient(); - return httpBackend.stop(); + httpBackend!.verifyNoOutstandingExpectation(); + client!.stopClient(); + return httpBackend!.stop(); }); describe("local echo events", function() { it("should be added immediately after calling MatrixClient.sendEvent " + "with EventStatus.SENDING and the right event.sender", function(done) { - client.on("sync", function(state) { + client!.on(ClientEvent.Sync, function(state) { if (state !== "PREPARED") { return; } - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; expect(room.timeline.length).toEqual(1); - client.sendTextMessage(roomId, "I am a fish", "txn1"); + client!.sendTextMessage(roomId, "I am a fish", "txn1"); // check it was added expect(room.timeline.length).toEqual(2); // check status @@ -150,68 +163,68 @@ describe("MatrixClient room timelines", function() { expect(member.userId).toEqual(userId); expect(member.name).toEqual(userName); - httpBackend.flush("/sync", 1).then(function() { + httpBackend!.flush("/sync", 1).then(function() { done(); }); }); - httpBackend.flush("/sync", 1); + httpBackend!.flush("/sync", 1); }); it("should be updated correctly when the send request finishes " + "BEFORE the event comes down the event stream", function(done) { const eventId = "$foo:bar"; - httpBackend.when("PUT", "/txn1").respond(200, { + httpBackend!.when("PUT", "/txn1").respond(200, { event_id: eventId, }); const ev = utils.mkMessage({ - body: "I am a fish", user: userId, room: roomId, + msg: "I am a fish", user: userId, room: roomId, }); ev.event_id = eventId; ev.unsigned = { transaction_id: "txn1" }; setNextSyncData([ev]); - client.on("sync", function(state) { + client!.on(ClientEvent.Sync, function(state) { if (state !== "PREPARED") { return; } - const room = client.getRoom(roomId); - client.sendTextMessage(roomId, "I am a fish", "txn1").then( - function() { - expect(room.timeline[1].getId()).toEqual(eventId); - httpBackend.flush("/sync", 1).then(function() { + const room = client!.getRoom(roomId)!; + client!.sendTextMessage(roomId, "I am a fish", "txn1").then( + function() { expect(room.timeline[1].getId()).toEqual(eventId); - done(); + httpBackend!.flush("/sync", 1).then(function() { + expect(room.timeline[1].getId()).toEqual(eventId); + done(); + }); }); - }); - httpBackend.flush("/txn1", 1); + httpBackend!.flush("/txn1", 1); }); - httpBackend.flush("/sync", 1); + httpBackend!.flush("/sync", 1); }); it("should be updated correctly when the send request finishes " + "AFTER the event comes down the event stream", function(done) { const eventId = "$foo:bar"; - httpBackend.when("PUT", "/txn1").respond(200, { + httpBackend!.when("PUT", "/txn1").respond(200, { event_id: eventId, }); const ev = utils.mkMessage({ - body: "I am a fish", user: userId, room: roomId, + msg: "I am a fish", user: userId, room: roomId, }); ev.event_id = eventId; ev.unsigned = { transaction_id: "txn1" }; setNextSyncData([ev]); - client.on("sync", function(state) { + client!.on(ClientEvent.Sync, function(state) { if (state !== "PREPARED") { return; } - const room = client.getRoom(roomId); - const promise = client.sendTextMessage(roomId, "I am a fish", "txn1"); - httpBackend.flush("/sync", 1).then(function() { + const room = client!.getRoom(roomId)!; + const promise = client!.sendTextMessage(roomId, "I am a fish", "txn1"); + httpBackend!.flush("/sync", 1).then(function() { expect(room.timeline.length).toEqual(2); - httpBackend.flush("/txn1", 1); + httpBackend!.flush("/txn1", 1); promise.then(function() { expect(room.timeline.length).toEqual(2); expect(room.timeline[1].getId()).toEqual(eventId); @@ -219,7 +232,7 @@ describe("MatrixClient room timelines", function() { }); }); }); - httpBackend.flush("/sync", 1); + httpBackend!.flush("/sync", 1); }); }); @@ -229,7 +242,7 @@ describe("MatrixClient room timelines", function() { beforeEach(function() { sbEvents = []; - httpBackend.when("GET", "/messages").respond(200, function() { + httpBackend!.when("GET", "/messages").respond(200, function() { return { chunk: sbEvents, start: "pagin_start", @@ -240,26 +253,26 @@ describe("MatrixClient room timelines", function() { it("should set Room.oldState.paginationToken to null at the start" + " of the timeline.", function(done) { - client.on("sync", function(state) { + client!.on(ClientEvent.Sync, function(state) { if (state !== "PREPARED") { return; } - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; expect(room.timeline.length).toEqual(1); - client.scrollback(room).then(function() { + client!.scrollback(room).then(function() { expect(room.timeline.length).toEqual(1); expect(room.oldState.paginationToken).toBe(null); // still have a sync to flush - httpBackend.flush("/sync", 1).then(() => { + httpBackend!.flush("/sync", 1).then(() => { done(); }); }); - httpBackend.flush("/messages", 1); + httpBackend!.flush("/messages", 1); }); - httpBackend.flush("/sync", 1); + httpBackend!.flush("/sync", 1); }); it("should set the right event.sender values", function(done) { @@ -275,7 +288,7 @@ describe("MatrixClient room timelines", function() { // make an m.room.member event for alice's join const joinMshipEvent = utils.mkMembership({ mship: "join", user: userId, room: roomId, name: "Old Alice", - url: null, + url: undefined, }); // make an m.room.member event with prev_content for alice's nick @@ -286,7 +299,7 @@ describe("MatrixClient room timelines", function() { }); oldMshipEvent.prev_content = { displayname: "Old Alice", - avatar_url: null, + avatar_url: undefined, membership: "join", }; @@ -303,15 +316,15 @@ describe("MatrixClient room timelines", function() { joinMshipEvent, ]; - client.on("sync", function(state) { + client!.on(ClientEvent.Sync, function(state) { if (state !== "PREPARED") { return; } - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; // sync response expect(room.timeline.length).toEqual(1); - client.scrollback(room).then(function() { + client!.scrollback(room).then(function() { expect(room.timeline.length).toEqual(5); const joinMsg = room.timeline[0]; expect(joinMsg.sender.name).toEqual("Old Alice"); @@ -321,14 +334,14 @@ describe("MatrixClient room timelines", function() { expect(newMsg.sender.name).toEqual(userName); // still have a sync to flush - httpBackend.flush("/sync", 1).then(() => { + httpBackend!.flush("/sync", 1).then(() => { done(); }); }); - httpBackend.flush("/messages", 1); + httpBackend!.flush("/messages", 1); }); - httpBackend.flush("/sync", 1); + httpBackend!.flush("/sync", 1); }); it("should add it them to the right place in the timeline", function(done) { @@ -342,27 +355,27 @@ describe("MatrixClient room timelines", function() { }), ]; - client.on("sync", function(state) { + client!.on(ClientEvent.Sync, function(state) { if (state !== "PREPARED") { return; } - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; expect(room.timeline.length).toEqual(1); - client.scrollback(room).then(function() { + client!.scrollback(room).then(function() { expect(room.timeline.length).toEqual(3); expect(room.timeline[0].event).toEqual(sbEvents[1]); expect(room.timeline[1].event).toEqual(sbEvents[0]); // still have a sync to flush - httpBackend.flush("/sync", 1).then(() => { + httpBackend!.flush("/sync", 1).then(() => { done(); }); }); - httpBackend.flush("/messages", 1); + httpBackend!.flush("/messages", 1); }); - httpBackend.flush("/sync", 1); + httpBackend!.flush("/sync", 1); }); it("should use 'end' as the next pagination token", function(done) { @@ -373,25 +386,25 @@ describe("MatrixClient room timelines", function() { }), ]; - client.on("sync", function(state) { + client!.on(ClientEvent.Sync, function(state) { if (state !== "PREPARED") { return; } - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; expect(room.oldState.paginationToken).toBeTruthy(); - client.scrollback(room, 1).then(function() { + client!.scrollback(room, 1).then(function() { expect(room.oldState.paginationToken).toEqual(sbEndTok); }); - httpBackend.flush("/messages", 1).then(function() { + httpBackend!.flush("/messages", 1).then(function() { // still have a sync to flush - httpBackend.flush("/sync", 1).then(() => { + httpBackend!.flush("/sync", 1).then(() => { done(); }); }); }); - httpBackend.flush("/sync", 1); + httpBackend!.flush("/sync", 1); }); }); @@ -404,23 +417,23 @@ describe("MatrixClient room timelines", function() { setNextSyncData(eventData); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(() => { - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; let index = 0; - client.on("Room.timeline", function(event, rm, toStart) { + client!.on(RoomEvent.Timeline, function(event, rm, toStart) { expect(toStart).toBe(false); expect(rm).toEqual(room); expect(event.event).toEqual(eventData[index]); index += 1; }); - httpBackend.flush("/messages", 1); + httpBackend!.flush("/messages", 1); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(function() { expect(index).toEqual(2); expect(room.timeline.length).toEqual(3); @@ -442,17 +455,16 @@ describe("MatrixClient room timelines", function() { }), utils.mkMessage({ user: userId, room: roomId }), ]; - eventData[1].__prev_event = USER_MEMBERSHIP_EVENT; setNextSyncData(eventData); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(() => { - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(function() { const preNameEvent = room.timeline[room.timeline.length - 3]; const postNameEvent = room.timeline[room.timeline.length - 1]; @@ -468,22 +480,21 @@ describe("MatrixClient room timelines", function() { name: "Room 2", }, }); - secondRoomNameEvent.__prev_event = ROOM_NAME_EVENT; setNextSyncData([secondRoomNameEvent]); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(() => { - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; let nameEmitCount = 0; - client.on("Room.name", function(rm) { + client!.on(RoomEvent.Name, function(rm) { nameEmitCount += 1; }); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(function() { expect(nameEmitCount).toEqual(1); expect(room.name).toEqual("Room 2"); @@ -493,12 +504,11 @@ describe("MatrixClient room timelines", function() { name: "Room 3", }, }); - thirdRoomNameEvent.__prev_event = secondRoomNameEvent; setNextSyncData([thirdRoomNameEvent]); - httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]); }).then(function() { expect(nameEmitCount).toEqual(2); @@ -518,26 +528,24 @@ describe("MatrixClient room timelines", function() { user: userC, room: roomId, mship: "invite", skey: userD, }), ]; - eventData[0].__prev_event = null; - eventData[1].__prev_event = null; setNextSyncData(eventData); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(() => { - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(function() { expect(room.currentState.getMembers().length).toEqual(4); - expect(room.currentState.getMember(userC).name).toEqual("C"); - expect(room.currentState.getMember(userC).membership).toEqual( + expect(room.currentState.getMember(userC)!.name).toEqual("C"); + expect(room.currentState.getMember(userC)!.membership).toEqual( "join", ); - expect(room.currentState.getMember(userD).name).toEqual(userD); - expect(room.currentState.getMember(userD).membership).toEqual( + expect(room.currentState.getMember(userD)!.name).toEqual(userD); + expect(room.currentState.getMember(userD)!.membership).toEqual( "invite", ); }); @@ -554,26 +562,26 @@ describe("MatrixClient room timelines", function() { NEXT_SYNC_DATA.rooms.join[roomId].timeline.limited = true; return Promise.all([ - httpBackend.flush("/versions", 1), - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/versions", 1), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(() => { - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; - httpBackend.flush("/messages", 1); + httpBackend!.flush("/messages", 1); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(function() { expect(room.timeline.length).toEqual(1); expect(room.timeline[0].event).toEqual(eventData[0]); expect(room.currentState.getMembers().length).toEqual(2); - expect(room.currentState.getMember(userId).name).toEqual(userName); - expect(room.currentState.getMember(userId).membership).toEqual( + expect(room.currentState.getMember(userId)!.name).toEqual(userName); + expect(room.currentState.getMember(userId)!.membership).toEqual( "join", ); - expect(room.currentState.getMember(otherUserId).name).toEqual("Bob"); - expect(room.currentState.getMember(otherUserId).membership).toEqual( + expect(room.currentState.getMember(otherUserId)!.name).toEqual("Bob"); + expect(room.currentState.getMember(otherUserId)!.membership).toEqual( "join", ); }); @@ -588,21 +596,21 @@ describe("MatrixClient room timelines", function() { NEXT_SYNC_DATA.rooms.join[roomId].timeline.limited = true; return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(() => { - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; let emitCount = 0; - client.on("Room.timelineReset", function(emitRoom) { + client!.on(RoomEvent.TimelineReset, function(emitRoom) { expect(emitRoom).toEqual(room); emitCount++; }); - httpBackend.flush("/messages", 1); + httpBackend!.flush("/messages", 1); return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!), ]).then(function() { expect(emitCount).toEqual(1); }); @@ -618,7 +626,7 @@ describe("MatrixClient room timelines", function() { ]; const contextUrl = `/rooms/${encodeURIComponent(roomId)}/context/` + - `${encodeURIComponent(initialSyncEventData[2].event_id)}`; + `${encodeURIComponent(initialSyncEventData[2].event_id!)}`; const contextResponse = { start: "start_token", events_before: [initialSyncEventData[1], initialSyncEventData[0]], @@ -636,19 +644,19 @@ describe("MatrixClient room timelines", function() { // Create a room from the sync await Promise.all([ - httpBackend.flushAllExpected(), - utils.syncPromise(client, 1), + httpBackend!.flushAllExpected(), + utils.syncPromise(client!, 1), ]); // Get the room after the first sync so the room is created - room = client.getRoom(roomId); + room = client!.getRoom(roomId)!; expect(room).toBeTruthy(); }); it('should clear and refresh messages in timeline', async () => { // `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()` // to construct a new timeline from. - httpBackend.when("GET", contextUrl) + httpBackend!.when("GET", contextUrl) .respond(200, function() { // The timeline should be cleared at this point in the refresh expect(room.timeline.length).toEqual(0); @@ -659,7 +667,7 @@ describe("MatrixClient room timelines", function() { // Refresh the timeline. await Promise.all([ room.refreshLiveTimeline(), - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), ]); // Make sure the message are visible @@ -681,7 +689,7 @@ describe("MatrixClient room timelines", function() { // middle of all of this refresh timeline logic. We want to make // sure the sync pagination still works as expected after messing // the refresh timline logic messes with the pagination tokens. - httpBackend.when("GET", contextUrl) + httpBackend!.when("GET", contextUrl) .respond(200, () => { // Now finally return and make the `/context` request respond return contextResponse; @@ -700,7 +708,7 @@ describe("MatrixClient room timelines", function() { const racingSyncEventData = [ utils.mkMessage({ user: userId, room: roomId }), ]; - const waitForRaceySyncAfterResetPromise = new Promise((resolve, reject) => { + const waitForRaceySyncAfterResetPromise = new Promise((resolve, reject) => { let eventFired = false; // Throw a more descriptive error if this part of the test times out. const failTimeout = setTimeout(() => { @@ -726,12 +734,12 @@ describe("MatrixClient room timelines", function() { // Then make a `/sync` happen by sending a message and seeing that it // shows up (simulate a /sync naturally racing with us). setNextSyncData(racingSyncEventData); - httpBackend.when("GET", "/sync").respond(200, function() { + httpBackend!.when("GET", "/sync").respond(200, function() { return NEXT_SYNC_DATA; }); await Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client, 1), + httpBackend!.flush("/sync", 1), + utils.syncPromise(client!, 1), ]); // Make sure the timeline has the racey sync data const afterRaceySyncTimelineEvents = room @@ -761,7 +769,7 @@ describe("MatrixClient room timelines", function() { await Promise.all([ refreshLiveTimelinePromise, // Then flush the remaining `/context` to left the refresh logic complete - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), ]); // Make sure sync pagination still works by seeing a new message show up @@ -770,12 +778,12 @@ describe("MatrixClient room timelines", function() { utils.mkMessage({ user: userId, room: roomId }), ]; setNextSyncData(afterRefreshEventData); - httpBackend.when("GET", "/sync").respond(200, function() { + httpBackend!.when("GET", "/sync").respond(200, function() { return NEXT_SYNC_DATA; }); await Promise.all([ - httpBackend.flushAllExpected(), - utils.syncPromise(client, 1), + httpBackend!.flushAllExpected(), + utils.syncPromise(client!, 1), ]); // Make sure the timeline includes the the events from the `/sync` @@ -794,22 +802,19 @@ describe("MatrixClient room timelines", function() { it('Timeline recovers after `/context` request to generate new timeline fails', async () => { // `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()` // to construct a new timeline from. - httpBackend.when("GET", contextUrl) - .respond(500, function() { - // The timeline should be cleared at this point in the refresh - expect(room.timeline.length).toEqual(0); - - return { - 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([ room.refreshLiveTimeline(), - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), ]); // We only expect `TEST_FAKE_ERROR` here. Anything else is // unexpected and should fail the test. @@ -825,7 +830,7 @@ describe("MatrixClient room timelines", function() { // `/messages` request for `refreshLiveTimeline()` -> // `getLatestTimeline()` to construct a new timeline from. - httpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/messages`) + httpBackend!.when("GET", `/rooms/${encodeURIComponent(roomId)}/messages`) .respond(200, function() { return { chunk: [{ @@ -837,7 +842,7 @@ describe("MatrixClient room timelines", function() { // `/context` request for `refreshLiveTimeline()` -> // `getLatestTimeline()` -> `getEventTimeline()` to construct a new // timeline from. - httpBackend.when("GET", contextUrl) + httpBackend!.when("GET", contextUrl) .respond(200, function() { // The timeline should be cleared at this point in the refresh expect(room.timeline.length).toEqual(0); @@ -848,7 +853,7 @@ describe("MatrixClient room timelines", function() { // Refresh the timeline again but this time it should pass await Promise.all([ room.refreshLiveTimeline(), - httpBackend.flushAllExpected(), + httpBackend!.flushAllExpected(), ]); // Make sure sync pagination still works by seeing a new message show up @@ -857,12 +862,12 @@ describe("MatrixClient room timelines", function() { utils.mkMessage({ user: userId, room: roomId }), ]; setNextSyncData(afterRefreshEventData); - httpBackend.when("GET", "/sync").respond(200, function() { + httpBackend!.when("GET", "/sync").respond(200, function() { return NEXT_SYNC_DATA; }); await Promise.all([ - httpBackend.flushAllExpected(), - utils.syncPromise(client, 1), + httpBackend!.flushAllExpected(), + utils.syncPromise(client!, 1), ]); // Make sure the message are visible diff --git a/spec/integ/matrix-client-syncing.spec.ts b/spec/integ/matrix-client-syncing.spec.ts index 03126c506..fd8fd7b7d 100644 --- a/spec/integ/matrix-client-syncing.spec.ts +++ b/spec/integ/matrix-client-syncing.spec.ts @@ -16,7 +16,6 @@ limitations under the License. import 'fake-indexeddb/auto'; -import { Optional } from "matrix-events-sdk/lib/types"; import HttpBackend from "matrix-mock-request"; import { @@ -29,13 +28,18 @@ import { MatrixClient, ClientEvent, IndexedDBCryptoStore, + ISyncResponse, + IRoomEvent, + IJoinedRoom, + IStateEvent, + IMinimalEvent, + NotificationCountType, } from "../../src"; +import { UNREAD_THREAD_NOTIFICATIONS } from '../../src/@types/sync'; import * as utils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; describe("MatrixClient syncing", () => { - let client: Optional = null; - let httpBackend: Optional = null; const selfUserId = "@alice:localhost"; const selfAccessToken = "aseukfgwef"; const otherUserId = "@bob:localhost"; @@ -44,14 +48,21 @@ describe("MatrixClient syncing", () => { const userC = "@claire:bar"; const roomOne = "!foo:localhost"; const roomTwo = "!bar:localhost"; + let client: MatrixClient | undefined; + let httpBackend: HttpBackend | undefined; - beforeEach(() => { + const setupTestClient = (): [MatrixClient, HttpBackend] => { const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken); - httpBackend = testClient.httpBackend; - client = testClient.client; + const httpBackend = testClient.httpBackend; + const client = testClient.client; httpBackend!.when("GET", "/versions").respond(200, {}); httpBackend!.when("GET", "/pushrules").respond(200, {}); httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + return [client, httpBackend]; + }; + + beforeEach(() => { + [client, httpBackend] = setupTestClient(); }); afterEach(() => { @@ -80,7 +91,7 @@ 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); + expect(req.queryParams!.since).toEqual(syncData.next_batch); }).respond(200, syncData); client!.startClient(); @@ -91,7 +102,7 @@ describe("MatrixClient syncing", () => { }); it("should emit RoomEvent.MyMembership for invite->leave->invite cycles", async () => { - await client.initCrypto(); + await client!.initCrypto(); const roomId = "!cycles:example.org"; @@ -202,7 +213,7 @@ describe("MatrixClient syncing", () => { client!.doesServerSupportLazyLoading = jest.fn().mockResolvedValue(true); httpBackend!.when("GET", "/sync").check((req) => { - expect(JSON.parse(req.queryParams.filter).room.state.lazy_load_members).toBeTruthy(); + expect(JSON.parse(req.queryParams!.filter).room.state.lazy_load_members).toBeTruthy(); }).respond(200, syncData); client!.setGuest(false); @@ -217,7 +228,7 @@ describe("MatrixClient syncing", () => { client!.doesServerSupportLazyLoading = jest.fn().mockResolvedValue(true); httpBackend!.when("GET", "/sync").check((req) => { - expect(JSON.parse(req.queryParams.filter).room?.state?.lazy_load_members).toBeFalsy(); + expect(JSON.parse(req.queryParams!.filter).room?.state?.lazy_load_members).toBeFalsy(); }).respond(200, syncData); client!.setGuest(true); @@ -225,6 +236,44 @@ describe("MatrixClient syncing", () => { return httpBackend!.flushAllExpected(); }); + + it("should emit ClientEvent.Room when invited while crypto is disabled", async () => { + const roomId = "!invite:example.org"; + + // First sync: an invite + const inviteSyncRoomSection = { + invite: { + [roomId]: { + invite_state: { + events: [{ + type: "m.room.member", + state_key: selfUserId, + content: { + membership: "invite", + }, + }], + }, + }, + }, + }; + httpBackend!.when("GET", "/sync").respond(200, { + ...syncData, + rooms: inviteSyncRoomSection, + }); + + // First fire: an initial invite + let fires = 0; + client!.once(ClientEvent.Room, (room) => { + fires++; + expect(room.roomId).toBe(roomId); + }); + + // noinspection ES6MissingAwait + client!.startClient(); + await httpBackend!.flushAllExpected(); + + expect(fires).toBe(1); + }); }); describe("initial sync", () => { @@ -237,11 +286,11 @@ 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); + 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"); + expect(req.queryParams!.filter).toEqual("a filter id"); }).respond(200, syncData); client!.startClient({ initialSyncLimit: 1 }); @@ -252,7 +301,7 @@ 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"); + expect(req.queryParams!.filter).toEqual("a filter id"); }).respond(200, syncData); client!.store.getSavedSyncToken = jest.fn().mockResolvedValue("this-is-a-token"); @@ -263,26 +312,29 @@ describe("MatrixClient syncing", () => { }); describe("resolving invites to profile info", () => { - const syncData = { + const syncData: ISyncResponse = { + account_data: { + events: [], + }, next_batch: "s_5_3", presence: { events: [], }, rooms: { - join: { - - }, + join: {}, + invite: {}, + leave: {}, }, }; beforeEach(() => { - syncData.presence.events = []; + syncData.presence!.events = []; syncData.rooms.join[roomOne] = { timeline: { events: [ utils.mkMessage({ room: roomOne, user: otherUserId, msg: "hello", - }), + }) as IRoomEvent, ], }, state: { @@ -301,14 +353,14 @@ describe("MatrixClient syncing", () => { }), ], }, - }; + } as unknown as IJoinedRoom; }); it("should resolve incoming invites from /sync", () => { syncData.rooms.join[roomOne].state.events.push( utils.mkMembership({ room: roomOne, mship: "invite", user: userC, - }), + }) as IStateEvent, ); httpBackend!.when("GET", "/sync").respond(200, syncData); @@ -327,26 +379,26 @@ describe("MatrixClient syncing", () => { httpBackend!.flushAllExpected(), awaitSyncEvent(), ]).then(() => { - const member = client!.getRoom(roomOne).getMember(userC); + const member = client!.getRoom(roomOne)!.getMember(userC)!; expect(member.name).toEqual("The Boss"); expect( - member.getAvatarUrl("home.server.url", null, null, null, false, false), + member.getAvatarUrl("home.server.url", 1, 1, '', false, false), ).toBeTruthy(); }); }); it("should use cached values from m.presence wherever possible", () => { - syncData.presence.events = [ + syncData.presence!.events = [ utils.mkPresence({ user: userC, presence: "online", name: "The Ghost", - }), + }) as IMinimalEvent, ]; syncData.rooms.join[roomOne].state.events.push( utils.mkMembership({ room: roomOne, mship: "invite", user: userC, - }), + }) as IStateEvent, ); httpBackend!.when("GET", "/sync").respond(200, syncData); @@ -359,28 +411,28 @@ describe("MatrixClient syncing", () => { httpBackend!.flushAllExpected(), awaitSyncEvent(), ]).then(() => { - const member = client!.getRoom(roomOne).getMember(userC); + const member = client!.getRoom(roomOne)!.getMember(userC)!; expect(member.name).toEqual("The Ghost"); }); }); it("should result in events on the room member firing", () => { - syncData.presence.events = [ + syncData.presence!.events = [ utils.mkPresence({ user: userC, presence: "online", name: "The Ghost", - }), + }) as IMinimalEvent, ]; syncData.rooms.join[roomOne].state.events.push( utils.mkMembership({ room: roomOne, mship: "invite", user: userC, - }), + }) as IStateEvent, ); httpBackend!.when("GET", "/sync").respond(200, syncData); - let latestFiredName = null; + let latestFiredName: string; client!.on(RoomMemberEvent.Name, (event, m) => { if (m.userId === userC && m.roomId === roomOne) { latestFiredName = m.name; @@ -403,7 +455,7 @@ describe("MatrixClient syncing", () => { syncData.rooms.join[roomOne].state.events.push( utils.mkMembership({ room: roomOne, mship: "invite", user: userC, - }), + }) as IStateEvent, ); httpBackend!.when("GET", "/sync").respond(200, syncData); @@ -414,10 +466,10 @@ describe("MatrixClient syncing", () => { httpBackend!.flushAllExpected(), awaitSyncEvent(), ]).then(() => { - const member = client!.getRoom(roomOne).getMember(userC); + const member = client!.getRoom(roomOne)!.getMember(userC)!; expect(member.name).toEqual(userC); expect( - member.getAvatarUrl("home.server.url", null, null, null, false, false), + member.getAvatarUrl("home.server.url", 1, 1, '', false, false), ).toBe(null); }); }); @@ -449,8 +501,8 @@ describe("MatrixClient syncing", () => { httpBackend!.flushAllExpected(), awaitSyncEvent(), ]).then(() => { - expect(client!.getUser(userA).presence).toEqual("online"); - expect(client!.getUser(userB).presence).toEqual("unavailable"); + expect(client!.getUser(userA)!.presence).toEqual("online"); + expect(client!.getUser(userB)!.presence).toEqual("unavailable"); }); }); }); @@ -571,7 +623,7 @@ describe("MatrixClient syncing", () => { httpBackend!.flushAllExpected(), awaitSyncEvent(2), ]).then(() => { - const room = client!.getRoom(roomOne); + 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, @@ -589,7 +641,7 @@ describe("MatrixClient syncing", () => { httpBackend!.flushAllExpected(), awaitSyncEvent(2), ]).then(() => { - const room = client!.getRoom(roomTwo); + const room = client!.getRoom(roomTwo)!; // should have added the message from /events expect(room.timeline.length).toEqual(2); expect(room.timeline[1].getContent().body).toEqual(msgText); @@ -605,7 +657,7 @@ describe("MatrixClient syncing", () => { httpBackend!.flushAllExpected(), awaitSyncEvent(2), ]).then(() => { - const room = client!.getRoom(roomTwo); + const room = client!.getRoom(roomTwo)!; // should use the display name of the other person. expect(room.name).toEqual(otherDisplayName); }); @@ -621,11 +673,11 @@ describe("MatrixClient syncing", () => { httpBackend!.flushAllExpected(), awaitSyncEvent(2), ]).then(() => { - const room = client!.getRoom(roomTwo); - let member = room.getMember(otherUserId); + const room = client!.getRoom(roomTwo)!; + let member = room.getMember(otherUserId)!; expect(member).toBeTruthy(); expect(member.typing).toEqual(true); - member = room.getMember(selfUserId); + member = room.getMember(selfUserId)!; expect(member).toBeTruthy(); expect(member.typing).toEqual(false); }); @@ -644,7 +696,7 @@ describe("MatrixClient syncing", () => { httpBackend!.flushAllExpected(), awaitSyncEvent(2), ]).then(() => { - const room = client!.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; const stateAtStart = room.getLiveTimeline().getState( EventTimeline.BACKWARDS, ); @@ -742,7 +794,7 @@ describe("MatrixClient syncing", () => { awaitSyncEvent(2), ]); - const room = client!.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; expect(room.getTimelineNeedsRefresh()).toEqual(false); }); @@ -812,7 +864,7 @@ describe("MatrixClient syncing", () => { awaitSyncEvent(), ]); - const room = client!.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; expect(room.getTimelineNeedsRefresh()).toEqual(false); }); @@ -842,7 +894,7 @@ describe("MatrixClient syncing", () => { awaitSyncEvent(), ]); - const room = client!.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; expect(room.getTimelineNeedsRefresh()).toEqual(false); }); @@ -875,7 +927,7 @@ describe("MatrixClient syncing", () => { awaitSyncEvent(), ]); - const room = client!.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; expect(room.getTimelineNeedsRefresh()).toEqual(false); }); @@ -909,7 +961,7 @@ describe("MatrixClient syncing", () => { ]); // Get the room after the first sync so the room is created - const room = client!.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; let emitCount = 0; room.on(RoomEvent.HistoryImportedWithinTimeline, (markerEvent, room) => { @@ -965,7 +1017,7 @@ describe("MatrixClient syncing", () => { awaitSyncEvent(2), ]); - const room = client!.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; expect(room.getTimelineNeedsRefresh()).toEqual(true); }); }); @@ -1020,7 +1072,7 @@ describe("MatrixClient syncing", () => { ]); // Get the room after the first sync so the room is created - const room = client!.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; expect(room).toBeTruthy(); let stateEventEmitCount = 0; @@ -1094,7 +1146,7 @@ describe("MatrixClient syncing", () => { ]); // Get the room after the first sync so the room is created - const room = client!.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; expect(room).toBeTruthy(); let stateEventEmitCount = 0; @@ -1191,7 +1243,7 @@ describe("MatrixClient syncing", () => { httpBackend!.flushAllExpected(), awaitSyncEvent(), ]).then(() => { - const room = client!.getRoom(roomTwo); + const room = client!.getRoom(roomTwo)!; expect(room).toBeTruthy(); const tok = room.getLiveTimeline() .getPaginationToken(EventTimeline.BACKWARDS); @@ -1234,7 +1286,7 @@ describe("MatrixClient syncing", () => { httpBackend!.flushAllExpected(), awaitSyncEvent(), ]).then(() => { - const room = client!.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; const tl = room.getLiveTimeline(); expect(tl.getEvents().length).toEqual(1); expect(resetCallCount).toEqual(1); @@ -1313,7 +1365,7 @@ describe("MatrixClient syncing", () => { httpBackend!.flushAllExpected(), awaitSyncEvent(), ]).then(() => { - const room = client!.getRoom(roomOne); + const room = client!.getRoom(roomOne)!; expect(room.getReceiptsForEvent(new MatrixEvent(ackEvent))).toEqual([{ type: "m.read", userId: userC, @@ -1325,6 +1377,73 @@ describe("MatrixClient syncing", () => { }); }); + describe("unread notifications", () => { + const THREAD_ID = "$ThisIsARandomEventId"; + + const syncData = { + rooms: { + join: { + [roomOne]: { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "hello", + }), + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "world", + }), + ], + }, + state: { + events: [ + utils.mkEvent({ + type: "m.room.name", room: roomOne, user: otherUserId, + content: { + name: "Room name", + }, + }), + utils.mkMembership({ + room: roomOne, mship: "join", user: otherUserId, + }), + utils.mkMembership({ + room: roomOne, mship: "join", user: selfUserId, + }), + utils.mkEvent({ + type: "m.room.create", room: roomOne, user: selfUserId, + content: { + creator: selfUserId, + }, + }), + ], + }, + }, + }, + }, + }; + it("should sync unread notifications.", () => { + syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = { + [THREAD_ID]: { + "highlight_count": 2, + "notification_count": 5, + }, + }; + + httpBackend!.when("GET", "/sync").respond(200, syncData); + + client!.startClient(); + + return Promise.all([ + httpBackend!.flushAllExpected(), + awaitSyncEvent(), + ]).then(() => { + const room = client!.getRoom(roomOne); + + expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(5); + expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(2); + }); + }); + }); + describe("of a room", () => { xit("should sync when a join event (which changes state) for the user" + " arrives down the event stream (e.g. join from another device)", () => { @@ -1362,7 +1481,7 @@ describe("MatrixClient syncing", () => { const prom = new Promise((resolve) => { httpBackend!.when("GET", "/sync").check((req) => { - expect(req.queryParams.filter).toEqual("another_id"); + expect(req.queryParams!.filter).toEqual("another_id"); resolve(); }).respond(200, {}); }); @@ -1407,7 +1526,7 @@ describe("MatrixClient syncing", () => { return Promise.all([ client!.syncLeftRooms().then(() => { - const room = client!.getRoom(roomTwo); + const room = client!.getRoom(roomTwo)!; const tok = room.getLiveTimeline().getPaginationToken( EventTimeline.BACKWARDS); @@ -1429,7 +1548,7 @@ describe("MatrixClient syncing", () => { * @returns {Promise} promise which resolves after the sync events have happened */ function awaitSyncEvent(numSyncs?: number) { - return utils.syncPromise(client, numSyncs); + return utils.syncPromise(client!, numSyncs); } }); @@ -1453,7 +1572,7 @@ describe("MatrixClient syncing (IndexedDB version)", () => { const idbHttpBackend = idbTestClient.httpBackend; const idbClient = idbTestClient.client; idbHttpBackend.when("GET", "/versions").respond(200, {}); - idbHttpBackend.when("GET", "/pushrules").respond(200, {}); + idbHttpBackend.when("GET", "/pushrules/").respond(200, {}); idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); await idbClient.initCrypto(); diff --git a/spec/integ/megolm-backup.spec.ts b/spec/integ/megolm-backup.spec.ts index 5fa675519..492e4f1dc 100644 --- a/spec/integ/megolm-backup.spec.ts +++ b/spec/integ/megolm-backup.spec.ts @@ -95,26 +95,31 @@ describe("megolm key backups", function() { return; } const Olm = global.Olm; - - let testOlmAccount: Account; + let testOlmAccount: Olm.Account; let aliceTestClient: TestClient; + const setupTestClient = (): [Account, TestClient] => { + const aliceTestClient = new TestClient( + "@alice:localhost", "xzcvb", "akjgkrgjs", + ); + const testOlmAccount = new Olm.Account(); + testOlmAccount!.create(); + + return [testOlmAccount, aliceTestClient]; + }; + beforeAll(function() { return Olm.init(); }); beforeEach(async function() { - aliceTestClient = new TestClient( - "@alice:localhost", "xzcvb", "akjgkrgjs", - ); - testOlmAccount = new Olm.Account(); - testOlmAccount.create(); - await aliceTestClient.client.initCrypto(); - aliceTestClient.client.crypto.backupManager.backupInfo = CURVE25519_BACKUP_INFO; + [testOlmAccount, aliceTestClient] = setupTestClient(); + await aliceTestClient!.client.initCrypto(); + aliceTestClient!.client.crypto!.backupManager.backupInfo = CURVE25519_BACKUP_INFO; }); afterEach(function() { - return aliceTestClient.stop(); + return aliceTestClient!.stop(); }); it("Alice checks key backups when receiving a message she can't decrypt", function() { @@ -130,22 +135,22 @@ describe("megolm key backups", function() { }, }; - return aliceTestClient.start().then(() => { + return aliceTestClient!.start().then(() => { return createOlmSession(testOlmAccount, aliceTestClient); }).then(() => { const privkey = decodeRecoveryKey(RECOVERY_KEY); - return aliceTestClient.client.crypto.storeSessionBackupPrivateKey(privkey); + return aliceTestClient!.client!.crypto!.storeSessionBackupPrivateKey(privkey); }).then(() => { - aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); - aliceTestClient.expectKeyBackupQuery( + aliceTestClient!.httpBackend.when("GET", "/sync").respond(200, syncResponse); + aliceTestClient!.expectKeyBackupQuery( ROOM_ID, SESSION_ID, 200, CURVE25519_KEY_BACKUP_DATA, ); - return aliceTestClient.httpBackend.flushAllExpected(); + return aliceTestClient!.httpBackend.flushAllExpected(); }).then(function(): Promise { - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient!.client.getRoom(ROOM_ID)!; const event = room.getLiveTimeline().getEvents()[0]; if (event.getContent()) { diff --git a/spec/integ/megolm-integ.spec.ts b/spec/integ/megolm-integ.spec.ts index ae2771f3b..9454749a9 100644 --- a/spec/integ/megolm-integ.spec.ts +++ b/spec/integ/megolm-integ.spec.ts @@ -207,9 +207,11 @@ describe("megolm", () => { } const Olm = global.Olm; - let testOlmAccount: Olm.Account; - let testSenderKey: string; - let aliceTestClient: TestClient; + let testOlmAccount = {} as unknown as Olm.Account; + let testSenderKey = ''; + let aliceTestClient = new TestClient( + "@alice:localhost", "device2", "access_token2", + ); /** * Get the device keys for testOlmAccount in a format suitable for a @@ -283,12 +285,12 @@ describe("megolm", () => { it("Alice receives a megolm message", async () => { await aliceTestClient.start(); - aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + 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"; + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; // make the room_key event const roomKeyEncrypted = encryptGroupSessionKey({ @@ -322,7 +324,7 @@ describe("megolm", () => { aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); await aliceTestClient.flushSync(); - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; const event = room.getLiveTimeline().getEvents()[0]; expect(event.isEncrypted()).toBe(true); const decryptedEvent = await testUtils.awaitDecryption(event); @@ -332,12 +334,12 @@ describe("megolm", () => { it("Alice receives a megolm message before the session keys", async () => { // https://github.com/vector-im/element-web/issues/2273 await aliceTestClient.start(); - aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + 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"; + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; // make the room_key event, but don't send it yet const roomKeyEncrypted = encryptGroupSessionKey({ @@ -362,7 +364,7 @@ describe("megolm", () => { }); await aliceTestClient.flushSync(); - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; expect(room.getLiveTimeline().getEvents()[0].getContent().msgtype).toEqual('m.bad.encrypted'); // now she gets the room_key event @@ -392,12 +394,12 @@ describe("megolm", () => { it("Alice gets a second room_key message", async () => { await aliceTestClient.start(); - aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + 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"; + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; // make the room_key event const roomKeyEncrypted1 = encryptGroupSessionKey({ @@ -451,7 +453,7 @@ describe("megolm", () => { await aliceTestClient.flushSync(); await aliceTestClient.flushSync(); - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; await room.decryptCriticalEvents(); const event = room.getLiveTimeline().getEvents()[0]; expect(event.getContent().body).toEqual('42'); @@ -499,7 +501,7 @@ describe("megolm", () => { let inboundGroupSession: Olm.InboundGroupSession; aliceTestClient.httpBackend.when( 'PUT', '/sendToDevice/m.room.encrypted/', - ).respond(200, function(_path, content) { + ).respond(200, function(_path, content: any) { const m = content.messages['@bob:xyz'].DEVICE_ID; const ct = m.ciphertext[testSenderKey]; const decrypted = JSON.parse(p2pSession.decrypt(ct.type, ct.body)); @@ -525,7 +527,7 @@ describe("megolm", () => { return { event_id: '$event_id' }; }); - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; const pendingMsg = room.getPendingEvents()[0]; await Promise.all([ @@ -628,7 +630,7 @@ describe("megolm", () => { let megolmSessionId: string; aliceTestClient.httpBackend.when( 'PUT', '/sendToDevice/m.room.encrypted/', - ).respond(200, function(_path, content) { + ).respond(200, function(_path, content: any) { logger.log('sendToDevice: ', content); const m = content.messages['@bob:xyz'].DEVICE_ID; const ct = m.ciphertext[testSenderKey]; @@ -706,7 +708,7 @@ describe("megolm", () => { // invalidate the device cache for all members in e2e rooms (ie, // herself), and do a key query. aliceTestClient.expectKeyQuery( - getTestKeysQueryResponse(aliceTestClient.userId), + getTestKeysQueryResponse(aliceTestClient.userId!), ); await aliceTestClient.httpBackend.flushAllExpected(); @@ -716,28 +718,30 @@ describe("megolm", () => { await aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'); throw new Error("sendTextMessage succeeded on an unknown device"); } catch (e) { - expect(e.name).toEqual("UnknownDeviceError"); - expect(Object.keys(e.devices)).toEqual([aliceTestClient.userId]); - expect(Object.keys(e.devices[aliceTestClient.userId])). + expect((e as any).name).toEqual("UnknownDeviceError"); + expect(Object.keys((e as any).devices)).toEqual([aliceTestClient.userId!]); + expect(Object.keys((e as any)?.devices[aliceTestClient.userId!])). toEqual(['DEVICE_ID']); } // mark the device as known, and resend. - aliceTestClient.client.setDeviceKnown(aliceTestClient.userId, 'DEVICE_ID'); + aliceTestClient.client.setDeviceKnown(aliceTestClient.userId!, 'DEVICE_ID'); aliceTestClient.httpBackend.when('POST', '/keys/claim').respond( - 200, function(_path, content) { - expect(content.one_time_keys[aliceTestClient.userId].DEVICE_ID) + 200, function(_path, content: IClaimOTKsResult) { + expect(content.one_time_keys[aliceTestClient.userId!].DEVICE_ID) .toEqual("signed_curve25519"); - return getTestKeysClaimResponse(aliceTestClient.userId); + return getTestKeysClaimResponse(aliceTestClient.userId!); }); let p2pSession: Olm.Session; let inboundGroupSession: Olm.InboundGroupSession; aliceTestClient.httpBackend.when( 'PUT', '/sendToDevice/m.room.encrypted/', - ).respond(200, function(_path, content) { + ).respond(200, function(_path, content: { + messages: { [userId: string]: { [deviceId: string]: Record }}; + }) { logger.log("sendToDevice: ", content); - const m = content.messages[aliceTestClient.userId].DEVICE_ID; + const m = content.messages[aliceTestClient.userId!].DEVICE_ID; const ct = m.ciphertext[testSenderKey]; expect(ct.type).toEqual(0); // pre-key message @@ -751,7 +755,7 @@ describe("megolm", () => { return {}; }); - let decrypted: IEvent; + let decrypted: Partial = {}; aliceTestClient.httpBackend.when( 'PUT', '/send/', ).respond(200, function(_path, content: IContent) { @@ -766,7 +770,7 @@ describe("megolm", () => { }); // Grab the event that we'll need to resend - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; const pendingEvents = room.getPendingEvents(); expect(pendingEvents.length).toEqual(1); const unsentEvent = pendingEvents[0]; @@ -781,7 +785,7 @@ describe("megolm", () => { ]); expect(decrypted.type).toEqual('m.room.message'); - expect(decrypted.content.body).toEqual('test'); + expect(decrypted.content?.body).toEqual('test'); }); it('Alice should wait for device list to complete when sending a megolm message', async () => { @@ -830,11 +834,11 @@ describe("megolm", () => { it("Alice exports megolm keys and imports them to a new device", async () => { aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} }, failures: {} }); await aliceTestClient.start(); - aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); // establish an olm session with alice const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); - aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); @@ -867,7 +871,7 @@ describe("megolm", () => { }); await aliceTestClient.flushSync(); - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; await room.decryptCriticalEvents(); expect(room.getLiveTimeline().getEvents()[0].getContent().body).toEqual('42'); @@ -883,7 +887,7 @@ describe("megolm", () => { await aliceTestClient.client.importRoomKeys(exported); await aliceTestClient.start(); - aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; const syncResponse = { next_batch: 1, @@ -927,7 +931,7 @@ describe("megolm", () => { ...rawEvent, room: ROOM_ID, }); - await event1.attemptDecryption(testClient.client.crypto, { isRetry: true }); + await event1.attemptDecryption(testClient.client.crypto!, { isRetry: true }); expect(event1.isKeySourceUntrusted()).toBeTruthy(); const event2 = testUtils.mkEvent({ @@ -943,26 +947,26 @@ describe("megolm", () => { // @ts-ignore - private event2.senderCurve25519Key = testSenderKey; // @ts-ignore - private - testClient.client.crypto.onRoomKeyEvent(event2); + testClient.client.crypto!.onRoomKeyEvent(event2); const event3 = testUtils.mkEvent({ event: true, ...rawEvent, room: ROOM_ID, }); - await event3.attemptDecryption(testClient.client.crypto, { isRetry: true }); + await event3.attemptDecryption(testClient.client.crypto!, { isRetry: true }); expect(event3.isKeySourceUntrusted()).toBeFalsy(); testClient.stop(); }); it("Alice can decrypt a message with falsey content", async () => { await aliceTestClient.start(); - aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + 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"; + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; // make the room_key event const roomKeyEncrypted = encryptGroupSessionKey({ @@ -1005,7 +1009,7 @@ describe("megolm", () => { aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); await aliceTestClient.flushSync(); - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; const event = room.getLiveTimeline().getEvents()[0]; expect(event.isEncrypted()).toBe(true); const decryptedEvent = await testUtils.awaitDecryption(event); @@ -1018,12 +1022,12 @@ describe("megolm", () => { "should successfully decrypt bundled redaction events that don't include a room_id in their /sync data", async () => { await aliceTestClient.start(); - aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + 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"; + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; // make the room_key event const roomKeyEncrypted = encryptGroupSessionKey({ @@ -1072,10 +1076,10 @@ describe("megolm", () => { aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); await aliceTestClient.flushSync(); - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; const event = room.getLiveTimeline().getEvents()[0]; expect(event.isEncrypted()).toBe(true); - await event.attemptDecryption(aliceTestClient.client.crypto); + await event.attemptDecryption(aliceTestClient.client.crypto!); expect(event.getContent()).toEqual({}); const redactionEvent: any = event.getRedactionEvent(); expect(redactionEvent.content.reason).toEqual("redaction test"); @@ -1089,7 +1093,7 @@ describe("megolm", () => { await beccaTestClient.client.initCrypto(); await aliceTestClient.start(); - aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); await beccaTestClient.start(); const beccaRoom = new Room(ROOM_ID, beccaTestClient.client, "@becca:localhost", {}); @@ -1107,7 +1111,7 @@ describe("megolm", () => { }, }); - await beccaTestClient.client.crypto.encryptEvent(event, beccaRoom); + await beccaTestClient.client.crypto!.encryptEvent(event, beccaRoom); // remove keys from the event // @ts-ignore private properties event.clearEvent = undefined; @@ -1116,23 +1120,23 @@ describe("megolm", () => { // @ts-ignore private properties event.claimedEd25519Key = null; - const device = new DeviceInfo(beccaTestClient.client.deviceId); - aliceTestClient.client.crypto.deviceList.getDeviceByIdentityKey = () => device; - aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => beccaTestClient.client.getUserId(); + const device = new DeviceInfo(beccaTestClient.client.deviceId!); + aliceTestClient.client.crypto!.deviceList.getDeviceByIdentityKey = () => device; + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => beccaTestClient.client.getUserId()!; // Create an olm session for Becca and Alice's devices const aliceOtks = await aliceTestClient.awaitOneTimeKeyUpload(); const aliceOtkId = Object.keys(aliceOtks)[0]; const aliceOtk = aliceOtks[aliceOtkId]; const p2pSession = new global.Olm.Session(); - await beccaTestClient.client.crypto.cryptoStore.doTxn( + await beccaTestClient.client.crypto!.cryptoStore.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - beccaTestClient.client.crypto.cryptoStore.getAccount(txn, (pickledAccount: string) => { + beccaTestClient.client.crypto!.cryptoStore.getAccount(txn, (pickledAccount: string) => { const account = new global.Olm.Account(); try { - account.unpickle(beccaTestClient.client.crypto.olmDevice.pickleKey, pickledAccount); + account.unpickle(beccaTestClient.client.crypto!.olmDevice.pickleKey, pickledAccount); p2pSession.create_outbound(account, aliceTestClient.getDeviceKey(), aliceOtk.key); } finally { account.free(); @@ -1142,7 +1146,7 @@ describe("megolm", () => { ); const content = event.getWireContent(); - const groupSessionKey = await beccaTestClient.client.crypto.olmDevice.getInboundGroupSessionKey( + const groupSessionKey = await beccaTestClient.client.crypto!.olmDevice.getInboundGroupSessionKey( ROOM_ID, content.sender_key, content.session_id, @@ -1213,7 +1217,7 @@ describe("megolm", () => { }); await aliceTestClient.flushSync(); - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; const roomEvent = room.getLiveTimeline().getEvents()[0]; expect(roomEvent.isEncrypted()).toBe(true); const decryptedEvent = await testUtils.awaitDecryption(roomEvent); @@ -1246,7 +1250,7 @@ describe("megolm", () => { }, }); - await beccaTestClient.client.crypto.encryptEvent(event, beccaRoom); + await beccaTestClient.client.crypto!.encryptEvent(event, beccaRoom); // remove keys from the event // @ts-ignore private properties event.clearEvent = undefined; @@ -1255,22 +1259,22 @@ describe("megolm", () => { // @ts-ignore private properties event.claimedEd25519Key = null; - const device = new DeviceInfo(beccaTestClient.client.deviceId); - aliceTestClient.client.crypto.deviceList.getDeviceByIdentityKey = () => device; + const device = new DeviceInfo(beccaTestClient.client.deviceId!); + aliceTestClient.client.crypto!.deviceList.getDeviceByIdentityKey = () => device; // Create an olm session for Becca and Alice's devices const aliceOtks = await aliceTestClient.awaitOneTimeKeyUpload(); const aliceOtkId = Object.keys(aliceOtks)[0]; const aliceOtk = aliceOtks[aliceOtkId]; const p2pSession = new global.Olm.Session(); - await beccaTestClient.client.crypto.cryptoStore.doTxn( + await beccaTestClient.client.crypto!.cryptoStore.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - beccaTestClient.client.crypto.cryptoStore.getAccount(txn, (pickledAccount: string) => { + beccaTestClient.client.crypto!.cryptoStore.getAccount(txn, (pickledAccount: string) => { const account = new global.Olm.Account(); try { - account.unpickle(beccaTestClient.client.crypto.olmDevice.pickleKey, pickledAccount); + account.unpickle(beccaTestClient.client.crypto!.olmDevice.pickleKey, pickledAccount); p2pSession.create_outbound(account, aliceTestClient.getDeviceKey(), aliceOtk.key); } finally { account.free(); @@ -1280,7 +1284,7 @@ describe("megolm", () => { ); const content = event.getWireContent(); - const groupSessionKey = await beccaTestClient.client.crypto.olmDevice.getInboundGroupSessionKey( + const groupSessionKey = await beccaTestClient.client.crypto!.olmDevice.getInboundGroupSessionKey( ROOM_ID, content.sender_key, content.session_id, @@ -1352,7 +1356,7 @@ describe("megolm", () => { await aliceTestClient.flushSync(); // Decryption should fail, because Alice hasn't received any keys she can trust - const room = aliceTestClient.client.getRoom(ROOM_ID); + const room = aliceTestClient.client.getRoom(ROOM_ID)!; const roomEvent = room.getLiveTimeline().getEvents()[0]; expect(roomEvent.isEncrypted()).toBe(true); const decryptedEvent = await testUtils.awaitDecryption(roomEvent); diff --git a/spec/integ/sliding-sync-sdk.spec.ts b/spec/integ/sliding-sync-sdk.spec.ts index f7dc68754..3e50064a6 100644 --- a/spec/integ/sliding-sync-sdk.spec.ts +++ b/spec/integ/sliding-sync-sdk.spec.ts @@ -23,18 +23,19 @@ import { TestClient } from "../TestClient"; import { IRoomEvent, IStateEvent } from "../../src/sync-accumulator"; import { MatrixClient, MatrixEvent, NotificationCountType, JoinRule, MatrixError, - EventType, IPushRules, PushRuleKind, TweakName, ClientEvent, + EventType, IPushRules, PushRuleKind, TweakName, ClientEvent, RoomMemberEvent, } from "../../src"; import { SlidingSyncSdk } from "../../src/sliding-sync-sdk"; import { SyncState } from "../../src/sync"; import { IStoredClientOpts } from "../../src/client"; import { logger } from "../../src/logger"; +import { emitPromise } from "../test-utils/test-utils"; describe("SlidingSyncSdk", () => { - let client: MatrixClient = null; - let httpBackend: MockHttpBackend = null; - let sdk: SlidingSyncSdk = null; - let mockSlidingSync: SlidingSync = null; + let client: MatrixClient | undefined; + let httpBackend: MockHttpBackend | undefined; + let sdk: SlidingSyncSdk | undefined; + let mockSlidingSync: SlidingSync | undefined; const selfUserId = "@alice:localhost"; const selfAccessToken = "aseukfgwef"; @@ -66,7 +67,7 @@ describe("SlidingSyncSdk", () => { event_id: "$" + eventIdCounter, }; }; - const mkOwnStateEvent = (evType: string, content: object, stateKey?: string): IStateEvent => { + const mkOwnStateEvent = (evType: string, content: object, stateKey = ''): IStateEvent => { eventIdCounter++; return { type: evType, @@ -103,24 +104,24 @@ describe("SlidingSyncSdk", () => { client = testClient.client; mockSlidingSync = mockifySlidingSync(new SlidingSync("", [], {}, client, 0)); if (testOpts.withCrypto) { - httpBackend.when("GET", "/room_keys/version").respond(404, {}); - await client.initCrypto(); - testOpts.crypto = client.crypto; + httpBackend!.when("GET", "/room_keys/version").respond(404, {}); + await client!.initCrypto(); + testOpts.crypto = client!.crypto; } - httpBackend.when("GET", "/_matrix/client/r0/pushrules").respond(200, {}); + httpBackend!.when("GET", "/_matrix/client/r0/pushrules").respond(200, {}); sdk = new SlidingSyncSdk(mockSlidingSync, client, testOpts); }; // tear down client/httpBackend globals const teardownClient = () => { - client.stopClient(); - return httpBackend.stop(); + client!.stopClient(); + return httpBackend!.stop(); }; // find an extension on a SlidingSyncSdk instance const findExtension = (name: string): Extension => { - expect(mockSlidingSync.registerExtension).toHaveBeenCalled(); - const mockFn = mockSlidingSync.registerExtension as jest.Mock; + expect(mockSlidingSync!.registerExtension).toHaveBeenCalled(); + const mockFn = mockSlidingSync!.registerExtension as jest.Mock; // find the extension for (let i = 0; i < mockFn.mock.calls.length; i++) { const calledExtension = mockFn.mock.calls[i][0] as Extension; @@ -137,14 +138,14 @@ describe("SlidingSyncSdk", () => { }); afterAll(teardownClient); it("can sync()", async () => { - const hasSynced = sdk.sync(); - await httpBackend.flushAllExpected(); + const hasSynced = sdk!.sync(); + await httpBackend!.flushAllExpected(); await hasSynced; - expect(mockSlidingSync.start).toBeCalled(); + expect(mockSlidingSync!.start).toBeCalled(); }); it("can stop()", async () => { - sdk.stop(); - expect(mockSlidingSync.stop).toBeCalled(); + sdk!.stop(); + expect(mockSlidingSync!.stop).toBeCalled(); }); }); @@ -156,8 +157,8 @@ describe("SlidingSyncSdk", () => { describe("initial", () => { beforeAll(async () => { - const hasSynced = sdk.sync(); - await httpBackend.flushAllExpected(); + const hasSynced = sdk!.sync(); + await httpBackend!.flushAllExpected(); await hasSynced; }); // inject some rooms with different fields set. @@ -277,8 +278,8 @@ describe("SlidingSyncSdk", () => { }; it("can be created with required_state and timeline", () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]); - const gotRoom = client.getRoom(roomA); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]); + const gotRoom = client!.getRoom(roomA); expect(gotRoom).toBeDefined(); if (gotRoom == null) { return; } expect(gotRoom.name).toEqual(data[roomA].name); @@ -287,8 +288,8 @@ describe("SlidingSyncSdk", () => { }); it("can be created with timeline only", () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]); - const gotRoom = client.getRoom(roomB); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]); + const gotRoom = client!.getRoom(roomB); expect(gotRoom).toBeDefined(); if (gotRoom == null) { return; } expect(gotRoom.name).toEqual(data[roomB].name); @@ -297,8 +298,8 @@ describe("SlidingSyncSdk", () => { }); it("can be created with a highlight_count", () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]); - const gotRoom = client.getRoom(roomC); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]); + const gotRoom = client!.getRoom(roomC); expect(gotRoom).toBeDefined(); if (gotRoom == null) { return; } expect( @@ -307,8 +308,8 @@ describe("SlidingSyncSdk", () => { }); it("can be created with a notification_count", () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]); - const gotRoom = client.getRoom(roomD); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]); + const gotRoom = client!.getRoom(roomD); expect(gotRoom).toBeDefined(); if (gotRoom == null) { return; } expect( @@ -317,8 +318,8 @@ describe("SlidingSyncSdk", () => { }); it("can be created with an invited/joined_count", () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomG, data[roomG]); - const gotRoom = client.getRoom(roomG); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomG, data[roomG]); + const gotRoom = client!.getRoom(roomG); expect(gotRoom).toBeDefined(); if (gotRoom == null) { return; } expect(gotRoom.getInvitedMemberCount()).toEqual(data[roomG].invited_count); @@ -326,8 +327,8 @@ describe("SlidingSyncSdk", () => { }); it("can be created with invite_state", () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]); - const gotRoom = client.getRoom(roomE); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]); + const gotRoom = client!.getRoom(roomE); expect(gotRoom).toBeDefined(); if (gotRoom == null) { return; } expect(gotRoom.getMyMembership()).toEqual("invite"); @@ -335,8 +336,8 @@ describe("SlidingSyncSdk", () => { }); it("uses the 'name' field to caluclate the room name", () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]); - const gotRoom = client.getRoom(roomF); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]); + const gotRoom = client!.getRoom(roomF); expect(gotRoom).toBeDefined(); if (gotRoom == null) { return; } expect( @@ -347,12 +348,12 @@ describe("SlidingSyncSdk", () => { describe("updating", () => { it("can update with a new timeline event", async () => { const newEvent = mkOwnEvent(EventType.RoomMessage, { body: "new event A" }); - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomA, { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, { timeline: [newEvent], required_state: [], name: data[roomA].name, }); - const gotRoom = client.getRoom(roomA); + const gotRoom = client!.getRoom(roomA); expect(gotRoom).toBeDefined(); if (gotRoom == null) { return; } const newTimeline = data[roomA].timeline; @@ -361,31 +362,31 @@ describe("SlidingSyncSdk", () => { }); it("can update with a new required_state event", async () => { - let gotRoom = client.getRoom(roomB); + let gotRoom = client!.getRoom(roomB); expect(gotRoom).toBeDefined(); if (gotRoom == null) { return; } expect(gotRoom.getJoinRule()).toEqual(JoinRule.Invite); // default - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomB, { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, { required_state: [ mkOwnStateEvent("m.room.join_rules", { join_rule: "restricted" }, ""), ], timeline: [], name: data[roomB].name, }); - gotRoom = client.getRoom(roomB); + gotRoom = client!.getRoom(roomB); expect(gotRoom).toBeDefined(); if (gotRoom == null) { return; } expect(gotRoom.getJoinRule()).toEqual(JoinRule.Restricted); }); it("can update with a new highlight_count", async () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomC, { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomC, { name: data[roomC].name, required_state: [], timeline: [], highlight_count: 1, }); - const gotRoom = client.getRoom(roomC); + const gotRoom = client!.getRoom(roomC); expect(gotRoom).toBeDefined(); if (gotRoom == null) { return; } expect( @@ -394,13 +395,13 @@ describe("SlidingSyncSdk", () => { }); it("can update with a new notification_count", async () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomD, { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomD, { name: data[roomD].name, required_state: [], timeline: [], notification_count: 1, }); - const gotRoom = client.getRoom(roomD); + const gotRoom = client!.getRoom(roomD); expect(gotRoom).toBeDefined(); if (gotRoom == null) { return; } expect( @@ -409,13 +410,13 @@ describe("SlidingSyncSdk", () => { }); it("can update with a new joined_count", () => { - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomG, { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomG, { name: data[roomD].name, required_state: [], timeline: [], joined_count: 1, }); - const gotRoom = client.getRoom(roomG); + const gotRoom = client!.getRoom(roomG); expect(gotRoom).toBeDefined(); if (gotRoom == null) { return; } expect(gotRoom.getJoinedMemberCount()).toEqual(1); @@ -433,13 +434,13 @@ describe("SlidingSyncSdk", () => { mkOwnEvent(EventType.RoomMessage, { body: "old event C" }), ...timeline, ]; - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomA, { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, { timeline: oldTimeline, required_state: [], name: data[roomA].name, initial: true, // e.g requested via room subscription }); - const gotRoom = client.getRoom(roomA); + const gotRoom = client!.getRoom(roomA); expect(gotRoom).toBeDefined(); if (gotRoom == null) { return; } @@ -458,50 +459,50 @@ describe("SlidingSyncSdk", () => { describe("lifecycle", () => { beforeAll(async () => { await setupClient(); - const hasSynced = sdk.sync(); - await httpBackend.flushAllExpected(); + const hasSynced = sdk!.sync(); + await httpBackend!.flushAllExpected(); await hasSynced; }); const FAILED_SYNC_ERROR_THRESHOLD = 3; // would be nice to export the const in the actual class... it("emits SyncState.Reconnecting when < FAILED_SYNC_ERROR_THRESHOLD & SyncState.Error when over", async () => { - mockSlidingSync.emit( + mockSlidingSync!.emit( SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, { pos: "h", lists: [], rooms: {}, extensions: {} }, null, ); - expect(sdk.getSyncState()).toEqual(SyncState.Syncing); + expect(sdk!.getSyncState()).toEqual(SyncState.Syncing); - mockSlidingSync.emit( + mockSlidingSync!.emit( SlidingSyncEvent.Lifecycle, SlidingSyncState.RequestFinished, null, new Error("generic"), ); - expect(sdk.getSyncState()).toEqual(SyncState.Reconnecting); + expect(sdk!.getSyncState()).toEqual(SyncState.Reconnecting); for (let i = 0; i < FAILED_SYNC_ERROR_THRESHOLD; i++) { - mockSlidingSync.emit( + mockSlidingSync!.emit( SlidingSyncEvent.Lifecycle, SlidingSyncState.RequestFinished, null, new Error("generic"), ); } - expect(sdk.getSyncState()).toEqual(SyncState.Error); + expect(sdk!.getSyncState()).toEqual(SyncState.Error); }); it("emits SyncState.Syncing after a previous SyncState.Error", async () => { - mockSlidingSync.emit( + mockSlidingSync!.emit( SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, { pos: "i", lists: [], rooms: {}, extensions: {} }, null, ); - expect(sdk.getSyncState()).toEqual(SyncState.Syncing); + expect(sdk!.getSyncState()).toEqual(SyncState.Syncing); }); it("emits SyncState.Error immediately when receiving M_UNKNOWN_TOKEN and stops syncing", async () => { - expect(mockSlidingSync.stop).not.toBeCalled(); - mockSlidingSync.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.RequestFinished, null, new MatrixError({ + expect(mockSlidingSync!.stop).not.toBeCalled(); + mockSlidingSync!.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.RequestFinished, null, new MatrixError({ errcode: "M_UNKNOWN_TOKEN", message: "Oh no your access token is no longer valid", })); - expect(sdk.getSyncState()).toEqual(SyncState.Error); - expect(mockSlidingSync.stop).toBeCalled(); + expect(sdk!.getSyncState()).toEqual(SyncState.Error); + expect(mockSlidingSync!.stop).toBeCalled(); }); }); @@ -517,8 +518,8 @@ describe("SlidingSyncSdk", () => { avatar_url: "mxc://foobar", displayname: "The Invitee", }; - httpBackend.when("GET", "/profile").respond(200, inviteeProfile); - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomId, { + httpBackend!.when("GET", "/profile").respond(200, inviteeProfile); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomId, { initial: true, name: "Room with Invite", required_state: [], @@ -529,10 +530,11 @@ describe("SlidingSyncSdk", () => { mkOwnStateEvent(EventType.RoomMember, { membership: "invite" }, invitee), ], }); - await httpBackend.flush("/profile", 1, 1000); - const room = client.getRoom(roomId); + await httpBackend!.flush("/profile", 1, 1000); + await emitPromise(client!, RoomMemberEvent.Name); + const room = client!.getRoom(roomId)!; expect(room).toBeDefined(); - const inviteeMember = room.getMember(invitee); + const inviteeMember = room.getMember(invitee)!; expect(inviteeMember).toBeDefined(); expect(inviteeMember.getMxcAvatarUrl()).toEqual(inviteeProfile.avatar_url); expect(inviteeMember.name).toEqual(inviteeProfile.displayname); @@ -545,8 +547,8 @@ describe("SlidingSyncSdk", () => { await setupClient({ withCrypto: true, }); - const hasSynced = sdk.sync(); - await httpBackend.flushAllExpected(); + const hasSynced = sdk!.sync(); + await httpBackend!.flushAllExpected(); await hasSynced; ext = findExtension("e2ee"); }); @@ -554,7 +556,7 @@ describe("SlidingSyncSdk", () => { // needed else we do some async operations in the background which can cause Jest to whine: // "Cannot log after tests are done. Did you forget to wait for something async in your test?" // Attempted to log "Saving device tracking data null"." - client.crypto.stop(); + client!.crypto!.stop(); }); it("gets enabled on the initial request only", () => { expect(ext.onRequest(true)).toEqual({ @@ -572,38 +574,38 @@ describe("SlidingSyncSdk", () => { // TODO: more assertions? }); it("can update OTK counts", () => { - client.crypto.updateOneTimeKeyCount = jest.fn(); + client!.crypto!.updateOneTimeKeyCount = jest.fn(); ext.onResponse({ device_one_time_keys_count: { signed_curve25519: 42, }, }); - expect(client.crypto.updateOneTimeKeyCount).toHaveBeenCalledWith(42); + expect(client!.crypto!.updateOneTimeKeyCount).toHaveBeenCalledWith(42); ext.onResponse({ device_one_time_keys_count: { not_signed_curve25519: 42, // missing field -> default to 0 }, }); - expect(client.crypto.updateOneTimeKeyCount).toHaveBeenCalledWith(0); + expect(client!.crypto!.updateOneTimeKeyCount).toHaveBeenCalledWith(0); }); it("can update fallback keys", () => { ext.onResponse({ device_unused_fallback_key_types: ["signed_curve25519"], }); - expect(client.crypto.getNeedsNewFallback()).toEqual(false); + expect(client!.crypto!.getNeedsNewFallback()).toEqual(false); ext.onResponse({ device_unused_fallback_key_types: ["not_signed_curve25519"], }); - expect(client.crypto.getNeedsNewFallback()).toEqual(true); + expect(client!.crypto!.getNeedsNewFallback()).toEqual(true); }); }); describe("ExtensionAccountData", () => { let ext: Extension; beforeAll(async () => { await setupClient(); - const hasSynced = sdk.sync(); - await httpBackend.flushAllExpected(); + const hasSynced = sdk!.sync(); + await httpBackend!.flushAllExpected(); await hasSynced; ext = findExtension("account_data"); }); @@ -618,7 +620,7 @@ describe("SlidingSyncSdk", () => { const globalContent = { info: "here", }; - let globalData = client.getAccountData(globalType); + let globalData = client!.getAccountData(globalType); expect(globalData).toBeUndefined(); ext.onResponse({ global: [ @@ -628,13 +630,13 @@ describe("SlidingSyncSdk", () => { }, ], }); - globalData = client.getAccountData(globalType); + globalData = client!.getAccountData(globalType)!; expect(globalData).toBeDefined(); expect(globalData.getContent()).toEqual(globalContent); }); it("processes rooms account data", async () => { const roomId = "!room:id"; - mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomId, { + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomId, { name: "Room with account data", required_state: [], timeline: [ @@ -660,9 +662,9 @@ describe("SlidingSyncSdk", () => { ], }, }); - const room = client.getRoom(roomId); + const room = client!.getRoom(roomId)!; expect(room).toBeDefined(); - const event = room.getAccountData(roomType); + const event = room.getAccountData(roomType)!; expect(event).toBeDefined(); expect(event.getContent()).toEqual(roomContent); }); @@ -681,9 +683,9 @@ describe("SlidingSyncSdk", () => { ], }, }); - const room = client.getRoom(unknownRoomId); + const room = client!.getRoom(unknownRoomId); expect(room).toBeNull(); - expect(client.getAccountData(roomType)).toBeUndefined(); + expect(client!.getAccountData(roomType)).toBeUndefined(); }); it("can update push rules via account data", async () => { const roomId = "!foo:bar"; @@ -703,7 +705,7 @@ describe("SlidingSyncSdk", () => { }], }, }; - let pushRule = client.getRoomPushRule("global", roomId); + let pushRule = client!.getRoomPushRule("global", roomId); expect(pushRule).toBeUndefined(); ext.onResponse({ global: [ @@ -713,16 +715,16 @@ describe("SlidingSyncSdk", () => { }, ], }); - pushRule = client.getRoomPushRule("global", roomId); - expect(pushRule).toEqual(pushRulesContent.global[PushRuleKind.RoomSpecific][0]); + pushRule = client!.getRoomPushRule("global", roomId)!; + expect(pushRule).toEqual(pushRulesContent.global[PushRuleKind.RoomSpecific]![0]); }); }); describe("ExtensionToDevice", () => { let ext: Extension; beforeAll(async () => { await setupClient(); - const hasSynced = sdk.sync(); - await httpBackend.flushAllExpected(); + const hasSynced = sdk!.sync(); + await httpBackend!.flushAllExpected(); await hasSynced; ext = findExtension("to_device"); }); @@ -753,7 +755,7 @@ describe("SlidingSyncSdk", () => { foo: "bar", }; let called = false; - client.once(ClientEvent.ToDeviceEvent, (ev) => { + client!.once(ClientEvent.ToDeviceEvent, (ev) => { expect(ev.getContent()).toEqual(toDeviceContent); expect(ev.getType()).toEqual(toDeviceType); called = true; @@ -771,7 +773,7 @@ describe("SlidingSyncSdk", () => { }); it("can cancel key verification requests", async () => { const seen: Record = {}; - client.on(ClientEvent.ToDeviceEvent, (ev) => { + client!.on(ClientEvent.ToDeviceEvent, (ev) => { const evType = ev.getType(); expect(seen[evType]).toBeFalsy(); seen[evType] = true; diff --git a/spec/integ/sliding-sync.spec.ts b/spec/integ/sliding-sync.spec.ts index 4cfe39215..3390b48be 100644 --- a/spec/integ/sliding-sync.spec.ts +++ b/spec/integ/sliding-sync.spec.ts @@ -22,7 +22,6 @@ import { SlidingSync, SlidingSyncState, ExtensionState, SlidingSyncEvent } from import { TestClient } from "../TestClient"; import { logger } from "../../src/logger"; import { MatrixClient } from "../../src"; -import { sleep } from "../../src/utils"; /** * Tests for sliding sync. These tests are broken down into sub-tests which are reliant upon one another. @@ -30,8 +29,8 @@ import { sleep } from "../../src/utils"; * Each test will call different functions on SlidingSync which may depend on state from previous tests. */ describe("SlidingSync", () => { - let client: MatrixClient = null; - let httpBackend: MockHttpBackend = null; + let client: MatrixClient | undefined; + let httpBackend: MockHttpBackend | undefined; const selfUserId = "@alice:localhost"; const selfAccessToken = "aseukfgwef"; const proxyBaseUrl = "http://localhost:8008"; @@ -46,9 +45,9 @@ describe("SlidingSync", () => { // tear down client/httpBackend globals const teardownClient = () => { - httpBackend.verifyNoOutstandingExpectation(); - client.stopClient(); - return httpBackend.stop(); + httpBackend!.verifyNoOutstandingExpectation(); + client!.stopClient(); + return httpBackend!.stop(); }; describe("start/stop", () => { @@ -57,14 +56,14 @@ describe("SlidingSync", () => { let slidingSync: SlidingSync; it("should start the sync loop upon calling start()", async () => { - slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client, 1); + slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client!, 1); const fakeResp = { pos: "a", lists: [], rooms: {}, extensions: {}, }; - httpBackend.when("POST", syncUrl).respond(200, fakeResp); + httpBackend!.when("POST", syncUrl).respond(200, fakeResp); const p = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state, resp, err) => { expect(state).toEqual(SlidingSyncState.RequestFinished); expect(resp).toEqual(fakeResp); @@ -72,13 +71,113 @@ describe("SlidingSync", () => { return true; }); slidingSync.start(); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; }); it("should stop the sync loop upon calling stop()", () => { slidingSync.stop(); - httpBackend.verifyNoOutstandingExpectation(); + httpBackend!.verifyNoOutstandingExpectation(); + }); + + it("should reset the connection on HTTP 400 and send everything again", async () => { + // seed the connection with some lists, extensions and subscriptions to verify they are sent again + slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client, 1); + const roomId = "!sub:localhost"; + const subInfo = { + timeline_limit: 42, + required_state: [["m.room.create", ""]], + }; + const listInfo = { + ranges: [[0, 10]], + filters: { + is_dm: true, + }, + }; + const ext = { + name: () => "custom_extension", + onRequest: (initial) => { return { initial: initial }; }, + onResponse: (res) => { return {}; }, + when: () => ExtensionState.PreProcess, + }; + slidingSync.modifyRoomSubscriptions(new Set([roomId])); + slidingSync.modifyRoomSubscriptionInfo(subInfo); + slidingSync.setList(0, listInfo); + slidingSync.registerExtension(ext); + 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, + }); + 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]], + }); + 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() { + logger.debug("sending session expired 400"); + return { + error: "HTTP 400 : session expired", + }; + }); + 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, + }); + 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(); }); }); @@ -103,9 +202,9 @@ describe("SlidingSync", () => { it("should be able to subscribe to a room", async () => { // add the subscription - slidingSync = new SlidingSync(proxyBaseUrl, [], roomSubInfo, client, 1); + slidingSync = new SlidingSync(proxyBaseUrl, [], roomSubInfo, client!, 1); slidingSync.modifyRoomSubscriptions(new Set([roomId])); - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("room sub", body); expect(body.room_subscriptions).toBeTruthy(); @@ -125,7 +224,7 @@ describe("SlidingSync", () => { return true; }); slidingSync.start(); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; }); @@ -137,7 +236,7 @@ describe("SlidingSync", () => { ["m.room.member", "*"], ], }; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("adjusted sub", body); expect(body.room_subscriptions).toBeTruthy(); @@ -158,7 +257,7 @@ describe("SlidingSync", () => { }); slidingSync.modifyRoomSubscriptionInfo(newSubInfo); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; // need to set what the new subscription info is for subsequent tests roomSubInfo = newSubInfo; @@ -179,7 +278,7 @@ describe("SlidingSync", () => { required_state: [], timeline: [], }; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("new subs", body); expect(body.room_subscriptions).toBeTruthy(); @@ -204,12 +303,12 @@ describe("SlidingSync", () => { const subs = slidingSync.getRoomSubscriptions(); subs.add(anotherRoomID); slidingSync.modifyRoomSubscriptions(subs); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; }); it("should be able to unsubscribe from a room", async () => { - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("unsub request", body); expect(body.room_subscriptions).toBeFalsy(); @@ -226,7 +325,7 @@ describe("SlidingSync", () => { // remove the subscription for the first room slidingSync.modifyRoomSubscriptions(new Set([anotherRoomID])); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; slidingSync.stop(); @@ -273,8 +372,8 @@ describe("SlidingSync", () => { is_dm: true, }, }; - slidingSync = new SlidingSync(proxyBaseUrl, [listReq], {}, client, 1); - httpBackend.when("POST", syncUrl).check(function(req) { + slidingSync = new SlidingSync(proxyBaseUrl, [listReq], {}, client!, 1); + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("list", body); expect(body.lists).toBeTruthy(); @@ -301,7 +400,7 @@ describe("SlidingSync", () => { return state === SlidingSyncState.Complete; }); slidingSync.start(); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; expect(listenerData[roomA]).toEqual(rooms[roomA]); @@ -327,7 +426,7 @@ describe("SlidingSync", () => { it("should be possible to adjust list ranges", async () => { // modify the list ranges - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("next ranges", body.lists[0].ranges); expect(body.lists).toBeTruthy(); @@ -351,7 +450,7 @@ describe("SlidingSync", () => { return state === SlidingSyncState.RequestFinished; }); slidingSync.setListRanges(0, newRanges); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; }); @@ -364,7 +463,7 @@ describe("SlidingSync", () => { "is_dm": true, }, }; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("extra list", body); expect(body.lists).toBeTruthy(); @@ -403,13 +502,13 @@ describe("SlidingSync", () => { return state === SlidingSyncState.Complete; }); slidingSync.setList(1, extraListReq); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; }); it("should be possible to get list DELETE/INSERTs", async () => { // move C (2) to A (0) - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "e", lists: [{ count: 500, @@ -440,12 +539,12 @@ describe("SlidingSync", () => { let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; // move C (0) back to A (2) - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "f", lists: [{ count: 500, @@ -476,13 +575,13 @@ describe("SlidingSync", () => { responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; }); it("should ignore invalid list indexes", async () => { - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "e", lists: [{ count: 500, @@ -509,13 +608,13 @@ describe("SlidingSync", () => { const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; }); it("should be possible to update a list", async () => { - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "g", lists: [{ count: 42, @@ -555,7 +654,7 @@ describe("SlidingSync", () => { const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; }); @@ -567,7 +666,7 @@ describe("SlidingSync", () => { 1: roomC, }; expect(slidingSync.getListData(0).roomIndexToRoomId).toEqual(indexToRoomId); - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "f", // currently the list is [B,C] so we will insert D then immediately delete it lists: [{ @@ -598,7 +697,7 @@ describe("SlidingSync", () => { const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; }); @@ -608,7 +707,7 @@ describe("SlidingSync", () => { 0: roomB, 1: roomC, }); - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "g", lists: [{ count: 499, @@ -634,7 +733,7 @@ describe("SlidingSync", () => { const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; }); @@ -643,7 +742,7 @@ describe("SlidingSync", () => { expect(slidingSync.getListData(0).roomIndexToRoomId).toEqual({ 0: roomC, }); - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "h", lists: [{ count: 500, @@ -670,11 +769,11 @@ describe("SlidingSync", () => { let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; - httpBackend.when("POST", syncUrl).respond(200, { + httpBackend!.when("POST", syncUrl).respond(200, { pos: "h", lists: [{ count: 501, @@ -702,7 +801,7 @@ describe("SlidingSync", () => { responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await responseProcessed; await listPromise; slidingSync.stop(); @@ -725,11 +824,11 @@ describe("SlidingSync", () => { ], }; // add the subscription - slidingSync = new SlidingSync(proxyBaseUrl, [], roomSubInfo, client, 1); + slidingSync = new SlidingSync(proxyBaseUrl, [], roomSubInfo, client!, 1); // modification before SlidingSync.start() const subscribePromise = slidingSync.modifyRoomSubscriptions(new Set([roomId])); let txnId; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.debug("got ", body); expect(body.room_subscriptions).toBeTruthy(); @@ -752,7 +851,7 @@ describe("SlidingSync", () => { }; }); slidingSync.start(); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await subscribePromise; }); it("should resolve setList during a connection", async () => { @@ -761,7 +860,7 @@ describe("SlidingSync", () => { }; const promise = slidingSync.setList(0, newList); let txnId; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.debug("got ", body); expect(body.room_subscriptions).toBeFalsy(); @@ -776,14 +875,14 @@ describe("SlidingSync", () => { extensions: {}, }; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await promise; expect(txnId).toBeDefined(); }); it("should resolve setListRanges during a connection", async () => { const promise = slidingSync.setListRanges(0, [[20, 40]]); let txnId; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.debug("got ", body); expect(body.room_subscriptions).toBeFalsy(); @@ -800,7 +899,7 @@ describe("SlidingSync", () => { extensions: {}, }; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await promise; expect(txnId).toBeDefined(); }); @@ -809,7 +908,7 @@ describe("SlidingSync", () => { timeline_limit: 99, }); let txnId; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.debug("got ", body); expect(body.room_subscriptions).toBeTruthy(); @@ -825,22 +924,22 @@ describe("SlidingSync", () => { extensions: {}, }; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await promise; expect(txnId).toBeDefined(); }); it("should reject earlier pending promises if a later transaction is acknowledged", async () => { // i.e if we have [A,B,C] and see txn_id=C then A,B should be rejected. - const gotTxnIds = []; + const gotTxnIds: any[] = []; const pushTxn = function(req) { gotTxnIds.push(req.data.txn_id); }; const failPromise = slidingSync.setListRanges(0, [[20, 40]]); - httpBackend.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "e" }); // missing txn_id - await httpBackend.flushAllExpected(); + httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "e" }); // missing txn_id + await httpBackend!.flushAllExpected(); const failPromise2 = slidingSync.setListRanges(0, [[60, 70]]); - httpBackend.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "f" }); // missing txn_id - await httpBackend.flushAllExpected(); + httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "f" }); // missing txn_id + await httpBackend!.flushAllExpected(); // attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection // which is a fail. @@ -849,7 +948,7 @@ describe("SlidingSync", () => { const okPromise = slidingSync.setListRanges(0, [[0, 20]]); let txnId; - httpBackend.when("POST", syncUrl).check((req) => { + httpBackend!.when("POST", syncUrl).check((req) => { txnId = req.data.txn_id; }).respond(200, () => { // include the txn_id, earlier requests should now be reject()ed. @@ -858,23 +957,23 @@ describe("SlidingSync", () => { txn_id: txnId, }; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await okPromise; expect(txnId).toBeDefined(); }); it("should not reject later pending promises if an earlier transaction is acknowledged", async () => { // i.e if we have [A,B,C] and see txn_id=B then C should not be rejected but A should. - const gotTxnIds = []; + const gotTxnIds: any[] = []; const pushTxn = function(req) { - gotTxnIds.push(req.data.txn_id); + gotTxnIds.push(req.data?.txn_id); }; const A = slidingSync.setListRanges(0, [[20, 40]]); - httpBackend.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "A" }); - await httpBackend.flushAllExpected(); + httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "A" }); + await httpBackend!.flushAllExpected(); const B = slidingSync.setListRanges(0, [[60, 70]]); - httpBackend.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "B" }); // missing txn_id - await httpBackend.flushAllExpected(); + httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "B" }); // missing txn_id + await httpBackend!.flushAllExpected(); // attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection // which is a fail. @@ -885,14 +984,14 @@ describe("SlidingSync", () => { C.finally(() => { pendingC = false; }); - httpBackend.when("POST", syncUrl).check(pushTxn).respond(200, () => { + httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, () => { // include the txn_id for B, so C's promise is outstanding return { pos: "C", txn_id: gotTxnIds[1], }; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); // A is rejected, see above expect(B).resolves.toEqual(gotTxnIds[1]); // B is resolved expect(pendingC).toBe(true); // C is pending still @@ -904,7 +1003,7 @@ describe("SlidingSync", () => { pending = false; }); let txnId; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.debug("got ", body); expect(body.room_subscriptions).toBeFalsy(); @@ -921,7 +1020,7 @@ describe("SlidingSync", () => { extensions: {}, }; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); expect(txnId).toBeDefined(); expect(pending).toBe(true); slidingSync.stop(); @@ -963,10 +1062,10 @@ describe("SlidingSync", () => { }; it("should be able to register an extension", async () => { - slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client, 1); + slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client!, 1); slidingSync.registerExtension(extPre); - const callbackOrder = []; + const callbackOrder: string[] = []; let extensionOnResponseCalled = false; onPreExtensionRequest = () => { return extReq; @@ -977,7 +1076,7 @@ describe("SlidingSync", () => { expect(resp).toEqual(extResp); }; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("ext req", body); expect(body.extensions).toBeTruthy(); @@ -998,7 +1097,7 @@ describe("SlidingSync", () => { } }); slidingSync.start(); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; expect(extensionOnResponseCalled).toBe(true); expect(callbackOrder).toEqual(["onPreExtensionResponse", "Lifecycle"]); @@ -1012,7 +1111,7 @@ describe("SlidingSync", () => { onPreExtensionResponse = (resp) => { responseCalled = true; }; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("ext req nothing", body); expect(body.extensions).toBeTruthy(); @@ -1030,7 +1129,7 @@ describe("SlidingSync", () => { const p = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state, resp, err) => { return state === SlidingSyncState.Complete; }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; expect(responseCalled).toBe(false); }); @@ -1041,13 +1140,13 @@ describe("SlidingSync", () => { return extReq; }; let responseCalled = false; - const callbackOrder = []; + const callbackOrder: string[] = []; onPostExtensionResponse = (resp) => { expect(resp).toEqual(extResp); responseCalled = true; callbackOrder.push("onPostExtensionResponse"); }; - httpBackend.when("POST", syncUrl).check(function(req) { + httpBackend!.when("POST", syncUrl).check(function(req) { const body = req.data; logger.log("ext req after start", body); expect(body.extensions).toBeTruthy(); @@ -1071,7 +1170,7 @@ describe("SlidingSync", () => { return true; } }); - await httpBackend.flushAllExpected(); + await httpBackend!.flushAllExpected(); await p; expect(responseCalled).toBe(true); expect(callbackOrder).toEqual(["Lifecycle", "onPostExtensionResponse"]); @@ -1079,16 +1178,27 @@ describe("SlidingSync", () => { }); it("is not possible to register the same extension name twice", async () => { - slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client, 1); + slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client!, 1); slidingSync.registerExtension(extPre); expect(() => { slidingSync.registerExtension(extPre); }).toThrow(); }); }); }); -async function timeout(delayMs: number, reason: string): Promise { - await sleep(delayMs); - throw new Error(`timeout: ${delayMs}ms - ${reason}`); +function timeout(delayMs: number, reason: string): { promise: Promise, cancel: () => void } { + let timeoutId; + return { + promise: new Promise((resolve, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`timeout: ${delayMs}ms - ${reason}`)); + }, delayMs); + }), + cancel: () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }, + }; } /** @@ -1106,19 +1216,22 @@ function listenUntil( callback: (...args: any[]) => T, timeoutMs = 500, ): Promise { - const trace = new Error().stack.split(`\n`)[2]; + const trace = new Error().stack?.split(`\n`)[2]; + 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); + t.cancel(); resolve(data); } } catch (err) { reject(err); + t.cancel(); } }; emitter.on(eventName, wrapper); - }), timeout(timeoutMs, "timed out waiting for event " + eventName + " " + trace)]); + }), t.promise]); } diff --git a/spec/setupTests.ts b/spec/setupTests.ts new file mode 100644 index 000000000..bbd70fe3d --- /dev/null +++ b/spec/setupTests.ts @@ -0,0 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import DOMException from "domexception"; + +global.DOMException = DOMException; diff --git a/spec/test-utils/client.ts b/spec/test-utils/client.ts new file mode 100644 index 000000000..3cacd179d --- /dev/null +++ b/spec/test-utils/client.ts @@ -0,0 +1,94 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MethodKeysOf, mocked, MockedObject } from "jest-mock"; + +import { ClientEventHandlerMap, EmittedEvents, MatrixClient } from "../../src/client"; +import { TypedEventEmitter } from "../../src/models/typed-event-emitter"; +import { User } from "../../src/models/user"; + +/** + * Mock client with real event emitter + * useful for testing code that listens + * to MatrixClient events + */ +export class MockClientWithEventEmitter extends TypedEventEmitter { + constructor(mockProperties: Partial, unknown>> = {}) { + super(); + Object.assign(this, mockProperties); + } +} + +/** + * - make a mock client + * - cast the type to mocked(MatrixClient) + * - spy on MatrixClientPeg.get to return the mock + * eg + * ``` + * const mockClient = getMockClientWithEventEmitter({ + getUserId: jest.fn().mockReturnValue(aliceId), + }); + * ``` + */ +export const getMockClientWithEventEmitter = ( + mockProperties: Partial, unknown>>, +): MockedObject => { + const mock = mocked(new MockClientWithEventEmitter(mockProperties) as unknown as MatrixClient); + return mock; +}; + +/** + * Returns basic mocked client methods related to the current user + * ``` + * const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser('@mytestuser:domain'), + }); + * ``` + */ +export const mockClientMethodsUser = (userId = '@alice:domain') => ({ + getUserId: jest.fn().mockReturnValue(userId), + getUser: jest.fn().mockReturnValue(new User(userId)), + isGuest: jest.fn().mockReturnValue(false), + mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'), + credentials: { userId }, + getThreePids: jest.fn().mockResolvedValue({ threepids: [] }), + getAccessToken: jest.fn(), +}); + +/** + * Returns basic mocked client methods related to rendering events + * ``` + * const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser('@mytestuser:domain'), + }); + * ``` + */ +export const mockClientMethodsEvents = () => ({ + decryptEventIfNeeded: jest.fn(), + getPushActionsForEvent: jest.fn(), +}); + +/** + * Returns basic mocked client methods related to server support + */ +export const mockClientMethodsServer = (): Partial, unknown>> => ({ + doesServerSupportSeparateAddAndBind: jest.fn(), + getIdentityServerUrl: jest.fn(), + getHomeserverUrl: jest.fn(), + getCapabilities: jest.fn().mockReturnValue({}), + doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false), +}); + diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts index 0187b12f5..288e0355b 100644 --- a/spec/test-utils/test-utils.ts +++ b/spec/test-utils/test-utils.ts @@ -74,6 +74,7 @@ interface IEventOpts { sender?: string; skey?: string; content: IContent; + prev_content?: IContent; user?: string; unsigned?: IUnsigned; redacts?: string; @@ -103,6 +104,7 @@ export function mkEvent(opts: IEventOpts & { event?: boolean }, client?: MatrixC room_id: opts.room, sender: opts.sender || opts.user, // opts.user for backwards-compat content: opts.content, + prev_content: opts.prev_content, unsigned: opts.unsigned || {}, event_id: "$" + testEventIndex++ + "-" + Math.random() + "-" + Math.random(), txn_id: "~" + Math.random(), diff --git a/spec/unit/autodiscovery.spec.ts b/spec/unit/autodiscovery.spec.ts index 939f47797..13688c25b 100644 --- a/spec/unit/autodiscovery.spec.ts +++ b/spec/unit/autodiscovery.spec.ts @@ -17,13 +17,12 @@ limitations under the License. import MockHttpBackend from "matrix-mock-request"; -import { request } from "../../src/matrix"; import { AutoDiscovery } from "../../src/autodiscovery"; describe("AutoDiscovery", function() { const getHttpBackend = (): MockHttpBackend => { const httpBackend = new MockHttpBackend(); - request(httpBackend.requestFn); + AutoDiscovery.setFetchFn(httpBackend.fetchFn as typeof global.fetch); return httpBackend; }; @@ -176,8 +175,7 @@ describe("AutoDiscovery", function() { ]); }); - it("should return FAIL_PROMPT when .well-known does not have a base_url for " + - "m.homeserver (empty string)", function() { + it("should return FAIL_PROMPT when .well-known does not have a base_url for m.homeserver (empty string)", () => { const httpBackend = getHttpBackend(); httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { "m.homeserver": { @@ -205,8 +203,7 @@ describe("AutoDiscovery", function() { ]); }); - it("should return FAIL_PROMPT when .well-known does not have a base_url for " + - "m.homeserver (no property)", function() { + it("should return FAIL_PROMPT when .well-known does not have a base_url for m.homeserver (no property)", () => { const httpBackend = getHttpBackend(); httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { "m.homeserver": {}, @@ -232,8 +229,7 @@ describe("AutoDiscovery", function() { ]); }); - it("should return FAIL_ERROR when .well-known has an invalid base_url for " + - "m.homeserver (disallowed scheme)", function() { + it("should return FAIL_ERROR when .well-known has an invalid base_url for m.homeserver (disallowed scheme)", () => { const httpBackend = getHttpBackend(); httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { "m.homeserver": { @@ -679,4 +675,76 @@ describe("AutoDiscovery", function() { }), ]); }); + + it("should return FAIL_PROMPT for connection errors", () => { + const httpBackend = getHttpBackend(); + httpBackend.when("GET", "/.well-known/matrix/client").fail(0, undefined); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID, + base_url: null, + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_PROMPT for fetch errors", () => { + const httpBackend = getHttpBackend(); + httpBackend.when("GET", "/.well-known/matrix/client").fail(0, new Error("CORS or something")); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID, + base_url: null, + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_PROMPT for invalid JSON", () => { + const httpBackend = getHttpBackend(); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, "", true); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID, + base_url: null, + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); }); diff --git a/spec/unit/crypto/backup.spec.ts b/spec/unit/crypto/backup.spec.ts index acec47fce..ca4c09c53 100644 --- a/spec/unit/crypto/backup.spec.ts +++ b/spec/unit/crypto/backup.spec.ts @@ -30,7 +30,7 @@ import { Crypto } from "../../../src/crypto"; import { resetCrossSigningKeys } from "./crypto-utils"; import { BackupManager } from "../../../src/crypto/backup"; import { StubStore } from "../../../src/store/stub"; -import { IAbortablePromise, MatrixScheduler } from '../../../src'; +import { MatrixScheduler } from '../../../src'; const Olm = global.Olm; @@ -131,7 +131,7 @@ function makeTestClient(cryptoStore) { baseUrl: "https://my.home.server", idBaseUrl: "https://identity.server", accessToken: "my.access.token", - request: jest.fn(), // NOP + fetchFn: jest.fn(), // NOP store: store, scheduler: scheduler, userId: "@alice:bar", @@ -197,7 +197,7 @@ describe("MegolmBackup", function() { // 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); + return realSetTimeout(f!, n/100); }); }); @@ -298,25 +298,25 @@ describe("MegolmBackup", function() { }); let numCalls = 0; return new Promise((resolve, reject) => { - client.http.authedRequest = function( - callback, method, path, queryParams, data, opts, - ) { + client.http.authedRequest = function( + method, path, queryParams, data, opts, + ): Promise { ++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 IAbortablePromise; + return Promise.resolve({} as T); } expect(method).toBe("PUT"); expect(path).toBe("/room_keys/keys"); expect(queryParams.version).toBe('1'); - expect(data.rooms[ROOM_ID].sessions).toBeDefined(); - expect(data.rooms[ROOM_ID].sessions).toHaveProperty( + 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 IAbortablePromise; + return Promise.resolve({} as T); }; client.crypto.backupManager.backupGroupSession( "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", @@ -381,25 +381,25 @@ describe("MegolmBackup", function() { }); let numCalls = 0; return new Promise((resolve, reject) => { - client.http.authedRequest = function( - callback, method, path, queryParams, data, opts, - ) { + client.http.authedRequest = function( + method, path, queryParams, data, opts, + ): Promise { ++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 IAbortablePromise; + return Promise.resolve({} as T); } expect(method).toBe("PUT"); expect(path).toBe("/room_keys/keys"); expect(queryParams.version).toBe('1'); - expect(data.rooms[ROOM_ID].sessions).toBeDefined(); - expect(data.rooms[ROOM_ID].sessions).toHaveProperty( + 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 IAbortablePromise; + return Promise.resolve({} as T); }; client.crypto.backupManager.backupGroupSession( "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", @@ -439,7 +439,7 @@ describe("MegolmBackup", function() { new Promise((resolve, reject) => { let backupInfo; client.http.authedRequest = function( - callback, method, path, queryParams, data, opts, + method, path, queryParams, data, opts, ) { ++numCalls; expect(numCalls).toBeLessThanOrEqual(2); @@ -449,23 +449,23 @@ describe("MegolmBackup", function() { try { // make sure auth_data is signed by the master key olmlib.pkVerify( - data.auth_data, client.getCrossSigningId(), "@alice:bar", + (data as Record).auth_data, client.getCrossSigningId(), "@alice:bar", ); } catch (e) { reject(e); - return Promise.resolve({}) as IAbortablePromise; + return Promise.resolve({}); } backupInfo = data; - return Promise.resolve({}) as IAbortablePromise; + return Promise.resolve({}); } else if (numCalls === 2) { expect(method).toBe("GET"); expect(path).toBe("/room_keys/version"); resolve(); - return Promise.resolve(backupInfo) as IAbortablePromise; + return Promise.resolve(backupInfo); } else { // exit out of retry loop if there's something wrong reject(new Error("authedRequest called too many times")); - return Promise.resolve({}) as IAbortablePromise; + return Promise.resolve({}); } }; }), @@ -495,7 +495,7 @@ describe("MegolmBackup", function() { baseUrl: "https://my.home.server", idBaseUrl: "https://identity.server", accessToken: "my.access.token", - request: jest.fn(), // NOP + fetchFn: jest.fn(), // NOP store: store, scheduler: scheduler, userId: "@alice:bar", @@ -542,30 +542,30 @@ describe("MegolmBackup", function() { let numCalls = 0; await new Promise((resolve, reject) => { - client.http.authedRequest = function( - callback, method, path, queryParams, data, opts, - ) { + client.http.authedRequest = function( + method, path, queryParams, data, opts, + ): Promise { ++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 IAbortablePromise; + return Promise.resolve({} as T); } expect(method).toBe("PUT"); expect(path).toBe("/room_keys/keys"); expect(queryParams.version).toBe('1'); - expect(data.rooms[ROOM_ID].sessions).toBeDefined(); - expect(data.rooms[ROOM_ID].sessions).toHaveProperty( + 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 IAbortablePromise; + return Promise.resolve({} as T); } else { return Promise.reject( new Error("this is an expected failure"), - ) as IAbortablePromise; + ); } }; return client.crypto.backupManager.backupGroupSession( diff --git a/spec/unit/crypto/cross-signing.spec.ts b/spec/unit/crypto/cross-signing.spec.ts index 30c1bf82c..bfa7625cb 100644 --- a/spec/unit/crypto/cross-signing.spec.ts +++ b/spec/unit/crypto/cross-signing.spec.ts @@ -141,7 +141,7 @@ describe("Cross Signing", function() { }; alice.uploadKeySignatures = async () => ({ failures: {} }); alice.setAccountData = async () => ({}); - alice.getAccountDataFromServer = async (): Promise => ({} as T); + alice.getAccountDataFromServer = async (): Promise => ({} as T); const authUploadDeviceSigningKeys = async func => await func({}); // Try bootstrap, expecting `authUploadDeviceSigningKeys` to pass diff --git a/spec/unit/crypto/secrets.spec.ts b/spec/unit/crypto/secrets.spec.ts index d6ae8c3a3..7692292ff 100644 --- a/spec/unit/crypto/secrets.spec.ts +++ b/spec/unit/crypto/secrets.spec.ts @@ -109,16 +109,13 @@ describe("Secrets", function() { const secretStorage = alice.crypto.secretStorage; jest.spyOn(alice, 'setAccountData').mockImplementation( - async function(eventType, contents, callback) { + async function(eventType, contents) { alice.store.storeAccountDataEvents([ new MatrixEvent({ type: eventType, content: contents, }), ]); - if (callback) { - callback(undefined, undefined); - } return {}; }); @@ -192,7 +189,7 @@ describe("Secrets", function() { }, }, ); - alice.setAccountData = async function(eventType, contents, callback) { + alice.setAccountData = async function(eventType, contents) { alice.store.storeAccountDataEvents([ new MatrixEvent({ type: eventType, @@ -332,7 +329,7 @@ describe("Secrets", function() { ); bob.uploadDeviceSigningKeys = async () => ({}); bob.uploadKeySignatures = jest.fn().mockResolvedValue(undefined); - bob.setAccountData = async function(eventType, contents, callback) { + bob.setAccountData = async function(eventType, contents) { const event = new MatrixEvent({ type: eventType, content: contents, diff --git a/spec/unit/event-mapper.spec.ts b/spec/unit/event-mapper.spec.ts index a444c34fb..c21348c80 100644 --- a/spec/unit/event-mapper.spec.ts +++ b/spec/unit/event-mapper.spec.ts @@ -29,7 +29,7 @@ describe("eventMapperFor", function() { client = new MatrixClient({ baseUrl: "https://my.home.server", accessToken: "my.access.token", - request: function() {} as any, // NOP + fetchFn: function() {} as any, // NOP store: { getRoom(roomId: string): Room | null { return rooms.find(r => r.roomId === roomId); diff --git a/spec/unit/feature.spec.ts b/spec/unit/feature.spec.ts new file mode 100644 index 000000000..97420947d --- /dev/null +++ b/spec/unit/feature.spec.ts @@ -0,0 +1,62 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { buildFeatureSupportMap, Feature, ServerSupport } from "../../src/feature"; + +describe("Feature detection", () => { + it("checks the matrix version", async () => { + const support = await buildFeatureSupportMap({ + versions: ["v1.3"], + unstable_features: {}, + }); + + expect(support.get(Feature.Thread)).toBe(ServerSupport.Stable); + expect(support.get(Feature.ThreadUnreadNotifications)).toBe(ServerSupport.Unsupported); + }); + + it("checks the matrix msc number", async () => { + const support = await buildFeatureSupportMap({ + versions: ["v1.2"], + unstable_features: { + "org.matrix.msc3771": true, + "org.matrix.msc3773": true, + }, + }); + expect(support.get(Feature.ThreadUnreadNotifications)).toBe(ServerSupport.Unstable); + }); + + it("requires two MSCs to pass", async () => { + const support = await buildFeatureSupportMap({ + versions: ["v1.2"], + unstable_features: { + "org.matrix.msc3771": false, + "org.matrix.msc3773": true, + }, + }); + expect(support.get(Feature.ThreadUnreadNotifications)).toBe(ServerSupport.Unsupported); + }); + + it("requires two MSCs OR matrix versions to pass", async () => { + const support = await buildFeatureSupportMap({ + versions: ["v1.4"], + unstable_features: { + "org.matrix.msc3771": false, + "org.matrix.msc3773": true, + }, + }); + expect(support.get(Feature.ThreadUnreadNotifications)).toBe(ServerSupport.Stable); + }); +}); diff --git a/spec/unit/filter.spec.ts b/spec/unit/filter.spec.ts index faa0f53ca..e246ec1a2 100644 --- a/spec/unit/filter.spec.ts +++ b/spec/unit/filter.spec.ts @@ -1,3 +1,4 @@ +import { UNREAD_THREAD_NOTIFICATIONS } from "../../src/@types/sync"; import { Filter, IFilterDefinition } from "../../src/filter"; describe("Filter", function() { @@ -43,4 +44,17 @@ describe("Filter", function() { expect(filter.getDefinition()).toEqual(definition); }); }); + + describe("setUnreadThreadNotifications", function() { + it("setUnreadThreadNotifications", function() { + filter.setUnreadThreadNotifications(true); + expect(filter.getDefinition()).toEqual({ + room: { + timeline: { + [UNREAD_THREAD_NOTIFICATIONS.name]: true, + }, + }, + }); + }); + }); }); diff --git a/spec/unit/http-api/__snapshots__/index.spec.ts.snap b/spec/unit/http-api/__snapshots__/index.spec.ts.snap new file mode 100644 index 000000000..e6487ddeb --- /dev/null +++ b/spec/unit/http-api/__snapshots__/index.spec.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MatrixHttpApi should return expected object from \`getContentUri\` 1`] = ` +{ + "base": "http://baseUrl", + "params": { + "access_token": "token", + }, + "path": "/_matrix/media/r0/upload", +} +`; diff --git a/spec/unit/http-api/fetch.spec.ts b/spec/unit/http-api/fetch.spec.ts new file mode 100644 index 000000000..e100f2d93 --- /dev/null +++ b/spec/unit/http-api/fetch.spec.ts @@ -0,0 +1,223 @@ +/* +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 { FetchHttpApi } from "../../../src/http-api/fetch"; +import { TypedEventEmitter } from "../../../src/models/typed-event-emitter"; +import { ClientPrefix, HttpApiEvent, HttpApiEventHandlerMap, IdentityPrefix, IHttpOpts, Method } from "../../../src"; +import { emitPromise } from "../../test-utils/test-utils"; + +describe("FetchHttpApi", () => { + const baseUrl = "http://baseUrl"; + const idBaseUrl = "http://idBaseUrl"; + const prefix = ClientPrefix.V3; + + it("should support aborting multiple times", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, prefix, fetchFn }); + + api.request(Method.Get, "/foo"); + api.request(Method.Get, "/baz"); + expect(fetchFn.mock.calls[0][0].href.endsWith("/foo")).toBeTruthy(); + expect(fetchFn.mock.calls[0][1].signal.aborted).toBeFalsy(); + expect(fetchFn.mock.calls[1][0].href.endsWith("/baz")).toBeTruthy(); + expect(fetchFn.mock.calls[1][1].signal.aborted).toBeFalsy(); + + api.abort(); + expect(fetchFn.mock.calls[0][1].signal.aborted).toBeTruthy(); + expect(fetchFn.mock.calls[1][1].signal.aborted).toBeTruthy(); + + api.request(Method.Get, "/bar"); + expect(fetchFn.mock.calls[2][0].href.endsWith("/bar")).toBeTruthy(); + expect(fetchFn.mock.calls[2][1].signal.aborted).toBeFalsy(); + + api.abort(); + expect(fetchFn.mock.calls[2][1].signal.aborted).toBeTruthy(); + }); + + it("should fall back to global fetch if fetchFn not provided", () => { + global.fetch = jest.fn(); + expect(global.fetch).not.toHaveBeenCalled(); + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + api.fetch("test"); + expect(global.fetch).toHaveBeenCalled(); + }); + + it("should update identity server base url", () => { + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + expect(api.opts.idBaseUrl).toBeUndefined(); + api.setIdBaseUrl("https://id.foo.bar"); + expect(api.opts.idBaseUrl).toBe("https://id.foo.bar"); + }); + + 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"); + }); + + it("should send params as query string for GET requests", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, idBaseUrl, prefix, fetchFn }); + api.idServerRequest(Method.Get, "/test", { foo: "bar", via: ["a", "b"] }, IdentityPrefix.V2); + expect(fetchFn.mock.calls[0][0].searchParams.get("foo")).toBe("bar"); + expect(fetchFn.mock.calls[0][0].searchParams.getAll("via")).toEqual(["a", "b"]); + }); + + it("should send params as body for non-GET requests", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, idBaseUrl, prefix, fetchFn }); + const params = { foo: "bar", via: ["a", "b"] }; + api.idServerRequest(Method.Post, "/test", params, IdentityPrefix.V2); + expect(fetchFn.mock.calls[0][0].searchParams.get("foo")).not.toBe("bar"); + expect(JSON.parse(fetchFn.mock.calls[0][1].body)).toStrictEqual(params); + }); + + it("should add Authorization header if token provided", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, idBaseUrl, prefix, fetchFn }); + api.idServerRequest(Method.Post, "/test", {}, IdentityPrefix.V2, "token"); + expect(fetchFn.mock.calls[0][1].headers.Authorization).toBe("Bearer token"); + }); + }); + + it("should return the Response object if onlyData=false", async () => { + const res = { ok: true }; + const fetchFn = jest.fn().mockResolvedValue(res); + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, prefix, fetchFn, onlyData: false }); + await expect(api.requestOtherUrl(Method.Get, "http://url")).resolves.toBe(res); + }); + + it("should return text if json=false", async () => { + 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); + }); + + it("should send token via query params if useAuthorizationHeader=false", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + fetchFn, + accessToken: "token", + useAuthorizationHeader: false, + }); + api.authedRequest(Method.Get, "/path"); + expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBe("token"); + }); + + it("should send token via headers by default", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + fetchFn, + accessToken: "token", + }); + api.authedRequest(Method.Get, "/path"); + expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBe("Bearer token"); + }); + + it("should not send a token if not calling `authedRequest`", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + fetchFn, + accessToken: "token", + }); + api.request(Method.Get, "/path"); + expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBeFalsy(); + expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBeFalsy(); + }); + + it("should ensure no token is leaked out via query params if sending via headers", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + fetchFn, + accessToken: "token", + useAuthorizationHeader: true, + }); + api.authedRequest(Method.Get, "/path", { access_token: "123" }); + expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBeFalsy(); + expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBe("Bearer token"); + }); + + it("should not override manually specified access token via query params", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + fetchFn, + accessToken: "token", + useAuthorizationHeader: false, + }); + api.authedRequest(Method.Get, "/path", { access_token: "RealToken" }); + expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBe("RealToken"); + }); + + it("should not override manually specified access token via header", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + fetchFn, + accessToken: "token", + useAuthorizationHeader: true, + }); + api.authedRequest(Method.Get, "/path", undefined, undefined, { + headers: { Authorization: "Bearer RealToken" }, + }); + expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBe("Bearer RealToken"); + }); + + it("should not override Accept header", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, prefix, fetchFn }); + api.authedRequest(Method.Get, "/path", undefined, undefined, { + headers: { Accept: "text/html" }, + }); + expect(fetchFn.mock.calls[0][1].headers["Accept"]).toBe("text/html"); + }); + + it("should emit NoConsent when given errcode=M_CONTENT_NOT_GIVEN", async () => { + const fetchFn = jest.fn().mockResolvedValue({ + ok: false, + headers: { + get(name: string): string | null { + return name === "Content-Type" ? "application/json" : null; + }, + }, + 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 }); + + await Promise.all([ + emitPromise(emitter, HttpApiEvent.NoConsent), + expect(api.authedRequest(Method.Get, "/path")).rejects.toThrow("Ye shall ask for consent"), + ]); + }); +}); diff --git a/spec/unit/http-api/index.spec.ts b/spec/unit/http-api/index.spec.ts new file mode 100644 index 000000000..89e122452 --- /dev/null +++ b/spec/unit/http-api/index.spec.ts @@ -0,0 +1,228 @@ +/* +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 DOMException from "domexception"; +import { mocked } from "jest-mock"; + +import { ClientPrefix, MatrixHttpApi, Method, UploadResponse } from "../../../src"; +import { TypedEventEmitter } from "../../../src/models/typed-event-emitter"; + +type Writeable = { -readonly [P in keyof T]: T[P] }; + +jest.useFakeTimers(); + +describe("MatrixHttpApi", () => { + const baseUrl = "http://baseUrl"; + const prefix = ClientPrefix.V3; + + let xhr: Partial>; + let upload: Promise; + + const DONE = 0; + + global.DOMException = DOMException; + + beforeEach(() => { + xhr = { + upload: {} as XMLHttpRequestUpload, + open: jest.fn(), + send: jest.fn(), + abort: jest.fn(), + setRequestHeader: jest.fn(), + onreadystatechange: undefined, + getResponseHeader: jest.fn(), + }; + // We stub out XHR here as it is not available in JSDOM + // @ts-ignore + global.XMLHttpRequest = jest.fn().mockReturnValue(xhr); + // @ts-ignore + global.XMLHttpRequest.DONE = DONE; + }); + + afterEach(() => { + upload?.catch(() => {}); + // Abort any remaining requests + xhr.readyState = DONE; + xhr.status = 0; + // @ts-ignore + xhr.onreadystatechange?.(new Event("test")); + }); + + it("should fall back to `fetch` where xhr is unavailable", () => { + global.XMLHttpRequest = undefined; + const fetchFn = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({}) }); + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix, fetchFn }); + upload = api.uploadContent({} as File); + expect(fetchFn).toHaveBeenCalled(); + }); + + it("should prefer xhr where available", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix, fetchFn }); + upload = api.uploadContent({} as File); + expect(fetchFn).not.toHaveBeenCalled(); + expect(xhr.open).toHaveBeenCalled(); + }); + + it("should send access token in query params if header disabled", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + accessToken: "token", + useAuthorizationHeader: false, + }); + upload = api.uploadContent({} as File); + expect(xhr.open) + .toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/r0/upload?access_token=token"); + expect(xhr.setRequestHeader).not.toHaveBeenCalledWith("Authorization"); + }); + + it("should send access token in header by default", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + accessToken: "token", + }); + upload = api.uploadContent({} as File); + expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/r0/upload"); + expect(xhr.setRequestHeader).toHaveBeenCalledWith("Authorization", "Bearer token"); + }); + + 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"); + }); + + it("should allow not sending the filename", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File, { name: "name", includeFilename: false }); + expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/r0/upload"); + }); + + it("should abort xhr when the upload is aborted", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File); + api.cancelUpload(upload); + expect(xhr.abort).toHaveBeenCalled(); + return expect(upload).rejects.toThrow("Aborted"); + }); + + it("should timeout if no progress in 30s", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File); + jest.advanceTimersByTime(25000); + // @ts-ignore + xhr.upload.onprogress(new Event("progress", { loaded: 1, total: 100 })); + jest.advanceTimersByTime(25000); + expect(xhr.abort).not.toHaveBeenCalled(); + jest.advanceTimersByTime(5000); + expect(xhr.abort).toHaveBeenCalled(); + }); + + it("should call progressHandler", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + const progressHandler = jest.fn(); + upload = api.uploadContent({} as File, { progressHandler }); + const progressEvent = new Event("progress") as ProgressEvent; + Object.assign(progressEvent, { loaded: 1, total: 100 }); + // @ts-ignore + xhr.upload.onprogress(progressEvent); + expect(progressHandler).toHaveBeenCalledWith({ loaded: 1, total: 100 }); + + Object.assign(progressEvent, { loaded: 95, total: 100 }); + // @ts-ignore + xhr.upload.onprogress(progressEvent); + expect(progressHandler).toHaveBeenCalledWith({ loaded: 95, total: 100 }); + }); + + it("should error when no response body", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File); + + xhr.readyState = DONE; + xhr.responseText = ""; + xhr.status = 200; + // @ts-ignore + xhr.onreadystatechange?.(new Event("test")); + + return expect(upload).rejects.toThrow("No response body."); + }); + + it("should error on a 400-code", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File); + + xhr.readyState = DONE; + xhr.responseText = '{"errcode": "M_NOT_FOUND", "error": "Not found"}'; + xhr.status = 404; + mocked(xhr.getResponseHeader).mockReturnValue("application/json"); + // @ts-ignore + xhr.onreadystatechange?.(new Event("test")); + + return expect(upload).rejects.toThrow("Not found"); + }); + + it("should return response on successful upload", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File); + + xhr.readyState = DONE; + xhr.responseText = '{"content_uri": "mxc://server/foobar"}'; + xhr.status = 200; + mocked(xhr.getResponseHeader).mockReturnValue("application/json"); + // @ts-ignore + xhr.onreadystatechange?.(new Event("test")); + + return expect(upload).resolves.toStrictEqual({ content_uri: "mxc://server/foobar" }); + }); + + it("should abort xhr when calling `cancelUpload`", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File); + expect(api.cancelUpload(upload)).toBeTruthy(); + expect(xhr.abort).toHaveBeenCalled(); + }); + + it("should return false when `cancelUpload` is called but unsuccessful", async () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File); + + xhr.readyState = DONE; + xhr.status = 500; + mocked(xhr.getResponseHeader).mockReturnValue("application/json"); + // @ts-ignore + xhr.onreadystatechange?.(new Event("test")); + await upload.catch(() => {}); + + expect(api.cancelUpload(upload)).toBeFalsy(); + expect(xhr.abort).not.toHaveBeenCalled(); + }); + + 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(); + api.cancelUpload(upload); + expect(api.getCurrentUploads().find(u => u.promise === upload)).toBeFalsy(); + }); + + it("should return expected object from `getContentUri`", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix, accessToken: "token" }); + expect(api.getContentUri()).toMatchSnapshot(); + }); +}); diff --git a/spec/unit/http-api/utils.spec.ts b/spec/unit/http-api/utils.spec.ts new file mode 100644 index 000000000..1a266c842 --- /dev/null +++ b/spec/unit/http-api/utils.spec.ts @@ -0,0 +1,183 @@ +/* +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 { mocked } from "jest-mock"; + +import { + anySignal, + ConnectionError, + MatrixError, + parseErrorResponse, + retryNetworkOperation, + timeoutSignal, +} from "../../../src"; +import { sleep } from "../../../src/utils"; + +jest.mock("../../../src/utils"); + +describe("timeoutSignal", () => { + jest.useFakeTimers(); + + it("should fire abort signal after specified timeout", () => { + const signal = timeoutSignal(3000); + const onabort = jest.fn(); + signal.onabort = onabort; + expect(signal.aborted).toBeFalsy(); + expect(onabort).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(3000); + expect(signal.aborted).toBeTruthy(); + expect(onabort).toHaveBeenCalled(); + }); +}); + +describe("anySignal", () => { + jest.useFakeTimers(); + + it("should fire when any signal fires", () => { + const { signal } = anySignal([ + timeoutSignal(3000), + timeoutSignal(2000), + ]); + + const onabort = jest.fn(); + signal.onabort = onabort; + expect(signal.aborted).toBeFalsy(); + expect(onabort).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(2000); + expect(signal.aborted).toBeTruthy(); + expect(onabort).toHaveBeenCalled(); + }); + + it("should cleanup when instructed", () => { + const { signal, cleanup } = anySignal([ + timeoutSignal(3000), + timeoutSignal(2000), + ]); + + const onabort = jest.fn(); + signal.onabort = onabort; + expect(signal.aborted).toBeFalsy(); + expect(onabort).not.toHaveBeenCalled(); + + cleanup(); + jest.advanceTimersByTime(2000); + expect(signal.aborted).toBeFalsy(); + expect(onabort).not.toHaveBeenCalled(); + }); + + it("should abort immediately if passed an aborted signal", () => { + const controller = new AbortController(); + controller.abort(); + const { signal } = anySignal([controller.signal]); + expect(signal.aborted).toBeTruthy(); + }); +}); + +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)); + }); + + it("should resolve Matrix Errors from fetch", () => { + 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", + }, 500)); + }); + + it("should handle no type gracefully", () => { + expect(parseErrorResponse({ + headers: { + get(name: string): string | null { + return null; + }, + }, + status: 500, + } as Response, '{"errcode": "TEST"}')).toStrictEqual(new Error("Server returned 500 error")); + }); + + 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")); + }); + + 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 Error("Server returned 418 error: I'm a teapot")); + }); +}); + +describe("retryNetworkOperation", () => { + it("should retry given number of times with exponential sleeps", async () => { + const err = new ConnectionError("test"); + const fn = jest.fn().mockRejectedValue(err); + mocked(sleep).mockResolvedValue(undefined); + await expect(retryNetworkOperation(4, fn)).rejects.toThrow(err); + expect(fn).toHaveBeenCalledTimes(4); + expect(mocked(sleep)).toHaveBeenCalledTimes(3); + expect(mocked(sleep).mock.calls[0][0]).toBe(2000); + expect(mocked(sleep).mock.calls[1][0]).toBe(4000); + expect(mocked(sleep).mock.calls[2][0]).toBe(8000); + }); + + it("should bail out on errors other than ConnectionError", async () => { + const err = new TypeError("invalid JSON"); + const fn = jest.fn().mockRejectedValue(err); + mocked(sleep).mockResolvedValue(undefined); + await expect(retryNetworkOperation(3, fn)).rejects.toThrow(err); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("should return newest ConnectionError when giving up", async () => { + const err1 = new ConnectionError("test1"); + const err2 = new ConnectionError("test2"); + const err3 = new ConnectionError("test3"); + const errors = [err1, err2, err3]; + const fn = jest.fn().mockImplementation(() => { + throw errors.shift(); + }); + mocked(sleep).mockResolvedValue(undefined); + await expect(retryNetworkOperation(3, fn)).rejects.toThrow(err3); + }); +}); diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 98c496d76..dbd95e68e 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -103,7 +103,7 @@ describe("MatrixClient", function() { ]; let acceptKeepalives: boolean; let pendingLookup = null; - function httpReq(cb, method, path, qp, data, prefix) { + function httpReq(method, path, qp, data, prefix) { if (path === KEEP_ALIVE_PATH && acceptKeepalives) { return Promise.resolve({ unstable_features: { @@ -132,7 +132,6 @@ describe("MatrixClient", function() { method: method, path: path, }; - pendingLookup.promise.abort = () => {}; // to make it a valid IAbortablePromise return pendingLookup.promise; } if (next.path === path && next.method === method) { @@ -178,7 +177,7 @@ describe("MatrixClient", function() { baseUrl: "https://my.home.server", idBaseUrl: identityServerUrl, accessToken: "my.access.token", - request: function() {} as any, // NOP + fetchFn: function() {} as any, // NOP store: store, scheduler: scheduler, userId: userId, @@ -1153,8 +1152,7 @@ describe("MatrixClient", function() { // event type combined const expectedEventType = M_BEACON_INFO.name; - const [callback, method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0]; - expect(callback).toBeFalsy(); + const [method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0]; expect(method).toBe('PUT'); expect(path).toEqual( `/rooms/${encodeURIComponent(roomId)}/state/` + @@ -1168,7 +1166,7 @@ describe("MatrixClient", function() { await client.unstable_setLiveBeacon(roomId, content); // event type combined - const [, , path, , requestContent] = client.http.authedRequest.mock.calls[0]; + const [, path, , requestContent] = client.http.authedRequest.mock.calls[0]; expect(path).toEqual( `/rooms/${encodeURIComponent(roomId)}/state/` + `${encodeURIComponent(M_BEACON_INFO.name)}/${encodeURIComponent(userId)}`, @@ -1229,7 +1227,7 @@ describe("MatrixClient", function() { it("is called with plain text topic and callback and sends state event", async () => { const sendStateEvent = createSendStateEventMock("pizza"); client.sendStateEvent = sendStateEvent; - await client.setRoomTopic(roomId, "pizza", () => {}); + await client.setRoomTopic(roomId, "pizza"); expect(sendStateEvent).toHaveBeenCalledTimes(1); }); @@ -1244,15 +1242,9 @@ describe("MatrixClient", function() { describe("setPassword", () => { const auth = { session: 'abcdef', type: 'foo' }; const newPassword = 'newpassword'; - const callback = () => {}; - const passwordTest = (expectedRequestContent: any, expectedCallback?: Function) => { - const [callback, method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0]; - if (expectedCallback) { - expect(callback).toBe(expectedCallback); - } else { - expect(callback).toBeFalsy(); - } + const passwordTest = (expectedRequestContent: any) => { + const [method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0]; expect(method).toBe('POST'); expect(path).toEqual('/account/password'); expect(queryParams).toBeFalsy(); @@ -1269,8 +1261,8 @@ describe("MatrixClient", function() { }); it("no logout_devices specified + callback", async () => { - await client.setPassword(auth, newPassword, callback); - passwordTest({ auth, new_password: newPassword }, callback); + await client.setPassword(auth, newPassword); + passwordTest({ auth, new_password: newPassword }); }); it("overload logoutDevices=true", async () => { @@ -1279,8 +1271,8 @@ describe("MatrixClient", function() { }); it("overload logoutDevices=true + callback", async () => { - await client.setPassword(auth, newPassword, true, callback); - passwordTest({ auth, new_password: newPassword, logout_devices: true }, callback); + await client.setPassword(auth, newPassword, true); + passwordTest({ auth, new_password: newPassword, logout_devices: true }); }); it("overload logoutDevices=false", async () => { @@ -1289,8 +1281,8 @@ describe("MatrixClient", function() { }); it("overload logoutDevices=false + callback", async () => { - await client.setPassword(auth, newPassword, false, callback); - passwordTest({ auth, new_password: newPassword, logout_devices: false }, callback); + await client.setPassword(auth, newPassword, false); + passwordTest({ auth, new_password: newPassword, logout_devices: false }); }); }); @@ -1305,8 +1297,7 @@ describe("MatrixClient", function() { const result = await client.getLocalAliases(roomId); // Current version of the endpoint we support is v3 - const [callback, method, path, queryParams, data, opts] = client.http.authedRequest.mock.calls[0]; - expect(callback).toBeFalsy(); + const [method, path, queryParams, data, opts] = client.http.authedRequest.mock.calls[0]; expect(data).toBeFalsy(); expect(method).toBe('GET'); expect(path).toEqual(`/rooms/${encodeURIComponent(roomId)}/aliases`); diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index fdc8101eb..ef099fede 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -890,9 +890,8 @@ describe("MSC3089TreeSpace", () => { expect(contents.length).toEqual(fileContents.length); expect(opts).toMatchObject({ includeFilename: false, - onlyContentUri: true, // because the tests rely on this - we shouldn't really be testing for this. }); - return Promise.resolve(mxc); + return Promise.resolve({ content_uri: mxc }); }); client.uploadContent = uploadFn; @@ -950,9 +949,8 @@ describe("MSC3089TreeSpace", () => { expect(contents.length).toEqual(fileContents.length); expect(opts).toMatchObject({ includeFilename: false, - onlyContentUri: true, // because the tests rely on this - we shouldn't really be testing for this. }); - return Promise.resolve(mxc); + return Promise.resolve({ content_uri: mxc }); }); client.uploadContent = uploadFn; diff --git a/spec/unit/notifications.spec.ts b/spec/unit/notifications.spec.ts new file mode 100644 index 000000000..def7ef820 --- /dev/null +++ b/spec/unit/notifications.spec.ts @@ -0,0 +1,134 @@ +/* +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 { Feature, ServerSupport } from "../../src/feature"; +import { + EventType, + fixNotificationCountOnDecryption, + MatrixClient, + MatrixEvent, + MsgType, + NotificationCountType, + RelationType, + Room, + RoomEvent, +} from "../../src/matrix"; +import { IActionsObject } from "../../src/pushprocessor"; +import { ReEmitter } from "../../src/ReEmitter"; +import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../test-utils/client"; +import { mkEvent, mock } from "../test-utils/test-utils"; + +let mockClient: MatrixClient; +let room: Room; +let event: MatrixEvent; +let threadEvent: MatrixEvent; + +const ROOM_ID = "!roomId:example.org"; +let THREAD_ID; + +function mkPushAction(notify, highlight): IActionsObject { + return { + notify, + tweaks: { + highlight, + }, + }; +} + +describe("fixNotificationCountOnDecryption", () => { + beforeEach(() => { + mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(), + getPushActionsForEvent: jest.fn().mockReturnValue(mkPushAction(true, true)), + getRoom: jest.fn().mockImplementation(() => room), + decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0), + supportsExperimentalThreads: jest.fn().mockReturnValue(true), + }); + mockClient.reEmitter = mock(ReEmitter, 'ReEmitter'); + mockClient.canSupport = new Map(); + Object.keys(Feature).forEach(feature => { + mockClient.canSupport.set(feature as Feature, ServerSupport.Stable); + }); + + room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? ""); + room.setUnreadNotificationCount(NotificationCountType.Total, 1); + room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); + + event = mkEvent({ + type: EventType.RoomMessage, + content: { + msgtype: MsgType.Text, + body: "Hello world!", + }, + event: true, + }, mockClient); + + THREAD_ID = event.getId(); + threadEvent = mkEvent({ + type: EventType.RoomMessage, + content: { + "m.relates_to": { + rel_type: RelationType.Thread, + event_id: THREAD_ID, + }, + "msgtype": MsgType.Text, + "body": "Thread reply", + }, + event: true, + }); + room.createThread(THREAD_ID, event, [threadEvent], false); + + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 1); + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0); + + event.getPushActions = jest.fn().mockReturnValue(mkPushAction(false, false)); + threadEvent.getPushActions = jest.fn().mockReturnValue(mkPushAction(false, false)); + }); + + it("changes the room count to highlight on decryption", () => { + expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(2); + expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toBe(0); + + fixNotificationCountOnDecryption(mockClient, event); + + expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(2); + expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toBe(1); + }); + + it("changes the thread count to highlight on decryption", () => { + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(1); + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(0); + + fixNotificationCountOnDecryption(mockClient, threadEvent); + + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(1); + expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(1); + }); + + it("emits events", () => { + const cb = jest.fn(); + room.on(RoomEvent.UnreadNotifications, cb); + + room.setUnreadNotificationCount(NotificationCountType.Total, 1); + expect(cb).toHaveBeenLastCalledWith({ highlight: 0, total: 1 }); + + room.setUnreadNotificationCount(NotificationCountType.Highlight, 5); + expect(cb).toHaveBeenLastCalledWith({ highlight: 5, total: 1 }); + + room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 5); + expect(cb).toHaveBeenLastCalledWith({ highlight: 5 }, "$123"); + }); +}); diff --git a/spec/unit/pusher.spec.ts b/spec/unit/pusher.spec.ts index 4a27ef55b..dd46770a4 100644 --- a/spec/unit/pusher.spec.ts +++ b/spec/unit/pusher.spec.ts @@ -16,7 +16,7 @@ limitations under the License. import MockHttpBackend from 'matrix-mock-request'; -import { IHttpOpts, MatrixClient, PUSHER_ENABLED } from "../../src/matrix"; +import { MatrixClient, PUSHER_ENABLED } from "../../src/matrix"; import { mkPusher } from '../test-utils/test-utils'; const realSetTimeout = setTimeout; @@ -35,7 +35,7 @@ describe("Pushers", () => { client = new MatrixClient({ baseUrl: "https://my.home.server", accessToken: "my.access.token", - request: httpBackend.requestFn as unknown as IHttpOpts["request"], + fetchFn: httpBackend.fetchFn as typeof global.fetch, }); }); diff --git a/spec/unit/pushprocessor.spec.ts b/spec/unit/pushprocessor.spec.ts index 3bbdd5233..db4d2a417 100644 --- a/spec/unit/pushprocessor.spec.ts +++ b/spec/unit/pushprocessor.spec.ts @@ -1,6 +1,6 @@ import * as utils from "../test-utils/test-utils"; -import { PushProcessor } from "../../src/pushprocessor"; -import { EventType, MatrixClient, MatrixEvent } from "../../src"; +import { IActionsObject, PushProcessor } from "../../src/pushprocessor"; +import { EventType, IContent, MatrixClient, MatrixEvent } from "../../src"; describe('NotificationService', function() { const testUserId = "@ali:matrix.org"; @@ -336,4 +336,102 @@ describe('NotificationService', function() { enabled: true, }, testEvent)).toBe(true); }); + + describe("performCustomEventHandling()", () => { + const getActionsForEvent = (prevContent: IContent, content: IContent): IActionsObject => { + testEvent = utils.mkEvent({ + type: "org.matrix.msc3401.call", + room: testRoomId, + user: "@alice:foo", + skey: "state_key", + event: true, + content: content, + prev_content: prevContent, + }); + + return pushProcessor.actionsForEvent(testEvent); + }; + + const assertDoesNotify = (actions: IActionsObject): void => { + expect(actions.notify).toBeTruthy(); + expect(actions.tweaks.sound).toBeTruthy(); + expect(actions.tweaks.highlight).toBeFalsy(); + }; + + const assertDoesNotNotify = (actions: IActionsObject): void => { + expect(actions.notify).toBeFalsy(); + expect(actions.tweaks.sound).toBeFalsy(); + expect(actions.tweaks.highlight).toBeFalsy(); + }; + + it.each( + ["m.ring", "m.prompt"], + )("should notify when new group call event appears with %s intent", (intent: string) => { + assertDoesNotify(getActionsForEvent({}, { + "m.intent": intent, + "m.type": "m.voice", + "m.name": "Call", + })); + }); + + it("should notify when a call is un-terminated", () => { + assertDoesNotify(getActionsForEvent({ + "m.intent": "m.ring", + "m.type": "m.voice", + "m.name": "Call", + "m.terminated": "All users left", + }, { + "m.intent": "m.ring", + "m.type": "m.voice", + "m.name": "Call", + })); + }); + + it("should not notify when call is terminated", () => { + assertDoesNotNotify(getActionsForEvent({ + "m.intent": "m.ring", + "m.type": "m.voice", + "m.name": "Call", + }, { + "m.intent": "m.ring", + "m.type": "m.voice", + "m.name": "Call", + "m.terminated": "All users left", + })); + }); + + it("should ignore with m.room intent", () => { + assertDoesNotNotify(getActionsForEvent({}, { + "m.intent": "m.room", + "m.type": "m.voice", + "m.name": "Call", + })); + }); + + describe("ignoring non-relevant state changes", () => { + it("should ignore intent changes", () => { + assertDoesNotNotify(getActionsForEvent({ + "m.intent": "m.ring", + "m.type": "m.voice", + "m.name": "Call", + }, { + "m.intent": "m.ring", + "m.type": "m.video", + "m.name": "Call", + })); + }); + + it("should ignore name changes", () => { + assertDoesNotNotify(getActionsForEvent({ + "m.intent": "m.ring", + "m.type": "m.voice", + "m.name": "Call", + }, { + "m.intent": "m.ring", + "m.type": "m.voice", + "m.name": "New call", + })); + }); + }); + }); }); diff --git a/spec/unit/queueToDevice.spec.ts b/spec/unit/queueToDevice.spec.ts index d4a8b05a6..735407c56 100644 --- a/spec/unit/queueToDevice.spec.ts +++ b/spec/unit/queueToDevice.spec.ts @@ -17,7 +17,7 @@ limitations under the License. import MockHttpBackend from 'matrix-mock-request'; import { indexedDB as fakeIndexedDB } from 'fake-indexeddb'; -import { IHttpOpts, IndexedDBStore, MatrixEvent, MemoryStore, Room } from "../../src"; +import { IndexedDBStore, MatrixEvent, MemoryStore, Room } from "../../src"; import { MatrixClient } from "../../src/client"; import { ToDeviceBatch } from '../../src/models/ToDeviceMessage'; import { logger } from '../../src/logger'; @@ -77,7 +77,7 @@ describe.each([ client = new MatrixClient({ baseUrl: "https://my.home.server", accessToken: "my.access.token", - request: httpBackend.requestFn as IHttpOpts["request"], + fetchFn: httpBackend.fetchFn as typeof global.fetch, store, }); }); diff --git a/spec/unit/read-receipt.spec.ts b/spec/unit/read-receipt.spec.ts index 2e906a3cc..07acaa184 100644 --- a/spec/unit/read-receipt.spec.ts +++ b/spec/unit/read-receipt.spec.ts @@ -18,7 +18,6 @@ import MockHttpBackend from 'matrix-mock-request'; import { ReceiptType } from '../../src/@types/read_receipts'; import { MatrixClient } from "../../src/client"; -import { IHttpOpts } from '../../src/http-api'; import { EventType } from '../../src/matrix'; import { MAIN_ROOM_TIMELINE } from '../../src/models/read-receipt'; import { encodeUri } from '../../src/utils'; @@ -87,7 +86,7 @@ describe("Read receipt", () => { client = new MatrixClient({ baseUrl: "https://my.home.server", accessToken: "my.access.token", - request: httpBackend.requestFn as unknown as IHttpOpts["request"], + fetchFn: httpBackend.fetchFn as typeof global.fetch, }); client.isGuest = () => false; }); @@ -146,5 +145,23 @@ describe("Read receipt", () => { await httpBackend.flushAllExpected(); await flushPromises(); }); + + 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, {}); + + mockServerSideSupport(client, false); + client.sendReceipt(threadEvent, ReceiptType.Read, undefined); + + await httpBackend.flushAllExpected(); + await flushPromises(); + }); }); }); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index e79fb7110..902437e0b 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -32,13 +32,13 @@ import { RoomEvent, } from "../../src"; import { EventTimeline } from "../../src/models/event-timeline"; -import { Room } from "../../src/models/room"; +import { NotificationCountType, Room } from "../../src/models/room"; import { RoomState } from "../../src/models/room-state"; import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event"; import { TestClient } from "../TestClient"; import { emitPromise } from "../test-utils/test-utils"; import { ReceiptType } from "../../src/@types/read_receipts"; -import { Thread, ThreadEvent } from "../../src/models/thread"; +import { FeatureSupport, Thread, ThreadEvent } from "../../src/models/thread"; import { WrappedReceipt } from "../../src/models/read-receipt"; describe("Room", function() { @@ -2408,7 +2408,7 @@ describe("Room", function() { }); it("should aggregate relations in thread event timeline set", () => { - Thread.setServerSideSupport(true, true); + Thread.setServerSideSupport(FeatureSupport.Stable); const threadRoot = mkMessage(); const rootReaction = mkReaction(threadRoot); const threadResponse = mkThreadResponse(threadRoot); @@ -2562,4 +2562,78 @@ describe("Room", function() { expect(client.roomNameGenerator).toHaveBeenCalled(); }); }); + + describe("thread notifications", () => { + let room; + + beforeEach(() => { + const client = new TestClient(userA).client; + room = new Room(roomId, client, userA); + }); + + it("defaults to undefined", () => { + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(0); + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBe(0); + }); + + it("lets you set values", () => { + room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 1); + + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(1); + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBe(0); + + room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 10); + + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(1); + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBe(10); + }); + + it("lets you reset threads notifications", () => { + room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 666); + room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 123); + + expect(room.getThreadsAggregateNotificationType()).toBe(NotificationCountType.Highlight); + + room.resetThreadUnreadNotificationCount(); + + expect(room.getThreadsAggregateNotificationType()).toBe(null); + + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(0); + expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBe(0); + }); + + it("sets the room threads notification type", () => { + room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 666); + expect(room.getThreadsAggregateNotificationType()).toBe(NotificationCountType.Total); + room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 123); + expect(room.getThreadsAggregateNotificationType()).toBe(NotificationCountType.Highlight); + room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 333); + expect(room.getThreadsAggregateNotificationType()).toBe(NotificationCountType.Highlight); + }); + }); + + describe("hasThreadUnreadNotification", () => { + it('has no notifications by default', () => { + expect(room.hasThreadUnreadNotification()).toBe(false); + }); + + it('main timeline notification does not affect this', () => { + room.setUnreadNotificationCount(NotificationCountType.Highlight, 1); + expect(room.hasThreadUnreadNotification()).toBe(false); + room.setUnreadNotificationCount(NotificationCountType.Total, 1); + expect(room.hasThreadUnreadNotification()).toBe(false); + + room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 1); + expect(room.hasThreadUnreadNotification()).toBe(true); + }); + + it('lets you reset', () => { + room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 1); + expect(room.hasThreadUnreadNotification()).toBe(true); + + room.resetThreadUnreadNotificationCount(); + + expect(room.hasThreadUnreadNotification()).toBe(false); + }); + }); }); diff --git a/spec/unit/stores/indexeddb.spec.ts b/spec/unit/stores/indexeddb.spec.ts index 3fc7477cc..d0dd87243 100644 --- a/spec/unit/stores/indexeddb.spec.ts +++ b/spec/unit/stores/indexeddb.spec.ts @@ -20,6 +20,7 @@ import 'jest-localstorage-mock'; import { IndexedDBStore, IStateEventWithRoomId, MemoryStore } from "../../../src"; import { emitPromise } from "../../test-utils/test-utils"; import { LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend"; +import { defer } from "../../../src/utils"; describe("IndexedDBStore", () => { afterEach(() => { @@ -111,4 +112,57 @@ describe("IndexedDBStore", () => { await store.setPendingEvents(roomId, []); expect(localStorage.getItem("mx_pending_events_" + roomId)).toBeNull(); }); + + it("should resolve isNewlyCreated to true if no database existed initially", async () => { + const store = new IndexedDBStore({ + indexedDB, + dbName: "db1", + localStorage, + }); + await store.startup(); + + await expect(store.isNewlyCreated()).resolves.toBeTruthy(); + }); + + it("should resolve isNewlyCreated to false if database existed already", async () => { + let store = new IndexedDBStore({ + indexedDB, + dbName: "db2", + localStorage, + }); + await store.startup(); + + store = new IndexedDBStore({ + indexedDB, + dbName: "db2", + localStorage, + }); + await store.startup(); + + await expect(store.isNewlyCreated()).resolves.toBeFalsy(); + }); + + it("should resolve isNewlyCreated to false if database existed already but needs upgrade", async () => { + const deferred = defer(); + // seed db3 to Version 1 so it forces a migration + const req = indexedDB.open("matrix-js-sdk:db3", 1); + req.onupgradeneeded = () => { + const db = req.result; + db.createObjectStore("users", { keyPath: ["userId"] }); + db.createObjectStore("accountData", { keyPath: ["type"] }); + db.createObjectStore("sync", { keyPath: ["clobber"] }); + }; + req.onsuccess = deferred.resolve; + await deferred.promise; + req.result.close(); + + const store = new IndexedDBStore({ + indexedDB, + dbName: "db3", + localStorage, + }); + await store.startup(); + + await expect(store.isNewlyCreated()).resolves.toBeFalsy(); + }); }); diff --git a/spec/unit/sync-accumulator.spec.ts b/spec/unit/sync-accumulator.spec.ts index 645efbfbb..5618dbe22 100644 --- a/spec/unit/sync-accumulator.spec.ts +++ b/spec/unit/sync-accumulator.spec.ts @@ -30,6 +30,12 @@ const RES_WITH_AGE = { account_data: { events: [] }, ephemeral: { events: [] }, unread_notifications: {}, + unread_thread_notifications: { + "$143273582443PhrSn:example.org": { + highlight_count: 0, + notification_count: 1, + }, + }, timeline: { events: [ Object.freeze({ @@ -439,6 +445,13 @@ describe("SyncAccumulator", function() { Object.keys(RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0]), ); }); + + it("should retrieve unread thread notifications", () => { + sa.accumulate(RES_WITH_AGE); + const output = sa.getJSON(); + expect(output.roomsData.join["!foo:bar"] + .unread_thread_notifications["$143273582443PhrSn:example.org"]).not.toBeUndefined(); + }); }); }); diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts index 5d804022e..1b11f2a7c 100644 --- a/spec/unit/utils.spec.ts +++ b/spec/unit/utils.spec.ts @@ -26,9 +26,7 @@ describe("utils", function() { foo: "bar", baz: "beer@", }; - expect(utils.encodeParams(params)).toEqual( - "foo=bar&baz=beer%40", - ); + expect(utils.encodeParams(params).toString()).toEqual("foo=bar&baz=beer%40"); }); it("should handle boolean and numeric values", function() { @@ -37,7 +35,24 @@ describe("utils", function() { number: 12345, boolean: false, }; - expect(utils.encodeParams(params)).toEqual("string=foobar&number=12345&boolean=false"); + expect(utils.encodeParams(params).toString()).toEqual("string=foobar&number=12345&boolean=false"); + }); + + it("should handle string arrays", () => { + const params = { + via: ["one", "two", "three"], + }; + expect(utils.encodeParams(params).toString()).toEqual("via=one&via=two&via=three"); + }); + }); + + describe("decodeParams", () => { + it("should be able to decode multiple values into an array", () => { + const params = "foo=bar&via=a&via=b&via=c"; + expect(utils.decodeParams(params)).toEqual({ + foo: "bar", + via: ["a", "b", "c"], + }); }); }); diff --git a/spec/unit/webrtc/call.spec.ts b/spec/unit/webrtc/call.spec.ts index 5d29fe15a..e592cba9b 100644 --- a/spec/unit/webrtc/call.spec.ts +++ b/spec/unit/webrtc/call.spec.ts @@ -44,7 +44,7 @@ import { MockRTCRtpSender, } from "../../test-utils/webrtc"; import { CallFeed } from "../../../src/webrtc/callFeed"; -import { Callback, EventType, IContent, ISendEventResponse, MatrixEvent, Room } from "../../../src"; +import { EventType, IContent, ISendEventResponse, MatrixEvent, Room } from "../../../src"; const FAKE_ROOM_ID = "!foo:bar"; const CALL_LIFETIME = 60000; @@ -106,7 +106,7 @@ describe('Call', function() { let prevDocument: Document; let prevWindow: Window & typeof globalThis; // We retain a reference to this in the correct Mock type - let mockSendEvent: jest.Mock, [string, string, IContent, string, Callback]>; + let mockSendEvent: jest.Mock, [string, string, IContent, string]>; const errorListener = () => {}; diff --git a/spec/unit/webrtc/groupCall.spec.ts b/spec/unit/webrtc/groupCall.spec.ts index cfc70cfa4..ae95e4c86 100644 --- a/spec/unit/webrtc/groupCall.spec.ts +++ b/spec/unit/webrtc/groupCall.spec.ts @@ -1123,10 +1123,7 @@ describe('Group Call', function() { let client: MatrixClient; beforeEach(() => { - client = new MatrixClient({ - baseUrl: "base_url", - request: (() => {}) as any, // NOP - }); + client = new MatrixClient({ baseUrl: "base_url" }); jest.spyOn(client, "sendStateEvent").mockResolvedValue({} as any); }); diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 679a6afba..6b5124349 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -30,6 +30,8 @@ declare global { namespace NodeJS { interface Global { localStorage: Storage; + // marker variable used to detect both the browser & node entrypoints being used at once + __js_sdk_entrypoint: unknown; } } diff --git a/src/@types/partials.ts b/src/@types/partials.ts index a729d80dc..bf27eab0e 100644 --- a/src/@types/partials.ts +++ b/src/@types/partials.ts @@ -40,11 +40,6 @@ export enum Preset { export type ResizeMethod = "crop" | "scale"; -// TODO move to http-api after TSification -export interface IAbortablePromise extends Promise { - abort(): void; -} - export type IdServerUnbindResult = "no-support" | "success"; // Knock and private are reserved keywords which are not yet implemented. diff --git a/src/@types/requests.ts b/src/@types/requests.ts index 9d0472cee..f9095455e 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Callback } from "../client"; import { IContent, IEvent } from "../models/event"; import { Preset, Visibility } from "./partials"; import { IEventWithRoomId, SearchKey } from "./search"; @@ -22,7 +21,7 @@ import { IRoomEventFilter } from "../filter"; import { Direction } from "../models/event-timeline"; import { PushRuleAction } from "./PushRules"; import { IRoomEvent } from "../sync-accumulator"; -import { RoomType } from "./event"; +import { EventType, RoomType } from "./event"; // allow camelcase as these are things that go onto the wire /* eslint-disable camelcase */ @@ -98,7 +97,18 @@ export interface ICreateRoomOpts { name?: string; topic?: string; preset?: Preset; - power_level_content_override?: object; + power_level_content_override?: { + ban?: number; + events?: Record; + events_default?: number; + invite?: number; + kick?: number; + notifications?: Record; + redact?: number; + state_default?: number; + users?: Record; + users_default?: number; + }; creation_content?: object; initial_state?: ICreateRoomStateEvent[]; invite?: string[]; @@ -119,16 +129,6 @@ export interface IRoomDirectoryOptions { third_party_instance_id?: string; } -export interface IUploadOpts { - name?: string; - includeFilename?: boolean; - type?: string; - rawResponse?: boolean; - onlyContentUri?: boolean; - callback?: Callback; - progressHandler?: (state: {loaded: number, total: number}) => void; -} - export interface IAddThreePidOnlyBody { auth?: { type: string; @@ -149,7 +149,7 @@ export interface IRelationsRequestOpts { from?: string; to?: string; limit?: number; - direction?: Direction; + dir?: Direction; } export interface IRelationsResponse { diff --git a/src/@types/sync.ts b/src/@types/sync.ts new file mode 100644 index 000000000..036c542ba --- /dev/null +++ b/src/@types/sync.ts @@ -0,0 +1,26 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ServerControlledNamespacedValue } from "../NamespacedValue"; + +/** + * https://github.com/matrix-org/matrix-doc/pull/3773 + * + * @experimental + */ +export const UNREAD_THREAD_NOTIFICATIONS = new ServerControlledNamespacedValue( + "unread_thread_notifications", + "org.matrix.msc3773.unread_thread_notifications"); diff --git a/src/@types/uia.ts b/src/@types/uia.ts index 079306135..a976083a6 100644 --- a/src/@types/uia.ts +++ b/src/@types/uia.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IAuthData } from ".."; +import { IAuthData } from "../interactive-auth"; /** * Helper type to represent HTTP request body for a UIA enabled endpoint diff --git a/src/ToDeviceMessageQueue.ts b/src/ToDeviceMessageQueue.ts index 66f471c41..136be24b1 100644 --- a/src/ToDeviceMessageQueue.ts +++ b/src/ToDeviceMessageQueue.ts @@ -28,7 +28,7 @@ const MAX_BATCH_SIZE = 20; export class ToDeviceMessageQueue { private sending = false; private running = true; - private retryTimeout: number = null; + private retryTimeout: ReturnType | null = null; private retryAttempts = 0; constructor(private client: MatrixClient) { @@ -68,7 +68,7 @@ export class ToDeviceMessageQueue { logger.debug("Attempting to send queued to-device messages"); this.sending = true; - let headBatch; + let headBatch: IndexedToDeviceBatch; try { while (this.running) { headBatch = await this.client.store.getOldestToDeviceBatch(); @@ -92,7 +92,7 @@ export class ToDeviceMessageQueue { // bored and giving up for now if (Math.floor(e.httpStatus / 100) === 4) { logger.error("Fatal error when sending to-device message - dropping to-device batch!", e); - await this.client.store.removeToDeviceBatch(headBatch.id); + await this.client.store.removeToDeviceBatch(headBatch!.id); } else { logger.info("Automatic retry limit reached for to-device messages."); } diff --git a/src/autodiscovery.ts b/src/autodiscovery.ts index 71cbd2105..8bf87e517 100644 --- a/src/autodiscovery.ts +++ b/src/autodiscovery.ts @@ -17,10 +17,9 @@ limitations under the License. /** @module auto-discovery */ -import { ServerResponse } from "http"; - import { IClientWellKnown, IWellKnownConfig } from "./client"; import { logger } from './logger'; +import { MatrixError, Method, timeoutSignal } from "./http-api"; // Dev note: Auto discovery is part of the spec. // See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery @@ -395,6 +394,19 @@ export class AutoDiscovery { } } + private static fetch(resource: URL | string, options?: RequestInit): ReturnType { + if (this.fetchFn) { + return this.fetchFn(resource, options); + } + return global.fetch(resource, options); + } + + private static fetchFn?: typeof global.fetch; + + public static setFetchFn(fetchFn: typeof global.fetch): void { + AutoDiscovery.fetchFn = fetchFn; + } + /** * Fetches a JSON object from a given URL, as expected by all .well-known * related lookups. If the server gives a 404 then the `action` will be @@ -411,45 +423,55 @@ export class AutoDiscovery { * @return {Promise} Resolves to the returned state. * @private */ - private static fetchWellKnownObject(uri: string): Promise { - return new Promise((resolve) => { - // eslint-disable-next-line - const request = require("./matrix").getRequest(); - if (!request) throw new Error("No request library available"); - request( - { method: "GET", uri, timeout: 5000 }, - (error: Error, response: ServerResponse, body: string) => { - if (error || response?.statusCode < 200 || response?.statusCode >= 300) { - const result = { error, raw: {} }; - return resolve(response?.statusCode === 404 - ? { - ...result, - action: AutoDiscoveryAction.IGNORE, - reason: AutoDiscovery.ERROR_MISSING_WELLKNOWN, - } : { - ...result, - action: AutoDiscoveryAction.FAIL_PROMPT, - reason: error?.message || "General failure", - }); - } + private static async fetchWellKnownObject(url: string): Promise { + let response: Response; - try { - return resolve({ - raw: JSON.parse(body), - action: AutoDiscoveryAction.SUCCESS, - }); - } catch (err) { - return resolve({ - error: err, - raw: {}, - action: AutoDiscoveryAction.FAIL_PROMPT, - reason: err?.name === "SyntaxError" - ? AutoDiscovery.ERROR_INVALID_JSON - : AutoDiscovery.ERROR_INVALID, - }); - } - }, - ); - }); + try { + response = await AutoDiscovery.fetch(url, { + method: Method.Get, + signal: timeoutSignal(5000), + }); + + if (response.status === 404) { + return { + raw: {}, + action: AutoDiscoveryAction.IGNORE, + reason: AutoDiscovery.ERROR_MISSING_WELLKNOWN, + }; + } + + if (!response.ok) { + return { + raw: {}, + action: AutoDiscoveryAction.FAIL_PROMPT, + reason: "General failure", + }; + } + } catch (err) { + const error = err as Error | string | undefined; + return { + error, + raw: {}, + action: AutoDiscoveryAction.FAIL_PROMPT, + reason: (error)?.message || "General failure", + }; + } + + try { + return { + raw: await response.json(), + action: AutoDiscoveryAction.SUCCESS, + }; + } catch (err) { + const error = err as Error | string | undefined; + return { + error, + raw: {}, + action: AutoDiscoveryAction.FAIL_PROMPT, + reason: (error as MatrixError)?.name === "SyntaxError" + ? AutoDiscovery.ERROR_INVALID_JSON + : AutoDiscovery.ERROR_INVALID, + }; + } } } diff --git a/src/browser-index.js b/src/browser-index.js index 3e3627fa9..86e887bd4 100644 --- a/src/browser-index.js +++ b/src/browser-index.js @@ -14,25 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import request from "browser-request"; -import queryString from "qs"; - import * as matrixcs from "./matrix"; -if (matrixcs.getRequest()) { +if (global.__js_sdk_entrypoint) { throw new Error("Multiple matrix-js-sdk entrypoints detected!"); } - -matrixcs.request(function(opts, fn) { - // We manually fix the query string for browser-request because - // it doesn't correctly handle cases like ?via=one&via=two. Instead - // we mimic `request`'s query string interface to make it all work - // as expected. - // browser-request will happily take the constructed string as the - // query string without trying to modify it further. - opts.qs = queryString.stringify(opts.qs || {}, opts.qsStringifyOptions); - return request(opts, fn); -}); +global.__js_sdk_entrypoint = true; // just *accessing* indexedDB throws an exception in firefox with // indexeddb disabled. diff --git a/src/client.ts b/src/client.ts index fc340efb3..210075c42 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,7 +19,7 @@ limitations under the License. * @module client */ -import { EmoteEvent, IPartialEvent, MessageEvent, NoticeEvent } from "matrix-events-sdk"; +import { EmoteEvent, IPartialEvent, MessageEvent, NoticeEvent, Optional } from "matrix-events-sdk"; import { ISyncStateData, SyncApi, SyncState } from "./sync"; import { @@ -33,11 +33,11 @@ import { } from "./models/event"; import { StubStore } from "./store/stub"; import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call"; -import { Filter, IFilterDefinition } from "./filter"; +import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter"; import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler'; import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from './webrtc/groupCallEventHandler'; import * as utils from './utils'; -import { sleep } from './utils'; +import { QueryDict, sleep } from './utils'; import { Direction, EventTimeline } from "./models/event-timeline"; import { IActionsObject, PushProcessor } from "./pushprocessor"; import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery"; @@ -50,22 +50,17 @@ import { IRoomEncryption, RoomList } from './crypto/RoomList'; import { logger } from './logger'; import { SERVICE_TYPES } from './service-types'; import { - FileType, HttpApiEvent, HttpApiEventHandlerMap, - IHttpOpts, - IUpload, + Upload, + UploadOpts, MatrixError, MatrixHttpApi, Method, - PREFIX_IDENTITY_V2, - PREFIX_MEDIA_R0, - PREFIX_R0, - PREFIX_UNSTABLE, - PREFIX_V1, - PREFIX_V3, retryNetworkOperation, - UploadContentResponseType, + ClientPrefix, + MediaPrefix, + IdentityPrefix, IHttpOpts, FileType, UploadResponse, } from "./http-api"; import { Crypto, @@ -138,7 +133,6 @@ import { IRoomDirectoryOptions, ISearchOpts, ISendEventResponse, - IUploadOpts, INotificationsResponse, IFilterResponse, ITagsResponse, @@ -156,7 +150,7 @@ import { UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "./@types/event"; -import { IAbortablePromise, IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials"; +import { IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials"; import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper"; import { randomString } from "./randomstring"; import { BackupManager, IKeyBackup, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup"; @@ -199,7 +193,7 @@ import { TypedEventEmitter } from "./models/typed-event-emitter"; import { ReceiptType } from "./@types/read_receipts"; import { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, SlidingSync } from "./sliding-sync"; import { SlidingSyncSdk } from "./sliding-sync-sdk"; -import { Thread, THREAD_RELATION_TYPE } from "./models/thread"; +import { FeatureSupport, Thread, THREAD_RELATION_TYPE, determineFeatureSupport } from "./models/thread"; import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon"; import { UnstableValue } from "./NamespacedValue"; import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue"; @@ -208,10 +202,11 @@ import { MAIN_ROOM_TIMELINE } from "./models/read-receipt"; import { IgnoredInvites } from "./models/invites-ignorer"; import { UIARequest, UIAResponse } from "./@types/uia"; import { LocalNotificationSettings } from "./@types/local_notifications"; +import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync"; +import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature"; export type Store = IStore; -export type Callback = (err: Error | any | null, data?: T) => void; export type ResetTimelineCallback = (roomId: string) => boolean; const SCROLLBACK_DELAY_MS = 3000; @@ -219,6 +214,11 @@ export const CRYPTO_ENABLED: boolean = isCryptoAvailable(); const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes +export const UNSTABLE_MSC3852_LAST_SEEN_UA = new UnstableValue( + "last_seen_user_agent", + "org.matrix.msc3852.last_seen_user_agent", +); + interface IExportedDevice { olmDevice: IExportedOlmDevice; userId: string; @@ -259,12 +259,10 @@ export interface ICreateClientOpts { scheduler?: MatrixScheduler; /** - * The function to invoke for HTTP - * requests. The value of this property is typically require("request") - * as it returns a function which meets the required interface. See - * {@link requestFunction} for more information. + * The function to invoke for HTTP requests. + * Most supported environments have a global `fetch` registered to which this will fall back. */ - request?: IHttpOpts["request"]; + fetchFn?: typeof global.fetch; userId?: string; @@ -533,7 +531,7 @@ export interface ITurnServer { credential: string; } -interface IServerVersions { +export interface IServerVersions { versions: string[]; unstable_features: Record; } @@ -601,6 +599,13 @@ interface IMessagesResponse { state: IStateEvent[]; } +interface IThreadedMessagesResponse { + prev_batch: string; + next_batch: string; + chunk: IRoomEvent[]; + state: IStateEvent[]; +} + export interface IRequestTokenResponse { sid: string; submit_url?: string; @@ -618,7 +623,7 @@ export interface IUploadKeysRequest { "org.matrix.msc2732.fallback_keys"?: Record; } -interface IOpenIDToken { +export interface IOpenIDToken { access_token: string; token_type: "Bearer" | string; matrix_server_name: string; @@ -698,6 +703,8 @@ export interface IMyDevice { display_name?: string; last_seen_ip?: string; last_seen_ts?: number; + [UNSTABLE_MSC3852_LAST_SEEN_UA.stable]?: string; + [UNSTABLE_MSC3852_LAST_SEEN_UA.unstable]?: string; } export interface IDownloadKeyResult { @@ -876,7 +883,7 @@ type UserEvents = UserEvent.AvatarUrl | UserEvent.CurrentlyActive | UserEvent.LastPresenceTs; -type EmittedEvents = ClientEvent +export type EmittedEvents = ClientEvent | RoomEvents | RoomStateEvents | CryptoEvents @@ -930,16 +937,16 @@ 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 http: MatrixHttpApi; // XXX: Intended private, used in code. + public crypto?: Crypto; // XXX: Intended private, used in code. public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code. public callEventHandler: CallEventHandler; // XXX: Intended private, used in code. public groupCallEventHandler: GroupCallEventHandler; @@ -970,6 +977,8 @@ export class MatrixClient extends TypedEventEmitter; protected canResetTimelineCallback: ResetTimelineCallback; + public canSupport = new Map(); + // The pushprocessor caches useful things, so keep one and re-use it protected pushProcessor = new PushProcessor(this); @@ -1008,7 +1017,7 @@ export class MatrixClient extends TypedEventEmitter[0], { + fetchFn: opts.fetchFn, baseUrl: opts.baseUrl, idBaseUrl: opts.idBaseUrl, accessToken: opts.accessToken, - request: opts.request, - prefix: PREFIX_R0, + prefix: ClientPrefix.R0, onlyData: true, extraParams: opts.queryParams, localTimeoutMs: opts.localTimeoutMs, @@ -1104,35 +1113,7 @@ export class MatrixClient extends TypedEventEmitter { - const oldActions = event.getPushActions(); - const actions = this.getPushActionsForEvent(event, true); - - const room = this.getRoom(event.getRoomId()); - if (!room) return; - - const currentCount = room.getUnreadNotificationCount(NotificationCountType.Highlight); - - // Ensure the unread counts are kept up to date if the event is encrypted - // We also want to make sure that the notification count goes up if we already - // have encrypted events to avoid other code from resetting 'highlight' to zero. - const oldHighlight = !!oldActions?.tweaks?.highlight; - const newHighlight = !!actions?.tweaks?.highlight; - if (oldHighlight !== newHighlight || currentCount > 0) { - // TODO: Handle mentions received while the client is offline - // See also https://github.com/vector-im/element-web/issues/9069 - if (!room.hasUserReadEvent(this.getUserId(), event.getId())) { - let newCount = currentCount; - if (newHighlight && !oldHighlight) newCount++; - if (!newHighlight && oldHighlight) newCount--; - room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount); - - // Fix 'Mentions Only' rooms from not having the right badge count - const totalCount = room.getUnreadNotificationCount(NotificationCountType.Total); - if (totalCount < newCount) { - room.setUnreadNotificationCount(NotificationCountType.Total, newCount); - } - } - } + fixNotificationCountOnDecryption(this, event); }); // Like above, we have to listen for read receipts from ourselves in order to @@ -1234,14 +1215,15 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Post, "/dehydrated_device/claim", undefined, @@ -1388,7 +1369,6 @@ export class MatrixClient extends TypedEventEmitter { try { return await this.http.authedRequest( - undefined, Method.Get, "/dehydrated_device", undefined, undefined, @@ -1759,9 +1739,7 @@ export class MatrixClient extends TypedEventEmitter { + return this.http.authedRequest(Method.Get, "/capabilities").catch((e: Error): void => { // We swallow errors because we need a default object anyhow logger.error(e); }).then((r: { capabilities?: ICapabilities } = {}) => { @@ -2381,7 +2359,7 @@ export class MatrixClient extends TypedEventEmitter} Resolves to the number of sessions requiring backup + * @returns {Promise} Resolves to the number of sessions requiring backup */ public countSessionsNeedingBackup(): Promise { if (!this.crypto) { @@ -2793,8 +2771,8 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Get, "/room_keys/version", undefined, undefined, - { prefix: PREFIX_UNSTABLE }, + Method.Get, "/room_keys/version", undefined, undefined, + { prefix: ClientPrefix.V3 }, ); } catch (e) { if (e.errcode === 'M_NOT_FOUND') { @@ -2949,8 +2927,8 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Post, "/room_keys/version", undefined, data, - { prefix: PREFIX_UNSTABLE }, + Method.Post, "/room_keys/version", undefined, data, + { prefix: ClientPrefix.V3 }, ); // We could assume everything's okay and enable directly, but this ensures @@ -2964,7 +2942,7 @@ export class MatrixClient extends TypedEventEmitter { + public async deleteKeyBackupVersion(version: string): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -2980,20 +2958,16 @@ export class MatrixClient extends TypedEventEmitter; - public sendKeyBackup(roomId: string, sessionId: undefined, version: string, data: IKeyBackup): Promise; - public sendKeyBackup(roomId: string, sessionId: string, version: string, data: IKeyBackup): Promise; + public sendKeyBackup( + roomId: undefined, + sessionId: undefined, + version: string | undefined, + data: IKeyBackup, + ): Promise; public sendKeyBackup( roomId: string, + sessionId: undefined, + version: string | undefined, + data: IKeyBackup, + ): Promise; + public sendKeyBackup( + roomId: string, + sessionId: string, + version: string | undefined, + data: IKeyBackup, + ): Promise; + public async sendKeyBackup( + roomId: string | undefined, sessionId: string | undefined, version: string | undefined, data: IKeyBackup, @@ -3034,9 +3023,9 @@ export class MatrixClient extends TypedEventEmitter} Resolves to the number of sessions requiring a backup. + * @returns {Promise} Resolves to the number of sessions requiring a backup. */ public flagAllGroupSessionsForBackup(): Promise { if (!this.crypto) { @@ -3326,8 +3315,8 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Get, path.path, path.queryData, undefined, - { prefix: PREFIX_UNSTABLE }, + Method.Get, path.path, path.queryData, undefined, + { prefix: ClientPrefix.V3 }, ); if ((res as IRoomsKeysResponse).rooms) { @@ -3377,22 +3366,18 @@ export class MatrixClient extends TypedEventEmitter; - public deleteKeysFromBackup(roomId: string, sessionId: undefined, version: string): Promise; - public deleteKeysFromBackup(roomId: string, sessionId: string, version: string): Promise; - public deleteKeysFromBackup( - roomId: string | undefined, - sessionId: string | undefined, - version: string, - ): Promise { + public deleteKeysFromBackup(roomId: undefined, sessionId: undefined, version?: string): Promise; + public deleteKeysFromBackup(roomId: string, sessionId: undefined, version?: string): Promise; + public deleteKeysFromBackup(roomId: string, sessionId: string, version?: string): Promise; + public async deleteKeysFromBackup(roomId?: string, sessionId?: string, version?: string): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } const path = this.makeKeyBackupPath(roomId, sessionId, version); - return this.http.authedRequest( - undefined, Method.Delete, path.path, path.queryData, undefined, - { prefix: PREFIX_UNSTABLE }, + await this.http.authedRequest( + Method.Delete, path.path, path.queryData, undefined, + { prefix: ClientPrefix.V3 }, ); } @@ -3432,13 +3417,12 @@ export class MatrixClient extends TypedEventEmitter { + public getMediaConfig(): Promise { return this.http.authedRequest( - callback, Method.Get, "/config", undefined, undefined, { - prefix: PREFIX_MEDIA_R0, + Method.Get, "/config", undefined, undefined, { + prefix: MediaPrefix.R0, }, ); } @@ -3520,22 +3504,17 @@ export class MatrixClient extends TypedEventEmitter { + public setAccountData(eventType: EventType | string, content: IContent): Promise<{}> { const path = utils.encodeUri("/user/$userId/account_data/$type", { $userId: this.credentials.userId, $type: eventType, }); - const promise = retryNetworkOperation(5, () => { - return this.http.authedRequest(undefined, Method.Put, path, undefined, content); + return retryNetworkOperation(5, () => { + return this.http.authedRequest(Method.Put, path, undefined, content); }); - if (callback) { - promise.then(result => callback(null, result), callback); - } - return promise; } /** @@ -3552,11 +3531,10 @@ 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) { @@ -3564,14 +3542,14 @@ export class MatrixClient extends TypedEventEmitter(); } const path = utils.encodeUri("/user/$userId/account_data/$type", { $userId: this.credentials.userId, $type: eventType, }); try { - return await this.http.authedRequest(undefined, Method.Get, path); + return await this.http.authedRequest(Method.Get, path); } catch (e) { if (e.data?.errcode === 'M_NOT_FOUND') { return null; @@ -3593,16 +3571,15 @@ export class MatrixClient extends TypedEventEmitter { + public setIgnoredUsers(userIds: string[]): Promise<{}> { const content = { ignored_users: {} }; userIds.forEach((u) => { content.ignored_users[u] = {}; }); - return this.setAccountData("m.ignored_user_list", content, callback); + return this.setAccountData("m.ignored_user_list", content); } /** @@ -3623,31 +3600,25 @@ export class MatrixClient extends TypedEventEmitter 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. - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: Room object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public async joinRoom(roomIdOrAlias: string, opts?: IJoinRoomOpts, callback?: Callback): Promise { - // to help people when upgrading.. - if (utils.isFunction(opts)) { - throw new Error("Expected 'opts' object, got function."); - } - opts = opts || {}; + public async joinRoom(roomIdOrAlias: string, opts: IJoinRoomOpts = {}): Promise { if (opts.syncRoom === undefined) { opts.syncRoom = true; } const room = this.getRoom(roomIdOrAlias); - if (room && room.hasMembershipState(this.credentials.userId, "join")) { + if (room?.hasMembershipState(this.credentials.userId, "join")) { return Promise.resolve(room); } let signPromise: Promise = Promise.resolve(); if (opts.inviteSignUrl) { - signPromise = this.http.requestOtherUrl( - undefined, Method.Post, - opts.inviteSignUrl, { mxid: this.credentials.userId }, + signPromise = this.http.requestOtherUrl( + Method.Post, + new URL(opts.inviteSignUrl), { mxid: this.credentials.userId }, ); } @@ -3656,8 +3627,6 @@ export class MatrixClient extends TypedEventEmitter { - return this.sendStateEvent(roomId, EventType.RoomName, { name: name }, undefined, callback); + public setRoomName(roomId: string, name: string): Promise { + return this.sendStateEvent(roomId, EventType.RoomName, { name: name }); } /** * @param {string} roomId * @param {string} topic * @param {string} htmlTopic Optional. - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ @@ -3747,76 +3712,59 @@ export class MatrixClient extends TypedEventEmitter; - public setRoomTopic( - roomId: string, - topic: string, - callback?: Callback, - ): Promise; - public setRoomTopic( - roomId: string, - topic: string, - htmlTopicOrCallback?: string | Callback, ): Promise { - const isCallback = typeof htmlTopicOrCallback === 'function'; - const htmlTopic = isCallback ? undefined : htmlTopicOrCallback; - const callback = isCallback ? htmlTopicOrCallback : undefined; const content = ContentHelpers.makeTopicContent(topic, htmlTopic); - return this.sendStateEvent(roomId, EventType.RoomTopic, content, undefined, callback); + return this.sendStateEvent(roomId, EventType.RoomTopic, content); } /** * @param {string} roomId - * @param {module:client.callback} callback Optional. * @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. */ - public getRoomTags(roomId: string, callback?: Callback): Promise { + public getRoomTags(roomId: string): Promise { const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags", { $userId: this.credentials.userId, $roomId: roomId, }); - return this.http.authedRequest(callback, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** * @param {string} roomId * @param {string} tagName name of room tag to be set * @param {object} metadata associated with that tag to be stored - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: to an empty object * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public setRoomTag(roomId: string, tagName: string, metadata: ITagMetadata, callback?: Callback): Promise<{}> { + public setRoomTag(roomId: string, tagName: string, metadata: ITagMetadata): Promise<{}> { const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", { $userId: this.credentials.userId, $roomId: roomId, $tag: tagName, }); - return this.http.authedRequest(callback, Method.Put, path, undefined, metadata); + return this.http.authedRequest(Method.Put, path, undefined, metadata); } /** * @param {string} roomId * @param {string} tagName name of room tag to be removed - * @param {module:client.callback} callback Optional. - * @return {Promise} Resolves: void + * @return {Promise} Resolves: to an empty object * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public deleteRoomTag(roomId: string, tagName: string, callback?: Callback): Promise { + public deleteRoomTag(roomId: string, tagName: string): Promise<{}> { const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", { $userId: this.credentials.userId, $roomId: roomId, $tag: tagName, }); - return this.http.authedRequest(callback, Method.Delete, path); + return this.http.authedRequest(Method.Delete, path); } /** * @param {string} roomId * @param {string} eventType event type to be set * @param {object} content event content - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: to an empty object {} * @return {module:http-api.MatrixError} Rejects: with an error response. */ @@ -3824,14 +3772,13 @@ export class MatrixClient extends TypedEventEmitter, - callback?: Callback, ): Promise<{}> { const path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", { $userId: this.credentials.userId, $roomId: roomId, $type: eventType, }); - return this.http.authedRequest(callback, Method.Put, path, undefined, content); + return this.http.authedRequest(Method.Put, path, undefined, content); } /** @@ -3840,7 +3787,6 @@ export class MatrixClient extends TypedEventEmitter { let content = { users: {}, @@ -3863,7 +3808,7 @@ export class MatrixClient extends TypedEventEmitter; public sendEvent( roomId: string, @@ -3919,18 +3862,15 @@ export class MatrixClient extends TypedEventEmitter; public sendEvent( roomId: string, threadId: string | null, eventType: string | IContent, content: IContent | string, - txnId?: string | Callback, - callback?: Callback, + txnId?: string, ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = txnId as Callback; txnId = content as string; content = eventType as IContent; eventType = threadId; @@ -3958,7 +3898,7 @@ export class MatrixClient extends TypedEventEmitter { - if (utils.isFunction(txnId)) { - callback = txnId as any as Callback; // convert for legacy - txnId = undefined; - } - if (!txnId) { txnId = this.makeTxnId(); } @@ -4039,18 +3972,17 @@ export class MatrixClient extends TypedEventEmitter { + private encryptAndSendEvent(room: Room, event: MatrixEvent): Promise { 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 @@ -4096,9 +4028,6 @@ export class MatrixClient extends TypedEventEmitter { - callback?.(null, res); - return res; }).catch(err => { logger.error("Error sending event", err.stack || err); try { @@ -4110,8 +4039,6 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Put, path, undefined, event.getWireContent(), + Method.Put, path, undefined, event.getWireContent(), ).then((res) => { logger.log(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`); return res; @@ -4230,11 +4157,8 @@ export class MatrixClient extends TypedEventEmitter; public redactEvent( roomId: string, threadId: string | null, eventId: string, txnId?: string | undefined, - cbOrOpts?: Callback | IRedactOpts, + opts?: IRedactOpts, ): Promise; public redactEvent( roomId: string, threadId: string | null, eventId?: string, - txnId?: string | Callback | IRedactOpts, - cbOrOpts?: Callback | IRedactOpts, + txnId?: string | IRedactOpts, + opts?: IRedactOpts, ): Promise { if (!eventId?.startsWith(EVENT_ID_PREFIX)) { - cbOrOpts = txnId as (Callback | IRedactOpts); + opts = txnId as IRedactOpts; txnId = eventId; eventId = threadId; threadId = null; } - const opts = typeof (cbOrOpts) === 'object' ? cbOrOpts : {}; - const reason = opts.reason; - const callback = typeof (cbOrOpts) === 'function' ? cbOrOpts : undefined; + const reason = opts?.reason; return this.sendCompleteEvent(roomId, threadId, { type: EventType.RoomRedaction, content: { reason }, redacts: eventId, - }, txnId as string, callback); + }, txnId as string); } /** @@ -4279,7 +4201,6 @@ export class MatrixClient extends TypedEventEmitter; public sendMessage( roomId: string, threadId: string | null, content: IContent, txnId?: string, - callback?: Callback, ): Promise; public sendMessage( roomId: string, threadId: string | null | IContent, content: IContent | string, - txnId?: string | Callback, - callback?: Callback, + txnId?: string, ): Promise { if (typeof threadId !== "string" && threadId !== null) { - callback = txnId as Callback; txnId = content as string; content = threadId as IContent; threadId = null; } - if (utils.isFunction(txnId)) { - callback = txnId as any as Callback; // for legacy - txnId = undefined; - } // Populate all outbound events with Extensible Events metadata to ensure there's a // reasonably large pool of messages to parse. @@ -4358,8 +4271,7 @@ export class MatrixClient extends TypedEventEmitter; public sendTextMessage( roomId: string, threadId: string | null, body: string, txnId?: string, - callback?: Callback, ): Promise; public sendTextMessage( roomId: string, threadId: string | null, body: string, - txnId?: string | Callback, - callback?: Callback, + txnId?: string, ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = txnId as Callback; txnId = body; body = threadId; threadId = null; } const content = ContentHelpers.makeTextMessage(body); - return this.sendMessage(roomId, threadId, content, txnId as string, callback); + return this.sendMessage(roomId, threadId, content, txnId); } /** @@ -4407,7 +4314,6 @@ export class MatrixClient extends TypedEventEmitter; public sendNotice( roomId: string, threadId: string | null, body: string, txnId?: string, - callback?: Callback, ): Promise; public sendNotice( roomId: string, threadId: string | null, body: string, - txnId?: string | Callback, - callback?: Callback, + txnId?: string, ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = txnId as Callback; txnId = body; body = threadId; threadId = null; } const content = ContentHelpers.makeNotice(body); - return this.sendMessage(roomId, threadId, content, txnId as string, callback); + return this.sendMessage(roomId, threadId, content, txnId); } /** @@ -4446,7 +4348,6 @@ export class MatrixClient extends TypedEventEmitter; public sendEmoteMessage( roomId: string, threadId: string | null, body: string, txnId?: string, - callback?: Callback, ): Promise; public sendEmoteMessage( roomId: string, threadId: string | null, body: string, - txnId?: string | Callback, - callback?: Callback, + txnId?: string, ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = txnId as Callback; txnId = body; body = threadId; threadId = null; } const content = ContentHelpers.makeEmoteMessage(body); - return this.sendMessage(roomId, threadId, content, txnId as string, callback); + return this.sendMessage(roomId, threadId, content, txnId); } /** @@ -4486,7 +4383,6 @@ export class MatrixClient extends TypedEventEmitter; public sendImageMessage( roomId: string, @@ -4503,34 +4398,27 @@ export class MatrixClient extends TypedEventEmitter; public sendImageMessage( roomId: string, threadId: string | null, url: string | IImageInfo, info?: IImageInfo | string, - text: Callback | string = "Image", - callback?: Callback, + text = "Image", ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = text as Callback; text = info as string || "Image"; info = url as IImageInfo; url = threadId as string; threadId = null; } - if (utils.isFunction(text)) { - callback = text as any as Callback; // legacy - text = undefined; - } const content = { msgtype: MsgType.Image, url: url, info: info, body: text, }; - return this.sendMessage(roomId, threadId, content, undefined, callback); + return this.sendMessage(roomId, threadId, content); } /** @@ -4539,7 +4427,6 @@ export class MatrixClient extends TypedEventEmitter; public sendStickerMessage( roomId: string, @@ -4556,34 +4442,27 @@ export class MatrixClient extends TypedEventEmitter; public sendStickerMessage( roomId: string, threadId: string | null, url: string | IImageInfo, info?: IImageInfo | string, - text: Callback | string = "Sticker", - callback?: Callback, + text = "Sticker", ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = text as Callback; text = info as string || "Sticker"; info = url as IImageInfo; url = threadId as string; threadId = null; } - if (utils.isFunction(text)) { - callback = text as any as Callback; // legacy - text = undefined; - } const content = { url: url, info: info, body: text, }; - return this.sendEvent(roomId, threadId, EventType.Sticker, content, undefined, callback); + return this.sendEvent(roomId, threadId, EventType.Sticker, content); } /** @@ -4591,7 +4470,6 @@ export class MatrixClient extends TypedEventEmitter; public sendHtmlMessage( roomId: string, threadId: string | null, body: string, htmlBody: string, - callback?: Callback, ): Promise; public sendHtmlMessage( roomId: string, threadId: string | null, body: string, - htmlBody: string | Callback, - callback?: Callback, + htmlBody?: string, ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = htmlBody as Callback; htmlBody = body as string; body = threadId; threadId = null; } - const content = ContentHelpers.makeHtmlMessage(body, htmlBody as string); - return this.sendMessage(roomId, threadId, content, undefined, callback); + const content = ContentHelpers.makeHtmlMessage(body, htmlBody); + return this.sendMessage(roomId, threadId, content); } /** * @param {string} roomId * @param {string} body * @param {string} htmlBody - * @param {module:client.callback} callback Optional. Deprecated * @return {Promise} Resolves: to a ISendEventResponse object * @return {module:http-api.MatrixError} Rejects: with an error response. */ @@ -4637,30 +4510,26 @@ export class MatrixClient extends TypedEventEmitter; public sendHtmlNotice( roomId: string, threadId: string | null, body: string, htmlBody: string, - callback?: Callback, ): Promise; public sendHtmlNotice( roomId: string, threadId: string | null, body: string, - htmlBody: string | Callback, - callback?: Callback, + htmlBody?: string, ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = htmlBody as Callback; htmlBody = body as string; body = threadId; threadId = null; } - const content = ContentHelpers.makeHtmlNotice(body, htmlBody as string); - return this.sendMessage(roomId, threadId, content, undefined, callback); + const content = ContentHelpers.makeHtmlNotice(body, htmlBody); + return this.sendMessage(roomId, threadId, content); } /** @@ -4668,7 +4537,6 @@ export class MatrixClient extends TypedEventEmitter; public sendHtmlEmote( roomId: string, threadId: string | null, body: string, htmlBody: string, - callback?: Callback, ): Promise; public sendHtmlEmote( roomId: string, threadId: string | null, body: string, - htmlBody: string | Callback, - callback?: Callback, + htmlBody?: string, ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = htmlBody as Callback; htmlBody = body as string; body = threadId; threadId = null; } - const content = ContentHelpers.makeHtmlEmote(body, htmlBody as string); - return this.sendMessage(roomId, threadId, content, undefined, callback); + const content = ContentHelpers.makeHtmlEmote(body, htmlBody); + return this.sendMessage(roomId, threadId, content); } /** @@ -4708,7 +4572,6 @@ export class MatrixClient extends TypedEventEmitter { - if (typeof (body) === 'function') { - callback = body as any as Callback; // legacy - body = {}; - } - if (this.isGuest()) { return Promise.resolve({}); // guests cannot send receipts so don't bother. } @@ -4741,7 +4598,7 @@ export class MatrixClient extends TypedEventEmitter { if (!event) return; const eventId = event.getId(); @@ -4770,7 +4625,7 @@ export class MatrixClient extends TypedEventEmitter { + public getUrlPreview(url: string, ts: number): Promise { // bucket the timestamp to the nearest minute to prevent excessive spam to the server // Surely 60-second accuracy is enough for anyone. ts = Math.floor(ts / 60000) * 60000; @@ -4850,20 +4704,15 @@ export class MatrixClient extends TypedEventEmitter(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; @@ -4873,11 +4722,10 @@ export class MatrixClient extends TypedEventEmitter { + public sendTyping(roomId: string, isTyping: boolean, timeoutMs: number): Promise<{}> { if (this.isGuest()) { return Promise.resolve({}); // guests cannot send typing notifications so don't bother. } @@ -4892,7 +4740,7 @@ export class MatrixClient extends TypedEventEmitter { - return this.membershipChange(roomId, userId, "invite", reason, callback); + public invite(roomId: string, userId: string, reason?: string): Promise<{}> { + return this.membershipChange(roomId, userId, "invite", reason); } /** * Invite a user to a room based on their email address. * @param {string} roomId The room to invite the user to. * @param {string} email The email address to invite. - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public inviteByEmail(roomId: string, email: string, callback?: Callback): Promise<{}> { - return this.inviteByThreePid(roomId, "email", email, callback); + public inviteByEmail(roomId: string, email: string): Promise<{}> { + return this.inviteByThreePid(roomId, "email", email); } /** @@ -5002,11 +4848,10 @@ export class MatrixClient extends TypedEventEmitter { + public async inviteByThreePid(roomId: string, medium: string, address: string): Promise<{}> { const path = utils.encodeUri( "/rooms/$roomId/invite", { $roomId: roomId }, @@ -5035,17 +4880,16 @@ export class MatrixClient extends TypedEventEmitter { - return this.membershipChange(roomId, undefined, "leave", undefined, callback); + public leave(roomId: string): Promise<{}> { + return this.membershipChange(roomId, undefined, "leave"); } /** @@ -5099,28 +4943,22 @@ 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. * Default: true. - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public forget(roomId: string, deleteRoom?: boolean, callback?: Callback): Promise<{}> { - if (deleteRoom === undefined) { - deleteRoom = true; - } - const promise = this.membershipChange(roomId, undefined, "forget", undefined, - callback); + public forget(roomId: string, deleteRoom = true): Promise<{}> { + const promise = this.membershipChange(roomId, undefined, "forget"); if (!deleteRoom) { return promise; } @@ -5134,11 +4972,10 @@ export class MatrixClient extends TypedEventEmitter { + public unban(roomId: string, userId: string): Promise<{}> { // unbanning != set their state to leave: this used to be // the case, but was then changed so that leaving was always // a revoking of privilege, otherwise two people racing to @@ -5150,20 +4987,17 @@ export class MatrixClient extends TypedEventEmitter { + public kick(roomId: string, userId: string, reason?: string): Promise<{}> { const path = utils.encodeUri("/rooms/$roomId/kick", { $roomId: roomId, }); @@ -5171,9 +5005,7 @@ export class MatrixClient extends TypedEventEmitter { // API returns an empty object - if (utils.isFunction(reason)) { - callback = reason as any as Callback; // legacy - reason = undefined; - } - const path = utils.encodeUri("/rooms/$room_id/$membership", { $room_id: roomId, $membership: membership, }); return this.http.authedRequest( - callback, Method.Post, path, undefined, { + Method.Post, path, undefined, { user_id: userId, // may be undefined e.g. on leave reason: reason, }, @@ -5218,29 +5044,27 @@ export class MatrixClient extends TypedEventEmitter; - public setProfileInfo(info: "displayname", data: { displayname: string }, callback?: Callback): Promise<{}>; - public setProfileInfo(info: "avatar_url" | "displayname", data: object, callback?: Callback): Promise<{}> { + public setProfileInfo(info: "avatar_url", data: { avatar_url: string }): Promise<{}>; + public setProfileInfo(info: "displayname", data: { displayname: string }): Promise<{}>; + public setProfileInfo(info: "avatar_url" | "displayname", data: object): Promise<{}> { const path = utils.encodeUri("/profile/$userId/$info", { $userId: this.credentials.userId, $info: info, }); - return this.http.authedRequest(callback, Method.Put, path, undefined, data); + return this.http.authedRequest(Method.Put, path, undefined, data); } /** * @param {string} name - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public async setDisplayName(name: string, callback?: Callback): Promise<{}> { - const prom = await this.setProfileInfo("displayname", { displayname: name }, callback); + public async setDisplayName(name: string): Promise<{}> { + const prom = await this.setProfileInfo("displayname", { displayname: name }); // XXX: synthesise a profile update for ourselves because Synapse is broken and won't const user = this.getUser(this.getUserId()); if (user) { @@ -5252,12 +5076,11 @@ export class MatrixClient extends TypedEventEmitter { - const prom = await this.setProfileInfo("avatar_url", { avatar_url: url }, callback); + public async setAvatarUrl(url: string): Promise<{}> { + const prom = await this.setProfileInfo("avatar_url", { avatar_url: url }); // XXX: synthesise a profile update for ourselves because Synapse is broken and won't const user = this.getUser(this.getUserId()); if (user) { @@ -5294,12 +5117,11 @@ export class MatrixClient extends TypedEventEmitter { + public async setPresence(opts: IPresenceOpts): Promise { const path = utils.encodeUri("/presence/$userId/status", { $userId: this.credentials.userId, }); @@ -5312,23 +5134,20 @@ export class MatrixClient extends TypedEventEmitter { + public getPresence(userId: string): Promise { const path = utils.encodeUri("/presence/$userId/status", { $userId: userId, }); - return this.http.authedRequest(callback, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** @@ -5342,17 +5161,12 @@ export class MatrixClient extends TypedEventEmitterRoom.oldState.paginationToken will be * null. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public scrollback(room: Room, limit = 30, callback?: Callback): Promise { - if (utils.isFunction(limit)) { - callback = limit as any as Callback; // legacy - limit = undefined; - } + public scrollback(room: Room, limit = 30): Promise { let timeToWaitMs = 0; let info = this.ongoingScrollbacks[room.roomId] || {}; @@ -5404,13 +5218,11 @@ export class MatrixClient extends TypedEventEmitter { this.ongoingScrollbacks[room.roomId] = { errorTs: Date.now(), }; - callback?.(err); reject(err); }); }); @@ -5449,13 +5261,17 @@ export class MatrixClient extends TypedEventEmitter { + public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise> { // don't allow any timeline support unless it's been enabled. if (!this.timelineSupport) { throw new Error("timeline support is disabled. Set the 'timelineSupport'" + " parameter to true when creating MatrixClient to enable it."); } + if (!timelineSet?.room) { + throw new Error("getEventTimeline only supports room timelines"); + } + if (timelineSet.getTimelineForEvent(eventId)) { return timelineSet.getTimelineForEvent(eventId); } @@ -5467,13 +5283,13 @@ export class MatrixClient extends TypedEventEmitter = undefined; + let params: Record | undefined = undefined; if (this.clientOpts.lazyLoadMembers) { params = { filter: JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER) }; } // TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors. - const res = await this.http.authedRequest(undefined, Method.Get, path, params); + const res = await this.http.authedRequest(Method.Get, path, params); if (!res.event) { throw new Error("'event' not in '/context' result - homeserver too old?"); } @@ -5505,7 +5321,7 @@ export class MatrixClient extends TypedEventEmitter { + public async getLatestTimeline(timelineSet: EventTimelineSet): Promise> { // don't allow any timeline support unless it's been enabled. if (!this.timelineSupport) { throw new Error("timeline support is disabled. Set the 'timelineSupport'" + " parameter to true when creating MatrixClient to enable it."); } - const messagesPath = utils.encodeUri( - "/rooms/$roomId/messages", { - $roomId: timelineSet.room.roomId, - }, - ); - - const params: Record = { - dir: 'b', - }; - if (this.clientOpts.lazyLoadMembers) { - params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER); + if (!timelineSet.room) { + throw new Error("getLatestTimeline only supports room timelines"); } - const res = await this.http.authedRequest(undefined, Method.Get, messagesPath, params); + let res: IMessagesResponse; + const roomId = timelineSet.room.roomId; + if (timelineSet.isThreadTimeline) { + res = await this.createThreadListMessagesRequest( + roomId, + null, + 1, + Direction.Backward, + timelineSet.getFilter(), + ); + } else { + res = await this.createMessagesRequest( + roomId, + null, + 1, + Direction.Backward, + timelineSet.getFilter(), + ); + } const event = res.chunk?.[0]; if (!event) { throw new Error("No message returned from /messages when trying to construct getLatestTimeline"); @@ -5620,7 +5445,7 @@ export class MatrixClient extends TypedEventEmitter { + const path = utils.encodeUri("/rooms/$roomId/threads", { $roomId: roomId }); + + const params: Record = { + limit: limit.toString(), + dir: dir, + include: 'all', + }; + + if (fromToken) { + params.from = fromToken; + } + + let filter: IRoomEventFilter | null = null; + if (this.clientOpts.lazyLoadMembers) { + // create a shallow copy of LAZY_LOADING_MESSAGES_FILTER, + // so the timelineFilter doesn't get written into it below + filter = { + ...filter, + ...Filter.LAZY_LOADING_MESSAGES_FILTER, + }; + } + if (timelineFilter) { + // XXX: it's horrific that /messages' filter parameter doesn't match + // /sync's one - see https://matrix.org/jira/browse/SPEC-451 + filter = { + ...filter, + ...timelineFilter.getRoomTimelineFilterComponent()?.toJSON(), + }; + } + if (filter) { + params.filter = JSON.stringify(filter); + } + + const opts: { prefix?: string } = {}; + if (Thread.hasServerSideListSupport === FeatureSupport.Experimental) { + opts.prefix = "/_matrix/client/unstable/org.matrix.msc3856"; + } + + return this.http.authedRequest(Method.Get, path, params, undefined, opts) + .then(res => ({ + ...res, + start: res.prev_batch, + end: res.next_batch, + })); } /** @@ -5653,6 +5544,8 @@ export class MatrixClient extends TypedEventEmitter { const isNotifTimeline = (eventTimeline.getTimelineSet() === this.notifTimelineSet); + const room = this.getRoom(eventTimeline.getRoomId()); + const isThreadTimeline = eventTimeline.getTimelineSet().isThreadTimeline; // TODO: we should implement a backoff (as per scrollback()) to deal more // nicely with HTTP errors. @@ -5686,15 +5579,15 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Get, path, params, + Method.Get, path, params, ).then(async (res) => { const token = res.next_token; - const matrixEvents = []; + const matrixEvents: MatrixEvent[] = []; for (let i = 0; i < res.notifications.length; i++) { const notification = res.notifications[i]; @@ -5718,13 +5611,48 @@ export class MatrixClient extends TypedEventEmitter { + eventTimeline.paginationRequests[dir] = null; + }); + eventTimeline.paginationRequests[dir] = promise; + } else if (isThreadTimeline) { + if (!room) { + throw new Error("Unknown room " + eventTimeline.getRoomId()); + } + + promise = this.createThreadListMessagesRequest( + eventTimeline.getRoomId(), + token, + opts.limit, + dir, + eventTimeline.getFilter(), + ).then((res) => { + if (res.state) { + const roomState = eventTimeline.getState(dir); + const stateEvents = res.state.map(this.getEventMapper()); + roomState.setUnknownStateEvents(stateEvents); + } + const token = res.end; + const matrixEvents = res.chunk.map(this.getEventMapper()); + + const timelineSet = eventTimeline.getTimelineSet(); + timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token); + this.processBeaconEvents(room, matrixEvents); + this.processThreadRoots(room, matrixEvents, backwards); + + // if we've hit the end of the timeline, we need to stop trying to + // paginate. We need to keep the 'forwards' token though, to make sure + // we can recover from gappy syncs. + if (backwards && res.end == res.start) { + eventTimeline.setPaginationToken(null, dir); + } + return res.end !== res.start; }).finally(() => { eventTimeline.paginationRequests[dir] = null; }); eventTimeline.paginationRequests[dir] = promise; } else { - const room = this.getRoom(eventTimeline.getRoomId()); if (!room) { throw new Error("Unknown room " + eventTimeline.getRoomId()); } @@ -5745,9 +5673,9 @@ export class MatrixClient extends TypedEventEmitter { + public searchMessageText(opts: ISearchOpts): Promise { const roomEvents: ISearchRequestBody["search_categories"]["room_events"] = { search_term: opts.query, }; @@ -6198,7 +6125,7 @@ export class MatrixClient extends TypedEventEmitter(undefined, Method.Post, path, undefined, content) + 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); @@ -6396,7 +6323,7 @@ export class MatrixClient extends TypedEventEmitter(undefined, Method.Get, path).then((response) => { + return this.http.authedRequest(Method.Get, path).then((response) => { // persist the filter const filter = Filter.fromJson(userId, filterId, response); this.store.storeFilter(filter); @@ -6471,9 +6398,7 @@ export class MatrixClient extends TypedEventEmitter { @@ -6485,12 +6410,11 @@ export class MatrixClient extends TypedEventEmitter { - return this.http.authedRequest(callback, Method.Get, "/voip/turnServer"); + public turnServer(): Promise { + return this.http.authedRequest(Method.Get, "/voip/turnServer"); } /** @@ -6596,7 +6520,7 @@ export class MatrixClient extends TypedEventEmitter r['admin']); // pull out the specific boolean we want } @@ -6612,7 +6536,7 @@ export class MatrixClient extends TypedEventEmitter { @@ -6687,8 +6609,8 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Get, path, undefined, undefined, - { prefix: PREFIX_UNSTABLE }, + Method.Get, path, undefined, undefined, + { prefix: ClientPrefix.Unstable }, ); return res.joined; } @@ -6704,7 +6626,6 @@ export class MatrixClient extends TypedEventEmitter( - undefined, // callback Method.Get, "/_matrix/client/versions", undefined, // queryParams undefined, // data @@ -6837,23 +6758,28 @@ export class MatrixClient extends TypedEventEmitter { + threads: FeatureSupport; + list: FeatureSupport; + }> { try { - const hasUnstableSupport = await this.doesServerSupportUnstableFeature("org.matrix.msc3440"); - const hasStableSupport = await this.doesServerSupportUnstableFeature("org.matrix.msc3440.stable"); + const [threadUnstable, threadStable, listUnstable, listStable] = await Promise.all([ + this.doesServerSupportUnstableFeature("org.matrix.msc3440"), + this.doesServerSupportUnstableFeature("org.matrix.msc3440.stable"), + this.doesServerSupportUnstableFeature("org.matrix.msc3856"), + this.doesServerSupportUnstableFeature("org.matrix.msc3856.stable"), + ]); // TODO: Use `this.isVersionSupported("v1.3")` for whatever spec version includes MSC3440 formally. return { - serverSupport: hasUnstableSupport || hasStableSupport, - stable: hasStableSupport, + threads: determineFeatureSupport(threadStable, threadUnstable), + list: determineFeatureSupport(listStable, listUnstable), }; } catch (e) { - // Assume server support and stability aren't available: null/no data return. - // XXX: This should just return an object with `false` booleans instead. - return null; + return { + threads: FeatureSupport.None, + list: FeatureSupport.None, + }; } } @@ -6910,7 +6836,7 @@ export class MatrixClient extends TypedEventEmitter { return this.http.authedRequest<{ available: true }>( - undefined, Method.Get, '/register/available', { username }, + Method.Get, '/register/available', { username }, ).then((response) => { return response.available; }).catch(response => { @@ -7089,7 +7015,6 @@ export class MatrixClient extends TypedEventEmitter { // backwards compat if (bindThreepids === true) { @@ -7109,11 +7033,6 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + public registerGuest(opts: { body?: any }): Promise { // TODO: Types opts = opts || {}; opts.body = opts.body || {}; - return this.registerRequest(opts.body, "guest", callback); + return this.registerRequest(opts.body, "guest"); } /** * @param {Object} data parameters for registration request * @param {string=} kind type of user to register. may be "guest" - * @param {module:client.callback=} callback * @return {Promise} Resolves: to the /register response * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public registerRequest(data: IRegisterRequestParams, kind?: string, callback?: Callback): Promise { + public registerRequest(data: IRegisterRequestParams, kind?: string): Promise { const params: { kind?: string } = {}; if (kind) { params.kind = kind; } - return this.http.request(callback, Method.Post, "/register", params, data); + return this.http.request(Method.Post, "/register", params, data); } /** @@ -7211,35 +7128,32 @@ export class MatrixClient extends TypedEventEmitter { return this.http.authedRequest( - undefined, Method.Post, "/refresh", undefined, { refresh_token: refreshToken }, { - prefix: PREFIX_V1, + prefix: ClientPrefix.V1, inhibitLogoutEmit: true, // we don't want to cause logout loops }, ); } /** - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves to the available login flows * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public loginFlows(callback?: Callback): Promise { - return this.http.request(callback, Method.Get, "/login"); + public loginFlows(): Promise { + return this.http.request(Method.Get, "/login"); } /** * @param {string} loginType * @param {Object} data - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public login(loginType: string, data: any, callback?: Callback): Promise { // TODO: Types + public login(loginType: string, data: any): Promise { // TODO: Types const loginData = { type: loginType, }; @@ -7247,46 +7161,42 @@ export class MatrixClient extends TypedEventEmitter { - if (response && response.access_token && response.user_id) { - this.http.opts.accessToken = response.access_token; - this.credentials = { - userId: response.user_id, - }; - } - - if (callback) { - callback(error, response); - } - }, Method.Post, "/login", undefined, loginData, - ); + 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 - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public loginWithPassword(user: string, password: string, callback?: Callback): Promise { // TODO: Types + public loginWithPassword(user: string, password: string): Promise { // TODO: Types return this.login("m.login.password", { user: user, password: password, - }, callback); + }); } /** * @param {string} relayState URL Callback after SAML2 Authentication - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public loginWithSAML2(relayState: string, callback?: Callback): Promise { // TODO: Types + public loginWithSAML2(relayState: string): Promise { // TODO: Types return this.login("m.login.saml2", { relay_state: relayState, - }, callback); + }); } /** @@ -7323,19 +7233,18 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + public loginWithToken(token: string): Promise { // TODO: Types return this.login("m.login.token", { token: token, - }, callback); + }); } /** @@ -7344,11 +7253,10 @@ export class MatrixClient extends TypedEventEmitter { + public async logout(stopClient = false): Promise<{}> { if (this.crypto?.backupManager?.getKeyBackupEnabled()) { try { while (await this.crypto.backupManager.backupPendingKeys(200) > 0); @@ -7362,11 +7270,10 @@ export class MatrixClient extends TypedEventEmitter { - if (typeof (erase) === 'function') { - throw new Error('deactivateAccount no longer accepts a callback parameter'); - } - const body: any = {}; if (auth) { body.auth = auth; @@ -7394,7 +7297,7 @@ export class MatrixClient extends TypedEventEmitter> { const body: UIARequest<{}> = { auth }; return this.http.authedRequest( - undefined, // no callback support Method.Post, "/org.matrix.msc3882/login/token", undefined, // no query params body, - { prefix: PREFIX_UNSTABLE }, + { prefix: ClientPrefix.Unstable }, ); } @@ -7433,7 +7335,7 @@ export class MatrixClient extends TypedEventEmitter{room_id: {string}} * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public async createRoom( - options: ICreateRoomOpts, - callback?: Callback, - ): Promise<{ room_id: string }> { // eslint-disable-line camelcase + 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 @@ -7471,7 +7369,7 @@ export class MatrixClient extends TypedEventEmitter { const queryString = utils.encodeParams(opts as Record); @@ -7512,44 +7410,38 @@ export class MatrixClient extends TypedEventEmitter { + public roomState(roomId: string): Promise { const path = utils.encodeUri("/rooms/$roomId/state", { $roomId: roomId }); - return this.http.authedRequest(callback, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** * Get an event in a room by its event id. * @param {string} roomId * @param {string} eventId - * @param {module:client.callback} callback Optional. * * @return {Promise} Resolves to an object containing the event. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public fetchRoomEvent( - roomId: string, - eventId: string, - callback?: Callback, - ): Promise { + public fetchRoomEvent(roomId: string, eventId: string): Promise { const path = utils.encodeUri( "/rooms/$roomId/event/$eventId", { $roomId: roomId, $eventId: eventId, }, ); - return this.http.authedRequest(callback, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** @@ -7557,7 +7449,6 @@ export class MatrixClient extends TypedEventEmitter { const queryParams: Record = {}; if (includeMembership) { @@ -7583,7 +7473,7 @@ export class MatrixClient extends TypedEventEmitter { // eslint-disable-line camelcase const path = utils.encodeUri("/rooms/$roomId/upgrade", { $roomId: roomId }); - return this.http.authedRequest( - undefined, Method.Post, path, undefined, { new_version: newVersion }, - ); + return this.http.authedRequest(Method.Post, path, undefined, { new_version: newVersion }); } /** @@ -7608,7 +7496,6 @@ export class MatrixClient extends TypedEventEmitter> { const pathParams = { $roomId: roomId, @@ -7627,9 +7513,7 @@ export class MatrixClient extends TypedEventEmitter { const pathParams = { $roomId: roomId, @@ -7657,27 +7539,21 @@ export class MatrixClient extends TypedEventEmitter { - if (utils.isFunction(limit)) { - callback = limit as any as Callback; // legacy - limit = undefined; - } - + public roomInitialSync(roomId: string, limit: number): Promise { const path = utils.encodeUri("/rooms/$roomId/initialSync", { $roomId: roomId }, ); - return this.http.authedRequest(callback, Method.Get, path, { limit: limit?.toString() ?? "30" }); + return this.http.authedRequest(Method.Get, path, { limit: limit?.toString() ?? "30" }); } /** @@ -7709,11 +7585,14 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/joined_rooms", {}); - return this.http.authedRequest(undefined, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** @@ -7736,7 +7615,7 @@ export class MatrixClient extends TypedEventEmitter { - if (typeof (options) == 'function') { - callback = options; - options = {}; - } - if (options === undefined) { - options = {}; - } - - const queryParams: any = {}; - if (options.server) { - queryParams.server = options.server; - delete options.server; - } - - if (Object.keys(options).length === 0 && Object.keys(queryParams).length === 0) { - return this.http.authedRequest(callback, Method.Get, "/publicRooms"); + 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); } else { - return this.http.authedRequest(callback, Method.Post, "/publicRooms", queryParams, options); + return this.http.authedRequest(Method.Post, "/publicRooms", queryParams, options); } } @@ -7778,33 +7645,31 @@ export class MatrixClient extends TypedEventEmitter { + public createAlias(alias: string, roomId: string): Promise<{}> { const path = utils.encodeUri("/directory/room/$alias", { $alias: alias, }); const data = { room_id: roomId, }; - return this.http.authedRequest(callback, Method.Put, path, undefined, data); + return this.http.authedRequest(Method.Put, path, undefined, data); } /** * Delete an alias to room ID mapping. This alias must be on your local server, * and you must have sufficient access to do this operation. * @param {string} alias The room alias to delete. - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: an empty object {}. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public deleteAlias(alias: string, callback?: Callback): Promise<{}> { + public deleteAlias(alias: string): Promise<{}> { const path = utils.encodeUri("/directory/room/$alias", { $alias: alias, }); - return this.http.authedRequest(callback, Method.Delete, path); + return this.http.authedRequest(Method.Delete, path); } /** @@ -7816,53 +7681,49 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomId/aliases", { $roomId: roomId }); - const prefix = PREFIX_V3; - return this.http.authedRequest(undefined, Method.Get, path, undefined, undefined, { prefix }); + const prefix = ClientPrefix.V3; + return this.http.authedRequest(Method.Get, path, undefined, undefined, { prefix }); } /** * Get room info for the given alias. * @param {string} alias The room alias to resolve. - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: Object with room_id and servers. * @return {module:http-api.MatrixError} Rejects: with an error response. */ public getRoomIdForAlias( alias: string, - callback?: Callback, ): Promise<{ room_id: string, servers: string[] }> { // eslint-disable-line camelcase // TODO: deprecate this or resolveRoomAlias const path = utils.encodeUri("/directory/room/$alias", { $alias: alias, }); - return this.http.authedRequest(callback, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** * @param {string} roomAlias - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: Object with room_id and servers. * @return {module:http-api.MatrixError} Rejects: with an error response. */ // eslint-disable-next-line camelcase - public resolveRoomAlias(roomAlias: string, callback?: Callback): Promise<{ room_id: string, servers: string[] }> { + 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(callback, Method.Get, path); + return this.http.request(Method.Get, path); } /** * Get the visibility of a room in the current HS's room directory * @param {string} roomId - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public getRoomDirectoryVisibility(roomId: string, callback?: Callback): Promise<{ visibility: Visibility }> { + public getRoomDirectoryVisibility(roomId: string): Promise<{ visibility: Visibility }> { const path = utils.encodeUri("/directory/list/room/$roomId", { $roomId: roomId, }); - return this.http.authedRequest(callback, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** @@ -7871,15 +7732,14 @@ export class MatrixClient extends TypedEventEmitter { + public setRoomDirectoryVisibility(roomId: string, visibility: Visibility): Promise<{}> { const path = utils.encodeUri("/directory/list/room/$roomId", { $roomId: roomId, }); - return this.http.authedRequest(callback, Method.Put, path, undefined, { visibility }); + return this.http.authedRequest(Method.Put, path, undefined, { visibility }); } /** @@ -7891,7 +7751,6 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types const path = utils.encodeUri("/directory/list/appservice/$networkId/$roomId", { $networkId: networkId, $roomId: roomId, }); - return this.http.authedRequest( - callback, Method.Put, path, undefined, { "visibility": visibility }, - ); + return this.http.authedRequest(Method.Put, path, undefined, { "visibility": visibility }); } /** @@ -7927,7 +7783,7 @@ export class MatrixClient extends TypedEventEmitter( - file: FileType, - opts?: O, - ): IAbortablePromise> { - return this.http.uploadContent(file, opts); + public uploadContent(file: FileType, opts?: UploadOpts): Promise { + return this.http.uploadContent(file, opts); } /** * Cancel a file upload in progress - * @param {Promise} promise The promise returned from uploadContent + * @param {Promise} upload The object returned from uploadContent * @return {boolean} true if canceled, otherwise false */ - public cancelUpload(promise: IAbortablePromise): boolean { - return this.http.cancelUpload(promise); + public cancelUpload(upload: Promise): boolean { + return this.http.cancelUpload(upload); } /** @@ -7994,7 +7843,7 @@ export class MatrixClient extends TypedEventEmitter { - if (utils.isFunction(info)) { - callback = info as any as Callback; // legacy - info = undefined; - } - const path = info ? utils.encodeUri("/profile/$userId/$info", { $userId: userId, $info: info }) : utils.encodeUri("/profile/$userId", { $userId: userId }); - return this.http.authedRequest(callback, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves to a list of the user's threepids. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public getThreePids(callback?: Callback): Promise<{ threepids: IThreepid[] }> { - return this.http.authedRequest(callback, Method.Get, "/account/3pid"); + public getThreePids(): Promise<{ threepids: IThreepid[] }> { + return this.http.authedRequest(Method.Get, "/account/3pid"); } /** @@ -8043,19 +7884,16 @@ 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, }; - return this.http.authedRequest( - callback, Method.Post, path, undefined, data, - ); + return this.http.authedRequest(Method.Post, path, undefined, data); } /** @@ -8072,8 +7910,8 @@ export class MatrixClient extends TypedEventEmitter { const path = "/account/3pid/add"; - const prefix = await this.isVersionSupported("r0.6.0") ? PREFIX_R0 : PREFIX_UNSTABLE; - return this.http.authedRequest(undefined, Method.Post, path, undefined, data, { prefix }); + const prefix = await this.isVersionSupported("r0.6.0") ? ClientPrefix.R0 : ClientPrefix.Unstable; + return this.http.authedRequest(Method.Post, path, undefined, data, { prefix }); } /** @@ -8092,11 +7930,8 @@ export class MatrixClient extends TypedEventEmitter { const path = "/account/3pid/bind"; - const prefix = await this.isVersionSupported("r0.6.0") ? - PREFIX_R0 : PREFIX_UNSTABLE; - return this.http.authedRequest( - undefined, Method.Post, path, undefined, data, { prefix }, - ); + const prefix = await this.isVersionSupported("r0.6.0") ? ClientPrefix.R0 : ClientPrefix.Unstable; + return this.http.authedRequest(Method.Post, path, undefined, data, { prefix }); } /** @@ -8121,8 +7956,8 @@ export class MatrixClient extends TypedEventEmitter { const path = "/account/3pid/delete"; - return this.http.authedRequest(undefined, Method.Post, path, undefined, { medium, address }); + return this.http.authedRequest(Method.Post, path, undefined, { medium, address }); } /** @@ -8147,36 +7982,14 @@ export class MatrixClient extends TypedEventEmitter; - public setPassword( - authDict: any, - newPassword: string, - logoutDevices: boolean, - callback?: Callback, - ): Promise<{}>; - public setPassword( - authDict: any, - newPassword: string, - logoutDevices?: Callback | boolean, - callback?: Callback, + logoutDevices?: boolean, ): Promise<{}> { - if (typeof logoutDevices === 'function') { - callback = logoutDevices; - } - if (typeof logoutDevices !== 'boolean') { - // Use backwards compatible behaviour of not specifying logout_devices - // This way it is left up to the server: - logoutDevices = undefined; - } - const path = "/account/password"; const data = { 'auth': authDict, @@ -8184,9 +7997,7 @@ export class MatrixClient extends TypedEventEmitter( - callback, Method.Post, path, undefined, data, - ); + return this.http.authedRequest<{}>(Method.Post, path, undefined, data); } /** @@ -8195,7 +8006,7 @@ export class MatrixClient extends TypedEventEmitter { - return this.http.authedRequest(undefined, Method.Get, "/devices"); + return this.http.authedRequest(Method.Get, "/devices"); } /** @@ -8208,7 +8019,7 @@ export class MatrixClient extends TypedEventEmitter { - const response = await this.http.authedRequest(callback, Method.Get, "/pushers"); + public async getPushers(): Promise<{ pushers: IPusher[] }> { + 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 @@ -8297,13 +8107,12 @@ export class MatrixClient extends TypedEventEmitter { + public setPusher(pusher: IPusherRequest): Promise<{}> { const path = "/pushers/set"; - return this.http.authedRequest(callback, Method.Post, path, undefined, pusher); + return this.http.authedRequest(Method.Post, path, undefined, pusher); } /** @@ -8323,12 +8132,11 @@ export class MatrixClient extends TypedEventEmitter { - return this.http.authedRequest(callback, Method.Get, "/pushrules/").then((rules: IPushRules) => { + public getPushRules(): Promise { + return this.http.authedRequest(Method.Get, "/pushrules/").then((rules: IPushRules) => { return PushProcessor.rewriteDefaultRules(rules); }); } @@ -8338,7 +8146,6 @@ export class MatrixClient extends TypedEventEmitter, body: Pick, - callback?: Callback, ): Promise<{}> { // NB. Scope not uri encoded because devices need the '/' const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", { $kind: kind, $ruleId: ruleId, }); - return this.http.authedRequest(callback, Method.Put, path, undefined, body); + return this.http.authedRequest(Method.Put, path, undefined, body); } /** * @param {string} scope * @param {string} kind * @param {string} ruleId - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: an empty object {} * @return {module:http-api.MatrixError} Rejects: with an error response. */ @@ -8369,14 +8174,13 @@ export class MatrixClient extends TypedEventEmitter, - callback?: Callback, ): Promise<{}> { // NB. Scope not uri encoded because devices need the '/' const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", { $kind: kind, $ruleId: ruleId, }); - return this.http.authedRequest(callback, Method.Delete, path); + return this.http.authedRequest(Method.Delete, path); } /** @@ -8385,7 +8189,6 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/enabled", { $kind: kind, $ruleId: ruleId, }); - return this.http.authedRequest( - callback, Method.Put, path, undefined, { "enabled": enabled }, - ); + return this.http.authedRequest(Method.Put, path, undefined, { "enabled": enabled }); } /** @@ -8411,7 +8211,6 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/actions", { $kind: kind, $ruleId: ruleId, }); - return this.http.authedRequest( - callback, Method.Put, path, undefined, { "actions": actions }, - ); + return this.http.authedRequest(Method.Put, path, undefined, { "actions": actions }); } /** @@ -8436,19 +8232,17 @@ export class MatrixClient extends TypedEventEmitter { const queryParams: any = {}; if (opts.next_batch) { queryParams.next_batch = opts.next_batch; } - return this.http.authedRequest(callback, Method.Post, "/search", queryParams, opts.body); + return this.http.authedRequest(Method.Post, "/search", queryParams, opts.body); } /** @@ -8459,24 +8253,21 @@ export class MatrixClient extends TypedEventEmitter { - return this.http.authedRequest(callback, Method.Post, "/keys/upload", undefined, content); + return this.http.authedRequest(Method.Post, "/keys/upload", undefined, content); } public uploadKeySignatures(content: KeySignatures): Promise { return this.http.authedRequest( - undefined, Method.Post, '/keys/signatures/upload', undefined, + Method.Post, '/keys/signatures/upload', undefined, content, { - prefix: PREFIX_UNSTABLE, + prefix: ClientPrefix.Unstable, }, ); } @@ -8494,13 +8285,7 @@ export class MatrixClient extends TypedEventEmitter { - if (utils.isFunction(opts)) { - // opts used to be 'callback'. - throw new Error('downloadKeysForUsers no longer accepts a callback parameter'); - } - opts = opts || {}; - + public downloadKeysForUsers(userIds: string[], opts: { token?: string } = {}): Promise { const content: any = { device_keys: {}, }; @@ -8511,7 +8296,7 @@ export class MatrixClient extends TypedEventEmitter { // API returns empty object const data = Object.assign({}, keys); if (auth) Object.assign(data, { auth }); return this.http.authedRequest( - undefined, Method.Post, "/keys/device_signing/upload", undefined, data, { - prefix: PREFIX_UNSTABLE, + Method.Post, "/keys/device_signing/upload", undefined, data, { + prefix: ClientPrefix.Unstable, }, ); } @@ -8600,11 +8385,8 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types const params = { @@ -8647,8 +8427,8 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types const params = { @@ -8697,8 +8475,8 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types - const params = { - sid: sid, - client_secret: clientSecret, - token: msisdnToken, - }; - - return this.http.requestOtherUrl( - undefined, Method.Post, url, undefined, params, - ); + const u = new URL(url); + u.searchParams.set("sid", sid); + u.searchParams.set("client_secret", clientSecret); + u.searchParams.set("token", msisdnToken); + return this.http.requestOtherUrl(Method.Post, u); } /** @@ -8782,8 +8556,8 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types return this.http.idServerRequest( - undefined, Method.Get, "/hash_details", - null, PREFIX_IDENTITY_V2, identityAccessToken, + Method.Get, "/hash_details", + null, IdentityPrefix.V2, identityAccessToken, ); } @@ -8851,8 +8625,8 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types // Note: we're using the V2 API by calling this function, but our @@ -8899,7 +8671,6 @@ export class MatrixClient extends TypedEventEmitter p.address === address); if (!result) { - if (callback) callback(null, {}); return {}; } @@ -8915,7 +8686,6 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types return this.http.idServerRequest( - undefined, Method.Get, "/account", - undefined, PREFIX_IDENTITY_V2, identityAccessToken, + Method.Get, "/account", + undefined, IdentityPrefix.V2, identityAccessToken, ); } @@ -9007,7 +8777,7 @@ export class MatrixClient extends TypedEventEmitter { return this.http.authedRequest>( - undefined, Method.Get, "/thirdparty/protocols", + Method.Get, "/thirdparty/protocols", ).then((response) => { // sanity check if (!response || typeof (response) !== 'object') { @@ -9054,7 +8824,7 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types const url = this.termsUrlForService(serviceType, baseUrl); - return this.http.requestOtherUrl(undefined, Method.Get, url); + return this.http.requestOtherUrl(Method.Get, url); } public agreeToTerms( @@ -9085,10 +8855,13 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types const url = this.termsUrlForService(serviceType, baseUrl); + utils.encodeParams({ + user_accepts: termsUrls, + }, url.searchParams); const headers = { Authorization: "Bearer " + accessToken, }; - return this.http.requestOtherUrl(undefined, Method.Post, url, null, { user_accepts: termsUrls }, { headers }); + return this.http.requestOtherUrl(Method.Post, url, null, { headers }); } /** @@ -9105,7 +8878,7 @@ export class MatrixClient extends TypedEventEmitter(undefined, Method.Get, path, queryParams, undefined, { - prefix: PREFIX_V1, + 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(undefined, Method.Get, path, queryParams, undefined, { + return this.http.authedRequest(Method.Get, path, queryParams, undefined, { prefix: "/_matrix/client/unstable/org.matrix.msc2946", }); } @@ -9166,7 +8939,7 @@ export class MatrixClient extends TypedEventEmitter { + req: MSC3575SlidingSyncRequest, + proxyBaseUrl?: string, + abortSignal?: AbortSignal, + ): Promise { const qps: Record = {}; if (req.pos) { qps.pos = req.pos; @@ -9239,7 +9015,6 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Post, "/sync", qps, @@ -9248,6 +9023,7 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias }); - return this.http.authedRequest(undefined, Method.Get, path, { via }, undefined, { - qsStringifyOptions: { arrayFormat: 'repeat' }, + return this.http.authedRequest(Method.Get, path, { via }, undefined, { prefix: "/_matrix/client/unstable/im.nheko.summary", }); } @@ -9280,6 +9055,13 @@ export class MatrixClient extends TypedEventEmitter { // eslint-disable-line camelcase - return this.http.authedRequest(undefined, Method.Get, "/account/whoami"); + return this.http.authedRequest(Method.Get, "/account/whoami"); } /** @@ -9313,7 +9095,6 @@ export class MatrixClient extends TypedEventEmitter 0) { + // TODO: Handle mentions received while the client is offline + // See also https://github.com/vector-im/element-web/issues/9069 + const hasReadEvent = isThreadEvent + ? room.getThread(event.threadRootId).hasUserReadEvent(cli.getUserId(), event.getId()) + : room.hasUserReadEvent(cli.getUserId(), event.getId()); + + if (!hasReadEvent) { + let newCount = currentCount; + if (newHighlight && !oldHighlight) newCount++; + if (!newHighlight && oldHighlight) newCount--; + + if (isThreadEvent) { + room.setThreadUnreadNotificationCount( + event.threadRootId, + NotificationCountType.Highlight, + newCount, + ); + } else { + room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount); + } + + // Fix 'Mentions Only' rooms from not having the right badge count + const totalCount = (isThreadEvent + ? room.getThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Total) + : room.getUnreadNotificationCount(NotificationCountType.Total)) ?? 0; + + if (totalCount < newCount) { + if (isThreadEvent) { + room.setThreadUnreadNotificationCount( + event.threadRootId, + NotificationCountType.Total, + newCount, + ); + } else { + room.setUnreadNotificationCount(NotificationCountType.Total, newCount); + } + } + } + } +} + /** * Fires whenever the SDK receives a new event. *

diff --git a/src/crypto/EncryptionSetup.ts b/src/crypto/EncryptionSetup.ts index 456c1cac4..22a2e2114 100644 --- a/src/crypto/EncryptionSetup.ts +++ b/src/crypto/EncryptionSetup.ts @@ -18,7 +18,7 @@ import { logger } from "../logger"; import { MatrixEvent } from "../models/event"; import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning"; import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; -import { Method, PREFIX_UNSTABLE } from "../http-api"; +import { Method, ClientPrefix } from "../http-api"; import { Crypto, ICryptoCallbacks, IBootstrapCrossSigningOpts } from "./index"; import { ClientEvent, @@ -240,19 +240,19 @@ 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( - undefined, Method.Put, "/room_keys/version/" + this.keyBackupInfo.version, + Method.Put, "/room_keys/version/" + this.keyBackupInfo.version, undefined, { algorithm: this.keyBackupInfo.algorithm, auth_data: this.keyBackupInfo.auth_data, }, - { prefix: PREFIX_UNSTABLE }, + { prefix: ClientPrefix.V3 }, ); } else { // add new key backup await baseApis.http.authedRequest( - undefined, Method.Post, "/room_keys/version", + Method.Post, "/room_keys/version", undefined, this.keyBackupInfo, - { prefix: PREFIX_UNSTABLE }, + { prefix: ClientPrefix.V3 }, ); } } diff --git a/src/crypto/SecretStorage.ts b/src/crypto/SecretStorage.ts index 11bade071..f343bb09a 100644 --- a/src/crypto/SecretStorage.ts +++ b/src/crypto/SecretStorage.ts @@ -550,7 +550,7 @@ export class SecretStorage { const senderKeyUser = this.baseApis.crypto.deviceList.getUserByIdentityKey( olmlib.OLM_ALGORITHM, - content.sender_key, + event.getSenderKey() || "", ); if (senderKeyUser !== event.getSender()) { logger.error("sending device does not belong to the user it claims to be from"); diff --git a/src/crypto/algorithms/base.ts b/src/crypto/algorithms/base.ts index 898a04dd8..070796720 100644 --- a/src/crypto/algorithms/base.ts +++ b/src/crypto/algorithms/base.ts @@ -23,7 +23,7 @@ limitations under the License. import { MatrixClient } from "../../client"; import { Room } from "../../models/room"; import { OlmDevice } from "../OlmDevice"; -import { MatrixEvent, RoomMember } from "../.."; +import { MatrixEvent, RoomMember } from "../../matrix"; import { Crypto, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from ".."; import { DeviceInfo } from "../deviceinfo"; import { IRoomEncryption } from "../RoomList"; diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts index 887ffe127..8769bbe01 100644 --- a/src/crypto/algorithms/megolm.ts +++ b/src/crypto/algorithms/megolm.ts @@ -1438,9 +1438,10 @@ class MegolmDecryption extends DecryptionAlgorithm { memberEvent?.getPrevContent()?.membership === "invite"); const fromUs = event.getSender() === this.baseApis.getUserId(); - if (!weRequested) { - // If someone sends us an unsolicited key and it's not - // shared history, ignore it + if (!weRequested && !fromUs) { + // If someone sends us an unsolicited key and they're + // not one of our other devices and it's not shared + // history, ignore it if (!extraSessionData.sharedHistory) { logger.log("forwarded key not shared history - ignoring"); return; @@ -1449,7 +1450,7 @@ class MegolmDecryption extends DecryptionAlgorithm { // If someone sends us an unsolicited key for a room // we're already in, and they're not one of our other // devices or the one who invited us, ignore it - if (room && !fromInviter && !fromUs) { + if (room && !fromInviter) { logger.log("forwarded key not from inviter or from us - ignoring"); return; } @@ -1794,12 +1795,17 @@ class MegolmDecryption extends DecryptionAlgorithm { * @private * @param {String} senderKey * @param {String} sessionId - * @param {Boolean} keyTrusted + * @param {Boolean} forceRedecryptIfUntrusted whether messages that were already + * successfully decrypted using untrusted keys should be re-decrypted * * @return {Boolean} whether all messages were successfully * decrypted with trusted keys */ - private async retryDecryption(senderKey: string, sessionId: string, keyTrusted?: boolean): Promise { + private async retryDecryption( + senderKey: string, + sessionId: string, + forceRedecryptIfUntrusted?: boolean, + ): Promise { const senderPendingEvents = this.pendingEvents.get(senderKey); if (!senderPendingEvents) { return true; @@ -1814,7 +1820,7 @@ class MegolmDecryption extends DecryptionAlgorithm { await Promise.all([...pending].map(async (ev) => { try { - await ev.attemptDecryption(this.crypto, { isRetry: true, keyTrusted }); + await ev.attemptDecryption(this.crypto, { isRetry: true, forceRedecryptIfUntrusted }); } catch (e) { // don't die if something goes wrong } diff --git a/src/crypto/algorithms/olm.ts b/src/crypto/algorithms/olm.ts index 38b1c97b3..14ee7516a 100644 --- a/src/crypto/algorithms/olm.ts +++ b/src/crypto/algorithms/olm.ts @@ -30,7 +30,7 @@ import { registerAlgorithm, } from "./base"; import { Room } from '../../models/room'; -import { MatrixEvent } from "../.."; +import { MatrixEvent } from "../../models/event"; import { IEventDecryptionResult } from "../index"; import { IInboundSession } from "../OlmDevice"; diff --git a/src/crypto/dehydration.ts b/src/crypto/dehydration.ts index b775d7606..b06561936 100644 --- a/src/crypto/dehydration.ts +++ b/src/crypto/dehydration.ts @@ -210,7 +210,6 @@ export class DehydrationManager { logger.log("Uploading account to server"); // eslint-disable-next-line camelcase const dehydrateResult = await this.crypto.baseApis.http.authedRequest<{ device_id: string }>( - undefined, Method.Put, "/dehydrated_device", undefined, @@ -273,7 +272,6 @@ export class DehydrationManager { logger.log("Uploading keys to server"); await this.crypto.baseApis.http.authedRequest( - undefined, Method.Post, "/keys/upload/" + encodeURI(deviceId), undefined, diff --git a/src/crypto/store/indexeddb-crypto-store-backend.ts b/src/crypto/store/indexeddb-crypto-store-backend.ts index 32539305d..51cd62a1e 100644 --- a/src/crypto/store/indexeddb-crypto-store-backend.ts +++ b/src/crypto/store/indexeddb-crypto-store-backend.ts @@ -34,7 +34,6 @@ import { IRoomEncryption } from "../RoomList"; import { InboundGroupSessionData } from "../OlmDevice"; import { IEncryptedPayload } from "../aes"; -export const VERSION = 11; const PROFILE_TRANSACTIONS = false; /** @@ -950,45 +949,34 @@ export class Backend implements CryptoStore { } } -export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void { - logger.log( - `Upgrading IndexedDBCryptoStore from version ${oldVersion}` - + ` to ${VERSION}`, - ); - if (oldVersion < 1) { // The database did not previously exist. - createDatabase(db); - } - if (oldVersion < 2) { - db.createObjectStore("account"); - } - if (oldVersion < 3) { +type DbMigration = (db: IDBDatabase) => void; +const DB_MIGRATIONS: DbMigration[] = [ + (db) => { createDatabase(db); }, + (db) => { db.createObjectStore("account"); }, + (db) => { const sessionsStore = db.createObjectStore("sessions", { keyPath: ["deviceKey", "sessionId"], }); sessionsStore.createIndex("deviceKey", "deviceKey"); - } - if (oldVersion < 4) { + }, + (db) => { db.createObjectStore("inbound_group_sessions", { keyPath: ["senderCurve25519Key", "sessionId"], }); - } - if (oldVersion < 5) { - db.createObjectStore("device_data"); - } - if (oldVersion < 6) { - db.createObjectStore("rooms"); - } - if (oldVersion < 7) { + }, + (db) => { db.createObjectStore("device_data"); }, + (db) => { db.createObjectStore("rooms"); }, + (db) => { db.createObjectStore("sessions_needing_backup", { keyPath: ["senderCurve25519Key", "sessionId"], }); - } - if (oldVersion < 8) { + }, + (db) => { db.createObjectStore("inbound_group_sessions_withheld", { keyPath: ["senderCurve25519Key", "sessionId"], }); - } - if (oldVersion < 9) { + }, + (db) => { const problemsStore = db.createObjectStore("session_problems", { keyPath: ["deviceKey", "time"], }); @@ -997,18 +985,29 @@ export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void { db.createObjectStore("notified_error_devices", { keyPath: ["userId", "deviceId"], }); - } - if (oldVersion < 10) { + }, + (db) => { db.createObjectStore("shared_history_inbound_group_sessions", { keyPath: ["roomId"], }); - } - if (oldVersion < 11) { + }, + (db) => { db.createObjectStore("parked_shared_history", { keyPath: ["roomId"], }); - } + }, // Expand as needed. +]; +export const VERSION = DB_MIGRATIONS.length; + +export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void { + logger.log( + `Upgrading IndexedDBCryptoStore from version ${oldVersion}` + + ` to ${VERSION}`, + ); + DB_MIGRATIONS.forEach((migration, index) => { + if (oldVersion <= index) migration(db); + }); } function createDatabase(db: IDBDatabase): void { diff --git a/src/feature.ts b/src/feature.ts new file mode 100644 index 000000000..5d7a49224 --- /dev/null +++ b/src/feature.ts @@ -0,0 +1,62 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IServerVersions } from "./client"; + +export enum ServerSupport { + Stable, + Unstable, + Unsupported +} + +export enum Feature { + Thread = "Thread", + ThreadUnreadNotifications = "ThreadUnreadNotifications", +} + +type FeatureSupportCondition = { + unstablePrefixes?: string[]; + matrixVersion?: string; +}; + +const featureSupportResolver: Record = { + [Feature.Thread]: { + unstablePrefixes: ["org.matrix.msc3440"], + matrixVersion: "v1.3", + }, + [Feature.ThreadUnreadNotifications]: { + unstablePrefixes: ["org.matrix.msc3771", "org.matrix.msc3773"], + matrixVersion: "v1.4", + }, +}; + +export async function buildFeatureSupportMap(versions: IServerVersions): Promise> { + const supportMap = new Map(); + for (const [feature, supportCondition] of Object.entries(featureSupportResolver)) { + const supportMatrixVersion = versions.versions?.includes(supportCondition.matrixVersion || "") ?? false; + const supportUnstablePrefixes = supportCondition.unstablePrefixes?.every(unstablePrefix => { + return versions.unstable_features?.[unstablePrefix] === true; + }) ?? false; + if (supportMatrixVersion) { + supportMap.set(feature as Feature, ServerSupport.Stable); + } else if (supportUnstablePrefixes) { + supportMap.set(feature as Feature, ServerSupport.Unstable); + } else { + supportMap.set(feature as Feature, ServerSupport.Unsupported); + } + } + return supportMap; +} diff --git a/src/filter-component.ts b/src/filter-component.ts index 8cfbea667..5e38238c6 100644 --- a/src/filter-component.ts +++ b/src/filter-component.ts @@ -73,7 +73,7 @@ export interface IFilterComponent { * @param {Object} filterJson the definition of this filter JSON, e.g. { 'contains_url': true } */ export class FilterComponent { - constructor(private filterJson: IFilterComponent, public readonly userId?: string) {} + constructor(private filterJson: IFilterComponent, public readonly userId?: string | undefined | null) {} /** * Checks with the filter component matches the given event diff --git a/src/filter.ts b/src/filter.ts index 663ba1bb9..14565c26f 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -22,6 +22,7 @@ import { EventType, RelationType, } from "./@types/event"; +import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync"; import { FilterComponent, IFilterComponent } from "./filter-component"; import { MatrixEvent } from "./models/event"; @@ -57,6 +58,8 @@ export interface IRoomEventFilter extends IFilterComponent { types?: Array; related_by_senders?: Array; related_by_rel_types?: string[]; + unread_thread_notifications?: boolean; + "org.matrix.msc3773.unread_thread_notifications"?: boolean; // Unstable values "io.element.relation_senders"?: Array; @@ -97,7 +100,7 @@ export class Filter { * @param {Object} jsonObj * @return {Filter} */ - public static fromJson(userId: string, filterId: string, jsonObj: IFilterDefinition): Filter { + public static fromJson(userId: string | undefined | null, filterId: string, jsonObj: IFilterDefinition): Filter { const filter = new Filter(userId, filterId); filter.setDefinition(jsonObj); return filter; @@ -107,7 +110,7 @@ export class Filter { private roomFilter: FilterComponent; private roomTimelineFilter: FilterComponent; - constructor(public readonly userId: string, public filterId?: string) {} + constructor(public readonly userId: string | undefined | null, public filterId?: string) {} /** * Get the ID of this filter on your homeserver (if known) @@ -220,7 +223,24 @@ export class Filter { setProp(this.definition, "room.timeline.limit", limit); } - setLazyLoadMembers(enabled: boolean) { + /** + * Enable threads unread notification + * @param {boolean} enabled + */ + public setUnreadThreadNotifications(enabled: boolean): void { + this.definition = { + ...this.definition, + room: { + ...this.definition?.room, + timeline: { + ...this.definition?.room?.timeline, + [UNREAD_THREAD_NOTIFICATIONS.name]: !!enabled, + }, + }, + }; + } + + setLazyLoadMembers(enabled: boolean): void { setProp(this.definition, "room.state.lazy_load_members", !!enabled); } diff --git a/src/http-api.ts b/src/http-api.ts deleted file mode 100644 index 1a7376b00..000000000 --- a/src/http-api.ts +++ /dev/null @@ -1,1140 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 - 2021 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. -*/ - -/** - * This is an internal module. See {@link MatrixHttpApi} for the public class. - * @module http-api - */ - -import { parse as parseContentType, ParsedMediaType } from "content-type"; - -import type { IncomingHttpHeaders, IncomingMessage } from "http"; -import type { Request as _Request, CoreOptions } from "request"; -// we use our own implementation of setTimeout, so that if we get suspended in -// the middle of a /sync, we cancel the sync as soon as we awake, rather than -// waiting for the delay to elapse. -import * as callbacks from "./realtime-callbacks"; -import { IUploadOpts } from "./@types/requests"; -import { IAbortablePromise, IUsageLimit } from "./@types/partials"; -import { IDeferred, sleep } from "./utils"; -import { Callback } from "./client"; -import * as utils from "./utils"; -import { logger } from './logger'; -import { TypedEventEmitter } from "./models/typed-event-emitter"; - -/* -TODO: -- CS: complete register function (doing stages) -- Identity server: linkEmail, authEmail, bindEmail, lookup3pid -*/ - -/** - * A constant representing the URI path for release 0 of the Client-Server HTTP API. - */ -export const PREFIX_R0 = "/_matrix/client/r0"; - -/** - * A constant representing the URI path for the legacy release v1 of the Client-Server HTTP API. - */ -export const PREFIX_V1 = "/_matrix/client/v1"; - -/** - * A constant representing the URI path for Client-Server API endpoints versioned at v3. - */ -export const PREFIX_V3 = "/_matrix/client/v3"; - -/** - * A constant representing the URI path for as-yet unspecified Client-Server HTTP APIs. - */ -export const PREFIX_UNSTABLE = "/_matrix/client/unstable"; - -/** - * URI path for v1 of the the identity API - * @deprecated Use v2. - */ -export const PREFIX_IDENTITY_V1 = "/_matrix/identity/api/v1"; - -/** - * URI path for the v2 identity API - */ -export const PREFIX_IDENTITY_V2 = "/_matrix/identity/v2"; - -/** - * URI path for the media repo API - */ -export const PREFIX_MEDIA_R0 = "/_matrix/media/r0"; - -type RequestProps = "method" - | "withCredentials" - | "json" - | "headers" - | "qs" - | "body" - | "qsStringifyOptions" - | "useQuerystring" - | "timeout"; - -export interface IHttpOpts { - baseUrl: string; - idBaseUrl?: string; - prefix: string; - onlyData: boolean; - accessToken?: string; - extraParams?: Record; - localTimeoutMs?: number; - useAuthorizationHeader?: boolean; - request(opts: Pick & { - uri: string; - method: Method; - // eslint-disable-next-line camelcase - _matrix_opts: IHttpOpts; - }, callback: RequestCallback): IRequest; -} - -interface IRequest extends _Request { - onprogress?(e: unknown): void; -} - -interface IRequestOpts { - prefix?: string; - baseUrl?: string; - localTimeoutMs?: number; - headers?: Record; - json?: boolean; // defaults to true - qsStringifyOptions?: CoreOptions["qsStringifyOptions"]; - bodyParser?(body: string): T; - - // Set to true to prevent the request function from emitting - // a Session.logged_out event. This is intended for use on - // endpoints where M_UNKNOWN_TOKEN is a valid/notable error - // response, such as with token refreshes. - inhibitLogoutEmit?: boolean; -} - -export interface IUpload { - loaded: number; - total: number; - promise: IAbortablePromise; -} - -interface IContentUri { - base: string; - path: string; - params: { - // eslint-disable-next-line camelcase - access_token: string; - }; -} - -type ResponseType | void = void> = - O extends { bodyParser: (body: string) => T } ? T : - O extends { json: false } ? string : - T; - -interface IUploadResponse { - // eslint-disable-next-line camelcase - content_uri: string; -} - -// This type's defaults only work for the Browser -// in the Browser we default rawResponse = false & onlyContentUri = true -// in Node we default rawResponse = true & onlyContentUri = false -export type UploadContentResponseType = - O extends undefined ? string : - O extends { rawResponse: true } ? string : - O extends { onlyContentUri: true } ? string : - O extends { rawResponse: false } ? IUploadResponse : - O extends { onlyContentUri: false } ? IUploadResponse : - string; - -export enum Method { - Get = "GET", - Put = "PUT", - Post = "POST", - Delete = "DELETE", -} - -export type FileType = Document | XMLHttpRequestBodyInit; - -export enum HttpApiEvent { - SessionLoggedOut = "Session.logged_out", - NoConsent = "no_consent", -} - -export type HttpApiEventHandlerMap = { - [HttpApiEvent.SessionLoggedOut]: (err: MatrixError) => void; - [HttpApiEvent.NoConsent]: (message: string, consentUri: string) => void; -}; - -/** - * Construct a MatrixHttpApi. - * @constructor - * @param {EventEmitter} eventEmitter The event emitter to use for emitting events - * @param {Object} opts The options to use for this HTTP API. - * @param {string} opts.baseUrl Required. The base client-server URL e.g. - * 'http://localhost:8008'. - * @param {Function} opts.request Required. The function to call for HTTP - * requests. This function must look like function(opts, callback){ ... }. - * @param {string} opts.prefix Required. The matrix client prefix to use, e.g. - * '/_matrix/client/r0'. See PREFIX_R0 and PREFIX_UNSTABLE for constants. - * - * @param {boolean} opts.onlyData True to return only the 'data' component of the - * response (e.g. the parsed HTTP body). If false, requests will return an - * object with the properties code, headers and data. - * - * @param {string=} opts.accessToken The access_token to send with requests. Can be - * null to not send an access token. - * @param {Object=} opts.extraParams Optional. Extra query parameters to send on - * requests. - * @param {Number=} opts.localTimeoutMs The default maximum amount of time to wait - * before timing out the request. If not specified, there is no timeout. - * @param {boolean} [opts.useAuthorizationHeader = false] Set to true to use - * Authorization header instead of query param to send the access token to the server. - */ -export class MatrixHttpApi { - private uploads: IUpload[] = []; - - constructor( - private eventEmitter: TypedEventEmitter, - public readonly opts: IHttpOpts, - ) { - utils.checkObjectHasKeys(opts, ["baseUrl", "request", "prefix"]); - opts.onlyData = !!opts.onlyData; - opts.useAuthorizationHeader = !!opts.useAuthorizationHeader; - } - - /** - * Sets the base URL for the identity server - * @param {string} url The new base url - */ - public setIdBaseUrl(url: string): void { - this.opts.idBaseUrl = url; - } - - /** - * Get the content repository url with query parameters. - * @return {Object} An object with a 'base', 'path' and 'params' for base URL, - * path and query parameters respectively. - */ - public getContentUri(): IContentUri { - return { - base: this.opts.baseUrl, - path: "/_matrix/media/r0/upload", - params: { - access_token: this.opts.accessToken, - }, - }; - } - - /** - * Upload content to the homeserver - * - * @param {object} 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 {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 applicaton/octet-stream. - * - * @param {boolean=} opts.rawResponse Return the raw body, rather than - * parsing the JSON. Defaults to false (except on node.js, where it - * defaults to true for backwards compatibility). - * - * @param {boolean=} opts.onlyContentUri Just return the content URI, - * rather than the whole body. Defaults to false (except on browsers, - * where it defaults to true for backwards compatibility). Ignored if - * opts.rawResponse is true. - * - * @param {Function=} opts.callback Deprecated. Optional. The callback to - * invoke on success/failure. See the promise return values for more - * information. - * - * @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 - * determined by this.opts.onlyData, opts.rawResponse, and - * opts.onlyContentUri. Rejects with an error (usually a MatrixError). - */ - public uploadContent( - file: FileType, - opts?: O, - ): IAbortablePromise> { - if (utils.isFunction(opts)) { - // opts used to be callback, backwards compatibility - opts = { - callback: opts as unknown as IUploadOpts["callback"], - } as O; - } else if (!opts) { - opts = {} as O; - } - - // default opts.includeFilename to true (ignoring falsey values) - const includeFilename = opts.includeFilename !== false; - - // 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 fileName = opts.name || (file as File).name; - - // We used to recommend setting file.stream to the thing to upload on - // Node.js. As of 2019-06-11, this is still in widespread use in various - // clients, so we should preserve this for simple objects used in - // Node.js. File API objects (via either the File or Blob interfaces) in - // the browser now define a `stream` method, which leads to trouble - // here, so we also check the type of `stream`. - let body = file; - const bodyStream = (body as File | Blob).stream; // this type is wrong but for legacy reasons is good enough - if (bodyStream && typeof bodyStream !== "function") { - logger.warn( - "Using `file.stream` as the content to upload. Future " + - "versions of the js-sdk will change this to expect `file` to " + - "be the content directly.", - ); - body = bodyStream; - } - - // backwards-compatibility hacks where we used to do different things - // between browser and node. - let rawResponse = opts.rawResponse; - if (rawResponse === undefined) { - if (global.XMLHttpRequest) { - rawResponse = false; - } else { - logger.warn( - "Returning the raw JSON from uploadContent(). Future " + - "versions of the js-sdk will change this default, to " + - "return the parsed object. Set opts.rawResponse=false " + - "to change this behaviour now.", - ); - rawResponse = true; - } - } - - let onlyContentUri = opts.onlyContentUri; - if (!rawResponse && onlyContentUri === undefined) { - if (global.XMLHttpRequest) { - logger.warn( - "Returning only the content-uri from uploadContent(). " + - "Future versions of the js-sdk will change this " + - "default, to return the whole response object. Set " + - "opts.onlyContentUri=false to change this behaviour now.", - ); - onlyContentUri = true; - } else { - onlyContentUri = false; - } - } - - // browser-request doesn't support File objects because it deep-copies - // the options using JSON.parse(JSON.stringify(options)). Instead of - // loading the whole file into memory as a string and letting - // browser-request base64 encode and then decode it again, we just - // use XMLHttpRequest directly. - // (browser-request doesn't support progress either, which is also kind - // of important here) - - const upload = { loaded: 0, total: 0 } as IUpload; - let promise: IAbortablePromise>; - - // XMLHttpRequest doesn't parse JSON for us. request normally does, but - // we're setting opts.json=false so that it doesn't JSON-encode the - // request, which also means it doesn't JSON-decode the response. Either - // way, we have to JSON-parse the response ourselves. - let bodyParser: ((body: string) => any) | undefined; - if (!rawResponse) { - bodyParser = function(rawBody: string) { - let body = JSON.parse(rawBody); - if (onlyContentUri) { - body = body.content_uri; - if (body === undefined) { - throw Error('Bad response'); - } - } - return body; - }; - } - - if (global.XMLHttpRequest) { - const defer = utils.defer>(); - const xhr = new global.XMLHttpRequest(); - const cb = requestCallback(defer, opts.callback, this.opts.onlyData); - - const timeoutFn = function() { - xhr.abort(); - cb(new Error('Timeout')); - }; - - // 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() { - let resp: string; - switch (xhr.readyState) { - case global.XMLHttpRequest.DONE: - callbacks.clearTimeout(timeoutTimer); - try { - if (xhr.status === 0) { - throw new AbortError(); - } - if (!xhr.responseText) { - throw new Error('No response body.'); - } - resp = xhr.responseText; - if (bodyParser) { - resp = bodyParser(resp); - } - } catch (err) { - err.httpStatus = xhr.status; - cb(err); - return; - } - cb(undefined, xhr, resp); - break; - } - }; - xhr.upload.addEventListener("progress", function(ev) { - callbacks.clearTimeout(timeoutTimer); - upload.loaded = ev.loaded; - upload.total = ev.total; - timeoutTimer = callbacks.setTimeout(timeoutFn, 30000); - if (opts.progressHandler) { - opts.progressHandler({ - loaded: ev.loaded, - total: ev.total, - }); - } - }); - let url = this.opts.baseUrl + "/_matrix/media/r0/upload"; - - const queryArgs = []; - - if (includeFilename && fileName) { - queryArgs.push("filename=" + encodeURIComponent(fileName)); - } - - if (!this.opts.useAuthorizationHeader) { - queryArgs.push("access_token=" + encodeURIComponent(this.opts.accessToken)); - } - - if (queryArgs.length > 0) { - url += "?" + queryArgs.join("&"); - } - - xhr.open("POST", url); - if (this.opts.useAuthorizationHeader) { - xhr.setRequestHeader("Authorization", "Bearer " + this.opts.accessToken); - } - xhr.setRequestHeader("Content-Type", contentType); - xhr.send(body); - promise = defer.promise as IAbortablePromise>; - - // dirty hack (as per doRequest) to allow the upload to be cancelled. - promise.abort = xhr.abort.bind(xhr); - } else { - const queryParams: Record = {}; - - if (includeFilename && fileName) { - queryParams.filename = fileName; - } - - const headers: Record = { "Content-Type": contentType }; - - // authedRequest uses `request` which is no longer maintained. - // `request` has a bug where if the body is zero bytes then you get an error: `Argument error, options.body`. - // See https://github.com/request/request/issues/920 - // if body looks like a byte array and empty then set the Content-Length explicitly as a workaround: - if ((body as unknown as ArrayLike).length === 0) { - headers["Content-Length"] = "0"; - } - - promise = this.authedRequest>( - opts.callback, Method.Post, "/upload", queryParams, body, { - prefix: "/_matrix/media/r0", - headers, - json: false, - bodyParser, - }, - ); - } - - // remove the upload from the list on completion - upload.promise = promise.finally(() => { - for (let i = 0; i < this.uploads.length; ++i) { - if (this.uploads[i] === upload) { - this.uploads.splice(i, 1); - return; - } - } - }) as IAbortablePromise>; - - // copy our dirty abort() method to the new promise - upload.promise.abort = promise.abort; - this.uploads.push(upload); - - return upload.promise as IAbortablePromise>; - } - - public cancelUpload(promise: IAbortablePromise): boolean { - if (promise.abort) { - promise.abort(); - return true; - } - return false; - } - - public getCurrentUploads(): IUpload[] { - return this.uploads; - } - - public idServerRequest( - callback: Callback, - method: Method, - path: string, - params: Record, - prefix: string, - accessToken: string, - ): Promise { - if (!this.opts.idBaseUrl) { - throw new Error("No identity server base URL set"); - } - - const fullUri = this.opts.idBaseUrl + prefix + path; - - if (callback !== undefined && !utils.isFunction(callback)) { - throw Error( - "Expected callback to be a function but got " + typeof callback, - ); - } - - const opts = { - uri: fullUri, - method, - withCredentials: false, - json: true, // we want a JSON response if we can - _matrix_opts: this.opts, - headers: {}, - } as Parameters[0]; - - if (method === Method.Get) { - opts.qs = params; - } else if (typeof params === "object") { - opts.json = params; - } - - if (accessToken) { - opts.headers['Authorization'] = `Bearer ${accessToken}`; - } - - const defer = utils.defer(); - this.opts.request(opts, requestCallback(defer, callback, this.opts.onlyData)); - return defer.promise; - } - - /** - * Perform an authorised request to the homeserver. - * @param {Function} callback Optional. The callback to invoke on - * success/failure. See the promise return values for more information. - * @param {string} method The HTTP method e.g. "GET". - * @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). If unspecified, there will be no query params. - * - * @param {Object} [data] The HTTP JSON body. - * - * @param {Object|Number=} 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. - */ - public authedRequest = IRequestOpts>( - callback: Callback | undefined, - method: Method, - path: string, - queryParams?: Record, - data?: CoreOptions["body"], - opts?: O | number, // number is legacy - ): IAbortablePromise> { - if (!queryParams) queryParams = {}; - let requestOpts = (opts || {}) as O; - - if (this.opts.useAuthorizationHeader) { - if (isFinite(opts as number)) { - // opts used to be localTimeoutMs - requestOpts = { - localTimeoutMs: opts as number, - } as O; - } - - if (!requestOpts.headers) { - requestOpts.headers = {}; - } - if (!requestOpts.headers.Authorization) { - requestOpts.headers.Authorization = "Bearer " + this.opts.accessToken; - } - if (queryParams.access_token) { - delete queryParams.access_token; - } - } else if (!queryParams.access_token) { - queryParams.access_token = this.opts.accessToken; - } - - const requestPromise = this.request(callback, method, path, queryParams, data, requestOpts); - - requestPromise.catch((err: MatrixError) => { - if (err.errcode == 'M_UNKNOWN_TOKEN' && !requestOpts?.inhibitLogoutEmit) { - this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, err); - } else if (err.errcode == 'M_CONSENT_NOT_GIVEN') { - this.eventEmitter.emit(HttpApiEvent.NoConsent, err.message, err.data.consent_uri); - } - }); - - // return the original promise, otherwise tests break due to it having to - // go around the event loop one more time to process the result of the request - return requestPromise; - } - - /** - * Perform a request to the homeserver without any credentials. - * @param {Function} callback Optional. The callback to invoke on - * success/failure. See the promise return values for more information. - * @param {string} method The HTTP method e.g. "GET". - * @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). If unspecified, there will be no query params. - * - * @param {Object} [data] The HTTP JSON body. - * - * @param {Object=} opts additional options - * - * @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 {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. - */ - public request = IRequestOpts>( - callback: Callback | undefined, - method: Method, - path: string, - queryParams?: CoreOptions["qs"], - data?: CoreOptions["body"], - opts?: O, - ): IAbortablePromise> { - const prefix = opts?.prefix ?? this.opts.prefix; - const baseUrl = opts?.baseUrl ?? this.opts.baseUrl; - const fullUri = baseUrl + prefix + path; - - return this.requestOtherUrl(callback, method, fullUri, queryParams, data, opts); - } - - /** - * Perform a request to an arbitrary URL. - * @param {Function} callback Optional. The callback to invoke on - * success/failure. See the promise return values for more information. - * @param {string} method The HTTP method e.g. "GET". - * @param {string} uri The HTTP URI - * - * @param {Object=} queryParams A dict of query params (these will NOT be - * urlencoded). If unspecified, there will be no query params. - * - * @param {Object} [data] The HTTP JSON body. - * - * @param {Object=} opts additional options - * - * @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 {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. - */ - public requestOtherUrl = IRequestOpts>( - callback: Callback | undefined, - method: Method, - uri: string, - queryParams?: CoreOptions["qs"], - data?: CoreOptions["body"], - opts?: O | number, // number is legacy - ): IAbortablePromise> { - let requestOpts = (opts || {}) as O; - if (isFinite(opts as number)) { - // opts used to be localTimeoutMs - requestOpts = { - localTimeoutMs: opts as number, - } as O; - } - - return this.doRequest(callback, method, uri, queryParams, data, requestOpts); - } - - /** - * 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". - * @return {string} URL - */ - public getUrl(path: string, queryParams: CoreOptions["qs"], prefix: string): string { - let queryString = ""; - if (queryParams) { - queryString = "?" + utils.encodeParams(queryParams); - } - return this.opts.baseUrl + prefix + path + queryString; - } - - /** - * @private - * - * @param {function} callback - * @param {string} method - * @param {string} uri - * @param {object} queryParams - * @param {object|string} data - * @param {object=} opts - * - * @param {boolean} [opts.json =true] Json-encode data before sending, and - * decode response on receipt. (We will still json-decode error - * responses, even if this is false.) - * - * @param {object=} opts.headers extra request headers - * - * @param {number=} opts.localTimeoutMs client-side timeout for the - * request. Default timeout if falsy. - * - * @param {function=} opts.bodyParser function to parse the body of the - * response before passing it to the promise and callback. - * - * @return {Promise} a promise which resolves to either the - * response object (if this.opts.onlyData is truthy), or the parsed - * body. Rejects - * - * Generic T is the callback/promise resolve type - * Generic O should be inferred - */ - private doRequest = IRequestOpts>( - callback: Callback | undefined, - method: Method, - uri: string, - queryParams?: Record, - data?: CoreOptions["body"], - opts?: O, - ): IAbortablePromise> { - if (callback !== undefined && !utils.isFunction(callback)) { - throw Error("Expected callback to be a function but got " + typeof callback); - } - - if (this.opts.extraParams) { - queryParams = { - ...(queryParams || {}), - ...this.opts.extraParams, - }; - } - - const headers = Object.assign({}, opts.headers || {}); - if (!opts) opts = {} as O; - const json = opts.json ?? true; - let bodyParser = opts.bodyParser; - - // we handle the json encoding/decoding here, because request and - // browser-request make a mess of it. Specifically, they attempt to - // json-decode plain-text error responses, which in turn means that the - // actual error gets swallowed by a SyntaxError. - - if (json) { - if (data) { - data = JSON.stringify(data); - headers['content-type'] = 'application/json'; - } - - if (!headers['accept']) { - headers['accept'] = 'application/json'; - } - - if (bodyParser === undefined) { - bodyParser = function(rawBody: string) { - return JSON.parse(rawBody); - }; - } - } - - const defer = utils.defer(); - - let timeoutId: number; - let timedOut = false; - let req: IRequest; - const localTimeoutMs = opts.localTimeoutMs || this.opts.localTimeoutMs; - - const resetTimeout = () => { - if (localTimeoutMs) { - if (timeoutId) { - callbacks.clearTimeout(timeoutId); - } - timeoutId = callbacks.setTimeout(function() { - timedOut = true; - req?.abort?.(); - defer.reject(new MatrixError({ - error: "Locally timed out waiting for a response", - errcode: "ORG.MATRIX.JSSDK_TIMEOUT", - timeout: localTimeoutMs, - })); - }, localTimeoutMs); - } - }; - resetTimeout(); - - const reqPromise = defer.promise as IAbortablePromise>; - - try { - req = this.opts.request( - { - uri: uri, - method: method, - withCredentials: false, - qs: queryParams, - qsStringifyOptions: opts.qsStringifyOptions, - useQuerystring: true, - body: data, - json: false, - timeout: localTimeoutMs, - headers: headers || {}, - _matrix_opts: this.opts, - }, - (err, response, body) => { - if (localTimeoutMs) { - callbacks.clearTimeout(timeoutId); - if (timedOut) { - return; // already rejected promise - } - } - - const handlerFn = requestCallback(defer, callback, this.opts.onlyData, bodyParser); - handlerFn(err, response, body); - }, - ); - if (req) { - // This will only work in a browser, where opts.request is the - // `browser-request` import. Currently, `request` does not support progress - // updates - see https://github.com/request/request/pull/2346. - // `browser-request` returns an XHRHttpRequest which exposes `onprogress` - if ('onprogress' in req) { - req.onprogress = (e) => { - // Prevent the timeout from rejecting the deferred promise if progress is - // seen with the request - resetTimeout(); - }; - } - - // FIXME: This is EVIL, but I can't think of a better way to expose - // abort() operations on underlying HTTP requests :( - if (req.abort) { - reqPromise.abort = req.abort.bind(req); - } - } - } catch (ex) { - defer.reject(ex); - if (callback) { - callback(ex); - } - } - return reqPromise; - } -} - -type RequestCallback = (err?: Error, response?: XMLHttpRequest | IncomingMessage, body?: string) => void; - -// if using onlyData=false then wrap your expected data type in this generic -export interface IResponse { - code: number; - data: T; - headers?: IncomingHttpHeaders; -} - -function getStatusCode(response: XMLHttpRequest | IncomingMessage): number { - return (response as XMLHttpRequest).status || (response as IncomingMessage).statusCode; -} - -/* - * Returns a callback that can be invoked by an HTTP request on completion, - * that will either resolve or reject the given defer as well as invoke the - * given userDefinedCallback (if any). - * - * HTTP errors are transformed into javascript errors and the deferred is rejected. - * - * If bodyParser is given, it is used to transform the body of the successful - * responses before passing to the defer/callback. - * - * If onlyData is true, the defer/callback is invoked with the body of the - * response, otherwise the result object (with `code` and `data` fields) - * - */ -function requestCallback( - defer: IDeferred, - userDefinedCallback?: Callback, - onlyData = false, - bodyParser?: (body: string) => T, -): RequestCallback { - return function(err: Error, response: XMLHttpRequest | IncomingMessage, body: string): void { - if (err) { - // the unit tests use matrix-mock-request, which throw the string "aborted" when aborting a request. - // See https://github.com/matrix-org/matrix-mock-request/blob/3276d0263a561b5b8326b47bae720578a2c7473a/src/index.js#L48 - const aborted = err.name === "AbortError" || (err as any as string) === "aborted"; - if (!aborted && !(err instanceof MatrixError)) { - // browser-request just throws normal Error objects, - // not `TypeError`s like fetch does. So just assume any - // error is due to the connection. - err = new ConnectionError("request failed", err); - } - } - - let data: T | string = body; - - if (!err) { - try { - if (getStatusCode(response) >= 400) { - err = parseErrorResponse(response, body); - } else if (bodyParser) { - data = bodyParser(body); - } - } catch (e) { - err = new Error(`Error parsing server response: ${e}`); - } - } - - if (err) { - defer.reject(err); - userDefinedCallback?.(err); - } else if (onlyData) { - defer.resolve(data as T); - userDefinedCallback?.(null, data as T); - } else { - const res: IResponse = { - code: getStatusCode(response), - - // XXX: why do we bother with this? it doesn't work for - // XMLHttpRequest, so clearly we don't use it. - headers: (response as IncomingMessage).headers, - data: data as T, - }; - // XXX: the variations in caller-expected types here are horrible, - // typescript doesn't do conditional types based on runtime values - defer.resolve(res as any as T); - userDefinedCallback?.(null, res as any as T); - } - }; -} - -/** - * Attempt to turn an HTTP error response into a Javascript Error. - * - * If it is a JSON response, we will parse it into a MatrixError. Otherwise - * we return a generic Error. - * - * @param {XMLHttpRequest|http.IncomingMessage} response response object - * @param {String} body raw body of the response - * @returns {Error} - */ -function parseErrorResponse(response: XMLHttpRequest | IncomingMessage, body?: string) { - const httpStatus = getStatusCode(response); - const contentType = getResponseContentType(response); - - let err; - if (contentType) { - if (contentType.type === 'application/json') { - const jsonBody = typeof(body) === 'object' ? body : JSON.parse(body); - err = new MatrixError(jsonBody); - } else if (contentType.type === 'text/plain') { - err = new Error(`Server returned ${httpStatus} error: ${body}`); - } - } - - if (!err) { - err = new Error(`Server returned ${httpStatus} error`); - } - err.httpStatus = httpStatus; - return err; -} - -/** - * extract the Content-Type header from the response object, and - * parse it to a `{type, parameters}` object. - * - * returns null if no content-type header could be found. - * - * @param {XMLHttpRequest|http.IncomingMessage} response response object - * @returns {{type: String, parameters: Object}?} parsed content-type header, or null if not found - */ -function getResponseContentType(response: XMLHttpRequest | IncomingMessage): ParsedMediaType { - let contentType; - if ((response as XMLHttpRequest).getResponseHeader) { - // XMLHttpRequest provides getResponseHeader - contentType = (response as XMLHttpRequest).getResponseHeader("Content-Type"); - } else if ((response as IncomingMessage).headers) { - // request provides http.IncomingMessage which has a message.headers map - contentType = (response as IncomingMessage).headers['content-type'] || null; - } - - if (!contentType) { - return null; - } - - try { - return parseContentType(contentType); - } catch (e) { - throw new Error(`Error parsing Content-Type '${contentType}': ${e}`); - } -} - -interface IErrorJson extends Partial { - [key: string]: any; // extensible - errcode?: string; - error?: string; -} - -/** - * 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 Error { - public readonly errcode: string; - public readonly data: IErrorJson; - public httpStatus?: number; // set by http-api - - constructor(errorJson: IErrorJson = {}) { - super(`MatrixError: ${errorJson.errcode}`); - this.errcode = errorJson.errcode; - this.name = errorJson.errcode || "Unknown error code"; - this.message = errorJson.error || "Unknown message"; - this.data = errorJson; - } -} - -/** - * Construct a ConnectionError. This is a JavaScript Error indicating - * 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 { - constructor(message: string, cause: Error = undefined) { - super(message + (cause ? `: ${cause.message}` : "")); - } - - get name() { - return "ConnectionError"; - } -} - -export class AbortError extends Error { - constructor() { - super("Operation aborted"); - } - - get name() { - return "AbortError"; - } -} - -/** - * 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 - */ -export async function retryNetworkOperation(maxAttempts: number, callback: () => Promise): Promise { - let attempts = 0; - let lastConnectionError = null; - while (attempts < maxAttempts) { - try { - if (attempts > 0) { - const timeout = 1000 * Math.pow(2, attempts); - logger.log(`network operation failed ${attempts} times,` + - ` retrying in ${timeout}ms...`); - await sleep(timeout); - } - return callback(); - } catch (err) { - if (err instanceof ConnectionError) { - attempts += 1; - lastConnectionError = err; - } else { - throw err; - } - } - } - throw lastConnectionError; -} diff --git a/src/http-api/errors.ts b/src/http-api/errors.ts new file mode 100644 index 000000000..cf057aff7 --- /dev/null +++ b/src/http-api/errors.ts @@ -0,0 +1,70 @@ +/* +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 { IUsageLimit } from "../@types/partials"; + +interface IErrorJson extends Partial { + [key: string]: any; // extensible + errcode?: string; + error?: string; +} + +/** + * 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 Error { + public readonly errcode?: string; + public readonly data: IErrorJson; + + constructor(errorJson: IErrorJson = {}, public httpStatus?: number, public url?: string) { + let message = errorJson.error || "Unknown message"; + if (httpStatus) { + message = `[${httpStatus}] ${message}`; + } + if (url) { + message = `${message} (${url})`; + } + super(`MatrixError: ${message}`); + this.errcode = errorJson.errcode; + this.name = errorJson.errcode || "Unknown error code"; + this.data = errorJson; + } +} + +/** + * Construct a ConnectionError. This is a JavaScript Error indicating + * 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 { + constructor(message: string, cause?: Error) { + super(message + (cause ? `: ${cause.message}` : "")); + } + + get name() { + return "ConnectionError"; + } +} diff --git a/src/http-api/fetch.ts b/src/http-api/fetch.ts new file mode 100644 index 000000000..4fecaaecf --- /dev/null +++ b/src/http-api/fetch.ts @@ -0,0 +1,327 @@ +/* +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. +*/ + +/** + * This is an internal module. See {@link MatrixHttpApi} for the public class. + * @module http-api + */ + +import * as utils from "../utils"; +import { TypedEventEmitter } from "../models/typed-event-emitter"; +import { Method } from "./method"; +import { ConnectionError, MatrixError } from "./errors"; +import { HttpApiEvent, HttpApiEventHandlerMap, IHttpOpts, IRequestOpts } from "./interface"; +import { anySignal, parseErrorResponse, timeoutSignal } from "./utils"; +import { QueryDict } from "../utils"; + +type Body = Record | BodyInit; + +interface TypedResponse extends Response { + json(): Promise; +} + +export type ResponseType = + O extends undefined ? T : + O extends { onlyData: true } ? T : + TypedResponse; + +export class FetchHttpApi { + private abortController = new AbortController(); + + constructor( + private eventEmitter: TypedEventEmitter, + public readonly opts: O, + ) { + utils.checkObjectHasKeys(opts, ["baseUrl", "prefix"]); + opts.onlyData = !!opts.onlyData; + opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true; + } + + public abort(): void { + this.abortController.abort(); + this.abortController = new AbortController(); + } + + public fetch(resource: URL | string, options?: RequestInit): ReturnType { + if (this.opts.fetchFn) { + return this.opts.fetchFn(resource, options); + } + return global.fetch(resource, options); + } + + /** + * Sets the base URL for the identity server + * @param {string} url The new base url + */ + public setIdBaseUrl(url: string): void { + this.opts.idBaseUrl = url; + } + + public idServerRequest( + method: Method, + path: string, + params: Record, + prefix: string, + accessToken?: string, + ): Promise> { + if (!this.opts.idBaseUrl) { + throw new Error("No identity server base URL set"); + } + + let queryParams: QueryDict | undefined = undefined; + let body: Record | undefined = undefined; + if (method === Method.Get) { + queryParams = params; + } else { + body = params; + } + + const fullUri = this.getUrl(path, queryParams, prefix, this.opts.idBaseUrl); + + const opts: IRequestOpts = { + json: true, + headers: {}, + }; + if (accessToken) { + opts.headers.Authorization = `Bearer ${accessToken}`; + } + + return this.requestOtherUrl(method, fullUri, body, opts); + } + + /** + * 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. + * "/createRoom". + * + * @param {Object=} 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|Number=} 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. + */ + public authedRequest( + method: Method, + path: string, + queryParams?: QueryDict, + body?: Body, + opts: IRequestOpts = {}, + ): Promise> { + if (!queryParams) queryParams = {}; + + if (this.opts.useAuthorizationHeader) { + if (!opts.headers) { + opts.headers = {}; + } + if (!opts.headers.Authorization) { + opts.headers.Authorization = "Bearer " + this.opts.accessToken; + } + if (queryParams.access_token) { + delete queryParams.access_token; + } + } else if (!queryParams.access_token) { + queryParams.access_token = this.opts.accessToken; + } + + const requestPromise = this.request(method, path, queryParams, body, opts); + + requestPromise.catch((err: MatrixError) => { + if (err.errcode == 'M_UNKNOWN_TOKEN' && !opts?.inhibitLogoutEmit) { + this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, err); + } else if (err.errcode == 'M_CONSENT_NOT_GIVEN') { + this.eventEmitter.emit(HttpApiEvent.NoConsent, err.message, err.data.consent_uri); + } + }); + + // return the original promise, otherwise tests break due to it having to + // go around the event loop one more time to process the result of the request + return requestPromise; + } + + /** + * 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. + * "/createRoom". + * + * @param {Object=} 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 {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 {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. + */ + public request( + method: Method, + path: string, + queryParams?: QueryDict, + body?: Body, + opts?: IRequestOpts, + ): Promise> { + const fullUri = this.getUrl(path, queryParams, opts?.prefix, opts?.baseUrl); + return this.requestOtherUrl(method, fullUri, body, opts); + } + + /** + * 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 {Object=} opts additional options + * + * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before + * timing out the request. If not specified, there is no timeout. + * + * @param {Object=} opts.headers map of additional request headers + * + * @return {Promise} 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 + * occurred. This includes network problems and Matrix-specific error JSON. + */ + public async requestOtherUrl( + method: Method, + url: URL | string, + body?: Body, + opts: Pick = {}, + ): Promise> { + const headers = Object.assign({}, opts.headers || {}); + const json = opts.json ?? true; + // We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref + const jsonBody = json && body?.constructor?.name === Object.name; + + if (json) { + if (jsonBody && !headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + + if (!headers["Accept"]) { + headers["Accept"] = "application/json"; + } + } + + const timeout = opts.localTimeoutMs ?? this.opts.localTimeoutMs; + const signals = [ + this.abortController.signal, + ]; + if (timeout !== undefined) { + signals.push(timeoutSignal(timeout)); + } + if (opts.abortSignal) { + signals.push(opts.abortSignal); + } + + let data: BodyInit; + if (jsonBody) { + data = JSON.stringify(body); + } else { + data = body as BodyInit; + } + + const { signal, cleanup } = anySignal(signals); + + let res: Response; + try { + res = await this.fetch(url, { + signal, + method, + body: data, + headers, + mode: "cors", + redirect: "follow", + referrer: "", + referrerPolicy: "no-referrer", + cache: "no-cache", + credentials: "omit", // we send credentials via headers + }); + } catch (e) { + if (e.name === "AbortError") { + throw e; + } + throw new ConnectionError("fetch failed", e); + } finally { + cleanup(); + } + + if (!res.ok) { + throw parseErrorResponse(res, await res.text()); + } + + if (this.opts.onlyData) { + return json ? res.json() : res.text(); + } + return res as ResponseType; + } + + /** + * 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 + */ + 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); + } + return url; + } +} diff --git a/src/http-api/index.ts b/src/http-api/index.ts new file mode 100644 index 000000000..62e4b478e --- /dev/null +++ b/src/http-api/index.ts @@ -0,0 +1,216 @@ +/* +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 { FetchHttpApi } from "./fetch"; +import { FileType, IContentUri, IHttpOpts, Upload, UploadOpts, UploadResponse } from "./interface"; +import { MediaPrefix } from "./prefix"; +import * as utils from "../utils"; +import * as callbacks from "../realtime-callbacks"; +import { Method } from "./method"; +import { ConnectionError, MatrixError } from "./errors"; +import { parseErrorResponse } from "./utils"; + +export * from "./interface"; +export * from "./prefix"; +export * from "./errors"; +export * from "./method"; +export * from "./utils"; + +export class MatrixHttpApi extends FetchHttpApi { + private uploads: Upload[] = []; + + /** + * Upload content to the homeserver + * + * @param {object} 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 {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 {boolean=} opts.rawResponse Return the raw body, rather than + * parsing the JSON. Defaults to false (except on node.js, where it + * defaults to true for backwards compatibility). + * + * @param {boolean=} opts.onlyContentUri Just return the content URI, + * rather than the whole body. Defaults to false (except on browsers, + * where it defaults to true for backwards compatibility). Ignored if + * opts.rawResponse is true. + * + * @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 + * determined by this.opts.onlyData, opts.rawResponse, and + * opts.onlyContentUri. Rejects with an error (usually a MatrixError). + */ + public uploadContent(file: FileType, opts: UploadOpts = {}): Promise { + const includeFilename = opts.includeFilename ?? true; + 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 fileName = opts.name ?? (file as File).name; + + const upload = { + loaded: 0, + total: 0, + abortController, + } as Upload; + const defer = utils.defer(); + + if (global.XMLHttpRequest) { + const xhr = new global.XMLHttpRequest(); + + const timeoutFn = function() { + xhr.abort(); + defer.reject(new Error("Timeout")); + }; + + // 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() { + switch (xhr.readyState) { + case global.XMLHttpRequest.DONE: + callbacks.clearTimeout(timeoutTimer); + try { + if (xhr.status === 0) { + throw new DOMException(xhr.statusText, "AbortError"); // mimic fetch API + } + if (!xhr.responseText) { + throw new Error('No response body.'); + } + + if (xhr.status >= 400) { + defer.reject(parseErrorResponse(xhr, xhr.responseText)); + } else { + defer.resolve(JSON.parse(xhr.responseText)); + } + } catch (err) { + if (err.name === "AbortError") { + defer.reject(err); + return; + } + + (err).httpStatus = xhr.status; + defer.reject(new ConnectionError("request failed", err)); + } + break; + } + }; + + xhr.upload.onprogress = (ev: ProgressEvent) => { + callbacks.clearTimeout(timeoutTimer); + upload.loaded = ev.loaded; + upload.total = ev.total; + timeoutTimer = callbacks.setTimeout(timeoutFn, 30000); + opts.progressHandler?.({ + loaded: ev.loaded, + total: ev.total, + }); + }; + + const url = this.getUrl("/upload", undefined, MediaPrefix.R0); + + if (includeFilename && fileName) { + url.searchParams.set("filename", encodeURIComponent(fileName)); + } + + if (!this.opts.useAuthorizationHeader && this.opts.accessToken) { + url.searchParams.set("access_token", encodeURIComponent(this.opts.accessToken)); + } + + xhr.open(Method.Post, url.href); + if (this.opts.useAuthorizationHeader && this.opts.accessToken) { + xhr.setRequestHeader("Authorization", "Bearer " + this.opts.accessToken); + } + xhr.setRequestHeader("Content-Type", contentType); + xhr.send(file); + + abortController.signal.addEventListener("abort", () => { + xhr.abort(); + }); + } else { + const queryParams: utils.QueryDict = {}; + if (includeFilename && fileName) { + queryParams.filename = fileName; + } + + 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); + } + + // remove the upload from the list on completion + upload.promise = defer.promise.finally(() => { + utils.removeElement(this.uploads, elem => elem === upload); + }); + abortController.signal.addEventListener("abort", () => { + utils.removeElement(this.uploads, elem => elem === upload); + defer.reject(new DOMException("Aborted", "AbortError")); + }); + this.uploads.push(upload); + return upload.promise; + } + + public cancelUpload(promise: Promise): boolean { + const upload = this.uploads.find(u => u.promise === promise); + if (upload) { + upload.abortController.abort(); + return true; + } + return false; + } + + public getCurrentUploads(): Upload[] { + return this.uploads; + } + + /** + * Get the content repository url with query parameters. + * @return {Object} An object with a 'base', 'path' and 'params' for base URL, + * path and query parameters respectively. + */ + public getContentUri(): IContentUri { + return { + base: this.opts.baseUrl, + path: MediaPrefix.R0 + "/upload", + params: { + access_token: this.opts.accessToken, + }, + }; + } +} diff --git a/src/http-api/interface.ts b/src/http-api/interface.ts new file mode 100644 index 000000000..c798bec0d --- /dev/null +++ b/src/http-api/interface.ts @@ -0,0 +1,93 @@ +/* +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 { MatrixError } from "./errors"; + +export interface IHttpOpts { + fetchFn?: typeof global.fetch; + + baseUrl: string; + idBaseUrl?: string; + prefix: string; + extraParams?: Record; + + accessToken?: string; + useAuthorizationHeader?: boolean; // defaults to true + + onlyData?: boolean; + localTimeoutMs?: number; +} + +export interface IRequestOpts { + baseUrl?: string; + prefix?: string; + + headers?: Record; + abortSignal?: AbortSignal; + localTimeoutMs?: number; + json?: boolean; // defaults to true + + // Set to true to prevent the request function from emitting a Session.logged_out event. + // This is intended for use on endpoints where M_UNKNOWN_TOKEN is a valid/notable error response, + // such as with token refreshes. + inhibitLogoutEmit?: boolean; +} + +export interface IContentUri { + base: string; + path: string; + params: { + // eslint-disable-next-line camelcase + access_token: string; + }; +} + +export enum HttpApiEvent { + SessionLoggedOut = "Session.logged_out", + NoConsent = "no_consent", +} + +export type HttpApiEventHandlerMap = { + [HttpApiEvent.SessionLoggedOut]: (err: MatrixError) => void; + [HttpApiEvent.NoConsent]: (message: string, consentUri: string) => void; +}; + +export interface UploadProgress { + loaded: number; + total: number; +} + +export interface UploadOpts { + name?: string; + type?: string; + includeFilename?: boolean; + progressHandler?(progress: UploadProgress): void; + abortController?: AbortController; +} + +export interface Upload { + loaded: number; + total: number; + promise: Promise; + abortController: AbortController; +} + +export interface UploadResponse { + // eslint-disable-next-line camelcase + content_uri: string; +} + +export type FileType = XMLHttpRequestBodyInit; diff --git a/src/http-api/method.ts b/src/http-api/method.ts new file mode 100644 index 000000000..1914360e3 --- /dev/null +++ b/src/http-api/method.ts @@ -0,0 +1,22 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export enum Method { + Get = "GET", + Put = "PUT", + Post = "POST", + Delete = "DELETE", +} diff --git a/src/http-api/prefix.ts b/src/http-api/prefix.ts new file mode 100644 index 000000000..8111bc355 --- /dev/null +++ b/src/http-api/prefix.ts @@ -0,0 +1,53 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export enum ClientPrefix { + /** + * A constant representing the URI path for release 0 of the Client-Server HTTP API. + */ + R0 = "/_matrix/client/r0", + /** + * A constant representing the URI path for the legacy release v1 of the Client-Server HTTP API. + */ + V1 = "/_matrix/client/v1", + /** + * A constant representing the URI path for Client-Server API endpoints versioned at v3. + */ + V3 = "/_matrix/client/v3", + /** + * A constant representing the URI path for as-yet unspecified Client-Server HTTP APIs. + */ + Unstable = "/_matrix/client/unstable", +} + +export enum IdentityPrefix { + /** + * URI path for v1 of the identity API + * @deprecated Use v2. + */ + V1 = "/_matrix/identity/api/v1", + /** + * URI path for the v2 identity API + */ + V2 = "/_matrix/identity/api/v2", +} + +export enum MediaPrefix { + /** + * URI path for the media repo API + */ + R0 = "/_matrix/media/r0", +} diff --git a/src/http-api/utils.ts b/src/http-api/utils.ts new file mode 100644 index 000000000..ac0eafb4b --- /dev/null +++ b/src/http-api/utils.ts @@ -0,0 +1,153 @@ +/* +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 { parse as parseContentType, ParsedMediaType } from "content-type"; + +import { logger } from "../logger"; +import { sleep } from "../utils"; +import { ConnectionError, MatrixError } from "./errors"; + +// Ponyfill for https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout +export function timeoutSignal(ms: number): AbortSignal { + const controller = new AbortController(); + setTimeout(() => { + controller.abort(); + }, ms); + + return controller.signal; +} + +export function anySignal(signals: AbortSignal[]): { + signal: AbortSignal; + cleanup(): void; +} { + const controller = new AbortController(); + + function cleanup() { + for (const signal of signals) { + signal.removeEventListener("abort", onAbort); + } + } + + function onAbort() { + controller.abort(); + cleanup(); + } + + for (const signal of signals) { + if (signal.aborted) { + onAbort(); + break; + } + signal.addEventListener("abort", onAbort); + } + + return { + signal: controller.signal, + cleanup, + }; +} + +/** + * Attempt to turn an HTTP error response into a Javascript Error. + * + * 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} + */ +export function parseErrorResponse(response: XMLHttpRequest | Response, body?: string): Error { + let contentType: ParsedMediaType; + try { + contentType = getResponseContentType(response); + } catch (e) { + return e; + } + + if (contentType?.type === "application/json" && body) { + return new MatrixError( + JSON.parse(body), + response.status, + isXhr(response) ? response.responseURL : response.url, + ); + } + if (contentType?.type === "text/plain") { + return new Error(`Server returned ${response.status} error: ${body}`); + } + return new Error(`Server returned ${response.status} error`); +} + +function isXhr(response: XMLHttpRequest | Response): response is XMLHttpRequest { + return "getResponseHeader" in response; +} + +/** + * extract the Content-Type header from the response object, and + * parse it to a `{type, parameters}` object. + * + * 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 + */ +function getResponseContentType(response: XMLHttpRequest | Response): ParsedMediaType | null { + let contentType: string | null; + if (isXhr(response)) { + contentType = response.getResponseHeader("Content-Type"); + } else { + contentType = response.headers.get("Content-Type"); + } + + if (!contentType) return null; + + try { + return parseContentType(contentType); + } catch (e) { + throw new Error(`Error parsing Content-Type '${contentType}': ${e}`); + } +} + +/** + * 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 + */ +export async function retryNetworkOperation(maxAttempts: number, callback: () => Promise): Promise { + let attempts = 0; + let lastConnectionError: ConnectionError | null = null; + while (attempts < maxAttempts) { + try { + if (attempts > 0) { + const timeout = 1000 * Math.pow(2, attempts); + logger.log(`network operation failed ${attempts} times, retrying in ${timeout}ms...`); + await sleep(timeout); + } + return await callback(); + } catch (err) { + if (err instanceof ConnectionError) { + attempts += 1; + lastConnectionError = err; + } else { + throw err; + } + } + } + throw lastConnectionError; +} diff --git a/src/index.ts b/src/index.ts index c651438fb..4b8422435 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,17 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import * as request from "request"; - import * as matrixcs from "./matrix"; import * as utils from "./utils"; import { logger } from './logger'; -if (matrixcs.getRequest()) { +if (global.__js_sdk_entrypoint) { throw new Error("Multiple matrix-js-sdk entrypoints detected!"); } - -matrixcs.request(request); +global.__js_sdk_entrypoint = true; try { // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/src/matrix.ts b/src/matrix.ts index 4fbd30773..e89feddeb 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -64,41 +64,6 @@ export { } from "./webrtc/groupCall"; export type { GroupCall } from "./webrtc/groupCall"; -// expose the underlying request object so different environments can use -// different request libs (e.g. request or browser-request) -let requestInstance; - -/** - * The function used to perform HTTP requests. Only use this if you want to - * use a different HTTP library, e.g. Angular's $http. This should - * be set prior to calling {@link createClient}. - * @param {requestFunction} r The request function to use. - */ -export function request(r) { - requestInstance = r; -} - -/** - * Return the currently-set request function. - * @return {requestFunction} The current request function. - */ -export function getRequest() { - return requestInstance; -} - -/** - * Apply wrapping code around the request function. The wrapper function is - * installed as the new request handler, and when invoked it is passed the - * previous value, along with the options and callback arguments. - * @param {requestWrapperFunction} wrapper The wrapping function. - */ -export function wrapRequest(wrapper) { - const origRequest = requestInstance; - requestInstance = function(options, callback) { - return wrapper(origRequest, options, callback); - }; -} - let cryptoStoreFactory = () => new MemoryCryptoStore; /** @@ -111,10 +76,7 @@ export function setCryptoStoreFactory(fac) { cryptoStoreFactory = fac; } -function amendClientOpts(opts: ICreateClientOpts | string): ICreateClientOpts { - if (typeof opts === "string") opts = { baseUrl: opts }; - - opts.request = opts.request ?? requestInstance; +function amendClientOpts(opts: ICreateClientOpts): ICreateClientOpts { opts.store = opts.store ?? new MemoryStore({ localStorage: global.localStorage, }); @@ -127,15 +89,12 @@ function amendClientOpts(opts: ICreateClientOpts | string): ICreateClientOpts { /** * Construct a Matrix Client. Similar to {@link module:client.MatrixClient} * except that the 'request', 'store' and 'scheduler' dependencies are satisfied. - * @param {(Object|string)} opts The configuration options for this client. If - * this is a string, it is assumed to be the base URL. These configuration + * @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 {requestFunction} opts.request If not set, defaults to the function - * supplied to {@link request} which defaults to the request module from NPM. * * @param {module:crypto.store.base~CryptoStore=} opts.cryptoStore * crypto store implementation. Calls the factory supplied to @@ -147,7 +106,7 @@ function amendClientOpts(opts: ICreateClientOpts | string): ICreateClientOpts { * @see {@link module:client.MatrixClient} for the full list of options for * opts. */ -export function createClient(opts: ICreateClientOpts | string): MatrixClient { +export function createClient(opts: ICreateClientOpts): MatrixClient { return new MatrixClient(amendClientOpts(opts)); } @@ -155,46 +114,7 @@ export function createRoomWidgetClient( widgetApi: WidgetApi, capabilities: ICapabilities, roomId: string, - opts: ICreateClientOpts | string, + opts: ICreateClientOpts, ): MatrixClient { return new RoomWidgetClient(widgetApi, capabilities, roomId, amendClientOpts(opts)); } - -/** - * The request function interface for performing HTTP requests. This matches the - * API for the {@link https://github.com/request/request#requestoptions-callback| - * request NPM module}. The SDK will attempt to call this function in order to - * perform an HTTP request. - * @callback requestFunction - * @param {Object} opts The options for this HTTP request. - * @param {string} opts.uri The complete URI. - * @param {string} opts.method The HTTP method. - * @param {Object} opts.qs The query parameters to append to the URI. - * @param {Object} opts.body The JSON-serializable object. - * @param {boolean} opts.json True if this is a JSON request. - * @param {Object} opts._matrix_opts The underlying options set for - * {@link MatrixHttpApi}. - * @param {requestCallback} callback The request callback. - */ - -/** - * A wrapper for the request function interface. - * @callback requestWrapperFunction - * @param {requestFunction} origRequest The underlying request function being - * wrapped - * @param {Object} opts The options for this HTTP request, given in the same - * form as {@link requestFunction}. - * @param {requestCallback} callback The request callback. - */ - -/** - * The request callback interface for performing HTTP requests. This matches the - * API for the {@link https://github.com/request/request#requestoptions-callback| - * request NPM module}. The SDK will implement a callback which meets this - * interface in order to handle the HTTP response. - * @callback requestCallback - * @param {Error} err The error if one occurred, else falsey. - * @param {Object} response The HTTP response which consists of - * {statusCode: {Number}, headers: {Object}} - * @param {Object} body The parsed HTTP response body. - */ diff --git a/src/models/MSC3089Branch.ts b/src/models/MSC3089Branch.ts index 0c2082a2a..a230a4f1a 100644 --- a/src/models/MSC3089Branch.ts +++ b/src/models/MSC3089Branch.ts @@ -20,7 +20,7 @@ import { IContent, MatrixEvent } from "./event"; import { MSC3089TreeSpace } from "./MSC3089TreeSpace"; import { EventTimeline } from "./event-timeline"; import { FileType } from "../http-api"; -import type { ISendEventResponse } from ".."; +import type { ISendEventResponse } from "../@types/requests"; /** * Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) branch - a reference diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index 0426d596e..9a9deec68 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -476,10 +476,8 @@ export class MSC3089TreeSpace { info: Partial, additionalContent?: IContent, ): Promise { - const mxc = await this.client.uploadContent(encryptedContents, { + const { content_uri: mxc } = await this.client.uploadContent(encryptedContents, { includeFilename: false, - onlyContentUri: true, - rawResponse: false, // make this explicit otherwise behaviour is different on browser vs NodeJS }); info.url = mxc; diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index e378f0a30..e7d687425 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -123,12 +123,15 @@ export class EventTimelineSet extends TypedEventEmitter> = { + public paginationRequests: Record | null> = { [Direction.Backward]: null, [Direction.Forward]: null, }; @@ -311,7 +311,7 @@ export class EventTimeline { * token for going backwards in time; EventTimeline.FORWARDS to set the * pagination token for going forwards in time. */ - public setPaginationToken(token: string, direction: Direction): void { + public setPaginationToken(token: string | null, direction: Direction): void { this.getState(direction).paginationToken = token; } diff --git a/src/models/event.ts b/src/models/event.ts index 071134978..406aded0b 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -151,7 +151,7 @@ interface IKeyRequestRecipient { export interface IDecryptOptions { emit?: boolean; isRetry?: boolean; - keyTrusted?: boolean; + forceRedecryptIfUntrusted?: boolean; } /** @@ -678,6 +678,8 @@ export class MatrixEvent extends TypedEventEmitter= 0; --i) { + for (let i = this.timeline?.length - 1; i >= 0; --i) { const ev = this.timeline[i]; // If we encounter the target event first, the user hasn't read it diff --git a/src/models/room-state.ts b/src/models/room-state.ts index b0104cf70..3cca4a7b8 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -97,7 +97,7 @@ export class RoomState extends TypedEventEmitter // XXX: Should be read-only public members: Record = {}; // userId: RoomMember public events = new Map>(); // Map> - public paginationToken: string = null; + public paginationToken: string | null = null; public readonly beacons = new Map(); private _liveBeaconIds: BeaconIdentifier[] = []; diff --git a/src/models/room.ts b/src/models/room.ts index 75267fa2a..b1019dc4e 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -57,6 +57,7 @@ import { ReceiptContent, synthesizeReceipt, } from "./read-receipt"; +import { Feature, ServerSupport } from "../feature"; // These constants are used as sane defaults when the homeserver doesn't support // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be @@ -96,6 +97,8 @@ export interface IRecommendedVersion { // price to pay to keep matrix-js-sdk responsive. const MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH = 30; +export type NotificationCount = Partial>; + export enum NotificationCountType { Highlight = "highlight", Total = "total", @@ -125,6 +128,7 @@ export enum RoomEvent { OldStateUpdated = "Room.OldStateUpdated", CurrentStateUpdated = "Room.CurrentStateUpdated", HistoryImportedWithinTimeline = "Room.historyImportedWithinTimeline", + UnreadNotifications = "Room.UnreadNotifications", } type EmittedEvents = RoomEvent @@ -162,6 +166,10 @@ export type RoomEventHandlerMap = { 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; } & ThreadHandlerMap @@ -183,7 +191,9 @@ export type RoomEventHandlerMap = { export class Room extends ReadReceipt { public readonly reEmitter: TypedReEmitter; private txnToEvent: Record = {}; // Pending in-flight requests { string: MatrixEvent } - private notificationCounts: Partial> = {}; + private notificationCounts: NotificationCount = {}; + private readonly threadNotifications = new Map(); + private roomThreadsNotificationType: NotificationCountType | null = null; private readonly timelineSets: EventTimelineSet[]; public readonly threadsTimelineSets: EventTimelineSet[] = []; // any filtered timeline sets we're maintaining for this room @@ -356,7 +366,7 @@ export class Room extends ReadReceipt { } private threadTimelineSetsPromise: Promise<[EventTimelineSet, EventTimelineSet]> | null = null; - public async createThreadsTimelineSets(): Promise<[EventTimelineSet, EventTimelineSet]> { + public async createThreadsTimelineSets(): Promise<[EventTimelineSet, EventTimelineSet] | null> { if (this.threadTimelineSetsPromise) { return this.threadTimelineSetsPromise; } @@ -369,10 +379,13 @@ export class Room extends ReadReceipt { ]); const timelineSets = await this.threadTimelineSetsPromise; this.threadsTimelineSets.push(...timelineSets); + return timelineSets; } catch (e) { this.threadTimelineSetsPromise = null; + return null; } } + return null; } /** @@ -1176,8 +1189,98 @@ export class Room extends ReadReceipt { * @return {Number} The notification count, or undefined if there is no count * for this type. */ - public getUnreadNotificationCount(type = NotificationCountType.Total): number | undefined { - return this.notificationCounts[type]; + public getUnreadNotificationCount(type = NotificationCountType.Total): number { + let count = this.notificationCounts[type] ?? 0; + if (this.client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported) { + for (const threadNotification of this.threadNotifications.values()) { + count += threadNotification[type] ?? 0; + } + } + return count; + } + + /** + * @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' + * @returns The notification count, or undefined if there is no count + * for this type. + */ + public getThreadUnreadNotificationCount(threadId: string, type = NotificationCountType.Total): number { + return this.threadNotifications.get(threadId)?.[type] ?? 0; + } + + /** + * @experimental + * Checks if the current room has unread thread notifications + * @returns {boolean} + */ + public hasThreadUnreadNotification(): boolean { + for (const notification of this.threadNotifications.values()) { + if ((notification.highlight ?? 0) > 0 || (notification.total ?? 0) > 0) { + return true; + } + } + return false; + } + + /** + * @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} + */ + public setThreadUnreadNotificationCount(threadId: string, type: NotificationCountType, count: number): void { + const notification: NotificationCount = { + highlight: this.threadNotifications.get(threadId)?.highlight, + total: this.threadNotifications.get(threadId)?.total, + ...{ + [type]: count, + }, + }; + + this.threadNotifications.set(threadId, notification); + + // Remember what the global threads notification count type is + // for all the threads in the room + if (count > 0) { + switch (this.roomThreadsNotificationType) { + case NotificationCountType.Highlight: + break; + case NotificationCountType.Total: + if (type === NotificationCountType.Highlight) { + this.roomThreadsNotificationType = type; + } + break; + default: + this.roomThreadsNotificationType = type; + } + } + + this.emit( + RoomEvent.UnreadNotifications, + notification, + threadId, + ); + } + + /** + * @experimental + * @returns the notification count type for all the threads in the room + */ + public getThreadsAggregateNotificationType(): NotificationCountType | null { + return this.roomThreadsNotificationType; + } + + /** + * @experimental + * Resets the thread notifications for this room + */ + public resetThreadUnreadNotificationCount(): void { + this.roomThreadsNotificationType = null; + this.threadNotifications.clear(); } /** @@ -1187,6 +1290,7 @@ export class Room extends ReadReceipt { */ public setUnreadNotificationCount(type: NotificationCountType, count: number): void { this.notificationCounts[type] = count; + this.emit(RoomEvent.UnreadNotifications, this.notificationCounts); } public setSummary(summary: IRoomSummary): void { @@ -1578,7 +1682,14 @@ export class Room extends ReadReceipt { private async createThreadTimelineSet(filterType?: ThreadFilterType): Promise { let timelineSet: EventTimelineSet; - if (Thread.hasServerSideSupport) { + if (Thread.hasServerSideListSupport) { + timelineSet = + new EventTimelineSet(this, this.opts, undefined, undefined, Boolean(Thread.hasServerSideListSupport)); + this.reEmitter.reEmit(timelineSet, [ + RoomEvent.Timeline, + RoomEvent.TimelineReset, + ]); + } else if (Thread.hasServerSideSupport) { const filter = await this.getThreadListFilter(filterType); timelineSet = this.getOrCreateFilteredTimelineSet( @@ -1611,81 +1722,148 @@ export class Room extends ReadReceipt { return timelineSet; } - public threadsReady = false; + private threadsReady = false; + /** + * Takes the given thread root events and creates threads for them. + * @param events + * @param toStartOfTimeline + */ + public processThreadRoots(events: MatrixEvent[], toStartOfTimeline: boolean): void { + for (const rootEvent of events) { + EventTimeline.setEventMetadata( + rootEvent, + this.currentState, + toStartOfTimeline, + ); + if (!this.getThread(rootEvent.getId())) { + this.createThread(rootEvent.getId(), rootEvent, [], toStartOfTimeline); + } + } + } + + /** + * Fetch the bare minimum of room threads required for the thread list to work reliably. + * With server support that means fetching one page. + * Without server support that means fetching as much at once as the server allows us to. + */ public async fetchRoomThreads(): Promise { if (this.threadsReady || !this.client.supportsExperimentalThreads()) { return; } - const allThreadsFilter = await this.getThreadListFilter(); + if (Thread.hasServerSideListSupport) { + await Promise.all([ + this.fetchRoomThreadList(ThreadFilterType.All), + this.fetchRoomThreadList(ThreadFilterType.My), + ]); + } else { + const allThreadsFilter = await this.getThreadListFilter(); - const { chunk: events } = await this.client.createMessagesRequest( - this.roomId, - "", - Number.MAX_SAFE_INTEGER, - Direction.Backward, - allThreadsFilter, - ); + const { chunk: events } = await this.client.createMessagesRequest( + this.roomId, + "", + Number.MAX_SAFE_INTEGER, + Direction.Backward, + allThreadsFilter, + ); - if (!events.length) return; + if (!events.length) return; - // Sorted by last_reply origin_server_ts - const threadRoots = events - .map(this.client.getEventMapper()) - .sort((eventA, eventB) => { - /** - * `origin_server_ts` in a decentralised world is far from ideal - * but for lack of any better, we will have to use this - * Long term the sorting should be handled by homeservers and this - * is only meant as a short term patch - */ - const threadAMetadata = eventA - .getServerAggregatedRelation(RelationType.Thread); - const threadBMetadata = eventB - .getServerAggregatedRelation(RelationType.Thread); - return threadAMetadata.latest_event.origin_server_ts - threadBMetadata.latest_event.origin_server_ts; - }); + // Sorted by last_reply origin_server_ts + const threadRoots = events + .map(this.client.getEventMapper()) + .sort((eventA, eventB) => { + /** + * `origin_server_ts` in a decentralised world is far from ideal + * but for lack of any better, we will have to use this + * Long term the sorting should be handled by homeservers and this + * is only meant as a short term patch + */ + const threadAMetadata = eventA + .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); + const threadBMetadata = eventB + .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); + return threadAMetadata.latest_event.origin_server_ts - + threadBMetadata.latest_event.origin_server_ts; + }); - let latestMyThreadsRootEvent: MatrixEvent; - const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); - for (const rootEvent of threadRoots) { - this.threadsTimelineSets[0].addLiveEvent(rootEvent, { - duplicateStrategy: DuplicateStrategy.Ignore, - fromCache: false, - roomState, - }); - - const threadRelationship = rootEvent - .getServerAggregatedRelation(RelationType.Thread); - if (threadRelationship.current_user_participated) { - this.threadsTimelineSets[1].addLiveEvent(rootEvent, { + let latestMyThreadsRootEvent: MatrixEvent; + const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); + for (const rootEvent of threadRoots) { + this.threadsTimelineSets[0].addLiveEvent(rootEvent, { duplicateStrategy: DuplicateStrategy.Ignore, fromCache: false, roomState, }); - latestMyThreadsRootEvent = rootEvent; + + const threadRelationship = rootEvent + .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); + if (threadRelationship.current_user_participated) { + this.threadsTimelineSets[1].addLiveEvent(rootEvent, { + duplicateStrategy: DuplicateStrategy.Ignore, + fromCache: false, + roomState, + }); + latestMyThreadsRootEvent = rootEvent; + } } - if (!this.getThread(rootEvent.getId())) { - this.createThread(rootEvent.getId(), rootEvent, [], true); + this.processThreadRoots(threadRoots, true); + + this.client.decryptEventIfNeeded(threadRoots[threadRoots.length -1]); + if (latestMyThreadsRootEvent) { + this.client.decryptEventIfNeeded(latestMyThreadsRootEvent); } } - this.client.decryptEventIfNeeded(threadRoots[threadRoots.length -1]); - if (latestMyThreadsRootEvent) { - this.client.decryptEventIfNeeded(latestMyThreadsRootEvent); - } - - this.threadsReady = true; - this.on(ThreadEvent.NewReply, this.onThreadNewReply); + this.threadsReady = true; + } + + /** + * Fetch a single page of threadlist messages for the specific thread filter + * @param filter + * @private + */ + private async fetchRoomThreadList(filter?: ThreadFilterType): Promise { + const timelineSet = filter === ThreadFilterType.My + ? this.threadsTimelineSets[1] + : this.threadsTimelineSets[0]; + + const { chunk: events, end } = await this.client.createThreadListMessagesRequest( + this.roomId, + null, + undefined, + Direction.Backward, + timelineSet.getFilter(), + ); + + timelineSet.getLiveTimeline().setPaginationToken(end, Direction.Backward); + + if (!events.length) return; + + const matrixEvents = events.map(this.client.getEventMapper()); + this.processThreadRoots(matrixEvents, true); + const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); + for (const rootEvent of matrixEvents) { + timelineSet.addLiveEvent(rootEvent, { + duplicateStrategy: DuplicateStrategy.Replace, + fromCache: false, + roomState, + }); + } } private onThreadNewReply(thread: Thread): void { + const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); for (const timelineSet of this.threadsTimelineSets) { timelineSet.removeEvent(thread.id); - timelineSet.addLiveEvent(thread.rootEvent); + timelineSet.addLiveEvent(thread.rootEvent, { + duplicateStrategy: DuplicateStrategy.Replace, + fromCache: false, + roomState, + }); } } @@ -1831,8 +2009,6 @@ export class Room extends ReadReceipt { this.lastThread = thread; } - this.emit(ThreadEvent.New, thread, toStartOfTimeline); - if (this.threadsReady) { this.threadsTimelineSets.forEach(timelineSet => { if (thread.rootEvent) { @@ -1849,6 +2025,8 @@ export class Room extends ReadReceipt { }); } + this.emit(ThreadEvent.New, thread, toStartOfTimeline); + return thread; } diff --git a/src/models/thread.ts b/src/models/thread.ts index ff1d77568..da7a4d867 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -52,11 +52,28 @@ interface IThreadOpts { client: MatrixClient; } +export enum FeatureSupport { + None = 0, + Experimental = 1, + Stable = 2 +} + +export function determineFeatureSupport(stable: boolean, unstable: boolean): FeatureSupport { + if (stable) { + return FeatureSupport.Stable; + } else if (unstable) { + return FeatureSupport.Experimental; + } else { + return FeatureSupport.None; + } +} + /** * @experimental */ export class Thread extends ReadReceipt { - public static hasServerSideSupport: boolean; + public static hasServerSideSupport = FeatureSupport.None; + public static hasServerSideListSupport = FeatureSupport.None; /** * A reference to all the events ID at the bottom of the threads @@ -135,15 +152,23 @@ export class Thread extends ReadReceipt { this.emit(ThreadEvent.Update, this); } - public static setServerSideSupport(hasServerSideSupport: boolean, useStable: boolean): void { - Thread.hasServerSideSupport = hasServerSideSupport; - if (!useStable) { + public static setServerSideSupport( + status: FeatureSupport, + ): void { + Thread.hasServerSideSupport = status; + if (status !== FeatureSupport.Stable) { FILTER_RELATED_BY_SENDERS.setPreferUnstable(true); FILTER_RELATED_BY_REL_TYPES.setPreferUnstable(true); THREAD_RELATION_TYPE.setPreferUnstable(true); } } + public static setServerSideListSupport( + status: FeatureSupport, + ): void { + Thread.hasServerSideListSupport = status; + } + private onBeforeRedaction = (event: MatrixEvent, redaction: MatrixEvent) => { if (event?.isRelation(THREAD_RELATION_TYPE.name) && this.room.eventShouldLiveIn(event).threadId === this.id && @@ -382,7 +407,7 @@ export class Thread extends ReadReceipt { return this.timelineSet.getLiveTimeline(); } - public async fetchEvents(opts: IRelationsRequestOpts = { limit: 20, direction: Direction.Backward }): Promise<{ + public async fetchEvents(opts: IRelationsRequestOpts = { limit: 20, dir: Direction.Backward }): Promise<{ originalEvent: MatrixEvent; events: MatrixEvent[]; nextBatch?: string | null; @@ -414,7 +439,7 @@ export class Thread extends ReadReceipt { return this.client.decryptEventIfNeeded(event); })); - const prependEvents = (opts.direction ?? Direction.Backward) === Direction.Backward; + const prependEvents = (opts.dir ?? Direction.Backward) === Direction.Backward; this.timelineSet.addEventsToTimeline( events, diff --git a/src/pushprocessor.ts b/src/pushprocessor.ts index 4e736c3a1..1caa78953 100644 --- a/src/pushprocessor.ts +++ b/src/pushprocessor.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { escapeRegExp, globToRegexp, isNullOrUndefined } from "./utils"; +import { deepCompare, escapeRegExp, globToRegexp, isNullOrUndefined } from "./utils"; import { logger } from './logger'; import { MatrixClient } from "./client"; import { MatrixEvent } from "./models/event"; @@ -91,6 +91,20 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [ ], actions: [], }, + { + // For homeservers which don't support MSC3401 yet + rule_id: ".org.matrix.msc3401.rule.room.call", + default: true, + enabled: true, + conditions: [ + { + kind: ConditionKind.EventMatch, + key: "type", + pattern: "org.matrix.msc3401.call", + }, + ], + actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Sound, value: "default" }], + }, ]; export interface IActionsObject { @@ -424,7 +438,7 @@ export class PushProcessor { return {} as IActionsObject; } - const actionObj = PushProcessor.actionListToActionsObject(rule.actions); + let actionObj = PushProcessor.actionListToActionsObject(rule.actions); // Some actions are implicit in some situations: we add those here if (actionObj.tweaks.highlight === undefined) { @@ -433,6 +447,30 @@ export class PushProcessor { actionObj.tweaks.highlight = (rule.kind == PushRuleKind.ContentSpecific); } + actionObj = this.performCustomEventHandling(ev, actionObj); + + return actionObj; + } + + /** + * Some events require custom event handling e.g. due to missing server support + */ + private performCustomEventHandling(ev: MatrixEvent, actionObj: IActionsObject): IActionsObject { + switch (ev.getType()) { + case "m.call": + case "org.matrix.msc3401.call": + // Since servers don't support properly sending push notification + // about MSC3401 call events, we do the handling ourselves + if ( + ev.getContent()["m.intent"] === "m.room" + || ("m.terminated" in ev.getContent()) + || !("m.terminated" in ev.getPrevContent()) && !deepCompare(ev.getPrevContent(), {}) + ) { + actionObj.notify = false; + actionObj.tweaks = {}; + } + } + return actionObj; } diff --git a/src/scheduler.ts b/src/scheduler.ts index 271982b74..2131e95c2 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -24,7 +24,7 @@ import { logger } from './logger'; import { MatrixEvent } from "./models/event"; import { EventType } from "./@types/event"; import { IDeferred } from "./utils"; -import { MatrixError } from "./http-api"; +import { ConnectionError, MatrixError } from "./http-api"; import { ISendEventResponse } from "./@types/requests"; const DEBUG = false; // set true to enable console logging. @@ -68,9 +68,7 @@ export class MatrixScheduler { // client error; no amount of retrying with save you now. return -1; } - // we ship with browser-request which returns { cors: rejected } when trying - // with no connection, so if we match that, give up since they have no conn. - if (err["cors"] === "rejected") { + if (err instanceof ConnectionError) { return -1; } diff --git a/src/sliding-sync-sdk.ts b/src/sliding-sync-sdk.ts index 8ac1b81b9..fb5f81d31 100644 --- a/src/sliding-sync-sdk.ts +++ b/src/sliding-sync-sdk.ts @@ -733,7 +733,7 @@ export class SlidingSyncSdk { member._requestedProfileInfo = true; // try to get a cached copy first. const user = client.getUser(member.userId); - let promise; + let promise: ReturnType; if (user) { promise = Promise.resolve({ avatar_url: user.avatarUrl, diff --git a/src/sliding-sync.ts b/src/sliding-sync.ts index da6419c96..480ee818c 100644 --- a/src/sliding-sync.ts +++ b/src/sliding-sync.ts @@ -15,10 +15,9 @@ limitations under the License. */ import { logger } from './logger'; -import { IAbortablePromise } from "./@types/partials"; import { MatrixClient } from "./client"; import { IRoomEvent, IStateEvent } from "./sync-accumulator"; -import { TypedEventEmitter } from "./models//typed-event-emitter"; +import { TypedEventEmitter } from "./models/typed-event-emitter"; import { sleep, IDeferred, defer } from "./utils"; // /sync requests allow you to set a timeout= but the request may continue @@ -353,7 +352,8 @@ export class SlidingSync extends TypedEventEmitter(); // the *desired* room subscriptions private confirmedRoomSubscriptions = new Set(); - private pendingReq?: IAbortablePromise; + private pendingReq?: Promise; + private abortController?: AbortController; /** * Create a new sliding sync instance @@ -700,7 +700,8 @@ export class SlidingSync extends TypedEventEmitter { + d.reject(d.txnId); + }); + this.txnIdDefers = []; + // resend sticky params and de-confirm all subscriptions + this.lists.forEach((l) => { + l.setModified(true); + }); + this.confirmedRoomSubscriptions = new Set(); // leave desired ones alone though! + // reset the connection as we might be wedged + this.needsResend = true; + this.abortController?.abort(); + this.abortController = new AbortController(); + } + /** * Start syncing with the server. Blocks until stopped. */ public async start() { + this.abortController = new AbortController(); + let currentPos: string; while (!this.terminated) { this.needsResend = false; @@ -780,9 +804,8 @@ export class SlidingSync extends TypedEventEmitter; + getOldestToDeviceBatch(): Promise; /** * Removes a specific batch of to-device messages from the queue diff --git a/src/store/indexeddb-backend.ts b/src/store/indexeddb-backend.ts index 93d1cb3ab..f7547d6e5 100644 --- a/src/store/indexeddb-backend.ts +++ b/src/store/indexeddb-backend.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { ISavedSync } from "./index"; -import { IEvent, IStartClientOpts, IStateEventWithRoomId, ISyncResponse } from ".."; +import { IEvent, IStartClientOpts, IStateEventWithRoomId, ISyncResponse } from "../matrix"; import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage"; export interface IIndexedDBBackend { @@ -33,7 +33,7 @@ export interface IIndexedDBBackend { getClientOptions(): Promise; storeClientOptions(options: IStartClientOpts): Promise; saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise; - getOldestToDeviceBatch(): Promise; + getOldestToDeviceBatch(): Promise; removeToDeviceBatch(id: number): Promise; } diff --git a/src/store/indexeddb-local-backend.ts b/src/store/indexeddb-local-backend.ts index 7e4bd7673..908ecec9e 100644 --- a/src/store/indexeddb-local-backend.ts +++ b/src/store/indexeddb-local-backend.ts @@ -18,41 +18,36 @@ import { IMinimalEvent, ISyncData, ISyncResponse, SyncAccumulator } from "../syn import * as utils from "../utils"; import * as IndexedDBHelpers from "../indexeddb-helpers"; import { logger } from '../logger'; -import { IStartClientOpts, IStateEventWithRoomId } from ".."; +import { IStartClientOpts, IStateEventWithRoomId } from "../matrix"; import { ISavedSync } from "./index"; import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend"; import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage"; -const VERSION = 4; +type DbMigration = (db: IDBDatabase) => void; +const DB_MIGRATIONS: DbMigration[] = [ + (db) => { + // Make user store, clobber based on user ID. (userId property of User objects) + db.createObjectStore("users", { keyPath: ["userId"] }); -function createDatabase(db: IDBDatabase): void { - // Make user store, clobber based on user ID. (userId property of User objects) - db.createObjectStore("users", { keyPath: ["userId"] }); + // Make account data store, clobber based on event type. + // (event.type property of MatrixEvent objects) + db.createObjectStore("accountData", { keyPath: ["type"] }); - // Make account data store, clobber based on event type. - // (event.type property of MatrixEvent objects) - db.createObjectStore("accountData", { keyPath: ["type"] }); - - // Make /sync store (sync tokens, room data, etc), always clobber (const key). - db.createObjectStore("sync", { keyPath: ["clobber"] }); -} - -function upgradeSchemaV2(db: IDBDatabase): void { - const oobMembersStore = db.createObjectStore( - "oob_membership_events", { - keyPath: ["room_id", "state_key"], - }); - oobMembersStore.createIndex("room", "room_id"); -} - -function upgradeSchemaV3(db: IDBDatabase): void { - db.createObjectStore("client_options", - { keyPath: ["clobber"] }); -} - -function upgradeSchemaV4(db: IDBDatabase): void { - db.createObjectStore("to_device_queue", { autoIncrement: true }); -} + // Make /sync store (sync tokens, room data, etc), always clobber (const key). + db.createObjectStore("sync", { keyPath: ["clobber"] }); + }, + (db) => { + const oobMembersStore = db.createObjectStore( + "oob_membership_events", { + keyPath: ["room_id", "state_key"], + }); + oobMembersStore.createIndex("room", "room_id"); + }, + (db) => { db.createObjectStore("client_options", { keyPath: ["clobber"] }); }, + (db) => { db.createObjectStore("to_device_queue", { autoIncrement: true }); }, + // Expand as needed. +]; +const VERSION = DB_MIGRATIONS.length; /** * Helper method to collect results from a Cursor and promiseify it. @@ -172,20 +167,13 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { logger.log( `LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`, ); - if (oldVersion < 1) { // The database did not previously exist. + if (oldVersion < 1) { + // The database did not previously exist this._isNewlyCreated = true; - createDatabase(db); } - if (oldVersion < 2) { - upgradeSchemaV2(db); - } - if (oldVersion < 3) { - upgradeSchemaV3(db); - } - if (oldVersion < 4) { - upgradeSchemaV4(db); - } - // Expand as needed. + DB_MIGRATIONS.forEach((migration, index) => { + if (oldVersion <= index) migration(db); + }); }; req.onblocked = () => { diff --git a/src/store/indexeddb-remote-backend.ts b/src/store/indexeddb-remote-backend.ts index 67ab2ccd2..d36404b90 100644 --- a/src/store/indexeddb-remote-backend.ts +++ b/src/store/indexeddb-remote-backend.ts @@ -18,7 +18,7 @@ import { logger } from "../logger"; import { defer, IDeferred } from "../utils"; import { ISavedSync } from "./index"; import { IStartClientOpts } from "../client"; -import { IStateEventWithRoomId, ISyncResponse } from ".."; +import { IStateEventWithRoomId, ISyncResponse } from "../matrix"; import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend"; import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage"; @@ -138,7 +138,7 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend { return this.doCmd('saveToDeviceBatches', [batches]); } - public async getOldestToDeviceBatch(): Promise { + public async getOldestToDeviceBatch(): Promise { return this.doCmd('getOldestToDeviceBatch'); } diff --git a/src/store/indexeddb.ts b/src/store/indexeddb.ts index 44f684bdf..2bc4f28f6 100644 --- a/src/store/indexeddb.ts +++ b/src/store/indexeddb.ts @@ -357,7 +357,7 @@ export class IndexedDBStore extends MemoryStore { return this.backend.saveToDeviceBatches(batches); } - public getOldestToDeviceBatch(): Promise { + public getOldestToDeviceBatch(): Promise { return this.backend.getOldestToDeviceBatch(); } diff --git a/src/store/memory.ts b/src/store/memory.ts index 0ed43a5b5..b44f24ca4 100644 --- a/src/store/memory.ts +++ b/src/store/memory.ts @@ -227,7 +227,7 @@ export class MemoryStore implements IStore { * @param {Filter} filter */ public storeFilter(filter: Filter): void { - if (!filter) { + if (!filter?.userId) { return; } if (!this.filters[filter.userId]) { diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index 037c9231b..ec60c1c3a 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -25,6 +25,7 @@ import { IContent, IUnsigned } from "./models/event"; import { IRoomSummary } from "./models/room-summary"; import { EventType } from "./@types/event"; import { ReceiptType } from "./@types/read_receipts"; +import { UNREAD_THREAD_NOTIFICATIONS } from './@types/sync'; interface IOpts { maxTimelineEntries?: number; @@ -41,7 +42,7 @@ export interface IEphemeral { } /* eslint-disable camelcase */ -interface IUnreadNotificationCounts { +interface UnreadNotificationCounts { highlight_count?: number; notification_count?: number; } @@ -75,7 +76,9 @@ export interface IJoinedRoom { timeline: ITimeline; ephemeral: IEphemeral; account_data: IAccountData; - unread_notifications: IUnreadNotificationCounts; + unread_notifications: UnreadNotificationCounts; + unread_thread_notifications?: Record; + "org.matrix.msc3773.unread_thread_notifications"?: Record; } export interface IStrippedState { @@ -153,7 +156,8 @@ interface IRoom { }[]; _summary: Partial; _accountData: { [eventType: string]: IMinimalEvent }; - _unreadNotifications: Partial; + _unreadNotifications: Partial; + _unreadThreadNotifications?: Record>; _readReceipts: { [userId: string]: { data: IMinimalEvent; @@ -362,6 +366,7 @@ export class SyncAccumulator { _timeline: [], _accountData: Object.create(null), _unreadNotifications: {}, + _unreadThreadNotifications: {}, _summary: {}, _readReceipts: {}, }; @@ -379,6 +384,10 @@ export class SyncAccumulator { if (data.unread_notifications) { currentData._unreadNotifications = data.unread_notifications; } + currentData._unreadThreadNotifications = data[UNREAD_THREAD_NOTIFICATIONS.stable] + ?? data[UNREAD_THREAD_NOTIFICATIONS.unstable] + ?? undefined; + if (data.summary) { const HEROES_KEY = "m.heroes"; const INVITED_COUNT_KEY = "m.invited_member_count"; @@ -537,6 +546,7 @@ export class SyncAccumulator { prev_batch: null, }, unread_notifications: roomData._unreadNotifications, + unread_thread_notifications: roomData._unreadThreadNotifications, summary: roomData._summary as IRoomSummary, }; // Add account data diff --git a/src/sync.ts b/src/sync.ts index 2e9e509ec..2c1146a7f 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -57,7 +57,8 @@ import { RoomStateEvent, IMarkerFoundOptions } from "./models/room-state"; import { RoomMemberEvent } from "./models/room-member"; import { BeaconEvent } from "./models/beacon"; import { IEventsResponse } from "./@types/requests"; -import { IAbortablePromise } from "./@types/partials"; +import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync"; +import { Feature, ServerSupport } from "./feature"; const DEBUG = true; @@ -162,7 +163,8 @@ type WrappedRoom = T & { */ export class SyncApi { private _peekRoom: Optional = null; - private currentSyncRequest: Optional> = null; + private currentSyncRequest: Optional> = null; + private abortController?: AbortController; private syncState: Optional = null; private syncStateData: Optional = null; // additional data (eg. error object for failed sync) private catchingUp = false; @@ -296,9 +298,9 @@ export class SyncApi { getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter, ).then(function(filterId) { qps.filter = filterId; - return client.http.authedRequest( - undefined, Method.Get, "/sync", qps as any, undefined, localTimeoutMs, - ); + return client.http.authedRequest(Method.Get, "/sync", qps as any, undefined, { + localTimeoutMs, + }); }).then(async (data) => { let leaveRooms = []; if (data.rooms?.leave) { @@ -431,11 +433,11 @@ export class SyncApi { } // FIXME: gut wrenching; hard-coded timeout values - this.client.http.authedRequest(undefined, Method.Get, "/events", { + this.client.http.authedRequest(Method.Get, "/events", { room_id: peekRoom.roomId, timeout: String(30 * 1000), from: token, - }, undefined, 50 * 1000).then((res) => { + }, undefined, { localTimeoutMs: 50 * 1000 }).then((res) => { if (this._peekRoom !== peekRoom) { debuglog("Stopped peeking in room %s", peekRoom.roomId); return; @@ -561,7 +563,11 @@ export class SyncApi { }; private buildDefaultFilter = () => { - return new Filter(this.client.credentials.userId); + const filter = new Filter(this.client.credentials.userId); + if (this.client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported) { + filter.setUnreadThreadNotifications(true); + } + return filter; }; private checkLazyLoadStatus = async () => { @@ -646,6 +652,7 @@ export class SyncApi { */ public async sync(): Promise { this.running = true; + this.abortController = new AbortController(); global.window?.addEventListener?.("online", this.onOnline, false); @@ -732,7 +739,7 @@ export class SyncApi { // but do not have global.window.removeEventListener. global.window?.removeEventListener?.("online", this.onOnline, false); this.running = false; - this.currentSyncRequest?.abort(); + this.abortController?.abort(); if (this.keepAliveTimer) { clearTimeout(this.keepAliveTimer); this.keepAliveTimer = null; @@ -896,12 +903,12 @@ export class SyncApi { } } - private doSyncRequest(syncOptions: ISyncOptions, syncToken: string): IAbortablePromise { + private doSyncRequest(syncOptions: ISyncOptions, syncToken: string): Promise { const qps = this.getSyncParams(syncOptions, syncToken); - return this.client.http.authedRequest( - undefined, Method.Get, "/sync", qps as any, undefined, - qps.timeout + BUFFER_PERIOD_MS, - ); + return this.client.http.authedRequest(Method.Get, "/sync", qps as any, undefined, { + localTimeoutMs: qps.timeout + BUFFER_PERIOD_MS, + abortSignal: this.abortController?.signal, + }); } private getSyncParams(syncOptions: ISyncOptions, syncToken: string): ISyncParams { @@ -1200,19 +1207,22 @@ export class SyncApi { await this.injectRoomEvents(room, stateEvents); const inviter = room.currentState.getStateEvents(EventType.RoomMember, client.getUserId())?.getSender(); - const parkedHistory = await client.crypto.cryptoStore.takeParkedSharedHistory(room.roomId); - for (const parked of parkedHistory) { - if (parked.senderId === inviter) { - await this.client.crypto.olmDevice.addInboundGroupSession( - room.roomId, - parked.senderKey, - parked.forwardingCurve25519KeyChain, - parked.sessionId, - parked.sessionKey, - parked.keysClaimed, - true, - { sharedHistory: true, untrusted: true }, - ); + + if (client.isCryptoEnabled()) { + const parkedHistory = await client.crypto.cryptoStore.takeParkedSharedHistory(room.roomId); + for (const parked of parkedHistory) { + if (parked.senderId === inviter) { + await client.crypto.olmDevice.addInboundGroupSession( + room.roomId, + parked.senderKey, + parked.forwardingCurve25519KeyChain, + parked.sessionId, + parked.sessionKey, + parked.keysClaimed, + true, + { sharedHistory: true, untrusted: true }, + ); + } } } @@ -1261,6 +1271,29 @@ export class SyncApi { } } + room.resetThreadUnreadNotificationCount(); + const unreadThreadNotifications = joinObj[UNREAD_THREAD_NOTIFICATIONS.name] + ?? joinObj[UNREAD_THREAD_NOTIFICATIONS.altName]; + if (unreadThreadNotifications) { + Object.entries(unreadThreadNotifications).forEach(([threadId, unreadNotification]) => { + room.setThreadUnreadNotificationCount( + threadId, + NotificationCountType.Total, + unreadNotification.notification_count, + ); + + const hasNoNotifications = + room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight) <= 0; + if (!encrypted || (encrypted && hasNoNotifications)) { + room.setThreadUnreadNotificationCount( + threadId, + NotificationCountType.Highlight, + unreadNotification.highlight_count, + ); + } + }); + } + joinObj.timeline = joinObj.timeline || {} as ITimeline; if (joinObj.isBrandNewRoom) { @@ -1489,7 +1522,6 @@ export class SyncApi { }; this.client.http.request( - undefined, // callback Method.Get, "/_matrix/client/versions", undefined, // queryParams undefined, // data diff --git a/src/utils.ts b/src/utils.ts index f51be323b..591803296 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -24,7 +24,7 @@ import unhomoglyph from "unhomoglyph"; import promiseRetry from "p-retry"; import type * as NodeCrypto from "crypto"; -import { MatrixEvent } from "."; +import { MatrixEvent } from "./models/event"; import { M_TIMESTAMP } from "./@types/location"; import { ReceiptType } from "./@types/read_receipts"; @@ -59,17 +59,23 @@ export function internaliseString(str: string): string { * {"foo": "bar", "baz": "taz"} * @return {string} The encoded string e.g. foo=bar&baz=taz */ -export function encodeParams(params: Record): string { - const searchParams = new URLSearchParams(); +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) { - searchParams.set(key, String(val)); + if (Array.isArray(val)) { + val.forEach(v => { + searchParams.append(key, String(v)); + }); + } else { + searchParams.append(key, String(val)); + } } } - return searchParams.toString(); + return searchParams; } -export type QueryDict = Record; +export type QueryDict = Record; /** * Decode a query string in `application/x-www-form-urlencoded` format. @@ -80,8 +86,8 @@ export type QueryDict = Record; * This behaviour matches Node's qs.parse but is built on URLSearchParams * for native web compatibility */ -export function decodeParams(query: string): QueryDict { - const o: QueryDict = {}; +export function decodeParams(query: string): Record { + const o: Record = {}; const params = new URLSearchParams(query); for (const key of params.keys()) { const val = params.getAll(key); @@ -334,8 +340,9 @@ export function normalize(str: string): string { // Arabic Letter RTL mark U+061C // Combining characters U+0300 - U+036F // Zero width no-break space (BOM) U+FEFF +// Blank/invisible characters (U2800, U2062-U2063) // eslint-disable-next-line no-misleading-character-class -const removeHiddenCharsRegex = /[\u2000-\u200F\u202A-\u202F\u0300-\u036F\uFEFF\u061C\s]/g; +const removeHiddenCharsRegex = /[\u2000-\u200F\u202A-\u202F\u0300-\u036F\uFEFF\u061C\u2800\u2062-\u2063\s]/g; export function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -673,4 +680,3 @@ export function sortEventsByLatestContentTimestamp(left: MatrixEvent, right: Mat export function isSupportedReceiptType(receiptType: string): boolean { return [ReceiptType.Read, ReceiptType.ReadPrivate].includes(receiptType as ReceiptType); } - diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index 7f60a863e..ce1cae8cd 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -357,7 +357,7 @@ export class MatrixCall extends TypedEventEmitter; - private inviteTimeout: ReturnType; + private inviteTimeout?: ReturnType; private readonly removeTrackListeners = new Map void>(); // The logic of when & if a call is on hold is nontrivial and explained in is*OnHold @@ -384,7 +384,7 @@ export class MatrixCall extends TypedEventEmitter; + private callLengthInterval?: ReturnType; private callLength = 0; private opponentDeviceId: string; @@ -1962,7 +1962,7 @@ export class MatrixCall extends TypedEventEmitter { - this.inviteTimeout = null; + this.inviteTimeout = undefined; if (this.state === CallState.InviteSent) { this.hangup(CallErrorCode.InviteTimeout, false); } @@ -2377,11 +2377,11 @@ export class MatrixCall extends TypedEventEmitter= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +safe-regex-test@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295" + integrity sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.3" + is-regex "^1.1.4" + +"safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -6277,9 +6142,9 @@ semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== semver@^7.3.5, semver@^7.3.7: - version "7.3.7" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" - integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== + version "7.3.8" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" + integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A== dependencies: lru-cache "^6.0.0" @@ -6356,6 +6221,11 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +slash@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" + integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== + source-map-support@0.5.13: version "0.5.13" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" @@ -6387,21 +6257,6 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== -sshpk@^1.7.0: - version "1.17.0" - resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5" - integrity sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ== - dependencies: - asn1 "~0.2.3" - assert-plus "^1.0.0" - bcrypt-pbkdf "^1.0.0" - dashdash "^1.12.0" - ecc-jsbn "~0.1.1" - getpass "^0.1.1" - jsbn "~0.1.0" - safer-buffer "^2.0.2" - tweetnacl "~0.14.0" - stack-utils@^2.0.3: version "2.0.5" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.5.tgz#d25265fca995154659dbbfba3b49254778d2fdd5" @@ -6584,6 +6439,14 @@ symbol-tree@^3.2.4: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== +synckit@^0.8.3: + version "0.8.4" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.8.4.tgz#0e6b392b73fafdafcde56692e3352500261d64ec" + integrity sha512-Dn2ZkzMdSX827QbowGbU/4yjWuvNaCoScLLoMo/yKbu+P4GBR6cRGKZH27k6a9bRzdqcyd1DE96pQtQ6uNkmyw== + dependencies: + "@pkgr/utils" "^2.3.1" + tslib "^2.4.0" + syntax-error@^1.1.1: version "1.4.0" resolved "https://registry.yarnpkg.com/syntax-error/-/syntax-error-1.4.0.tgz#2d9d4ff5c064acb711594a3e3b95054ad51d907c" @@ -6596,6 +6459,11 @@ taffydb@2.6.2: resolved "https://registry.yarnpkg.com/taffydb/-/taffydb-2.6.2.tgz#7cbcb64b5a141b6a2efc2c5d2c67b4e150b2a268" integrity sha512-y3JaeRSplks6NYQuCOj3ZFMO3j60rTwbuKCvZxsAraGYH2epusatvZ0baZYA01WsGqJBq/Dl6vOrMUJqyMj8kA== +tapable@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" + integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== + terminal-link@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" @@ -6605,9 +6473,9 @@ terminal-link@^2.0.0: supports-hyperlinks "^2.0.0" terser@^5.5.1: - version "5.15.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.15.0.tgz#e16967894eeba6e1091509ec83f0c60e179f2425" - integrity sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA== + version "5.15.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.15.1.tgz#8561af6e0fd6d839669c73b92bdd5777d870ed6c" + integrity sha512-K1faMUvpm/FBxjBXud0LWVAGxmvoPbZbfTCYbSgaaYQaIXI3/TdI7a7ZGA73Zrou6Q8Zmz3oeUTsp/dj+ag2Xw== dependencies: "@jridgewell/source-map" "^0.3.2" acorn "^8.5.0" @@ -6661,6 +6529,14 @@ timers-ext@^0.1.7: es5-ext "~0.10.46" next-tick "1" +tiny-glob@^0.2.9: + version "0.2.9" + resolved "https://registry.yarnpkg.com/tiny-glob/-/tiny-glob-0.2.9.tgz#2212d441ac17928033b110f8b3640683129d31e2" + integrity sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg== + dependencies: + globalyzer "0.1.0" + globrex "^0.1.2" + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -6697,14 +6573,6 @@ tough-cookie@^4.0.0: punycode "^2.1.1" universalify "^0.1.2" -tough-cookie@~2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" - integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== - dependencies: - psl "^1.1.28" - punycode "^2.1.1" - tr46@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" @@ -6766,7 +6634,7 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.1: +tslib@^2.0.1, tslib@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== @@ -6783,23 +6651,11 @@ tty-browserify@0.0.1: resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811" integrity sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw== -tunnel-agent@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" - integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== - dependencies: - safe-buffer "^5.0.1" - tunnel@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== -tweetnacl@^0.14.3, tweetnacl@~0.14.0: - version "0.14.5" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" - integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== - type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -6850,9 +6706,9 @@ typescript@^3.2.2: integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q== typescript@^4.5.3, typescript@^4.5.4: - version "4.8.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.3.tgz#d59344522c4bc464a65a730ac695007fdb66dd88" - integrity sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig== + version "4.8.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6" + integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ== typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39" @@ -6915,9 +6771,9 @@ undeclared-identifiers@^1.1.2: xtend "^4.0.1" underscore@^1.13.2, underscore@~1.13.2: - version "1.13.4" - resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.4.tgz#7886b46bbdf07f768e0052f1828e1dcab40c0dee" - integrity sha512-BQFnUDuAQ4Yf/cYY5LNrK9NCJFKriaRbD9uR1fTeXnBeoa97W0i41qkZfGO9pSo8I5KzjAcSY2XYtdf0oKd7KQ== + version "1.13.6" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441" + integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A== unhomoglyph@^1.0.6: version "1.0.6" @@ -6958,9 +6814,9 @@ universalify@^0.1.2: integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== update-browserslist-db@^1.0.9: - version "1.0.9" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.9.tgz#2924d3927367a38d5c555413a7ce138fc95fcb18" - integrity sha512-/xsqn21EGVdXI3EXSum1Yckj3ZVZugqyOZQ/CxYPBD/R+ko9NSUScf8tFF4dOKY+2pvSSJA/S+5B8s4Zr4kyvg== + version "1.0.10" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz#0f54b876545726f17d00cd9a2561e6dade943ff3" + integrity sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ== dependencies: escalade "^3.1.1" picocolors "^1.0.0" @@ -7004,11 +6860,6 @@ util@~0.12.0: safe-buffer "^5.1.2" which-typed-array "^1.1.2" -uuid@^3.3.2: - version "3.4.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" - integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== - uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" @@ -7023,15 +6874,6 @@ v8-to-istanbul@^9.0.0, v8-to-istanbul@^9.0.1: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^1.6.0" -verror@1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" - integrity sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw== - dependencies: - assert-plus "^1.0.0" - core-util-is "1.0.2" - extsprintf "^1.2.0" - vm-browserify@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" @@ -7059,9 +6901,9 @@ vue-docgen-api@^3.26.0: vue-template-compiler "^2.0.0" vue-template-compiler@^2.0.0: - version "2.7.10" - resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.7.10.tgz#9e20f35b2fdccacacf732dd7dedb49bf65f4556b" - integrity sha512-QO+8R9YRq1Gudm8ZMdo/lImZLJVUIAM8c07Vp84ojdDAf8HmPJc7XB556PcXV218k2AkKznsRz6xB5uOjAC4EQ== + version "2.7.12" + resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.7.12.tgz#762476971b13eb5ea5c786886be28395f345c1e1" + integrity sha512-6rhJAuo2vRzJMs8X/pd9yqtsJmnPEnv4E0cb9KCu0sfGhoDt8roCCa/6qbrvpc1b38zYgdmY/xrk4qfNWZIjwA== dependencies: de-indent "^1.0.2" he "^1.2.0" @@ -7303,11 +7145,11 @@ yargs@^16.2.0: yargs-parser "^20.2.2" yargs@^17.0.1, yargs@^17.3.1: - version "17.5.1" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.5.1.tgz#e109900cab6fcb7fd44b1d8249166feb0b36e58e" - integrity sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA== + version "17.6.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.6.0.tgz#e134900fc1f218bc230192bdec06a0a5f973e46c" + integrity sha512-8H/wTDqlSwoSnScvV2N/JHfLWOKuh5MVla9hqLjK3nsfyy6Y4kDSYSvkU5YCUEPOSnRXfIyx3Sq+B/IWudTo4g== dependencies: - cliui "^7.0.2" + cliui "^8.0.1" escalade "^3.1.1" get-caller-file "^2.0.5" require-directory "^2.1.1"