Compare commits

...

47 Commits

Author SHA1 Message Date
RiotRobot e15cf9976f v23.2.0 2023-01-31 10:46:15 +00:00
RiotRobot bdc3926417 Prepare changelog for v23.2.0 2023-01-31 10:46:12 +00:00
RiotRobot 1f58ee7f2c v23.2.0-rc.1 2023-01-24 11:21:25 +00:00
RiotRobot 2e28e9117a Prepare changelog for v23.2.0-rc.1 2023-01-24 11:21:22 +00:00
Andy Balaam a58a36e062 Tests for getRoomUpgradeHistory (#3088) 2023-01-24 10:45:04 +00:00
kegsay 6cf6a0c522 refactor: sliding sync: swap to lists-as-keys (#3086)
* refactor: sliding sync: swap to lists-as-keys

Update the request/response API shape to match the latest
MSC3575 version, which converts `lists` from being an array
of list objects to being a map of list objects.

* Linting

* prettier

* add extra setListRanges test

* Default to right type
2023-01-23 15:26:25 +00:00
Andy Balaam 02aa3edda4 Revert "refactor: sliding sync: swap to lists-as-keys (#3076)"
Reverting because the companion matrix-react-sdk change is not ready so
this is breaking our builds.

This reverts commit e04ea02c62.
2023-01-23 12:21:47 +00:00
kegsay e04ea02c62 refactor: sliding sync: swap to lists-as-keys (#3076)
* refactor: sliding sync: swap to lists-as-keys

Update the request/response API shape to match the latest
MSC3575 version, which converts `lists` from being an array
of list objects to being a map of list objects.

* Linting

* prettier

* add extra setListRanges test

* Default to right type
2023-01-23 11:45:22 +00:00
David Baker 64197bf4db Merge pull request #3082 from matrix-org/dbkr/ptt_null_member_workarounds
Add null check for our own member event
2023-01-20 13:11:16 +00:00
RiotRobot c309fe6942 Merge branch 'master' into develop 2023-01-20 12:28:33 +00:00
RiotRobot 8408f36c12 v23.1.1 2023-01-20 12:26:59 +00:00
RiotRobot dcd8f91e02 Prepare changelog for v23.1.1 2023-01-20 12:26:57 +00:00
ElementRobot cabe14d7e2 replace .at(-1) with array.length-1 (#3080) (#3081)
(cherry picked from commit baeb4acddf)

Co-authored-by: Germain <germains@element.io>
2023-01-20 12:13:36 +00:00
David Baker c019f2bb19 Add null check for our own member event
As per comment
2023-01-20 12:09:28 +00:00
Germain baeb4acddf replace .at(-1) with array.length-1 (#3080) 2023-01-20 12:05:35 +00:00
David Baker 4a6e9a0f8f Merge pull request #3078 from matrix-org/dbkr/group_call_double_stream_init
Handle group call getting initialised twice in quick succession
2023-01-20 11:01:46 +00:00
David Baker ea5ce8d1d8 Also wrap the initLocalFeed method of groupCall 2023-01-19 15:28:56 +00:00
David Baker 41854918a5 Merge branch 'develop' into dbkr/group_call_double_stream_init 2023-01-19 14:13:35 +00:00
David Baker 495642d041 Handle group call getting initialised twice in quick succession
This happens in dev mode with React 18 and caused extra user media
stream to end up floating around.

Fixes https://github.com/vector-im/element-call/issues/847
2023-01-19 14:04:59 +00:00
Richard van der Hoff c7210b9e9d Rename some of the .spec files which test crypto (#3077)
* `matrix-client-crypto.spec.ts` only tested a very specific bit of crypto (olm
  encryption). It goes back to the very early days, before Megolm was invented.
  I've renamed it to `olm-encryption-spec.ts`.

* `megolm-integ.spec.ts` is more of a general crypto test; it was just called
  `megolm` to distinguish it from the Olm tests above. Renamed to
  `crypto.spec.ts`.
2023-01-19 12:20:21 +00:00
Richard van der Hoff 83563c7a01 Implement decryption via the rust sdk (#3074)
A bunch of changes to tests, and wire up decryption.
2023-01-18 16:47:44 +00:00
RiotRobot 2fcc4811dd Resetting package fields for development 2023-01-18 13:38:53 +00:00
RiotRobot c392bc455d Merge branch 'master' into develop 2023-01-18 13:36:45 +00:00
RiotRobot b8711f15fd v23.1.0 2023-01-18 13:30:21 +00:00
RiotRobot 81f3aef960 Prepare changelog for v23.1.0 2023-01-18 13:30:19 +00:00
Richard van der Hoff d6b8332567 Element-R: stub implementations of some methods (#3075)
These are all called by the react-sdk when showing an encrypted event:

 * `getEventEncryptionInfo`
 * `checkUserTrust`
 * `checkDeviceTrust`

I don't particularly want to keep this API, but as a rapid means to an end,
let's stub them for now.
2023-01-18 12:07:49 +00:00
Richard van der Hoff 85b34b46c5 Remove brokenheaded encryption test (#3070)
This test seemed to be testing the behaviour of decrypting redacted events, but
that seems... strange. A redaction event cannot be encrypted (at least, there
is no spec for it), and it should be impossible to decrypt a (correctly)
redacted event, because such an event will lack a `ciphertext` property.

This test is just sticking a "redacted_because" property into a regular event,
which is a bit of a nonsense.
2023-01-17 11:27:30 +00:00
RiotRobot 4179f2978d v23.1.0-rc.4 2023-01-17 09:11:54 +00:00
RiotRobot 97df6db49c Prepare changelog for v23.1.0-rc.4 2023-01-17 09:11:52 +00:00
ElementRobot 3e693fab23 [Backport staging] Correctly handle limited sync responses by resetting the thread timeline (#3069)
Co-authored-by: Janne Mareike Koschinski <jannemk@element.io>
2023-01-17 09:06:28 +00:00
Janne Mareike Koschinski a34d06c7c2 Correctly handle limited sync responses by resetting the thread timeline (#3056)
* Reset thread livetimelines when desynced
* Implement workaround for https://github.com/matrix-org/synapse/issues/14830
2023-01-16 16:27:28 +00:00
David Baker 7b10fa367d Merge pull request #3066 from matrix-org/dbkr/olm_savesession_undecryptable_todevice_debug
Add some debugging & a debug event for decryption
2023-01-13 21:40:36 +00:00
Šimon Brandner 7f5d7091de Fix typos in src/webrtc/ (#3065) 2023-01-13 19:56:55 +01:00
David Baker 96f673ae92 Merge branch 'develop' into dbkr/olm_savesession_undecryptable_todevice_debug 2023-01-13 18:36:22 +00:00
David Baker 79faee7a67 Add emit so tests don't throw 2023-01-13 18:32:21 +00:00
David Baker 89d2984432 Add some debugging & a debug event for decryption
Adds a log line whenever we save a session and also adds an event
that's fired whenever we get a to-device event we can't decrypt
(hopefully the comment explains all).
2023-01-13 18:24:33 +00:00
Travis Ralston bc78784688 Extract v1 extensible events polls types out of the events-sdk (#3062)
* Extract v1 extensible events polls out of events-sdk

* Appease tsdoc?

* Appease naming standards

* Bring the tests over too
2023-01-13 10:02:27 -07:00
Richard van der Hoff eb058edb1b Fix spurious "Decryption key withheld" messages (#3061)
When we receive an `m.unavailable` notification, do not show it as "Decryption
key withheld".
2023-01-13 13:17:48 +00:00
Richard van der Hoff 4847d78b42 Improvements to megolm integration tests (#3060)
The megolm tests were making a few assumptions which they really shouldn't; in
particular:

 * They were creating mock events with event_ids not starting `$`, and lacking
   `sender`, `origin_server_ts` and `unsigned` properties

 * They were not including the (now) required `keys.ed25519` property inside
   the ciphertext of an olm message.

These work ok currently, but they aren't really correct, and they cause
problems when testing the new rust implementation.
2023-01-13 13:14:44 +00:00
renovate[bot] 789aec732a Update typescript-eslint monorepo to v5.48.1 (#3063)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-13 09:08:54 +00:00
renovate[bot] 3246114772 Update dependency docdash to v2.0.1 (#3057)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-12 18:38:54 +00:00
Richard van der Hoff aa1e118f18 Fix failure to start in firefox private browser (#3058) 2023-01-12 17:03:16 +00:00
Richard van der Hoff d23c3cb8b2 Improve logging in legacy megolm code (#3043)
* Use a PrefixedLogger throughout `megolm.ts`

Rather than manually adding `in ${this.roomId}` to each log line, use a
PrefixedLogger to achieve the same effect more consistently.

* Clean up logging in megolm.ts

Where we log a list of devices, we don't need the whole deviceinfo, just the
device id. All that noise makes it very hard to read the logs.

* Log users that we find in the room when encrypting

* Reduce log verbosity on decryption retries
2023-01-12 11:49:32 +00:00
Richard van der Hoff 9e37980e2d Handle edits which are bundled with an event, per MSC3925 (#3045) 2023-01-12 10:53:36 +00:00
renovate[bot] de176dbd66 Update all non-major dependencies (#3053)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-11 16:55:07 +00:00
renovate[bot] ac10b40f67 Update dependency @babel/core to v7.20.12 (#3054)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-11 16:46:47 +00:00
Michael Telatynski d7bf0f85c0 Fix browser entrypoint (#3051) 2023-01-11 15:17:55 +00:00
53 changed files with 3367 additions and 731 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: 📥 Download artifact
uses: dawidd6/action-download-artifact@e6e25ac3a2b93187502a8be1ef9e9603afc34925 # v2.24.2
uses: dawidd6/action-download-artifact@bd10f381a96414ce2b13a11bfa89902ba7cea07f # v2.24.3
with:
workflow: static_analysis.yml
run_id: ${{ github.event.workflow_run.id }}
+22 -10
View File
@@ -1,17 +1,26 @@
Changes in [23.1.0-rc.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.1.0-rc.3) (2023-01-13)
============================================================================================================
Changes in [23.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.2.0) (2023-01-31)
==================================================================================================
## ✨ Features
* Implement decryption via the rust sdk ([\#3074](https://github.com/matrix-org/matrix-js-sdk/pull/3074)).
* Handle edits which are bundled with an event, per MSC3925 ([\#3045](https://github.com/matrix-org/matrix-js-sdk/pull/3045)).
## 🐛 Bug Fixes
* Add null check for our own member event ([\#3082](https://github.com/matrix-org/matrix-js-sdk/pull/3082)).
* Handle group call getting initialised twice in quick succession ([\#3078](https://github.com/matrix-org/matrix-js-sdk/pull/3078)). Fixes vector-im/element-call#847.
* Correctly handle limited sync responses by resetting the thread timeline ([\#3056](https://github.com/matrix-org/matrix-js-sdk/pull/3056)). Fixes vector-im/element-web#23952. Contributed by @justjanne.
* Fix failure to start in firefox private browser ([\#3058](https://github.com/matrix-org/matrix-js-sdk/pull/3058)). Fixes vector-im/element-web#24216.
Changes in [23.1.0-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.1.0-rc.2) (2023-01-12)
============================================================================================================
## 🐛 Bug Fixes
* Fix spurious "Decryption key withheld" messages ([\#3061](https://github.com/matrix-org/matrix-js-sdk/pull/3061)). Fixes vector-im/element-web#23803.
* Fix browser entrypoint ([\#3051](https://github.com/matrix-org/matrix-js-sdk/pull/3051)). Fixes #3013.
Changes in [23.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.1.0-rc.1) (2023-01-11)
============================================================================================================
Changes in [23.1.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.1.1) (2023-01-20)
==================================================================================================
## 🐛 Bug Fixes
* Fix backwards compability for environment not support Array.prototype.at ([\#3080](https://github.com/matrix-org/matrix-js-sdk/pull/3080)).
Changes in [23.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.1.0) (2023-01-18)
==================================================================================================
## 🦖 Deprecations
* Remove extensible events v1 field population on legacy events ([\#3040](https://github.com/matrix-org/matrix-js-sdk/pull/3040)).
@@ -33,10 +42,13 @@ Changes in [23.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/ta
* Avoid triggering decryption errors when decrypting redacted events ([\#3004](https://github.com/matrix-org/matrix-js-sdk/pull/3004)). Fixes vector-im/element-web#24084.
* bugfix: upload OTKs in sliding sync mode ([\#3008](https://github.com/matrix-org/matrix-js-sdk/pull/3008)).
* Apply edits discovered from sync after thread is initialised ([\#3002](https://github.com/matrix-org/matrix-js-sdk/pull/3002)). Fixes vector-im/element-web#23921.
* sliding sync: Fix issue where no unsubs are sent when switching rooms ([\#2991](https://github.com/matrix-org/matrix-js-sdk/pull/2991)).
* Sliding sync: Fix issue where no unsubs are sent when switching rooms ([\#2991](https://github.com/matrix-org/matrix-js-sdk/pull/2991)).
* Threads are missing from the timeline ([\#2996](https://github.com/matrix-org/matrix-js-sdk/pull/2996)). Fixes vector-im/element-web#24036.
* Close all streams when a call ends ([\#2992](https://github.com/matrix-org/matrix-js-sdk/pull/2992)). Fixes vector-im/element-call#742.
* Resume to-device message queue after resumed sync ([\#2920](https://github.com/matrix-org/matrix-js-sdk/pull/2920)). Fixes matrix-org/element-web-rageshakes#17170.
* Fix browser entrypoint ([\#3051](https://github.com/matrix-org/matrix-js-sdk/pull/3051)). Fixes #3013.
* Fix failure to start in firefox private browser ([\#3058](https://github.com/matrix-org/matrix-js-sdk/pull/3058)). Fixes vector-im/element-web#24216.
* Correctly handle limited sync responses by resetting the thread timeline ([\#3056](https://github.com/matrix-org/matrix-js-sdk/pull/3056)). Fixes vector-im/element-web#23952.
Changes in [23.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.0.0) (2022-12-21)
==================================================================================================
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "23.1.0-rc.3",
"version": "23.2.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=16.0.0"
@@ -33,7 +33,7 @@
"matrix-org"
],
"main": "./lib/index.js",
"browser": "./lib/browser-index.js",
"browser": "./src/browser-index.ts",
"matrix_src_main": "./src/index.ts",
"matrix_src_browser": "./src/browser-index.ts",
"matrix_lib_main": "./lib/index.js",
@@ -98,7 +98,7 @@
"browserify": "^17.0.0",
"docdash": "^2.0.0",
"domexception": "^4.0.0",
"eslint": "8.29.0",
"eslint": "8.31.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.5.1",
@@ -114,7 +114,7 @@
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"matrix-mock-request": "^2.5.0",
"prettier": "2.8.1",
"prettier": "2.8.2",
"rimraf": "^3.0.2",
"terser": "^5.5.1",
"tsify": "^5.0.2",
+12 -3
View File
@@ -115,13 +115,12 @@ export class TestClient {
}
/**
* Set up expectations that the client will upload device keys.
* Set up expectations that the client will upload device keys (and possibly one-time keys)
*/
public expectDeviceKeyUpload() {
this.httpBackend
.when("POST", "/keys/upload")
.respond<IKeysUploadResponse, IUploadKeysRequest>(200, (_path, content) => {
expect(content.one_time_keys).toBe(undefined);
expect(content.device_keys).toBeTruthy();
logger.log(this + ": received device keys");
@@ -129,7 +128,17 @@ export class TestClient {
expect(Object.keys(this.oneTimeKeys!).length).toEqual(0);
this.deviceKeys = content.device_keys;
return { one_time_key_counts: { signed_curve25519: 0 } };
// the first batch of one-time keys may be uploaded at the same time.
if (content.one_time_keys) {
logger.log(`${this}: received ${Object.keys(content.one_time_keys).length} one-time keys`);
this.oneTimeKeys = content.one_time_keys;
}
return {
one_time_key_counts: {
signed_curve25519: Object.keys(this.oneTimeKeys!).length,
},
};
});
}
@@ -1,6 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -17,6 +17,8 @@ limitations under the License.
import anotherjson from "another-json";
import MockHttpBackend from "matrix-mock-request";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import * as testUtils from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
@@ -38,9 +40,17 @@ import {
} from "../../src/matrix";
import { IDeviceKeys } from "../../src/crypto/dehydration";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { CRYPTO_BACKENDS, InitCrypto } from "../test-utils/test-utils";
const ROOM_ID = "!room:id";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
});
// start an Olm session with a given recipient
async function createOlmSession(olmAccount: Olm.Account, recipientTestClient: TestClient): Promise<Olm.Session> {
const keys = await recipientTestClient.awaitOneTimeKeyUpload();
@@ -59,13 +69,21 @@ interface ToDeviceEvent {
type: string;
}
// encrypt an event with olm
/** encrypt an event with an existing olm session */
function encryptOlmEvent(opts: {
/** the sender's user id */
sender?: string;
/** the sender's curve25519 key */
senderKey: string;
/** the sender's ed25519 key */
senderSigningKey: string;
/** the olm session to use for encryption */
p2pSession: Olm.Session;
/** the recipient client */
recipient: TestClient;
/** the payload of the message */
plaincontent?: object;
/** the event type of the payload */
plaintype?: string;
}): ToDeviceEvent {
expect(opts.senderKey).toBeTruthy();
@@ -78,6 +96,9 @@ function encryptOlmEvent(opts: {
recipient_keys: {
ed25519: opts.recipient.getSigningKey(),
},
keys: {
ed25519: opts.senderSigningKey,
},
sender: opts.sender || "@bob:xyz",
type: opts.plaintype || "m.test",
};
@@ -101,7 +122,7 @@ function encryptMegolmEvent(opts: {
groupSession: Olm.OutboundGroupSession;
plaintext?: Partial<IEvent>;
room_id?: string;
}): Pick<IEvent, "event_id" | "content" | "type"> {
}): IEvent {
expect(opts.senderKey).toBeTruthy();
expect(opts.groupSession).toBeTruthy();
@@ -119,30 +140,44 @@ function encryptMegolmEvent(opts: {
expect(opts.room_id).toBeTruthy();
plaintext.room_id = opts.room_id;
}
return encryptMegolmEventRawPlainText({ senderKey: opts.senderKey, groupSession: opts.groupSession, plaintext });
}
function encryptMegolmEventRawPlainText(opts: {
senderKey: string;
groupSession: Olm.OutboundGroupSession;
plaintext: Partial<IEvent>;
}): IEvent {
return {
event_id: "test_megolm_event_" + Math.random(),
event_id: "$test_megolm_event_" + Math.random(),
sender: "@not_the_real_sender:example.com",
origin_server_ts: 1672944778000,
content: {
algorithm: "m.megolm.v1.aes-sha2",
ciphertext: opts.groupSession.encrypt(JSON.stringify(plaintext)),
ciphertext: opts.groupSession.encrypt(JSON.stringify(opts.plaintext)),
device_id: "testDevice",
sender_key: opts.senderKey,
session_id: opts.groupSession.session_id(),
},
type: "m.room.encrypted",
unsigned: {},
};
}
// build an encrypted room_key event to share a group session
/** build an encrypted room_key event to share a group session, using an existing olm session */
function encryptGroupSessionKey(opts: {
senderKey: string;
recipient: TestClient;
/** sender's olm account */
olmAccount: Olm.Account;
/** sender's olm session with the recipient */
p2pSession: Olm.Session;
groupSession: Olm.OutboundGroupSession;
room_id?: string;
}): Partial<IEvent> {
const senderKeys = JSON.parse(opts.olmAccount.identity_keys());
return encryptOlmEvent({
senderKey: opts.senderKey,
senderKey: senderKeys.curve25519,
senderSigningKey: senderKeys.ed25519,
recipient: opts.recipient,
p2pSession: opts.p2pSession,
plaincontent: {
@@ -219,6 +254,7 @@ async function establishOlmSession(testClient: TestClient, peerOlmAccount: Olm.A
const p2pSession = await createOlmSession(peerOlmAccount, testClient);
const olmEvent = encryptOlmEvent({
senderKey: peerE2EKeys.curve25519,
senderSigningKey: peerE2EKeys.ed25519,
recipient: testClient,
p2pSession: p2pSession,
});
@@ -315,11 +351,17 @@ async function expectSendMegolmMessage(
return JSON.parse(r.plaintext);
}
describe("megolm", () => {
describe.each(Object.entries(CRYPTO_BACKENDS))("megolm (%s)", (backend: string, initCrypto: InitCrypto) => {
if (!global.Olm) {
// currently we use libolm to implement the crypto in the tests, so need it to be present.
logger.warn("not running megolm tests: Olm not present");
return;
}
// oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the
// Rust backend. Once we have full support in the rust sdk, it will go away.
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
const Olm = global.Olm;
let testOlmAccount = {} as unknown as Olm.Account;
@@ -384,29 +426,38 @@ describe("megolm", () => {
beforeEach(async () => {
aliceTestClient = new TestClient("@alice:localhost", "xzcvb", "akjgkrgjs");
await aliceTestClient.client.initCrypto();
await initCrypto(aliceTestClient.client);
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
await Olm.init();
testOlmAccount = new Olm.Account();
testOlmAccount.create();
const testE2eKeys = JSON.parse(testOlmAccount.identity_keys());
testSenderKey = testE2eKeys.curve25519;
});
afterEach(() => aliceTestClient.stop());
afterEach(async () => {
await aliceTestClient.stop();
});
it("Alice receives a megolm message", async () => {
await aliceTestClient.start();
aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceTestClient.client.crypto) {
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// make the room_key event
const roomKeyEncrypted = encryptGroupSessionKey({
senderKey: testSenderKey,
recipient: aliceTestClient,
olmAccount: testOlmAccount,
p2pSession: p2pSession,
groupSession: groupSession,
room_id: ROOM_ID,
@@ -444,20 +495,25 @@ describe("megolm", () => {
expect(decryptedEvent.getContent().body).toEqual("42");
});
it("Alice receives a megolm message before the session keys", async () => {
oldBackendOnly("Alice receives a megolm message before the session keys", async () => {
// https://github.com/vector-im/element-web/issues/2273
await aliceTestClient.start();
aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceTestClient.client.crypto) {
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// make the room_key event, but don't send it yet
const roomKeyEncrypted = encryptGroupSessionKey({
senderKey: testSenderKey,
recipient: aliceTestClient,
olmAccount: testOlmAccount,
p2pSession: p2pSession,
groupSession: groupSession,
room_id: ROOM_ID,
@@ -507,17 +563,22 @@ describe("megolm", () => {
it("Alice gets a second room_key message", async () => {
await aliceTestClient.start();
aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceTestClient.client.crypto) {
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// make the room_key event
const roomKeyEncrypted1 = encryptGroupSessionKey({
senderKey: testSenderKey,
recipient: aliceTestClient,
olmAccount: testOlmAccount,
p2pSession: p2pSession,
groupSession: groupSession,
room_id: ROOM_ID,
@@ -533,8 +594,8 @@ describe("megolm", () => {
// make a second room_key event now that we have advanced the group
// session.
const roomKeyEncrypted2 = encryptGroupSessionKey({
senderKey: testSenderKey,
recipient: aliceTestClient,
olmAccount: testOlmAccount,
p2pSession: p2pSession,
groupSession: groupSession,
room_id: ROOM_ID,
@@ -572,7 +633,7 @@ describe("megolm", () => {
expect(event.getContent().body).toEqual("42");
});
it("Alice sends a megolm message", async () => {
oldBackendOnly("Alice sends a megolm message", async () => {
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await aliceTestClient.start();
const p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount);
@@ -615,7 +676,7 @@ describe("megolm", () => {
]);
});
it("We shouldn't attempt to send to blocked devices", async () => {
oldBackendOnly("We shouldn't attempt to send to blocked devices", async () => {
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await aliceTestClient.start();
await establishOlmSession(aliceTestClient, testOlmAccount);
@@ -659,7 +720,7 @@ describe("megolm", () => {
expect(() => aliceTestClient.client.getGlobalErrorOnUnknownDevices()).toThrowError("encryption disabled");
});
it("should permit sending to unknown devices", async () => {
oldBackendOnly("should permit sending to unknown devices", async () => {
expect(aliceTestClient.client.getGlobalErrorOnUnknownDevices()).toBeTruthy();
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
@@ -717,7 +778,7 @@ describe("megolm", () => {
);
});
it("should disable sending to unverified devices", async () => {
oldBackendOnly("should disable sending to unverified devices", async () => {
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await aliceTestClient.start();
const p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount);
@@ -775,7 +836,7 @@ describe("megolm", () => {
});
});
it("We should start a new megolm session when a device is blocked", async () => {
oldBackendOnly("We should start a new megolm session when a device is blocked", async () => {
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await aliceTestClient.start();
const p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount);
@@ -833,7 +894,7 @@ describe("megolm", () => {
});
// https://github.com/vector-im/element-web/issues/2676
it("Alice should send to her other devices", async () => {
oldBackendOnly("Alice should send to her other devices", async () => {
// for this test, we make the testOlmAccount be another of Alice's devices.
// it ought to get included in messages Alice sends.
await aliceTestClient.start();
@@ -914,7 +975,7 @@ describe("megolm", () => {
expect(decrypted.content?.body).toEqual("test");
});
it("Alice should wait for device list to complete when sending a megolm message", async () => {
oldBackendOnly("Alice should wait for device list to complete when sending a megolm message", async () => {
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await aliceTestClient.start();
await establishOlmSession(aliceTestClient, testOlmAccount);
@@ -944,22 +1005,27 @@ describe("megolm", () => {
await Promise.all([downloadPromise, sendPromise]);
});
it("Alice exports megolm keys and imports them to a new device", async () => {
oldBackendOnly("Alice exports megolm keys and imports them to a new device", async () => {
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await aliceTestClient.start();
aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceTestClient.client.crypto) {
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
// establish an olm session with alice
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz";
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
// make the room_key event
const roomKeyEncrypted = encryptGroupSessionKey({
senderKey: testSenderKey,
recipient: aliceTestClient,
olmAccount: testOlmAccount,
p2pSession: p2pSession,
groupSession: groupSession,
room_id: ROOM_ID,
@@ -999,11 +1065,15 @@ describe("megolm", () => {
aliceTestClient.stop();
aliceTestClient = new TestClient("@alice:localhost", "device2", "access_token2");
await aliceTestClient.client.initCrypto();
await initCrypto(aliceTestClient.client);
await aliceTestClient.client.importRoomKeys(exported);
await aliceTestClient.start();
aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceTestClient.client.crypto) {
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
const syncResponse = {
next_batch: 1,
@@ -1079,17 +1149,22 @@ describe("megolm", () => {
it("Alice can decrypt a message with falsey content", async () => {
await aliceTestClient.start();
aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceTestClient.client.crypto) {
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// make the room_key event
const roomKeyEncrypted = encryptGroupSessionKey({
senderKey: testSenderKey,
recipient: aliceTestClient,
olmAccount: testOlmAccount,
p2pSession: p2pSession,
groupSession: groupSession,
room_id: ROOM_ID,
@@ -1101,17 +1176,11 @@ describe("megolm", () => {
room_id: ROOM_ID,
};
const messageEncrypted = {
event_id: "test_megolm_event",
content: {
algorithm: "m.megolm.v1.aes-sha2",
ciphertext: groupSession.encrypt(JSON.stringify(plaintext)),
device_id: "testDevice",
sender_key: testSenderKey,
session_id: groupSession.session_id(),
},
type: "m.room.encrypted",
};
const messageEncrypted = encryptMegolmEventRawPlainText({
senderKey: testSenderKey,
groupSession: groupSession,
plaintext: plaintext,
});
// Alice gets both the events in a single sync
const syncResponse = {
@@ -1138,79 +1207,21 @@ describe("megolm", () => {
expect(decryptedEvent.getClearContent()).toBeUndefined();
});
it("should successfully decrypt bundled redaction events that don't include a room_id in their /sync data", async () => {
await aliceTestClient.start();
aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// make the room_key event
const roomKeyEncrypted = encryptGroupSessionKey({
senderKey: testSenderKey,
recipient: aliceTestClient,
p2pSession: p2pSession,
groupSession: groupSession,
room_id: ROOM_ID,
});
// encrypt a message with the group session
const messageEncrypted = encryptMegolmEvent({
senderKey: testSenderKey,
groupSession: groupSession,
room_id: ROOM_ID,
});
const redactionEncrypted = encryptMegolmEvent({
senderKey: testSenderKey,
groupSession: groupSession,
plaintext: {
room_id: ROOM_ID,
type: "m.room.redaction",
redacts: messageEncrypted.event_id,
content: { reason: "redaction test" },
},
});
const messageEncryptedWithRedaction = {
...messageEncrypted,
unsigned: { redacted_because: redactionEncrypted },
};
const syncResponse = {
next_batch: 1,
to_device: {
events: [roomKeyEncrypted],
},
rooms: {
join: {
[ROOM_ID]: { timeline: { events: [messageEncryptedWithRedaction] } },
},
},
};
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse);
await aliceTestClient.flushSync();
const room = aliceTestClient.client.getRoom(ROOM_ID)!;
const event = room.getLiveTimeline().getEvents()[0];
expect(event.isEncrypted()).toBe(true);
await event.attemptDecryption(aliceTestClient.client.crypto!);
expect(event.getContent()).toEqual({});
const redactionEvent: any = event.getRedactionEvent();
expect(redactionEvent.content.reason).toEqual("redaction test");
});
it("Alice receives shared history before being invited to a room by the sharer", async () => {
oldBackendOnly("Alice receives shared history before being invited to a room by the sharer", async () => {
const beccaTestClient = new TestClient("@becca:localhost", "foobar", "bazquux");
await beccaTestClient.client.initCrypto();
await aliceTestClient.start();
aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
await beccaTestClient.start();
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceTestClient.client.crypto) {
aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
aliceTestClient.client.crypto!.deviceList.getDeviceByIdentityKey = () => device;
aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => beccaTestClient.client.getUserId()!;
}
const beccaRoom = new Room(ROOM_ID, beccaTestClient.client, "@becca:localhost", {});
beccaTestClient.client.store.storeRoom(beccaRoom);
await beccaTestClient.client.setRoomEncryption(ROOM_ID, { algorithm: "m.megolm.v1.aes-sha2" });
@@ -1236,8 +1247,6 @@ describe("megolm", () => {
event.claimedEd25519Key = null;
const device = new DeviceInfo(beccaTestClient.client.deviceId!);
aliceTestClient.client.crypto!.deviceList.getDeviceByIdentityKey = () => device;
aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => beccaTestClient.client.getUserId()!;
// Create an olm session for Becca and Alice's devices
const aliceOtks = await aliceTestClient.awaitOneTimeKeyUpload();
@@ -1268,6 +1277,7 @@ describe("megolm", () => {
);
const encryptedForwardedKey = encryptOlmEvent({
sender: "@becca:localhost",
senderSigningKey: beccaTestClient.getSigningKey(),
senderKey: beccaTestClient.getDeviceKey(),
recipient: aliceTestClient,
p2pSession: p2pSession,
@@ -1349,7 +1359,7 @@ describe("megolm", () => {
await beccaTestClient.stop();
});
it("Alice receives shared history before being invited to a room by someone else", async () => {
oldBackendOnly("Alice receives shared history before being invited to a room by someone else", async () => {
const beccaTestClient = new TestClient("@becca:localhost", "foobar", "bazquux");
await beccaTestClient.client.initCrypto();
@@ -1413,6 +1423,7 @@ describe("megolm", () => {
const encryptedForwardedKey = encryptOlmEvent({
sender: "@becca:localhost",
senderKey: beccaTestClient.getDeviceKey(),
senderSigningKey: beccaTestClient.getSigningKey(),
recipient: aliceTestClient,
p2pSession: p2pSession,
plaincontent: {
@@ -1494,7 +1505,7 @@ describe("megolm", () => {
await beccaTestClient.stop();
});
it("allows sending an encrypted event as soon as room state arrives", async () => {
oldBackendOnly("allows sending an encrypted event as soon as room state arrives", async () => {
/* Empirically, clients expect to be able to send encrypted events as soon as the
* RoomStateEvent.NewMember notification is emitted, so test that works correctly.
*/
@@ -1619,7 +1630,7 @@ describe("megolm", () => {
await aliceTestClient.httpBackend.flush(membersPath, 1);
}
it("Sending an event initiates a member list sync", async () => {
oldBackendOnly("Sending an event initiates a member list sync", async () => {
// we expect a call to the /members list...
const memberListPromise = expectMembershipRequest(ROOM_ID, ["@bob:xyz"]);
@@ -1651,7 +1662,7 @@ describe("megolm", () => {
]);
});
it("loading the membership list inhibits a later load", async () => {
oldBackendOnly("loading the membership list inhibits a later load", async () => {
const room = aliceTestClient.client.getRoom(ROOM_ID)!;
await Promise.all([room.loadMembersIfNeeded(), expectMembershipRequest(ROOM_ID, ["@bob:xyz"])]);
@@ -1678,4 +1689,79 @@ describe("megolm", () => {
await Promise.all([sendPromise, megolmMessagePromise, aliceTestClient.httpBackend.flush("/keys/query", 1)]);
});
});
describe("m.room_key.withheld handling", () => {
// TODO: there are a bunch more tests for this sort of thing in spec/unit/crypto/algorithms/megolm.spec.ts.
// They should be converted to integ tests and moved.
oldBackendOnly("does not block decryption on an 'm.unavailable' report", async function () {
await aliceTestClient.start();
// there may be a key downloads for alice
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, {});
aliceTestClient.httpBackend.flush("/keys/query", 1, 5000);
// encrypt a message with a group session.
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
const messageEncryptedEvent = encryptMegolmEvent({
senderKey: testSenderKey,
groupSession: groupSession,
room_id: ROOM_ID,
});
// Alice gets the room message, but not the key
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 1,
rooms: {
join: { [ROOM_ID]: { timeline: { events: [messageEncryptedEvent] } } },
},
});
await aliceTestClient.flushSync();
// alice will (eventually) send a room-key request
aliceTestClient.httpBackend.when("PUT", "/sendToDevice/m.room_key_request/").respond(200, {});
await aliceTestClient.httpBackend.flush("/sendToDevice/m.room_key_request/", 1, 1000);
// at this point, the message should be a decryption failure
const room = aliceTestClient.client.getRoom(ROOM_ID)!;
const event = room.getLiveTimeline().getEvents()[0];
expect(event.isDecryptionFailure()).toBeTruthy();
// we want to wait for the message to be updated, so create a promise for it
const retryPromise = new Promise((resolve) => {
event.once(MatrixEventEvent.Decrypted, (ev) => {
resolve(ev);
});
});
// alice gets back a room-key-withheld notification
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 2,
to_device: {
events: [
{
type: "m.room_key.withheld",
sender: "@bob:example.com",
content: {
algorithm: "m.megolm.v1.aes-sha2",
room_id: ROOM_ID,
session_id: groupSession.session_id(),
sender_key: testSenderKey,
code: "m.unavailable",
reason: "",
},
},
],
},
});
await aliceTestClient.flushSync();
// the withheld notification should trigger a retry; wait for it
await retryPromise;
// finally: the message should still be a regular decryption failure, not a withheld notification.
expect(event.getContent().body).not.toContain("withheld");
});
});
});
@@ -22,7 +22,7 @@ limitations under the License.
*
* Note that megolm (group) conversation is not tested here.
*
* See also `megolm.spec.js`.
* See also `crypto.spec.js`.
*/
// load olm before the sdk if possible
+4 -5
View File
@@ -52,10 +52,9 @@ describe("SlidingSyncSdk", () => {
const selfAccessToken = "aseukfgwef";
const mockifySlidingSync = (s: SlidingSync): SlidingSync => {
s.getList = jest.fn();
s.getListParams = jest.fn();
s.getListData = jest.fn();
s.getRoomSubscriptions = jest.fn();
s.listLength = jest.fn();
s.modifyRoomSubscriptionInfo = jest.fn();
s.modifyRoomSubscriptions = jest.fn();
s.registerExtension = jest.fn();
@@ -115,7 +114,7 @@ describe("SlidingSyncSdk", () => {
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
httpBackend = testClient.httpBackend;
client = testClient.client;
mockSlidingSync = mockifySlidingSync(new SlidingSync("", [], {}, client, 0));
mockSlidingSync = mockifySlidingSync(new SlidingSync("", new Map(), {}, client, 0));
if (testOpts.withCrypto) {
httpBackend!.when("GET", "/room_keys/version").respond(404, {});
await client!.initCrypto();
@@ -549,7 +548,7 @@ describe("SlidingSyncSdk", () => {
it("emits SyncState.Reconnecting when < FAILED_SYNC_ERROR_THRESHOLD & SyncState.Error when over", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, {
pos: "h",
lists: [],
lists: {},
rooms: {},
extensions: {},
});
@@ -577,7 +576,7 @@ describe("SlidingSyncSdk", () => {
it("emits SyncState.Syncing after a previous SyncState.Error", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, {
pos: "i",
lists: [],
lists: {},
rooms: {},
extensions: {},
});
+151 -147
View File
@@ -64,10 +64,10 @@ describe("SlidingSync", () => {
let slidingSync: SlidingSync;
it("should start the sync loop upon calling start()", async () => {
slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client!, 1);
slidingSync = new SlidingSync(proxyBaseUrl, new Map(), {}, client!, 1);
const fakeResp = {
pos: "a",
lists: [],
lists: {},
rooms: {},
extensions: {},
};
@@ -90,7 +90,7 @@ describe("SlidingSync", () => {
it("should reset the connection on HTTP 400 and send everything again", async () => {
// seed the connection with some lists, extensions and subscriptions to verify they are sent again
slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client!, 1);
slidingSync = new SlidingSync(proxyBaseUrl, new Map(), {}, client!, 1);
const roomId = "!sub:localhost";
const subInfo = {
timeline_limit: 42,
@@ -114,7 +114,7 @@ describe("SlidingSync", () => {
};
slidingSync.modifyRoomSubscriptions(new Set([roomId]));
slidingSync.modifyRoomSubscriptionInfo(subInfo);
slidingSync.setList(0, listInfo);
slidingSync.setList("a", listInfo);
slidingSync.registerExtension(ext);
slidingSync.start();
@@ -128,7 +128,7 @@ describe("SlidingSync", () => {
expect(body.room_subscriptions).toEqual({
[roomId]: subInfo,
});
expect(body.lists[0]).toEqual(listInfo);
expect(body.lists["a"]).toEqual(listInfo);
expect(body.extensions).toBeTruthy();
expect(body.extensions["custom_extension"]).toEqual({ initial: true });
expect(req.queryParams!["pos"]).toBeUndefined();
@@ -137,7 +137,7 @@ describe("SlidingSync", () => {
.respond(200, function () {
return {
pos: "11",
lists: [{ count: 5 }],
lists: { a: { count: 5 } },
extensions: {},
txn_id: txnId,
};
@@ -151,7 +151,7 @@ describe("SlidingSync", () => {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeFalsy();
expect(body.lists[0]).toEqual({
expect(body.lists["a"]).toEqual({
ranges: [[0, 10]],
});
expect(body.extensions).toBeTruthy();
@@ -161,7 +161,7 @@ describe("SlidingSync", () => {
.respond(200, function () {
return {
pos: "12",
lists: [{ count: 5 }],
lists: { a: { count: 5 } },
extensions: {},
};
});
@@ -185,7 +185,7 @@ describe("SlidingSync", () => {
expect(body.room_subscriptions).toEqual({
[roomId]: subInfo,
});
expect(body.lists[0]).toEqual(listInfo);
expect(body.lists["a"]).toEqual(listInfo);
expect(body.extensions).toBeTruthy();
expect(body.extensions["custom_extension"]).toEqual({ initial: true });
expect(req.queryParams!["pos"]).toBeUndefined();
@@ -193,7 +193,7 @@ describe("SlidingSync", () => {
.respond(200, function () {
return {
pos: "1",
lists: [{ count: 6 }],
lists: { a: { count: 6 } },
extensions: {},
};
});
@@ -221,7 +221,7 @@ describe("SlidingSync", () => {
it("should be able to subscribe to a room", async () => {
// add the subscription
slidingSync = new SlidingSync(proxyBaseUrl, [], roomSubInfo, client!, 1);
slidingSync = new SlidingSync(proxyBaseUrl, new Map(), roomSubInfo, client!, 1);
slidingSync.modifyRoomSubscriptions(new Set([roomId]));
httpBackend!
.when("POST", syncUrl)
@@ -233,7 +233,7 @@ describe("SlidingSync", () => {
})
.respond(200, {
pos: "a",
lists: [],
lists: {},
extensions: {},
rooms: {
[roomId]: wantRoomData,
@@ -266,7 +266,7 @@ describe("SlidingSync", () => {
})
.respond(200, {
pos: "a",
lists: [],
lists: {},
extensions: {},
rooms: {
[roomId]: wantRoomData,
@@ -313,7 +313,7 @@ describe("SlidingSync", () => {
})
.respond(200, {
pos: "b",
lists: [],
lists: {},
extensions: {},
rooms: {
[anotherRoomID]: anotherRoomData,
@@ -344,7 +344,7 @@ describe("SlidingSync", () => {
})
.respond(200, {
pos: "b",
lists: [],
lists: {},
});
const p = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
@@ -402,19 +402,19 @@ describe("SlidingSync", () => {
is_dm: true,
},
};
slidingSync = new SlidingSync(proxyBaseUrl, [listReq], {}, client!, 1);
slidingSync = new SlidingSync(proxyBaseUrl, new Map([["a", listReq]]), {}, client!, 1);
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.log("list", body);
expect(body.lists).toBeTruthy();
expect(body.lists[0]).toEqual(listReq);
expect(body.lists["a"]).toEqual(listReq);
})
.respond(200, {
pos: "a",
lists: [
{
lists: {
a: {
count: 500,
ops: [
{
@@ -424,7 +424,7 @@ describe("SlidingSync", () => {
},
],
},
],
},
rooms: rooms,
});
const listenerData: Record<string, MSC3575RoomData> = {};
@@ -444,15 +444,14 @@ describe("SlidingSync", () => {
expect(listenerData[roomB]).toEqual(rooms[roomB]);
expect(listenerData[roomC]).toEqual(rooms[roomC]);
expect(slidingSync.listLength()).toEqual(1);
slidingSync.off(SlidingSyncEvent.RoomData, dataListener);
});
it("should be possible to retrieve list data", () => {
expect(slidingSync.getList(0)).toBeDefined();
expect(slidingSync.getList(5)).toBeNull();
expect(slidingSync.getListData(5)).toBeNull();
const syncData = slidingSync.getListData(0)!;
expect(slidingSync.getListParams("a")).toBeDefined();
expect(slidingSync.getListParams("b")).toBeNull();
expect(slidingSync.getListData("b")).toBeNull();
const syncData = slidingSync.getListData("a")!;
expect(syncData.joinedCount).toEqual(500); // from previous test
expect(syncData.roomIndexToRoomId).toEqual({
0: roomA,
@@ -467,17 +466,17 @@ describe("SlidingSync", () => {
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.log("next ranges", body.lists[0].ranges);
logger.log("next ranges", body.lists["a"].ranges);
expect(body.lists).toBeTruthy();
expect(body.lists[0]).toEqual({
expect(body.lists["a"]).toEqual({
// only the ranges should be sent as the rest are unchanged and sticky
ranges: newRanges,
});
})
.respond(200, {
pos: "b",
lists: [
{
lists: {
a: {
count: 500,
ops: [
{
@@ -487,15 +486,17 @@ describe("SlidingSync", () => {
},
],
},
],
},
});
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.RequestFinished;
});
slidingSync.setListRanges(0, newRanges);
slidingSync.setListRanges("a", newRanges);
await httpBackend!.flushAllExpected();
await responseProcessed;
// setListRanges for an invalid list key returns an error
await expect(slidingSync.setListRanges("idontexist", newRanges)).rejects.toBeTruthy();
});
it("should be possible to add an extra list", async () => {
@@ -513,19 +514,19 @@ describe("SlidingSync", () => {
const body = req.data;
logger.log("extra list", body);
expect(body.lists).toBeTruthy();
expect(body.lists[0]).toEqual({
expect(body.lists["a"]).toEqual({
// only the ranges should be sent as the rest are unchanged and sticky
ranges: newRanges,
});
expect(body.lists[1]).toEqual(extraListReq);
expect(body.lists["b"]).toEqual(extraListReq);
})
.respond(200, {
pos: "c",
lists: [
{
lists: {
a: {
count: 500,
},
{
b: {
count: 50,
ops: [
{
@@ -535,10 +536,10 @@ describe("SlidingSync", () => {
},
],
},
],
},
});
listenUntil(slidingSync, "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(1);
listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("b");
expect(joinedCount).toEqual(50);
expect(roomIndexToRoomId).toEqual({
0: roomA,
@@ -550,7 +551,7 @@ describe("SlidingSync", () => {
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
slidingSync.setList(1, extraListReq);
slidingSync.setList("b", extraListReq);
await httpBackend!.flushAllExpected();
await responseProcessed;
});
@@ -559,8 +560,8 @@ describe("SlidingSync", () => {
// move C (2) to A (0)
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "e",
lists: [
{
lists: {
a: {
count: 500,
ops: [
{
@@ -574,16 +575,16 @@ describe("SlidingSync", () => {
},
],
},
{
b: {
count: 50,
},
],
},
});
let listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomC,
@@ -603,8 +604,8 @@ describe("SlidingSync", () => {
// move C (0) back to A (2)
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "f",
lists: [
{
lists: {
a: {
count: 500,
ops: [
{
@@ -618,13 +619,13 @@ describe("SlidingSync", () => {
},
],
},
{
b: {
count: 50,
},
],
},
});
listPromise = listenUntil(slidingSync, "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
listPromise = listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomA,
@@ -644,8 +645,8 @@ describe("SlidingSync", () => {
it("should ignore invalid list indexes", async () => {
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "e",
lists: [
{
lists: {
a: {
count: 500,
ops: [
{
@@ -654,16 +655,16 @@ describe("SlidingSync", () => {
},
],
},
{
b: {
count: 50,
},
],
},
});
const listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomA,
@@ -684,8 +685,8 @@ describe("SlidingSync", () => {
it("should be possible to update a list", async () => {
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "g",
lists: [
{
lists: {
a: {
count: 42,
ops: [
{
@@ -699,13 +700,13 @@ describe("SlidingSync", () => {
},
],
},
{
b: {
count: 50,
},
],
},
});
// update the list with a new filter
slidingSync.setList(0, {
slidingSync.setList("a", {
filters: {
is_encrypted: true,
},
@@ -714,8 +715,8 @@ describe("SlidingSync", () => {
const listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(42);
expect(roomIndexToRoomId).toEqual({
0: roomB,
@@ -738,12 +739,12 @@ describe("SlidingSync", () => {
0: roomB,
1: roomC,
};
expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual(indexToRoomId);
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual(indexToRoomId);
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "f",
// currently the list is [B,C] so we will insert D then immediately delete it
lists: [
{
lists: {
a: {
count: 500,
ops: [
{
@@ -761,16 +762,16 @@ describe("SlidingSync", () => {
},
],
},
{
b: {
count: 50,
},
],
},
});
const listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual(indexToRoomId);
return true;
@@ -785,14 +786,14 @@ describe("SlidingSync", () => {
});
it("should handle deletions correctly", async () => {
expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual({
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
0: roomB,
1: roomC,
});
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "g",
lists: [
{
lists: {
a: {
count: 499,
ops: [
{
@@ -801,16 +802,16 @@ describe("SlidingSync", () => {
},
],
},
{
b: {
count: 50,
},
],
},
});
const listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(499);
expect(roomIndexToRoomId).toEqual({
0: roomC,
@@ -827,13 +828,13 @@ describe("SlidingSync", () => {
});
it("should handle insertions correctly", async () => {
expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual({
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
0: roomC,
});
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "h",
lists: [
{
lists: {
a: {
count: 500,
ops: [
{
@@ -843,16 +844,16 @@ describe("SlidingSync", () => {
},
],
},
{
b: {
count: 50,
},
],
},
});
let listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomC,
@@ -870,8 +871,8 @@ describe("SlidingSync", () => {
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "h",
lists: [
{
lists: {
a: {
count: 501,
ops: [
{
@@ -881,13 +882,13 @@ describe("SlidingSync", () => {
},
],
},
{
b: {
count: 50,
},
],
},
});
listPromise = listenUntil(slidingSync, "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
listPromise = listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(501);
expect(roomIndexToRoomId).toEqual({
0: roomC,
@@ -910,11 +911,14 @@ describe("SlidingSync", () => {
it("should handle insertions with a spurious DELETE correctly", async () => {
slidingSync = new SlidingSync(
proxyBaseUrl,
[
{
ranges: [[0, 20]],
},
],
new Map([
[
"a",
{
ranges: [[0, 20]],
},
],
]),
{},
client!,
1,
@@ -922,22 +926,22 @@ describe("SlidingSync", () => {
// initially start with nothing
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "a",
lists: [
{
lists: {
a: {
count: 0,
ops: [],
},
],
},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual({});
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({});
// insert a room
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "b",
lists: [
{
lists: {
a: {
count: 1,
ops: [
{
@@ -951,18 +955,18 @@ describe("SlidingSync", () => {
},
],
},
],
},
});
await httpBackend!.flushAllExpected();
expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual({
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
0: roomA,
});
// insert another room
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "c",
lists: [
{
lists: {
a: {
count: 1,
ops: [
{
@@ -976,10 +980,10 @@ describe("SlidingSync", () => {
},
],
},
],
},
});
await httpBackend!.flushAllExpected();
expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual({
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
0: roomB,
1: roomA,
});
@@ -987,8 +991,8 @@ describe("SlidingSync", () => {
// insert a final room
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "c",
lists: [
{
lists: {
a: {
count: 1,
ops: [
{
@@ -1002,10 +1006,10 @@ describe("SlidingSync", () => {
},
],
},
],
},
});
await httpBackend!.flushAllExpected();
expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual({
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
0: roomC,
1: roomB,
2: roomA,
@@ -1028,7 +1032,7 @@ describe("SlidingSync", () => {
required_state: [["m.room.name", ""]],
};
// add the subscription
slidingSync = new SlidingSync(proxyBaseUrl, [], roomSubInfo, client!, 1);
slidingSync = new SlidingSync(proxyBaseUrl, new Map(), roomSubInfo, client!, 1);
// modification before SlidingSync.start()
const subscribePromise = slidingSync.modifyRoomSubscriptions(new Set([roomId]));
let txnId: string | undefined;
@@ -1046,7 +1050,7 @@ describe("SlidingSync", () => {
return {
pos: "aaa",
txn_id: txnId,
lists: [],
lists: {},
extensions: {},
rooms: {
[roomId]: {
@@ -1065,7 +1069,7 @@ describe("SlidingSync", () => {
const newList = {
ranges: [[0, 20]],
};
const promise = slidingSync.setList(0, newList);
const promise = slidingSync.setList("a", newList);
let txnId: string | undefined;
httpBackend!
.when("POST", syncUrl)
@@ -1073,7 +1077,7 @@ describe("SlidingSync", () => {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeFalsy();
expect(body.lists[0]).toEqual(newList);
expect(body.lists["a"]).toEqual(newList);
expect(body.txn_id).toBeTruthy();
txnId = body.txn_id;
})
@@ -1081,7 +1085,7 @@ describe("SlidingSync", () => {
return {
pos: "bbb",
txn_id: txnId,
lists: [{ count: 5 }],
lists: { a: { count: 5 } },
extensions: {},
};
});
@@ -1090,7 +1094,7 @@ describe("SlidingSync", () => {
expect(txnId).toBeDefined();
});
it("should resolve setListRanges during a connection", async () => {
const promise = slidingSync.setListRanges(0, [[20, 40]]);
const promise = slidingSync.setListRanges("a", [[20, 40]]);
let txnId: string | undefined;
httpBackend!
.when("POST", syncUrl)
@@ -1098,7 +1102,7 @@ describe("SlidingSync", () => {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeFalsy();
expect(body.lists[0]).toEqual({
expect(body.lists["a"]).toEqual({
ranges: [[20, 40]],
});
expect(body.txn_id).toBeTruthy();
@@ -1108,7 +1112,7 @@ describe("SlidingSync", () => {
return {
pos: "ccc",
txn_id: txnId,
lists: [{ count: 5 }],
lists: { a: { count: 5 } },
extensions: {},
};
});
@@ -1150,10 +1154,10 @@ describe("SlidingSync", () => {
const pushTxn = function (req: MockHttpBackend["requests"][0]) {
gotTxnIds.push(req.data.txn_id);
};
const failPromise = slidingSync.setListRanges(0, [[20, 40]]);
const failPromise = slidingSync.setListRanges("a", [[20, 40]]);
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "e" }); // missing txn_id
await httpBackend!.flushAllExpected();
const failPromise2 = slidingSync.setListRanges(0, [[60, 70]]);
const failPromise2 = slidingSync.setListRanges("a", [[60, 70]]);
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "f" }); // missing txn_id
await httpBackend!.flushAllExpected();
@@ -1162,7 +1166,7 @@ describe("SlidingSync", () => {
expect(failPromise).rejects.toEqual(gotTxnIds[0]);
expect(failPromise2).rejects.toEqual(gotTxnIds[1]);
const okPromise = slidingSync.setListRanges(0, [[0, 20]]);
const okPromise = slidingSync.setListRanges("a", [[0, 20]]);
let txnId: string | undefined;
httpBackend!
.when("POST", syncUrl)
@@ -1187,10 +1191,10 @@ describe("SlidingSync", () => {
const pushTxn = function (req: MockHttpBackend["requests"][0]) {
gotTxnIds.push(req.data?.txn_id);
};
const A = slidingSync.setListRanges(0, [[20, 40]]);
const A = slidingSync.setListRanges("a", [[20, 40]]);
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "A" });
await httpBackend!.flushAllExpected();
const B = slidingSync.setListRanges(0, [[60, 70]]);
const B = slidingSync.setListRanges("a", [[60, 70]]);
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "B" }); // missing txn_id
await httpBackend!.flushAllExpected();
@@ -1198,7 +1202,7 @@ describe("SlidingSync", () => {
// which is a fail.
expect(A).rejects.toEqual(gotTxnIds[0]);
const C = slidingSync.setListRanges(0, [[0, 20]]);
const C = slidingSync.setListRanges("a", [[0, 20]]);
let pendingC = true;
C.finally(() => {
pendingC = false;
@@ -1219,7 +1223,7 @@ describe("SlidingSync", () => {
expect(pendingC).toBe(true); // C is pending still
});
it("should do nothing for unknown txn_ids", async () => {
const promise = slidingSync.setListRanges(0, [[20, 40]]);
const promise = slidingSync.setListRanges("a", [[20, 40]]);
let pending = true;
promise.finally(() => {
pending = false;
@@ -1231,7 +1235,7 @@ describe("SlidingSync", () => {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeFalsy();
expect(body.lists[0]).toEqual({
expect(body.lists["a"]).toEqual({
ranges: [[20, 40]],
});
expect(body.txn_id).toBeTruthy();
@@ -1241,7 +1245,7 @@ describe("SlidingSync", () => {
return {
pos: "ccc",
txn_id: "bogus transaction id",
lists: [{ count: 5 }],
lists: { a: { count: 5 } },
extensions: {},
};
});
@@ -1279,7 +1283,7 @@ describe("SlidingSync", () => {
};
it("should be possible to use custom subscriptions on startup", async () => {
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
const slidingSync = new SlidingSync(proxyBaseUrl, new Map(), defaultSub, client!, 1);
// the intention is for clients to set this up at startup
slidingSync.addCustomSubscription(customSubName1, customSub1);
slidingSync.addCustomSubscription(customSubName2, customSub2);
@@ -1302,7 +1306,7 @@ describe("SlidingSync", () => {
})
.respond(200, {
pos: "b",
lists: [],
lists: {},
extensions: {},
rooms: {},
});
@@ -1312,7 +1316,7 @@ describe("SlidingSync", () => {
});
it("should be possible to use custom subscriptions mid-connection", async () => {
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
const slidingSync = new SlidingSync(proxyBaseUrl, new Map(), defaultSub, client!, 1);
// the intention is for clients to set this up at startup
slidingSync.addCustomSubscription(customSubName1, customSub1);
slidingSync.addCustomSubscription(customSubName2, customSub2);
@@ -1326,7 +1330,7 @@ describe("SlidingSync", () => {
})
.respond(200, {
pos: "b",
lists: [],
lists: {},
extensions: {},
rooms: {},
});
@@ -1344,7 +1348,7 @@ describe("SlidingSync", () => {
})
.respond(200, {
pos: "b",
lists: [],
lists: {},
extensions: {},
rooms: {},
});
@@ -1363,7 +1367,7 @@ describe("SlidingSync", () => {
})
.respond(200, {
pos: "b",
lists: [],
lists: {},
extensions: {},
rooms: {},
});
@@ -1383,7 +1387,7 @@ describe("SlidingSync", () => {
})
.respond(200, {
pos: "b",
lists: [],
lists: {},
extensions: {},
rooms: {},
});
@@ -1395,7 +1399,7 @@ describe("SlidingSync", () => {
});
it("uses the default subscription for unknown subscription names", async () => {
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
const slidingSync = new SlidingSync(proxyBaseUrl, new Map(), defaultSub, client!, 1);
slidingSync.addCustomSubscription(customSubName1, customSub1);
slidingSync.useCustomSubscription(roomA, "unknown name");
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
@@ -1410,7 +1414,7 @@ describe("SlidingSync", () => {
})
.respond(200, {
pos: "b",
lists: [],
lists: {},
extensions: {},
rooms: {},
});
@@ -1420,7 +1424,7 @@ describe("SlidingSync", () => {
});
it("should not be possible to add/modify an already added custom subscription", async () => {
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
const slidingSync = new SlidingSync(proxyBaseUrl, new Map(), defaultSub, client!, 1);
slidingSync.addCustomSubscription(customSubName1, customSub1);
slidingSync.addCustomSubscription(customSubName1, customSub2);
slidingSync.useCustomSubscription(roomA, customSubName1);
@@ -1436,7 +1440,7 @@ describe("SlidingSync", () => {
})
.respond(200, {
pos: "b",
lists: [],
lists: {},
extensions: {},
rooms: {},
});
@@ -1446,7 +1450,7 @@ describe("SlidingSync", () => {
});
it("should change the custom subscription if they are different", async () => {
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
const slidingSync = new SlidingSync(proxyBaseUrl, new Map(), defaultSub, client!, 1);
slidingSync.addCustomSubscription(customSubName1, customSub1);
slidingSync.addCustomSubscription(customSubName2, customSub2);
slidingSync.useCustomSubscription(roomA, customSubName1);
@@ -1463,7 +1467,7 @@ describe("SlidingSync", () => {
})
.respond(200, {
pos: "b",
lists: [],
lists: {},
extensions: {},
rooms: {},
});
@@ -1484,7 +1488,7 @@ describe("SlidingSync", () => {
})
.respond(200, {
pos: "b",
lists: [],
lists: {},
extensions: {},
rooms: {},
});
@@ -1506,7 +1510,7 @@ describe("SlidingSync", () => {
})
.respond(200, {
pos: "b",
lists: [],
lists: {},
extensions: {},
rooms: {},
});
@@ -1559,7 +1563,7 @@ describe("SlidingSync", () => {
};
it("should be able to register an extension", async () => {
slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client!, 1);
slidingSync = new SlidingSync(proxyBaseUrl, new Map(), {}, client!, 1);
slidingSync.registerExtension(extPre);
const callbackOrder: string[] = [];
@@ -1684,7 +1688,7 @@ describe("SlidingSync", () => {
});
it("is not possible to register the same extension name twice", async () => {
slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client!, 1);
slidingSync = new SlidingSync(proxyBaseUrl, new Map(), {}, client!, 1);
slidingSync.registerExtension(extPre);
expect(() => {
slidingSync.registerExtension(extPre);
+12
View File
@@ -403,3 +403,15 @@ export const mkPusher = (extra: Partial<IPusher> = {}): IPusher => ({
pushkey: "pushpush",
...extra,
});
/**
* a list of the supported crypto implementations, each with a callback to initialise that implementation
* for the given client
*/
export const CRYPTO_BACKENDS: Record<string, InitCrypto> = {};
export type InitCrypto = (_: MatrixClient) => Promise<void>;
CRYPTO_BACKENDS["rust-sdk"] = (client: MatrixClient) => client.initRustCrypto();
if (global.Olm) {
CRYPTO_BACKENDS["libolm"] = (client: MatrixClient) => client.initCrypto();
}
+1 -2
View File
@@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { REFERENCE_RELATION } from "matrix-events-sdk";
import { LocationAssetType, M_ASSET, M_LOCATION, M_TIMESTAMP } from "../../src/@types/location";
import { M_TOPIC } from "../../src/@types/topic";
import {
@@ -25,6 +23,7 @@ import {
parseBeaconContent,
parseTopicContent,
} from "../../src/content-helpers";
import { REFERENCE_RELATION } from "../../src/@types/extensible_events";
describe("Beacon content helpers", () => {
describe("makeBeaconInfoContent()", () => {
+1
View File
@@ -254,6 +254,7 @@ describe("Crypto", function () {
sendToDevice: jest.fn(),
getKeyBackupVersion: jest.fn(),
isGuest: jest.fn(),
emit: jest.fn(),
} as unknown as MatrixClient;
mockRoomList = {} as unknown as RoomList;
@@ -387,8 +387,10 @@ describe("MegolmDecryption", function () {
mockCrypto.baseApis = mockBaseApis;
mockRoom = {
roomId: ROOM_ID,
getEncryptionTargetMembers: jest.fn().mockReturnValue([{ userId: "@alice:home.server" }]),
getBlacklistUnverifiedDevices: jest.fn().mockReturnValue(false),
shouldEncryptForInvitedMembers: jest.fn().mockReturnValue(false),
} as unknown as Room;
});
@@ -0,0 +1,41 @@
/*
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ExtensibleEventType, IPartialEvent } from "../../../src/@types/extensible_events";
import { ExtensibleEvent } from "../../../src/extensible_events_v1/ExtensibleEvent";
class MockEvent extends ExtensibleEvent<any> {
public constructor(wireEvent: IPartialEvent<any>) {
super(wireEvent);
}
public serialize(): IPartialEvent<object> {
throw new Error("Not implemented for tests");
}
public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean {
throw new Error("Not implemented for tests");
}
}
describe("ExtensibleEvent", () => {
it("should expose the wire event directly", () => {
const input: IPartialEvent<any> = { type: "org.example.custom", content: { hello: "world" } };
const event = new MockEvent(input);
expect(event.wireFormat).toBe(input);
expect(event.wireContent).toBe(input.content);
});
});
@@ -0,0 +1,156 @@
/*
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
ExtensibleAnyMessageEventContent,
IPartialEvent,
M_HTML,
M_MESSAGE,
M_TEXT,
} from "../../../src/@types/extensible_events";
import { MessageEvent } from "../../../src/extensible_events_v1/MessageEvent";
import { InvalidEventError } from "../../../src/extensible_events_v1/InvalidEventError";
describe("MessageEvent", () => {
it("should parse m.text", () => {
const input: IPartialEvent<ExtensibleAnyMessageEventContent> = {
type: "org.example.message-like",
content: {
[M_TEXT.name]: "Text here",
},
};
const message = new MessageEvent(input);
expect(message.text).toBe("Text here");
expect(message.html).toBeFalsy();
expect(message.renderings.length).toBe(1);
expect(message.renderings.some((r) => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true);
});
it("should parse m.html", () => {
const input: IPartialEvent<ExtensibleAnyMessageEventContent> = {
type: "org.example.message-like",
content: {
[M_TEXT.name]: "Text here",
[M_HTML.name]: "HTML here",
},
};
const message = new MessageEvent(input);
expect(message.text).toBe("Text here");
expect(message.html).toBe("HTML here");
expect(message.renderings.length).toBe(2);
expect(message.renderings.some((r) => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true);
expect(message.renderings.some((r) => r.mimetype === "text/html" && r.body === "HTML here")).toBe(true);
});
it("should parse m.message", () => {
const input: IPartialEvent<ExtensibleAnyMessageEventContent> = {
type: "org.example.message-like",
content: {
[M_MESSAGE.name]: [
{ body: "Text here", mimetype: "text/plain" },
{ body: "HTML here", mimetype: "text/html" },
{ body: "MD here", mimetype: "text/markdown" },
],
// These should be ignored
[M_TEXT.name]: "WRONG Text here",
[M_HTML.name]: "WRONG HTML here",
},
};
const message = new MessageEvent(input);
expect(message.text).toBe("Text here");
expect(message.html).toBe("HTML here");
expect(message.renderings.length).toBe(3);
expect(message.renderings.some((r) => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true);
expect(message.renderings.some((r) => r.mimetype === "text/html" && r.body === "HTML here")).toBe(true);
expect(message.renderings.some((r) => r.mimetype === "text/markdown" && r.body === "MD here")).toBe(true);
});
it("should fail to parse missing text", () => {
const input: IPartialEvent<ExtensibleAnyMessageEventContent> = {
type: "org.example.message-like",
content: {
hello: "world",
} as any, // force invalid type
};
expect(() => new MessageEvent(input)).toThrow(
new InvalidEventError("Missing textual representation for event"),
);
});
it("should fail to parse missing plain text in m.message", () => {
const input: IPartialEvent<ExtensibleAnyMessageEventContent> = {
type: "org.example.message-like",
content: {
[M_MESSAGE.name]: [{ body: "HTML here", mimetype: "text/html" }],
},
};
expect(() => new MessageEvent(input)).toThrow(
new InvalidEventError("m.message is missing a plain text representation"),
);
});
it("should fail to parse non-array m.message", () => {
const input: IPartialEvent<ExtensibleAnyMessageEventContent> = {
type: "org.example.message-like",
content: {
[M_MESSAGE.name]: "invalid",
} as any, // force invalid type
};
expect(() => new MessageEvent(input)).toThrow(new InvalidEventError("m.message contents must be an array"));
});
describe("from & serialize", () => {
it("should serialize to a legacy fallback", () => {
const message = MessageEvent.from("Text here", "HTML here");
expect(message.text).toBe("Text here");
expect(message.html).toBe("HTML here");
expect(message.renderings.length).toBe(2);
expect(message.renderings.some((r) => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true);
expect(message.renderings.some((r) => r.mimetype === "text/html" && r.body === "HTML here")).toBe(true);
const serialized = message.serialize();
expect(serialized.type).toBe("m.room.message");
expect(serialized.content).toMatchObject({
[M_MESSAGE.name]: [
{ body: "Text here", mimetype: "text/plain" },
{ body: "HTML here", mimetype: "text/html" },
],
body: "Text here",
msgtype: "m.text",
format: "org.matrix.custom.html",
formatted_body: "HTML here",
});
});
it("should serialize non-html content to a legacy fallback", () => {
const message = MessageEvent.from("Text here");
expect(message.text).toBe("Text here");
expect(message.renderings.length).toBe(1);
expect(message.renderings.some((r) => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true);
const serialized = message.serialize();
expect(serialized.type).toBe("m.room.message");
expect(serialized.content).toMatchObject({
[M_TEXT.name]: "Text here",
body: "Text here",
msgtype: "m.text",
format: undefined,
formatted_body: undefined,
});
});
});
});
@@ -0,0 +1,107 @@
/*
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { PollEndEventContent, M_POLL_END } from "../../../src/@types/polls";
import { IPartialEvent, REFERENCE_RELATION, M_TEXT } from "../../../src/@types/extensible_events";
import { PollEndEvent } from "../../../src/extensible_events_v1/PollEndEvent";
import { InvalidEventError } from "../../../src/extensible_events_v1/InvalidEventError";
describe("PollEndEvent", () => {
// Note: throughout these tests we don't really bother testing that
// MessageEvent is doing its job. It has its own tests to worry about.
it("should parse a poll closure", () => {
const input: IPartialEvent<PollEndEventContent> = {
type: M_POLL_END.name,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: "$poll",
},
[M_POLL_END.name]: {},
[M_TEXT.name]: "Poll closed",
},
};
const event = new PollEndEvent(input);
expect(event.pollEventId).toBe("$poll");
expect(event.closingMessage.text).toBe("Poll closed");
});
it("should fail to parse a missing relationship", () => {
const input: IPartialEvent<PollEndEventContent> = {
type: M_POLL_END.name,
content: {
[M_POLL_END.name]: {},
[M_TEXT.name]: "Poll closed",
} as any, // force invalid type
};
expect(() => new PollEndEvent(input)).toThrow(
new InvalidEventError("Relationship must be a reference to an event"),
);
});
it("should fail to parse a missing relationship event ID", () => {
const input: IPartialEvent<PollEndEventContent> = {
type: M_POLL_END.name,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
},
[M_POLL_END.name]: {},
[M_TEXT.name]: "Poll closed",
} as any, // force invalid type
};
expect(() => new PollEndEvent(input)).toThrow(
new InvalidEventError("Relationship must be a reference to an event"),
);
});
it("should fail to parse an improper relationship", () => {
const input: IPartialEvent<PollEndEventContent> = {
type: M_POLL_END.name,
content: {
"m.relates_to": {
rel_type: "org.example.not-relationship",
event_id: "$poll",
},
[M_POLL_END.name]: {},
[M_TEXT.name]: "Poll closed",
} as any, // force invalid type
};
expect(() => new PollEndEvent(input)).toThrow(
new InvalidEventError("Relationship must be a reference to an event"),
);
});
describe("from & serialize", () => {
it("should serialize to a poll end event", () => {
const event = PollEndEvent.from("$poll", "Poll closed");
expect(event.pollEventId).toBe("$poll");
expect(event.closingMessage.text).toBe("Poll closed");
const serialized = event.serialize();
expect(M_POLL_END.matches(serialized.type)).toBe(true);
expect(serialized.content).toMatchObject({
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: "$poll",
},
[M_POLL_END.name]: {},
[M_TEXT.name]: expect.any(String), // tested by MessageEvent tests
});
});
});
});
@@ -0,0 +1,277 @@
/*
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { M_TEXT, IPartialEvent, REFERENCE_RELATION } from "../../../src/@types/extensible_events";
import {
M_POLL_START,
M_POLL_KIND_DISCLOSED,
PollResponseEventContent,
M_POLL_RESPONSE,
} from "../../../src/@types/polls";
import { PollStartEvent } from "../../../src/extensible_events_v1/PollStartEvent";
import { InvalidEventError } from "../../../src/extensible_events_v1/InvalidEventError";
import { PollResponseEvent } from "../../../src/extensible_events_v1/PollResponseEvent";
const SAMPLE_POLL = new PollStartEvent({
type: M_POLL_START.name,
content: {
[M_TEXT.name]: "FALLBACK Question here",
[M_POLL_START.name]: {
question: { [M_TEXT.name]: "Question here" },
kind: M_POLL_KIND_DISCLOSED.name,
max_selections: 2,
answers: [
{ id: "one", [M_TEXT.name]: "ONE" },
{ id: "two", [M_TEXT.name]: "TWO" },
{ id: "thr", [M_TEXT.name]: "THR" },
],
},
},
});
describe("PollResponseEvent", () => {
it("should parse a poll response", () => {
const input: IPartialEvent<PollResponseEventContent> = {
type: M_POLL_RESPONSE.name,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: "$poll",
},
[M_POLL_RESPONSE.name]: {
answers: ["one"],
},
},
};
const response = new PollResponseEvent(input);
expect(response.spoiled).toBe(false);
expect(response.answerIds).toMatchObject(["one"]);
expect(response.pollEventId).toBe("$poll");
});
it("should fail to parse a missing relationship", () => {
const input: IPartialEvent<PollResponseEventContent> = {
type: M_POLL_RESPONSE.name,
content: {
[M_POLL_RESPONSE.name]: {
answers: ["one"],
},
} as any, // force invalid type
};
expect(() => new PollResponseEvent(input)).toThrow(
new InvalidEventError("Relationship must be a reference to an event"),
);
});
it("should fail to parse a missing relationship event ID", () => {
const input: IPartialEvent<PollResponseEventContent> = {
type: M_POLL_RESPONSE.name,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
},
[M_POLL_RESPONSE.name]: {
answers: ["one"],
},
} as any, // force invalid type
};
expect(() => new PollResponseEvent(input)).toThrow(
new InvalidEventError("Relationship must be a reference to an event"),
);
});
it("should fail to parse an improper relationship", () => {
const input: IPartialEvent<PollResponseEventContent> = {
type: M_POLL_RESPONSE.name,
content: {
"m.relates_to": {
rel_type: "org.example.not-relationship",
event_id: "$poll",
},
[M_POLL_RESPONSE.name]: {
answers: ["one"],
},
} as any, // force invalid type
};
expect(() => new PollResponseEvent(input)).toThrow(
new InvalidEventError("Relationship must be a reference to an event"),
);
});
describe("validateAgainst", () => {
it("should spoil the vote when no answers", () => {
const input: IPartialEvent<PollResponseEventContent> = {
type: M_POLL_RESPONSE.name,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: "$poll",
},
[M_POLL_RESPONSE.name]: {},
} as any, // force invalid type
};
const response = new PollResponseEvent(input);
expect(response.spoiled).toBe(true);
response.validateAgainst(SAMPLE_POLL);
expect(response.spoiled).toBe(true);
});
it("should spoil the vote when answers are empty", () => {
const input: IPartialEvent<PollResponseEventContent> = {
type: M_POLL_RESPONSE.name,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: "$poll",
},
[M_POLL_RESPONSE.name]: {
answers: [],
},
},
};
const response = new PollResponseEvent(input);
expect(response.spoiled).toBe(true);
response.validateAgainst(SAMPLE_POLL);
expect(response.spoiled).toBe(true);
});
it("should spoil the vote when answers are empty", () => {
const input: IPartialEvent<PollResponseEventContent> = {
type: M_POLL_RESPONSE.name,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: "$poll",
},
[M_POLL_RESPONSE.name]: {
answers: [],
},
},
};
const response = new PollResponseEvent(input);
expect(response.spoiled).toBe(true);
response.validateAgainst(SAMPLE_POLL);
expect(response.spoiled).toBe(true);
});
it("should spoil the vote when answers are not strings", () => {
const input: IPartialEvent<PollResponseEventContent> = {
type: M_POLL_RESPONSE.name,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: "$poll",
},
[M_POLL_RESPONSE.name]: {
answers: [1, 2, 3],
},
} as any, // force invalid type
};
const response = new PollResponseEvent(input);
expect(response.spoiled).toBe(true);
response.validateAgainst(SAMPLE_POLL);
expect(response.spoiled).toBe(true);
});
describe("consumer usage", () => {
it("should spoil the vote when invalid answers are given", () => {
const input: IPartialEvent<PollResponseEventContent> = {
type: M_POLL_RESPONSE.name,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: "$poll",
},
[M_POLL_RESPONSE.name]: {
answers: ["A", "B", "C"],
},
},
};
const response = new PollResponseEvent(input);
expect(response.spoiled).toBe(false); // it won't know better
response.validateAgainst(SAMPLE_POLL);
expect(response.spoiled).toBe(true);
});
it("should truncate answers to the poll max selections", () => {
const input: IPartialEvent<PollResponseEventContent> = {
type: M_POLL_RESPONSE.name,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: "$poll",
},
[M_POLL_RESPONSE.name]: {
answers: ["one", "two", "thr"],
},
},
};
const response = new PollResponseEvent(input);
expect(response.spoiled).toBe(false); // it won't know better
expect(response.answerIds).toMatchObject(["one", "two", "thr"]);
response.validateAgainst(SAMPLE_POLL);
expect(response.spoiled).toBe(false);
expect(response.answerIds).toMatchObject(["one", "two"]);
});
});
});
describe("from & serialize", () => {
it("should serialize to a poll response event", () => {
const response = PollResponseEvent.from(["A", "B", "C"], "$poll");
expect(response.spoiled).toBe(false);
expect(response.answerIds).toMatchObject(["A", "B", "C"]);
expect(response.pollEventId).toBe("$poll");
const serialized = response.serialize();
expect(M_POLL_RESPONSE.matches(serialized.type)).toBe(true);
expect(serialized.content).toMatchObject({
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: "$poll",
},
[M_POLL_RESPONSE.name]: {
answers: ["A", "B", "C"],
},
});
});
it("should serialize a spoiled vote", () => {
const response = PollResponseEvent.from([], "$poll");
expect(response.spoiled).toBe(true);
expect(response.answerIds).toMatchObject([]);
expect(response.pollEventId).toBe("$poll");
const serialized = response.serialize();
expect(M_POLL_RESPONSE.matches(serialized.type)).toBe(true);
expect(serialized.content).toMatchObject({
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: "$poll",
},
[M_POLL_RESPONSE.name]: {
answers: undefined,
},
});
});
});
});
@@ -0,0 +1,337 @@
/*
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { M_TEXT, IPartialEvent } from "../../../src/@types/extensible_events";
import {
M_POLL_START,
M_POLL_KIND_DISCLOSED,
PollAnswer,
PollStartEventContent,
M_POLL_KIND_UNDISCLOSED,
} from "../../../src/@types/polls";
import { PollStartEvent, PollAnswerSubevent } from "../../../src/extensible_events_v1/PollStartEvent";
import { InvalidEventError } from "../../../src/extensible_events_v1/InvalidEventError";
describe("PollAnswerSubevent", () => {
// Note: throughout these tests we don't really bother testing that
// MessageEvent is doing its job. It has its own tests to worry about.
it("should parse an answer representation", () => {
const input: IPartialEvent<PollAnswer> = {
type: "org.matrix.sdk.poll.answer",
content: {
id: "one",
[M_TEXT.name]: "ONE",
},
};
const answer = new PollAnswerSubevent(input);
expect(answer.id).toBe("one");
expect(answer.text).toBe("ONE");
});
it("should fail to parse answers without an ID", () => {
const input: IPartialEvent<PollAnswer> = {
type: "org.matrix.sdk.poll.answer",
content: {
[M_TEXT.name]: "ONE",
} as any, // force invalid type
};
expect(() => new PollAnswerSubevent(input)).toThrow(
new InvalidEventError("Answer ID must be a non-empty string"),
);
});
it("should fail to parse answers without text", () => {
const input: IPartialEvent<PollAnswer> = {
type: "org.matrix.sdk.poll.answer",
content: {
id: "one",
} as any, // force invalid type
};
expect(() => new PollAnswerSubevent(input)).toThrow(); // we don't check message - that'll be MessageEvent's problem
});
describe("from & serialize", () => {
it("should serialize to a placeholder representation", () => {
const answer = PollAnswerSubevent.from("one", "ONE");
expect(answer.id).toBe("one");
expect(answer.text).toBe("ONE");
const serialized = answer.serialize();
expect(serialized.type).toBe("org.matrix.sdk.poll.answer");
expect(serialized.content).toMatchObject({
id: "one",
[M_TEXT.name]: expect.any(String), // tested by MessageEvent
});
});
});
});
describe("PollStartEvent", () => {
// Note: throughout these tests we don't really bother testing that
// MessageEvent is doing its job. It has its own tests to worry about.
it("should parse a poll", () => {
const input: IPartialEvent<PollStartEventContent> = {
type: M_POLL_START.name,
content: {
[M_TEXT.name]: "FALLBACK Question here",
[M_POLL_START.name]: {
question: { [M_TEXT.name]: "Question here" },
kind: M_POLL_KIND_DISCLOSED.name,
max_selections: 2,
answers: [
{ id: "one", [M_TEXT.name]: "ONE" },
{ id: "two", [M_TEXT.name]: "TWO" },
{ id: "thr", [M_TEXT.name]: "THR" },
],
},
},
};
const poll = new PollStartEvent(input);
expect(poll.question).toBeDefined();
expect(poll.question.text).toBe("Question here");
expect(poll.kind).toBe(M_POLL_KIND_DISCLOSED);
expect(M_POLL_KIND_DISCLOSED.matches(poll.rawKind)).toBe(true);
expect(poll.maxSelections).toBe(2);
expect(poll.answers.length).toBe(3);
expect(poll.answers.some((a) => a.id === "one" && a.text === "ONE")).toBe(true);
expect(poll.answers.some((a) => a.id === "two" && a.text === "TWO")).toBe(true);
expect(poll.answers.some((a) => a.id === "thr" && a.text === "THR")).toBe(true);
});
it("should fail to parse a missing question", () => {
const input: IPartialEvent<PollStartEventContent> = {
type: M_POLL_START.name,
content: {
[M_TEXT.name]: "FALLBACK Question here",
[M_POLL_START.name]: {
kind: M_POLL_KIND_DISCLOSED.name,
max_selections: 2,
answers: [
{ id: "one", [M_TEXT.name]: "ONE" },
{ id: "two", [M_TEXT.name]: "TWO" },
{ id: "thr", [M_TEXT.name]: "THR" },
],
},
} as any, // force invalid type
};
expect(() => new PollStartEvent(input)).toThrow(new InvalidEventError("A question is required"));
});
it("should fail to parse non-array answers", () => {
const input: IPartialEvent<PollStartEventContent> = {
type: M_POLL_START.name,
content: {
[M_TEXT.name]: "FALLBACK Question here",
[M_POLL_START.name]: {
question: { [M_TEXT.name]: "Question here" },
kind: M_POLL_KIND_DISCLOSED.name,
max_selections: 2,
answers: "one",
} as any, // force invalid type
},
};
expect(() => new PollStartEvent(input)).toThrow(new InvalidEventError("Poll answers must be an array"));
});
it("should fail to parse invalid answers", () => {
const input: IPartialEvent<PollStartEventContent> = {
type: M_POLL_START.name,
content: {
[M_TEXT.name]: "FALLBACK Question here",
[M_POLL_START.name]: {
question: { [M_TEXT.name]: "Question here" },
kind: M_POLL_KIND_DISCLOSED.name,
max_selections: 2,
answers: [{ id: "one" }, { [M_TEXT.name]: "TWO" }],
} as any, // force invalid type
},
};
expect(() => new PollStartEvent(input)).toThrow(); // error tested by PollAnswerSubevent tests
});
it("should fail to parse lack of answers", () => {
const input: IPartialEvent<PollStartEventContent> = {
type: M_POLL_START.name,
content: {
[M_TEXT.name]: "FALLBACK Question here",
[M_POLL_START.name]: {
question: { [M_TEXT.name]: "Question here" },
kind: M_POLL_KIND_DISCLOSED.name,
max_selections: 2,
answers: [],
} as any, // force invalid type
},
};
expect(() => new PollStartEvent(input)).toThrow(new InvalidEventError("No answers available"));
});
it("should truncate answers at 20", () => {
const input: IPartialEvent<PollStartEventContent> = {
type: M_POLL_START.name,
content: {
[M_TEXT.name]: "FALLBACK Question here",
[M_POLL_START.name]: {
question: { [M_TEXT.name]: "Question here" },
kind: M_POLL_KIND_DISCLOSED.name,
max_selections: 2,
answers: [
{ id: "01", [M_TEXT.name]: "A" },
{ id: "02", [M_TEXT.name]: "B" },
{ id: "03", [M_TEXT.name]: "C" },
{ id: "04", [M_TEXT.name]: "D" },
{ id: "05", [M_TEXT.name]: "E" },
{ id: "06", [M_TEXT.name]: "F" },
{ id: "07", [M_TEXT.name]: "G" },
{ id: "08", [M_TEXT.name]: "H" },
{ id: "09", [M_TEXT.name]: "I" },
{ id: "10", [M_TEXT.name]: "J" },
{ id: "11", [M_TEXT.name]: "K" },
{ id: "12", [M_TEXT.name]: "L" },
{ id: "13", [M_TEXT.name]: "M" },
{ id: "14", [M_TEXT.name]: "N" },
{ id: "15", [M_TEXT.name]: "O" },
{ id: "16", [M_TEXT.name]: "P" },
{ id: "17", [M_TEXT.name]: "Q" },
{ id: "18", [M_TEXT.name]: "R" },
{ id: "19", [M_TEXT.name]: "S" },
{ id: "20", [M_TEXT.name]: "T" },
{ id: "FAIL", [M_TEXT.name]: "U" },
],
},
},
};
const poll = new PollStartEvent(input);
expect(poll.answers.length).toBe(20);
expect(poll.answers.some((a) => a.id === "FAIL")).toBe(false);
});
it("should infer a kind from unknown kinds", () => {
const input: IPartialEvent<PollStartEventContent> = {
type: M_POLL_START.name,
content: {
[M_TEXT.name]: "FALLBACK Question here",
[M_POLL_START.name]: {
question: { [M_TEXT.name]: "Question here" },
kind: "org.example.custom.poll.kind",
max_selections: 2,
answers: [
{ id: "01", [M_TEXT.name]: "A" },
{ id: "02", [M_TEXT.name]: "B" },
{ id: "03", [M_TEXT.name]: "C" },
],
},
},
};
const poll = new PollStartEvent(input);
expect(poll.kind).toBe(M_POLL_KIND_UNDISCLOSED);
expect(poll.rawKind).toBe("org.example.custom.poll.kind");
});
it("should infer a kind from missing kinds", () => {
const input: IPartialEvent<PollStartEventContent> = {
type: M_POLL_START.name,
content: {
[M_TEXT.name]: "FALLBACK Question here",
[M_POLL_START.name]: {
question: { [M_TEXT.name]: "Question here" },
max_selections: 2,
answers: [
{ id: "01", [M_TEXT.name]: "A" },
{ id: "02", [M_TEXT.name]: "B" },
{ id: "03", [M_TEXT.name]: "C" },
],
} as any, // force invalid type
},
};
const poll = new PollStartEvent(input);
expect(poll.kind).toBe(M_POLL_KIND_UNDISCLOSED);
expect(poll.rawKind).toBeFalsy();
});
describe("from & serialize", () => {
it("should serialize to a poll start event", () => {
const poll = PollStartEvent.from("Question here", ["A", "B", "C"], M_POLL_KIND_DISCLOSED, 2);
expect(poll.question.text).toBe("Question here");
expect(poll.kind).toBe(M_POLL_KIND_DISCLOSED);
expect(M_POLL_KIND_DISCLOSED.matches(poll.rawKind)).toBe(true);
expect(poll.maxSelections).toBe(2);
expect(poll.answers.length).toBe(3);
expect(poll.answers.some((a) => a.text === "A")).toBe(true);
expect(poll.answers.some((a) => a.text === "B")).toBe(true);
expect(poll.answers.some((a) => a.text === "C")).toBe(true);
// Ids are non-empty and unique
expect(poll.answers[0].id).toHaveLength(16);
expect(poll.answers[1].id).toHaveLength(16);
expect(poll.answers[2].id).toHaveLength(16);
expect(poll.answers[0].id).not.toEqual(poll.answers[1].id);
expect(poll.answers[0].id).not.toEqual(poll.answers[2].id);
expect(poll.answers[1].id).not.toEqual(poll.answers[2].id);
const serialized = poll.serialize();
expect(M_POLL_START.matches(serialized.type)).toBe(true);
expect(serialized.content).toMatchObject({
[M_TEXT.name]: "Question here\n1. A\n2. B\n3. C",
[M_POLL_START.name]: {
question: {
[M_TEXT.name]: expect.any(String), // tested by MessageEvent tests
},
kind: M_POLL_KIND_DISCLOSED.name,
max_selections: 2,
answers: [
// M_TEXT tested by MessageEvent tests
{ id: expect.any(String), [M_TEXT.name]: expect.any(String) },
{ id: expect.any(String), [M_TEXT.name]: expect.any(String) },
{ id: expect.any(String), [M_TEXT.name]: expect.any(String) },
],
},
});
});
it("should serialize to a custom kind poll start event", () => {
const poll = PollStartEvent.from("Question here", ["A", "B", "C"], "org.example.poll.kind", 2);
expect(poll.question.text).toBe("Question here");
expect(poll.kind).toBe(M_POLL_KIND_UNDISCLOSED);
expect(poll.rawKind).toBe("org.example.poll.kind");
expect(poll.maxSelections).toBe(2);
expect(poll.answers.length).toBe(3);
expect(poll.answers.some((a) => a.text === "A")).toBe(true);
expect(poll.answers.some((a) => a.text === "B")).toBe(true);
expect(poll.answers.some((a) => a.text === "C")).toBe(true);
const serialized = poll.serialize();
expect(M_POLL_START.matches(serialized.type)).toBe(true);
expect(serialized.content).toMatchObject({
[M_TEXT.name]: "Question here\n1. A\n2. B\n3. C",
[M_POLL_START.name]: {
question: {
[M_TEXT.name]: expect.any(String), // tested by MessageEvent tests
},
kind: "org.example.poll.kind",
max_selections: 2,
answers: [
// M_MESSAGE tested by MessageEvent tests
{ id: expect.any(String), [M_TEXT.name]: expect.any(String) },
{ id: expect.any(String), [M_TEXT.name]: expect.any(String) },
{ id: expect.any(String), [M_TEXT.name]: expect.any(String) },
],
},
});
});
});
});
@@ -0,0 +1,87 @@
/*
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { NamespacedValue } from "matrix-events-sdk";
import { isEventTypeSame } from "../../../src/@types/extensible_events";
describe("isEventTypeSame", () => {
it("should match string and string", () => {
const a = "org.example.message-like";
const b = "org.example.different";
expect(isEventTypeSame(a, b)).toBe(false);
expect(isEventTypeSame(b, a)).toBe(false);
expect(isEventTypeSame(a, a)).toBe(true);
expect(isEventTypeSame(b, b)).toBe(true);
});
it("should match string and namespace", () => {
const a = "org.example.message-like";
const b = new NamespacedValue<string, string>("org.example.stable", "org.example.unstable");
expect(isEventTypeSame(a, b)).toBe(false);
expect(isEventTypeSame(b, a)).toBe(false);
expect(isEventTypeSame(a, a)).toBe(true);
expect(isEventTypeSame(b, b)).toBe(true);
expect(isEventTypeSame(b.name, b)).toBe(true);
expect(isEventTypeSame(b.altName, b)).toBe(true);
expect(isEventTypeSame(b, b.name)).toBe(true);
expect(isEventTypeSame(b, b.altName)).toBe(true);
});
it("should match namespace and namespace", () => {
const a = new NamespacedValue<string, string>("org.example.stable1", "org.example.unstable1");
const b = new NamespacedValue<string, string>("org.example.stable2", "org.example.unstable2");
expect(isEventTypeSame(a, b)).toBe(false);
expect(isEventTypeSame(b, a)).toBe(false);
expect(isEventTypeSame(a, a)).toBe(true);
expect(isEventTypeSame(a.name, a)).toBe(true);
expect(isEventTypeSame(a.altName, a)).toBe(true);
expect(isEventTypeSame(a, a.name)).toBe(true);
expect(isEventTypeSame(a, a.altName)).toBe(true);
expect(isEventTypeSame(b, b)).toBe(true);
expect(isEventTypeSame(b.name, b)).toBe(true);
expect(isEventTypeSame(b.altName, b)).toBe(true);
expect(isEventTypeSame(b, b.name)).toBe(true);
expect(isEventTypeSame(b, b.altName)).toBe(true);
});
it("should match namespaces of different pointers", () => {
const a = new NamespacedValue<string, string>("org.example.stable", "org.example.unstable");
const b = new NamespacedValue<string, string>("org.example.stable", "org.example.unstable");
expect(isEventTypeSame(a, b)).toBe(true);
expect(isEventTypeSame(b, a)).toBe(true);
expect(isEventTypeSame(a, a)).toBe(true);
expect(isEventTypeSame(a.name, a)).toBe(true);
expect(isEventTypeSame(a.altName, a)).toBe(true);
expect(isEventTypeSame(a, a.name)).toBe(true);
expect(isEventTypeSame(a, a.altName)).toBe(true);
expect(isEventTypeSame(b, b)).toBe(true);
expect(isEventTypeSame(b.name, b)).toBe(true);
expect(isEventTypeSame(b.altName, b)).toBe(true);
expect(isEventTypeSame(b, b.name)).toBe(true);
expect(isEventTypeSame(b, b.altName)).toBe(true);
});
});
+4 -4
View File
@@ -22,7 +22,7 @@ import {
M_TIMESTAMP,
LocationEventWireContent,
} from "../../src/@types/location";
import { TEXT_NODE_TYPE } from "../../src/@types/extensible_events";
import { M_TEXT } from "../../src/@types/extensible_events";
import { MsgType } from "../../src/@types/event";
describe("Location", function () {
@@ -32,7 +32,7 @@ describe("Location", function () {
geo_uri: "geo:-36.24484561954707,175.46884959563613;u=10",
[M_LOCATION.name]: { uri: "geo:-36.24484561954707,175.46884959563613;u=10", description: null },
[M_ASSET.name]: { type: "m.self" },
[TEXT_NODE_TYPE.name]: "Location geo:-36.24484561954707,175.46884959563613;u=10 at 2022-03-09T11:01:52.443Z",
[M_TEXT.name]: "Location geo:-36.24484561954707,175.46884959563613;u=10 at 2022-03-09T11:01:52.443Z",
[M_TIMESTAMP.name]: 1646823712443,
} as any;
@@ -59,7 +59,7 @@ describe("Location", function () {
description: undefined,
});
expect(M_ASSET.findIn(loc)).toEqual({ type: LocationAssetType.Self });
expect(TEXT_NODE_TYPE.findIn(loc)).toEqual("User Location geo:foo at 1970-01-02T13:17:15.435Z");
expect(M_TEXT.findIn(loc)).toEqual("User Location geo:foo at 1970-01-02T13:17:15.435Z");
expect(M_TIMESTAMP.findIn(loc)).toEqual(134235435);
});
@@ -74,7 +74,7 @@ describe("Location", function () {
description: "desc",
});
expect(M_ASSET.findIn(loc)).toEqual({ type: LocationAssetType.Pin });
expect(TEXT_NODE_TYPE.findIn(loc)).toEqual('Location "desc" geo:bar at 1970-01-02T13:17:15.436Z');
expect(M_TEXT.findIn(loc)).toEqual('Location "desc" geo:bar at 1970-01-02T13:17:15.436Z');
expect(M_TIMESTAMP.findIn(loc)).toEqual(134235436);
});
+117 -35
View File
@@ -2198,7 +2198,7 @@ describe("MatrixClient", function () {
});
});
describe("getVisibleRooms", () => {
describe("room lists and history", () => {
function roomCreateEvent(newRoomId: string, predecessorRoomId: string): MatrixEvent {
return new MatrixEvent({
content: {
@@ -2234,45 +2234,127 @@ describe("MatrixClient", function () {
});
}
it("Returns an empty list if there are no rooms", () => {
client.store = new StubStore();
client.store.getRooms = () => [];
const rooms = client.getVisibleRooms();
expect(rooms).toHaveLength(0);
describe("getVisibleRooms", () => {
it("Returns an empty list if there are no rooms", () => {
client.store = new StubStore();
client.store.getRooms = () => [];
const rooms = client.getVisibleRooms();
expect(rooms).toHaveLength(0);
});
it("Returns all non-replaced rooms", () => {
const room1 = new Room("room1", client, "@carol:alexandria.example.com");
const room2 = new Room("room2", client, "@daryl:alexandria.example.com");
client.store = new StubStore();
client.store.getRooms = () => [room1, room2];
const rooms = client.getVisibleRooms();
expect(rooms).toContain(room1);
expect(rooms).toContain(room2);
expect(rooms).toHaveLength(2);
});
it("Does not return replaced rooms", () => {
// Given 4 rooms, 2 of which have been replaced
const room1 = new Room("room1", client, "@carol:alexandria.example.com");
const replacedRoom1 = new Room("replacedRoom1", client, "@carol:alexandria.example.com");
const replacedRoom2 = new Room("replacedRoom2", client, "@carol:alexandria.example.com");
const room2 = new Room("room2", client, "@daryl:alexandria.example.com");
client.store = new StubStore();
client.store.getRooms = () => [room1, replacedRoom1, replacedRoom2, room2];
room1.addLiveEvents([roomCreateEvent(room1.roomId, replacedRoom1.roomId)], {});
room2.addLiveEvents([roomCreateEvent(room2.roomId, replacedRoom2.roomId)], {});
replacedRoom1.addLiveEvents([tombstoneEvent(room1.roomId, replacedRoom1.roomId)], {});
replacedRoom2.addLiveEvents([tombstoneEvent(room2.roomId, replacedRoom2.roomId)], {});
// When we ask for the visible rooms
const rooms = client.getVisibleRooms();
// Then we only get the ones that have not been replaced
expect(rooms).not.toContain(replacedRoom1);
expect(rooms).not.toContain(replacedRoom2);
expect(rooms).toContain(room1);
expect(rooms).toContain(room2);
});
});
it("Returns all non-replaced rooms", () => {
const room1 = new Room("room1", client, "@carol:alexandria.example.com");
const room2 = new Room("room2", client, "@daryl:alexandria.example.com");
client.store = new StubStore();
client.store.getRooms = () => [room1, room2];
const rooms = client.getVisibleRooms();
expect(rooms).toContain(room1);
expect(rooms).toContain(room2);
expect(rooms).toHaveLength(2);
});
describe("getRoomUpgradeHistory", () => {
function createRoomHistory(): [Room, Room, Room, Room] {
const room1 = new Room("room1", client, "@carol:alexandria.example.com");
const room2 = new Room("room2", client, "@daryl:alexandria.example.com");
const room3 = new Room("room3", client, "@rick:helicopter.example.com");
const room4 = new Room("room4", client, "@michonne:hawthorne.example.com");
it("Does not return replaced rooms", () => {
// Given 4 rooms, 2 of which have been replaced
const room1 = new Room("room1", client, "@carol:alexandria.example.com");
const replacedRoom1 = new Room("replacedRoom1", client, "@carol:alexandria.example.com");
const replacedRoom2 = new Room("replacedRoom2", client, "@carol:alexandria.example.com");
const room2 = new Room("room2", client, "@daryl:alexandria.example.com");
client.store = new StubStore();
client.store.getRooms = () => [room1, replacedRoom1, replacedRoom2, room2];
room1.addLiveEvents([roomCreateEvent(room1.roomId, replacedRoom1.roomId)], {});
room2.addLiveEvents([roomCreateEvent(room2.roomId, replacedRoom2.roomId)], {});
replacedRoom1.addLiveEvents([tombstoneEvent(room1.roomId, replacedRoom1.roomId)], {});
replacedRoom2.addLiveEvents([tombstoneEvent(room2.roomId, replacedRoom2.roomId)], {});
room1.addLiveEvents([tombstoneEvent(room2.roomId, room1.roomId)], {});
room2.addLiveEvents([roomCreateEvent(room2.roomId, room1.roomId)]);
// When we ask for the visible rooms
const rooms = client.getVisibleRooms();
room2.addLiveEvents([tombstoneEvent(room3.roomId, room2.roomId)], {});
room3.addLiveEvents([roomCreateEvent(room3.roomId, room2.roomId)]);
// Then we only get the ones that have not been replaced
expect(rooms).not.toContain(replacedRoom1);
expect(rooms).not.toContain(replacedRoom2);
expect(rooms).toContain(room1);
expect(rooms).toContain(room2);
room3.addLiveEvents([tombstoneEvent(room4.roomId, room3.roomId)], {});
room4.addLiveEvents([roomCreateEvent(room4.roomId, room3.roomId)]);
mocked(store.getRoom).mockImplementation((roomId: string) => {
switch (roomId) {
case "room1":
return room1;
case "room2":
return room2;
case "room3":
return room3;
case "room4":
return room4;
default:
return null;
}
});
return [room1, room2, room3, room4];
}
it("Returns an empty list if room does not exist", () => {
const history = client.getRoomUpgradeHistory("roomthatdoesnotexist");
expect(history).toHaveLength(0);
});
it("Returns just this room if there is no predecessor", () => {
const mainRoom = new Room("mainRoom", client, "@carol:alexandria.example.com");
mocked(store.getRoom).mockReturnValue(mainRoom);
const history = client.getRoomUpgradeHistory(mainRoom.roomId);
expect(history).toEqual([mainRoom]);
});
it("Returns the predecessors of this room", () => {
const [room1, room2, room3, room4] = createRoomHistory();
const history = client.getRoomUpgradeHistory(room4.roomId);
expect(history.map((room) => room.roomId)).toEqual([
room1.roomId,
room2.roomId,
room3.roomId,
room4.roomId,
]);
});
it("Returns the subsequent rooms", () => {
const [room1, room2, room3, room4] = createRoomHistory();
const history = client.getRoomUpgradeHistory(room1.roomId);
expect(history.map((room) => room.roomId)).toEqual([
room1.roomId,
room2.roomId,
room3.roomId,
room4.roomId,
]);
});
it("Returns the predecessors and subsequent rooms", () => {
const [room1, room2, room3, room4] = createRoomHistory();
const history = client.getRoomUpgradeHistory(room3.roomId);
expect(history.map((room) => room.roomId)).toEqual([
room1.roomId,
room2.roomId,
room3.roomId,
room4.roomId,
]);
});
});
});
});
+1 -2
View File
@@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { REFERENCE_RELATION } from "matrix-events-sdk";
import { REFERENCE_RELATION } from "../../../src/@types/extensible_events";
import { MatrixEvent } from "../../../src";
import { M_BEACON_INFO } from "../../../src/@types/beacon";
import { isTimestampInDuration, Beacon, BeaconEvent } from "../../../src/models/beacon";
+140 -1
View File
@@ -20,7 +20,7 @@ import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/t
import { mkThread } from "../../test-utils/thread";
import { TestClient } from "../../TestClient";
import { emitPromise, mkMessage, mock } from "../../test-utils/test-utils";
import { EventStatus, MatrixEvent } from "../../../src";
import { Direction, EventStatus, MatrixEvent } from "../../../src";
import { ReceiptType } from "../../../src/@types/read_receipts";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
import { ReEmitter } from "../../../src/ReEmitter";
@@ -283,4 +283,143 @@ describe("Thread", () => {
expect(thread2.getEventReadUpTo(myUserId)).toBe(null);
});
});
describe("resetLiveTimeline", () => {
// ResetLiveTimeline is used when we have missing messages between the current live timeline's end and newly
// received messages. In that case, we want to replace the existing live timeline. To ensure pagination
// continues working correctly, new pagination tokens need to be set on both the old live timeline (which is
// now a regular timeline) and the new live timeline.
it("replaces the live timeline and correctly sets pagination tokens", async () => {
const myUserId = "@bob:example.org";
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
timelineSupport: false,
});
const client = testClient.client;
const room = new Room("123", client, myUserId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "getRoom").mockReturnValue(room);
const { thread } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: ["@alice:example.org"],
length: 3,
});
await emitPromise(thread, ThreadEvent.Update);
expect(thread.length).toBe(2);
jest.spyOn(client, "createMessagesRequest").mockImplementation((_, token) =>
Promise.resolve({
chunk: [],
start: `${token}-new`,
end: `${token}-new`,
}),
);
function timelines(): [string | null, string | null][] {
return thread.timelineSet
.getTimelines()
.map((it) => [it.getPaginationToken(Direction.Backward), it.getPaginationToken(Direction.Forward)]);
}
expect(timelines()).toEqual([[null, null]]);
const promise = thread.resetLiveTimeline("b1", "f1");
expect(timelines()).toEqual([
[null, "f1"],
["b1", null],
]);
await promise;
expect(timelines()).toEqual([
[null, "f1-new"],
["b1-new", null],
]);
});
// As the pagination tokens cannot be used right now, resetLiveTimeline needs to replace them before they can
// be used. But if in the future the bug in synapse is fixed, and they can actually be used, we can get into a
// state where the client has paginated (and changed the tokens) while resetLiveTimeline tries to set the
// corrected tokens. To prevent such a race condition, we make sure that resetLiveTimeline respects any
// changes done to the pagination tokens.
it("replaces the live timeline but does not replace changed pagination tokens", async () => {
const myUserId = "@bob:example.org";
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
timelineSupport: false,
});
const client = testClient.client;
const room = new Room("123", client, myUserId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "getRoom").mockReturnValue(room);
const { thread } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: ["@alice:example.org"],
length: 3,
});
await emitPromise(thread, ThreadEvent.Update);
expect(thread.length).toBe(2);
jest.spyOn(client, "createMessagesRequest").mockImplementation((_, token) =>
Promise.resolve({
chunk: [],
start: `${token}-new`,
end: `${token}-new`,
}),
);
function timelines(): [string | null, string | null][] {
return thread.timelineSet
.getTimelines()
.map((it) => [it.getPaginationToken(Direction.Backward), it.getPaginationToken(Direction.Forward)]);
}
expect(timelines()).toEqual([[null, null]]);
const promise = thread.resetLiveTimeline("b1", "f1");
expect(timelines()).toEqual([
[null, "f1"],
["b1", null],
]);
thread.timelineSet.getTimelines()[0].setPaginationToken("f2", Direction.Forward);
thread.timelineSet.getTimelines()[1].setPaginationToken("b2", Direction.Backward);
await promise;
expect(timelines()).toEqual([
[null, "f2"],
["b2", null],
]);
});
it("is correctly called by the room", async () => {
const myUserId = "@bob:example.org";
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
timelineSupport: false,
});
const client = testClient.client;
const room = new Room("123", client, myUserId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "getRoom").mockReturnValue(room);
const { thread } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: ["@alice:example.org"],
length: 3,
});
await emitPromise(thread, ThreadEvent.Update);
expect(thread.length).toBe(2);
const mock = jest.spyOn(thread, "resetLiveTimeline");
mock.mockReturnValue(Promise.resolve());
room.resetLiveTimeline("b1", "f1");
expect(mock).toHaveBeenCalledWith("b1", "f1");
});
});
});
+1 -2
View File
@@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { M_POLL_START } from "matrix-events-sdk";
import { M_POLL_START } from "../../src/@types/polls";
import { EventTimelineSet } from "../../src/models/event-timeline-set";
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { Room } from "../../src/models/room";
+32
View File
@@ -32,6 +32,9 @@ import { RustCrypto } from "../../src/rust-crypto/rust-crypto";
import { initRustCrypto } from "../../src/rust-crypto";
import { HttpApiEvent, HttpApiEventHandlerMap, IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../src";
import { TypedEventEmitter } from "../../src/models/typed-event-emitter";
import { mkEvent } from "../test-utils/test-utils";
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
import { IEventDecryptionResult } from "../../src/@types/crypto";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -245,4 +248,33 @@ describe("RustCrypto", () => {
expect(outgoingRequestQueue.length).toEqual(2);
});
});
describe(".getEventEncryptionInfo", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
});
it("should handle unencrypted events", () => {
const event = mkEvent({ event: true, type: "m.room.message", content: { body: "xyz" } });
const res = rustCrypto.getEventEncryptionInfo(event);
expect(res.encrypted).toBeFalsy();
});
it("should handle encrypted events", async () => {
const event = mkEvent({ event: true, type: "m.room.encrypted", content: { algorithm: "fake_alg" } });
const mockCryptoBackend = {
decryptEvent: () =>
({
senderCurve25519Key: "1234",
} as IEventDecryptionResult),
} as unknown as CryptoBackend;
await event.attemptDecryption(mockCryptoBackend);
const res = rustCrypto.getEventEncryptionInfo(event);
expect(res.encrypted).toBeTruthy();
});
});
});
+1 -1
View File
@@ -1385,7 +1385,7 @@ describe("Call", function () {
}),
);
// @ts-ignore Mock
expect(call.terminate).toHaveBeenCalledWith(CallParty.Local, CallErrorCode.Transfered, true);
expect(call.terminate).toHaveBeenCalledWith(CallParty.Local, CallErrorCode.Transferred, true);
});
});
+2 -3
View File
@@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { RELATES_TO_RELATIONSHIP, REFERENCE_RELATION } from "matrix-events-sdk";
import { RelatesToRelationship, REFERENCE_RELATION } from "./extensible_events";
import { UnstableValue } from "../NamespacedValue";
import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location";
@@ -138,4 +137,4 @@ export type MBeaconEventContent = MLocationEvent &
// timestamp when location was taken
MTimestampEvent &
// relates to a beacon_info event
RELATES_TO_RELATIONSHIP<typeof REFERENCE_RELATION>;
RelatesToRelationship<typeof REFERENCE_RELATION>;
+134 -4
View File
@@ -1,5 +1,5 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,8 +14,138 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// Types for MSC1767: Extensible events in Matrix
import { EitherAnd, NamespacedValue, Optional, UnstableValue } from "matrix-events-sdk";
import { UnstableValue } from "../NamespacedValue";
import { isProvided } from "../extensible_events_v1/utilities";
export const TEXT_NODE_TYPE = new UnstableValue("m.text", "org.matrix.msc1767.text");
// Types and utilities for MSC1767: Extensible events (version 1) in Matrix
/**
* Represents the stable and unstable values of a given namespace.
*/
export type TSNamespace<N> = N extends NamespacedValue<infer S, infer U>
? TSNamespaceValue<S> | TSNamespaceValue<U>
: never;
/**
* Represents a namespaced value, if the value is a string. Used to extract provided types
* from a TSNamespace<N> (in cases where only stable *or* unstable is provided).
*/
export type TSNamespaceValue<V> = V extends string ? V : never;
/**
* Creates a type which is V when T is `never`, otherwise T.
*/
// See https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 for details on the array syntax.
export type DefaultNever<T, V> = [T] extends [never] ? V : T;
/**
* The namespaced value for m.message
*/
export const M_MESSAGE = new UnstableValue("m.message", "org.matrix.msc1767.message");
/**
* An m.message event rendering
*/
export interface IMessageRendering {
body: string;
mimetype?: string;
}
/**
* The content for an m.message event
*/
export type ExtensibleMessageEventContent = EitherAnd<
{ [M_MESSAGE.name]: IMessageRendering[] },
{ [M_MESSAGE.altName]: IMessageRendering[] }
>;
/**
* The namespaced value for m.text
*/
export const M_TEXT = new UnstableValue("m.text", "org.matrix.msc1767.text");
/**
* The content for an m.text event
*/
export type TextEventContent = EitherAnd<{ [M_TEXT.name]: string }, { [M_TEXT.altName]: string }>;
/**
* The namespaced value for m.html
*/
export const M_HTML = new UnstableValue("m.html", "org.matrix.msc1767.html");
/**
* The content for an m.html event
*/
export type HtmlEventContent = EitherAnd<{ [M_HTML.name]: string }, { [M_HTML.altName]: string }>;
/**
* The content for an m.message, m.text, or m.html event
*/
export type ExtensibleAnyMessageEventContent = ExtensibleMessageEventContent | TextEventContent | HtmlEventContent;
/**
* The namespaced value for an m.reference relation
*/
export const REFERENCE_RELATION = new NamespacedValue("m.reference");
/**
* Represents any relation type
*/
export type AnyRelation = TSNamespace<typeof REFERENCE_RELATION> | string;
/**
* An m.relates_to relationship
*/
export type RelatesToRelationship<R = never, C = never> = {
"m.relates_to": {
// See https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 for array syntax
rel_type: [R] extends [never] ? AnyRelation : TSNamespace<R>;
event_id: string;
} & DefaultNever<C, {}>;
};
/**
* Partial types for a Matrix Event.
*/
export interface IPartialEvent<TContent> {
type: string;
content: TContent;
}
/**
* Represents a potentially namespaced event type.
*/
export type ExtensibleEventType = NamespacedValue<string, string> | string;
/**
* Determines if two event types are the same, including namespaces.
* @param given - The given event type. This will be compared
* against the expected type.
* @param expected - The expected event type.
* @returns True if the given type matches the expected type.
*/
export function isEventTypeSame(
given: Optional<ExtensibleEventType>,
expected: Optional<ExtensibleEventType>,
): boolean {
if (typeof given === "string") {
if (typeof expected === "string") {
return expected === given;
} else {
return (expected as NamespacedValue<string, string>).matches(given as string);
}
} else {
if (typeof expected === "string") {
return (given as NamespacedValue<string, string>).matches(expected as string);
} else {
const expectedNs = expected as NamespacedValue<string, string>;
const givenNs = given as NamespacedValue<string, string>;
return (
expectedNs.matches(givenNs.name) ||
(isProvided(givenNs.altName) && expectedNs.matches(givenNs.altName!))
);
}
}
}
+2 -2
View File
@@ -18,7 +18,7 @@ limitations under the License.
import { EitherAnd } from "matrix-events-sdk";
import { UnstableValue } from "../NamespacedValue";
import { TEXT_NODE_TYPE } from "./extensible_events";
import { M_TEXT } from "./extensible_events";
export enum LocationAssetType {
Self = "m.self",
@@ -50,7 +50,7 @@ export type MLocationEvent = EitherAnd<
{ [M_LOCATION.altName]: MLocationContent }
>;
export type MTextEvent = EitherAnd<{ [TEXT_NODE_TYPE.name]: string }, { [TEXT_NODE_TYPE.altName]: string }>;
export type MTextEvent = EitherAnd<{ [M_TEXT.name]: string }, { [M_TEXT.altName]: string }>;
/* From the spec at:
* https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md
+119
View File
@@ -0,0 +1,119 @@
/*
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { EitherAnd, UnstableValue } from "matrix-events-sdk";
import {
ExtensibleAnyMessageEventContent,
REFERENCE_RELATION,
RelatesToRelationship,
TSNamespace,
} from "./extensible_events";
/**
* Identifier for a disclosed poll.
*/
export const M_POLL_KIND_DISCLOSED = new UnstableValue("m.poll.disclosed", "org.matrix.msc3381.poll.disclosed");
/**
* Identifier for an undisclosed poll.
*/
export const M_POLL_KIND_UNDISCLOSED = new UnstableValue("m.poll.undisclosed", "org.matrix.msc3381.poll.undisclosed");
/**
* Any poll kind.
*/
export type PollKind = TSNamespace<typeof M_POLL_KIND_DISCLOSED> | TSNamespace<typeof M_POLL_KIND_UNDISCLOSED> | string;
/**
* Known poll kind namespaces.
*/
export type KnownPollKind = typeof M_POLL_KIND_DISCLOSED | typeof M_POLL_KIND_UNDISCLOSED;
/**
* The namespaced value for m.poll.start
*/
export const M_POLL_START = new UnstableValue("m.poll.start", "org.matrix.msc3381.poll.start");
/**
* The m.poll.start type within event content
*/
export type PollStartSubtype = {
question: ExtensibleAnyMessageEventContent;
kind: PollKind;
max_selections?: number; // default 1, always positive
answers: PollAnswer[];
};
/**
* A poll answer.
*/
export type PollAnswer = ExtensibleAnyMessageEventContent & { id: string };
/**
* The event definition for an m.poll.start event (in content)
*/
export type PollStartEvent = EitherAnd<
{ [M_POLL_START.name]: PollStartSubtype },
{ [M_POLL_START.altName]: PollStartSubtype }
>;
/**
* The content for an m.poll.start event
*/
export type PollStartEventContent = PollStartEvent & ExtensibleAnyMessageEventContent;
/**
* The namespaced value for m.poll.response
*/
export const M_POLL_RESPONSE = new UnstableValue("m.poll.response", "org.matrix.msc3381.poll.response");
/**
* The m.poll.response type within event content
*/
export type PollResponseSubtype = {
answers: string[];
};
/**
* The event definition for an m.poll.response event (in content)
*/
export type PollResponseEvent = EitherAnd<
{ [M_POLL_RESPONSE.name]: PollResponseSubtype },
{ [M_POLL_RESPONSE.altName]: PollResponseSubtype }
>;
/**
* The content for an m.poll.response event
*/
export type PollResponseEventContent = PollResponseEvent & RelatesToRelationship<typeof REFERENCE_RELATION>;
/**
* The namespaced value for m.poll.end
*/
export const M_POLL_END = new UnstableValue("m.poll.end", "org.matrix.msc3381.poll.end");
/**
* The event definition for an m.poll.end event (in content)
*/
export type PollEndEvent = EitherAnd<{ [M_POLL_END.name]: {} }, { [M_POLL_END.altName]: {} }>;
/**
* The content for an m.poll.end event
*/
export type PollEndEventContent = PollEndEvent &
RelatesToRelationship<typeof REFERENCE_RELATION> &
ExtensibleAnyMessageEventContent;
+2 -1
View File
@@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EitherAnd, IMessageRendering } from "matrix-events-sdk";
import { EitherAnd } from "matrix-events-sdk";
import { UnstableValue } from "../NamespacedValue";
import { IMessageRendering } from "./extensible_events";
/**
* Extensible topic event type based on MSC3765
+19 -6
View File
@@ -855,6 +855,7 @@ export enum ClientEvent {
SyncUnexpectedError = "sync.unexpectedError",
ClientWellKnown = "WellKnown.client",
ReceivedVoipEvent = "received_voip_event",
UndecryptableToDeviceEvent = "toDeviceEvent.undecryptable",
TurnServers = "turnServers",
TurnServersError = "turnServers.error",
}
@@ -1063,6 +1064,18 @@ export type ClientEventHandlerMap = {
* ```
*/
[ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void;
/**
* Fires if a to-device event is received that cannot be decrypted.
* Encrypted to-device events will (generally) use plain Olm encryption,
* in which case decryption failures are fatal: the event will never be
* decryptable, unlike Megolm encrypted events where the key may simply
* arrive later.
*
* An undecryptable to-device event is therefore likley to indicate problems.
*
* @param event - The undecyptable to-device event
*/
[ClientEvent.UndecryptableToDeviceEvent]: (event: MatrixEvent) => void;
/**
* Fires whenever new user-scoped account_data is added.
* @param event - The event describing the account_data just added
@@ -2494,10 +2507,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns
*/
public checkUserTrust(userId: string): UserTrustLevel {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.checkUserTrust(userId);
return this.cryptoBackend.checkUserTrust(userId);
}
/**
@@ -2509,10 +2522,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param deviceId - The ID of the device to check
*/
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.checkDeviceTrust(userId, deviceId);
return this.cryptoBackend.checkDeviceTrust(userId, deviceId);
}
/**
@@ -2676,10 +2689,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns The event information.
*/
public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.getEventEncryptionInfo(event);
return this.cryptoBackend.getEventEncryptionInfo(event);
}
/**
+28
View File
@@ -16,7 +16,9 @@ limitations under the License.
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
import type { IToDeviceEvent } from "../sync-accumulator";
import type { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
import { MatrixEvent } from "../models/event";
import { IEncryptedEventInfo } from "../crypto/api";
/**
* Common interface for the crypto implementations
@@ -54,6 +56,25 @@ export interface CryptoBackend extends SyncCryptoCallbacks {
*/
userHasCrossSigningKeys(): Promise<boolean>;
/**
* Get the verification level for a given user
*
* TODO: define this better
*
* @param userId - user to be checked
*/
checkUserTrust(userId: string): UserTrustLevel;
/**
* Get the verification level for a given device
*
* TODO: define this better
*
* @param userId - user to be checked
* @param deviceId - device to be checked
*/
checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel;
/**
* Decrypt a received event
*
@@ -62,6 +83,13 @@ export interface CryptoBackend extends SyncCryptoCallbacks {
*/
decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult>;
/**
* Get information about the encryption of an event
*
* @param event - event to be checked
*/
getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo;
/**
* Get a list containing all of the room keys
*
+4 -5
View File
@@ -14,11 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { isProvided, REFERENCE_RELATION } from "matrix-events-sdk";
import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent } from "./@types/beacon";
import { MsgType } from "./@types/event";
import { TEXT_NODE_TYPE } from "./@types/extensible_events";
import { M_TEXT, REFERENCE_RELATION } from "./@types/extensible_events";
import { isProvided } from "./extensible_events_v1/utilities";
import {
M_ASSET,
LocationAssetType,
@@ -160,7 +159,7 @@ export const makeLocationContent = (
[M_ASSET.name]: {
type: assetType || LocationAssetType.Self,
},
[TEXT_NODE_TYPE.name]: defaultedText,
[M_TEXT.name]: defaultedText,
...timestampEvent,
} as LegacyLocationEventContent & MLocationEventContent;
};
@@ -173,7 +172,7 @@ export const parseLocationEvent = (wireEventContent: LocationEventWireContent):
const location = M_LOCATION.findIn<MLocationContent>(wireEventContent);
const asset = M_ASSET.findIn<MAssetContent>(wireEventContent);
const timestamp = M_TIMESTAMP.findIn<number>(wireEventContent);
const text = TEXT_NODE_TYPE.findIn<string>(wireEventContent);
const text = M_TEXT.findIn<string>(wireEventContent);
const geoUri = location?.uri ?? wireEventContent?.geo_uri;
const description = location?.description;
+5
View File
@@ -368,6 +368,11 @@ export class OlmDevice {
*/
private saveSession(deviceKey: string, sessionInfo: IUnpickledSessionInfo, txn: unknown): void {
const sessionId = sessionInfo.session.session_id();
logger.debug(`Saving Olm session ${sessionId} with device ${deviceKey}: ${sessionInfo.session.describe()}`);
// Why do we re-use the input object for this, overwriting the same key with a different
// type? Is it because we want to erase the unpickled session to enforce that it's no longer
// used? A comment would be great.
const pickledSessionInfo = Object.assign(sessionInfo, {
session: sessionInfo.session.pickle(this.pickleKey),
});
+199 -140
View File
@@ -21,7 +21,7 @@ limitations under the License.
import { v4 as uuidv4 } from "uuid";
import type { IEventDecryptionResult, IMegolmSessionData } from "../../@types/crypto";
import { logger } from "../../logger";
import { logger, PrefixedLogger } from "../../logger";
import * as olmlib from "../olmlib";
import {
DecryptionAlgorithm,
@@ -226,10 +226,12 @@ export class MegolmEncryption extends EncryptionAlgorithm {
};
protected readonly roomId: string;
private readonly prefixedLogger: PrefixedLogger;
public constructor(params: IParams & Required<Pick<IParams, "roomId">>) {
super(params);
this.roomId = params.roomId;
this.prefixedLogger = logger.withPrefix(`[${this.roomId} encryption]`);
this.sessionRotationPeriodMsgs = params.config?.rotation_period_msgs ?? 100;
this.sessionRotationPeriodMs = params.config?.rotation_period_ms ?? 7 * 24 * 3600 * 1000;
@@ -291,7 +293,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
// setupPromise resolves to `null` or the `OutboundSessionInfo` whether
// or not the share succeeds
this.setupPromise = fallible.catch((e) => {
logger.error(`Failed to setup outbound session in ${this.roomId}`, e);
this.prefixedLogger.error(`Failed to setup outbound session`, e);
return null;
});
@@ -311,7 +313,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
// need to make a brand new session?
if (session?.needsRotation(this.sessionRotationPeriodMsgs, this.sessionRotationPeriodMs)) {
logger.log("Starting new megolm session because we need to rotate.");
this.prefixedLogger.log("Starting new megolm session because we need to rotate.");
session = null;
}
@@ -321,9 +323,9 @@ export class MegolmEncryption extends EncryptionAlgorithm {
}
if (!session) {
logger.log(`Starting new megolm session for room ${this.roomId}`);
this.prefixedLogger.log("Starting new megolm session");
session = await this.prepareNewSession(sharedHistory);
logger.log(`Started new megolm session ${session.sessionId} ` + `for room ${this.roomId}`);
this.prefixedLogger.log(`Started new megolm session ${session.sessionId}`);
this.outboundSessions[session.sessionId] = session;
}
@@ -376,14 +378,24 @@ export class MegolmEncryption extends EncryptionAlgorithm {
await Promise.all([
(async (): Promise<void> => {
// share keys with devices that we already have a session for
logger.debug(`Sharing keys with existing Olm sessions in ${this.roomId}`, olmSessions);
const olmSessionList = Object.entries(olmSessions)
.map(([userId, sessionsByUser]) =>
Object.entries(sessionsByUser).map(
([deviceId, session]) => `${userId}/${deviceId}: ${session.sessionId}`,
),
)
.flat(1);
this.prefixedLogger.debug("Sharing keys with devices with existing Olm sessions:", olmSessionList);
await this.shareKeyWithOlmSessions(session, key, payload, olmSessions);
logger.debug(`Shared keys with existing Olm sessions in ${this.roomId}`);
this.prefixedLogger.debug("Shared keys with existing Olm sessions");
})(),
(async (): Promise<void> => {
logger.debug(
`Sharing keys (start phase 1) with new Olm sessions in ${this.roomId}`,
devicesWithoutSession,
const deviceList = Object.entries(devicesWithoutSession)
.map(([userId, devicesByUser]) => devicesByUser.map((device) => `${userId}/${device.deviceId}`))
.flat(1);
this.prefixedLogger.debug(
"Sharing keys (start phase 1) with devices without existing Olm sessions:",
deviceList,
);
const errorDevices: IOlmDevice[] = [];
@@ -403,7 +415,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
singleOlmCreationPhase ? 10000 : 2000,
failedServers,
);
logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this.roomId}`);
this.prefixedLogger.debug("Shared keys (end phase 1) with devices without existing Olm sessions");
if (!singleOlmCreationPhase && Date.now() - start < 10000) {
// perform the second phase of olm session creation if requested,
@@ -432,25 +444,40 @@ export class MegolmEncryption extends EncryptionAlgorithm {
}
}
logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this.roomId}`);
await this.shareKeyWithDevices(session, key, payload, retryDevices, failedDevices, 30000);
logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this.roomId}`);
const retryDeviceList = Object.entries(retryDevices)
.map(([userId, devicesByUser]) =>
devicesByUser.map((device) => `${userId}/${device.deviceId}`),
)
.flat(1);
if (retryDeviceList.length > 0) {
this.prefixedLogger.debug(
"Sharing keys (start phase 2) with devices without existing Olm sessions:",
retryDeviceList,
);
await this.shareKeyWithDevices(session, key, payload, retryDevices, failedDevices, 30000);
this.prefixedLogger.debug(
"Shared keys (end phase 2) with devices without existing Olm sessions",
);
}
await this.notifyFailedOlmDevices(session, key, failedDevices);
})();
} else {
await this.notifyFailedOlmDevices(session, key, errorDevices);
}
logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this.roomId}`);
})(),
(async (): Promise<void> => {
logger.debug(
`There are ${Object.entries(blocked).length} blocked devices in ${this.roomId}`,
Object.entries(blocked),
this.prefixedLogger.debug(
`There are ${Object.entries(blocked).length} blocked devices:`,
Object.entries(blocked)
.map(([userId, blockedByUser]) =>
Object.entries(blockedByUser).map(([deviceId, _deviceInfo]) => `${userId}/${deviceId}`),
)
.flat(1),
);
// also, notify newly blocked devices that they're blocked
logger.debug(`Notifying newly blocked devices in ${this.roomId}`);
const blockedMap: Record<string, Record<string, { device: IBlockedDevice }>> = {};
let blockedCount = 0;
for (const [userId, userBlockedDevices] of Object.entries(blocked)) {
@@ -466,8 +493,18 @@ export class MegolmEncryption extends EncryptionAlgorithm {
}
}
await this.notifyBlockedDevices(session, blockedMap);
logger.debug(`Notified ${blockedCount} newly blocked devices in ${this.roomId}`, blockedMap);
if (blockedCount) {
this.prefixedLogger.debug(
`Notifying ${blockedCount} newly blocked devices:`,
Object.entries(blockedMap)
.map(([userId, blockedByUser]) =>
Object.entries(blockedByUser).map(([deviceId, _deviceInfo]) => `${userId}/${deviceId}`),
)
.flat(1),
);
await this.notifyBlockedDevices(session, blockedMap);
this.prefixedLogger.debug(`Notified ${blockedCount} newly blocked devices`);
}
})(),
]);
}
@@ -620,7 +657,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
}
})
.catch((error) => {
logger.error("failed to encryptAndSendToDevices", error);
this.prefixedLogger.error("failed to encryptAndSendToDevices", error);
throw error;
});
}
@@ -694,25 +731,25 @@ export class MegolmEncryption extends EncryptionAlgorithm {
): Promise<void> {
const obSessionInfo = this.outboundSessions[sessionId];
if (!obSessionInfo) {
logger.debug(`megolm session ${senderKey}|${sessionId} not found: not re-sharing keys`);
this.prefixedLogger.debug(`megolm session ${senderKey}|${sessionId} not found: not re-sharing keys`);
return;
}
// The chain index of the key we previously sent this device
if (obSessionInfo.sharedWithDevices[userId] === undefined) {
logger.debug(`megolm session ${senderKey}|${sessionId} never shared with user ${userId}`);
this.prefixedLogger.debug(`megolm session ${senderKey}|${sessionId} never shared with user ${userId}`);
return;
}
const sessionSharedData = obSessionInfo.sharedWithDevices[userId][device.deviceId];
if (sessionSharedData === undefined) {
logger.debug(
this.prefixedLogger.debug(
`megolm session ${senderKey}|${sessionId} never shared with device ${userId}:${device.deviceId}`,
);
return;
}
if (sessionSharedData.deviceKey !== device.getIdentityKey()) {
logger.warn(
this.prefixedLogger.warn(
`Megolm session ${senderKey}|${sessionId} has been shared with device ${device.deviceId} but ` +
`with identity key ${sessionSharedData.deviceKey}. Key is now ${device.getIdentityKey()}!`,
);
@@ -729,7 +766,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
);
if (!key) {
logger.warn(
this.prefixedLogger.warn(
`No inbound session key found for megolm session ${senderKey}|${sessionId}: not re-sharing keys`,
);
return;
@@ -775,7 +812,9 @@ export class MegolmEncryption extends EncryptionAlgorithm {
[device.deviceId]: encryptedContent,
},
});
logger.debug(`Re-shared key for megolm session ${senderKey}|${sessionId} with ${userId}:${device.deviceId}`);
this.prefixedLogger.debug(
`Re-shared key for megolm session ${senderKey}|${sessionId} with ${userId}:${device.deviceId}`,
);
}
/**
@@ -807,7 +846,6 @@ export class MegolmEncryption extends EncryptionAlgorithm {
otkTimeout: number,
failedServers?: string[],
): Promise<void> {
logger.debug(`Ensuring Olm sessions for devices in ${this.roomId}`);
const devicemap = await olmlib.ensureOlmSessionsForDevices(
this.olmDevice,
this.baseApis,
@@ -815,15 +853,10 @@ export class MegolmEncryption extends EncryptionAlgorithm {
false,
otkTimeout,
failedServers,
logger.withPrefix?.(`[${this.roomId}]`),
this.prefixedLogger,
);
logger.debug(`Ensured Olm sessions for devices in ${this.roomId}`);
this.getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices);
logger.debug(`Sharing keys with newly created Olm sessions in ${this.roomId}`);
await this.shareKeyWithOlmSessions(session, key, payload, devicemap);
logger.debug(`Shared keys with newly created Olm sessions in ${this.roomId}`);
}
private async shareKeyWithOlmSessions(
@@ -835,17 +868,16 @@ export class MegolmEncryption extends EncryptionAlgorithm {
const userDeviceMaps = this.splitDevices(devicemap);
for (let i = 0; i < userDeviceMaps.length; i++) {
const taskDetail =
`megolm keys for ${session.sessionId} ` + `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`;
const taskDetail = `megolm keys for ${session.sessionId} (slice ${i + 1}/${userDeviceMaps.length})`;
try {
logger.debug(
this.prefixedLogger.debug(
`Sharing ${taskDetail}`,
userDeviceMaps[i].map((d) => `${d.userId}/${d.deviceInfo.deviceId}`),
);
await this.encryptAndSendKeysToDevices(session, key.chain_index, userDeviceMaps[i], payload);
logger.debug(`Shared ${taskDetail}`);
this.prefixedLogger.debug(`Shared ${taskDetail}`);
} catch (e) {
logger.error(`Failed to share ${taskDetail}`);
this.prefixedLogger.error(`Failed to share ${taskDetail}`);
throw e;
}
}
@@ -864,9 +896,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
key: IOutboundGroupSessionKey,
failedDevices: IOlmDevice[],
): Promise<void> {
logger.debug(
`Notifying ${failedDevices.length} devices we failed to ` + `create Olm sessions in ${this.roomId}`,
);
this.prefixedLogger.debug(`Notifying ${failedDevices.length} devices we failed to create Olm sessions`);
// mark the devices that failed as "handled" because we don't want to try
// to claim a one-time-key for dead devices on every message.
@@ -877,9 +907,8 @@ export class MegolmEncryption extends EncryptionAlgorithm {
}
const unnotifiedFailedDevices = await this.olmDevice.filterOutNotifiedErrorDevices(failedDevices);
logger.debug(
`Need to notify ${unnotifiedFailedDevices.length} failed devices ` +
`which haven't been notified before in ${this.roomId}`,
this.prefixedLogger.debug(
`Need to notify ${unnotifiedFailedDevices.length} failed devices which haven't been notified before`,
);
const blockedMap: Record<string, Record<string, { device: IBlockedDevice }>> = {};
for (const { userId, deviceInfo } of unnotifiedFailedDevices) {
@@ -898,9 +927,8 @@ export class MegolmEncryption extends EncryptionAlgorithm {
// send the notifications
await this.notifyBlockedDevices(session, blockedMap);
logger.debug(
`Notified ${unnotifiedFailedDevices.length} devices we failed to ` +
`create Olm sessions in ${this.roomId}`,
this.prefixedLogger.debug(
`Notified ${unnotifiedFailedDevices.length} devices we failed to create Olm sessions`,
);
}
@@ -926,14 +954,14 @@ export class MegolmEncryption extends EncryptionAlgorithm {
for (let i = 0; i < userDeviceMaps.length; i++) {
try {
await this.sendBlockedNotificationsToDevices(session, userDeviceMaps[i], payload);
logger.log(
this.prefixedLogger.log(
`Completed blacklist notification for ${session.sessionId} ` +
`in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`,
`(slice ${i + 1}/${userDeviceMaps.length})`,
);
} catch (e) {
logger.log(
`blacklist notification for ${session.sessionId} in ` +
`${this.roomId} (slice ${i + 1}/${userDeviceMaps.length}) failed`,
this.prefixedLogger.log(
`blacklist notification for ${session.sessionId} ` +
`(slice ${i + 1}/${userDeviceMaps.length}) failed`,
);
throw e;
@@ -948,24 +976,27 @@ export class MegolmEncryption extends EncryptionAlgorithm {
* @param room - the room the event is in
*/
public prepareToEncrypt(room: Room): void {
if (room.roomId !== this.roomId) {
throw new Error("MegolmEncryption.prepareToEncrypt called on unexpected room");
}
if (this.encryptionPreparation != null) {
// We're already preparing something, so don't do anything else.
// FIXME: check if we need to restart
// (https://github.com/matrix-org/matrix-js-sdk/issues/1255)
const elapsedTime = Date.now() - this.encryptionPreparation.startTime;
logger.debug(
`Already started preparing to encrypt for ${this.roomId} ` + `${elapsedTime} ms ago, skipping`,
this.prefixedLogger.debug(
`Already started preparing to encrypt for this room ${elapsedTime}ms ago, skipping`,
);
return;
}
logger.debug(`Preparing to encrypt events for ${this.roomId}`);
this.prefixedLogger.debug("Preparing to encrypt events");
this.encryptionPreparation = {
startTime: Date.now(),
promise: (async (): Promise<void> => {
try {
logger.debug(`Getting devices in ${this.roomId}`);
const [devicesInRoom, blocked] = await this.getDevicesInRoom(room);
if (this.crypto.globalErrorOnUnknownDevices) {
@@ -975,12 +1006,12 @@ export class MegolmEncryption extends EncryptionAlgorithm {
this.removeUnknownDevices(devicesInRoom);
}
logger.debug(`Ensuring outbound session in ${this.roomId}`);
this.prefixedLogger.debug("Ensuring outbound megolm session");
await this.ensureOutboundSession(room, devicesInRoom, blocked, true);
logger.debug(`Ready to encrypt events for ${this.roomId}`);
this.prefixedLogger.debug("Ready to encrypt events");
} catch (e) {
logger.error(`Failed to prepare to encrypt events for ${this.roomId}`, e);
this.prefixedLogger.error("Failed to prepare to encrypt events", e);
} finally {
delete this.encryptionPreparation;
}
@@ -994,7 +1025,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
* @returns Promise which resolves to the new event body
*/
public async encryptMessage(room: Room, eventType: string, content: IContent): Promise<IMegolmEncryptedContent> {
logger.log(`Starting to encrypt event for ${this.roomId}`);
this.prefixedLogger.log("Starting to encrypt event");
if (this.encryptionPreparation != null) {
// If we started sending keys, wait for it to be done.
@@ -1146,6 +1177,11 @@ export class MegolmEncryption extends EncryptionAlgorithm {
forceDistributeToUnverified = false,
): Promise<[DeviceInfoMap, IBlockedMap]> {
const members = await room.getEncryptionTargetMembers();
this.prefixedLogger.debug(
`Encrypting for users (shouldEncryptForInvitedMembers: ${room.shouldEncryptForInvitedMembers()}):`,
members.map((u) => `${u.userId} (${u.membership})`),
);
const roomMembers = members.map(function (u) {
return u.userId;
});
@@ -1216,10 +1252,12 @@ export class MegolmDecryption extends DecryptionAlgorithm {
private olmlib = olmlib;
protected readonly roomId: string;
private readonly prefixedLogger: PrefixedLogger;
public constructor(params: DecryptionClassParams<IParams & Required<Pick<IParams, "roomId">>>) {
super(params);
this.roomId = params.roomId;
this.prefixedLogger = logger.withPrefix(`[${this.roomId} decryption]`);
}
/**
@@ -1287,7 +1325,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
// event was sent. Use a fuzz factor of 2 minutes.
const problem = await this.olmDevice.sessionMayHaveProblems(content.sender_key, event.getTs() - 120000);
if (problem) {
logger.info(
this.prefixedLogger.info(
`When handling UISI from ${event.getSender()} (sender key ${content.sender_key}): ` +
`recent session problem with that sender: ${problem}`,
);
@@ -1406,12 +1444,12 @@ export class MegolmDecryption extends DecryptionAlgorithm {
const extraSessionData: OlmGroupSessionExtraData = {};
if (!content.room_id || !content.session_key || !content.session_id || !content.algorithm) {
logger.error("key event is missing fields");
this.prefixedLogger.error("key event is missing fields");
return;
}
if (!olmlib.isOlmEncrypted(event)) {
logger.error("key event not properly encrypted");
this.prefixedLogger.error("key event not properly encrypted");
return;
}
@@ -1426,7 +1464,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
senderKey,
);
if (senderKeyUser !== event.getSender()) {
logger.error("sending device does not belong to the user it claims to be from");
this.prefixedLogger.error("sending device does not belong to the user it claims to be from");
return;
}
const outgoingRequests = deviceInfo
@@ -1453,7 +1491,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
// not one of our other devices and it's not shared
// history, ignore it
if (!extraSessionData.sharedHistory) {
logger.log("forwarded key not shared history - ignoring");
this.prefixedLogger.log("forwarded key not shared history - ignoring");
return;
}
@@ -1461,7 +1499,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
// we're already in, and they're not one of our other
// devices or the one who invited us, ignore it
if (room && !fromInviter) {
logger.log("forwarded key not from inviter or from us - ignoring");
this.prefixedLogger.log("forwarded key not from inviter or from us - ignoring");
return;
}
}
@@ -1476,13 +1514,13 @@ export class MegolmDecryption extends DecryptionAlgorithm {
forwardingKeyChain.push(senderKey);
if (!content.sender_key) {
logger.error("forwarded_room_key event is missing sender_key field");
this.prefixedLogger.error("forwarded_room_key event is missing sender_key field");
return;
}
const ed25519Key = content.sender_claimed_ed25519_key;
if (!ed25519Key) {
logger.error(`forwarded_room_key_event is missing sender_claimed_ed25519_key field`);
this.prefixedLogger.error(`forwarded_room_key_event is missing sender_claimed_ed25519_key field`);
return;
}
@@ -1506,7 +1544,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
"readwrite",
["parked_shared_history"],
(txn) => this.crypto.cryptoStore.addParkedSharedHistory(content.room_id!, parkedData, txn),
logger.withPrefix("[addParkedSharedHistory]"),
this.prefixedLogger.withPrefix("[addParkedSharedHistory]"),
);
return;
}
@@ -1563,7 +1601,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
// don't wait for the keys to be backed up for the server
await this.crypto.backupManager.backupGroupSession(senderKey, content.session_id);
} catch (e) {
logger.error(`Error handling m.room_key_event: ${e}`);
this.prefixedLogger.error(`Error handling m.room_key_event: ${e}`);
}
}
@@ -1575,65 +1613,10 @@ export class MegolmDecryption extends DecryptionAlgorithm {
const senderKey = content.sender_key;
if (content.code === "m.no_olm") {
const sender = event.getSender()!;
logger.warn(`${sender}:${senderKey} was unable to establish an olm session with us`);
// if the sender says that they haven't been able to establish an olm
// session, let's proactively establish one
// Note: after we record that the olm session has had a problem, we
// trigger retrying decryption for all the messages from the sender's
// key, so that we can update the error message to indicate the olm
// session problem.
if (await this.olmDevice.getSessionIdForDevice(senderKey)) {
// a session has already been established, so we don't need to
// create a new one.
logger.debug("New session already created. Not creating a new one.");
await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true);
this.retryDecryptionFromSender(senderKey);
return;
}
let device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey);
if (!device) {
// if we don't know about the device, fetch the user's devices again
// and retry before giving up
await this.crypto.downloadKeys([sender], false);
device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey);
if (!device) {
logger.info("Couldn't find device for identity key " + senderKey + ": not establishing session");
await this.olmDevice.recordSessionProblem(senderKey, "no_olm", false);
this.retryDecryptionFromSender(senderKey);
return;
}
}
// XXX: switch this to use encryptAndSendToDevices() rather than duplicating it?
await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, { [sender]: [device] }, false);
const encryptedContent: IEncryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: this.olmDevice.deviceCurve25519Key!,
ciphertext: {},
[ToDeviceMessageId]: uuidv4(),
};
await olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
this.userId,
undefined,
this.olmDevice,
sender,
device,
{ type: "m.dummy" },
);
await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true);
this.retryDecryptionFromSender(senderKey);
await this.baseApis.sendToDevice("m.room.encrypted", {
[sender]: {
[device.deviceId]: encryptedContent,
},
});
await this.onNoOlmWithheldEvent(event);
} else if (content.code === "m.unavailable") {
// this simply means that the other device didn't have the key, which isn't very useful information. Don't
// record it in the storage
} else {
await this.olmDevice.addInboundGroupSessionWithheld(
content.room_id,
@@ -1643,6 +1626,78 @@ export class MegolmDecryption extends DecryptionAlgorithm {
content.reason,
);
}
// Having recorded the problem, retry decryption on any affected messages.
// It's unlikely we'll be able to decrypt sucessfully now, but this will
// update the error message.
//
if (content.session_id) {
await this.retryDecryption(senderKey, content.session_id);
} else {
// no_olm messages aren't specific to a given megolm session, so
// we trigger retrying decryption for all the messages from the sender's
// key, so that we can update the error message to indicate the olm
// session problem.
await this.retryDecryptionFromSender(senderKey);
}
}
private async onNoOlmWithheldEvent(event: MatrixEvent): Promise<void> {
const content = event.getContent();
const senderKey = content.sender_key;
const sender = event.getSender()!;
this.prefixedLogger.warn(`${sender}:${senderKey} was unable to establish an olm session with us`);
// if the sender says that they haven't been able to establish an olm
// session, let's proactively establish one
if (await this.olmDevice.getSessionIdForDevice(senderKey)) {
// a session has already been established, so we don't need to
// create a new one.
this.prefixedLogger.debug("New session already created. Not creating a new one.");
await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true);
return;
}
let device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey);
if (!device) {
// if we don't know about the device, fetch the user's devices again
// and retry before giving up
await this.crypto.downloadKeys([sender], false);
device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey);
if (!device) {
this.prefixedLogger.info(
"Couldn't find device for identity key " + senderKey + ": not establishing session",
);
await this.olmDevice.recordSessionProblem(senderKey, "no_olm", false);
return;
}
}
// XXX: switch this to use encryptAndSendToDevices() rather than duplicating it?
await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, { [sender]: [device] }, false);
const encryptedContent: IEncryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: this.olmDevice.deviceCurve25519Key!,
ciphertext: {},
[ToDeviceMessageId]: uuidv4(),
};
await olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
this.userId,
undefined,
this.olmDevice,
sender,
device,
{ type: "m.dummy" },
);
await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true);
await this.baseApis.sendToDevice("m.room.encrypted", {
[sender]: {
[device.deviceId]: encryptedContent,
},
});
}
public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise<boolean> {
@@ -1679,7 +1734,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
return null;
}
logger.log(
this.prefixedLogger.log(
"sharing keys for session " +
body.sender_key +
"|" +
@@ -1778,7 +1833,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
this.crypto.backupManager.backupGroupSession(session.sender_key, session.session_id).catch((e) => {
// This throws if the upload failed, but this is fine
// since it will have written it to the db and will retry.
logger.log("Failed to back up megolm session", e);
this.prefixedLogger.log("Failed to back up megolm session", e);
});
}
// have another go at decrypting events sent with this session.
@@ -1812,10 +1867,14 @@ export class MegolmDecryption extends DecryptionAlgorithm {
return true;
}
logger.debug("Retrying decryption on events", [...pending]);
const pendingList = [...pending];
this.prefixedLogger.debug(
"Retrying decryption on events:",
pendingList.map((e) => `${e.getId()}`),
);
await Promise.all(
[...pending].map(async (ev) => {
pendingList.map(async (ev) => {
try {
await ev.attemptDecryption(this.crypto, { isRetry: true, forceRedecryptIfUntrusted });
} catch (e) {
@@ -1858,8 +1917,8 @@ export class MegolmDecryption extends DecryptionAlgorithm {
await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser);
const sharedHistorySessions = await this.olmDevice.getSharedHistoryInboundGroupSessions(this.roomId);
logger.log(
`Sharing history in ${this.roomId} with users ${Object.keys(devicesByUser)}`,
this.prefixedLogger.log(
`Sharing history in with users ${Object.keys(devicesByUser)}`,
sharedHistorySessions.map(([senderKey, sessionId]) => `${senderKey}|${sessionId}`),
);
for (const [senderKey, sessionId] of sharedHistorySessions) {
@@ -1900,20 +1959,20 @@ export class MegolmDecryption extends DecryptionAlgorithm {
for (const userId of Object.keys(contentMap)) {
for (const deviceId of Object.keys(contentMap[userId])) {
if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) {
logger.log("No ciphertext for device " + userId + ":" + deviceId + ": pruning");
this.prefixedLogger.log("No ciphertext for device " + userId + ":" + deviceId + ": pruning");
delete contentMap[userId][deviceId];
}
}
// No devices left for that user? Strip that too.
if (Object.keys(contentMap[userId]).length === 0) {
logger.log("Pruned all devices for user " + userId);
this.prefixedLogger.log("Pruned all devices for user " + userId);
delete contentMap[userId];
}
}
// Is there anything left?
if (Object.keys(contentMap).length === 0) {
logger.log("No users left to send to: aborting");
this.prefixedLogger.log("No users left to send to: aborting");
return;
}
+2
View File
@@ -3433,6 +3433,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
const algorithm = content.algorithm;
const deviceKey = content.sender_key;
this.baseApis.emit(ClientEvent.UndecryptableToDeviceEvent, event);
// retry decryption for all events sent by the sender_key. This will
// update the events to show a message indicating that the olm session was
// wedged.
+14
View File
@@ -16,6 +16,7 @@ limitations under the License.
import { MatrixClient } from "./client";
import { IEvent, MatrixEvent, MatrixEventEvent } from "./models/event";
import { RelationType } from "./@types/event";
export type EventMapper = (obj: Partial<IEvent>) => MatrixEvent;
@@ -55,6 +56,19 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event
preventReEmit = true;
}
// if there is a complete edit bundled alongside the event, perform the replacement.
// (prior to MSC3925, events were automatically replaced on the server-side. MSC3925 proposes that that doesn't
// happen automatically but the server does provide us with the whole content of the edit event.)
const bundledEdit = event.getServerAggregatedRelation<Partial<IEvent>>(RelationType.Replace);
if (bundledEdit?.content) {
const replacement = mapper(bundledEdit);
// XXX: it's worth noting that the spec says we should only respect encrypted edits if, once decrypted, the
// replacement has a `m.new_content` property. The problem is that we haven't yet decrypted the replacement
// (it should be happening in the background), so we can't enforce this. Possibly we should for decryption
// to complete, but that sounds a bit racy. For now, we just assume it's ok.
event.makeReplaced(replacement);
}
const thread = room?.findThreadForEvent(event);
if (thread) {
event.setThread(thread);
@@ -0,0 +1,58 @@
/*
Copyright 2021 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ExtensibleEventType, IPartialEvent } from "../@types/extensible_events";
/**
* Represents an Extensible Event in Matrix.
*/
export abstract class ExtensibleEvent<TContent extends object = object> {
protected constructor(public readonly wireFormat: IPartialEvent<TContent>) {}
/**
* Shortcut to wireFormat.content
*/
public get wireContent(): TContent {
return this.wireFormat.content;
}
/**
* Serializes the event into a format which can be used to send the
* event to the room.
* @returns The serialized event.
*/
public abstract serialize(): IPartialEvent<object>;
/**
* Determines if this event is equivalent to the provided event type.
* This is recommended over `instanceof` checks due to issues in the JS
* runtime (and layering of dependencies in some projects).
*
* Implementations should pass this check off to their super classes
* if their own checks fail. Some primary implementations do not extend
* fallback classes given they support the primary type first. Thus,
* those classes may return false if asked about their fallback
* representation.
*
* Note that this only checks primary event types: legacy events, like
* m.room.message, should/will fail this check.
* @param primaryEventType - The (potentially namespaced) event
* type.
* @returns True if this event *could* be represented as the
* given type.
*/
public abstract isEquivalentTo(primaryEventType: ExtensibleEventType): boolean;
}
@@ -0,0 +1,24 @@
/*
Copyright 2022 - 2023 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.
*/
/**
* Thrown when an event is unforgivably unparsable.
*/
export class InvalidEventError extends Error {
public constructor(message: string) {
super(message);
}
}
+145
View File
@@ -0,0 +1,145 @@
/*
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Optional } from "matrix-events-sdk";
import { ExtensibleEvent } from "./ExtensibleEvent";
import {
ExtensibleEventType,
IMessageRendering,
IPartialEvent,
isEventTypeSame,
M_HTML,
M_MESSAGE,
ExtensibleAnyMessageEventContent,
M_TEXT,
} from "../@types/extensible_events";
import { isOptionalAString, isProvided } from "./utilities";
import { InvalidEventError } from "./InvalidEventError";
/**
* Represents a message event. Message events are the simplest form of event with
* just text (optionally of different mimetypes, like HTML).
*
* Message events can additionally be an Emote or Notice, though typically those
* are represented as EmoteEvent and NoticeEvent respectively.
*/
export class MessageEvent extends ExtensibleEvent<ExtensibleAnyMessageEventContent> {
/**
* The default text for the event.
*/
public readonly text: string;
/**
* The default HTML for the event, if provided.
*/
public readonly html: Optional<string>;
/**
* All the different renderings of the message. Note that this is the same
* format as an m.message body but may contain elements not found directly
* in the event content: this is because this is interpreted based off the
* other information available in the event.
*/
public readonly renderings: IMessageRendering[];
/**
* Creates a new MessageEvent from a pure format. Note that the event is
* *not* parsed here: it will be treated as a literal m.message primary
* typed event.
* @param wireFormat - The event.
*/
public constructor(wireFormat: IPartialEvent<ExtensibleAnyMessageEventContent>) {
super(wireFormat);
const mmessage = M_MESSAGE.findIn(this.wireContent);
const mtext = M_TEXT.findIn<string>(this.wireContent);
const mhtml = M_HTML.findIn<string>(this.wireContent);
if (isProvided(mmessage)) {
if (!Array.isArray(mmessage)) {
throw new InvalidEventError("m.message contents must be an array");
}
const text = mmessage.find((r) => !isProvided(r.mimetype) || r.mimetype === "text/plain");
const html = mmessage.find((r) => r.mimetype === "text/html");
if (!text) throw new InvalidEventError("m.message is missing a plain text representation");
this.text = text.body;
this.html = html?.body;
this.renderings = mmessage;
} else if (isOptionalAString(mtext)) {
this.text = mtext;
this.html = mhtml;
this.renderings = [{ body: mtext, mimetype: "text/plain" }];
if (this.html) {
this.renderings.push({ body: this.html, mimetype: "text/html" });
}
} else {
throw new InvalidEventError("Missing textual representation for event");
}
}
public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean {
return isEventTypeSame(primaryEventType, M_MESSAGE);
}
protected serializeMMessageOnly(): ExtensibleAnyMessageEventContent {
let messageRendering: ExtensibleAnyMessageEventContent = {
[M_MESSAGE.name]: this.renderings,
};
// Use the shorthand if it's just a simple text event
if (this.renderings.length === 1) {
const mime = this.renderings[0].mimetype;
if (mime === undefined || mime === "text/plain") {
messageRendering = {
[M_TEXT.name]: this.renderings[0].body,
};
}
}
return messageRendering;
}
public serialize(): IPartialEvent<object> {
return {
type: "m.room.message",
content: {
...this.serializeMMessageOnly(),
body: this.text,
msgtype: "m.text",
format: this.html ? "org.matrix.custom.html" : undefined,
formatted_body: this.html ?? undefined,
},
};
}
/**
* Creates a new MessageEvent from text and HTML.
* @param text - The text.
* @param html - Optional HTML.
* @returns The representative message event.
*/
public static from(text: string, html?: string): MessageEvent {
return new MessageEvent({
type: M_MESSAGE.name,
content: {
[M_TEXT.name]: text,
[M_HTML.name]: html,
},
});
}
}
+97
View File
@@ -0,0 +1,97 @@
/*
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
ExtensibleEventType,
IPartialEvent,
isEventTypeSame,
M_TEXT,
REFERENCE_RELATION,
} from "../@types/extensible_events";
import { M_POLL_END, PollEndEventContent } from "../@types/polls";
import { ExtensibleEvent } from "./ExtensibleEvent";
import { InvalidEventError } from "./InvalidEventError";
import { MessageEvent } from "./MessageEvent";
/**
* Represents a poll end/closure event.
*/
export class PollEndEvent extends ExtensibleEvent<PollEndEventContent> {
/**
* The poll start event ID referenced by the response.
*/
public readonly pollEventId: string;
/**
* The closing message for the event.
*/
public readonly closingMessage: MessageEvent;
/**
* Creates a new PollEndEvent from a pure format. Note that the event is *not*
* parsed here: it will be treated as a literal m.poll.response primary typed event.
* @param wireFormat - The event.
*/
public constructor(wireFormat: IPartialEvent<PollEndEventContent>) {
super(wireFormat);
const rel = this.wireContent["m.relates_to"];
if (!REFERENCE_RELATION.matches(rel?.rel_type) || typeof rel?.event_id !== "string") {
throw new InvalidEventError("Relationship must be a reference to an event");
}
this.pollEventId = rel.event_id;
this.closingMessage = new MessageEvent(this.wireFormat);
}
public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean {
return isEventTypeSame(primaryEventType, M_POLL_END);
}
public serialize(): IPartialEvent<object> {
return {
type: M_POLL_END.name,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: this.pollEventId,
},
[M_POLL_END.name]: {},
...this.closingMessage.serialize().content,
},
};
}
/**
* Creates a new PollEndEvent from a poll event ID.
* @param pollEventId - The poll start event ID.
* @param message - A closing message, typically revealing the top answer.
* @returns The representative poll closure event.
*/
public static from(pollEventId: string, message: string): PollEndEvent {
return new PollEndEvent({
type: M_POLL_END.name,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: pollEventId,
},
[M_POLL_END.name]: {},
[M_TEXT.name]: message,
},
});
}
}
@@ -0,0 +1,143 @@
/*
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ExtensibleEvent } from "./ExtensibleEvent";
import { M_POLL_RESPONSE, PollResponseEventContent, PollResponseSubtype } from "../@types/polls";
import { ExtensibleEventType, IPartialEvent, isEventTypeSame, REFERENCE_RELATION } from "../@types/extensible_events";
import { InvalidEventError } from "./InvalidEventError";
import { PollStartEvent } from "./PollStartEvent";
/**
* Represents a poll response event.
*/
export class PollResponseEvent extends ExtensibleEvent<PollResponseEventContent> {
private internalAnswerIds: string[] = [];
private internalSpoiled = false;
/**
* The provided answers for the poll. Note that this may be falsy/unpredictable if
* the `spoiled` property is true.
*/
public get answerIds(): string[] {
return this.internalAnswerIds;
}
/**
* The poll start event ID referenced by the response.
*/
public readonly pollEventId: string;
/**
* Whether the vote is spoiled.
*/
public get spoiled(): boolean {
return this.internalSpoiled;
}
/**
* Creates a new PollResponseEvent from a pure format. Note that the event is *not*
* parsed here: it will be treated as a literal m.poll.response primary typed event.
*
* To validate the response against a poll, call `validateAgainst` after creation.
* @param wireFormat - The event.
*/
public constructor(wireFormat: IPartialEvent<PollResponseEventContent>) {
super(wireFormat);
const rel = this.wireContent["m.relates_to"];
if (!REFERENCE_RELATION.matches(rel?.rel_type) || typeof rel?.event_id !== "string") {
throw new InvalidEventError("Relationship must be a reference to an event");
}
this.pollEventId = rel.event_id;
this.validateAgainst(null);
}
/**
* Validates the poll response using the poll start event as a frame of reference. This
* is used to determine if the vote is spoiled, whether the answers are valid, etc.
* @param poll - The poll start event.
*/
public validateAgainst(poll: PollStartEvent | null): void {
const response = M_POLL_RESPONSE.findIn<PollResponseSubtype>(this.wireContent);
if (!Array.isArray(response?.answers)) {
this.internalSpoiled = true;
this.internalAnswerIds = [];
return;
}
let answers = response?.answers ?? [];
if (answers.some((a) => typeof a !== "string") || answers.length === 0) {
this.internalSpoiled = true;
this.internalAnswerIds = [];
return;
}
if (poll) {
if (answers.some((a) => !poll.answers.some((pa) => pa.id === a))) {
this.internalSpoiled = true;
this.internalAnswerIds = [];
return;
}
answers = answers.slice(0, poll.maxSelections);
}
this.internalAnswerIds = answers;
this.internalSpoiled = false;
}
public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean {
return isEventTypeSame(primaryEventType, M_POLL_RESPONSE);
}
public serialize(): IPartialEvent<object> {
return {
type: M_POLL_RESPONSE.name,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: this.pollEventId,
},
[M_POLL_RESPONSE.name]: {
answers: this.spoiled ? undefined : this.answerIds,
},
},
};
}
/**
* Creates a new PollResponseEvent from a set of answers. To spoil the vote, pass an empty
* answers array.
* @param answers - The user's answers. Should be valid from a poll's answer IDs.
* @param pollEventId - The poll start event ID.
* @returns The representative poll response event.
*/
public static from(answers: string[], pollEventId: string): PollResponseEvent {
return new PollResponseEvent({
type: M_POLL_RESPONSE.name,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: pollEventId,
},
[M_POLL_RESPONSE.name]: {
answers: answers,
},
},
});
}
}
+207
View File
@@ -0,0 +1,207 @@
/*
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { NamespacedValue } from "matrix-events-sdk";
import { MessageEvent } from "./MessageEvent";
import { ExtensibleEventType, IPartialEvent, isEventTypeSame, M_TEXT } from "../@types/extensible_events";
import {
KnownPollKind,
M_POLL_KIND_DISCLOSED,
M_POLL_KIND_UNDISCLOSED,
M_POLL_START,
PollStartEventContent,
PollStartSubtype,
PollAnswer,
} from "../@types/polls";
import { InvalidEventError } from "./InvalidEventError";
import { ExtensibleEvent } from "./ExtensibleEvent";
/**
* Represents a poll answer. Note that this is represented as a subtype and is
* not registered as a parsable event - it is implied for usage exclusively
* within the PollStartEvent parsing.
*/
export class PollAnswerSubevent extends MessageEvent {
/**
* The answer ID.
*/
public readonly id: string;
public constructor(wireFormat: IPartialEvent<PollAnswer>) {
super(wireFormat);
const id = wireFormat.content.id;
if (!id || typeof id !== "string") {
throw new InvalidEventError("Answer ID must be a non-empty string");
}
this.id = id;
}
public serialize(): IPartialEvent<object> {
return {
type: "org.matrix.sdk.poll.answer",
content: {
id: this.id,
...this.serializeMMessageOnly(),
},
};
}
/**
* Creates a new PollAnswerSubevent from ID and text.
* @param id - The answer ID (unique within the poll).
* @param text - The text.
* @returns The representative answer.
*/
public static from(id: string, text: string): PollAnswerSubevent {
return new PollAnswerSubevent({
type: "org.matrix.sdk.poll.answer",
content: {
id: id,
[M_TEXT.name]: text,
},
});
}
}
/**
* Represents a poll start event.
*/
export class PollStartEvent extends ExtensibleEvent<PollStartEventContent> {
/**
* The question being asked, as a MessageEvent node.
*/
public readonly question: MessageEvent;
/**
* The interpreted kind of poll. Note that this will infer a value that is known to the
* SDK rather than verbatim - this means unknown types will be represented as undisclosed
* polls.
*
* To get the raw kind, use rawKind.
*/
public readonly kind: KnownPollKind;
/**
* The true kind as provided by the event sender. Might not be valid.
*/
public readonly rawKind: string;
/**
* The maximum number of selections a user is allowed to make.
*/
public readonly maxSelections: number;
/**
* The possible answers for the poll.
*/
public readonly answers: PollAnswerSubevent[];
/**
* Creates a new PollStartEvent from a pure format. Note that the event is *not*
* parsed here: it will be treated as a literal m.poll.start primary typed event.
* @param wireFormat - The event.
*/
public constructor(wireFormat: IPartialEvent<PollStartEventContent>) {
super(wireFormat);
const poll = M_POLL_START.findIn<PollStartSubtype>(this.wireContent);
if (!poll?.question) {
throw new InvalidEventError("A question is required");
}
this.question = new MessageEvent({ type: "org.matrix.sdk.poll.question", content: poll.question });
this.rawKind = poll.kind;
if (M_POLL_KIND_DISCLOSED.matches(this.rawKind)) {
this.kind = M_POLL_KIND_DISCLOSED;
} else {
this.kind = M_POLL_KIND_UNDISCLOSED; // default & assumed value
}
this.maxSelections =
Number.isFinite(poll.max_selections) && poll.max_selections! > 0 ? poll.max_selections! : 1;
if (!Array.isArray(poll.answers)) {
throw new InvalidEventError("Poll answers must be an array");
}
const answers = poll.answers.slice(0, 20).map(
(a) =>
new PollAnswerSubevent({
type: "org.matrix.sdk.poll.answer",
content: a,
}),
);
if (answers.length <= 0) {
throw new InvalidEventError("No answers available");
}
this.answers = answers;
}
public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean {
return isEventTypeSame(primaryEventType, M_POLL_START);
}
public serialize(): IPartialEvent<object> {
return {
type: M_POLL_START.name,
content: {
[M_POLL_START.name]: {
question: this.question.serialize().content,
kind: this.rawKind,
max_selections: this.maxSelections,
answers: this.answers.map((a) => a.serialize().content),
},
[M_TEXT.name]: `${this.question.text}\n${this.answers.map((a, i) => `${i + 1}. ${a.text}`).join("\n")}`,
},
};
}
/**
* Creates a new PollStartEvent from question, answers, and metadata.
* @param question - The question to ask.
* @param answers - The answers. Should be unique within each other.
* @param kind - The kind of poll.
* @param maxSelections - The maximum number of selections. Must be 1 or higher.
* @returns The representative poll start event.
*/
public static from(
question: string,
answers: string[],
kind: KnownPollKind | string,
maxSelections = 1,
): PollStartEvent {
return new PollStartEvent({
type: M_POLL_START.name,
content: {
[M_TEXT.name]: question, // unused by parsing
[M_POLL_START.name]: {
question: { [M_TEXT.name]: question },
kind: kind instanceof NamespacedValue ? kind.name : kind,
max_selections: maxSelections,
answers: answers.map((a) => ({ id: makeId(), [M_TEXT.name]: a })),
},
},
});
}
}
const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
function makeId(): string {
return [...Array(16)].map(() => LETTERS.charAt(Math.floor(Math.random() * LETTERS.length))).join("");
}
+35
View File
@@ -0,0 +1,35 @@
/*
Copyright 2021 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Optional } from "matrix-events-sdk";
/**
* Determines if the given optional was provided a value.
* @param s - The optional to test.
* @returns True if the value is defined.
*/
export function isProvided<T>(s: Optional<T>): boolean {
return s !== null && s !== undefined;
}
/**
* Determines if the given optional string is a defined string.
* @param s - The input string.
* @returns True if the input is a defined string.
*/
export function isOptionalAString(s: Optional<string>): s is string {
return isProvided(s) && typeof s === "string";
}
+4 -1
View File
@@ -1114,6 +1114,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
for (const timelineSet of this.timelineSets) {
timelineSet.resetLiveTimeline(backPaginationToken ?? undefined, forwardPaginationToken ?? undefined);
}
for (const thread of this.threads.values()) {
thread.resetLiveTimeline(backPaginationToken, forwardPaginationToken);
}
this.fixUpLegacyTimelineFields();
}
@@ -1223,7 +1226,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
const event = this.findEventById(eventId);
const thread = this.findThreadForEvent(event);
if (thread) {
return thread.timelineSet.getLiveTimeline();
return thread.timelineSet.getTimelineForEvent(eventId);
} else {
return this.getUnfilteredTimelineSet().getTimelineForEvent(eventId);
}
+59 -2
View File
@@ -256,7 +256,7 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
this.setEventMetadata(event);
const lastReply = this.lastReply();
const isNewestReply = !lastReply || event.localTimestamp > lastReply!.localTimestamp;
const isNewestReply = !lastReply || event.localTimestamp >= lastReply!.localTimestamp;
// Add all incoming events to the thread's timeline set when there's no server support
if (!Thread.hasServerSideSupport) {
@@ -358,6 +358,63 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
this.pendingReplyCount = pendingEvents.length;
}
/**
* Reset the live timeline of all timelineSets, and start new ones.
*
* <p>This is used when /sync returns a 'limited' timeline. 'Limited' means that there's a gap between the messages
* /sync returned, and the last known message in our timeline. In such a case, our live timeline isn't live anymore
* and has to be replaced by a new one. To make sure we can continue paginating our timelines correctly, we have to
* set new pagination tokens on the old and the new timeline.
*
* @param backPaginationToken - token for back-paginating the new timeline
* @param forwardPaginationToken - token for forward-paginating the old live timeline,
* if absent or null, all timelines are reset, removing old ones (including the previous live
* timeline which would otherwise be unable to paginate forwards without this token).
* Removing just the old live timeline whilst preserving previous ones is not supported.
*/
public async resetLiveTimeline(
backPaginationToken?: string | null,
forwardPaginationToken?: string | null,
): Promise<void> {
const oldLive = this.liveTimeline;
this.timelineSet.resetLiveTimeline(backPaginationToken ?? undefined, forwardPaginationToken ?? undefined);
const newLive = this.liveTimeline;
// FIXME: Remove the following as soon as https://github.com/matrix-org/synapse/issues/14830 is resolved.
//
// The pagination API for thread timelines currently can't handle the type of pagination tokens returned by sync
//
// To make this work anyway, we'll have to transform them into one of the types that the API can handle.
// One option is passing the tokens to /messages, which can handle sync tokens, and returns the right format.
// /messages does not return new tokens on requests with a limit of 0.
// This means our timelines might overlap a slight bit, but that's not an issue, as we deduplicate messages
// anyway.
let newBackward: string | undefined;
let oldForward: string | undefined;
if (backPaginationToken) {
const res = await this.client.createMessagesRequest(this.roomId, backPaginationToken, 1, Direction.Forward);
newBackward = res.end;
}
if (forwardPaginationToken) {
const res = await this.client.createMessagesRequest(
this.roomId,
forwardPaginationToken,
1,
Direction.Backward,
);
oldForward = res.start;
}
// Only replace the token if we don't have paginated away from this position already. This situation doesn't
// occur today, but if the above issue is resolved, we'd have to go down this path.
if (forwardPaginationToken && oldLive.getPaginationToken(Direction.Forward) === forwardPaginationToken) {
oldLive.setPaginationToken(oldForward ?? null, Direction.Forward);
}
if (backPaginationToken && newLive.getPaginationToken(Direction.Backward) === backPaginationToken) {
newLive.setPaginationToken(newBackward ?? null, Direction.Backward);
}
}
private async updateThreadMetadata(): Promise<void> {
this.updatePendingReplyCount();
@@ -515,7 +572,7 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
*/
public getEventReadUpTo(userId: string, ignoreSynthesized?: boolean): string | null {
const isCurrentUser = userId === this.client.getUserId();
const lastReply = this.timeline.at(-1);
const lastReply = this.timeline[this.timeline.length - 1];
if (isCurrentUser && lastReply) {
// If the last activity in a thread is prior to the first threaded read receipt
// sent in the room (suggesting that it was sent before the user started
+48 -2
View File
@@ -16,6 +16,7 @@ limitations under the License.
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import {
DecryptedRoomEvent,
KeysBackupRequest,
KeysClaimRequest,
KeysQueryRequest,
@@ -25,11 +26,13 @@ import {
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
import type { IToDeviceEvent } from "../sync-accumulator";
import type { IEncryptedEventInfo } from "../crypto/api";
import { MatrixEvent } from "../models/event";
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
import { logger } from "../logger";
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
import { QueryDict } from "../utils";
import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
/**
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
@@ -74,8 +77,41 @@ export class RustCrypto implements CryptoBackend {
}
public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
await this.olmMachine.decryptRoomEvent("event", new RustSdkCryptoJs.RoomId("room"));
throw new Error("not implemented");
const res = (await this.olmMachine.decryptRoomEvent(
JSON.stringify({
event_id: event.getId(),
type: event.getWireType(),
sender: event.getSender(),
state_key: event.getStateKey(),
content: event.getWireContent(),
origin_server_ts: event.getTs(),
}),
new RustSdkCryptoJs.RoomId(event.getRoomId()!),
)) as DecryptedRoomEvent;
return {
clearEvent: JSON.parse(res.event),
claimedEd25519Key: res.senderClaimedEd25519Key,
senderCurve25519Key: res.senderCurve25519Key,
forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain,
};
}
public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo {
// TODO: make this work properly. Or better, replace it.
const ret: Partial<IEncryptedEventInfo> = {};
ret.senderKey = event.getSenderKey() ?? undefined;
ret.algorithm = event.getWireContent().algorithm;
if (!ret.senderKey || !ret.algorithm) {
ret.encrypted = false;
return ret as IEncryptedEventInfo;
}
ret.encrypted = true;
ret.authenticated = true;
ret.mismatchedSender = true;
return ret as IEncryptedEventInfo;
}
public async userHasCrossSigningKeys(): Promise<boolean> {
@@ -88,6 +124,16 @@ export class RustCrypto implements CryptoBackend {
return [];
}
public checkUserTrust(userId: string): UserTrustLevel {
// TODO
return new UserTrustLevel(false, false, false);
}
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
// TODO
return new DeviceTrustLevel(false, false, false, false);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// SyncCryptoCallbacks implementation
+111 -84
View File
@@ -70,7 +70,7 @@ export interface MSC3575List extends MSC3575RoomSubscription {
*/
export interface MSC3575SlidingSyncRequest {
// json body params
lists?: MSC3575List[];
lists?: Record<string, MSC3575List>;
unsubscribe_rooms?: string[];
room_subscriptions?: Record<string, MSC3575RoomSubscription>;
extensions?: object;
@@ -137,7 +137,7 @@ type Operation = DeleteOperation | InsertOperation | InvalidateOperation | SyncO
export interface MSC3575SlidingSyncResponse {
pos: string;
txn_id?: string;
lists: ListResponse[];
lists: Record<string, ListResponse>;
rooms: Record<string, MSC3575RoomData>;
extensions: Record<string, object>;
}
@@ -332,11 +332,7 @@ export type SlidingSyncEventHandlerMap = {
resp: MSC3575SlidingSyncResponse | null,
err?: Error,
) => void;
[SlidingSyncEvent.List]: (
listIndex: number,
joinedCount: number,
roomIndexToRoomId: Record<number, string>,
) => void;
[SlidingSyncEvent.List]: (listKey: string, joinedCount: number, roomIndexToRoomId: Record<number, string>) => void;
};
/**
@@ -346,7 +342,7 @@ export type SlidingSyncEventHandlerMap = {
* To hook this up with the JS SDK, you need to use SlidingSyncSdk.
*/
export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSyncEventHandlerMap> {
private lists: SlidingList[];
private lists: Map<string, SlidingList>;
private listModifiedCount = 0;
private terminated = false;
// flag set when resend() is called because we cannot rely on detecting AbortError in JS SDK :(
@@ -380,13 +376,16 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
*/
public constructor(
private readonly proxyBaseUrl: string,
lists: MSC3575List[],
lists: Map<string, MSC3575List>,
private roomSubscriptionInfo: MSC3575RoomSubscription,
private readonly client: MatrixClient,
private readonly timeoutMS: number,
) {
super();
this.lists = lists.map((l) => new SlidingList(l));
this.lists = new Map<string, SlidingList>();
lists.forEach((list, key) => {
this.lists.set(key, new SlidingList(list));
});
}
/**
@@ -423,70 +422,70 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
}
/**
* Get the length of the sliding lists.
* @returns The number of lists in the sync request
*/
public listLength(): number {
return this.lists.length;
}
/**
* Get the room data for a list.
* @param index - The list index
* Get the room index data for a list.
* @param key - The list key
* @returns The list data which contains the rooms in this list
*/
public getListData(index: number): { joinedCount: number; roomIndexToRoomId: Record<number, string> } | null {
if (!this.lists[index]) {
public getListData(key: string): { joinedCount: number; roomIndexToRoomId: Record<number, string> } | null {
const data = this.lists.get(key);
if (!data) {
return null;
}
return {
joinedCount: this.lists[index].joinedCount,
roomIndexToRoomId: Object.assign({}, this.lists[index].roomIndexToRoomId),
joinedCount: data.joinedCount,
roomIndexToRoomId: Object.assign({}, data.roomIndexToRoomId),
};
}
/**
* Get the full list parameters for a list index. This function is provided for callers to use
* Get the full request list parameters for a list index. This function is provided for callers to use
* in conjunction with setList to update fields on an existing list.
* @param index - The list index to get the list for.
* @returns A copy of the list or undefined.
* @param key - The list key to get the params for.
* @returns A copy of the list params or undefined.
*/
public getList(index: number): MSC3575List | null {
if (!this.lists[index]) {
public getListParams(key: string): MSC3575List | null {
const params = this.lists.get(key);
if (!params) {
return null;
}
return this.lists[index].getList(true);
return params.getList(true);
}
/**
* Set new ranges for an existing list. Calling this function when _only_ the ranges have changed
* is more efficient than calling setList(index,list) as this function won't resend sticky params,
* whereas setList always will.
* @param index - The list index to modify
* @param key - The list key to modify
* @param ranges - The new ranges to apply.
* @returns A promise which resolves to the transaction ID when it has been received down sync
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
* immediately after sending, in which case the action will be applied in the subsequent request)
*/
public setListRanges(index: number, ranges: number[][]): Promise<string> {
this.lists[index].updateListRange(ranges);
public setListRanges(key: string, ranges: number[][]): Promise<string> {
const list = this.lists.get(key);
if (!list) {
return Promise.reject(new Error("no list with key " + key));
}
list.updateListRange(ranges);
return this.resend();
}
/**
* Add or replace a list. Calling this function will interrupt the /sync request to resend new
* lists.
* @param index - The index to modify
* @param key - The key to modify
* @param list - The new list parameters.
* @returns A promise which resolves to the transaction ID when it has been received down sync
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
* immediately after sending, in which case the action will be applied in the subsequent request)
*/
public setList(index: number, list: MSC3575List): Promise<string> {
if (this.lists[index]) {
this.lists[index].replaceList(list);
public setList(key: string, list: MSC3575List): Promise<string> {
const existingList = this.lists.get(key);
if (existingList) {
existingList.replaceList(list);
this.lists.set(key, existingList);
} else {
this.lists[index] = new SlidingList(list);
this.lists.set(key, new SlidingList(list));
}
this.listModifiedCount += 1;
return this.resend();
@@ -592,32 +591,44 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
this.emit(SlidingSyncEvent.Lifecycle, state, resp, err);
}
private shiftRight(listIndex: number, hi: number, low: number): void {
private shiftRight(listKey: string, hi: number, low: number): void {
const list = this.lists.get(listKey);
if (!list) {
return;
}
// l h
// 0,1,2,3,4 <- before
// 0,1,2,2,3 <- after, hi is deleted and low is duplicated
for (let i = hi; i > low; i--) {
if (this.lists[listIndex].isIndexInRange(i)) {
this.lists[listIndex].roomIndexToRoomId[i] = this.lists[listIndex].roomIndexToRoomId[i - 1];
if (list.isIndexInRange(i)) {
list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i - 1];
}
}
}
private shiftLeft(listIndex: number, hi: number, low: number): void {
private shiftLeft(listKey: string, hi: number, low: number): void {
const list = this.lists.get(listKey);
if (!list) {
return;
}
// l h
// 0,1,2,3,4 <- before
// 0,1,3,4,4 <- after, low is deleted and hi is duplicated
for (let i = low; i < hi; i++) {
if (this.lists[listIndex].isIndexInRange(i)) {
this.lists[listIndex].roomIndexToRoomId[i] = this.lists[listIndex].roomIndexToRoomId[i + 1];
if (list.isIndexInRange(i)) {
list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i + 1];
}
}
}
private removeEntry(listIndex: number, index: number): void {
private removeEntry(listKey: string, index: number): void {
const list = this.lists.get(listKey);
if (!list) {
return;
}
// work out the max index
let max = -1;
for (const n in this.lists[listIndex].roomIndexToRoomId) {
for (const n in list.roomIndexToRoomId) {
if (Number(n) > max) {
max = Number(n);
}
@@ -626,14 +637,18 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
return;
}
// Everything higher than the gap needs to be shifted left.
this.shiftLeft(listIndex, max, index);
delete this.lists[listIndex].roomIndexToRoomId[max];
this.shiftLeft(listKey, max, index);
delete list.roomIndexToRoomId[max];
}
private addEntry(listIndex: number, index: number): void {
private addEntry(listKey: string, index: number): void {
const list = this.lists.get(listKey);
if (!list) {
return;
}
// work out the max index
let max = -1;
for (const n in this.lists[listIndex].roomIndexToRoomId) {
for (const n in list.roomIndexToRoomId) {
if (Number(n) > max) {
max = Number(n);
}
@@ -642,30 +657,37 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
return;
}
// Everything higher than the gap needs to be shifted right, +1 so we don't delete the highest element
this.shiftRight(listIndex, max + 1, index);
this.shiftRight(listKey, max + 1, index);
}
private processListOps(list: ListResponse, listIndex: number): void {
private processListOps(list: ListResponse, listKey: string): void {
let gapIndex = -1;
const listData = this.lists.get(listKey);
if (!listData) {
return;
}
list.ops.forEach((op: Operation) => {
if (!listData) {
return;
}
switch (op.op) {
case "DELETE": {
logger.debug("DELETE", listIndex, op.index, ";");
delete this.lists[listIndex].roomIndexToRoomId[op.index];
logger.debug("DELETE", listKey, op.index, ";");
delete listData.roomIndexToRoomId[op.index];
if (gapIndex !== -1) {
// we already have a DELETE operation to process, so process it.
this.removeEntry(listIndex, gapIndex);
this.removeEntry(listKey, gapIndex);
}
gapIndex = op.index;
break;
}
case "INSERT": {
logger.debug("INSERT", listIndex, op.index, op.room_id, ";");
if (this.lists[listIndex].roomIndexToRoomId[op.index]) {
logger.debug("INSERT", listKey, op.index, op.room_id, ";");
if (listData.roomIndexToRoomId[op.index]) {
// something is in this space, shift items out of the way
if (gapIndex < 0) {
// we haven't been told where to shift from, so make way for a new room entry.
this.addEntry(listIndex, op.index);
this.addEntry(listKey, op.index);
} else if (gapIndex > op.index) {
// the gap is further down the list, shift every element to the right
// starting at the gap so we can just shift each element in turn:
@@ -674,11 +696,11 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
// [A,B,B,C] i=2
// [A,A,B,C] i=1
// Terminate. We'll assign into op.index next.
this.shiftRight(listIndex, gapIndex, op.index);
this.shiftRight(listKey, gapIndex, op.index);
} else if (gapIndex < op.index) {
// the gap is further up the list, shift every element to the left
// starting at the gap so we can just shift each element in turn
this.shiftLeft(listIndex, op.index, gapIndex);
this.shiftLeft(listKey, op.index, gapIndex);
}
}
// forget the gap, we don't need it anymore. This is outside the check for
@@ -686,15 +708,15 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
// forget the gap, not conditionally based on the presence of a room in the INSERT
// position. Without this, DELETE 0; INSERT 0; would do the wrong thing.
gapIndex = -1;
this.lists[listIndex].roomIndexToRoomId[op.index] = op.room_id;
listData.roomIndexToRoomId[op.index] = op.room_id;
break;
}
case "INVALIDATE": {
const startIndex = op.range[0];
for (let i = startIndex; i <= op.range[1]; i++) {
delete this.lists[listIndex].roomIndexToRoomId[i];
delete listData.roomIndexToRoomId[i];
}
logger.debug("INVALIDATE", listIndex, op.range[0], op.range[1], ";");
logger.debug("INVALIDATE", listKey, op.range[0], op.range[1], ";");
break;
}
case "SYNC": {
@@ -704,9 +726,9 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
if (!roomId) {
break; // we are at the end of list
}
this.lists[listIndex].roomIndexToRoomId[i] = roomId;
listData.roomIndexToRoomId[i] = roomId;
}
logger.debug("SYNC", listIndex, op.range[0], op.range[1], (op.room_ids || []).join(" "), ";");
logger.debug("SYNC", listKey, op.range[0], op.range[1], (op.room_ids || []).join(" "), ";");
break;
}
}
@@ -714,7 +736,7 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
if (gapIndex !== -1) {
// we already have a DELETE operation to process, so process it
// Everything higher than the gap needs to be shifted left.
this.removeEntry(listIndex, gapIndex);
this.removeEntry(listKey, gapIndex);
}
}
@@ -814,10 +836,12 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
let resp: MSC3575SlidingSyncResponse | undefined;
try {
const listModifiedCount = this.listModifiedCount;
const reqLists: Record<string, MSC3575List> = {};
this.lists.forEach((l: SlidingList, key: string) => {
reqLists[key] = l.getList(false);
});
const reqBody: MSC3575SlidingSyncRequest = {
lists: this.lists.map((l) => {
return l.getList(false);
}),
lists: reqLists,
pos: currentPos,
timeout: this.timeoutMS,
clientTimeout: this.timeoutMS + BUFFER_PERIOD_MS,
@@ -866,11 +890,15 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
l.setModified(false);
});
// set default empty values so we don't need to null check
resp.lists = resp.lists || [];
resp.lists = resp.lists || {};
resp.rooms = resp.rooms || {};
resp.extensions = resp.extensions || {};
resp.lists.forEach((val, i) => {
this.lists[i].joinedCount = val.count;
Object.keys(resp.lists).forEach((key: string) => {
const list = this.lists.get(key);
if (!list || !resp) {
return;
}
list.joinedCount = resp.lists[key].count;
});
this.invokeLifecycleListeners(SlidingSyncState.RequestFinished, resp);
} catch (err) {
@@ -899,25 +927,24 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
this.invokeRoomDataListeners(roomId, resp!.rooms[roomId]);
});
const listIndexesWithUpdates: Set<number> = new Set();
const listKeysWithUpdates: Set<string> = new Set();
if (!doNotUpdateList) {
resp.lists.forEach((list, listIndex) => {
for (const [key, list] of Object.entries(resp.lists)) {
list.ops = list.ops || [];
if (list.ops.length > 0) {
listIndexesWithUpdates.add(listIndex);
listKeysWithUpdates.add(key);
}
this.processListOps(list, listIndex);
});
this.processListOps(list, key);
}
}
this.invokeLifecycleListeners(SlidingSyncState.Complete, resp);
this.onPostExtensionsResponse(resp.extensions);
listIndexesWithUpdates.forEach((i) => {
this.emit(
SlidingSyncEvent.List,
i,
this.lists[i].joinedCount,
Object.assign({}, this.lists[i].roomIndexToRoomId),
);
listKeysWithUpdates.forEach((listKey: string) => {
const list = this.lists.get(listKey);
if (!list) {
return;
}
this.emit(SlidingSyncEvent.List, listKey, list.joinedCount, Object.assign({}, list.roomIndexToRoomId));
});
this.resolveTransactionDefers(resp.txn_id);
+16 -16
View File
@@ -236,7 +236,7 @@ export enum CallErrorCode {
/**
* We transferred the call off to somewhere else
*/
Transfered = "transferred",
Transferred = "transferred",
/**
* A call from the same user was found with a new session id
@@ -786,9 +786,9 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
const newSender = this.peerConn!.addTrack(track, callFeed.stream);
// now go & fish for the new transceiver
const newTransciever = this.peerConn!.getTransceivers().find((t) => t.sender === newSender);
if (newTransciever) {
this.transceivers.set(tKey, newTransciever);
const newTransceiver = this.peerConn!.getTransceivers().find((t) => t.sender === newSender);
if (newTransceiver) {
this.transceivers.set(tKey, newTransceiver);
} else {
logger.warn("Didn't find a matching transceiver after adding track!");
}
@@ -1344,9 +1344,9 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
);
const newSender = this.peerConn!.addTrack(track, this.localUsermediaStream!);
const newTransciever = this.peerConn!.getTransceivers().find((t) => t.sender === newSender);
if (newTransciever) {
this.transceivers.set(tKey, newTransciever);
const newTransceiver = this.peerConn!.getTransceivers().find((t) => t.sender === newSender);
if (newTransceiver) {
this.transceivers.set(tKey, newTransceiver);
} else {
logger.warn("Couldn't find matching transceiver for newly added track!");
}
@@ -1610,25 +1610,25 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
continue;
}
const extraconfig: string[] = [];
const extraConfig: string[] = [];
if (mod.enableDtx !== undefined) {
extraconfig.push(`usedtx=${mod.enableDtx ? "1" : "0"}`);
extraConfig.push(`usedtx=${mod.enableDtx ? "1" : "0"}`);
}
if (mod.maxAverageBitrate !== undefined) {
extraconfig.push(`maxaveragebitrate=${mod.maxAverageBitrate}`);
extraConfig.push(`maxaveragebitrate=${mod.maxAverageBitrate}`);
}
let found = false;
for (const fmtp of media.fmtp) {
if (payloadTypeToCodecMap.get(fmtp.payload) === mod.codec) {
found = true;
fmtp.config += ";" + extraconfig.join(";");
fmtp.config += ";" + extraConfig.join(";");
}
}
if (!found) {
media.fmtp.push({
payload: codecToPayloadTypeMap.get(mod.codec)!,
config: extraconfig.join(";"),
config: extraConfig.join(";"),
});
}
}
@@ -2417,7 +2417,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
await this.sendVoipEvent(EventType.CallReplaces, body);
await this.terminate(CallParty.Local, CallErrorCode.Transfered, true);
await this.terminate(CallParty.Local, CallErrorCode.Transferred, true);
}
/*
@@ -2458,8 +2458,8 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
await this.sendVoipEvent(EventType.CallReplaces, bodyToTransferee);
await this.terminate(CallParty.Local, CallErrorCode.Transfered, true);
await transferTargetCall.terminate(CallParty.Local, CallErrorCode.Transfered, true);
await this.terminate(CallParty.Local, CallErrorCode.Transferred, true);
await transferTargetCall.terminate(CallParty.Local, CallErrorCode.Transferred, true);
}
private async terminate(hangupParty: CallParty, hangupReason: CallErrorCode, shouldEmit: boolean): Promise<void> {
@@ -2504,7 +2504,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
for (const feed of this.feeds) {
// Slightly awkward as local feed need to go via the correct method on
// the mediahandler so they get removed from mediahandler (remote tracks
// the MediaHandler so they get removed from MediaHandler (remote tracks
// don't)
// NB. We clone local streams when passing them to individual calls in a group
// call, so we can (and should) stop the clones once we no longer need them:
+1 -1
View File
@@ -223,7 +223,7 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
/**
* Replaces the current MediaStream with a new one.
* The stream will be different and new stream as remore parties are
* The stream will be different and new stream as remote parties are
* concerned, but this can be used for convenience locally to set up
* volume listeners automatically on the new stream etc.
* @param newStream - new stream with which to replace the current one
+33 -20
View File
@@ -208,6 +208,7 @@ export class GroupCall extends TypedEventEmitter<
private resendMemberStateTimer: ReturnType<typeof setInterval> | null = null;
private initWithAudioMuted = false;
private initWithVideoMuted = false;
private initCallFeedPromise?: Promise<void>;
public constructor(
private client: MatrixClient,
@@ -347,36 +348,43 @@ export class GroupCall extends TypedEventEmitter<
);
}
public async initLocalCallFeed(): Promise<CallFeed> {
logger.log(`groupCall ${this.groupCallId} initLocalCallFeed`);
public async initLocalCallFeed(): Promise<void> {
if (this.state !== GroupCallState.LocalCallFeedUninitialized) {
throw new Error(`Cannot initialize local call feed in the "${this.state}" state.`);
}
this.state = GroupCallState.InitializingLocalCallFeed;
let stream: MediaStream;
// wraps the real method to serialise calls, because we don't want to try starting
// multiple call feeds at once
if (this.initCallFeedPromise) return this.initCallFeedPromise;
let disposed = false;
const onState = (state: GroupCallState): void => {
if (state === GroupCallState.LocalCallFeedUninitialized) {
disposed = true;
}
};
this.on(GroupCallEvent.GroupCallStateChanged, onState);
try {
this.initCallFeedPromise = this.initLocalCallFeedInternal();
await this.initCallFeedPromise;
} finally {
this.initCallFeedPromise = undefined;
}
}
private async initLocalCallFeedInternal(): Promise<void> {
logger.log(`groupCall ${this.groupCallId} initLocalCallFeed`);
let stream: MediaStream;
try {
stream = await this.client.getMediaHandler().getUserMediaStream(true, this.type === GroupCallType.Video);
} catch (error) {
this.state = GroupCallState.LocalCallFeedUninitialized;
throw error;
} finally {
this.off(GroupCallEvent.GroupCallStateChanged, onState);
}
// The call could've been disposed while we were waiting
if (disposed) throw new Error("Group call disposed");
// The call could've been disposed while we were waiting, and could
// also have been started back up again (hello, React 18) so if we're
// still in this 'initializing' state, carry on, otherwise bail.
if (this._state !== GroupCallState.InitializingLocalCallFeed) {
this.client.getMediaHandler().stopUserMediaStream(stream);
throw new Error("Group call disposed while gathering media stream");
}
const callFeed = new CallFeed({
client: this.client,
@@ -396,8 +404,6 @@ export class GroupCall extends TypedEventEmitter<
this.addUserMediaFeed(callFeed);
this.state = GroupCallState.LocalCallFeedInitialized;
return callFeed;
}
public async updateLocalUsermediaStream(stream: MediaStream): Promise<void> {
@@ -575,7 +581,7 @@ export class GroupCall extends TypedEventEmitter<
);
this.localCallFeed.setAudioVideoMuted(muted, null);
// I don't believe its actually necessary to enable these tracks: they
// are the one on the groupcall's own CallFeed and are cloned before being
// are the one on the GroupCall's own CallFeed and are cloned before being
// given to any of the actual calls, so these tracks don't actually go
// anywhere. Let's do it anyway to avoid confusion.
setTracksEnabled(this.localCallFeed.stream.getAudioTracks(), !muted);
@@ -1182,6 +1188,14 @@ export class GroupCall extends TypedEventEmitter<
* Recalculates and updates the participant map to match the room state.
*/
private updateParticipants(): void {
const localMember = this.room.getMember(this.client.getUserId()!)!;
if (!localMember) {
// The client hasn't fetched enough of the room state to get our own member
// event. This probably shouldn't happen, but sanity check & exit for now.
logger.warn("Tried to update participants before local room member is available");
return;
}
if (this.participantsExpirationTimer !== null) {
clearTimeout(this.participantsExpirationTimer);
this.participantsExpirationTimer = null;
@@ -1236,7 +1250,6 @@ export class GroupCall extends TypedEventEmitter<
// Apply local echo for the entered case
if (entered) {
const localMember = this.room.getMember(this.client.getUserId()!)!;
let deviceMap = participants.get(localMember);
if (deviceMap === undefined) {
deviceMap = new Map();
+16
View File
@@ -57,6 +57,9 @@ export class MediaHandler extends TypedEventEmitter<
public userMediaStreams: MediaStream[] = [];
public screensharingStreams: MediaStream[] = [];
// Promise chain to serialise calls to getMediaStream
private getMediaStreamPromise?: Promise<MediaStream>;
public constructor(private client: MatrixClient) {
super();
}
@@ -196,6 +199,19 @@ export class MediaHandler extends TypedEventEmitter<
* @returns based on passed parameters
*/
public async getUserMediaStream(audio: boolean, video: boolean, reusable = true): Promise<MediaStream> {
// Serialise calls, othertwise we can't sensibly re-use the stream
if (this.getMediaStreamPromise) {
this.getMediaStreamPromise = this.getMediaStreamPromise.then(() => {
return this.getUserMediaStreamInternal(audio, video, reusable);
});
} else {
this.getMediaStreamPromise = this.getUserMediaStreamInternal(audio, video, reusable);
}
return this.getMediaStreamPromise;
}
private async getUserMediaStreamInternal(audio: boolean, video: boolean, reusable: boolean): Promise<MediaStream> {
const shouldRequestAudio = audio && (await this.hasAudioDevice());
const shouldRequestVideo = video && (await this.hasVideoDevice());
+115 -99
View File
@@ -64,24 +64,24 @@
integrity sha512-sEnuDPpOJR/fcafHMjpcpGN5M2jbUGUHwmuWKM/YdPzeEDJg8bgmbcWQFUfE32MQjti1koACvoPVsDe8Uq+idg==
"@babel/core@^7.11.6", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.7.5":
version "7.20.7"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.20.7.tgz#37072f951bd4d28315445f66e0ec9f6ae0c8c35f"
integrity sha512-t1ZjCluspe5DW24bn2Rr1CDb2v9rn/hROtg9a2tmd0+QYf4bsloYfLQzjG4qHPNMhWtKdGC33R5AxGR2Af2cBw==
version "7.20.12"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.20.12.tgz#7930db57443c6714ad216953d1356dac0eb8496d"
integrity sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==
dependencies:
"@ampproject/remapping" "^2.1.0"
"@babel/code-frame" "^7.18.6"
"@babel/generator" "^7.20.7"
"@babel/helper-compilation-targets" "^7.20.7"
"@babel/helper-module-transforms" "^7.20.7"
"@babel/helper-module-transforms" "^7.20.11"
"@babel/helpers" "^7.20.7"
"@babel/parser" "^7.20.7"
"@babel/template" "^7.20.7"
"@babel/traverse" "^7.20.7"
"@babel/traverse" "^7.20.12"
"@babel/types" "^7.20.7"
convert-source-map "^1.7.0"
debug "^4.1.0"
gensync "^1.0.0-beta.2"
json5 "^2.2.1"
json5 "^2.2.2"
semver "^6.3.0"
"@babel/eslint-parser@^7.12.10":
@@ -209,7 +209,7 @@
dependencies:
"@babel/types" "^7.18.6"
"@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.20.11", "@babel/helper-module-transforms@^7.20.7":
"@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.20.11":
version "7.20.11"
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.20.11.tgz#df4c7af713c557938c50ea3ad0117a7944b2f1b0"
integrity sha512-uRy78kN4psmji1s2QtbtcCSaj/LILFDp0f/ymhpQH5QY3nljUZCaNWz9X1dEj/8MBdBEFECs7yRhKn8i7NjZgg==
@@ -1004,7 +1004,7 @@
"@babel/parser" "^7.20.7"
"@babel/types" "^7.20.7"
"@babel/traverse@^7.1.6", "@babel/traverse@^7.20.10", "@babel/traverse@^7.20.5", "@babel/traverse@^7.20.7", "@babel/traverse@^7.7.2":
"@babel/traverse@^7.1.6", "@babel/traverse@^7.20.5", "@babel/traverse@^7.7.2":
version "7.20.10"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.20.10.tgz#2bf98239597fcec12f842756f186a9dde6d09230"
integrity sha512-oSf1juCgymrSez8NI4A2sr4+uB/mFd9MXplYGPEBnfAuWmmyeVcHa6xLPiaRBcXkcb/28bgxmQLTVwFKE1yfsg==
@@ -1020,6 +1020,22 @@
debug "^4.1.0"
globals "^11.1.0"
"@babel/traverse@^7.20.10", "@babel/traverse@^7.20.12", "@babel/traverse@^7.20.7":
version "7.20.12"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.20.12.tgz#7f0f787b3a67ca4475adef1f56cb94f6abd4a4b5"
integrity sha512-MsIbFN0u+raeja38qboyF8TIT7K0BFzz/Yd/77ta4MsUsmP2RAnidIlwq7d5HFQrH/OZJecGV6B71C4zAgpoSQ==
dependencies:
"@babel/code-frame" "^7.18.6"
"@babel/generator" "^7.20.7"
"@babel/helper-environment-visitor" "^7.18.9"
"@babel/helper-function-name" "^7.19.0"
"@babel/helper-hoist-variables" "^7.18.6"
"@babel/helper-split-export-declaration" "^7.18.6"
"@babel/parser" "^7.20.7"
"@babel/types" "^7.20.7"
debug "^4.1.0"
globals "^11.1.0"
"@babel/types@^7.0.0", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.2.0", "@babel/types@^7.20.0", "@babel/types@^7.20.2", "@babel/types@^7.20.5", "@babel/types@^7.20.7", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
version "7.20.7"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.20.7.tgz#54ec75e252318423fc07fb644dc6a58a64c09b7f"
@@ -1059,7 +1075,7 @@
dependencies:
eslint-visitor-keys "^3.3.0"
"@eslint/eslintrc@^1.3.3":
"@eslint/eslintrc@^1.4.1":
version "1.4.1"
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.4.1.tgz#af58772019a2d271b7e2d4c23ff4ddcba3ccfb3e"
integrity sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==
@@ -1074,7 +1090,7 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@humanwhocodes/config-array@^0.11.6":
"@humanwhocodes/config-array@^0.11.8":
version "0.11.8"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9"
integrity sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==
@@ -1762,13 +1778,13 @@
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@^5.45.0":
version "5.48.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.48.0.tgz#54f8368d080eb384a455f60c2ee044e948a8ce67"
integrity sha512-SVLafp0NXpoJY7ut6VFVUU9I+YeFsDzeQwtK0WZ+xbRN3mtxJ08je+6Oi2N89qDn087COdO0u3blKZNv9VetRQ==
version "5.48.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.48.1.tgz#deee67e399f2cb6b4608c935777110e509d8018c"
integrity sha512-9nY5K1Rp2ppmpb9s9S2aBiF3xo5uExCehMDmYmmFqqyxgenbHJ3qbarcLt4ITgaD6r/2ypdlcFRdcuVPnks+fQ==
dependencies:
"@typescript-eslint/scope-manager" "5.48.0"
"@typescript-eslint/type-utils" "5.48.0"
"@typescript-eslint/utils" "5.48.0"
"@typescript-eslint/scope-manager" "5.48.1"
"@typescript-eslint/type-utils" "5.48.1"
"@typescript-eslint/utils" "5.48.1"
debug "^4.3.4"
ignore "^5.2.0"
natural-compare-lite "^1.4.0"
@@ -1777,71 +1793,71 @@
tsutils "^3.21.0"
"@typescript-eslint/parser@^5.45.0":
version "5.48.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.48.0.tgz#02803355b23884a83e543755349809a50b7ed9ba"
integrity sha512-1mxNA8qfgxX8kBvRDIHEzrRGrKHQfQlbW6iHyfHYS0Q4X1af+S6mkLNtgCOsGVl8+/LUPrqdHMssAemkrQ01qg==
version "5.48.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.48.1.tgz#d0125792dab7e232035434ab8ef0658154db2f10"
integrity sha512-4yg+FJR/V1M9Xoq56SF9Iygqm+r5LMXvheo6DQ7/yUWynQ4YfCRnsKuRgqH4EQ5Ya76rVwlEpw4Xu+TgWQUcdA==
dependencies:
"@typescript-eslint/scope-manager" "5.48.0"
"@typescript-eslint/types" "5.48.0"
"@typescript-eslint/typescript-estree" "5.48.0"
"@typescript-eslint/scope-manager" "5.48.1"
"@typescript-eslint/types" "5.48.1"
"@typescript-eslint/typescript-estree" "5.48.1"
debug "^4.3.4"
"@typescript-eslint/scope-manager@5.48.0":
version "5.48.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.48.0.tgz#607731cb0957fbc52fd754fd79507d1b6659cecf"
integrity sha512-0AA4LviDtVtZqlyUQnZMVHydDATpD9SAX/RC5qh6cBd3xmyWvmXYF+WT1oOmxkeMnWDlUVTwdODeucUnjz3gow==
"@typescript-eslint/scope-manager@5.48.1":
version "5.48.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.48.1.tgz#39c71e4de639f5fe08b988005beaaf6d79f9d64d"
integrity sha512-S035ueRrbxRMKvSTv9vJKIWgr86BD8s3RqoRZmsSh/s8HhIs90g6UlK8ZabUSjUZQkhVxt7nmZ63VJ9dcZhtDQ==
dependencies:
"@typescript-eslint/types" "5.48.0"
"@typescript-eslint/visitor-keys" "5.48.0"
"@typescript-eslint/types" "5.48.1"
"@typescript-eslint/visitor-keys" "5.48.1"
"@typescript-eslint/type-utils@5.48.0":
version "5.48.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.48.0.tgz#40496dccfdc2daa14a565f8be80ad1ae3882d6d6"
integrity sha512-vbtPO5sJyFjtHkGlGK4Sthmta0Bbls4Onv0bEqOGm7hP9h8UpRsHJwsrCiWtCUndTRNQO/qe6Ijz9rnT/DB+7g==
"@typescript-eslint/type-utils@5.48.1":
version "5.48.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.48.1.tgz#5d94ac0c269a81a91ad77c03407cea2caf481412"
integrity sha512-Hyr8HU8Alcuva1ppmqSYtM/Gp0q4JOp1F+/JH5D1IZm/bUBrV0edoewQZiEc1r6I8L4JL21broddxK8HAcZiqQ==
dependencies:
"@typescript-eslint/typescript-estree" "5.48.0"
"@typescript-eslint/utils" "5.48.0"
"@typescript-eslint/typescript-estree" "5.48.1"
"@typescript-eslint/utils" "5.48.1"
debug "^4.3.4"
tsutils "^3.21.0"
"@typescript-eslint/types@5.48.0":
version "5.48.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.48.0.tgz#d725da8dfcff320aab2ac6f65c97b0df30058449"
integrity sha512-UTe67B0Ypius0fnEE518NB2N8gGutIlTojeTg4nt0GQvikReVkurqxd2LvYa9q9M5MQ6rtpNyWTBxdscw40Xhw==
"@typescript-eslint/types@5.48.1":
version "5.48.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.48.1.tgz#efd1913a9aaf67caf8a6e6779fd53e14e8587e14"
integrity sha512-xHyDLU6MSuEEdIlzrrAerCGS3T7AA/L8Hggd0RCYBi0w3JMvGYxlLlXHeg50JI9Tfg5MrtsfuNxbS/3zF1/ATg==
"@typescript-eslint/typescript-estree@5.48.0":
version "5.48.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.48.0.tgz#a7f04bccb001003405bb5452d43953a382c2fac2"
integrity sha512-7pjd94vvIjI1zTz6aq/5wwE/YrfIyEPLtGJmRfyNR9NYIW+rOvzzUv3Cmq2hRKpvt6e9vpvPUQ7puzX7VSmsEw==
"@typescript-eslint/typescript-estree@5.48.1":
version "5.48.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.48.1.tgz#9efa8ee2aa471c6ab62e649f6e64d8d121bc2056"
integrity sha512-Hut+Osk5FYr+sgFh8J/FHjqX6HFcDzTlWLrFqGoK5kVUN3VBHF/QzZmAsIXCQ8T/W9nQNBTqalxi1P3LSqWnRA==
dependencies:
"@typescript-eslint/types" "5.48.0"
"@typescript-eslint/visitor-keys" "5.48.0"
"@typescript-eslint/types" "5.48.1"
"@typescript-eslint/visitor-keys" "5.48.1"
debug "^4.3.4"
globby "^11.1.0"
is-glob "^4.0.3"
semver "^7.3.7"
tsutils "^3.21.0"
"@typescript-eslint/utils@5.48.0":
version "5.48.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.48.0.tgz#eee926af2733f7156ad8d15e51791e42ce300273"
integrity sha512-x2jrMcPaMfsHRRIkL+x96++xdzvrdBCnYRd5QiW5Wgo1OB4kDYPbC1XjWP/TNqlfK93K/lUL92erq5zPLgFScQ==
"@typescript-eslint/utils@5.48.1":
version "5.48.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.48.1.tgz#20f2f4e88e9e2a0961cbebcb47a1f0f7da7ba7f9"
integrity sha512-SmQuSrCGUOdmGMwivW14Z0Lj8dxG1mOFZ7soeJ0TQZEJcs3n5Ndgkg0A4bcMFzBELqLJ6GTHnEU+iIoaD6hFGA==
dependencies:
"@types/json-schema" "^7.0.9"
"@types/semver" "^7.3.12"
"@typescript-eslint/scope-manager" "5.48.0"
"@typescript-eslint/types" "5.48.0"
"@typescript-eslint/typescript-estree" "5.48.0"
"@typescript-eslint/scope-manager" "5.48.1"
"@typescript-eslint/types" "5.48.1"
"@typescript-eslint/typescript-estree" "5.48.1"
eslint-scope "^5.1.1"
eslint-utils "^3.0.0"
semver "^7.3.7"
"@typescript-eslint/visitor-keys@5.48.0":
version "5.48.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.48.0.tgz#4446d5e7f6cadde7140390c0e284c8702d944904"
integrity sha512-5motVPz5EgxQ0bHjut3chzBkJ3Z3sheYVcSwS5BpHZpLqSptSmELNtGixmgj65+rIfhvtQTz5i9OP2vtzdDH7Q==
"@typescript-eslint/visitor-keys@5.48.1":
version "5.48.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.48.1.tgz#79fd4fb9996023ef86849bf6f904f33eb6c8fccb"
integrity sha512-Ns0XBwmfuX7ZknznfXozgnydyR8F6ev/KEGePP4i74uL3ArsKbEhJ7raeKr1JSa997DBDwol/4a0Y+At82c9dA==
dependencies:
"@typescript-eslint/types" "5.48.0"
"@typescript-eslint/types" "5.48.1"
eslint-visitor-keys "^3.3.0"
JSONStream@^1.0.3:
@@ -2536,9 +2552,9 @@ camelcase@^6.2.0:
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
caniuse-lite@^1.0.30001400:
version "1.0.30001441"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001441.tgz#987437b266260b640a23cd18fbddb509d7f69f3e"
integrity sha512-OyxRR4Vof59I3yGWXws6i908EtGbMzVUi3ganaZQHmydk1iwDhRnvaPG2WaR0KcqrDFKrxVZHULT396LEPhXfg==
version "1.0.30001442"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001442.tgz#40337f1cf3be7c637b061e2f78582dc1daec0614"
integrity sha512-239m03Pqy0hwxYPYR5JwOIxRJfLTWtle9FV8zosfV5pHg+/51uD4nxcUlM8+mWWGfwKtt8lJNHnD3cWw9VZ6ow==
center-align@^0.1.1:
version "0.1.3"
@@ -3053,9 +3069,9 @@ dir-glob@^3.0.1:
path-type "^4.0.0"
docdash@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/docdash/-/docdash-2.0.0.tgz#1db24c934f9d4feb5576c638e8c061deb5ae1832"
integrity sha512-AxxZwrMLmiArEHJirdyZmW6YTBKxkd/Z5V9U2EU1crIMtpgoU/cH7Hnc9n1E0lzB1ZSam+VVMSnvlc+9+GUGVg==
version "2.0.1"
resolved "https://registry.yarnpkg.com/docdash/-/docdash-2.0.1.tgz#ac36dd1b64a2ae298e642c9a8d8d3c0f7f0a2c55"
integrity sha512-mkBhkeMyMwGV4YIdA7S4dIC25ENrfU/ZBfyTs/MXj/HUewW/dtx44xoho4PttCOMsqxlcghzfj8HRlam5QiSoQ==
dependencies:
"@jsdoc/salty" "^0.2.1"
@@ -3410,13 +3426,13 @@ eslint-visitor-keys@^3.3.0:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826"
integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==
eslint@8.29.0:
version "8.29.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.29.0.tgz#d74a88a20fb44d59c51851625bc4ee8d0ec43f87"
integrity sha512-isQ4EEiyUjZFbEKvEGJKKGBwXtvXX+zJbkVKCgTuB9t/+jUBcy8avhkEwWJecI15BkRkOYmvIM5ynbhRjEkoeg==
eslint@8.31.0:
version "8.31.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.31.0.tgz#75028e77cbcff102a9feae1d718135931532d524"
integrity sha512-0tQQEVdmPZ1UtUKXjX7EMm9BlgJ08G90IhWh0PKDCb3ZLsgAOHI8fYSIzYVZej92zsgq+ft0FGsxhJ3xo2tbuA==
dependencies:
"@eslint/eslintrc" "^1.3.3"
"@humanwhocodes/config-array" "^0.11.6"
"@eslint/eslintrc" "^1.4.1"
"@humanwhocodes/config-array" "^0.11.8"
"@humanwhocodes/module-importer" "^1.0.1"
"@nodelib/fs.walk" "^1.2.8"
ajv "^6.10.0"
@@ -3435,7 +3451,7 @@ eslint@8.29.0:
file-entry-cache "^6.0.1"
find-up "^5.0.0"
glob-parent "^6.0.2"
globals "^13.15.0"
globals "^13.19.0"
grapheme-splitter "^1.0.4"
ignore "^5.2.0"
import-fresh "^3.0.0"
@@ -3835,7 +3851,7 @@ 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@^13.15.0, globals@^13.19.0:
globals@^13.19.0:
version "13.19.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-13.19.0.tgz#7a42de8e6ad4f7242fbcca27ea5b23aca367b5c8"
integrity sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==
@@ -4565,9 +4581,9 @@ jest-leak-detector@^29.3.1:
pretty-format "^29.3.1"
jest-localstorage-mock@^2.4.6:
version "2.4.25"
resolved "https://registry.yarnpkg.com/jest-localstorage-mock/-/jest-localstorage-mock-2.4.25.tgz#9be525ebcd4eb791a445dbeba8474ceb2abeb434"
integrity sha512-VdQ8PTpNzUJDx/KY3hBrTwxqVMzMS+LccngC15EZSFdxJ+VeeCYmyW7BSzubk9FUKCVeXPjYPibzXe6swXYA+g==
version "2.4.26"
resolved "https://registry.yarnpkg.com/jest-localstorage-mock/-/jest-localstorage-mock-2.4.26.tgz#7d57fb3555f2ed5b7ed16fd8423fd81f95e9e8db"
integrity sha512-owAJrYnjulVlMIXOYQIPRCCn3MmqI3GzgfZCXdD3/pmwrIvFMXcKVWZ+aMc44IzaASapg0Z4SEFxR+v5qxDA2w==
jest-matcher-utils@^28.1.3:
version "28.1.3"
@@ -4925,12 +4941,12 @@ json5@^1.0.1:
dependencies:
minimist "^1.2.0"
json5@^2.2.1:
json5@^2.2.2:
version "2.2.3"
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
jsonc-parser@^3.0.0:
jsonc-parser@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.0.tgz#31ff3f4c2b9793f89c67212627c51c6394f88e76"
integrity sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==
@@ -5129,7 +5145,7 @@ makeerror@1.0.12:
dependencies:
tmpl "1.0.5"
marked@^4.2.4:
marked@^4.2.5:
version "4.2.5"
resolved "https://registry.yarnpkg.com/marked/-/marked-4.2.5.tgz#979813dfc1252cc123a79b71b095759a32f42a5d"
integrity sha512-jPueVhumq7idETHkb203WDD4fMA3yV9emQ5vLwop58lu8bTclMghBWcYAavlDqIEMaisADinV1TooIFCfqOsYQ==
@@ -5242,7 +5258,7 @@ minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatc
dependencies:
brace-expansion "^1.1.7"
minimatch@^5.1.1:
minimatch@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.2.tgz#0939d7d6f0898acbd1508abe534d1929368a8fff"
integrity sha512-bNH9mmM9qsJ2X4r2Nat1B//1dJVcn3+iBLa3IgqJ7EbGaDNepL9QSHOxN4ng33s52VMMhhIfgCYDk3C4ZmlDAg==
@@ -5667,10 +5683,10 @@ prelude-ls@~1.1.2:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==
prettier@2.8.1:
version "2.8.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.1.tgz#4e1fd11c34e2421bc1da9aea9bd8127cd0a35efc"
integrity sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==
prettier@2.8.2:
version "2.8.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.2.tgz#c4ea1b5b454d7c4b59966db2e06ed7eec5dfd160"
integrity sha512-BtRV9BcncDyI2tsuS19zzhzoxD8Dh8LiCx7j7tHzrkz8GFXAexeWFdi22mjE1d16dftH2qNaytVxqiRTGlMfpw==
pretty-format@^28.1.3:
version "28.1.3"
@@ -6287,14 +6303,14 @@ shell-quote@^1.6.1:
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.4.tgz#33fe15dee71ab2a81fcbd3a52106c5cfb9fb75d8"
integrity sha512-8o/QEhSSRb1a5i7TFR0iM4G16Z0vYB2OQVs4G3aAFXjn3T6yEx8AZxy1PgDF7I00LZHYA3WxaSYIf5e5sAX8Rw==
shiki@^0.11.1:
version "0.11.1"
resolved "https://registry.yarnpkg.com/shiki/-/shiki-0.11.1.tgz#df0f719e7ab592c484d8b73ec10e215a503ab8cc"
integrity sha512-EugY9VASFuDqOexOgXR18ZV+TbFrQHeCpEYaXamO+SZlsnT/2LxuLBX25GGtIrwaEVFXUAbUQ601SWE2rMwWHA==
shiki@^0.12.1:
version "0.12.1"
resolved "https://registry.yarnpkg.com/shiki/-/shiki-0.12.1.tgz#26fce51da12d055f479a091a5307470786f300cd"
integrity sha512-aieaV1m349rZINEBkjxh2QbBvFFQOlgqYTNtCal82hHj4dDZ76oMlQIX+C7ryerBTDiga3e5NfH6smjdJ02BbQ==
dependencies:
jsonc-parser "^3.0.0"
vscode-oniguruma "^1.6.1"
vscode-textmate "^6.0.0"
jsonc-parser "^3.2.0"
vscode-oniguruma "^1.7.0"
vscode-textmate "^8.0.0"
side-channel@^1.0.4:
version "1.0.4"
@@ -6831,14 +6847,14 @@ typedoc-plugin-missing-exports@^1.0.0:
integrity sha512-7s6znXnuAj1eD9KYPyzVzR1lBF5nwAY8IKccP5sdoO9crG4lpd16RoFpLsh2PccJM+I2NASpr0+/NMka6ThwVA==
typedoc@^0.23.20:
version "0.23.23"
resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.23.23.tgz#9cf95b03d2d40031d8978b55e88b0b968d69f512"
integrity sha512-cg1YQWj+/BU6wq74iott513U16fbrPCbyYs04PHZgvoKJIc6EY4xNobyDZh4KMfRGW8Yjv6wwIzQyoqopKOUGw==
version "0.23.24"
resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.23.24.tgz#01cf32c09f2c19362e72a9ce1552d6e5b48c4fef"
integrity sha512-bfmy8lNQh+WrPYcJbtjQ6JEEsVl/ce1ZIXyXhyW+a1vFrjO39t6J8sL/d6FfAGrJTc7McCXgk9AanYBSNvLdIA==
dependencies:
lunr "^2.3.9"
marked "^4.2.4"
minimatch "^5.1.1"
shiki "^0.11.1"
marked "^4.2.5"
minimatch "^5.1.2"
shiki "^0.12.1"
typescript@^3.2.2:
version "3.9.10"
@@ -7039,15 +7055,15 @@ void-elements@^2.0.1:
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
integrity sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==
vscode-oniguruma@^1.6.1:
vscode-oniguruma@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz#439bfad8fe71abd7798338d1cd3dc53a8beea94b"
integrity sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==
vscode-textmate@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/vscode-textmate/-/vscode-textmate-6.0.0.tgz#a3777197235036814ac9a92451492f2748589210"
integrity sha512-gu73tuZfJgu+mvCSy4UZwd2JXykjK9zAZsfmDeut5dx/1a7FeTk0XwJsSuqQn+cuMCGVbIBfl+s53X4T19DnzQ==
vscode-textmate@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/vscode-textmate/-/vscode-textmate-8.0.0.tgz#2c7a3b1163ef0441097e0b5d6389cd5504b59e5d"
integrity sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==
vue-docgen-api@^3.26.0:
version "3.26.0"