diff --git a/.eslintrc.js b/.eslintrc.js index 1735739e326..6fc5b99a671 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -31,6 +31,9 @@ module.exports = { "no-async-promise-executor": "off", // We use a `logger` intermediary module "no-console": "error", + + // restrict EventEmitters to force callers to use TypedEventEmitter + "no-restricted-imports": ["error", "events"], }, overrides: [{ files: [ diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 00000000000..0cd4cec72de --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,6 @@ +comment: + layout: "diff, files" + behavior: default + require_changes: false + require_base: no + require_head: no diff --git a/.github/workflows/test_coverage.yml b/.github/workflows/test_coverage.yml new file mode 100644 index 00000000000..adf206ba04a --- /dev/null +++ b/.github/workflows/test_coverage.yml @@ -0,0 +1,19 @@ +name: Test coverage +on: + pull_request: {} + push: + branches: [develop, main, master] +jobs: + test-coverage: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Run tests with coverage + run: "yarn install && yarn build && yarn coverage" + + - name: Upload coverage + uses: codecov/codecov-action@v2 + with: + verbose: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 824da060489..1cc5bfe3468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,43 @@ +Changes in [16.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v16.0.0) (2022-03-15) +================================================================================================== + +## 🚨 BREAKING CHANGES + * Improve typing around event emitter handlers ([\#2180](https://github.com/matrix-org/matrix-js-sdk/pull/2180)). + +## ✨ Features + * Fix defer not supporting resolving with a Promise ([\#2216](https://github.com/matrix-org/matrix-js-sdk/pull/2216)). + * add LocationAssetType enum ([\#2214](https://github.com/matrix-org/matrix-js-sdk/pull/2214)). + * Support for mid-call devices changes ([\#2154](https://github.com/matrix-org/matrix-js-sdk/pull/2154)). Contributed by @SimonBrandner. + * Add new room state emit RoomStateEvent.Update for lower-frequency hits ([\#2192](https://github.com/matrix-org/matrix-js-sdk/pull/2192)). + +## 🐛 Bug Fixes + * Fix wrong event_id being sent for m.in_reply_to of threads ([\#2213](https://github.com/matrix-org/matrix-js-sdk/pull/2213)). + * Fix wrongly asserting that PushRule::conditions is non-null ([\#2217](https://github.com/matrix-org/matrix-js-sdk/pull/2217)). + * Make createThread more resilient when missing rootEvent ([\#2207](https://github.com/matrix-org/matrix-js-sdk/pull/2207)). Fixes vector-im/element-web#21130. + * Fix bug with the /hierarchy API sending invalid requests ([\#2201](https://github.com/matrix-org/matrix-js-sdk/pull/2201)). Fixes vector-im/element-web#21170. + * fix relation sender filter ([\#2196](https://github.com/matrix-org/matrix-js-sdk/pull/2196)). Fixes vector-im/element-web#20877. + * Fix bug with one-way audio after a transfer ([\#2193](https://github.com/matrix-org/matrix-js-sdk/pull/2193)). + +Changes in [16.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v16.0.0-rc.1) (2022-03-08) +============================================================================================================ + +## 🚨 BREAKING CHANGES + * Improve typing around event emitter handlers ([\#2180](https://github.com/matrix-org/matrix-js-sdk/pull/2180)). + +## ✨ Features + * Fix defer not supporting resolving with a Promise ([\#2216](https://github.com/matrix-org/matrix-js-sdk/pull/2216)). + * add LocationAssetType enum ([\#2214](https://github.com/matrix-org/matrix-js-sdk/pull/2214)). + * Support for mid-call devices changes ([\#2154](https://github.com/matrix-org/matrix-js-sdk/pull/2154)). Contributed by @SimonBrandner. + * Add new room state emit RoomStateEvent.Update for lower-frequency hits ([\#2192](https://github.com/matrix-org/matrix-js-sdk/pull/2192)). + +## 🐛 Bug Fixes + * Fix wrong event_id being sent for m.in_reply_to of threads ([\#2213](https://github.com/matrix-org/matrix-js-sdk/pull/2213)). + * Fix wrongly asserting that PushRule::conditions is non-null ([\#2217](https://github.com/matrix-org/matrix-js-sdk/pull/2217)). + * Make createThread more resilient when missing rootEvent ([\#2207](https://github.com/matrix-org/matrix-js-sdk/pull/2207)). Fixes vector-im/element-web#21130. + * Fix bug with the /hierarchy API sending invalid requests ([\#2201](https://github.com/matrix-org/matrix-js-sdk/pull/2201)). Fixes vector-im/element-web#21170. + * fix relation sender filter ([\#2196](https://github.com/matrix-org/matrix-js-sdk/pull/2196)). Fixes vector-im/element-web#20877. + * Fix bug with one-way audio after a transfer ([\#2193](https://github.com/matrix-org/matrix-js-sdk/pull/2193)). + Changes in [15.6.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v15.6.0) (2022-02-28) ================================================================================================== diff --git a/package.json b/package.json index a6e15f006a9..5edf65b0322 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "15.6.0", + "version": "16.0.0", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", @@ -89,7 +89,7 @@ "better-docs": "^2.4.0-beta.9", "browserify": "^17.0.0", "docdash": "^1.2.0", - "eslint": "7.18.0", + "eslint": "8.9.0", "eslint-config-google": "^0.14.0", "eslint-plugin-import": "^2.25.4", "eslint-plugin-matrix-org": "^0.4.0", @@ -113,7 +113,8 @@ "/src/**/*.{js,ts}" ], "coverageReporters": [ - "text" + "text", + "json" ] }, "typings": "./lib/index.d.ts" diff --git a/spec/integ/matrix-client-retrying.spec.ts b/spec/integ/matrix-client-retrying.spec.ts index d0335668f02..6f74e4188b8 100644 --- a/spec/integ/matrix-client-retrying.spec.ts +++ b/spec/integ/matrix-client-retrying.spec.ts @@ -1,4 +1,4 @@ -import { EventStatus } from "../../src/matrix"; +import { EventStatus, RoomEvent } from "../../src/matrix"; import { MatrixScheduler } from "../../src/scheduler"; import { Room } from "../../src/models/room"; import { TestClient } from "../TestClient"; @@ -95,7 +95,7 @@ describe("MatrixClient retrying", function() { // wait for the localecho of ev1 to be updated const p3 = new Promise((resolve, reject) => { - room.on("Room.localEchoUpdated", (ev0) => { + room.on(RoomEvent.LocalEchoUpdated, (ev0) => { if (ev0 === ev1) { resolve(); } diff --git a/spec/test-utils.js b/spec/test-utils.js index e5d5c490bef..111c032a3b0 100644 --- a/spec/test-utils.js +++ b/spec/test-utils.js @@ -85,6 +85,7 @@ export function mkEvent(opts) { room_id: opts.room, sender: opts.sender || opts.user, // opts.user for backwards-compat content: opts.content, + unsigned: opts.unsigned, event_id: "$" + Math.random() + "-" + Math.random(), }; if (opts.skey !== undefined) { diff --git a/spec/unit/ReEmitter.spec.ts b/spec/unit/ReEmitter.spec.ts index 3570b06fea1..4ce28429d12 100644 --- a/spec/unit/ReEmitter.spec.ts +++ b/spec/unit/ReEmitter.spec.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +// eslint-disable-next-line no-restricted-imports import { EventEmitter } from "events"; import { ReEmitter } from "../../src/ReEmitter"; diff --git a/spec/unit/crypto.spec.js b/spec/unit/crypto.spec.js index 3245a28c0ad..450a99af43e 100644 --- a/spec/unit/crypto.spec.js +++ b/spec/unit/crypto.spec.js @@ -1,4 +1,5 @@ import '../olm-loader'; +// eslint-disable-next-line no-restricted-imports import { EventEmitter } from "events"; import { Crypto } from "../../src/crypto"; diff --git a/spec/unit/crypto/crypto-utils.js b/spec/unit/crypto/crypto-utils.js index b54b1a18ebe..ecc6fc4b0ae 100644 --- a/spec/unit/crypto/crypto-utils.js +++ b/spec/unit/crypto/crypto-utils.js @@ -26,7 +26,7 @@ export async function resetCrossSigningKeys(client, { crypto.crossSigningInfo.keys = oldKeys; throw e; } - crypto.baseApis.emit("crossSigning.keysChanged", {}); + crypto.emit("crossSigning.keysChanged", {}); await crypto.afterCrossSigningLocalKeyChange(); } diff --git a/spec/unit/crypto/verification/secret_request.spec.js b/spec/unit/crypto/verification/secret_request.spec.js index 4b768311a3d..398edc10a60 100644 --- a/spec/unit/crypto/verification/secret_request.spec.js +++ b/spec/unit/crypto/verification/secret_request.spec.js @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { VerificationBase } from '../../../../src/crypto/verification/Base'; import { CrossSigningInfo } from '../../../../src/crypto/CrossSigning'; import { encodeBase64 } from "../../../../src/crypto/olmlib"; import { setupWebcrypto, teardownWebcrypto } from './util'; +import { VerificationBase } from '../../../../src/crypto/verification/Base'; jest.useFakeTimers(); diff --git a/spec/unit/filter-component.spec.js b/spec/unit/filter-component.spec.js deleted file mode 100644 index 49f1d561456..00000000000 --- a/spec/unit/filter-component.spec.js +++ /dev/null @@ -1,34 +0,0 @@ -import { FilterComponent } from "../../src/filter-component"; -import { mkEvent } from '../test-utils'; - -describe("Filter Component", function() { - describe("types", function() { - it("should filter out events with other types", function() { - const filter = new FilterComponent({ types: ['m.room.message'] }); - const event = mkEvent({ - type: 'm.room.member', - content: { }, - room: 'roomId', - event: true, - }); - - const checkResult = filter.check(event); - - expect(checkResult).toBe(false); - }); - - it("should validate events with the same type", function() { - const filter = new FilterComponent({ types: ['m.room.message'] }); - const event = mkEvent({ - type: 'm.room.message', - content: { }, - room: 'roomId', - event: true, - }); - - const checkResult = filter.check(event); - - expect(checkResult).toBe(true); - }); - }); -}); diff --git a/spec/unit/filter-component.spec.ts b/spec/unit/filter-component.spec.ts new file mode 100644 index 00000000000..636a413f643 --- /dev/null +++ b/spec/unit/filter-component.spec.ts @@ -0,0 +1,132 @@ +import { + RelationType, + UNSTABLE_FILTER_RELATED_BY_REL_TYPES, + UNSTABLE_FILTER_RELATED_BY_SENDERS, +} from "../../src"; +import { FilterComponent } from "../../src/filter-component"; +import { mkEvent } from '../test-utils'; + +describe("Filter Component", function() { + describe("types", function() { + it("should filter out events with other types", function() { + const filter = new FilterComponent({ types: ['m.room.message'] }); + const event = mkEvent({ + type: 'm.room.member', + content: { }, + room: 'roomId', + event: true, + }); + + const checkResult = filter.check(event); + + expect(checkResult).toBe(false); + }); + + it("should validate events with the same type", function() { + const filter = new FilterComponent({ types: ['m.room.message'] }); + const event = mkEvent({ + type: 'm.room.message', + content: { }, + room: 'roomId', + event: true, + }); + + const checkResult = filter.check(event); + + expect(checkResult).toBe(true); + }); + + it("should filter out events by relation participation", function() { + const currentUserId = '@me:server.org'; + const filter = new FilterComponent({ + [UNSTABLE_FILTER_RELATED_BY_SENDERS.name]: [currentUserId], + }, currentUserId); + + const threadRootNotParticipated = mkEvent({ + type: 'm.room.message', + content: {}, + room: 'roomId', + user: '@someone-else:server.org', + event: true, + unsigned: { + "m.relations": { + [RelationType.Thread]: { + count: 2, + current_user_participated: false, + }, + }, + }, + }); + + expect(filter.check(threadRootNotParticipated)).toBe(false); + }); + + it("should keep events by relation participation", function() { + const currentUserId = '@me:server.org'; + const filter = new FilterComponent({ + [UNSTABLE_FILTER_RELATED_BY_SENDERS.name]: [currentUserId], + }, currentUserId); + + const threadRootParticipated = mkEvent({ + type: 'm.room.message', + content: {}, + unsigned: { + "m.relations": { + [RelationType.Thread]: { + count: 2, + current_user_participated: true, + }, + }, + }, + user: '@someone-else:server.org', + room: 'roomId', + event: true, + }); + + expect(filter.check(threadRootParticipated)).toBe(true); + }); + + it("should filter out events by relation type", function() { + const filter = new FilterComponent({ + [UNSTABLE_FILTER_RELATED_BY_REL_TYPES.name]: [RelationType.Thread], + }); + + const referenceRelationEvent = mkEvent({ + type: 'm.room.message', + content: {}, + room: 'roomId', + event: true, + unsigned: { + "m.relations": { + [RelationType.Reference]: {}, + }, + }, + }); + + expect(filter.check(referenceRelationEvent)).toBe(false); + }); + + it("should keep events by relation type", function() { + const filter = new FilterComponent({ + [UNSTABLE_FILTER_RELATED_BY_REL_TYPES.name]: [RelationType.Thread], + }); + + const threadRootEvent = mkEvent({ + type: 'm.room.message', + content: {}, + unsigned: { + "m.relations": { + [RelationType.Thread]: { + count: 2, + current_user_participated: true, + }, + }, + }, + room: 'roomId', + event: true, + }); + + expect(filter.check(threadRootEvent)).toBe(true); + }); + }); +}); diff --git a/spec/unit/location.spec.ts b/spec/unit/location.spec.ts index 82314996068..a58b605e6be 100644 --- a/spec/unit/location.spec.ts +++ b/spec/unit/location.spec.ts @@ -17,7 +17,7 @@ limitations under the License. import { makeLocationContent } from "../../src/content-helpers"; import { ASSET_NODE_TYPE, - ASSET_TYPE_SELF, + LocationAssetType, LOCATION_EVENT_TYPE, TIMESTAMP_NODE_TYPE, } from "../../src/@types/location"; @@ -33,14 +33,14 @@ describe("Location", function() { uri: "geo:foo", description: undefined, }); - expect(ASSET_NODE_TYPE.findIn(loc)).toEqual({ type: ASSET_TYPE_SELF }); + expect(ASSET_NODE_TYPE.findIn(loc)).toEqual({ type: LocationAssetType.Self }); expect(TEXT_NODE_TYPE.findIn(loc)).toEqual("txt"); expect(TIMESTAMP_NODE_TYPE.findIn(loc)).toEqual(134235435); }); it("should create a valid location with explicit properties", function() { const loc = makeLocationContent( - "txxt", "geo:bar", 134235436, "desc", "m.something"); + "txxt", "geo:bar", 134235436, "desc", LocationAssetType.Pin); expect(loc.body).toEqual("txxt"); expect(loc.msgtype).toEqual("m.location"); @@ -49,7 +49,7 @@ describe("Location", function() { uri: "geo:bar", description: "desc", }); - expect(ASSET_NODE_TYPE.findIn(loc)).toEqual({ type: "m.something" }); + expect(ASSET_NODE_TYPE.findIn(loc)).toEqual({ type: LocationAssetType.Pin }); expect(TEXT_NODE_TYPE.findIn(loc)).toEqual("txxt"); expect(TIMESTAMP_NODE_TYPE.findIn(loc)).toEqual(134235436); }); diff --git a/spec/unit/matrix-client.spec.js b/spec/unit/matrix-client.spec.ts similarity index 89% rename from spec/unit/matrix-client.spec.js rename to spec/unit/matrix-client.spec.ts index fea2888575c..760526e80a2 100644 --- a/spec/unit/matrix-client.spec.js +++ b/spec/unit/matrix-client.spec.ts @@ -91,11 +91,7 @@ describe("MatrixClient", function() { return pendingLookup.promise; } // >1 pending thing, and they are different, whine. - expect(false).toBe( - true, ">1 pending request. You should probably handle them. " + - "PENDING: " + JSON.stringify(pendingLookup) + " JUST GOT: " + - method + " " + path, - ); + expect(false).toBe(true); } pendingLookup = { promise: new Promise(() => {}), @@ -123,6 +119,7 @@ describe("MatrixClient", function() { } if (next.error) { + // eslint-disable-next-line return Promise.reject({ errcode: next.error.errcode, httpStatus: next.error.httpStatus, @@ -133,7 +130,7 @@ describe("MatrixClient", function() { } return Promise.resolve(next.data); } - expect(true).toBe(false, "Expected different request. " + logLine); + expect(true).toBe(false); return new Promise(() => {}); } @@ -158,7 +155,7 @@ describe("MatrixClient", function() { baseUrl: "https://my.home.server", idBaseUrl: identityServerUrl, accessToken: "my.access.token", - request: function() {}, // NOP + request: function() {} as any, // NOP store: store, scheduler: scheduler, userId: userId, @@ -379,10 +376,10 @@ describe("MatrixClient", function() { ]; const filterId = "ehfewf"; store.getFilterIdByName.mockReturnValue(filterId); - const filter = new Filter(0, filterId); + const filter = new Filter("0", filterId); filter.setDefinition({ "room": { "timeline": { "limit": 8 } } }); store.getFilter.mockReturnValue(filter); - const syncPromise = new Promise((resolve, reject) => { + const syncPromise = new Promise((resolve, reject) => { client.on("sync", function syncListener(state) { if (state === "SYNCING") { expect(httpLookups.length).toEqual(0); @@ -403,7 +400,7 @@ describe("MatrixClient", function() { }); it("should return the same sync state as emitted sync events", async function() { - const syncingPromise = new Promise((resolve) => { + const syncingPromise = new Promise((resolve) => { client.on("sync", function syncListener(state) { expect(state).toEqual(client.getSyncState()); if (state === "SYNCING") { @@ -423,7 +420,7 @@ describe("MatrixClient", function() { it("should use an existing filter if id is present in localStorage", function() { }); it("should handle localStorage filterId missing from the server", function(done) { - function getFilterName(userId, suffix) { + function getFilterName(userId, suffix?: string) { // scope this on the user ID because people may login on many accounts // and they all need to be stored! return "FILTER_SYNC_" + userId + (suffix ? "_" + suffix : ""); @@ -501,7 +498,7 @@ describe("MatrixClient", function() { if (state === "ERROR" && httpLookups.length > 0) { expect(httpLookups.length).toEqual(1); expect(client.retryImmediately()).toBe( - true, "retryImmediately returned false", + true, ); jest.advanceTimersByTime(1); } else if (state === "RECONNECTING" && httpLookups.length > 0) { @@ -584,33 +581,33 @@ describe("MatrixClient", function() { }); it("should transition ERROR -> CATCHUP after /sync if prev failed", - function(done) { - const expectedStates = []; - acceptKeepalives = false; - httpLookups = []; - httpLookups.push(CAPABILITIES_RESPONSE); - httpLookups.push(PUSH_RULES_RESPONSE); - httpLookups.push(FILTER_RESPONSE); - httpLookups.push({ - method: "GET", path: "/sync", error: { errcode: "NOPE_NOPE_NOPE" }, - }); - httpLookups.push({ - method: "GET", path: KEEP_ALIVE_PATH, - error: { errcode: "KEEPALIVE_FAIL" }, - }); - httpLookups.push({ - method: "GET", path: KEEP_ALIVE_PATH, data: {}, - }); - httpLookups.push({ - method: "GET", path: "/sync", data: SYNC_DATA, - }); + function(done) { + const expectedStates = []; + acceptKeepalives = false; + httpLookups = []; + httpLookups.push(CAPABILITIES_RESPONSE); + httpLookups.push(PUSH_RULES_RESPONSE); + httpLookups.push(FILTER_RESPONSE); + httpLookups.push({ + method: "GET", path: "/sync", error: { errcode: "NOPE_NOPE_NOPE" }, + }); + httpLookups.push({ + method: "GET", path: KEEP_ALIVE_PATH, + error: { errcode: "KEEPALIVE_FAIL" }, + }); + httpLookups.push({ + method: "GET", path: KEEP_ALIVE_PATH, data: {}, + }); + httpLookups.push({ + method: "GET", path: "/sync", data: SYNC_DATA, + }); - expectedStates.push(["RECONNECTING", null]); - expectedStates.push(["ERROR", "RECONNECTING"]); - expectedStates.push(["CATCHUP", "ERROR"]); - client.on("sync", syncChecker(expectedStates, done)); - client.startClient(); - }); + expectedStates.push(["RECONNECTING", null]); + expectedStates.push(["ERROR", "RECONNECTING"]); + expectedStates.push(["CATCHUP", "ERROR"]); + client.on("sync", syncChecker(expectedStates, done)); + client.startClient(); + }); it("should transition PREPARED -> SYNCING after /sync", function(done) { const expectedStates = []; @@ -640,32 +637,32 @@ describe("MatrixClient", function() { }); xit("should transition ERROR -> SYNCING after /sync if prev failed", - function(done) { - const expectedStates = []; - httpLookups.push({ - method: "GET", path: "/sync", error: { errcode: "NONONONONO" }, - }); - httpLookups.push(SYNC_RESPONSE); + function(done) { + const expectedStates = []; + httpLookups.push({ + method: "GET", path: "/sync", error: { errcode: "NONONONONO" }, + }); + httpLookups.push(SYNC_RESPONSE); - expectedStates.push(["PREPARED", null]); - expectedStates.push(["SYNCING", "PREPARED"]); - expectedStates.push(["ERROR", "SYNCING"]); - client.on("sync", syncChecker(expectedStates, done)); - client.startClient(); - }); + expectedStates.push(["PREPARED", null]); + expectedStates.push(["SYNCING", "PREPARED"]); + expectedStates.push(["ERROR", "SYNCING"]); + client.on("sync", syncChecker(expectedStates, done)); + client.startClient(); + }); it("should transition SYNCING -> SYNCING on subsequent /sync successes", - function(done) { - const expectedStates = []; - httpLookups.push(SYNC_RESPONSE); - httpLookups.push(SYNC_RESPONSE); - - expectedStates.push(["PREPARED", null]); - expectedStates.push(["SYNCING", "PREPARED"]); - expectedStates.push(["SYNCING", "SYNCING"]); - client.on("sync", syncChecker(expectedStates, done)); - client.startClient(); - }); + function(done) { + const expectedStates = []; + httpLookups.push(SYNC_RESPONSE); + httpLookups.push(SYNC_RESPONSE); + + expectedStates.push(["PREPARED", null]); + expectedStates.push(["SYNCING", "PREPARED"]); + expectedStates.push(["SYNCING", "SYNCING"]); + client.on("sync", syncChecker(expectedStates, done)); + client.startClient(); + }); it("should transition ERROR -> ERROR if keepalive keeps failing", function(done) { acceptKeepalives = false; @@ -948,4 +945,39 @@ describe("MatrixClient", function() { expect(event.status).toBe(EventStatus.SENDING); }); }); + + describe("threads", () => { + it("partitions root events to room timeline and thread timeline", () => { + const supportsExperimentalThreads = client.supportsExperimentalThreads; + client.supportsExperimentalThreads = () => true; + + const rootEvent = new MatrixEvent({ + "content": {}, + "origin_server_ts": 1, + "room_id": "!room1:matrix.org", + "sender": "@alice:matrix.org", + "type": "m.room.message", + "unsigned": { + "m.relations": { + "io.element.thread": { + "latest_event": {}, + "count": 33, + "current_user_participated": false, + }, + }, + }, + "event_id": "$ev1", + "user_id": "@alice:matrix.org", + }); + + expect(rootEvent.isThreadRoot).toBe(true); + + const [room, threads] = client.partitionThreadedEvents([rootEvent]); + expect(room).toHaveLength(1); + expect(threads).toHaveLength(1); + + // Restore method + client.supportsExperimentalThreads = supportsExperimentalThreads; + }); + }); }); diff --git a/spec/unit/pushprocessor.spec.js b/spec/unit/pushprocessor.spec.js index b625ade4825..68480f5c791 100644 --- a/spec/unit/pushprocessor.spec.js +++ b/spec/unit/pushprocessor.spec.js @@ -302,4 +302,20 @@ describe('NotificationService', function() { const actions = pushProcessor.actionsForEvent(testEvent); expect(actions.tweaks.highlight).toEqual(false); }); + + it("a rule with no conditions matches every event.", function() { + expect(pushProcessor.ruleMatchesEvent({ + rule_id: "rule1", + actions: [], + conditions: [], + default: false, + enabled: true, + }, testEvent)).toBe(true); + expect(pushProcessor.ruleMatchesEvent({ + rule_id: "rule1", + actions: [], + default: false, + enabled: true, + }, testEvent)).toBe(true); + }); }); diff --git a/spec/unit/relations.spec.ts b/spec/unit/relations.spec.ts index 27370fba0e5..1b479ebbef8 100644 --- a/spec/unit/relations.spec.ts +++ b/spec/unit/relations.spec.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { EventTimelineSet } from "../../src/models/event-timeline-set"; -import { MatrixEvent } from "../../src/models/event"; +import { MatrixEvent, MatrixEventEvent } from "../../src/models/event"; import { Room } from "../../src/models/room"; import { Relations } from "../../src/models/relations"; @@ -103,7 +103,7 @@ describe("Relations", function() { // Add the target event first, then the relation event { const relationsCreated = new Promise(resolve => { - targetEvent.once("Event.relationsCreated", resolve); + targetEvent.once(MatrixEventEvent.RelationsCreated, resolve); }); const timelineSet = new EventTimelineSet(room, { @@ -118,7 +118,7 @@ describe("Relations", function() { // Add the relation event first, then the target event { const relationsCreated = new Promise(resolve => { - targetEvent.once("Event.relationsCreated", resolve); + targetEvent.once(MatrixEventEvent.RelationsCreated, resolve); }); const timelineSet = new EventTimelineSet(room, { diff --git a/spec/unit/room.spec.js b/spec/unit/room.spec.ts similarity index 70% rename from spec/unit/room.spec.js rename to spec/unit/room.spec.ts index 70b6a7a2e07..2d3aaa5e55c 100644 --- a/spec/unit/room.spec.js +++ b/spec/unit/room.spec.ts @@ -1,10 +1,32 @@ +/* +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 MatrixClient} for the public class. + * @module client + */ + import * as utils from "../test-utils"; -import { DuplicateStrategy, EventStatus, MatrixEvent } from "../../src"; +import { DuplicateStrategy, EventStatus, MatrixEvent, PendingEventOrdering, RoomEvent } from "../../src"; import { EventTimeline } from "../../src/models/event-timeline"; -import { RoomState } from "../../src"; -import { Room } from "../../src"; -import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event"; +import { Room } from "../../src/models/room"; +import { RoomState } from "../../src/models/room-state"; +import { RelationType, UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event"; import { TestClient } from "../TestClient"; +import { Thread } from "../../src/models/thread"; describe("Room", function() { const roomId = "!foo:bar"; @@ -15,7 +37,7 @@ describe("Room", function() { let room; beforeEach(function() { - room = new Room(roomId); + room = new Room(roomId, null, userA); // mock RoomStates room.oldState = room.getLiveTimeline().startState = utils.mock(RoomState, "oldState"); @@ -48,10 +70,10 @@ describe("Room", function() { }); it("should return nothing if there is no m.room.avatar and allowDefault=false", - function() { - const url = room.getAvatarUrl(hsUrl, 64, 64, "crop", false); - expect(url).toEqual(null); - }); + function() { + const url = room.getAvatarUrl(hsUrl, 64, 64, "crop", false); + expect(url).toEqual(null); + }); }); describe("getMember", function() { @@ -130,43 +152,43 @@ describe("Room", function() { }); it("should emit 'Room.timeline' events", - function() { - let callCount = 0; - room.on("Room.timeline", function(event, emitRoom, toStart) { - callCount += 1; - expect(room.timeline.length).toEqual(callCount); - expect(event).toEqual(events[callCount - 1]); - expect(emitRoom).toEqual(room); - expect(toStart).toBeFalsy(); + function() { + let callCount = 0; + room.on("Room.timeline", function(event, emitRoom, toStart) { + callCount += 1; + expect(room.timeline.length).toEqual(callCount); + expect(event).toEqual(events[callCount - 1]); + expect(emitRoom).toEqual(room); + expect(toStart).toBeFalsy(); + }); + room.addLiveEvents(events); + expect(callCount).toEqual(2); }); - room.addLiveEvents(events); - expect(callCount).toEqual(2); - }); it("should call setStateEvents on the right RoomState with the right forwardLooking value for new events", - function() { - const events = [ - utils.mkMembership({ - room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }), - utils.mkEvent({ - type: "m.room.name", room: roomId, user: userB, event: true, - content: { - name: "New room", - }, - }), - ]; - room.addLiveEvents(events); - expect(room.currentState.setStateEvents).toHaveBeenCalledWith( - [events[0]], - ); - expect(room.currentState.setStateEvents).toHaveBeenCalledWith( - [events[1]], - ); - expect(events[0].forwardLooking).toBe(true); - expect(events[1].forwardLooking).toBe(true); - expect(room.oldState.setStateEvents).not.toHaveBeenCalled(); - }); + function() { + const events = [ + utils.mkMembership({ + room: roomId, mship: "invite", user: userB, skey: userA, event: true, + }), + utils.mkEvent({ + type: "m.room.name", room: roomId, user: userB, event: true, + content: { + name: "New room", + }, + }), + ]; + room.addLiveEvents(events); + expect(room.currentState.setStateEvents).toHaveBeenCalledWith( + [events[0]], + ); + expect(room.currentState.setStateEvents).toHaveBeenCalledWith( + [events[1]], + ); + expect(events[0].forwardLooking).toBe(true); + expect(events[1].forwardLooking).toBe(true); + expect(room.oldState.setStateEvents).not.toHaveBeenCalled(); + }); it("should synthesize read receipts for the senders of events", function() { const sentinel = { @@ -201,20 +223,20 @@ describe("Room", function() { room.on("Room.localEchoUpdated", function(event, emitRoom, oldEventId, oldStatus) { switch (callCount) { - case 0: - expect(event.getId()).toEqual(localEventId); - expect(event.status).toEqual(EventStatus.SENDING); - expect(emitRoom).toEqual(room); - expect(oldEventId).toBe(null); - expect(oldStatus).toBe(null); - break; - case 1: - expect(event.getId()).toEqual(remoteEventId); - expect(event.status).toBe(null); - expect(emitRoom).toEqual(room); - expect(oldEventId).toEqual(localEventId); - expect(oldStatus).toBe(EventStatus.SENDING); - break; + case 0: + expect(event.getId()).toEqual(localEventId); + expect(event.status).toEqual(EventStatus.SENDING); + expect(emitRoom).toEqual(room); + expect(oldEventId).toBe(null); + expect(oldStatus).toBe(null); + break; + case 1: + expect(event.getId()).toEqual(remoteEventId); + expect(event.status).toBe(null); + expect(emitRoom).toEqual(room); + expect(oldEventId).toEqual(localEventId); + expect(oldStatus).toBe(EventStatus.SENDING); + break; } callCount += 1; }, @@ -257,18 +279,18 @@ describe("Room", function() { }); it("should emit 'Room.timeline' events when added to the start", - function() { - let callCount = 0; - room.on("Room.timeline", function(event, emitRoom, toStart) { - callCount += 1; - expect(room.timeline.length).toEqual(callCount); - expect(event).toEqual(events[callCount - 1]); - expect(emitRoom).toEqual(room); - expect(toStart).toBe(true); + function() { + let callCount = 0; + room.on("Room.timeline", function(event, emitRoom, toStart) { + callCount += 1; + expect(room.timeline.length).toEqual(callCount); + expect(event).toEqual(events[callCount - 1]); + expect(emitRoom).toEqual(room); + expect(toStart).toBe(true); + }); + room.addEventsToTimeline(events, true, room.getLiveTimeline()); + expect(callCount).toEqual(2); }); - room.addEventsToTimeline(events, true, room.getLiveTimeline()); - expect(callCount).toEqual(2); - }); }); describe("event metadata handling", function() { @@ -311,41 +333,41 @@ describe("Room", function() { }); it("should set event.target for new and old m.room.member events", - function() { - const sentinel = { - userId: userA, - membership: "join", - name: "Alice", - }; - const oldSentinel = { - userId: userA, - membership: "join", - name: "Old Alice", - }; - room.currentState.getSentinelMember.mockImplementation(function(uid) { - if (uid === userA) { - return sentinel; - } - return null; - }); - room.oldState.getSentinelMember.mockImplementation(function(uid) { - if (uid === userA) { - return oldSentinel; - } - return null; - }); + function() { + const sentinel = { + userId: userA, + membership: "join", + name: "Alice", + }; + const oldSentinel = { + userId: userA, + membership: "join", + name: "Old Alice", + }; + room.currentState.getSentinelMember.mockImplementation(function(uid) { + if (uid === userA) { + return sentinel; + } + return null; + }); + room.oldState.getSentinelMember.mockImplementation(function(uid) { + if (uid === userA) { + return oldSentinel; + } + return null; + }); - const newEv = utils.mkMembership({ - room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }); - const oldEv = utils.mkMembership({ - room: roomId, mship: "ban", user: userB, skey: userA, event: true, + const newEv = utils.mkMembership({ + room: roomId, mship: "invite", user: userB, skey: userA, event: true, + }); + const oldEv = utils.mkMembership({ + room: roomId, mship: "ban", user: userB, skey: userA, event: true, + }); + room.addLiveEvents([newEv]); + expect(newEv.target).toEqual(sentinel); + room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); + expect(oldEv.target).toEqual(oldSentinel); }); - room.addLiveEvents([newEv]); - expect(newEv.target).toEqual(sentinel); - room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); - expect(oldEv.target).toEqual(oldSentinel); - }); it("should call setStateEvents on the right RoomState with the right " + "forwardLooking value for old events", function() { @@ -454,9 +476,9 @@ describe("Room", function() { }; describe("resetLiveTimeline with timelinesupport enabled", - resetTimelineTests.bind(null, true)); + resetTimelineTests.bind(null, true)); describe("resetLiveTimeline with timelinesupport disabled", - resetTimelineTests.bind(null, false)); + resetTimelineTests.bind(null, false)); describe("compareEventOrdering", function() { beforeEach(function() { @@ -479,13 +501,13 @@ describe("Room", function() { room.addLiveEvents(events); expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId(), - events[1].getId())) + events[1].getId())) .toBeLessThan(0); expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[2].getId(), - events[1].getId())) + events[1].getId())) .toBeGreaterThan(0); expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId(), - events[1].getId())) + events[1].getId())) .toEqual(0); }); @@ -498,10 +520,10 @@ describe("Room", function() { room.addLiveEvents([events[1]]); expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId(), - events[1].getId())) + events[1].getId())) .toBeLessThan(0); expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId(), - events[0].getId())) + events[0].getId())) .toBeGreaterThan(0); }); @@ -512,10 +534,10 @@ describe("Room", function() { room.addLiveEvents([events[1]]); expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId(), - events[1].getId())) + events[1].getId())) .toBe(null); expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId(), - events[0].getId())) + events[0].getId())) .toBe(null); }); @@ -523,14 +545,14 @@ describe("Room", function() { room.addLiveEvents(events); expect(room.getUnfilteredTimelineSet() - .compareEventOrdering(events[0].getId(), "xxx")) - .toBe(null); + .compareEventOrdering(events[0].getId(), "xxx")) + .toBe(null); expect(room.getUnfilteredTimelineSet() - .compareEventOrdering("xxx", events[0].getId())) - .toBe(null); + .compareEventOrdering("xxx", events[0].getId())) + .toBe(null); expect(room.getUnfilteredTimelineSet() - .compareEventOrdering(events[0].getId(), events[0].getId())) - .toBe(0); + .compareEventOrdering(events[0].getId(), events[0].getId())) + .toBe(0); }); }); @@ -561,50 +583,50 @@ describe("Room", function() { describe("hasMembershipState", function() { it("should return true for a matching userId and membership", - function() { - room.currentState.getMember.mockImplementation(function(userId) { - return { - "@alice:bar": { userId: "@alice:bar", membership: "join" }, - "@bob:bar": { userId: "@bob:bar", membership: "invite" }, - }[userId]; + function() { + room.currentState.getMember.mockImplementation(function(userId) { + return { + "@alice:bar": { userId: "@alice:bar", membership: "join" }, + "@bob:bar": { userId: "@bob:bar", membership: "invite" }, + }[userId]; + }); + expect(room.hasMembershipState("@bob:bar", "invite")).toBe(true); }); - expect(room.hasMembershipState("@bob:bar", "invite")).toBe(true); - }); it("should return false if match membership but no match userId", - function() { - room.currentState.getMember.mockImplementation(function(userId) { - return { - "@alice:bar": { userId: "@alice:bar", membership: "join" }, - }[userId]; + function() { + room.currentState.getMember.mockImplementation(function(userId) { + return { + "@alice:bar": { userId: "@alice:bar", membership: "join" }, + }[userId]; + }); + expect(room.hasMembershipState("@bob:bar", "join")).toBe(false); }); - expect(room.hasMembershipState("@bob:bar", "join")).toBe(false); - }); it("should return false if match userId but no match membership", - function() { - room.currentState.getMember.mockImplementation(function(userId) { - return { - "@alice:bar": { userId: "@alice:bar", membership: "join" }, - }[userId]; + function() { + room.currentState.getMember.mockImplementation(function(userId) { + return { + "@alice:bar": { userId: "@alice:bar", membership: "join" }, + }[userId]; + }); + expect(room.hasMembershipState("@alice:bar", "ban")).toBe(false); }); - expect(room.hasMembershipState("@alice:bar", "ban")).toBe(false); - }); it("should return false if no match membership or userId", - function() { - room.currentState.getMember.mockImplementation(function(userId) { - return { - "@alice:bar": { userId: "@alice:bar", membership: "join" }, - }[userId]; + function() { + room.currentState.getMember.mockImplementation(function(userId) { + return { + "@alice:bar": { userId: "@alice:bar", membership: "join" }, + }[userId]; + }); + expect(room.hasMembershipState("@bob:bar", "invite")).toBe(false); }); - expect(room.hasMembershipState("@bob:bar", "invite")).toBe(false); - }); it("should return false if no members exist", - function() { - expect(room.hasMembershipState("@foo:bar", "join")).toBe(false); - }); + function() { + expect(room.hasMembershipState("@foo:bar", "join")).toBe(false); + }); }); describe("recalculate", function() { @@ -634,11 +656,7 @@ describe("Room", function() { }, event: true, })]); }; - const addMember = function(userId, state, opts) { - if (!state) { - state = "join"; - } - opts = opts || {}; + const addMember = function(userId, state = "join", opts: any = {}) { opts.room = roomId; opts.mship = state; opts.user = opts.user || userId; @@ -797,7 +815,7 @@ describe("Room", function() { break; } } - expect(found).toEqual(true, name); + expect(found).toEqual(true); }); it("should return the names of members in a private (invite join_rules)" + @@ -809,8 +827,8 @@ describe("Room", function() { addMember(userC); room.recalculate(); const name = room.name; - expect(name.indexOf(userB)).not.toEqual(-1, name); - expect(name.indexOf(userC)).not.toEqual(-1, name); + expect(name.indexOf(userB)).not.toEqual(-1); + expect(name.indexOf(userC)).not.toEqual(-1); }); it("should return the names of members in a public (public join_rules)" + @@ -822,8 +840,8 @@ describe("Room", function() { addMember(userC); room.recalculate(); const name = room.name; - expect(name.indexOf(userB)).not.toEqual(-1, name); - expect(name.indexOf(userC)).not.toEqual(-1, name); + expect(name.indexOf(userB)).not.toEqual(-1); + expect(name.indexOf(userC)).not.toEqual(-1); }); it("should show the other user's name for public (public join_rules)" + @@ -834,7 +852,7 @@ describe("Room", function() { addMember(userB); room.recalculate(); const name = room.name; - expect(name.indexOf(userB)).not.toEqual(-1, name); + expect(name.indexOf(userB)).not.toEqual(-1); }); it("should show the other user's name for private " + @@ -845,7 +863,7 @@ describe("Room", function() { addMember(userB); room.recalculate(); const name = room.name; - expect(name.indexOf(userB)).not.toEqual(-1, name); + expect(name.indexOf(userB)).not.toEqual(-1); }); it("should show the other user's name for private" + @@ -855,7 +873,7 @@ describe("Room", function() { addMember(userB); room.recalculate(); const name = room.name; - expect(name.indexOf(userB)).not.toEqual(-1, name); + expect(name.indexOf(userB)).not.toEqual(-1); }); it("should show the room alias if one exists for private " + @@ -942,14 +960,14 @@ describe("Room", function() { }); it("should return inviter mxid if display name not available", - function() { - setJoinRule("invite"); - addMember(userB); - addMember(userA, "invite", { user: userA }); - room.recalculate(); - const name = room.name; - expect(name).toEqual(userB); - }); + function() { + setJoinRule("invite"); + addMember(userB); + addMember(userA, "invite", { user: userA }); + room.recalculate(); + const name = room.name; + expect(name).toEqual(userB); + }); }); }); @@ -991,34 +1009,34 @@ describe("Room", function() { describe("addReceipt", function() { it("should store the receipt so it can be obtained via getReceiptsForEvent", - function() { - const ts = 13787898424; - room.addReceipt(mkReceipt(roomId, [ - mkRecord(eventToAck.getId(), "m.read", userB, ts), - ])); - expect(room.getReceiptsForEvent(eventToAck)).toEqual([{ - type: "m.read", - userId: userB, - data: { - ts: ts, - }, - }]); - }); + function() { + const ts = 13787898424; + room.addReceipt(mkReceipt(roomId, [ + mkRecord(eventToAck.getId(), "m.read", userB, ts), + ])); + expect(room.getReceiptsForEvent(eventToAck)).toEqual([{ + type: "m.read", + userId: userB, + data: { + ts: ts, + }, + }]); + }); it("should emit an event when a receipt is added", - function() { - const listener = jest.fn(); - room.on("Room.receipt", listener); + function() { + const listener = jest.fn(); + room.on("Room.receipt", listener); - const ts = 13787898424; + const ts = 13787898424; - const receiptEvent = mkReceipt(roomId, [ - mkRecord(eventToAck.getId(), "m.read", userB, ts), - ]); + const receiptEvent = mkReceipt(roomId, [ + mkRecord(eventToAck.getId(), "m.read", userB, ts), + ]); - room.addReceipt(receiptEvent); - expect(listener).toHaveBeenCalledWith(receiptEvent, room); - }); + room.addReceipt(receiptEvent); + expect(listener).toHaveBeenCalledWith(receiptEvent, room); + }); it("should clobber receipts based on type and user ID", function() { const nextEventToAck = utils.mkMessage({ @@ -1082,27 +1100,27 @@ describe("Room", function() { mkRecord(eventToAck.getId(), "m.seen", userB, 33333333), ])); expect(room.getReceiptsForEvent(eventToAck)).toEqual([ - { - type: "m.delivered", - userId: userB, - data: { - ts: 13787898424, + { + type: "m.delivered", + userId: userB, + data: { + ts: 13787898424, + }, }, - }, - { - type: "m.read", - userId: userB, - data: { - ts: 22222222, + { + type: "m.read", + userId: userB, + data: { + ts: 22222222, + }, }, - }, - { - type: "m.seen", - userId: userB, - data: { - ts: 33333333, + { + type: "m.seen", + userId: userB, + data: { + ts: 33333333, + }, }, - }, ]); }); @@ -1244,7 +1262,7 @@ describe("Room", function() { "@alice:example.com", "alicedevice", )).client; const room = new Room(roomId, client, userA, { - pendingEventOrdering: "detached", + pendingEventOrdering: PendingEventOrdering.Detached, }); const eventA = utils.mkMessage({ room: roomId, user: userA, msg: "remote 1", event: true, @@ -1270,7 +1288,7 @@ describe("Room", function() { it("should add pending events to the timeline if " + "pendingEventOrdering == 'chronological'", function() { room = new Room(roomId, null, userA, { - pendingEventOrdering: "chronological", + pendingEventOrdering: PendingEventOrdering.Chronological, }); const eventA = utils.mkMessage({ room: roomId, user: userA, msg: "remote 1", event: true, @@ -1297,7 +1315,7 @@ describe("Room", function() { "@alice:example.com", "alicedevice", )).client; const room = new Room(roomId, client, userA, { - pendingEventOrdering: "detached", + pendingEventOrdering: PendingEventOrdering.Detached, }); const eventA = utils.mkMessage({ room: roomId, user: userA, event: true, @@ -1315,7 +1333,7 @@ describe("Room", function() { room.updatePendingEvent(eventA, EventStatus.NOT_SENT); let callCount = 0; - room.on("Room.localEchoUpdated", + room.on(RoomEvent.LocalEchoUpdated, function(event, emitRoom, oldEventId, oldStatus) { expect(event).toEqual(eventA); expect(event.status).toEqual(EventStatus.CANCELLED); @@ -1348,7 +1366,7 @@ describe("Room", function() { room.updatePendingEvent(eventA, EventStatus.NOT_SENT); let callCount = 0; - room.on("Room.localEchoUpdated", + room.on(RoomEvent.LocalEchoUpdated, function(event, emitRoom, oldEventId, oldStatus) { expect(event).toEqual(eventA); expect(event.status).toEqual(EventStatus.CANCELLED); @@ -1413,7 +1431,7 @@ describe("Room", function() { it("should load members from server on first call", async function() { const client = createClientMock([memberEvent]); - const room = new Room(roomId, client, null, { lazyLoadMembers: true }); + const room = new Room(roomId, client as any, null, { lazyLoadMembers: true }); await room.loadMembersIfNeeded(); const memberA = room.getMember("@user_a:bar"); expect(memberA.name).toEqual("User A"); @@ -1428,7 +1446,7 @@ describe("Room", function() { room: roomId, event: true, name: "Ms A", }); const client = createClientMock([memberEvent2], [memberEvent]); - const room = new Room(roomId, client, null, { lazyLoadMembers: true }); + const room = new Room(roomId, client as any, null, { lazyLoadMembers: true }); await room.loadMembersIfNeeded(); @@ -1438,7 +1456,7 @@ describe("Room", function() { it("should allow retry on error", async function() { const client = createClientMock(new Error("server says no")); - const room = new Room(roomId, client, null, { lazyLoadMembers: true }); + const room = new Room(roomId, client as any, null, { lazyLoadMembers: true }); let hasThrown = false; try { await room.loadMembersIfNeeded(); @@ -1456,183 +1474,78 @@ describe("Room", function() { describe("getMyMembership", function() { it("should return synced membership if membership isn't available yet", - function() { - const room = new Room(roomId, null, userA); - room.updateMyMembership("invite"); - expect(room.getMyMembership()).toEqual("invite"); - }); + function() { + const room = new Room(roomId, null, userA); + room.updateMyMembership("invite"); + expect(room.getMyMembership()).toEqual("invite"); + }); it("should emit a Room.myMembership event on a change", - function() { - const room = new Room(roomId, null, userA); - const events = []; - room.on("Room.myMembership", (_room, membership, oldMembership) => { - events.push({ membership, oldMembership }); - }); - room.updateMyMembership("invite"); - expect(room.getMyMembership()).toEqual("invite"); - expect(events[0]).toEqual({ membership: "invite", oldMembership: null }); - events.splice(0); //clear - room.updateMyMembership("invite"); - expect(events.length).toEqual(0); - room.updateMyMembership("join"); - expect(room.getMyMembership()).toEqual("join"); - expect(events[0]).toEqual({ membership: "join", oldMembership: "invite" }); - }); + function() { + const room = new Room(roomId, null, userA); + const events = []; + room.on(RoomEvent.MyMembership, (_room, membership, oldMembership) => { + events.push({ membership, oldMembership }); + }); + room.updateMyMembership("invite"); + expect(room.getMyMembership()).toEqual("invite"); + expect(events[0]).toEqual({ membership: "invite", oldMembership: null }); + events.splice(0); //clear + room.updateMyMembership("invite"); + expect(events.length).toEqual(0); + room.updateMyMembership("join"); + expect(room.getMyMembership()).toEqual("join"); + expect(events[0]).toEqual({ membership: "join", oldMembership: "invite" }); + }); }); describe("guessDMUserId", function() { it("should return first hero id", - function() { - const room = new Room(roomId, null, userA); - room.setSummary({ 'm.heroes': [userB] }); - expect(room.guessDMUserId()).toEqual(userB); - }); + function() { + const room = new Room(roomId, null, userA); + room.setSummary({ + 'm.heroes': [userB], + 'm.joined_member_count': 1, + 'm.invited_member_count': 1, + }); + expect(room.guessDMUserId()).toEqual(userB); + }); it("should return first member that isn't self", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, - })]); - expect(room.guessDMUserId()).toEqual(userB); - }); + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, + })]); + expect(room.guessDMUserId()).toEqual(userB); + }); it("should return self if only member present", - function() { - const room = new Room(roomId, null, userA); - expect(room.guessDMUserId()).toEqual(userA); - }); + function() { + const room = new Room(roomId, null, userA); + expect(room.guessDMUserId()).toEqual(userA); + }); }); describe("maySendMessage", function() { it("should return false if synced membership not join", - function() { - const room = new Room(roomId, { isRoomEncrypted: () => false }, userA); - room.updateMyMembership("invite"); - expect(room.maySendMessage()).toEqual(false); - room.updateMyMembership("leave"); - expect(room.maySendMessage()).toEqual(false); - room.updateMyMembership("join"); - expect(room.maySendMessage()).toEqual(true); - }); + function() { + const room = new Room(roomId, { isRoomEncrypted: () => false } as any, userA); + room.updateMyMembership("invite"); + expect(room.maySendMessage()).toEqual(false); + room.updateMyMembership("leave"); + expect(room.maySendMessage()).toEqual(false); + room.updateMyMembership("join"); + expect(room.maySendMessage()).toEqual(true); + }); }); describe("getDefaultRoomName", function() { it("should return 'Empty room' if a user is the only member", - function() { - const room = new Room(roomId, null, userA); - expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); - }); + function() { + const room = new Room(roomId, null, userA); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); + }); it("should return a display name if one other member is in the room", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); - - it("should return a display name if one other member is banned", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "ban", - room: roomId, event: true, name: "User B", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); - }); - - it("should return a display name if one other member is invited", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "invite", - room: roomId, event: true, name: "User B", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); - - it("should return 'Empty room (was User B)' if User B left the room", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "leave", - room: roomId, event: true, name: "User B", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); - }); - - it("should return 'User B and User C' if in a room with two other users", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkMembership({ - user: userC, mship: "join", - room: roomId, event: true, name: "User C", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B and User C"); - }); - - it("should return 'User B and 2 others' if in a room with three other users", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkMembership({ - user: userC, mship: "join", - room: roomId, event: true, name: "User C", - }), - utils.mkMembership({ - user: userD, mship: "join", - room: roomId, event: true, name: "User D", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B and 2 others"); - }); - - describe("io.element.functional_users", function() { - it("should return a display name (default behaviour) if no one is marked as a functional member", function() { const room = new Room(roomId, null, userA); room.addLiveEvents([ @@ -1644,18 +1557,11 @@ describe("Room", function() { user: userB, mship: "join", room: roomId, event: true, name: "User B", }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, - content: { - service_members: [], - }, - }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); - it("should return a display name (default behaviour) if service members is a number (invalid)", + it("should return a display name if one other member is banned", function() { const room = new Room(roomId, null, userA); room.addLiveEvents([ @@ -1664,21 +1570,14 @@ describe("Room", function() { room: roomId, event: true, name: "User A", }), utils.mkMembership({ - user: userB, mship: "join", + user: userB, mship: "ban", room: roomId, event: true, name: "User B", }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, - content: { - service_members: 1, - }, - }), ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); }); - it("should return a display name (default behaviour) if service members is a string (invalid)", + it("should return a display name if one other member is invited", function() { const room = new Room(roomId, null, userA); room.addLiveEvents([ @@ -1687,21 +1586,14 @@ describe("Room", function() { room: roomId, event: true, name: "User A", }), utils.mkMembership({ - user: userB, mship: "join", + user: userB, mship: "invite", room: roomId, event: true, name: "User B", }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, - content: { - service_members: userB, - }, - }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); - it("should return 'Empty room' if the only other member is a functional member", + it("should return 'Empty room (was User B)' if User B left the room", function() { const room = new Room(roomId, null, userA); room.addLiveEvents([ @@ -1710,21 +1602,14 @@ describe("Room", function() { room: roomId, event: true, name: "User A", }), utils.mkMembership({ - user: userB, mship: "join", + user: userB, mship: "leave", room: roomId, event: true, name: "User B", }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, - content: { - service_members: [userB], - }, - }), ]); - expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); }); - it("should return 'User B' if User B is the only other member who isn't a functional member", + it("should return 'User B and User C' if in a room with two other users", function() { const room = new Room(roomId, null, userA); room.addLiveEvents([ @@ -1740,18 +1625,11 @@ describe("Room", function() { user: userC, mship: "join", room: roomId, event: true, name: "User C", }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, user: userA, - content: { - service_members: [userC], - }, - }), ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); + expect(room.getDefaultRoomName(userA)).toEqual("User B and User C"); }); - it("should return 'Empty room' if all other members are functional members", + it("should return 'User B and 2 others' if in a room with three other users", function() { const room = new Room(roomId, null, userA); room.addLiveEvents([ @@ -1767,38 +1645,275 @@ describe("Room", function() { user: userC, mship: "join", room: roomId, event: true, name: "User C", }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, user: userA, - content: { - service_members: [userB, userC], - }, + utils.mkMembership({ + user: userD, mship: "join", + room: roomId, event: true, name: "User D", }), ]); - expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); + expect(room.getDefaultRoomName(userA)).toEqual("User B and 2 others"); }); + describe("io.element.functional_users", function() { + it("should return a display name (default behaviour) if no one is marked as a functional member", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, + content: { + service_members: [], + }, + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + + it("should return a display name (default behaviour) if service members is a number (invalid)", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, + content: { + service_members: 1, + }, + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + + it("should return a display name (default behaviour) if service members is a string (invalid)", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, + content: { + service_members: userB, + }, + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + + it("should return 'Empty room' if the only other member is a functional member", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, + content: { + service_members: [userB], + }, + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); + }); + + it("should return 'User B' if User B is the only other member who isn't a functional member", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkMembership({ + user: userC, mship: "join", + room: roomId, event: true, name: "User C", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, user: userA, + content: { + service_members: [userC], + }, + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + + it("should return 'Empty room' if all other members are functional members", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkMembership({ + user: userC, mship: "join", + room: roomId, event: true, name: "User C", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, user: userA, + content: { + service_members: [userB, userC], + }, + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); + }); + it("should not break if an unjoined user is marked as a service user", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, user: userA, + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, user: userA, + content: { + service_members: [userC], + }, + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + }); + + describe("threads", function() { + beforeEach(() => { + const client = (new TestClient( + "@alice:example.com", "alicedevice", + )).client; + room = new Room(roomId, client, userA); + }); + + it("allow create threads without a root event", function() { + const eventWithoutARootEvent = new MatrixEvent({ + event_id: "$123", + room_id: roomId, + content: { + "m.relates_to": { + "rel_type": RelationType.Thread, + "event_id": "$000", + }, + }, + unsigned: { + "age": 1, + }, + }); + + room.createThread(undefined, [eventWithoutARootEvent]); + + const rootEvent = new MatrixEvent({ + event_id: "$666", + room_id: roomId, + content: {}, + unsigned: { + "age": 1, + "m.relations": { + [RelationType.Thread]: { + latest_event: null, + count: 1, + current_user_participated: false, + }, + }, + }, + }); + + expect(() => room.createThread(rootEvent, [])).not.toThrow(); + }); + + it("should not add events before server supports is known", function() { + Thread.hasServerSideSupport = undefined; + + const rootEvent = new MatrixEvent({ + event_id: "$666", + room_id: roomId, + content: {}, + unsigned: { + "age": 1, + "m.relations": { + [RelationType.Thread]: { + latest_event: null, + count: 1, + current_user_participated: false, + }, + }, + }, + }); + + let age = 1; + function mkEvt(id): MatrixEvent { + return new MatrixEvent({ + event_id: id, + room_id: roomId, content: { - service_members: [userC], + "m.relates_to": { + "rel_type": RelationType.Thread, + "event_id": "$666", + }, }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); + unsigned: { + "age": age++, + }, + }); + } + + const thread = room.createThread(rootEvent, []); + expect(thread.length).toBe(0); + + thread.addEvent(mkEvt("$1")); + expect(thread.length).toBe(0); + + Thread.hasServerSideSupport = true; + + thread.addEvent(mkEvt("$2")); + expect(thread.length).toBeGreaterThan(0); }); }); }); diff --git a/spec/unit/webrtc/call.spec.ts b/spec/unit/webrtc/call.spec.ts index bcded3fb78e..8a2e255e811 100644 --- a/spec/unit/webrtc/call.spec.ts +++ b/spec/unit/webrtc/call.spec.ts @@ -82,17 +82,34 @@ class MockRTCPeerConnection { } close() {} getStats() { return []; } + addTrack(track: MockMediaStreamTrack) {return new MockRTCRtpSender(track);} +} + +class MockRTCRtpSender { + constructor(public track: MockMediaStreamTrack) {} + + replaceTrack(track: MockMediaStreamTrack) {this.track = track;} +} + +class MockMediaStreamTrack { + constructor(public readonly id: string, public readonly kind: "audio" | "video", public enabled = true) {} + + stop() {} } class MockMediaStream { constructor( public id: string, + private tracks: MockMediaStreamTrack[] = [], ) {} - getTracks() { return []; } - getAudioTracks() { return [{ enabled: true }]; } - getVideoTracks() { return [{ enabled: true }]; } + getTracks() { return this.tracks; } + getAudioTracks() { return this.tracks.filter((track) => track.kind === "audio"); } + getVideoTracks() { return this.tracks.filter((track) => track.kind === "video"); } addEventListener() {} + removeEventListener() { } + addTrack(track: MockMediaStreamTrack) {this.tracks.push(track);} + removeTrack(track: MockMediaStreamTrack) {this.tracks.splice(this.tracks.indexOf(track), 1);} } class MockMediaDeviceInfo { @@ -102,7 +119,13 @@ class MockMediaDeviceInfo { } class MockMediaHandler { - getUserMediaStream() { return new MockMediaStream("mock_stream_from_media_handler"); } + getUserMediaStream(audio: boolean, video: boolean) { + const tracks = []; + if (audio) tracks.push(new MockMediaStreamTrack("audio_track", "audio")); + if (video) tracks.push(new MockMediaStreamTrack("video_track", "video")); + + return new MockMediaStream("mock_stream_from_media_handler", tracks); + } stopUserMediaStream() {} } @@ -350,7 +373,15 @@ describe('Call', function() { }, }); - call.pushRemoteFeed(new MockMediaStream("remote_stream")); + call.pushRemoteFeed( + new MockMediaStream( + "remote_stream", + [ + new MockMediaStreamTrack("remote_audio_track", "audio"), + new MockMediaStreamTrack("remote_video_track", "video"), + ], + ), + ); const feed = call.getFeeds().find((feed) => feed.stream.id === "remote_stream"); expect(feed?.purpose).toBe(SDPStreamMetadataPurpose.Usermedia); expect(feed?.isAudioMuted()).toBeTruthy(); @@ -396,4 +427,82 @@ describe('Call', function() { expect(client.client.mediaHandler.getUserMediaStream).toHaveBeenNthCalledWith(1, true, true); expect(client.client.mediaHandler.getUserMediaStream).toHaveBeenNthCalledWith(2, true, false); }); + + it("should handle mid-call device changes", async () => { + client.client.mediaHandler.getUserMediaStream = jest.fn().mockReturnValue( + new MockMediaStream( + "stream", [ + new MockMediaStreamTrack("audio_track", "audio"), + new MockMediaStreamTrack("video_track", "video"), + ], + ), + ); + + const callPromise = call.placeVideoCall(); + await client.httpBackend.flush(); + await callPromise; + + await call.onAnswerReceived({ + getContent: () => { + return { + version: 1, + call_id: call.callId, + party_id: 'party_id', + answer: { + sdp: DUMMY_SDP, + }, + }; + }, + }); + + await call.updateLocalUsermediaStream( + new MockMediaStream( + "replacement_stream", + [ + new MockMediaStreamTrack("new_audio_track", "audio"), + new MockMediaStreamTrack("video_track", "video"), + ], + ), + ); + expect(call.localUsermediaStream.id).toBe("stream"); + expect(call.localUsermediaStream.getAudioTracks()[0].id).toBe("new_audio_track"); + expect(call.localUsermediaStream.getVideoTracks()[0].id).toBe("video_track"); + expect(call.usermediaSenders.find((sender) => { + return sender?.track?.kind === "audio"; + }).track.id).toBe("new_audio_track"); + expect(call.usermediaSenders.find((sender) => { + return sender?.track?.kind === "video"; + }).track.id).toBe("video_track"); + }); + + it("should handle upgrade to video call", async () => { + const callPromise = call.placeVoiceCall(); + await client.httpBackend.flush(); + await callPromise; + + await call.onAnswerReceived({ + getContent: () => { + return { + version: 1, + call_id: call.callId, + party_id: 'party_id', + answer: { + sdp: DUMMY_SDP, + }, + [SDPStreamMetadataKey]: {}, + }; + }, + }); + + await call.upgradeCall(false, true); + + expect(call.localUsermediaStream.getAudioTracks()[0].id).toBe("audio_track"); + expect(call.localUsermediaStream.getVideoTracks()[0].id).toBe("video_track"); + expect(call.usermediaSenders.find((sender) => { + return sender?.track?.kind === "audio"; + }).track.id).toBe("audio_track"); + expect(call.usermediaSenders.find((sender) => { + return sender?.track?.kind === "video"; + }).track.id).toBe("video_track"); + }); }); diff --git a/src/@types/location.ts b/src/@types/location.ts index e1c2601be17..09ef3117161 100644 --- a/src/@types/location.ts +++ b/src/@types/location.ts @@ -27,7 +27,10 @@ export const ASSET_NODE_TYPE = new UnstableValue("m.asset", "org.matrix.msc3488. export const TIMESTAMP_NODE_TYPE = new UnstableValue("m.ts", "org.matrix.msc3488.ts"); -export const ASSET_TYPE_SELF = "m.self"; +export enum LocationAssetType { + Self = "m.self", + Pin = "m.pin", +} /* From the spec at: * https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md @@ -60,7 +63,7 @@ export interface ILocationContent extends IContent { description?: string; }; [ASSET_NODE_TYPE.name]: { - type: string; + type: LocationAssetType; }; [TEXT_NODE_TYPE.name]: string; [TIMESTAMP_NODE_TYPE.name]: number; diff --git a/src/@types/spaces.ts b/src/@types/spaces.ts index 7ca55c39e1b..9edab274a16 100644 --- a/src/@types/spaces.ts +++ b/src/@types/spaces.ts @@ -21,28 +21,6 @@ import { IStrippedState } from "../sync-accumulator"; // Types relating to Rooms of type `m.space` and related APIs /* eslint-disable camelcase */ -/** @deprecated Use hierarchy instead where possible. */ -export interface ISpaceSummaryRoom extends IPublicRoomsChunkRoom { - num_refs: number; - room_type: string; -} - -/** @deprecated Use hierarchy instead where possible. */ -export interface ISpaceSummaryEvent { - room_id: string; - event_id: string; - origin_server_ts: number; - type: string; - state_key: string; - sender: string; - content: { - order?: string; - suggested?: boolean; - auto_join?: boolean; - via?: string[]; - }; -} - export interface IHierarchyRelation extends IStrippedState { origin_server_ts: number; content: { diff --git a/src/ReEmitter.ts b/src/ReEmitter.ts index 03c13dd602e..5a352b8f077 100644 --- a/src/ReEmitter.ts +++ b/src/ReEmitter.ts @@ -16,16 +16,15 @@ See the License for the specific language governing permissions and limitations under the License. */ +// eslint-disable-next-line no-restricted-imports import { EventEmitter } from "events"; -export class ReEmitter { - private target: EventEmitter; +import { ListenerMap, TypedEventEmitter } from "./models/typed-event-emitter"; - constructor(target: EventEmitter) { - this.target = target; - } +export class ReEmitter { + constructor(private readonly target: EventEmitter) {} - reEmit(source: EventEmitter, eventNames: string[]) { + public reEmit(source: EventEmitter, eventNames: string[]): void { for (const eventName of eventNames) { // We include the source as the last argument for event handlers which may need it, // such as read receipt listeners on the client class which won't have the context @@ -48,3 +47,19 @@ export class ReEmitter { } } } + +export class TypedReEmitter< + Events extends string, + Arguments extends ListenerMap, +> extends ReEmitter { + constructor(target: TypedEventEmitter) { + super(target); + } + + public reEmit( + source: TypedEventEmitter, + eventNames: T[], + ): void { + super.reEmit(source, eventNames); + } +} diff --git a/src/browser-index.js b/src/browser-index.js index b82e829812d..3e3627fa9d8 100644 --- a/src/browser-index.js +++ b/src/browser-index.js @@ -19,6 +19,10 @@ import queryString from "qs"; import * as matrixcs from "./matrix"; +if (matrixcs.getRequest()) { + 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 diff --git a/src/client.ts b/src/client.ts index 3cc5dfa6fb7..b56c2052bb9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,15 +19,22 @@ limitations under the License. * @module client */ -import { EventEmitter } from "events"; import { EmoteEvent, IPartialEvent, MessageEvent, NoticeEvent } from "matrix-events-sdk"; import { ISyncStateData, SyncApi, SyncState } from "./sync"; -import { EventStatus, IContent, IDecryptOptions, IEvent, MatrixEvent } from "./models/event"; +import { + EventStatus, + IContent, + IDecryptOptions, + IEvent, + MatrixEvent, + MatrixEventEvent, + MatrixEventHandlerMap, +} from "./models/event"; import { StubStore } from "./store/stub"; -import { createNewMatrixCall, MatrixCall } from "./webrtc/call"; +import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall } from "./webrtc/call"; import { Filter, IFilterDefinition } from "./filter"; -import { CallEventHandler } from './webrtc/callEventHandler'; +import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler'; import * as utils from './utils'; import { sleep } from './utils'; import { Group } from "./models/group"; @@ -37,12 +44,12 @@ import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery"; import * as olmlib from "./crypto/olmlib"; import { decodeBase64, encodeBase64 } from "./crypto/olmlib"; import { IExportedDevice as IOlmDevice } from "./crypto/OlmDevice"; -import { ReEmitter } from './ReEmitter'; +import { TypedReEmitter } from './ReEmitter'; import { IRoomEncryption, RoomList } from './crypto/RoomList'; import { logger } from './logger'; import { SERVICE_TYPES } from './service-types'; import { - FileType, + FileType, HttpApiEvent, HttpApiEventHandlerMap, IHttpOpts, IUpload, MatrixError, @@ -58,6 +65,8 @@ import { } from "./http-api"; import { Crypto, + CryptoEvent, + CryptoEventHandlerMap, fixBackupKey, IBootstrapCrossSigningOpts, ICheckOwnCrossSigningTrustOpts, @@ -68,7 +77,7 @@ import { import { DeviceInfo, IDevice } from "./crypto/deviceinfo"; import { decodeRecoveryKey } from './crypto/recoverykey'; import { keyFromAuthData } from './crypto/key_passphrase'; -import { User } from "./models/user"; +import { User, UserEvent, UserEventHandlerMap } from "./models/user"; import { getHttpUriForMxc } from "./content-repo"; import { SearchResult } from "./models/search-result"; import { @@ -88,7 +97,20 @@ import { } from "./crypto/keybackup"; import { IIdentityServerProvider } from "./@types/IIdentityServerProvider"; import { MatrixScheduler } from "./scheduler"; -import { IAuthData, ICryptoCallbacks, IMinimalEvent, IRoomEvent, IStateEvent, NotificationCountType } from "./matrix"; +import { + IAuthData, + ICryptoCallbacks, + IMinimalEvent, + IRoomEvent, + IStateEvent, + NotificationCountType, + RoomEvent, + RoomEventHandlerMap, + RoomMemberEvent, + RoomMemberEventHandlerMap, + RoomStateEvent, + RoomStateEventHandlerMap, +} from "./matrix"; import { CrossSigningKey, IAddSecretStorageKeyOpts, @@ -149,12 +171,13 @@ import { SearchOrderBy, } from "./@types/search"; import { ISynapseAdminDeactivateResponse, ISynapseAdminWhoisResponse } from "./@types/synapse"; -import { IHierarchyRoom, ISpaceSummaryEvent, ISpaceSummaryRoom } from "./@types/spaces"; +import { IHierarchyRoom } from "./@types/spaces"; import { IPusher, IPusherRequest, IPushRules, PushRuleAction, PushRuleKind, RuleId } from "./@types/PushRules"; import { IThreepid } from "./@types/threepids"; import { CryptoStore } from "./crypto/store/base"; import { MediaHandler } from "./webrtc/mediaHandler"; import { IRefreshTokenResponse } from "./@types/auth"; +import { TypedEventEmitter } from "./models/typed-event-emitter"; export type Store = IStore; export type SessionStore = WebStorageSessionStore; @@ -453,7 +476,7 @@ export interface ISignedKey { } export type KeySignatures = Record>; -interface IUploadKeySignaturesResponse { +export interface IUploadKeySignaturesResponse { failures: Record; } @@ -747,15 +770,109 @@ interface ITimestampToEventResponse { // Probably not the most graceful solution but does a good enough job for now const EVENT_ID_PREFIX = "$"; +export enum ClientEvent { + Sync = "sync", + Event = "event", + ToDeviceEvent = "toDeviceEvent", + AccountData = "accountData", + Room = "Room", + DeleteRoom = "deleteRoom", + SyncUnexpectedError = "sync.unexpectedError", + ClientWellKnown = "WellKnown.client", + /* @deprecated */ + Group = "Group", + // The following enum members are both deprecated and in the wrong place, Groups haven't been TSified + GroupProfile = "Group.profile", + GroupMyMembership = "Group.myMembership", +} + +type RoomEvents = RoomEvent.Name + | RoomEvent.Redaction + | RoomEvent.RedactionCancelled + | RoomEvent.Receipt + | RoomEvent.Tags + | RoomEvent.LocalEchoUpdated + | RoomEvent.AccountData + | RoomEvent.MyMembership + | RoomEvent.Timeline + | RoomEvent.TimelineReset; + +type RoomStateEvents = RoomStateEvent.Events + | RoomStateEvent.Members + | RoomStateEvent.NewMember + | RoomStateEvent.Update + ; + +type CryptoEvents = CryptoEvent.KeySignatureUploadFailure + | CryptoEvent.KeyBackupStatus + | CryptoEvent.KeyBackupFailed + | CryptoEvent.KeyBackupSessionsRemaining + | CryptoEvent.RoomKeyRequest + | CryptoEvent.RoomKeyRequestCancellation + | CryptoEvent.VerificationRequest + | CryptoEvent.DeviceVerificationChanged + | CryptoEvent.UserTrustStatusChanged + | CryptoEvent.KeysChanged + | CryptoEvent.Warning + | CryptoEvent.DevicesUpdated + | CryptoEvent.WillUpdateDevices; + +type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange; + +type RoomMemberEvents = RoomMemberEvent.Name + | RoomMemberEvent.Typing + | RoomMemberEvent.PowerLevel + | RoomMemberEvent.Membership; + +type UserEvents = UserEvent.AvatarUrl + | UserEvent.DisplayName + | UserEvent.Presence + | UserEvent.CurrentlyActive + | UserEvent.LastPresenceTs; + +type EmittedEvents = ClientEvent + | RoomEvents + | RoomStateEvents + | CryptoEvents + | MatrixEventEvents + | RoomMemberEvents + | UserEvents + | CallEvent // re-emitted by call.ts using Object.values + | CallEventHandlerEvent.Incoming + | HttpApiEvent.SessionLoggedOut + | HttpApiEvent.NoConsent; + +export type ClientEventHandlerMap = { + [ClientEvent.Sync]: (state: SyncState, lastState?: SyncState, data?: ISyncStateData) => void; + [ClientEvent.Event]: (event: MatrixEvent) => void; + [ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void; + [ClientEvent.AccountData]: (event: MatrixEvent, lastEvent?: MatrixEvent) => void; + [ClientEvent.Room]: (room: Room) => void; + [ClientEvent.DeleteRoom]: (roomId: string) => void; + [ClientEvent.SyncUnexpectedError]: (error: Error) => void; + [ClientEvent.ClientWellKnown]: (data: IClientWellKnown) => void; + [ClientEvent.Group]: (group: Group) => void; + [ClientEvent.GroupProfile]: (group: Group) => void; + [ClientEvent.GroupMyMembership]: (group: Group) => void; +} & RoomEventHandlerMap + & RoomStateEventHandlerMap + & CryptoEventHandlerMap + & MatrixEventHandlerMap + & RoomMemberEventHandlerMap + & UserEventHandlerMap + & CallEventHandlerEventHandlerMap + & CallEventHandlerMap + & HttpApiEventHandlerMap; + /** * Represents a Matrix Client. Only directly construct this if you want to use * custom modules. Normally, {@link createClient} should be used * as it specifies 'sensible' defaults for these modules. */ -export class MatrixClient extends EventEmitter { +export class MatrixClient extends TypedEventEmitter { public static readonly RESTORE_BACKUP_ERROR_BAD_KEY = 'RESTORE_BACKUP_ERROR_BAD_KEY'; - public reEmitter = new ReEmitter(this); + public reEmitter = new TypedReEmitter(this); public olmVersion: [number, number, number] = null; // populated after initCrypto public usingExternalCrypto = false; public store: Store; @@ -817,7 +934,7 @@ export class MatrixClient extends EventEmitter { protected checkTurnServersIntervalID: number; protected exportedOlmDeviceToImport: IOlmDevice; protected txnCtr = 0; - protected mediaHandler = new MediaHandler(); + protected mediaHandler = new MediaHandler(this); protected pendingEventEncryption = new Map>(); constructor(opts: IMatrixClientCreateOpts) { @@ -836,7 +953,7 @@ export class MatrixClient extends EventEmitter { const userId = opts.userId || null; this.credentials = { userId }; - this.http = new MatrixHttpApi(this, { + this.http = new MatrixHttpApi(this as ConstructorParameters[0], { baseUrl: opts.baseUrl, idBaseUrl: opts.idBaseUrl, accessToken: opts.accessToken, @@ -897,7 +1014,7 @@ export class MatrixClient extends EventEmitter { // Start listening for calls after the initial sync is done // We do not need to backfill the call event buffer // with encrypted events that might never get decrypted - this.on("sync", this.startCallEventHandler); + this.on(ClientEvent.Sync, this.startCallEventHandler); } this.timelineSupport = Boolean(opts.timelineSupport); @@ -922,7 +1039,7 @@ export class MatrixClient extends EventEmitter { // actions for themselves, so we have to kinda help them out when they are encrypted. // We do this so that push rules are correctly executed on events in their decrypted // state, such as highlights when the user's name is mentioned. - this.on("Event.decrypted", (event) => { + this.on(MatrixEventEvent.Decrypted, (event) => { const oldActions = event.getPushActions(); const actions = this.getPushActionsForEvent(event, true); @@ -957,7 +1074,7 @@ export class MatrixClient extends EventEmitter { // Like above, we have to listen for read receipts from ourselves in order to // correctly handle notification counts on encrypted rooms. // This fixes https://github.com/vector-im/element-web/issues/9421 - this.on("Room.receipt", (event, room) => { + this.on(RoomEvent.Receipt, (event, room) => { if (room && this.isRoomEncrypted(room.roomId)) { // Figure out if we've read something or if it's just informational const content = event.getContent(); @@ -992,7 +1109,7 @@ export class MatrixClient extends EventEmitter { // Note: we don't need to handle 'total' notifications because the counts // will come from the server. - room.setUnreadNotificationCount("highlight", highlightCount); + room.setUnreadNotificationCount(NotificationCountType.Highlight, highlightCount); } }); } @@ -1557,16 +1674,16 @@ export class MatrixClient extends EventEmitter { ); this.reEmitter.reEmit(crypto, [ - "crypto.keyBackupFailed", - "crypto.keyBackupSessionsRemaining", - "crypto.roomKeyRequest", - "crypto.roomKeyRequestCancellation", - "crypto.warning", - "crypto.devicesUpdated", - "crypto.willUpdateDevices", - "deviceVerificationChanged", - "userTrustStatusChanged", - "crossSigning.keysChanged", + CryptoEvent.KeyBackupFailed, + CryptoEvent.KeyBackupSessionsRemaining, + CryptoEvent.RoomKeyRequest, + CryptoEvent.RoomKeyRequestCancellation, + CryptoEvent.Warning, + CryptoEvent.DevicesUpdated, + CryptoEvent.WillUpdateDevices, + CryptoEvent.DeviceVerificationChanged, + CryptoEvent.UserTrustStatusChanged, + CryptoEvent.KeysChanged, ]); logger.log("Crypto: initialising crypto object..."); @@ -1578,9 +1695,8 @@ export class MatrixClient extends EventEmitter { this.olmVersion = Crypto.getOlmVersion(); - // if crypto initialisation was successful, tell it to attach its event - // handlers. - crypto.registerEventHandlers(this); + // if crypto initialisation was successful, tell it to attach its event handlers. + crypto.registerEventHandlers(this as Parameters[0]); this.crypto = crypto; } @@ -1820,7 +1936,7 @@ export class MatrixClient extends EventEmitter { * @returns {Verification} a verification object * @deprecated Use `requestVerification` instead. */ - public beginKeyVerification(method: string, userId: string, deviceId: string): Verification { + public beginKeyVerification(method: string, userId: string, deviceId: string): Verification { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -3154,9 +3270,9 @@ export class MatrixClient extends EventEmitter { * has been emitted. Note in particular that other events, eg. RoomState.members * will be emitted for a room before this function will return the given room. * @param {string} roomId The room ID - * @return {Room} The Room or null if it doesn't exist or there is no data store. + * @return {Room|null} The Room or null if it doesn't exist or there is no data store. */ - public getRoom(roomId: string): Room { + public getRoom(roomId: string): Room | null { return this.store.getRoom(roomId); } @@ -3602,7 +3718,7 @@ export class MatrixClient extends EventEmitter { content["m.relates_to"]["m.in_reply_to"] = { "event_id": thread.lastReply((ev: MatrixEvent) => { return ev.isThreadRelation && !ev.status; - }), + })?.getId(), }; } } @@ -3658,9 +3774,9 @@ export class MatrixClient extends EventEmitter { // then listen for the remote echo of that event so that by the time // this event does get sent, we have the correct event_id const targetId = localEvent.getAssociatedId(); - if (targetId && targetId.startsWith("~")) { + if (targetId?.startsWith("~")) { const target = room.getPendingEvents().find(e => e.getId() === targetId); - target.once("Event.localEventIdReplaced", () => { + target.once(MatrixEventEvent.LocalEventIdReplaced, () => { localEvent.updateAssociatedId(target.getId()); }); } @@ -4758,7 +4874,7 @@ export class MatrixClient extends EventEmitter { } return promise.then((response) => { this.store.removeRoom(roomId); - this.emit("deleteRoom", roomId); + this.emit(ClientEvent.DeleteRoom, roomId); return response; }); } @@ -4911,7 +5027,7 @@ export class MatrixClient extends EventEmitter { const user = this.getUser(this.getUserId()); if (user) { user.displayName = name; - user.emit("User.displayName", user.events.presence, user); + user.emit(UserEvent.DisplayName, user.events.presence, user); } return prom; } @@ -4928,7 +5044,7 @@ export class MatrixClient extends EventEmitter { const user = this.getUser(this.getUserId()); if (user) { user.avatarUrl = url; - user.emit("User.avatarUrl", user.events.presence, user); + user.emit(UserEvent.AvatarUrl, user.events.presence, user); } return prom; } @@ -5081,7 +5197,7 @@ export class MatrixClient extends EventEmitter { const [timelineEvents, threadedEvents] = this.partitionThreadedEvents(matrixEvents); room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline()); - await this.processThreadEvents(room, threadedEvents); + await this.processThreadEvents(room, threadedEvents, true); room.oldState.paginationToken = res.end; if (res.chunk.length === 0) { @@ -5192,7 +5308,7 @@ export class MatrixClient extends EventEmitter { const [timelineEvents, threadedEvents] = this.partitionThreadedEvents(matrixEvents); timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start); - await this.processThreadEvents(timelineSet.room, threadedEvents); + await this.processThreadEvents(timelineSet.room, threadedEvents, true); // there is no guarantee that the event ended up in "timeline" (we // might have switched to a neighbouring timeline) - so check the @@ -5325,7 +5441,7 @@ export class MatrixClient extends EventEmitter { const timelineSet = eventTimeline.getTimelineSet(); timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token); - await this.processThreadEvents(timelineSet.room, threadedEvents); + await this.processThreadEvents(timelineSet.room, threadedEvents, 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 @@ -5363,7 +5479,7 @@ export class MatrixClient extends EventEmitter { eventTimeline.getTimelineSet() .addEventsToTimeline(timelineEvents, backwards, eventTimeline, token); - await this.processThreadEvents(room, threadedEvents); + await this.processThreadEvents(room, threadedEvents, 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 @@ -6098,7 +6214,7 @@ export class MatrixClient extends EventEmitter { private startCallEventHandler = (): void => { if (this.isInitialSyncComplete()) { this.callEventHandler.start(); - this.off("sync", this.startCallEventHandler); + this.off(ClientEvent.Sync, this.startCallEventHandler); } }; @@ -6246,7 +6362,7 @@ export class MatrixClient extends EventEmitter { // it absorbs errors and returns `{}`. this.clientWellKnownPromise = AutoDiscovery.getRawClientConfig(this.getDomain()); this.clientWellKnown = await this.clientWellKnownPromise; - this.emit("WellKnown.client", this.clientWellKnown); + this.emit(ClientEvent.ClientWellKnown, this.clientWellKnown); } public getClientWellKnown(): IClientWellKnown { @@ -6484,8 +6600,8 @@ export class MatrixClient extends EventEmitter { public async relations( roomId: string, eventId: string, - relationType: RelationType | string | null, - eventType: EventType | string | null, + relationType?: RelationType | string | null, + eventType?: EventType | string | null, opts: IRelationsRequestOpts = {}, ): Promise<{ originalEvent: MatrixEvent; @@ -6508,12 +6624,10 @@ export class MatrixClient extends EventEmitter { let events = result.chunk.map(mapper); if (fetchedEventType === EventType.RoomMessageEncrypted) { const allEvents = originalEvent ? events.concat(originalEvent) : events; - await Promise.all(allEvents.map(e => { - if (e.isEncrypted()) { - return new Promise(resolve => e.once("Event.decrypted", resolve)); - } - })); - events = events.filter(e => e.getType() === eventType); + await Promise.all(allEvents.map(e => this.decryptEventIfNeeded(e))); + if (eventType !== null) { + events = events.filter(e => e.getType() === eventType); + } } if (originalEvent && relationType === RelationType.Replace) { events = events.filter(e => e.getSender() === originalEvent.getSender()); @@ -7034,8 +7148,16 @@ export class MatrixClient extends EventEmitter { const queryString = utils.encodeParams(opts as Record); let templatedUrl = "/rooms/$roomId/relations/$eventId"; - if (relationType !== null) templatedUrl += "/$relationType"; - if (eventType !== null) templatedUrl += "/$eventType"; + if (relationType !== null) { + templatedUrl += "/$relationType"; + if (eventType !== null) { + templatedUrl += "/$eventType"; + } + } else if (eventType !== null) { + logger.warn(`eventType: ${eventType} ignored when fetching + relations as relationType is null`); + eventType = null; + } const path = utils.encodeUri( templatedUrl + "?" + queryString, { @@ -8573,40 +8695,6 @@ export class MatrixClient extends EventEmitter { return this.http.authedRequest(undefined, Method.Post, path, null, { score, reason }); } - /** - * Fetches or paginates a summary of a space as defined by an initial version of MSC2946 - * @param {string} roomId The ID of the space-room to use as the root of the summary. - * @param {number?} maxRoomsPerSpace The maximum number of rooms to return per subspace. - * @param {boolean?} suggestedOnly Whether to only return rooms with suggested=true. - * @param {boolean?} autoJoinOnly Whether to only return rooms with auto_join=true. - * @param {number?} limit The maximum number of rooms to return in total. - * @param {string?} batch The opaque token to paginate a previous summary request. - * @returns {Promise} the response, with next_token, rooms fields. - * @deprecated in favour of `getRoomHierarchy` due to the MSC changing paths. - */ - public getSpaceSummary( - roomId: string, - maxRoomsPerSpace?: number, - suggestedOnly?: boolean, - autoJoinOnly?: boolean, - limit?: number, - batch?: string, - ): Promise<{rooms: ISpaceSummaryRoom[], events: ISpaceSummaryEvent[]}> { - const path = utils.encodeUri("/rooms/$roomId/spaces", { - $roomId: roomId, - }); - - return this.http.authedRequest(undefined, Method.Post, path, null, { - max_rooms_per_space: maxRoomsPerSpace, - suggested_only: suggestedOnly, - auto_join_only: autoJoinOnly, - limit, - batch, - }, { - prefix: "/_matrix/client/unstable/org.matrix.msc2946", - }); - } - /** * Fetches or paginates a room hierarchy as defined by MSC2946. * Falls back gracefully to sourcing its data from `getSpaceSummary` if this API is not yet supported by the server. @@ -8630,37 +8718,19 @@ export class MatrixClient extends EventEmitter { const queryParams: Record = { suggested_only: String(suggestedOnly), + max_depth: maxDepth?.toString(), + from: fromToken, + limit: limit?.toString(), }; - if (limit !== undefined) { - queryParams["limit"] = limit.toString(); - } - if (maxDepth !== undefined) { - queryParams["max_depth"] = maxDepth.toString(); - } - if (fromToken !== undefined) { - queryParams["from"] = fromToken; - } - return this.http.authedRequest(undefined, Method.Get, path, queryParams, undefined, { - prefix: "/_matrix/client/unstable/org.matrix.msc2946", + prefix: PREFIX_V1, }).catch(e => { if (e.errcode === "M_UNRECOGNIZED") { - // fall back to the older space summary API as it exposes the same data just in a different shape. - return this.getSpaceSummary(roomId, undefined, suggestedOnly, undefined, limit) - .then(({ rooms, events }) => { - // Translate response from `/spaces` to that we expect in this API. - const roomMap = new Map(rooms.map(r => { - return [r.room_id, { ...r, children_state: [] }]; - })); - events.forEach(e => { - roomMap.get(e.room_id)?.children_state.push(e); - }); - - return { - rooms: Array.from(roomMap.values()), - }; - }); + // fall back to the prefixed hierarchy API. + return this.http.authedRequest(undefined, Method.Get, path, queryParams, undefined, { + prefix: "/_matrix/client/unstable/org.matrix.msc2946", + }); } throw e; @@ -9136,6 +9206,14 @@ export class MatrixClient extends EventEmitter { shouldLiveInThread: boolean; threadId?: string; } { + if (event.isThreadRoot) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: true, + threadId: event.getId(), + }; + } + // A thread relation is always only shown in a thread if (event.isThreadRelation) { return { @@ -9216,10 +9294,13 @@ export class MatrixClient extends EventEmitter { /** * @experimental */ - public async processThreadEvents(room: Room, threadedEvents: MatrixEvent[]): Promise { - threadedEvents.sort((a, b) => a.getTs() - b.getTs()); + public async processThreadEvents( + room: Room, + threadedEvents: MatrixEvent[], + toStartOfTimeline: boolean, + ): Promise { for (const event of threadedEvents) { - await room.addThreadedEvent(event); + await room.addThreadedEvent(event, toStartOfTimeline); } } diff --git a/src/content-helpers.ts b/src/content-helpers.ts index 6621520a717..89955bbaee9 100644 --- a/src/content-helpers.ts +++ b/src/content-helpers.ts @@ -20,8 +20,8 @@ import { MsgType } from "./@types/event"; import { TEXT_NODE_TYPE } from "./@types/extensible_events"; import { ASSET_NODE_TYPE, - ASSET_TYPE_SELF, ILocationContent, + LocationAssetType, LOCATION_EVENT_TYPE, TIMESTAMP_NODE_TYPE, } from "./@types/location"; @@ -121,7 +121,7 @@ export function makeLocationContent( uri: string, ts: number, description?: string, - assetType?: string, + assetType?: LocationAssetType, ): ILocationContent { return { "body": text, @@ -132,7 +132,7 @@ export function makeLocationContent( description, }, [ASSET_NODE_TYPE.name]: { - type: assetType ?? ASSET_TYPE_SELF, + type: assetType ?? LocationAssetType.Self, }, [TEXT_NODE_TYPE.name]: text, [TIMESTAMP_NODE_TYPE.name]: ts, diff --git a/src/crypto/CrossSigning.ts b/src/crypto/CrossSigning.ts index 077d705b846..21dd0ee1623 100644 --- a/src/crypto/CrossSigning.ts +++ b/src/crypto/CrossSigning.ts @@ -19,7 +19,6 @@ limitations under the License. * @module crypto/CrossSigning */ -import { EventEmitter } from 'events'; import { PkSigning } from "@matrix-org/olm"; import { decodeBase64, encodeBase64, pkSign, pkVerify } from './olmlib'; @@ -55,7 +54,7 @@ export interface ICrossSigningInfo { crossSigningVerifiedBefore: boolean; } -export class CrossSigningInfo extends EventEmitter { +export class CrossSigningInfo { public keys: Record = {}; public firstUse = true; // This tracks whether we've ever verified this user with any identity. @@ -79,9 +78,7 @@ export class CrossSigningInfo extends EventEmitter { public readonly userId: string, private callbacks: ICryptoCallbacks = {}, private cacheCallbacks: ICacheCallbacks = {}, - ) { - super(); - } + ) {} public static fromStorage(obj: ICrossSigningInfo, userId: string): CrossSigningInfo { const res = new CrossSigningInfo(userId); diff --git a/src/crypto/DeviceList.ts b/src/crypto/DeviceList.ts index 6e951263cab..1de1f989496 100644 --- a/src/crypto/DeviceList.ts +++ b/src/crypto/DeviceList.ts @@ -20,8 +20,6 @@ limitations under the License. * Manages the list of other users' devices */ -import { EventEmitter } from 'events'; - import { logger } from '../logger'; import { DeviceInfo, IDevice } from './deviceinfo'; import { CrossSigningInfo, ICrossSigningInfo } from './CrossSigning'; @@ -31,6 +29,8 @@ import { chunkPromises, defer, IDeferred, sleep } from '../utils'; import { IDownloadKeyResult, MatrixClient } from "../client"; import { OlmDevice } from "./OlmDevice"; import { CryptoStore } from "./store/base"; +import { TypedEventEmitter } from "../models/typed-event-emitter"; +import { CryptoEvent, CryptoEventHandlerMap } from "./index"; /* State transition diagram for DeviceList.deviceTrackingStatus * @@ -62,10 +62,12 @@ export enum TrackingStatus { export type DeviceInfoMap = Record>; +type EmittedEvents = CryptoEvent.WillUpdateDevices | CryptoEvent.DevicesUpdated | CryptoEvent.UserCrossSigningUpdated; + /** * @alias module:crypto/DeviceList */ -export class DeviceList extends EventEmitter { +export class DeviceList extends TypedEventEmitter { private devices: { [userId: string]: { [deviceId: string]: IDevice } } = {}; public crossSigningInfo: { [userId: string]: ICrossSigningInfo } = {}; @@ -634,7 +636,7 @@ export class DeviceList extends EventEmitter { }); const finished = (success: boolean): void => { - this.emit("crypto.willUpdateDevices", users, !this.hasFetched); + this.emit(CryptoEvent.WillUpdateDevices, users, !this.hasFetched); users.forEach((u) => { this.dirty = true; @@ -659,7 +661,7 @@ export class DeviceList extends EventEmitter { } }); this.saveIfDirty(); - this.emit("crypto.devicesUpdated", users, !this.hasFetched); + this.emit(CryptoEvent.DevicesUpdated, users, !this.hasFetched); this.hasFetched = true; }; @@ -867,7 +869,7 @@ class DeviceListUpdateSerialiser { // NB. Unlike most events in the js-sdk, this one is internal to the // js-sdk and is not re-emitted - this.deviceList.emit('userCrossSigningUpdated', userId); + this.deviceList.emit(CryptoEvent.UserCrossSigningUpdated, userId); } } } diff --git a/src/crypto/EncryptionSetup.ts b/src/crypto/EncryptionSetup.ts index 27bcf7d780d..61ba34eaf99 100644 --- a/src/crypto/EncryptionSetup.ts +++ b/src/crypto/EncryptionSetup.ts @@ -14,17 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from "events"; - 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 { Crypto, IBootstrapCrossSigningOpts } from "./index"; -import { CrossSigningKeys, ICrossSigningKey, ICryptoCallbacks, ISignedKey, KeySignatures } from "../matrix"; +import { + ClientEvent, + CrossSigningKeys, + ClientEventHandlerMap, + ICrossSigningKey, + ICryptoCallbacks, + ISignedKey, + KeySignatures, +} from "../matrix"; import { ISecretStorageKeyInfo } from "./api"; import { IKeyBackupInfo } from "./keybackup"; +import { TypedEventEmitter } from "../models/typed-event-emitter"; +import { IAccountDataClient } from "./SecretStorage"; interface ICrossSigningKeys { authUpload: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"]; @@ -256,7 +264,10 @@ export class EncryptionSetupOperation { * Catches account data set by SecretStorage during bootstrapping by * implementing the methods related to account data in MatrixClient */ -class AccountDataClientAdapter extends EventEmitter { +class AccountDataClientAdapter + extends TypedEventEmitter + implements IAccountDataClient { + // public readonly values = new Map(); /** @@ -303,7 +314,7 @@ class AccountDataClientAdapter extends EventEmitter { // and it seems to rely on this. return Promise.resolve().then(() => { const event = new MatrixEvent({ type, content }); - this.emit("accountData", event, lastEvent); + this.emit(ClientEvent.AccountData, event, lastEvent); return {}; }); } diff --git a/src/crypto/SecretStorage.ts b/src/crypto/SecretStorage.ts index f3cdb8683f3..b0c7891d0d6 100644 --- a/src/crypto/SecretStorage.ts +++ b/src/crypto/SecretStorage.ts @@ -14,15 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from 'stream'; - import { logger } from '../logger'; import * as olmlib from './olmlib'; +import { encodeBase64 } from './olmlib'; import { randomString } from '../randomstring'; -import { encryptAES, decryptAES, IEncryptedPayload, calculateKeyCheck } from './aes'; -import { encodeBase64 } from "./olmlib"; -import { ICryptoCallbacks, MatrixClient, MatrixEvent } from '../matrix'; +import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from './aes'; +import { ClientEvent, ICryptoCallbacks, MatrixEvent } from '../matrix'; +import { ClientEventHandlerMap, MatrixClient } from "../client"; import { IAddSecretStorageKeyOpts, ISecretStorageKeyInfo } from './api'; +import { TypedEventEmitter } from '../models/typed-event-emitter'; export const SECRET_STORAGE_ALGORITHM_V1_AES = "m.secret_storage.v1.aes-hmac-sha2"; @@ -36,7 +36,7 @@ export interface ISecretRequest { cancel: (reason: string) => void; } -export interface IAccountDataClient extends EventEmitter { +export interface IAccountDataClient extends TypedEventEmitter { // Subset of MatrixClient (which also uses any for the event content) getAccountDataFromServer: (eventType: string) => Promise; getAccountData: (eventType: string) => MatrixEvent; @@ -98,17 +98,17 @@ export class SecretStorage { ev.getType() === 'm.secret_storage.default_key' && ev.getContent().key === keyId ) { - this.accountDataAdapter.removeListener('accountData', listener); + this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener); resolve(); } }; - this.accountDataAdapter.on('accountData', listener); + this.accountDataAdapter.on(ClientEvent.AccountData, listener); this.accountDataAdapter.setAccountData( 'm.secret_storage.default_key', { key: keyId }, ).catch(e => { - this.accountDataAdapter.removeListener('accountData', listener); + this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener); reject(e); }); }); diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index 3577fade720..f3a6824d140 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -26,14 +26,13 @@ import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib"; import { DeviceInfo } from "./deviceinfo"; import { DeviceTrustLevel } from './CrossSigning'; import { keyFromPassphrase } from './key_passphrase'; -import { sleep } from "../utils"; +import { getCrypto, sleep } from "../utils"; import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; import { encodeRecoveryKey } from './recoverykey'; -import { encryptAES, decryptAES, calculateKeyCheck } from './aes'; -import { getCrypto } from '../utils'; -import { ICurve25519AuthData, IAes256AuthData, IKeyBackupInfo, IKeyBackupSession } from "./keybackup"; +import { calculateKeyCheck, decryptAES, encryptAES } from './aes'; +import { IAes256AuthData, ICurve25519AuthData, IKeyBackupInfo, IKeyBackupSession } from "./keybackup"; import { UnstableValue } from "../NamespacedValue"; -import { IMegolmSessionData } from "./index"; +import { CryptoEvent, IMegolmSessionData } from "./index"; const KEY_BACKUP_KEYS_PER_REQUEST = 200; @@ -155,7 +154,7 @@ export class BackupManager { this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey); - this.baseApis.emit('crypto.keyBackupStatus', true); + this.baseApis.emit(CryptoEvent.KeyBackupStatus, true); // There may be keys left over from a partially completed backup, so // schedule a send to check. @@ -173,7 +172,7 @@ export class BackupManager { this.backupInfo = undefined; - this.baseApis.emit('crypto.keyBackupStatus', false); + this.baseApis.emit(CryptoEvent.KeyBackupStatus, false); } public getKeyBackupEnabled(): boolean | null { @@ -458,7 +457,7 @@ export class BackupManager { await this.checkKeyBackup(); // Backup version has changed or this backup version // has been deleted - this.baseApis.crypto.emit("crypto.keyBackupFailed", err.data.errcode); + this.baseApis.crypto.emit(CryptoEvent.KeyBackupFailed, err.data.errcode); throw err; } } @@ -487,7 +486,7 @@ export class BackupManager { } let remaining = await this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup(); - this.baseApis.crypto.emit("crypto.keyBackupSessionsRemaining", remaining); + this.baseApis.crypto.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining); const rooms: IKeyBackup["rooms"] = {}; for (const session of sessions) { @@ -524,7 +523,7 @@ export class BackupManager { await this.baseApis.crypto.cryptoStore.unmarkSessionsNeedingBackup(sessions); remaining = await this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup(); - this.baseApis.crypto.emit("crypto.keyBackupSessionsRemaining", remaining); + this.baseApis.crypto.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining); return sessions.length; } @@ -580,7 +579,7 @@ export class BackupManager { ); const remaining = await this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup(); - this.baseApis.emit("crypto.keyBackupSessionsRemaining", remaining); + this.baseApis.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining); return remaining; } diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 03650d069ad..14771da4312 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -22,27 +22,36 @@ limitations under the License. */ import anotherjson from "another-json"; -import { EventEmitter } from 'events'; -import { ReEmitter } from '../ReEmitter'; +import { TypedReEmitter } from '../ReEmitter'; import { logger } from '../logger'; import { IExportedDevice, OlmDevice } from "./OlmDevice"; import * as olmlib from "./olmlib"; import { DeviceInfoMap, DeviceList } from "./DeviceList"; import { DeviceInfo, IDevice } from "./deviceinfo"; +import type { DecryptionAlgorithm, EncryptionAlgorithm } from "./algorithms"; import * as algorithms from "./algorithms"; import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from './CrossSigning'; import { EncryptionSetupBuilder } from "./EncryptionSetup"; import { + IAccountDataClient, + ISecretRequest, SECRET_STORAGE_ALGORITHM_V1_AES, SecretStorage, - SecretStorageKeyTuple, - ISecretRequest, SecretStorageKeyObject, + SecretStorageKeyTuple, } from './SecretStorage'; -import { IAddSecretStorageKeyOpts, ICreateSecretStorageOpts, IImportRoomKeysOpts, ISecretStorageKeyInfo } from "./api"; +import { + IAddSecretStorageKeyOpts, + ICreateSecretStorageOpts, + IEncryptedEventInfo, + IImportRoomKeysOpts, + IRecoveryKey, + ISecretStorageKeyInfo, +} from "./api"; import { OutgoingRoomKeyRequestManager } from './OutgoingRoomKeyRequestManager'; import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; +import { VerificationBase } from "./verification/Base"; import { ReciprocateQRCode, SCAN_QR_CODE_METHOD, SHOW_QR_CODE_METHOD } from './verification/QRCode'; import { SAS as SASVerification } from './verification/SAS'; import { keyFromPassphrase } from './key_passphrase'; @@ -52,21 +61,28 @@ import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChan import { ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel"; import { IllegalMethod } from "./verification/IllegalMethod"; import { KeySignatureUploadError } from "../errors"; -import { decryptAES, encryptAES, calculateKeyCheck } from './aes'; +import { calculateKeyCheck, decryptAES, encryptAES } from './aes'; import { DehydrationManager, IDeviceKeys, IOneTimeKey } from './dehydration'; import { BackupManager } from "./backup"; import { IStore } from "../store"; -import { Room } from "../models/room"; -import { RoomMember } from "../models/room-member"; -import { MatrixEvent, EventStatus, IClearEvent, IEvent } from "../models/event"; -import { MatrixClient, IKeysUploadResponse, SessionStore, ISignedKey, ICrossSigningKey } from "../client"; -import type { EncryptionAlgorithm, DecryptionAlgorithm } from "./algorithms/base"; +import { Room, RoomEvent } from "../models/room"; +import { RoomMember, RoomMemberEvent } from "../models/room-member"; +import { EventStatus, IClearEvent, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event"; +import { + ClientEvent, + ICrossSigningKey, + IKeysUploadResponse, + ISignedKey, + IUploadKeySignaturesResponse, + MatrixClient, + SessionStore, +} from "../client"; import type { IRoomEncryption, RoomList } from "./RoomList"; -import { IRecoveryKey, IEncryptedEventInfo } from "./api"; import { IKeyBackupInfo } from "./keybackup"; import { ISyncStateData } from "../sync"; import { CryptoStore } from "./store/base"; import { IVerificationChannel } from "./verification/request/Channel"; +import { TypedEventEmitter } from "../models/typed-event-emitter"; const DeviceVerification = DeviceInfo.DeviceVerification; @@ -186,7 +202,45 @@ export interface IRequestsMap { setRequestByChannel(channel: IVerificationChannel, request: VerificationRequest): void; } -export class Crypto extends EventEmitter { +export enum CryptoEvent { + DeviceVerificationChanged = "deviceVerificationChanged", + UserTrustStatusChanged = "userTrustStatusChanged", + UserCrossSigningUpdated = "userCrossSigningUpdated", + RoomKeyRequest = "crypto.roomKeyRequest", + RoomKeyRequestCancellation = "crypto.roomKeyRequestCancellation", + KeyBackupStatus = "crypto.keyBackupStatus", + KeyBackupFailed = "crypto.keyBackupFailed", + KeyBackupSessionsRemaining = "crypto.keyBackupSessionsRemaining", + KeySignatureUploadFailure = "crypto.keySignatureUploadFailure", + VerificationRequest = "crypto.verification.request", + Warning = "crypto.warning", + WillUpdateDevices = "crypto.willUpdateDevices", + DevicesUpdated = "crypto.devicesUpdated", + KeysChanged = "crossSigning.keysChanged", +} + +export type CryptoEventHandlerMap = { + [CryptoEvent.DeviceVerificationChanged]: (userId: string, deviceId: string, device: DeviceInfo) => void; + [CryptoEvent.UserTrustStatusChanged]: (userId: string, trustLevel: UserTrustLevel) => void; + [CryptoEvent.RoomKeyRequest]: (request: IncomingRoomKeyRequest) => void; + [CryptoEvent.RoomKeyRequestCancellation]: (request: IncomingRoomKeyRequestCancellation) => void; + [CryptoEvent.KeyBackupStatus]: (enabled: boolean) => void; + [CryptoEvent.KeyBackupFailed]: (errcode: string) => void; + [CryptoEvent.KeyBackupSessionsRemaining]: (remaining: number) => void; + [CryptoEvent.KeySignatureUploadFailure]: ( + failures: IUploadKeySignaturesResponse["failures"], + source: "checkOwnCrossSigningTrust" | "afterCrossSigningLocalKeyChange" | "setDeviceVerification", + upload: (opts: { shouldEmit: boolean }) => Promise + ) => void; + [CryptoEvent.VerificationRequest]: (request: VerificationRequest) => void; + [CryptoEvent.Warning]: (type: string) => void; + [CryptoEvent.KeysChanged]: (data: {}) => void; + [CryptoEvent.WillUpdateDevices]: (users: string[], initialFetch: boolean) => void; + [CryptoEvent.DevicesUpdated]: (users: string[], initialFetch: boolean) => void; + [CryptoEvent.UserCrossSigningUpdated]: (userId: string) => void; +}; + +export class Crypto extends TypedEventEmitter { /** * @return {string} The version of Olm. */ @@ -201,8 +255,8 @@ export class Crypto extends EventEmitter { public readonly dehydrationManager: DehydrationManager; public readonly secretStorage: SecretStorage; - private readonly reEmitter: ReEmitter; - private readonly verificationMethods: any; // TODO types + private readonly reEmitter: TypedReEmitter; + private readonly verificationMethods: Map; public readonly supportedAlgorithms: string[]; private readonly outgoingRoomKeyRequestManager: OutgoingRoomKeyRequestManager; private readonly toDeviceVerificationRequests: ToDeviceRequests; @@ -295,10 +349,10 @@ export class Crypto extends EventEmitter { private readonly clientStore: IStore, public readonly cryptoStore: CryptoStore, private readonly roomList: RoomList, - verificationMethods: any[], // TODO types + verificationMethods: Array, ) { super(); - this.reEmitter = new ReEmitter(this); + this.reEmitter = new TypedReEmitter(this); if (verificationMethods) { this.verificationMethods = new Map(); @@ -307,20 +361,21 @@ export class Crypto extends EventEmitter { if (defaultVerificationMethods[method]) { this.verificationMethods.set( method, - defaultVerificationMethods[method], + defaultVerificationMethods[method], ); } - } else if (method.NAME) { + } else if (method["NAME"]) { this.verificationMethods.set( - method.NAME, - method, + method["NAME"], + method as typeof VerificationBase, ); } else { logger.warn(`Excluding unknown verification method ${method}`); } } } else { - this.verificationMethods = defaultVerificationMethods; + this.verificationMethods = + new Map(Object.entries(defaultVerificationMethods)) as Map; } this.backupManager = new BackupManager(baseApis, async () => { @@ -358,8 +413,8 @@ export class Crypto extends EventEmitter { // XXX: This isn't removed at any point, but then none of the event listeners // this class sets seem to be removed at any point... :/ - this.deviceList.on('userCrossSigningUpdated', this.onDeviceListUserCrossSigningUpdated); - this.reEmitter.reEmit(this.deviceList, ["crypto.devicesUpdated", "crypto.willUpdateDevices"]); + this.deviceList.on(CryptoEvent.UserCrossSigningUpdated, this.onDeviceListUserCrossSigningUpdated); + this.reEmitter.reEmit(this.deviceList, [CryptoEvent.DevicesUpdated, CryptoEvent.WillUpdateDevices]); this.supportedAlgorithms = Object.keys(algorithms.DECRYPTION_CLASSES); @@ -375,7 +430,7 @@ export class Crypto extends EventEmitter { this.crossSigningInfo = new CrossSigningInfo(userId, cryptoCallbacks, cacheCallbacks); // Yes, we pass the client twice here: see SecretStorage - this.secretStorage = new SecretStorage(baseApis, cryptoCallbacks, baseApis); + this.secretStorage = new SecretStorage(baseApis as IAccountDataClient, cryptoCallbacks, baseApis); this.dehydrationManager = new DehydrationManager(this); // Assuming no app-supplied callback, default to getting from SSSS. @@ -487,7 +542,7 @@ export class Crypto extends EventEmitter { deviceTrust.isCrossSigningVerified() ) { const deviceObj = this.deviceList.getStoredDevice(userId, deviceId); - this.emit("deviceVerificationChanged", userId, deviceId, deviceObj); + this.emit(CryptoEvent.DeviceVerificationChanged, userId, deviceId, deviceObj); } } } @@ -1165,7 +1220,7 @@ export class Crypto extends EventEmitter { if (Object.keys(failures || []).length > 0) { if (shouldEmit) { this.baseApis.emit( - "crypto.keySignatureUploadFailure", + CryptoEvent.KeySignatureUploadFailure, failures, "afterCrossSigningLocalKeyChange", upload, // continuation @@ -1391,11 +1446,10 @@ export class Crypto extends EventEmitter { // that reset the keys this.storeTrustedSelfKeys(null); // emit cross-signing has been disabled - this.emit("crossSigning.keysChanged", {}); + this.emit(CryptoEvent.KeysChanged, {}); // as the trust for our own user has changed, // also emit an event for this - this.emit("userTrustStatusChanged", - this.userId, this.checkUserTrust(userId)); + this.emit(CryptoEvent.UserTrustStatusChanged, this.userId, this.checkUserTrust(userId)); } } else { await this.checkDeviceVerifications(userId); @@ -1410,7 +1464,7 @@ export class Crypto extends EventEmitter { this.deviceList.setRawStoredCrossSigningForUser(userId, crossSigning.toStorage()); } - this.emit("userTrustStatusChanged", userId, this.checkUserTrust(userId)); + this.emit(CryptoEvent.UserTrustStatusChanged, userId, this.checkUserTrust(userId)); } }; @@ -1567,7 +1621,7 @@ export class Crypto extends EventEmitter { if (Object.keys(failures || []).length > 0) { if (shouldEmit) { this.baseApis.emit( - "crypto.keySignatureUploadFailure", + CryptoEvent.KeySignatureUploadFailure, failures, "checkOwnCrossSigningTrust", upload, @@ -1585,10 +1639,10 @@ export class Crypto extends EventEmitter { upload({ shouldEmit: true }); } - this.emit("userTrustStatusChanged", userId, this.checkUserTrust(userId)); + this.emit(CryptoEvent.UserTrustStatusChanged, userId, this.checkUserTrust(userId)); if (masterChanged) { - this.baseApis.emit("crossSigning.keysChanged", {}); + this.emit(CryptoEvent.KeysChanged, {}); await this.afterCrossSigningLocalKeyChange(); } @@ -1675,18 +1729,14 @@ export class Crypto extends EventEmitter { * @param {external:EventEmitter} eventEmitter event source where we can register * for event notifications */ - public registerEventHandlers(eventEmitter: EventEmitter): void { - eventEmitter.on("RoomMember.membership", (event: MatrixEvent, member: RoomMember, oldMembership?: string) => { - try { - this.onRoomMembership(event, member, oldMembership); - } catch (e) { - logger.error("Error handling membership change:", e); - } - }); - - eventEmitter.on("toDeviceEvent", this.onToDeviceEvent); - eventEmitter.on("Room.timeline", this.onTimelineEvent); - eventEmitter.on("Event.decrypted", this.onTimelineEvent); + public registerEventHandlers(eventEmitter: TypedEventEmitter< + RoomMemberEvent.Membership | ClientEvent.ToDeviceEvent | RoomEvent.Timeline | MatrixEventEvent.Decrypted, + any + >): void { + eventEmitter.on(RoomMemberEvent.Membership, this.onMembership); + eventEmitter.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + eventEmitter.on(RoomEvent.Timeline, this.onTimelineEvent); + eventEmitter.on(MatrixEventEvent.Decrypted, this.onTimelineEvent); } /** Start background processes related to crypto */ @@ -2070,9 +2120,7 @@ export class Crypto extends EventEmitter { if (!this.crossSigningInfo.getId() && userId === this.crossSigningInfo.userId) { this.storeTrustedSelfKeys(xsk.keys); // This will cause our own user trust to change, so emit the event - this.emit( - "userTrustStatusChanged", this.userId, this.checkUserTrust(userId), - ); + this.emit(CryptoEvent.UserTrustStatusChanged, this.userId, this.checkUserTrust(userId)); } // Now sign the master key with our user signing key (unless it's ourself) @@ -2094,7 +2142,7 @@ export class Crypto extends EventEmitter { if (Object.keys(failures || []).length > 0) { if (shouldEmit) { this.baseApis.emit( - "crypto.keySignatureUploadFailure", + CryptoEvent.KeySignatureUploadFailure, failures, "setDeviceVerification", upload, @@ -2178,7 +2226,7 @@ export class Crypto extends EventEmitter { if (Object.keys(failures || []).length > 0) { if (shouldEmit) { this.baseApis.emit( - "crypto.keySignatureUploadFailure", + CryptoEvent.KeySignatureUploadFailure, failures, "setDeviceVerification", upload, // continuation @@ -2193,7 +2241,7 @@ export class Crypto extends EventEmitter { } const deviceObj = DeviceInfo.fromStorage(dev, deviceId); - this.emit("deviceVerificationChanged", userId, deviceId, deviceObj); + this.emit(CryptoEvent.DeviceVerificationChanged, userId, deviceId, deviceObj); return deviceObj; } @@ -3045,6 +3093,14 @@ export class Crypto extends EventEmitter { }); } + private onMembership = (event: MatrixEvent, member: RoomMember, oldMembership?: string) => { + try { + this.onRoomMembership(event, member, oldMembership); + } catch (e) { + logger.error("Error handling membership change:", e); + } + }; + private onToDeviceEvent = (event: MatrixEvent): void => { try { logger.log(`received to_device ${event.getType()} from: ` + @@ -3070,7 +3126,7 @@ export class Crypto extends EventEmitter { event.attemptDecryption(this); } // once the event has been decrypted, try again - event.once('Event.decrypted', (ev) => { + event.once(MatrixEventEvent.Decrypted, (ev) => { this.onToDeviceEvent(ev); }); } @@ -3219,15 +3275,15 @@ export class Crypto extends EventEmitter { reject(new Error("Event status set to CANCELLED.")); } }; - event.once("Event.localEventIdReplaced", eventIdListener); - event.on("Event.status", statusListener); + event.once(MatrixEventEvent.LocalEventIdReplaced, eventIdListener); + event.on(MatrixEventEvent.Status, statusListener); }); } catch (err) { logger.error("error while waiting for the verification event to be sent: " + err.message); return; } finally { - event.removeListener("Event.localEventIdReplaced", eventIdListener); - event.removeListener("Event.status", statusListener); + event.removeListener(MatrixEventEvent.LocalEventIdReplaced, eventIdListener); + event.removeListener(MatrixEventEvent.Status, statusListener); } } let request = requestsMap.getRequest(event); @@ -3254,7 +3310,7 @@ export class Crypto extends EventEmitter { !request.invalid && // check it has enough events to pass the UNSENT stage !request.observeOnly; if (shouldEmit) { - this.baseApis.emit("crypto.verification.request", request); + this.baseApis.emit(CryptoEvent.VerificationRequest, request); } } @@ -3555,7 +3611,7 @@ export class Crypto extends EventEmitter { return; } - this.emit("crypto.roomKeyRequest", req); + this.emit(CryptoEvent.RoomKeyRequest, req); } /** @@ -3574,7 +3630,7 @@ export class Crypto extends EventEmitter { // we should probably only notify the app of cancellations we told it // about, but we don't currently have a record of that, so we just pass // everything through. - this.emit("crypto.roomKeyRequestCancellation", cancellation); + this.emit(CryptoEvent.RoomKeyRequestCancellation, cancellation); } /** diff --git a/src/crypto/verification/Base.ts b/src/crypto/verification/Base.ts index a47c0960716..68e9c96fc0a 100644 --- a/src/crypto/verification/Base.ts +++ b/src/crypto/verification/Base.ts @@ -20,8 +20,6 @@ limitations under the License. * @module crypto/verification/Base */ -import { EventEmitter } from 'events'; - import { MatrixEvent } from '../../models/event'; import { logger } from '../../logger'; import { DeviceInfo } from '../deviceinfo'; @@ -30,6 +28,7 @@ import { KeysDuringVerification, requestKeysDuringVerification } from "../CrossS import { IVerificationChannel } from "./request/Channel"; import { MatrixClient } from "../../client"; import { VerificationRequest } from "./request/VerificationRequest"; +import { ListenerMap, TypedEventEmitter } from "../../models/typed-event-emitter"; const timeoutException = new Error("Verification timed out"); @@ -41,7 +40,18 @@ export class SwitchStartEventError extends Error { export type KeyVerifier = (keyId: string, device: DeviceInfo, keyInfo: string) => void; -export class VerificationBase extends EventEmitter { +export enum VerificationEvent { + Cancel = "cancel", +} + +export type VerificationEventHandlerMap = { + [VerificationEvent.Cancel]: (e: Error | MatrixEvent) => void; +}; + +export class VerificationBase< + Events extends string, + Arguments extends ListenerMap, +> extends TypedEventEmitter { private cancelled = false; private _done = false; private promise: Promise = null; @@ -261,7 +271,7 @@ export class VerificationBase extends EventEmitter { } // Also emit a 'cancel' event that the app can listen for to detect cancellation // before calling verify() - this.emit('cancel', e); + this.emit(VerificationEvent.Cancel, e); } } diff --git a/src/crypto/verification/IllegalMethod.ts b/src/crypto/verification/IllegalMethod.ts index b752d7404d3..f01364a212f 100644 --- a/src/crypto/verification/IllegalMethod.ts +++ b/src/crypto/verification/IllegalMethod.ts @@ -20,7 +20,7 @@ limitations under the License. * @module crypto/verification/IllegalMethod */ -import { VerificationBase as Base } from "./Base"; +import { VerificationBase as Base, VerificationEvent, VerificationEventHandlerMap } from "./Base"; import { IVerificationChannel } from "./request/Channel"; import { MatrixClient } from "../../client"; import { MatrixEvent } from "../../models/event"; @@ -30,7 +30,7 @@ import { VerificationRequest } from "./request/VerificationRequest"; * @class crypto/verification/IllegalMethod/IllegalMethod * @extends {module:crypto/verification/Base} */ -export class IllegalMethod extends Base { +export class IllegalMethod extends Base { public static factory( channel: IVerificationChannel, baseApis: MatrixClient, diff --git a/src/crypto/verification/QRCode.ts b/src/crypto/verification/QRCode.ts index 5b4c45ddaea..3c16c4955c9 100644 --- a/src/crypto/verification/QRCode.ts +++ b/src/crypto/verification/QRCode.ts @@ -19,7 +19,7 @@ limitations under the License. * @module crypto/verification/QRCode */ -import { VerificationBase as Base } from "./Base"; +import { VerificationBase as Base, VerificationEventHandlerMap } from "./Base"; import { newKeyMismatchError, newUserCancelledError } from './Error'; import { decodeBase64, encodeUnpaddedBase64 } from "../olmlib"; import { logger } from '../../logger'; @@ -31,15 +31,25 @@ import { MatrixEvent } from "../../models/event"; export const SHOW_QR_CODE_METHOD = "m.qr_code.show.v1"; export const SCAN_QR_CODE_METHOD = "m.qr_code.scan.v1"; +interface IReciprocateQr { + confirm(): void; + cancel(): void; +} + +export enum QrCodeEvent { + ShowReciprocateQr = "show_reciprocate_qr", +} + +type EventHandlerMap = { + [QrCodeEvent.ShowReciprocateQr]: (qr: IReciprocateQr) => void; +} & VerificationEventHandlerMap; + /** * @class crypto/verification/QRCode/ReciprocateQRCode * @extends {module:crypto/verification/Base} */ -export class ReciprocateQRCode extends Base { - public reciprocateQREvent: { - confirm(): void; - cancel(): void; - }; +export class ReciprocateQRCode extends Base { + public reciprocateQREvent: IReciprocateQr; public static factory( channel: IVerificationChannel, @@ -76,7 +86,7 @@ export class ReciprocateQRCode extends Base { confirm: resolve, cancel: () => reject(newUserCancelledError()), }; - this.emit("show_reciprocate_qr", this.reciprocateQREvent); + this.emit(QrCodeEvent.ShowReciprocateQr, this.reciprocateQREvent); }); // 3. determine key to sign / mark as trusted diff --git a/src/crypto/verification/SAS.ts b/src/crypto/verification/SAS.ts index 5582ff4f462..a3599d5dc68 100644 --- a/src/crypto/verification/SAS.ts +++ b/src/crypto/verification/SAS.ts @@ -22,7 +22,7 @@ limitations under the License. import anotherjson from 'another-json'; import { Utility, SAS as OlmSAS } from "@matrix-org/olm"; -import { VerificationBase as Base, SwitchStartEventError } from "./Base"; +import { VerificationBase as Base, SwitchStartEventError, VerificationEventHandlerMap } from "./Base"; import { errorFactory, newInvalidMessageError, @@ -232,11 +232,19 @@ function intersection(anArray: T[], aSet: Set): T[] { return anArray instanceof Array ? anArray.filter(x => aSet.has(x)) : []; } +export enum SasEvent { + ShowSas = "show_sas", +} + +type EventHandlerMap = { + [SasEvent.ShowSas]: (sas: ISasEvent) => void; +} & VerificationEventHandlerMap; + /** * @alias module:crypto/verification/SAS * @extends {module:crypto/verification/Base} */ -export class SAS extends Base { +export class SAS extends Base { private waitingForAccept: boolean; public ourSASPubKey: string; public theirSASPubKey: string; @@ -371,7 +379,7 @@ export class SAS extends Base { cancel: () => reject(newUserCancelledError()), mismatch: () => reject(newMismatchedSASError()), }; - this.emit("show_sas", this.sasEvent); + this.emit(SasEvent.ShowSas, this.sasEvent); }); [e] = await Promise.all([ @@ -447,7 +455,7 @@ export class SAS extends Base { cancel: () => reject(newUserCancelledError()), mismatch: () => reject(newMismatchedSASError()), }; - this.emit("show_sas", this.sasEvent); + this.emit(SasEvent.ShowSas, this.sasEvent); }); [e] = await Promise.all([ diff --git a/src/crypto/verification/request/VerificationRequest.ts b/src/crypto/verification/request/VerificationRequest.ts index b6c0d9ef4bb..71611558f79 100644 --- a/src/crypto/verification/request/VerificationRequest.ts +++ b/src/crypto/verification/request/VerificationRequest.ts @@ -14,8 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from 'events'; - import { logger } from '../../../logger'; import { errorFactory, @@ -29,6 +27,7 @@ import { MatrixClient } from "../../../client"; import { MatrixEvent } from "../../../models/event"; import { VerificationBase } from "../Base"; import { VerificationMethod } from "../../index"; +import { TypedEventEmitter } from "../../../models/typed-event-emitter"; // How long after the event's timestamp that the request times out const TIMEOUT_FROM_EVENT_TS = 10 * 60 * 1000; // 10 minutes @@ -76,13 +75,23 @@ interface ITransition { event?: MatrixEvent; } +export enum VerificationRequestEvent { + Change = "change", +} + +type EventHandlerMap = { + [VerificationRequestEvent.Change]: () => void; +}; + /** * State machine for verification requests. * Things that differ based on what channel is used to * send and receive verification events are put in `InRoomChannel` or `ToDeviceChannel`. * @event "change" whenever the state of the request object has changed. */ -export class VerificationRequest extends EventEmitter { +export class VerificationRequest< + C extends IVerificationChannel = IVerificationChannel, +> extends TypedEventEmitter { private eventsByUs = new Map(); private eventsByThem = new Map(); private _observeOnly = false; @@ -104,7 +113,7 @@ export class VerificationRequest; constructor( public readonly channel: C, @@ -236,7 +245,7 @@ export class VerificationRequest { return this._verifier; } @@ -410,7 +419,10 @@ export class VerificationRequest { // need to allow also when unsent in case of to_device if (!this.observeOnly && !this._verifier) { const validStartPhase = @@ -453,7 +465,7 @@ export class VerificationRequest { if (!this.observeOnly && this._phase !== PHASE_CANCELLED) { this._declining = true; - this.emit("change"); + this.emit(VerificationRequestEvent.Change); if (this._verifier) { return this._verifier.cancel(errorFactory(code, reason)()); } else { @@ -471,7 +483,7 @@ export class VerificationRequest { if (!targetDevice) { targetDevice = this.targetDevice; } diff --git a/src/event-mapper.ts b/src/event-mapper.ts index 9b938486021..53873d11333 100644 --- a/src/event-mapper.ts +++ b/src/event-mapper.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { MatrixClient } from "./client"; -import { IEvent, MatrixEvent } from "./models/event"; +import { IEvent, MatrixEvent, MatrixEventEvent } from "./models/event"; export type EventMapper = (obj: Partial) => MatrixEvent; @@ -30,10 +30,16 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event function mapper(plainOldJsObject: Partial) { const event = new MatrixEvent(plainOldJsObject); + + const room = client.getRoom(event.getRoomId()); + if (room?.threads.has(event.getId())) { + event.setThread(room.threads.get(event.getId())); + } + if (event.isEncrypted()) { if (!preventReEmit) { client.reEmitter.reEmit(event, [ - "Event.decrypted", + MatrixEventEvent.Decrypted, ]); } if (decrypt) { @@ -41,7 +47,10 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event } } if (!preventReEmit) { - client.reEmitter.reEmit(event, ["Event.replaced", "Event.visibilityChange"]); + client.reEmitter.reEmit(event, [ + MatrixEventEvent.Replaced, + MatrixEventEvent.VisibilityChange, + ]); } return event; } diff --git a/src/filter-component.ts b/src/filter-component.ts index 9ef5355587a..7d310203d03 100644 --- a/src/filter-component.ts +++ b/src/filter-component.ts @@ -15,7 +15,10 @@ limitations under the License. */ import { RelationType } from "./@types/event"; -import { UNSTABLE_FILTER_RELATION_SENDERS, UNSTABLE_FILTER_RELATION_TYPES } from "./filter"; +import { + UNSTABLE_FILTER_RELATED_BY_REL_TYPES, + UNSTABLE_FILTER_RELATED_BY_SENDERS, +} from "./filter"; import { MatrixEvent } from "./models/event"; /** @@ -48,7 +51,8 @@ export interface IFilterComponent { not_senders?: string[]; contains_url?: boolean; limit?: number; - [UNSTABLE_FILTER_RELATION_TYPES.name]?: Array; + [UNSTABLE_FILTER_RELATED_BY_SENDERS.name]?: string[]; + [UNSTABLE_FILTER_RELATED_BY_REL_TYPES.name]?: Array; } /* eslint-enable camelcase */ @@ -80,9 +84,10 @@ export class FilterComponent { // of performance // This should be improved when bundled relationships solve that problem const relationSenders = []; - if (this.userId && relations?.[RelationType.Thread]?.current_user_participated) { + if (this.userId && bundledRelationships?.[RelationType.Thread]?.current_user_participated) { relationSenders.push(this.userId); } + return this.checkFields( event.getRoomId(), event.getSender(), @@ -105,8 +110,8 @@ export class FilterComponent { senders: this.filterJson.senders || null, not_senders: this.filterJson.not_senders || [], contains_url: this.filterJson.contains_url || null, - [UNSTABLE_FILTER_RELATION_SENDERS.name]: UNSTABLE_FILTER_RELATION_SENDERS.findIn(this.filterJson), - [UNSTABLE_FILTER_RELATION_TYPES.name]: UNSTABLE_FILTER_RELATION_TYPES.findIn(this.filterJson), + [UNSTABLE_FILTER_RELATED_BY_SENDERS.name]: UNSTABLE_FILTER_RELATED_BY_SENDERS.findIn(this.filterJson), + [UNSTABLE_FILTER_RELATED_BY_REL_TYPES.name]: UNSTABLE_FILTER_RELATED_BY_REL_TYPES.findIn(this.filterJson), }; } @@ -160,14 +165,14 @@ export class FilterComponent { return false; } - const relationTypesFilter = this.filterJson[UNSTABLE_FILTER_RELATION_TYPES.name]; + const relationTypesFilter = this.filterJson[UNSTABLE_FILTER_RELATED_BY_REL_TYPES.name]; if (relationTypesFilter !== undefined) { if (!this.arrayMatchesFilter(relationTypesFilter, relationTypes)) { return false; } } - const relationSendersFilter = this.filterJson[UNSTABLE_FILTER_RELATION_SENDERS.name]; + const relationSendersFilter = this.filterJson[UNSTABLE_FILTER_RELATED_BY_SENDERS.name]; if (relationSendersFilter !== undefined) { if (!this.arrayMatchesFilter(relationSendersFilter, relationSenders)) { return false; diff --git a/src/filter.ts b/src/filter.ts index 888d82a61ee..7ceaaba577d 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -26,13 +26,13 @@ import { FilterComponent, IFilterComponent } from "./filter-component"; import { MatrixEvent } from "./models/event"; import { UnstableValue } from "./NamespacedValue"; -export const UNSTABLE_FILTER_RELATION_SENDERS = new UnstableValue( - "relation_senders", +export const UNSTABLE_FILTER_RELATED_BY_SENDERS = new UnstableValue( + "related_by_senders", "io.element.relation_senders", ); -export const UNSTABLE_FILTER_RELATION_TYPES = new UnstableValue( - "relation_types", +export const UNSTABLE_FILTER_RELATED_BY_REL_TYPES = new UnstableValue( + "related_by_rel_types", "io.element.relation_types", ); @@ -66,8 +66,8 @@ export interface IRoomEventFilter extends IFilterComponent { lazy_load_members?: boolean; include_redundant_members?: boolean; types?: Array; - [UNSTABLE_FILTER_RELATION_TYPES.name]?: Array; - [UNSTABLE_FILTER_RELATION_SENDERS.name]?: string[]; + [UNSTABLE_FILTER_RELATED_BY_REL_TYPES.name]?: Array; + [UNSTABLE_FILTER_RELATED_BY_SENDERS.name]?: string[]; } interface IStateFilter extends IRoomEventFilter {} diff --git a/src/http-api.ts b/src/http-api.ts index fd016c731e8..2879ea68159 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -21,7 +21,6 @@ limitations under the License. */ import { parse as parseContentType, ParsedMediaType } from "content-type"; -import EventEmitter from "events"; import type { IncomingHttpHeaders, IncomingMessage } from "http"; import type { Request as _Request, CoreOptions } from "request"; @@ -35,6 +34,7 @@ import { IDeferred } from "./utils"; import { Callback } from "./client"; import * as utils from "./utils"; import { logger } from './logger'; +import { TypedEventEmitter } from "./models/typed-event-emitter"; /* TODO: @@ -164,6 +164,16 @@ export enum Method { 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 @@ -192,7 +202,10 @@ export type FileType = Document | XMLHttpRequestBodyInit; export class MatrixHttpApi { private uploads: IUpload[] = []; - constructor(private eventEmitter: EventEmitter, public readonly opts: IHttpOpts) { + constructor( + private eventEmitter: TypedEventEmitter, + public readonly opts: IHttpOpts, + ) { utils.checkObjectHasKeys(opts, ["baseUrl", "request", "prefix"]); opts.onlyData = !!opts.onlyData; opts.useAuthorizationHeader = !!opts.useAuthorizationHeader; @@ -603,13 +616,9 @@ export class MatrixHttpApi { requestPromise.catch((err: MatrixError) => { if (err.errcode == 'M_UNKNOWN_TOKEN' && !requestOpts?.inhibitLogoutEmit) { - this.eventEmitter.emit("Session.logged_out", err); + this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, err); } else if (err.errcode == 'M_CONSENT_NOT_GIVEN') { - this.eventEmitter.emit( - "no_consent", - err.message, - err.data.consent_uri, - ); + this.eventEmitter.emit(HttpApiEvent.NoConsent, err.message, err.data.consent_uri); } }); diff --git a/src/index.ts b/src/index.ts index a67a567998c..faab0fed08b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,10 @@ import * as matrixcs from "./matrix"; import * as utils from "./utils"; import { logger } from './logger'; +if (matrixcs.getRequest()) { + throw new Error("Multiple matrix-js-sdk entrypoints detected!"); +} + matrixcs.request(request); try { diff --git a/src/matrix.ts b/src/matrix.ts index f43d35d728a..798f990fbce 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -48,6 +48,7 @@ export * from "./crypto/store/indexeddb-crypto-store"; export * from "./content-repo"; export * from './@types/event'; export * from './@types/PushRules'; +export * from './@types/partials'; export * from './@types/requests'; export * from './@types/search'; export * from './models/room-summary'; diff --git a/src/models/event-status.ts b/src/models/event-status.ts new file mode 100644 index 00000000000..faca97186c9 --- /dev/null +++ b/src/models/event-status.ts @@ -0,0 +1,40 @@ +/* +Copyright 2015 - 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. +*/ + +/** + * Enum for event statuses. + * @readonly + * @enum {string} + */ +export enum EventStatus { + /** The event was not sent and will no longer be retried. */ + NOT_SENT = "not_sent", + + /** The message is being encrypted */ + ENCRYPTING = "encrypting", + + /** The event is in the process of being sent. */ + SENDING = "sending", + + /** The event is in a queue waiting to be sent. */ + QUEUED = "queued", + + /** The event has been sent to the server, but we have not yet received the echo. */ + SENT = "sent", + + /** The event was cancelled before it was successfully sent. */ + CANCELLED = "cancelled", +} diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index 03408c08ba8..1fda0d977a4 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -18,16 +18,15 @@ limitations under the License. * @module models/event-timeline-set */ -import { EventEmitter } from "events"; - import { EventTimeline } from "./event-timeline"; -import { EventStatus, MatrixEvent } from "./event"; +import { EventStatus, MatrixEvent, MatrixEventEvent } from "./event"; import { logger } from '../logger'; import { Relations } from './relations'; -import { Room } from "./room"; +import { Room, RoomEvent } from "./room"; import { Filter } from "../filter"; import { EventType, RelationType } from "../@types/event"; import { RoomState } from "./room-state"; +import { TypedEventEmitter } from "./typed-event-emitter"; // var DEBUG = false; const DEBUG = true; @@ -57,7 +56,15 @@ export interface IRoomTimelineData { liveEvent?: boolean; } -export class EventTimelineSet extends EventEmitter { +type EmittedEvents = RoomEvent.Timeline | RoomEvent.TimelineReset; + +export type EventTimelineSetHandlerMap = { + [RoomEvent.Timeline]: + (event: MatrixEvent, room: Room, toStartOfTimeline: boolean, removed: boolean, data: IRoomTimelineData) => void; + [RoomEvent.TimelineReset]: (room: Room, eventTimelineSet: EventTimelineSet, resetAllTimelines: boolean) => void; +}; + +export class EventTimelineSet extends TypedEventEmitter { private readonly timelineSupport: boolean; private unstableClientRelationAggregation: boolean; private displayPendingEvents: boolean; @@ -247,7 +254,7 @@ export class EventTimelineSet extends EventEmitter { // Now we can swap the live timeline to the new one. this.liveTimeline = newTimeline; - this.emit("Room.timelineReset", this.room, this, resetAllTimelines); + this.emit(RoomEvent.TimelineReset, this.room, this, resetAllTimelines); } /** @@ -597,8 +604,7 @@ export class EventTimelineSet extends EventEmitter { timeline: timeline, liveEvent: !toStartOfTimeline && timeline == this.liveTimeline && !fromCache, }; - this.emit("Room.timeline", event, this.room, - Boolean(toStartOfTimeline), false, data); + this.emit(RoomEvent.Timeline, event, this.room, Boolean(toStartOfTimeline), false, data); } /** @@ -652,7 +658,7 @@ export class EventTimelineSet extends EventEmitter { const data = { timeline: timeline, }; - this.emit("Room.timeline", removed, this.room, undefined, true, data); + this.emit(RoomEvent.Timeline, removed, this.room, undefined, true, data); } return removed; } @@ -819,7 +825,7 @@ export class EventTimelineSet extends EventEmitter { // If the event is currently encrypted, wait until it has been decrypted. if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { - event.once("Event.decrypted", () => { + event.once(MatrixEventEvent.Decrypted, () => { this.aggregateRelations(event); }); return; diff --git a/src/models/event.ts b/src/models/event.ts index 9b04ae0996c..47def019b47 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -20,49 +20,22 @@ limitations under the License. * @module models/event */ -import { EventEmitter } from 'events'; import { ExtensibleEvent, ExtensibleEvents, Optional } from "matrix-events-sdk"; import { logger } from '../logger'; import { VerificationRequest } from "../crypto/verification/request/VerificationRequest"; -import { - EventType, - MsgType, - RelationType, - EVENT_VISIBILITY_CHANGE_TYPE, -} from "../@types/event"; +import { EVENT_VISIBILITY_CHANGE_TYPE, EventType, MsgType, RelationType } from "../@types/event"; import { Crypto, IEventDecryptionResult } from "../crypto"; import { deepSortedObjectEntries } from "../utils"; import { RoomMember } from "./room-member"; -import { Thread, ThreadEvent } from "./thread"; +import { Thread, ThreadEvent, EventHandlerMap as ThreadEventHandlerMap } from "./thread"; import { IActionsObject } from '../pushprocessor'; -import { ReEmitter } from '../ReEmitter'; +import { TypedReEmitter } from '../ReEmitter'; import { MatrixError } from "../http-api"; +import { TypedEventEmitter } from "./typed-event-emitter"; +import { EventStatus } from "./event-status"; -/** - * Enum for event statuses. - * @readonly - * @enum {string} - */ -export enum EventStatus { - /** The event was not sent and will no longer be retried. */ - NOT_SENT = "not_sent", - - /** The message is being encrypted */ - ENCRYPTING = "encrypting", - - /** The event is in the process of being sent. */ - SENDING = "sending", - - /** The event is in a queue waiting to be sent. */ - QUEUED = "queued", - - /** The event has been sent to the server, but we have not yet received the echo. */ - SENT = "sent", - - /** The event was cancelled before it was successfully sent. */ - CANCELLED = "cancelled", -} +export { EventStatus } from "./event-status"; const interns: Record = {}; function intern(str: string): string { @@ -209,7 +182,29 @@ export interface IMessageVisibilityHidden { // A singleton implementing `IMessageVisibilityVisible`. const MESSAGE_VISIBLE: IMessageVisibilityVisible = Object.freeze({ visible: true }); -export class MatrixEvent extends EventEmitter { +export enum MatrixEventEvent { + Decrypted = "Event.decrypted", + BeforeRedaction = "Event.beforeRedaction", + VisibilityChange = "Event.visibilityChange", + LocalEventIdReplaced = "Event.localEventIdReplaced", + Status = "Event.status", + Replaced = "Event.replaced", + RelationsCreated = "Event.relationsCreated", +} + +type EmittedEvents = MatrixEventEvent | ThreadEvent.Update; + +export type MatrixEventHandlerMap = { + [MatrixEventEvent.Decrypted]: (event: MatrixEvent, err?: Error) => void; + [MatrixEventEvent.BeforeRedaction]: (event: MatrixEvent, redactionEvent: MatrixEvent) => void; + [MatrixEventEvent.VisibilityChange]: (event: MatrixEvent, visible: boolean) => void; + [MatrixEventEvent.LocalEventIdReplaced]: (event: MatrixEvent) => void; + [MatrixEventEvent.Status]: (event: MatrixEvent, status: EventStatus) => void; + [MatrixEventEvent.Replaced]: (event: MatrixEvent) => void; + [MatrixEventEvent.RelationsCreated]: (relationType: string, eventType: string) => void; +} & ThreadEventHandlerMap; + +export class MatrixEvent extends TypedEventEmitter { private pushActions: IActionsObject = null; private _replacingEvent: MatrixEvent = null; private _localRedactionEvent: MatrixEvent = null; @@ -292,7 +287,7 @@ export class MatrixEvent extends EventEmitter { */ public verificationRequest: VerificationRequest = null; - private readonly reEmitter: ReEmitter; + private readonly reEmitter: TypedReEmitter; /** * Construct a Matrix Event object @@ -343,7 +338,7 @@ export class MatrixEvent extends EventEmitter { this.txnId = event.txn_id || null; this.localTimestamp = Date.now() - (this.getAge() ?? 0); - this.reEmitter = new ReEmitter(this); + this.reEmitter = new TypedReEmitter(this); } /** @@ -871,7 +866,7 @@ export class MatrixEvent extends EventEmitter { this.setPushActions(null); if (options.emit !== false) { - this.emit("Event.decrypted", this, err); + this.emit(MatrixEventEvent.Decrypted, this, err); } return; @@ -1030,7 +1025,7 @@ export class MatrixEvent extends EventEmitter { public markLocallyRedacted(redactionEvent: MatrixEvent): void { if (this._localRedactionEvent) return; - this.emit("Event.beforeRedaction", this, redactionEvent); + this.emit(MatrixEventEvent.BeforeRedaction, this, redactionEvent); this._localRedactionEvent = redactionEvent; if (!this.event.unsigned) { this.event.unsigned = {}; @@ -1068,7 +1063,7 @@ export class MatrixEvent extends EventEmitter { }); } if (change) { - this.emit("Event.visibilityChange", this, visible); + this.emit(MatrixEventEvent.VisibilityChange, this, visible); } } } @@ -1100,7 +1095,7 @@ export class MatrixEvent extends EventEmitter { this._localRedactionEvent = null; - this.emit("Event.beforeRedaction", this, redactionEvent); + this.emit(MatrixEventEvent.BeforeRedaction, this, redactionEvent); this._replacingEvent = null; // we attempt to replicate what we would see from the server if @@ -1263,7 +1258,7 @@ export class MatrixEvent extends EventEmitter { this.setStatus(null); if (this.getId() !== oldId) { // emit the event if it changed - this.emit("Event.localEventIdReplaced", this); + this.emit(MatrixEventEvent.LocalEventIdReplaced, this); } this.localTimestamp = Date.now() - this.getAge(); @@ -1286,12 +1281,12 @@ export class MatrixEvent extends EventEmitter { */ public setStatus(status: EventStatus): void { this.status = status; - this.emit("Event.status", this, status); + this.emit(MatrixEventEvent.Status, this, status); } public replaceLocalEventId(eventId: string): void { this.event.event_id = eventId; - this.emit("Event.localEventIdReplaced", this); + this.emit(MatrixEventEvent.LocalEventIdReplaced, this); } /** @@ -1340,7 +1335,7 @@ export class MatrixEvent extends EventEmitter { } if (this._replacingEvent !== newEvent) { this._replacingEvent = newEvent; - this.emit("Event.replaced", this); + this.emit(MatrixEventEvent.Replaced, this); this.invalidateExtensibleEvent(); } } @@ -1559,7 +1554,7 @@ export class MatrixEvent extends EventEmitter { public setThread(thread: Thread): void { this.thread = thread; this.setThreadId(thread.id); - this.reEmitter.reEmit(thread, [ThreadEvent.Ready, ThreadEvent.Update]); + this.reEmitter.reEmit(thread, [ThreadEvent.Update]); } /** diff --git a/src/models/group.js b/src/models/group.js index 44fae31661e..29f0fb3846c 100644 --- a/src/models/group.js +++ b/src/models/group.js @@ -20,6 +20,7 @@ limitations under the License. * @deprecated groups/communities never made it to the spec and support for them is being discontinued. */ +// eslint-disable-next-line no-restricted-imports import { EventEmitter } from "events"; import * as utils from "../utils"; diff --git a/src/models/related-relations.ts b/src/models/related-relations.ts index 55db8e51056..539f94a1cd5 100644 --- a/src/models/related-relations.ts +++ b/src/models/related-relations.ts @@ -14,8 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Relations } from "./relations"; +import { Relations, RelationsEvent, EventHandlerMap } from "./relations"; import { MatrixEvent } from "./event"; +import { Listener } from "./typed-event-emitter"; export class RelatedRelations { private relations: Relations[]; @@ -28,11 +29,11 @@ export class RelatedRelations { return this.relations.reduce((c, p) => [...c, ...p.getRelations()], []); } - public on(ev: string, fn: (...params) => void) { + public on(ev: T, fn: Listener) { this.relations.forEach(r => r.on(ev, fn)); } - public off(ev: string, fn: (...params) => void) { + public off(ev: T, fn: Listener) { this.relations.forEach(r => r.off(ev, fn)); } } diff --git a/src/models/relations.ts b/src/models/relations.ts index 29adaab6685..1bd70929700 100644 --- a/src/models/relations.ts +++ b/src/models/relations.ts @@ -14,12 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from 'events'; - -import { EventStatus, MatrixEvent, IAggregatedRelation } from './event'; +import { EventStatus, IAggregatedRelation, MatrixEvent, MatrixEventEvent } from './event'; import { Room } from './room'; import { logger } from '../logger'; import { RelationType } from "../@types/event"; +import { TypedEventEmitter } from "./typed-event-emitter"; + +export enum RelationsEvent { + Add = "Relations.add", + Remove = "Relations.remove", + Redaction = "Relations.redaction", +} + +export type EventHandlerMap = { + [RelationsEvent.Add]: (event: MatrixEvent) => void; + [RelationsEvent.Remove]: (event: MatrixEvent) => void; + [RelationsEvent.Redaction]: (event: MatrixEvent) => void; +}; /** * A container for relation events that supports easy access to common ways of @@ -29,7 +40,7 @@ import { RelationType } from "../@types/event"; * The typical way to get one of these containers is via * EventTimelineSet#getRelationsForEvent. */ -export class Relations extends EventEmitter { +export class Relations extends TypedEventEmitter { private relationEventIds = new Set(); private relations = new Set(); private annotationsByKey: Record> = {}; @@ -84,7 +95,7 @@ export class Relations extends EventEmitter { // If the event is in the process of being sent, listen for cancellation // so we can remove the event from the collection. if (event.isSending()) { - event.on("Event.status", this.onEventStatus); + event.on(MatrixEventEvent.Status, this.onEventStatus); } this.relations.add(event); @@ -97,9 +108,9 @@ export class Relations extends EventEmitter { this.targetEvent.makeReplaced(lastReplacement); } - event.on("Event.beforeRedaction", this.onBeforeRedaction); + event.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); - this.emit("Relations.add", event); + this.emit(RelationsEvent.Add, event); this.maybeEmitCreated(); } @@ -138,7 +149,7 @@ export class Relations extends EventEmitter { this.targetEvent.makeReplaced(lastReplacement); } - this.emit("Relations.remove", event); + this.emit(RelationsEvent.Remove, event); } /** @@ -150,14 +161,14 @@ export class Relations extends EventEmitter { private onEventStatus = (event: MatrixEvent, status: EventStatus) => { if (!event.isSending()) { // Sending is done, so we don't need to listen anymore - event.removeListener("Event.status", this.onEventStatus); + event.removeListener(MatrixEventEvent.Status, this.onEventStatus); return; } if (status !== EventStatus.CANCELLED) { return; } // Event was cancelled, remove from the collection - event.removeListener("Event.status", this.onEventStatus); + event.removeListener(MatrixEventEvent.Status, this.onEventStatus); this.removeEvent(event); }; @@ -255,9 +266,9 @@ export class Relations extends EventEmitter { this.targetEvent.makeReplaced(lastReplacement); } - redactedEvent.removeListener("Event.beforeRedaction", this.onBeforeRedaction); + redactedEvent.removeListener(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); - this.emit("Relations.redaction", redactedEvent); + this.emit(RelationsEvent.Redaction, redactedEvent); }; /** @@ -375,6 +386,6 @@ export class Relations extends EventEmitter { return; } this.creationEmitted = true; - this.targetEvent.emit("Event.relationsCreated", this.relationType, this.eventType); + this.targetEvent.emit(MatrixEventEvent.RelationsCreated, this.relationType, this.eventType); } } diff --git a/src/models/room-member.ts b/src/models/room-member.ts index fab65ba8809..2ea13b536ca 100644 --- a/src/models/room-member.ts +++ b/src/models/room-member.ts @@ -18,16 +18,30 @@ limitations under the License. * @module models/room-member */ -import { EventEmitter } from "events"; - import { getHttpUriForMxc } from "../content-repo"; import * as utils from "../utils"; import { User } from "./user"; import { MatrixEvent } from "./event"; import { RoomState } from "./room-state"; import { logger } from "../logger"; +import { TypedEventEmitter } from "./typed-event-emitter"; +import { EventType } from "../@types/event"; + +export enum RoomMemberEvent { + Membership = "RoomMember.membership", + Name = "RoomMember.name", + PowerLevel = "RoomMember.powerLevel", + Typing = "RoomMember.typing", +} + +export type RoomMemberEventHandlerMap = { + [RoomMemberEvent.Membership]: (event: MatrixEvent, member: RoomMember, oldMembership: string | null) => void; + [RoomMemberEvent.Name]: (event: MatrixEvent, member: RoomMember, oldName: string | null) => void; + [RoomMemberEvent.PowerLevel]: (event: MatrixEvent, member: RoomMember) => void; + [RoomMemberEvent.Typing]: (event: MatrixEvent, member: RoomMember) => void; +}; -export class RoomMember extends EventEmitter { +export class RoomMember extends TypedEventEmitter { private _isOutOfBand = false; private _modified: number; public _requestedProfileInfo: boolean; // used by sync.ts @@ -44,8 +58,8 @@ export class RoomMember extends EventEmitter { public events: { member?: MatrixEvent; } = { - member: null, - }; + member: null, + }; /** * Construct a new room member. @@ -107,7 +121,7 @@ export class RoomMember extends EventEmitter { public setMembershipEvent(event: MatrixEvent, roomState?: RoomState): void { const displayName = event.getDirectionalContent().displayname; - if (event.getType() !== "m.room.member") { + if (event.getType() !== EventType.RoomMember) { return; } @@ -150,11 +164,11 @@ export class RoomMember extends EventEmitter { if (oldMembership !== this.membership) { this.updateModifiedTime(); - this.emit("RoomMember.membership", event, this, oldMembership); + this.emit(RoomMemberEvent.Membership, event, this, oldMembership); } if (oldName !== this.name) { this.updateModifiedTime(); - this.emit("RoomMember.name", event, this, oldName); + this.emit(RoomMemberEvent.Name, event, this, oldName); } } @@ -196,7 +210,7 @@ export class RoomMember extends EventEmitter { // redraw everyone's level if the max has changed) if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) { this.updateModifiedTime(); - this.emit("RoomMember.powerLevel", powerLevelEvent, this); + this.emit(RoomMemberEvent.PowerLevel, powerLevelEvent, this); } } @@ -222,7 +236,7 @@ export class RoomMember extends EventEmitter { } if (oldTyping !== this.typing) { this.updateModifiedTime(); - this.emit("RoomMember.typing", event, this); + this.emit(RoomMemberEvent.Typing, event, this); } } diff --git a/src/models/room-state.ts b/src/models/room-state.ts index e1fa9827093..93c76df7289 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -18,8 +18,6 @@ limitations under the License. * @module models/room-state */ -import { EventEmitter } from "events"; - import { RoomMember } from "./room-member"; import { logger } from '../logger'; import * as utils from "../utils"; @@ -27,6 +25,7 @@ import { EventType } from "../@types/event"; import { MatrixEvent } from "./event"; import { MatrixClient } from "../client"; import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials"; +import { TypedEventEmitter } from "./typed-event-emitter"; // possible statuses for out-of-band member loading enum OobStatus { @@ -35,7 +34,21 @@ enum OobStatus { Finished, } -export class RoomState extends EventEmitter { +export enum RoomStateEvent { + Events = "RoomState.events", + Members = "RoomState.members", + NewMember = "RoomState.newMember", + Update = "RoomState.update", // signals batches of updates without specificity +} + +export type RoomStateEventHandlerMap = { + [RoomStateEvent.Events]: (event: MatrixEvent, state: RoomState, lastStateEvent: MatrixEvent | null) => void; + [RoomStateEvent.Members]: (event: MatrixEvent, state: RoomState, member: RoomMember) => void; + [RoomStateEvent.NewMember]: (event: MatrixEvent, state: RoomState, member: RoomMember) => void; + [RoomStateEvent.Update]: (state: RoomState) => void; +}; + +export class RoomState extends TypedEventEmitter { private sentinels: Record = {}; // userId: RoomMember // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys) private displayNameToUserIds: Record = {}; @@ -307,7 +320,7 @@ export class RoomState extends EventEmitter { this.updateDisplayNameCache(event.getStateKey(), event.getContent().displayname); this.updateThirdPartyTokenCache(event); } - this.emit("RoomState.events", event, this, lastStateEvent); + this.emit(RoomStateEvent.Events, event, this, lastStateEvent); }); // update higher level data structures. This needs to be done AFTER the @@ -342,7 +355,7 @@ export class RoomState extends EventEmitter { member.setMembershipEvent(event, this); this.updateMember(member); - this.emit("RoomState.members", event, this, member); + this.emit(RoomStateEvent.Members, event, this, member); } else if (event.getType() === EventType.RoomPowerLevels) { // events with unknown state keys should be ignored // and should not aggregate onto members power levels @@ -357,7 +370,7 @@ export class RoomState extends EventEmitter { const oldLastModified = member.getLastModifiedTime(); member.setPowerLevelEvent(event); if (oldLastModified !== member.getLastModifiedTime()) { - this.emit("RoomState.members", event, this, member); + this.emit(RoomStateEvent.Members, event, this, member); } }); @@ -365,6 +378,8 @@ export class RoomState extends EventEmitter { this.sentinels = {}; } }); + + this.emit(RoomStateEvent.Update, this); } /** @@ -384,7 +399,7 @@ export class RoomState extends EventEmitter { // add member to members before emitting any events, // as event handlers often lookup the member this.members[userId] = member; - this.emit("RoomState.newMember", event, this, member); + this.emit(RoomStateEvent.NewMember, event, this, member); } return member; } @@ -397,8 +412,7 @@ export class RoomState extends EventEmitter { } private getStateEventMatching(event: MatrixEvent): MatrixEvent | null { - if (!this.events.has(event.getType())) return null; - return this.events.get(event.getType()).get(event.getStateKey()); + return this.events.get(event.getType())?.get(event.getStateKey()) ?? null; } private updateMember(member: RoomMember): void { @@ -475,6 +489,7 @@ export class RoomState extends EventEmitter { logger.log(`LL: RoomState put in finished state ...`); this.oobMemberFlags.status = OobStatus.Finished; stateEvents.forEach((e) => this.setOutOfBandMember(e)); + this.emit(RoomStateEvent.Update, this); } /** @@ -503,7 +518,7 @@ export class RoomState extends EventEmitter { this.setStateEvent(stateEvent); this.updateMember(member); - this.emit("RoomState.members", stateEvent, this, member); + this.emit(RoomStateEvent.Members, stateEvent, this, member); } /** diff --git a/src/models/room.ts b/src/models/room.ts index e3cad8cb631..7b019190cdf 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -18,18 +18,17 @@ limitations under the License. * @module models/room */ -import { EventEmitter } from "events"; - import { EventTimelineSet, DuplicateStrategy } from "./event-timeline-set"; import { Direction, EventTimeline } from "./event-timeline"; import { getHttpUriForMxc } from "../content-repo"; import * as utils from "../utils"; import { normalize } from "../utils"; -import { EventStatus, IEvent, MatrixEvent } from "./event"; +import { IEvent, MatrixEvent } from "./event"; +import { EventStatus } from "./event-status"; import { RoomMember } from "./room-member"; import { IRoomSummary, RoomSummary } from "./room-summary"; import { logger } from '../logger'; -import { ReEmitter } from '../ReEmitter'; +import { TypedReEmitter } from '../ReEmitter'; import { EventType, RoomCreateTypeField, RoomType, UNSTABLE_ELEMENT_FUNCTIONAL_USERS, EVENT_VISIBILITY_CHANGE_TYPE, @@ -38,8 +37,9 @@ import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersio import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials"; import { Filter } from "../filter"; import { RoomState } from "./room-state"; -import { Thread, ThreadEvent } from "./thread"; +import { Thread, ThreadEvent, EventHandlerMap as ThreadHandlerMap } from "./thread"; import { Method } from "../http-api"; +import { TypedEventEmitter } from "./typed-event-emitter"; // 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 @@ -143,8 +143,44 @@ export interface ICreateFilterOpts { prepopulateTimeline?: boolean; } -export class Room extends EventEmitter { - private readonly reEmitter: ReEmitter; +export enum RoomEvent { + MyMembership = "Room.myMembership", + Tags = "Room.tags", + AccountData = "Room.accountData", + Receipt = "Room.receipt", + Name = "Room.name", + Redaction = "Room.redaction", + RedactionCancelled = "Room.redactionCancelled", + LocalEchoUpdated = "Room.localEchoUpdated", + Timeline = "Room.timeline", + TimelineReset = "Room.timelineReset", +} + +type EmittedEvents = RoomEvent + | ThreadEvent.New + | ThreadEvent.Update + | RoomEvent.Timeline + | RoomEvent.TimelineReset; + +export type RoomEventHandlerMap = { + [RoomEvent.MyMembership]: (room: Room, membership: string, prevMembership?: string) => void; + [RoomEvent.Tags]: (event: MatrixEvent, room: Room) => void; + [RoomEvent.AccountData]: (event: MatrixEvent, room: Room, lastEvent?: MatrixEvent) => void; + [RoomEvent.Receipt]: (event: MatrixEvent, room: Room) => void; + [RoomEvent.Name]: (room: Room) => void; + [RoomEvent.Redaction]: (event: MatrixEvent, room: Room) => void; + [RoomEvent.RedactionCancelled]: (event: MatrixEvent, room: Room) => void; + [RoomEvent.LocalEchoUpdated]: ( + event: MatrixEvent, + room: Room, + oldEventId?: string, + oldStatus?: EventStatus, + ) => void; + [ThreadEvent.New]: (thread: Thread) => void; +} & ThreadHandlerMap; + +export class Room extends TypedEventEmitter { + private readonly reEmitter: TypedReEmitter; private txnToEvent: Record = {}; // Pending in-flight requests { string: MatrixEvent } // receipts should clobber based on receipt_type and user_id pairs hence // the form of this structure. This is sub-optimal for the exposed APIs @@ -287,7 +323,7 @@ export class Room extends EventEmitter { // In some cases, we add listeners for every displayed Matrix event, so it's // common to have quite a few more than the default limit. this.setMaxListeners(100); - this.reEmitter = new ReEmitter(this); + this.reEmitter = new TypedReEmitter(this); opts.pendingEventOrdering = opts.pendingEventOrdering || PendingEventOrdering.Chronological; @@ -297,7 +333,8 @@ export class Room extends EventEmitter { // the subsequent ones are the filtered ones in no particular order. this.timelineSets = [new EventTimelineSet(this, opts)]; this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), [ - "Room.timeline", "Room.timelineReset", + RoomEvent.Timeline, + RoomEvent.TimelineReset, ]); this.fixUpLegacyTimelineFields(); @@ -712,7 +749,7 @@ export class Room extends EventEmitter { if (membership === "leave") { this.cleanupAfterLeaving(); } - this.emit("Room.myMembership", this, membership, prevMembership); + this.emit(RoomEvent.MyMembership, this, membership, prevMembership); } } @@ -1285,7 +1322,10 @@ export class Room extends EventEmitter { } const opts = Object.assign({ filter: filter }, this.opts); const timelineSet = new EventTimelineSet(this, opts); - this.reEmitter.reEmit(timelineSet, ["Room.timeline", "Room.timelineReset"]); + this.reEmitter.reEmit(timelineSet, [ + RoomEvent.Timeline, + RoomEvent.TimelineReset, + ]); this.filteredTimelineSets[filter.filterId] = timelineSet; this.timelineSets.push(timelineSet); @@ -1366,10 +1406,10 @@ export class Room extends EventEmitter { * Add an event to a thread's timeline. Will fire "Thread.update" * @experimental */ - public async addThreadedEvent(event: MatrixEvent): Promise { + public async addThreadedEvent(event: MatrixEvent, toStartOfTimeline: boolean): Promise { let thread = this.findThreadForEvent(event); if (thread) { - thread.addEvent(event); + thread.addEvent(event, toStartOfTimeline); } else { const events = [event]; let rootEvent = this.findEventById(event.threadRootId); @@ -1418,12 +1458,11 @@ export class Room extends EventEmitter { this.threads.set(thread.id, thread); this.reEmitter.reEmit(thread, [ ThreadEvent.Update, - ThreadEvent.Ready, - "Room.timeline", - "Room.timelineReset", + RoomEvent.Timeline, + RoomEvent.TimelineReset, ]); - if (!this.lastThread || this.lastThread.rootEvent.localTimestamp < rootEvent.localTimestamp) { + if (!this.lastThread || this.lastThread.rootEvent?.localTimestamp < rootEvent?.localTimestamp) { this.lastThread = thread; } @@ -1462,7 +1501,7 @@ export class Room extends EventEmitter { } } - this.emit("Room.redaction", event, this); + this.emit(RoomEvent.Redaction, event, this); // TODO: we stash user displaynames (among other things) in // RoomMember objects which are then attached to other events @@ -1584,7 +1623,7 @@ export class Room extends EventEmitter { } if (redactedEvent) { redactedEvent.markLocallyRedacted(event); - this.emit("Room.redaction", event, this); + this.emit(RoomEvent.Redaction, event, this); } } } else { @@ -1602,7 +1641,7 @@ export class Room extends EventEmitter { } } - this.emit("Room.localEchoUpdated", event, this, null, null); + this.emit(RoomEvent.LocalEchoUpdated, event, this, null, null); } /** @@ -1730,8 +1769,7 @@ export class Room extends EventEmitter { } } - this.emit("Room.localEchoUpdated", localEvent, this, - oldEventId, oldStatus); + this.emit(RoomEvent.LocalEchoUpdated, localEvent, this, oldEventId, oldStatus); } /** @@ -1815,7 +1853,7 @@ export class Room extends EventEmitter { } this.savePendingEvents(); - this.emit("Room.localEchoUpdated", event, this, oldEventId, oldStatus); + this.emit(RoomEvent.LocalEchoUpdated, event, this, oldEventId, oldStatus); } private revertRedactionLocalEcho(redactionEvent: MatrixEvent): void { @@ -1828,7 +1866,7 @@ export class Room extends EventEmitter { if (redactedEvent) { redactedEvent.unmarkLocallyRedacted(); // re-render after undoing redaction - this.emit("Room.redactionCancelled", redactionEvent, this); + this.emit(RoomEvent.RedactionCancelled, redactionEvent, this); // reapply relation now redaction failed if (redactedEvent.isRelation()) { this.aggregateNonLiveRelation(redactedEvent); @@ -1968,7 +2006,7 @@ export class Room extends EventEmitter { }); if (oldName !== this.name) { - this.emit("Room.name", this); + this.emit(RoomEvent.Name, this); } } @@ -2061,7 +2099,7 @@ export class Room extends EventEmitter { this.addReceiptsToStructure(event, synthetic); // send events after we've regenerated the structure & cache, otherwise things that // listened for the event would read stale data. - this.emit("Room.receipt", event, this); + this.emit(RoomEvent.Receipt, event, this); } /** @@ -2195,7 +2233,7 @@ export class Room extends EventEmitter { // XXX: we could do a deep-comparison to see if the tags have really // changed - but do we want to bother? - this.emit("Room.tags", event, this); + this.emit(RoomEvent.Tags, event, this); } /** @@ -2210,7 +2248,7 @@ export class Room extends EventEmitter { } const lastEvent = this.accountData[event.getType()]; this.accountData[event.getType()] = event; - this.emit("Room.accountData", event, this, lastEvent); + this.emit(RoomEvent.AccountData, event, this, lastEvent); } } diff --git a/src/models/thread.ts b/src/models/thread.ts index 9465cc6a988..5255914b248 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -14,25 +14,34 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient } from "../matrix"; -import { ReEmitter } from "../ReEmitter"; +import { MatrixClient, RoomEvent } from "../matrix"; +import { TypedReEmitter } from "../ReEmitter"; import { RelationType } from "../@types/event"; import { IRelationsRequestOpts } from "../@types/requests"; -import { MatrixEvent, IThreadBundledRelationship } from "./event"; +import { IThreadBundledRelationship, MatrixEvent } from "./event"; import { Direction, EventTimeline } from "./event-timeline"; -import { EventTimelineSet } from './event-timeline-set'; +import { EventTimelineSet, EventTimelineSetHandlerMap } from './event-timeline-set'; import { Room } from './room'; import { TypedEventEmitter } from "./typed-event-emitter"; import { RoomState } from "./room-state"; export enum ThreadEvent { New = "Thread.new", - Ready = "Thread.ready", Update = "Thread.update", NewReply = "Thread.newReply", - ViewThread = "Thred.viewThread", + ViewThread = "Thread.viewThread", } +type EmittedEvents = Exclude + | RoomEvent.Timeline + | RoomEvent.TimelineReset; + +export type EventHandlerMap = { + [ThreadEvent.Update]: (thread: Thread) => void; + [ThreadEvent.NewReply]: (thread: Thread, event: MatrixEvent) => void; + [ThreadEvent.ViewThread]: () => void; +} & EventTimelineSetHandlerMap; + interface IThreadOpts { initialEvents?: MatrixEvent[]; room: Room; @@ -42,15 +51,18 @@ interface IThreadOpts { /** * @experimental */ -export class Thread extends TypedEventEmitter { +export class Thread extends TypedEventEmitter { + public static hasServerSideSupport: boolean; + private static serverSupportPromise: Promise | null; + /** * A reference to all the events ID at the bottom of the threads */ - public readonly timelineSet; + public readonly timelineSet: EventTimelineSet; private _currentUserParticipated = false; - private reEmitter: ReEmitter; + private reEmitter: TypedReEmitter; private lastEvent: MatrixEvent; private replyCount = 0; @@ -75,13 +87,22 @@ export class Thread extends TypedEventEmitter { timelineSupport: true, pendingEvents: true, }); - this.reEmitter = new ReEmitter(this); + this.reEmitter = new TypedReEmitter(this); this.reEmitter.reEmit(this.timelineSet, [ - "Room.timeline", - "Room.timelineReset", + RoomEvent.Timeline, + RoomEvent.TimelineReset, ]); + if (Thread.hasServerSideSupport === undefined) { + Thread.serverSupportPromise = this.client.doesServerSupportUnstableFeature("org.matrix.msc3440"); + Thread.serverSupportPromise.then((serverSupportsThread) => { + Thread.hasServerSideSupport = serverSupportsThread; + }).catch(() => { + Thread.serverSupportPromise = null; + }); + } + // If we weren't able to find the root event, it's probably missing // and we define the thread ID from one of the thread relation if (!rootEvent) { @@ -92,18 +113,13 @@ export class Thread extends TypedEventEmitter { } this.initialiseThread(this.rootEvent); - opts?.initialEvents?.forEach(event => this.addEvent(event)); + opts?.initialEvents?.forEach(event => this.addEvent(event, false)); - this.room.on("Room.localEchoUpdated", this.onEcho); - this.room.on("Room.timeline", this.onEcho); + this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho); + this.room.on(RoomEvent.Timeline, this.onEcho); } - public get hasServerSideSupport(): boolean { - return this.client.cachedCapabilities - ?.capabilities?.[RelationType.Thread]?.enabled; - } - - onEcho = (event: MatrixEvent) => { + private onEcho = (event: MatrixEvent) => { if (this.timelineSet.eventIdToTimeline(event.getId())) { this.emit(ThreadEvent.Update, this); } @@ -139,11 +155,16 @@ export class Thread extends TypedEventEmitter { * the tail/root references if needed * Will fire "Thread.update" * @param event The event to add + * @param {boolean} toStartOfTimeline whether the event is being added + * to the start (and not the end) of the timeline. */ - public async addEvent(event: MatrixEvent, toStartOfTimeline = false): Promise { - // Add all incoming events to the thread's timeline set when there's - // no server support - if (!this.hasServerSideSupport) { + public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean): Promise { + if (Thread.hasServerSideSupport === undefined) { + await Thread.serverSupportPromise; + } + + // Add all incoming events to the thread's timeline set when there's no server support + if (!Thread.hasServerSideSupport) { // all the relevant membership info to hydrate events with a sender // is held in the main room timeline // We want to fetch the room state from there and pass it down to this thread @@ -155,7 +176,7 @@ export class Thread extends TypedEventEmitter { await this.client.decryptEventIfNeeded(event, {}); } - if (this.hasServerSideSupport && this.initialEventsFetched) { + if (Thread.hasServerSideSupport && this.initialEventsFetched) { if (event.localTimestamp > this.lastReply().localTimestamp) { this.addEventToTimeline(event, false); } @@ -168,7 +189,7 @@ export class Thread extends TypedEventEmitter { const isThreadReply = event.getRelation()?.rel_type === RelationType.Thread; // If no thread support exists we want to count all thread relation // added as a reply. We can't rely on the bundled relationships count - if (!this.hasServerSideSupport && isThreadReply) { + if (!Thread.hasServerSideSupport && isThreadReply) { this.replyCount++; } @@ -181,7 +202,7 @@ export class Thread extends TypedEventEmitter { // This counting only works when server side support is enabled // as we started the counting from the value returned in the // bundled relationship - if (this.hasServerSideSupport) { + if (Thread.hasServerSideSupport) { this.replyCount++; } @@ -193,10 +214,17 @@ export class Thread extends TypedEventEmitter { } private initialiseThread(rootEvent: MatrixEvent | undefined): void { + if (Thread.hasServerSideSupport === undefined) { + Thread.serverSupportPromise.then(() => { + this.initialiseThread(rootEvent); + }); + return; + } + const bundledRelationship = rootEvent ?.getServerAggregatedRelation(RelationType.Thread); - if (this.hasServerSideSupport && bundledRelationship) { + if (Thread.hasServerSideSupport && bundledRelationship) { this.replyCount = bundledRelationship.count; this._currentUserParticipated = bundledRelationship.current_user_participated; @@ -204,19 +232,28 @@ export class Thread extends TypedEventEmitter { this.setEventMetadata(event); this.lastEvent = event; } + } - if (!bundledRelationship && rootEvent) { - this.addEvent(rootEvent); + public async fetchInitialEvents(): Promise<{ + originalEvent: MatrixEvent; + events: MatrixEvent[]; + nextBatch?: string; + prevBatch?: string; + } | null> { + if (Thread.hasServerSideSupport === undefined) { + await Thread.serverSupportPromise; } - } - public async fetchInitialEvents(): Promise { + if (!Thread.hasServerSideSupport) { + this.initialEventsFetched = true; + return null; + } try { - await this.fetchEvents(); + const response = await this.fetchEvents(); this.initialEventsFetched = true; - return true; + return response; } catch (e) { - return false; + return null; } } @@ -286,6 +323,10 @@ export class Thread extends TypedEventEmitter { nextBatch?: string; prevBatch?: string; }> { + if (Thread.hasServerSideSupport === undefined) { + await Thread.serverSupportPromise; + } + let { originalEvent, events, @@ -302,13 +343,13 @@ export class Thread extends TypedEventEmitter { // When there's no nextBatch returned with a `from` request we have reached // the end of the thread, and therefore want to return an empty one if (!opts.to && !nextBatch) { - events = [originalEvent, ...events]; + events = [...events, originalEvent]; } - for (const event of events) { - await this.client.decryptEventIfNeeded(event); + await Promise.all(events.map(event => { this.setEventMetadata(event); - } + return this.client.decryptEventIfNeeded(event); + })); const prependEvents = !opts.direction || opts.direction === Direction.Backward; diff --git a/src/models/typed-event-emitter.ts b/src/models/typed-event-emitter.ts index 5bbe750bace..691ec5ec350 100644 --- a/src/models/typed-event-emitter.ts +++ b/src/models/typed-event-emitter.ts @@ -14,13 +14,28 @@ See the License for the specific language governing permissions and limitations under the License. */ +// eslint-disable-next-line no-restricted-imports import { EventEmitter } from "events"; -enum EventEmitterEvents { +export enum EventEmitterEvents { NewListener = "newListener", RemoveListener = "removeListener", + Error = "error", } +type AnyListener = (...args: any) => any; +export type ListenerMap = { [eventName in E]: AnyListener }; +type EventEmitterEventListener = (eventName: string, listener: AnyListener) => void; +type EventEmitterErrorListener = (error: Error) => void; + +export type Listener< + E extends string, + A extends ListenerMap, + T extends E | EventEmitterEvents, +> = T extends E ? A[T] + : T extends EventEmitterEvents ? EventEmitterErrorListener + : EventEmitterEventListener; + /** * Typed Event Emitter class which can act as a Base Model for all our model * and communication events. @@ -28,17 +43,26 @@ enum EventEmitterEvents { * to properly type this, so that our events are not stringly-based and prone * to silly typos. */ -export abstract class TypedEventEmitter extends EventEmitter { - public addListener(event: Events | EventEmitterEvents, listener: (...args: any[]) => void): this { +export class TypedEventEmitter< + Events extends string, + Arguments extends ListenerMap, + SuperclassArguments extends ListenerMap = Arguments, +> extends EventEmitter { + public addListener( + event: T, + listener: Listener, + ): this { return super.addListener(event, listener); } - public emit(event: Events | EventEmitterEvents, ...args: any[]): boolean { + public emit(event: T, ...args: Parameters): boolean; + public emit(event: T, ...args: Parameters): boolean; + public emit(event: T, ...args: any[]): boolean { return super.emit(event, ...args); } public eventNames(): (Events | EventEmitterEvents)[] { - return super.eventNames() as Events[]; + return super.eventNames() as Array; } public listenerCount(event: Events | EventEmitterEvents): number { @@ -49,23 +73,38 @@ export abstract class TypedEventEmitter extends EventEmit return super.listeners(event); } - public off(event: Events | EventEmitterEvents, listener: (...args: any[]) => void): this { + public off( + event: T, + listener: Listener, + ): this { return super.off(event, listener); } - public on(event: Events | EventEmitterEvents, listener: (...args: any[]) => void): this { + public on( + event: T, + listener: Listener, + ): this { return super.on(event, listener); } - public once(event: Events | EventEmitterEvents, listener: (...args: any[]) => void): this { + public once( + event: T, + listener: Listener, + ): this { return super.once(event, listener); } - public prependListener(event: Events | EventEmitterEvents, listener: (...args: any[]) => void): this { + public prependListener( + event: T, + listener: Listener, + ): this { return super.prependListener(event, listener); } - public prependOnceListener(event: Events | EventEmitterEvents, listener: (...args: any[]) => void): this { + public prependOnceListener( + event: T, + listener: Listener, + ): this { return super.prependOnceListener(event, listener); } @@ -73,7 +112,10 @@ export abstract class TypedEventEmitter extends EventEmit return super.removeAllListeners(event); } - public removeListener(event: Events | EventEmitterEvents, listener: (...args: any[]) => void): this { + public removeListener( + event: T, + listener: Listener, + ): this { return super.removeListener(event, listener); } diff --git a/src/models/user.ts b/src/models/user.ts index 613a03a69ea..aad80e57501 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -18,12 +18,29 @@ limitations under the License. * @module models/user */ -import { EventEmitter } from "events"; - import { MatrixEvent } from "./event"; +import { TypedEventEmitter } from "./typed-event-emitter"; -export class User extends EventEmitter { - // eslint-disable-next-line camelcase +export enum UserEvent { + DisplayName = "User.displayName", + AvatarUrl = "User.avatarUrl", + Presence = "User.presence", + CurrentlyActive = "User.currentlyActive", + LastPresenceTs = "User.lastPresenceTs", + /* @deprecated */ + _UnstableStatusMessage = "User.unstable_statusMessage", +} + +export type UserEventHandlerMap = { + [UserEvent.DisplayName]: (event: MatrixEvent | undefined, user: User) => void; + [UserEvent.AvatarUrl]: (event: MatrixEvent | undefined, user: User) => void; + [UserEvent.Presence]: (event: MatrixEvent | undefined, user: User) => void; + [UserEvent.CurrentlyActive]: (event: MatrixEvent | undefined, user: User) => void; + [UserEvent.LastPresenceTs]: (event: MatrixEvent | undefined, user: User) => void; + [UserEvent._UnstableStatusMessage]: (user: User) => void; +}; + +export class User extends TypedEventEmitter { private modified: number; // XXX these should be read-only @@ -39,9 +56,9 @@ export class User extends EventEmitter { presence?: MatrixEvent; profile?: MatrixEvent; } = { - presence: null, - profile: null, - }; + presence: null, + profile: null, + }; // eslint-disable-next-line camelcase public unstable_statusMessage = ""; @@ -94,25 +111,25 @@ export class User extends EventEmitter { const firstFire = this.events.presence === null; this.events.presence = event; - const eventsToFire = []; + const eventsToFire: UserEvent[] = []; if (event.getContent().presence !== this.presence || firstFire) { - eventsToFire.push("User.presence"); + eventsToFire.push(UserEvent.Presence); } if (event.getContent().avatar_url && event.getContent().avatar_url !== this.avatarUrl) { - eventsToFire.push("User.avatarUrl"); + eventsToFire.push(UserEvent.AvatarUrl); } if (event.getContent().displayname && event.getContent().displayname !== this.displayName) { - eventsToFire.push("User.displayName"); + eventsToFire.push(UserEvent.DisplayName); } if (event.getContent().currently_active !== undefined && event.getContent().currently_active !== this.currentlyActive) { - eventsToFire.push("User.currentlyActive"); + eventsToFire.push(UserEvent.CurrentlyActive); } this.presence = event.getContent().presence; - eventsToFire.push("User.lastPresenceTs"); + eventsToFire.push(UserEvent.LastPresenceTs); if (event.getContent().status_msg) { this.presenceStatusMsg = event.getContent().status_msg; @@ -213,7 +230,7 @@ export class User extends EventEmitter { if (!event.getContent()) this.unstable_statusMessage = ""; else this.unstable_statusMessage = event.getContent()["status"]; this.updateModifiedTime(); - this.emit("User.unstable_statusMessage", this); + this.emit(UserEvent._UnstableStatusMessage, this); } } diff --git a/src/pushprocessor.ts b/src/pushprocessor.ts index 7e551202c5d..ae170751f00 100644 --- a/src/pushprocessor.ts +++ b/src/pushprocessor.ts @@ -158,8 +158,7 @@ export class PushProcessor { .find((r) => r.rule_id === override.rule_id); if (existingRule) { - // Copy over the actions, default, and conditions. Don't touch the user's - // preference. + // Copy over the actions, default, and conditions. Don't touch the user's preference. existingRule.default = override.default; existingRule.conditions = override.conditions; existingRule.actions = override.actions; @@ -447,6 +446,8 @@ export class PushProcessor { } public ruleMatchesEvent(rule: IPushRule, ev: MatrixEvent): boolean { + if (!rule.conditions?.length) return true; + let ret = true; for (let i = 0; i < rule.conditions.length; ++i) { const cond = rule.conditions[i]; diff --git a/src/store/indexeddb.ts b/src/store/indexeddb.ts index 51fa88d5f53..018f5abd197 100644 --- a/src/store/indexeddb.ts +++ b/src/store/indexeddb.ts @@ -16,8 +16,6 @@ limitations under the License. /* eslint-disable @babel/no-invalid-this */ -import { EventEmitter } from 'events'; - import { MemoryStore, IOpts as IBaseOpts } from "./memory"; import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend"; import { RemoteIndexedDBStoreBackend } from "./indexeddb-remote-backend"; @@ -27,6 +25,7 @@ import { logger } from '../logger'; import { ISavedSync } from "./index"; import { IIndexedDBBackend } from "./indexeddb-backend"; import { ISyncResponse } from "../sync-accumulator"; +import { TypedEventEmitter } from "../models/typed-event-emitter"; /** * This is an internal module. See {@link IndexedDBStore} for the public class. @@ -46,6 +45,10 @@ interface IOpts extends IBaseOpts { workerFactory?: () => Worker; } +type EventHandlerMap = { + "degraded": (e: Error) => void; +}; + export class IndexedDBStore extends MemoryStore { static exists(indexedDB: IDBFactory, dbName: string): Promise { return LocalIndexedDBStoreBackend.exists(indexedDB, dbName); @@ -59,7 +62,7 @@ export class IndexedDBStore extends MemoryStore { // the database, such that we can derive the set if users that have been // modified since we last saved. private userModifiedMap: Record = {}; // user_id : timestamp - private emitter = new EventEmitter(); + private emitter = new TypedEventEmitter(); /** * Construct a new Indexed Database store, which extends MemoryStore. diff --git a/src/store/local-storage-events-emitter.ts b/src/store/local-storage-events-emitter.ts index 18f15b59353..24524c63438 100644 --- a/src/store/local-storage-events-emitter.ts +++ b/src/store/local-storage-events-emitter.ts @@ -25,6 +25,15 @@ export enum LocalStorageErrors { QuotaExceededError = 'QuotaExceededError' } +type EventHandlerMap = { + [LocalStorageErrors.Global]: (error: Error) => void; + [LocalStorageErrors.SetItemError]: (error: Error) => void; + [LocalStorageErrors.GetItemError]: (error: Error) => void; + [LocalStorageErrors.RemoveItemError]: (error: Error) => void; + [LocalStorageErrors.ClearError]: (error: Error) => void; + [LocalStorageErrors.QuotaExceededError]: (error: Error) => void; +}; + /** * Used in element-web as a temporary hack to handle all the localStorage errors on the highest level possible * As of 15.11.2021 (DD/MM/YYYY) we're not properly handling local storage exceptions anywhere. @@ -33,5 +42,5 @@ export enum LocalStorageErrors { * maybe you should check out your disk, as it's probably dying and your session may die with it. * See: https://github.com/vector-im/element-web/issues/18423 */ -class LocalStorageErrorsEventsEmitter extends TypedEventEmitter {} +class LocalStorageErrorsEventsEmitter extends TypedEventEmitter {} export const localStorageErrorsEventsEmitter = new LocalStorageErrorsEventsEmitter(); diff --git a/src/store/memory.ts b/src/store/memory.ts index 7effd9f61d2..b29d3d3647a 100644 --- a/src/store/memory.ts +++ b/src/store/memory.ts @@ -24,7 +24,7 @@ import { Group } from "../models/group"; import { Room } from "../models/room"; import { User } from "../models/user"; import { IEvent, MatrixEvent } from "../models/event"; -import { RoomState } from "../models/room-state"; +import { RoomState, RoomStateEvent } from "../models/room-state"; import { RoomMember } from "../models/room-member"; import { Filter } from "../filter"; import { ISavedSync, IStore } from "./index"; @@ -126,7 +126,7 @@ export class MemoryStore implements IStore { this.rooms[room.roomId] = room; // add listeners for room member changes so we can keep the room member // map up-to-date. - room.currentState.on("RoomState.members", this.onRoomMember); + room.currentState.on(RoomStateEvent.Members, this.onRoomMember); // add existing members room.currentState.getMembers().forEach((m) => { this.onRoomMember(null, room.currentState, m); @@ -185,7 +185,7 @@ export class MemoryStore implements IStore { */ public removeRoom(roomId: string): void { if (this.rooms[roomId]) { - this.rooms[roomId].removeListener("RoomState.members", this.onRoomMember); + this.rooms[roomId].currentState.removeListener(RoomStateEvent.Members, this.onRoomMember); } delete this.rooms[roomId]; } diff --git a/src/sync.ts b/src/sync.ts index c0da84c44d4..afb66262705 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -23,8 +23,8 @@ limitations under the License. * for HTTP and WS at some point. */ -import { User } from "./models/user"; -import { NotificationCountType, Room } from "./models/room"; +import { User, UserEvent } from "./models/user"; +import { NotificationCountType, Room, RoomEvent } from "./models/room"; import { Group } from "./models/group"; import * as utils from "./utils"; import { IDeferred } from "./utils"; @@ -33,7 +33,7 @@ import { EventTimeline } from "./models/event-timeline"; import { PushProcessor } from "./pushprocessor"; import { logger } from './logger'; import { InvalidStoreError } from './errors'; -import { IStoredClientOpts, MatrixClient, PendingEventOrdering } from "./client"; +import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering } from "./client"; import { Category, IEphemeral, @@ -53,6 +53,8 @@ import { MatrixError, Method } from "./http-api"; import { ISavedSync } from "./store"; import { EventType } from "./@types/event"; import { IPushRules } from "./@types/PushRules"; +import { RoomStateEvent } from "./models/room-state"; +import { RoomMemberEvent } from "./models/room-member"; const DEBUG = true; @@ -171,8 +173,10 @@ export class SyncApi { } if (client.getNotifTimelineSet()) { - client.reEmitter.reEmit(client.getNotifTimelineSet(), - ["Room.timeline", "Room.timelineReset"]); + client.reEmitter.reEmit(client.getNotifTimelineSet(), [ + RoomEvent.Timeline, + RoomEvent.TimelineReset, + ]); } } @@ -192,16 +196,17 @@ export class SyncApi { timelineSupport, unstableClientRelationAggregation, }); - client.reEmitter.reEmit(room, ["Room.name", "Room.timeline", - "Room.redaction", - "Room.redactionCancelled", - "Room.receipt", "Room.tags", - "Room.timelineReset", - "Room.localEchoUpdated", - "Room.accountData", - "Room.myMembership", - "Room.replaceEvent", - "Room.visibilityChange", + client.reEmitter.reEmit(room, [ + RoomEvent.Name, + RoomEvent.Redaction, + RoomEvent.RedactionCancelled, + RoomEvent.Receipt, + RoomEvent.Tags, + RoomEvent.LocalEchoUpdated, + RoomEvent.AccountData, + RoomEvent.MyMembership, + RoomEvent.Timeline, + RoomEvent.TimelineReset, ]); this.registerStateListeners(room); return room; @@ -214,7 +219,10 @@ export class SyncApi { public createGroup(groupId: string): Group { const client = this.client; const group = new Group(groupId); - client.reEmitter.reEmit(group, ["Group.profile", "Group.myMembership"]); + client.reEmitter.reEmit(group, [ + ClientEvent.GroupProfile, + ClientEvent.GroupMyMembership, + ]); client.store.storeGroup(group); return group; } @@ -229,17 +237,19 @@ export class SyncApi { // to the client now. We need to add a listener for RoomState.members in // order to hook them correctly. (TODO: find a better way?) client.reEmitter.reEmit(room.currentState, [ - "RoomState.events", "RoomState.members", "RoomState.newMember", + RoomStateEvent.Events, + RoomStateEvent.Members, + RoomStateEvent.NewMember, + RoomStateEvent.Update, ]); - room.currentState.on("RoomState.newMember", function(event, state, member) { + room.currentState.on(RoomStateEvent.NewMember, function(event, state, member) { member.user = client.getUser(member.userId); - client.reEmitter.reEmit( - member, - [ - "RoomMember.name", "RoomMember.typing", "RoomMember.powerLevel", - "RoomMember.membership", - ], - ); + client.reEmitter.reEmit(member, [ + RoomMemberEvent.Name, + RoomMemberEvent.Typing, + RoomMemberEvent.PowerLevel, + RoomMemberEvent.Membership, + ]); }); } @@ -249,9 +259,9 @@ export class SyncApi { */ private deregisterStateListeners(room: Room): void { // could do with a better way of achieving this. - room.currentState.removeAllListeners("RoomState.events"); - room.currentState.removeAllListeners("RoomState.members"); - room.currentState.removeAllListeners("RoomState.newMember"); + room.currentState.removeAllListeners(RoomStateEvent.Events); + room.currentState.removeAllListeners(RoomStateEvent.Members); + room.currentState.removeAllListeners(RoomStateEvent.NewMember); } /** @@ -310,11 +320,11 @@ export class SyncApi { EventTimeline.BACKWARDS); this.processRoomEvents(room, stateEvents, timelineEvents); - await this.processThreadEvents(room, threadedEvents); + await this.processThreadEvents(room, threadedEvents, false); room.recalculate(); client.store.storeRoom(room); - client.emit("Room", room); + client.emit(ClientEvent.Room, room); this.processEventsForNotifs(room, events); }); @@ -362,7 +372,7 @@ export class SyncApi { user.setPresenceEvent(presenceEvent); client.store.storeUser(user); } - client.emit("event", presenceEvent); + client.emit(ClientEvent.Event, presenceEvent); }); } @@ -388,7 +398,7 @@ export class SyncApi { response.messages.start); client.store.storeRoom(this._peekRoom); - client.emit("Room", this._peekRoom); + client.emit(ClientEvent.Room, this._peekRoom); this.peekPoll(this._peekRoom); return this._peekRoom; @@ -445,7 +455,7 @@ export class SyncApi { user.setPresenceEvent(presenceEvent); this.client.store.storeUser(user); } - this.client.emit("event", presenceEvent); + this.client.emit(ClientEvent.Event, presenceEvent); }); // strip out events which aren't for the given room_id (e.g presence) @@ -840,7 +850,7 @@ export class SyncApi { logger.error("Caught /sync error", e.stack || e); // Emit the exception for client handling - this.client.emit("sync.unexpectedError", e); + this.client.emit(ClientEvent.SyncUnexpectedError, e); } // update this as it may have changed @@ -1073,7 +1083,7 @@ export class SyncApi { user.setPresenceEvent(presenceEvent); client.store.storeUser(user); } - client.emit("event", presenceEvent); + client.emit(ClientEvent.Event, presenceEvent); }); } @@ -1096,7 +1106,7 @@ export class SyncApi { client.pushRules = PushProcessor.rewriteDefaultRules(rules); } const prevEvent = prevEventsMap[accountDataEvent.getId()]; - client.emit("accountData", accountDataEvent, prevEvent); + client.emit(ClientEvent.AccountData, accountDataEvent, prevEvent); return accountDataEvent; }, ); @@ -1149,7 +1159,7 @@ export class SyncApi { } } - client.emit("toDeviceEvent", toDeviceEvent); + client.emit(ClientEvent.ToDeviceEvent, toDeviceEvent); }, ); } else { @@ -1201,10 +1211,10 @@ export class SyncApi { if (inviteObj.isBrandNewRoom) { room.recalculate(); client.store.storeRoom(room); - client.emit("Room", room); + client.emit(ClientEvent.Room, room); } stateEvents.forEach(function(e) { - client.emit("event", e); + client.emit(ClientEvent.Event, e); }); room.updateMyMembership("invite"); }); @@ -1307,7 +1317,7 @@ export class SyncApi { const [timelineEvents, threadedEvents] = this.client.partitionThreadedEvents(events); this.processRoomEvents(room, stateEvents, timelineEvents, syncEventData.fromCache); - await this.processThreadEvents(room, threadedEvents); + await this.processThreadEvents(room, threadedEvents, false); // set summary after processing events, // because it will trigger a name calculation @@ -1325,13 +1335,13 @@ export class SyncApi { room.recalculate(); if (joinObj.isBrandNewRoom) { client.store.storeRoom(room); - client.emit("Room", room); + client.emit(ClientEvent.Room, room); } this.processEventsForNotifs(room, events); const processRoomEvent = async (e) => { - client.emit("event", e); + client.emit(ClientEvent.Event, e); if (e.isState() && e.getType() == "m.room.encryption" && this.opts.crypto) { await this.opts.crypto.onCryptoEvent(e); } @@ -1351,10 +1361,10 @@ export class SyncApi { await utils.promiseMapSeries(timelineEvents, processRoomEvent); await utils.promiseMapSeries(threadedEvents, processRoomEvent); ephemeralEvents.forEach(function(e) { - client.emit("event", e); + client.emit(ClientEvent.Event, e); }); accountDataEvents.forEach(function(e) { - client.emit("event", e); + client.emit(ClientEvent.Event, e); }); room.updateMyMembership("join"); @@ -1375,28 +1385,28 @@ export class SyncApi { const [timelineEvents, threadedEvents] = this.client.partitionThreadedEvents(events); this.processRoomEvents(room, stateEvents, timelineEvents); - await this.processThreadEvents(room, threadedEvents); + await this.processThreadEvents(room, threadedEvents, false); room.addAccountData(accountDataEvents); room.recalculate(); if (leaveObj.isBrandNewRoom) { client.store.storeRoom(room); - client.emit("Room", room); + client.emit(ClientEvent.Room, room); } this.processEventsForNotifs(room, events); stateEvents.forEach(function(e) { - client.emit("event", e); + client.emit(ClientEvent.Event, e); }); timelineEvents.forEach(function(e) { - client.emit("event", e); + client.emit(ClientEvent.Event, e); }); threadedEvents.forEach(function(e) { - client.emit("event", e); + client.emit(ClientEvent.Event, e); }); accountDataEvents.forEach(function(e) { - client.emit("event", e); + client.emit(ClientEvent.Event, e); }); room.updateMyMembership("leave"); @@ -1551,7 +1561,7 @@ export class SyncApi { group.setMyMembership(sectionName); if (isBrandNew) { // Now we've filled in all the fields, emit the Group event - this.client.emit("Group", group); + this.client.emit(ClientEvent.Group, group); } } } @@ -1720,8 +1730,12 @@ export class SyncApi { /** * @experimental */ - private processThreadEvents(room: Room, threadedEvents: MatrixEvent[]): Promise { - return this.client.processThreadEvents(room, threadedEvents); + private processThreadEvents( + room: Room, + threadedEvents: MatrixEvent[], + toStartOfTimeline: boolean, + ): Promise { + return this.client.processThreadEvents(room, threadedEvents, toStartOfTimeline); } // extractRelatedEvents(event: MatrixEvent, events: MatrixEvent[], relatedEvents: MatrixEvent[] = []): MatrixEvent[] { @@ -1778,7 +1792,7 @@ export class SyncApi { const old = this.syncState; this.syncState = newState; this.syncStateData = data; - this.client.emit("sync", this.syncState, old, data); + this.client.emit(ClientEvent.Sync, this.syncState, old, data); } /** @@ -1796,8 +1810,11 @@ export class SyncApi { function createNewUser(client: MatrixClient, userId: string): User { const user = new User(userId); client.reEmitter.reEmit(user, [ - "User.avatarUrl", "User.displayName", "User.presence", - "User.currentlyActive", "User.lastPresenceTs", + UserEvent.AvatarUrl, + UserEvent.DisplayName, + UserEvent.Presence, + UserEvent.CurrentlyActive, + UserEvent.LastPresenceTs, ]); return user; } diff --git a/src/utils.ts b/src/utils.ts index 136d7ffe013..e17607808d0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -443,7 +443,7 @@ export function isNullOrUndefined(val: any): boolean { } export interface IDeferred { - resolve: (value: T) => void; + resolve: (value: T | Promise) => void; reject: (reason?: any) => void; promise: Promise; } diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index e96928c0dd6..16f443b4bc3 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -2,6 +2,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 New Vector Ltd Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -21,8 +22,6 @@ limitations under the License. * @module webrtc/call */ -import { EventEmitter } from 'events'; - import { logger } from '../logger'; import * as utils from '../utils'; import { MatrixEvent } from '../models/event'; @@ -47,6 +46,7 @@ import { import { CallFeed } from './callFeed'; import { MatrixClient } from "../client"; import { ISendEventResponse } from "../@types/requests"; +import { EventEmitterEvents, TypedEventEmitter } from "../models/typed-event-emitter"; // events: hangup, error(err), replaced(call), state(state, oldState) @@ -241,6 +241,21 @@ function genCallID(): string { return Date.now().toString() + randomString(16); } +export type CallEventHandlerMap = { + [CallEvent.DataChannel]: (channel: RTCDataChannel) => void; + [CallEvent.FeedsChanged]: (feeds: CallFeed[]) => void; + [CallEvent.Replaced]: (newCall: MatrixCall) => void; + [CallEvent.Error]: (error: CallError) => void; + [CallEvent.RemoteHoldUnhold]: (onHold: boolean) => void; + [CallEvent.LocalHoldUnhold]: (onHold: boolean) => void; + [CallEvent.LengthChanged]: (length: number) => void; + [CallEvent.State]: (state: CallState, oldState?: CallState) => void; + [CallEvent.Hangup]: () => void; + [CallEvent.AssertedIdentityChanged]: () => void; + /* @deprecated */ + [CallEvent.HoldUnhold]: (onHold: boolean) => void; +}; + /** * Construct a new Matrix Call. * @constructor @@ -252,7 +267,7 @@ function genCallID(): string { * @param {Array} opts.turnServers Optional. A list of TURN servers. * @param {MatrixClient} opts.client The Matrix Client instance to send events to. */ -export class MatrixCall extends EventEmitter { +export class MatrixCall extends TypedEventEmitter { public roomId: string; public callId: string; public state = CallState.Fledgling; @@ -571,9 +586,11 @@ export class MatrixCall extends EventEmitter { private pushNewLocalFeed(stream: MediaStream, purpose: SDPStreamMetadataPurpose, addToPeerConnection = true): void { const userId = this.client.getUserId(); - // TODO: Find out what is going on here - // why do we enable audio (and only audio) tracks here? -- matthew + // Tracks don't always start off enabled, eg. chrome will give a disabled + // audio track if you ask for user media audio and already had one that + // you'd set to disabled (presumably because it clones them internally). setTracksEnabled(stream.getAudioTracks(), true); + setTracksEnabled(stream.getVideoTracks(), true); // We try to replace an existing feed if there already is one with the same purpose const existingFeed = this.getLocalFeeds().find((feed) => feed.purpose === purpose); @@ -616,7 +633,8 @@ export class MatrixCall extends EventEmitter { `id="${track.id}", ` + `kind="${track.kind}", ` + `streamId="${callFeed.stream.id}", ` + - `streamPurpose="${callFeed.purpose}"` + + `streamPurpose="${callFeed.purpose}", ` + + `enabled=${track.enabled}` + `) to peer connection`, ); senderArray.push(this.peerConn.addTrack(track, callFeed.stream)); @@ -933,29 +951,13 @@ export class MatrixCall extends EventEmitter { if (!this.opponentSupportsSDPStreamMetadata()) return; try { - const upgradeAudio = audio && !this.hasLocalUserMediaAudioTrack; - const upgradeVideo = video && !this.hasLocalUserMediaVideoTrack; - logger.debug(`Upgrading call: audio?=${upgradeAudio} video?=${upgradeVideo}`); - - const stream = await this.client.getMediaHandler().getUserMediaStream(upgradeAudio, upgradeVideo, false); - if (upgradeAudio && upgradeVideo) { - if (this.hasLocalUserMediaAudioTrack) return; - if (this.hasLocalUserMediaVideoTrack) return; - - this.pushNewLocalFeed(stream, SDPStreamMetadataPurpose.Usermedia); - } else if (upgradeAudio) { - if (this.hasLocalUserMediaAudioTrack) return; - - const audioTrack = stream.getAudioTracks()[0]; - this.localUsermediaStream.addTrack(audioTrack); - this.peerConn.addTrack(audioTrack, this.localUsermediaStream); - } else if (upgradeVideo) { - if (this.hasLocalUserMediaVideoTrack) return; - - const videoTrack = stream.getVideoTracks()[0]; - this.localUsermediaStream.addTrack(videoTrack); - this.peerConn.addTrack(videoTrack, this.localUsermediaStream); - } + const getAudio = audio || this.hasLocalUserMediaAudioTrack; + const getVideo = video || this.hasLocalUserMediaVideoTrack; + + // updateLocalUsermediaStream() will take the tracks, use them as + // replacement and throw the stream away, so it isn't reusable + const stream = await this.client.getMediaHandler().getUserMediaStream(getAudio, getVideo, false); + await this.updateLocalUsermediaStream(stream, audio, video); } catch (error) { logger.error("Failed to upgrade the call", error); this.emit(CallEvent.Error, @@ -1071,6 +1073,63 @@ export class MatrixCall extends EventEmitter { } } + /** + * Replaces/adds the tracks from the passed stream to the localUsermediaStream + * @param {MediaStream} stream to use a replacement for the local usermedia stream + */ + public async updateLocalUsermediaStream( + stream: MediaStream, forceAudio = false, forceVideo = false, + ): Promise { + const callFeed = this.localUsermediaFeed; + const audioEnabled = forceAudio || (!callFeed.isAudioMuted() && !this.remoteOnHold); + const videoEnabled = forceVideo || (!callFeed.isVideoMuted() && !this.remoteOnHold); + setTracksEnabled(stream.getAudioTracks(), audioEnabled); + setTracksEnabled(stream.getVideoTracks(), videoEnabled); + + // We want to keep the same stream id, so we replace the tracks rather than the whole stream + for (const track of this.localUsermediaStream.getTracks()) { + this.localUsermediaStream.removeTrack(track); + track.stop(); + } + for (const track of stream.getTracks()) { + this.localUsermediaStream.addTrack(track); + } + + const newSenders = []; + + for (const track of stream.getTracks()) { + const oldSender = this.usermediaSenders.find((sender) => sender.track?.kind === track.kind); + let newSender: RTCRtpSender; + + if (oldSender) { + logger.info( + `Replacing track (` + + `id="${track.id}", ` + + `kind="${track.kind}", ` + + `streamId="${stream.id}", ` + + `streamPurpose="${callFeed.purpose}"` + + `) to peer connection`, + ); + await oldSender.replaceTrack(track); + newSender = oldSender; + } else { + logger.info( + `Adding track (` + + `id="${track.id}", ` + + `kind="${track.kind}", ` + + `streamId="${stream.id}", ` + + `streamPurpose="${callFeed.purpose}"` + + `) to peer connection`, + ); + newSender = this.peerConn.addTrack(track, this.localUsermediaStream); + } + + newSenders.push(newSender); + } + + this.usermediaSenders = newSenders; + } + /** * Set whether our outbound video should be muted or not. * @param {boolean} muted True to mute the outbound video. @@ -1199,8 +1258,8 @@ export class MatrixCall extends EventEmitter { [SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(), }); - const micShouldBeMuted = this.localUsermediaFeed?.isAudioMuted() || this.remoteOnHold; - const vidShouldBeMuted = this.localUsermediaFeed?.isVideoMuted() || this.remoteOnHold; + const micShouldBeMuted = this.isMicrophoneMuted() || this.remoteOnHold; + const vidShouldBeMuted = this.isLocalVideoMuted() || this.remoteOnHold; setTracksEnabled(this.localUsermediaStream.getAudioTracks(), !micShouldBeMuted); setTracksEnabled(this.localUsermediaStream.getVideoTracks(), !vidShouldBeMuted); @@ -1973,7 +2032,7 @@ export class MatrixCall extends EventEmitter { this.peerConn.close(); } if (shouldEmit) { - this.emit(CallEvent.Hangup, this); + this.emit(CallEvent.Hangup); } } @@ -1995,7 +2054,7 @@ export class MatrixCall extends EventEmitter { } private checkForErrorListener(): void { - if (this.listeners("error").length === 0) { + if (this.listeners(EventEmitterEvents.Error).length === 0) { throw new Error( "You MUST attach an error listener using call.on('error', function() {})", ); @@ -2064,6 +2123,12 @@ export class MatrixCall extends EventEmitter { try { const stream = await this.client.getMediaHandler().getUserMediaStream(audio, video); + + // make sure all the tracks are enabled (same as pushNewLocalFeed - + // we probably ought to just have one code path for adding streams) + setTracksEnabled(stream.getAudioTracks(), true); + setTracksEnabled(stream.getVideoTracks(), true); + const callFeed = new CallFeed({ client: this.client, roomId: this.roomId, diff --git a/src/webrtc/callEventHandler.ts b/src/webrtc/callEventHandler.ts index 6599971921e..f190bde6016 100644 --- a/src/webrtc/callEventHandler.ts +++ b/src/webrtc/callEventHandler.ts @@ -14,17 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixEvent } from '../models/event'; +import { MatrixEvent, MatrixEventEvent } from '../models/event'; import { logger } from '../logger'; -import { createNewMatrixCall, MatrixCall, CallErrorCode, CallState, CallDirection } from './call'; +import { CallDirection, CallErrorCode, CallState, createNewMatrixCall, MatrixCall } from './call'; import { EventType } from '../@types/event'; -import { MatrixClient } from '../client'; +import { ClientEvent, MatrixClient } from '../client'; import { MCallAnswer, MCallHangupReject } from "./callEventTypes"; +import { SyncState } from "../sync"; +import { RoomEvent } from "../models/room"; // Don't ring unless we'd be ringing for at least 3 seconds: the user needs some // time to press the 'accept' button const RING_GRACE_PERIOD = 3000; +export enum CallEventHandlerEvent { + Incoming = "Call.incoming", +} + +export type CallEventHandlerEventHandlerMap = { + [CallEventHandlerEvent.Incoming]: (call: MatrixCall) => void; +}; + export class CallEventHandler { client: MatrixClient; calls: Map; @@ -47,17 +57,17 @@ export class CallEventHandler { } public start() { - this.client.on("sync", this.evaluateEventBuffer); - this.client.on("Room.timeline", this.onRoomTimeline); + this.client.on(ClientEvent.Sync, this.evaluateEventBuffer); + this.client.on(RoomEvent.Timeline, this.onRoomTimeline); } public stop() { - this.client.removeListener("sync", this.evaluateEventBuffer); - this.client.removeListener("Room.timeline", this.onRoomTimeline); + this.client.removeListener(ClientEvent.Sync, this.evaluateEventBuffer); + this.client.removeListener(RoomEvent.Timeline, this.onRoomTimeline); } private evaluateEventBuffer = async () => { - if (this.client.getSyncState() === "SYNCING") { + if (this.client.getSyncState() === SyncState.Syncing) { await Promise.all(this.callEventBuffer.map(event => { this.client.decryptEventIfNeeded(event); })); @@ -101,7 +111,7 @@ export class CallEventHandler { if (event.isBeingDecrypted() || event.isDecryptionFailure()) { // add an event listener for once the event is decrypted. - event.once("Event.decrypted", async () => { + event.once(MatrixEventEvent.Decrypted, async () => { if (!this.eventIsACall(event)) return; if (this.callEventBuffer.includes(event)) { @@ -221,7 +231,7 @@ export class CallEventHandler { call.hangup(CallErrorCode.Replaced, true); } } else { - this.client.emit("Call.incoming", call); + this.client.emit(CallEventHandlerEvent.Incoming, call); } return; } else if (type === EventType.CallCandidates) { diff --git a/src/webrtc/callFeed.ts b/src/webrtc/callFeed.ts index 0c23f3832ce..8f61afaa5d0 100644 --- a/src/webrtc/callFeed.ts +++ b/src/webrtc/callFeed.ts @@ -14,11 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import EventEmitter from "events"; - import { SDPStreamMetadataPurpose } from "./callEventTypes"; import { MatrixClient } from "../client"; import { RoomMember } from "../models/room-member"; +import { TypedEventEmitter } from "../models/typed-event-emitter"; const POLLING_INTERVAL = 200; // ms export const SPEAKING_THRESHOLD = -60; // dB @@ -47,7 +46,14 @@ export enum CallFeedEvent { Speaking = "speaking", } -export class CallFeed extends EventEmitter { +type EventHandlerMap = { + [CallFeedEvent.NewStream]: (stream: MediaStream) => void; + [CallFeedEvent.MuteStateChanged]: (audioMuted: boolean, videoMuted: boolean) => void; + [CallFeedEvent.VolumeChanged]: (volume: number) => void; + [CallFeedEvent.Speaking]: (speaking: boolean) => void; +}; + +export class CallFeed extends TypedEventEmitter { public stream: MediaStream; public userId: string; public purpose: SDPStreamMetadataPurpose; diff --git a/src/webrtc/mediaHandler.ts b/src/webrtc/mediaHandler.ts index b1c599d4513..ba84ca899a9 100644 --- a/src/webrtc/mediaHandler.ts +++ b/src/webrtc/mediaHandler.ts @@ -2,7 +2,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 New Vector Ltd Copyright 2019, 2020 The Matrix.org Foundation C.I.C. -Copyright 2021 Šimon Brandner +Copyright 2021 - 2022 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,20 +18,30 @@ limitations under the License. */ import { logger } from "../logger"; +import { MatrixClient } from "../client"; +import { CallState } from "./call"; export class MediaHandler { private audioInput: string; private videoInput: string; - private userMediaStreams: MediaStream[] = []; - private screensharingStreams: MediaStream[] = []; + private localUserMediaStream?: MediaStream; + public userMediaStreams: MediaStream[] = []; + public screensharingStreams: MediaStream[] = []; + + constructor(private client: MatrixClient) { } /** * Set an audio input device to use for MatrixCalls * @param {string} deviceId the identifier for the device * undefined treated as unset */ - public setAudioInput(deviceId: string): void { + public async setAudioInput(deviceId: string): Promise { + logger.info("LOG setting audio input to", deviceId); + + if (this.audioInput === deviceId) return; + this.audioInput = deviceId; + await this.updateLocalUsermediaStreams(); } /** @@ -39,8 +49,39 @@ export class MediaHandler { * @param {string} deviceId the identifier for the device * undefined treated as unset */ - public setVideoInput(deviceId: string): void { + public async setVideoInput(deviceId: string): Promise { + logger.info("LOG setting video input to", deviceId); + + if (this.videoInput === deviceId) return; + this.videoInput = deviceId; + await this.updateLocalUsermediaStreams(); + } + + /** + * Requests new usermedia streams and replace the old ones + */ + public async updateLocalUsermediaStreams(): Promise { + if (this.userMediaStreams.length === 0) return; + + const callMediaStreamParams: Map = new Map(); + for (const call of this.client.callEventHandler.calls.values()) { + callMediaStreamParams.set(call.callId, { + audio: call.hasLocalUserMediaAudioTrack, + video: call.hasLocalUserMediaVideoTrack, + }); + } + + for (const call of this.client.callEventHandler.calls.values()) { + if (call.state === CallState.Ended || !callMediaStreamParams.has(call.callId)) continue; + + const { audio, video } = callMediaStreamParams.get(call.callId); + + // This stream won't be reusable as we will replace the tracks of the old stream + const stream = await this.getUserMediaStream(audio, video, false); + + await call.updateLocalUsermediaStream(stream); + } } public async hasAudioDevice(): Promise { @@ -65,20 +106,44 @@ export class MediaHandler { let stream: MediaStream; - // Find a stream with matching tracks - const matchingStream = this.userMediaStreams.find((stream) => { - if (shouldRequestAudio !== (stream.getAudioTracks().length > 0)) return false; - if (shouldRequestVideo !== (stream.getVideoTracks().length > 0)) return false; - return true; - }); - - if (matchingStream) { - logger.log("Cloning user media stream", matchingStream.id); - stream = matchingStream.clone(); - } else { + if ( + !this.localUserMediaStream || + (this.localUserMediaStream.getAudioTracks().length === 0 && shouldRequestAudio) || + (this.localUserMediaStream.getVideoTracks().length === 0 && shouldRequestVideo) || + (this.localUserMediaStream.getAudioTracks()[0]?.getSettings()?.deviceId !== this.audioInput) || + (this.localUserMediaStream.getVideoTracks()[0]?.getSettings()?.deviceId !== this.videoInput) + ) { const constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo); logger.log("Getting user media with constraints", constraints); stream = await navigator.mediaDevices.getUserMedia(constraints); + + for (const track of stream.getTracks()) { + const settings = track.getSettings(); + + if (track.kind === "audio") { + this.audioInput = settings.deviceId; + } else if (track.kind === "video") { + this.videoInput = settings.deviceId; + } + } + + if (reusable) { + this.localUserMediaStream = stream; + } + } else { + stream = this.localUserMediaStream.clone(); + + if (!shouldRequestAudio) { + for (const track of stream.getAudioTracks()) { + stream.removeTrack(track); + } + } + + if (!shouldRequestVideo) { + for (const track of stream.getVideoTracks()) { + stream.removeTrack(track); + } + } } if (reusable) { @@ -103,6 +168,10 @@ export class MediaHandler { logger.debug("Splicing usermedia stream out stream array", mediaStream.id); this.userMediaStreams.splice(index, 1); } + + if (this.localUserMediaStream === mediaStream) { + this.localUserMediaStream = undefined; + } } /** @@ -174,6 +243,7 @@ export class MediaHandler { this.userMediaStreams = []; this.screensharingStreams = []; + this.localUserMediaStream = undefined; } private getUserMediaContraints(audio: boolean, video: boolean): MediaStreamConstraints { diff --git a/yarn.lock b/yarn.lock index 2fdb6e01ac9..96c2f48a6f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1026,22 +1026,35 @@ exec-sh "^0.3.2" minimist "^1.2.0" -"@eslint/eslintrc@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.3.0.tgz#d736d6963d7003b6514e6324bec9c602ac340318" - integrity sha512-1JTKgrOKAHVivSvOYw+sJOunkBjUOvjqWk1DPja7ZFhIS2mX/4EgTT8M7eTK9jrKhL/FvXXEbQwIs3pg1xp3dg== +"@eslint/eslintrc@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.1.0.tgz#583d12dbec5d4f22f333f9669f7d0b7c7815b4d3" + integrity sha512-C1DfL7XX4nPqGd6jcP01W9pVM1HYCuUkFk1432D7F0v3JSlUIeOYn9oCoi3eoLZ+iwBSb29BMFxxny0YrrEZqg== dependencies: ajv "^6.12.4" - debug "^4.1.1" - espree "^7.3.0" - globals "^12.1.0" + debug "^4.3.2" + espree "^9.3.1" + globals "^13.9.0" ignore "^4.0.6" import-fresh "^3.2.1" - js-yaml "^3.13.1" - lodash "^4.17.20" + js-yaml "^4.1.0" minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@humanwhocodes/config-array@^0.9.2": + version "0.9.5" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7" + integrity sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw== + dependencies: + "@humanwhocodes/object-schema" "^1.2.1" + debug "^4.1.1" + minimatch "^3.0.4" + +"@humanwhocodes/object-schema@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" + integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -1720,12 +1733,12 @@ acorn@^4.0.4, acorn@~4.0.2: resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787" integrity sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c= -acorn@^7.0.0, acorn@^7.1.1, acorn@^7.4.0: +acorn@^7.0.0, acorn@^7.1.1: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.2.4: +acorn@^8.2.4, acorn@^8.7.0: version "8.7.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf" integrity sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ== @@ -1747,16 +1760,6 @@ ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.0.1: - version "8.10.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.10.0.tgz#e573f719bd3af069017e3b66538ab968d040e54d" - integrity sha512-bzqAEZOjkrUMl2afH8dknrq5KEk2SrwdBROR+vH1EKVQTqaUbJVPdc/gEdggTMM0Se+s+Ja4ju4TlNcStKl2Hw== - dependencies: - fast-deep-equal "^3.1.1" - json-schema-traverse "^1.0.0" - require-from-string "^2.0.2" - uri-js "^4.2.2" - align-text@^0.1.1, align-text@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" @@ -1785,11 +1788,6 @@ another-json@^0.2.0: resolved "https://registry.yarnpkg.com/another-json/-/another-json-0.2.0.tgz#b5f4019c973b6dd5c6506a2d93469cb6d32aeedc" integrity sha1-tfQBnJc7bdXGUGotk0acttMq7tw= -ansi-colors@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" - integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== - ansi-escapes@^4.2.1: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" @@ -1946,11 +1944,6 @@ ast-types@^0.14.2: dependencies: tslib "^2.0.1" -astral-regex@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" - integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== - asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -2912,7 +2905,7 @@ de-indent@^1.0.2: resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" integrity sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0= -debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2: +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2: version "4.3.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== @@ -3153,13 +3146,6 @@ end-of-stream@^1.1.0: dependencies: once "^1.4.0" -enquirer@^2.3.5: - version "2.3.6" - resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" - integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== - dependencies: - ansi-colors "^4.1.1" - entities@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" @@ -3272,6 +3258,11 @@ escape-string-regexp@^2.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + escodegen@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.0.0.tgz#5e32b12833e8aa8fa35e1bf0befa89380484c7dd" @@ -3342,12 +3333,13 @@ eslint-scope@^5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-utils@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" - integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== +eslint-scope@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642" + integrity sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw== dependencies: - eslint-visitor-keys "^1.1.0" + esrecurse "^4.3.0" + estraverse "^5.2.0" eslint-utils@^3.0.0: version "3.0.0" @@ -3356,11 +3348,6 @@ eslint-utils@^3.0.0: dependencies: eslint-visitor-keys "^2.0.0" -eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" - integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== - eslint-visitor-keys@^2.0.0, eslint-visitor-keys@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" @@ -3371,64 +3358,67 @@ eslint-visitor-keys@^3.0.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.2.0.tgz#6fbb166a6798ee5991358bc2daa1ba76cc1254a1" integrity sha512-IOzT0X126zn7ALX0dwFiUQEdsfzrm4+ISsQS8nukaJXwEyYKRSnEIIDULYg1mCtGp7UUXgfGl7BIolXREQK+XQ== -eslint@7.18.0: - version "7.18.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.18.0.tgz#7fdcd2f3715a41fe6295a16234bd69aed2c75e67" - integrity sha512-fbgTiE8BfUJZuBeq2Yi7J3RB3WGUQ9PNuNbmgi6jt9Iv8qrkxfy19Ds3OpL1Pm7zg3BtTVhvcUZbIRQ0wmSjAQ== +eslint-visitor-keys@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" + integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== + +eslint@8.9.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.9.0.tgz#a2a8227a99599adc4342fd9b854cb8d8d6412fdb" + integrity sha512-PB09IGwv4F4b0/atrbcMFboF/giawbBLVC7fyDamk5Wtey4Jh2K+rYaBhCAbUyEI4QzB1ly09Uglc9iCtFaG2Q== dependencies: - "@babel/code-frame" "^7.0.0" - "@eslint/eslintrc" "^0.3.0" + "@eslint/eslintrc" "^1.1.0" + "@humanwhocodes/config-array" "^0.9.2" ajv "^6.10.0" chalk "^4.0.0" cross-spawn "^7.0.2" - debug "^4.0.1" + debug "^4.3.2" doctrine "^3.0.0" - enquirer "^2.3.5" - eslint-scope "^5.1.1" - eslint-utils "^2.1.0" - eslint-visitor-keys "^2.0.0" - espree "^7.3.1" - esquery "^1.2.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.1.1" + eslint-utils "^3.0.0" + eslint-visitor-keys "^3.3.0" + espree "^9.3.1" + esquery "^1.4.0" esutils "^2.0.2" - file-entry-cache "^6.0.0" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" functional-red-black-tree "^1.0.1" - glob-parent "^5.0.0" - globals "^12.1.0" - ignore "^4.0.6" + glob-parent "^6.0.1" + globals "^13.6.0" + ignore "^5.2.0" import-fresh "^3.0.0" imurmurhash "^0.1.4" is-glob "^4.0.0" - js-yaml "^3.13.1" + js-yaml "^4.1.0" json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" - lodash "^4.17.20" + lodash.merge "^4.6.2" minimatch "^3.0.4" natural-compare "^1.4.0" optionator "^0.9.1" - progress "^2.0.0" - regexpp "^3.1.0" - semver "^7.2.1" - strip-ansi "^6.0.0" + regexpp "^3.2.0" + strip-ansi "^6.0.1" strip-json-comments "^3.1.0" - table "^6.0.4" text-table "^0.2.0" v8-compile-cache "^2.0.3" -espree@^7.3.0, espree@^7.3.1: - version "7.3.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" - integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g== +espree@^9.3.1: + version "9.3.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.1.tgz#8793b4bc27ea4c778c19908e0719e7b8f4115bcd" + integrity sha512-bvdyLmJMfwkV3NCRl5ZhJf22zBFo1y8bYh3VYb+bfzqNB4Je68P2sSuXyuFquzWLebHpNd2/d5uv7yoP9ISnGQ== dependencies: - acorn "^7.4.0" + acorn "^8.7.0" acorn-jsx "^5.3.1" - eslint-visitor-keys "^1.3.0" + eslint-visitor-keys "^3.3.0" esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.2.0: +esquery@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.4.0.tgz#2148ffc38b82e8c7057dfed48425b3e61f0f24a5" integrity sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w== @@ -3631,7 +3621,7 @@ fake-indexeddb@^3.1.2: dependencies: realistic-structured-clone "^2.0.1" -fast-deep-equal@^3.1.1: +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== @@ -3676,7 +3666,7 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" -file-entry-cache@^6.0.0: +file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== @@ -3912,13 +3902,20 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" -glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@~5.1.2: +glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== dependencies: is-glob "^4.0.1" +glob-parent@^6.0.1: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + glob@^7.0.0, glob@^7.1.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: version "7.2.0" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" @@ -3936,12 +3933,12 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -globals@^12.1.0: - version "12.4.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-12.4.0.tgz#a18813576a41b00a24a97e7f815918c2e19925f8" - integrity sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg== +globals@^13.6.0, globals@^13.9.0: + version "13.12.1" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.12.1.tgz#ec206be932e6c77236677127577aa8e50bf1c5cb" + integrity sha512-317dFlgY2pdJZ9rspXDks7073GpDmXdfbM3vYYp0HAMKGDh1FfWPleI2ljVNLQX5M5lXcAslTcPTrOrMEFOjyw== dependencies: - type-fest "^0.8.1" + type-fest "^0.20.2" globby@^11.0.4: version "11.1.0" @@ -5161,11 +5158,6 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== -json-schema-traverse@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" - integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== - json-schema@0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" @@ -5348,12 +5340,12 @@ lodash.memoize@~3.0.3: resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f" integrity sha1-LcvSwofLwKVcxCMovQxzYVDVPj8= -lodash.truncate@^4.4.2: - version "4.4.2" - resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" - integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM= +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.7.0: +lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -6168,11 +6160,6 @@ process@~0.11.0: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= -progress@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" - integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== - promise@^7.0.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" @@ -6554,7 +6541,7 @@ regex-not@^1.0.0, regex-not@^1.0.2: extend-shallow "^3.0.2" safe-regex "^1.1.0" -regexpp@^3.1.0, regexpp@^3.2.0: +regexpp@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== @@ -6629,11 +6616,6 @@ require-directory@^2.1.1: resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= -require-from-string@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" - integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== - require-main-filename@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" @@ -6785,7 +6767,7 @@ semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== -semver@^7.2.1, semver@^7.3.2, semver@^7.3.5: +semver@^7.3.2, semver@^7.3.5: version "7.3.5" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== @@ -6897,15 +6879,6 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== -slice-ansi@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" - integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== - dependencies: - ansi-styles "^4.0.0" - astral-regex "^2.0.0" - is-fullwidth-code-point "^3.0.0" - snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" @@ -7221,17 +7194,6 @@ syntax-error@^1.1.1: dependencies: acorn-node "^1.2.0" -table@^6.0.4: - version "6.8.0" - resolved "https://registry.yarnpkg.com/table/-/table-6.8.0.tgz#87e28f14fa4321c3377ba286f07b79b281a3b3ca" - integrity sha512-s/fitrbVeEyHKFa7mFdkuQMWlH1Wgw/yEXMt5xACT4ZpzWFluehAxRtUUQKPuWhaLAWhFcVx6w3oC8VKaUfPGA== - dependencies: - ajv "^8.0.1" - lodash.truncate "^4.4.2" - slice-ansi "^4.0.0" - string-width "^4.2.3" - strip-ansi "^6.0.1" - taffydb@2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/taffydb/-/taffydb-2.6.2.tgz#7cbcb64b5a141b6a2efc2c5d2c67b4e150b2a268" @@ -7487,6 +7449,11 @@ type-detect@4.0.8: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + type-fest@^0.21.3: version "0.21.3" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"