Compare commits
88 Commits
v23.1.0
...
v23.3.0-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
| f61db81961 | |||
| e2a694115f | |||
| 71cf812d24 | |||
| 8a3d7d5671 | |||
| 2a363598dd | |||
| 05bf6428bc | |||
| e492a44dde | |||
| 44d2e47f96 | |||
| 8f5db463e7 | |||
| 4e8affafcc | |||
| 2800681bb1 | |||
| b2a9e6f12f | |||
| fbd2c97f87 | |||
| 6c6304a620 | |||
| 0c1d5f6b25 | |||
| 1c26dc0233 | |||
| a163a202e7 | |||
| e15cf9976f | |||
| bdc3926417 | |||
| 4f918f684e | |||
| c142232f4d | |||
| c9bc20aa4d | |||
| 5c0cb3a536 | |||
| 415576d0a0 | |||
| 4f9fad66e4 | |||
| ebd9854980 | |||
| cb2fab64d8 | |||
| f446b49e49 | |||
| 22f5e41058 | |||
| fee5a006f1 | |||
| ef51ee28fd | |||
| cb61345780 | |||
| a18d4e226e | |||
| b09b33eb4c | |||
| 5fedc06d7c | |||
| d8c9f6db33 | |||
| 0af5fa0328 | |||
| b328b72cd5 | |||
| ce2a9d7036 | |||
| 66ae985af5 | |||
| 1828e2849c | |||
| b4f8b0fe4f | |||
| 70656e954f | |||
| 40a4c8d954 | |||
| 7ed787b86a | |||
| 1f58ee7f2c | |||
| 2e28e9117a | |||
| a58a36e062 | |||
| 6cf6a0c522 | |||
| 02aa3edda4 | |||
| e04ea02c62 | |||
| 64197bf4db | |||
| c309fe6942 | |||
| 8408f36c12 | |||
| dcd8f91e02 | |||
| cabe14d7e2 | |||
| c019f2bb19 | |||
| baeb4acddf | |||
| 4a6e9a0f8f | |||
| ea5ce8d1d8 | |||
| 41854918a5 | |||
| 495642d041 | |||
| c7210b9e9d | |||
| 83563c7a01 | |||
| 2fcc4811dd | |||
| c392bc455d | |||
| d6b8332567 | |||
| 85b34b46c5 | |||
| 1ee487a2ff | |||
| b76e7ca782 | |||
| ddce1bcd28 | |||
| a34d06c7c2 | |||
| 7b10fa367d | |||
| 7f5d7091de | |||
| 96f673ae92 | |||
| 79faee7a67 | |||
| 89d2984432 | |||
| bc78784688 | |||
| eb058edb1b | |||
| 4847d78b42 | |||
| 789aec732a | |||
| 3246114772 | |||
| aa1e118f18 | |||
| d23c3cb8b2 | |||
| 9e37980e2d | |||
| de176dbd66 | |||
| ac10b40f67 | |||
| d7bf0f85c0 |
@@ -1,6 +1,9 @@
|
||||
module.exports = {
|
||||
plugins: ["matrix-org", "import", "jsdoc"],
|
||||
extends: ["plugin:matrix-org/babel", "plugin:import/typescript"],
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.json"],
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,3 +1,46 @@
|
||||
Changes in [23.3.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.3.0-rc.1) (2023-02-07)
|
||||
============================================================================================================
|
||||
|
||||
## ✨ Features
|
||||
* Element-R: implement encryption of outgoing events ([\#3122](https://github.com/matrix-org/matrix-js-sdk/pull/3122)).
|
||||
* Poll model - page /relations results ([\#3073](https://github.com/matrix-org/matrix-js-sdk/pull/3073)). Contributed by @kerryarchibald.
|
||||
* Poll model - validate end events ([\#3072](https://github.com/matrix-org/matrix-js-sdk/pull/3072)). Contributed by @kerryarchibald.
|
||||
* Handle optional last_known_event_id property in m.predecessor ([\#3119](https://github.com/matrix-org/matrix-js-sdk/pull/3119)). Contributed by @andybalaam.
|
||||
* Add support for stable identifier for fixed MAC in SAS verification ([\#3101](https://github.com/matrix-org/matrix-js-sdk/pull/3101)).
|
||||
* Provide eventId as well as roomId from Room.findPredecessor ([\#3095](https://github.com/matrix-org/matrix-js-sdk/pull/3095)). Contributed by @andybalaam.
|
||||
* MSC3946 Dynamic room predecessors ([\#3042](https://github.com/matrix-org/matrix-js-sdk/pull/3042)). Contributed by @andybalaam.
|
||||
* Poll model ([\#3036](https://github.com/matrix-org/matrix-js-sdk/pull/3036)). Contributed by @kerryarchibald.
|
||||
* Remove video tracks on video mute without renegotiating ([\#3091](https://github.com/matrix-org/matrix-js-sdk/pull/3091)).
|
||||
* Introduces a backwards-compatible API change. `MegolmEncrypter#prepareToEncrypt`'s return type has changed from `void` to `() => void`. ([\#3035](https://github.com/matrix-org/matrix-js-sdk/pull/3035)). Contributed by @clarkf.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Element-R: fix a bug which prevented encryption working after a reload ([\#3126](https://github.com/matrix-org/matrix-js-sdk/pull/3126)).
|
||||
* Element-R: Fix invite processing ([\#3121](https://github.com/matrix-org/matrix-js-sdk/pull/3121)).
|
||||
* Don't throw with no `opponentDeviceInfo` ([\#3107](https://github.com/matrix-org/matrix-js-sdk/pull/3107)).
|
||||
* Remove flaky megolm test ([\#3098](https://github.com/matrix-org/matrix-js-sdk/pull/3098)). Contributed by @clarkf.
|
||||
* Fix "verifyLinks" functionality of getRoomUpgradeHistory ([\#3089](https://github.com/matrix-org/matrix-js-sdk/pull/3089)). Contributed by @andybalaam.
|
||||
|
||||
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.
|
||||
* 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.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)
|
||||
==================================================================================================
|
||||
|
||||
|
||||
+9
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "23.1.0",
|
||||
"version": "23.3.0-rc.1",
|
||||
"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",
|
||||
@@ -55,7 +55,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.2",
|
||||
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.3",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^5.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -88,7 +88,7 @@
|
||||
"@types/jest": "^29.0.0",
|
||||
"@types/node": "18",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@types/uuid": "7",
|
||||
"@types/uuid": "9",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"allchange": "^1.0.6",
|
||||
@@ -98,24 +98,25 @@
|
||||
"browserify": "^17.0.0",
|
||||
"docdash": "^2.0.0",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.29.0",
|
||||
"eslint": "8.32.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jsdoc": "^39.6.4",
|
||||
"eslint-plugin-matrix-org": "^0.9.0",
|
||||
"eslint-plugin-matrix-org": "^0.10.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.17",
|
||||
"eslint-plugin-unicorn": "^45.0.0",
|
||||
"exorcist": "^2.0.0",
|
||||
"fake-indexeddb": "^4.0.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"jest": "^29.0.0",
|
||||
"jest-environment-jsdom": "^29.0.0",
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-mock": "^29.0.0",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"prettier": "2.8.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"prettier": "2.8.3",
|
||||
"rimraf": "^4.0.0",
|
||||
"terser": "^5.5.1",
|
||||
"tsify": "^5.0.2",
|
||||
"typedoc": "^0.23.20",
|
||||
|
||||
+12
-3
@@ -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,36 @@ describe("megolm", () => {
|
||||
expect(event.getContent().body).toEqual("42");
|
||||
});
|
||||
|
||||
it("Alice sends a megolm message", async () => {
|
||||
oldBackendOnly("prepareToEncrypt", async () => {
|
||||
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await aliceTestClient.start();
|
||||
aliceTestClient.client.setGlobalErrorOnUnknownDevices(false);
|
||||
|
||||
// tell alice she is sharing a room with bob
|
||||
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse(["@bob:xyz"]));
|
||||
await aliceTestClient.flushSync();
|
||||
|
||||
// we expect alice first to query bob's keys...
|
||||
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz"));
|
||||
aliceTestClient.httpBackend.flush("/keys/query", 1);
|
||||
|
||||
// ... and then claim one of his OTKs
|
||||
aliceTestClient.httpBackend.when("POST", "/keys/claim").respond(200, getTestKeysClaimResponse("@bob:xyz"));
|
||||
aliceTestClient.httpBackend.flush("/keys/claim", 1);
|
||||
|
||||
// fire off the prepare request
|
||||
const room = aliceTestClient.client.getRoom(ROOM_ID);
|
||||
expect(room).toBeTruthy();
|
||||
const p = aliceTestClient.client.prepareToEncrypt(room!);
|
||||
|
||||
// we expect to get a room key message
|
||||
await expectSendRoomKey(aliceTestClient.httpBackend, "@bob:xyz", testOlmAccount);
|
||||
|
||||
// the prepare request should complete successfully.
|
||||
await p;
|
||||
});
|
||||
|
||||
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 +705,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 +749,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 +807,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 +865,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 +923,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 +1004,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 +1034,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 +1094,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 +1178,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 +1205,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 +1236,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 +1276,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 +1306,7 @@ describe("megolm", () => {
|
||||
);
|
||||
const encryptedForwardedKey = encryptOlmEvent({
|
||||
sender: "@becca:localhost",
|
||||
senderSigningKey: beccaTestClient.getSigningKey(),
|
||||
senderKey: beccaTestClient.getDeviceKey(),
|
||||
recipient: aliceTestClient,
|
||||
p2pSession: p2pSession,
|
||||
@@ -1349,7 +1388,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 +1452,7 @@ describe("megolm", () => {
|
||||
const encryptedForwardedKey = encryptOlmEvent({
|
||||
sender: "@becca:localhost",
|
||||
senderKey: beccaTestClient.getDeviceKey(),
|
||||
senderSigningKey: beccaTestClient.getSigningKey(),
|
||||
recipient: aliceTestClient,
|
||||
p2pSession: p2pSession,
|
||||
plaincontent: {
|
||||
@@ -1494,7 +1534,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 +1659,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 +1691,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 +1718,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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
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.
|
||||
@@ -590,7 +590,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("should handle thread replies with server support by fetching a contiguous thread timeline", async () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
await client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
@@ -647,7 +647,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("should return relevant timeline from non-thread timelineSet when asking for the thread root", async () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
@@ -680,7 +680,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("should return undefined when event is not in the thread that the given timelineSet is representing", () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
@@ -709,7 +709,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("should return undefined when event is within a thread but timelineSet is not", () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
@@ -1127,7 +1127,7 @@ describe("MatrixClient event timelines", function () {
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
|
||||
@@ -1263,7 +1263,7 @@ describe("MatrixClient event timelines", function () {
|
||||
describe("with server compatibility", function () {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
|
||||
@@ -1421,7 +1421,7 @@ describe("MatrixClient event timelines", function () {
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
|
||||
@@ -1473,7 +1473,7 @@ describe("MatrixClient event timelines", function () {
|
||||
describe("without server compatibility", function () {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
Thread.setServerSideListSupport(FeatureSupport.None);
|
||||
});
|
||||
@@ -1539,7 +1539,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("should add lazy loading filter", async () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
// @ts-ignore
|
||||
@@ -1567,7 +1567,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("should correctly pass pagination token", async () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
|
||||
@@ -1892,7 +1892,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("in stable mode", async () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
|
||||
@@ -1902,7 +1902,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("in backwards compatible unstable mode", async () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Experimental);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Experimental);
|
||||
@@ -1912,7 +1912,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("in backwards compatible mode", async () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
Thread.setServerSideListSupport(FeatureSupport.None);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.None);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
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.
|
||||
@@ -35,7 +35,7 @@ describe("MatrixClient", function () {
|
||||
let store: MemoryStore | undefined;
|
||||
|
||||
const defaultClientOpts: IStoredClientOpts = {
|
||||
experimentalThreadSupport: false,
|
||||
threadSupport: false,
|
||||
};
|
||||
const setupTests = (): [MatrixClient, HttpBackend, MemoryStore] => {
|
||||
const store = new MemoryStore();
|
||||
@@ -671,7 +671,7 @@ describe("MatrixClient", function () {
|
||||
// @ts-ignore setting private property
|
||||
client!.clientOpts = {
|
||||
...defaultClientOpts,
|
||||
experimentalThreadSupport: true,
|
||||
threadSupport: true,
|
||||
};
|
||||
|
||||
const eventPollResponseReference = buildEventPollResponseReference();
|
||||
@@ -702,7 +702,7 @@ describe("MatrixClient", function () {
|
||||
// @ts-ignore setting private property
|
||||
client!.clientOpts = {
|
||||
...defaultClientOpts,
|
||||
experimentalThreadSupport: true,
|
||||
threadSupport: true,
|
||||
};
|
||||
|
||||
const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
|
||||
@@ -726,7 +726,7 @@ describe("MatrixClient", function () {
|
||||
// @ts-ignore setting private property
|
||||
client!.clientOpts = {
|
||||
...defaultClientOpts,
|
||||
experimentalThreadSupport: true,
|
||||
threadSupport: true,
|
||||
};
|
||||
|
||||
const eventPollResponseReference = buildEventPollResponseReference();
|
||||
@@ -750,7 +750,7 @@ describe("MatrixClient", function () {
|
||||
// @ts-ignore setting private property
|
||||
client!.clientOpts = {
|
||||
...defaultClientOpts,
|
||||
experimentalThreadSupport: true,
|
||||
threadSupport: true,
|
||||
};
|
||||
|
||||
const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
|
||||
@@ -774,7 +774,7 @@ describe("MatrixClient", function () {
|
||||
// @ts-ignore setting private property
|
||||
client!.clientOpts = {
|
||||
...defaultClientOpts,
|
||||
experimentalThreadSupport: true,
|
||||
threadSupport: true,
|
||||
};
|
||||
// This is based on recording the events in a real room:
|
||||
|
||||
@@ -831,7 +831,7 @@ describe("MatrixClient", function () {
|
||||
// @ts-ignore setting private property
|
||||
client!.clientOpts = {
|
||||
...defaultClientOpts,
|
||||
experimentalThreadSupport: true,
|
||||
threadSupport: true,
|
||||
};
|
||||
|
||||
const threadRootEvent = buildEventPollStartThreadRoot();
|
||||
@@ -857,7 +857,7 @@ describe("MatrixClient", function () {
|
||||
// @ts-ignore setting private property
|
||||
client!.clientOpts = {
|
||||
...defaultClientOpts,
|
||||
experimentalThreadSupport: true,
|
||||
threadSupport: true,
|
||||
};
|
||||
|
||||
const threadRootEvent = buildEventPollStartThreadRoot();
|
||||
@@ -878,7 +878,7 @@ describe("MatrixClient", function () {
|
||||
// @ts-ignore setting private property
|
||||
client!.clientOpts = {
|
||||
...defaultClientOpts,
|
||||
experimentalThreadSupport: true,
|
||||
threadSupport: true,
|
||||
};
|
||||
|
||||
const threadRootEvent = buildEventPollStartThreadRoot();
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -60,6 +60,7 @@ export const getMockClientWithEventEmitter = (
|
||||
*/
|
||||
export const mockClientMethodsUser = (userId = "@alice:domain") => ({
|
||||
getUserId: jest.fn().mockReturnValue(userId),
|
||||
getSafeUserId: jest.fn().mockReturnValue(userId),
|
||||
getUser: jest.fn().mockReturnValue(new User(userId)),
|
||||
isGuest: jest.fn().mockReturnValue(false),
|
||||
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
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.
|
||||
@@ -476,7 +476,7 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
|
||||
public getRooms = jest.fn<Room[], []>().mockReturnValue([]);
|
||||
public getRoom = jest.fn();
|
||||
|
||||
public supportsExperimentalThreads(): boolean {
|
||||
public supportsThreads(): boolean {
|
||||
return true;
|
||||
}
|
||||
public async decryptEventIfNeeded(): Promise<void> {}
|
||||
|
||||
@@ -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()", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
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.
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
|
||||
import { mocked, MockedObject } from "jest-mock";
|
||||
|
||||
import type { DeviceInfoMap } from "../../../../src/crypto/DeviceList";
|
||||
import "../../../olm-loader";
|
||||
import type { OutboundGroupSession } from "@matrix-org/olm";
|
||||
import * as algorithms from "../../../../src/crypto/algorithms";
|
||||
@@ -33,6 +34,7 @@ import { ClientEvent, MatrixClient, RoomMember } from "../../../../src";
|
||||
import { DeviceInfo, IDevice } from "../../../../src/crypto/deviceinfo";
|
||||
import { DeviceTrustLevel } from "../../../../src/crypto/CrossSigning";
|
||||
import { MegolmEncryption as MegolmEncryptionClass } from "../../../../src/crypto/algorithms/megolm";
|
||||
import { sleep } from "../../../../src/utils";
|
||||
|
||||
const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get("m.megolm.v1.aes-sha2")!;
|
||||
const MegolmEncryption = algorithms.ENCRYPTION_CLASSES.get("m.megolm.v1.aes-sha2")!;
|
||||
@@ -58,6 +60,12 @@ describe("MegolmDecryption", function () {
|
||||
|
||||
beforeEach(async function () {
|
||||
mockCrypto = testUtils.mock(Crypto, "Crypto") as MockedObject<Crypto>;
|
||||
|
||||
// @ts-ignore assigning to readonly prop
|
||||
mockCrypto.backupManager = {
|
||||
backupGroupSession: () => {},
|
||||
};
|
||||
|
||||
mockBaseApis = {
|
||||
claimOneTimeKeys: jest.fn(),
|
||||
sendToDevice: jest.fn(),
|
||||
@@ -314,10 +322,6 @@ describe("MegolmDecryption", function () {
|
||||
let olmDevice: OlmDevice;
|
||||
|
||||
beforeEach(async () => {
|
||||
// @ts-ignore assigning to readonly prop
|
||||
mockCrypto.backupManager = {
|
||||
backupGroupSession: () => {},
|
||||
};
|
||||
const cryptoStore = new MemoryCryptoStore();
|
||||
|
||||
olmDevice = new OlmDevice(cryptoStore);
|
||||
@@ -387,8 +391,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;
|
||||
});
|
||||
|
||||
@@ -513,6 +519,78 @@ describe("MegolmDecryption", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareToEncrypt", () => {
|
||||
let megolm: MegolmEncryptionClass;
|
||||
let room: jest.Mocked<Room>;
|
||||
|
||||
const deviceMap: DeviceInfoMap = {
|
||||
"user-a": {
|
||||
"device-a": new DeviceInfo("device-a"),
|
||||
"device-b": new DeviceInfo("device-b"),
|
||||
"device-c": new DeviceInfo("device-c"),
|
||||
},
|
||||
"user-b": {
|
||||
"device-d": new DeviceInfo("device-d"),
|
||||
"device-e": new DeviceInfo("device-e"),
|
||||
"device-f": new DeviceInfo("device-f"),
|
||||
},
|
||||
"user-c": {
|
||||
"device-g": new DeviceInfo("device-g"),
|
||||
"device-h": new DeviceInfo("device-h"),
|
||||
"device-i": new DeviceInfo("device-i"),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
room = testUtils.mock(Room, "Room") as jest.Mocked<Room>;
|
||||
room.getEncryptionTargetMembers.mockImplementation(async () => [
|
||||
new RoomMember(room.roomId, "@user:example.org"),
|
||||
]);
|
||||
room.getBlacklistUnverifiedDevices.mockReturnValue(false);
|
||||
|
||||
mockCrypto.downloadKeys.mockImplementation(async () => deviceMap);
|
||||
|
||||
mockCrypto.checkDeviceTrust.mockImplementation(() => new DeviceTrustLevel(true, true, true, true));
|
||||
|
||||
const olmDevice = new OlmDevice(new MemoryCryptoStore());
|
||||
megolm = new MegolmEncryptionClass({
|
||||
userId: "@user:id",
|
||||
deviceId: "12345",
|
||||
crypto: mockCrypto,
|
||||
olmDevice,
|
||||
baseApis: mockBaseApis,
|
||||
roomId: room.roomId,
|
||||
config: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
rotation_period_ms: 9_999_999,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("checks each device", async () => {
|
||||
megolm.prepareToEncrypt(room);
|
||||
//@ts-ignore private member access, gross
|
||||
await megolm.encryptionPreparation?.promise;
|
||||
|
||||
for (const userId in deviceMap) {
|
||||
for (const deviceId in deviceMap[userId]) {
|
||||
expect(mockCrypto.checkDeviceTrust).toHaveBeenCalledWith(userId, deviceId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is cancellable", async () => {
|
||||
const stop = megolm.prepareToEncrypt(room);
|
||||
|
||||
const before = mockCrypto.checkDeviceTrust.mock.calls.length;
|
||||
stop();
|
||||
|
||||
// Ensure that no more devices were checked after cancellation.
|
||||
await sleep(10);
|
||||
expect(mockCrypto.checkDeviceTrust).toHaveBeenCalledTimes(before);
|
||||
});
|
||||
});
|
||||
|
||||
it("notifies devices that have been blocked", async function () {
|
||||
const aliceClient = new TestClient("@alice:example.com", "alicedevice").client;
|
||||
const bobClient1 = new TestClient("@bob:example.com", "bobdevice1").client;
|
||||
|
||||
@@ -215,7 +215,7 @@ describe("SAS verification", function () {
|
||||
]);
|
||||
|
||||
// make sure that it uses the preferred method
|
||||
expect(macMethod).toBe("org.matrix.msc3783.hkdf-hmac-sha256");
|
||||
expect(macMethod).toBe("hkdf-hmac-sha256.v2");
|
||||
expect(keyAgreement).toBe("curve25519-hkdf-sha256");
|
||||
|
||||
// make sure Alice and Bob verified each other
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
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.
|
||||
@@ -147,7 +147,7 @@ describe("EventTimelineSet", () => {
|
||||
let thread: Thread;
|
||||
|
||||
beforeEach(() => {
|
||||
(client.supportsExperimentalThreads as jest.Mock).mockReturnValue(true);
|
||||
(client.supportsThreads as jest.Mock).mockReturnValue(true);
|
||||
thread = new Thread("!thread_id:server", messageEvent, { room, client });
|
||||
});
|
||||
|
||||
@@ -206,7 +206,7 @@ describe("EventTimelineSet", () => {
|
||||
});
|
||||
|
||||
it("should allow edits to be added to thread timeline", async () => {
|
||||
jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true);
|
||||
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
|
||||
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
|
||||
Thread.hasServerSideSupport = FeatureSupport.Stable;
|
||||
|
||||
@@ -393,7 +393,7 @@ describe("EventTimelineSet", () => {
|
||||
let thread: Thread;
|
||||
|
||||
beforeEach(() => {
|
||||
(client.supportsExperimentalThreads as jest.Mock).mockReturnValue(true);
|
||||
(client.supportsThreads as jest.Mock).mockReturnValue(true);
|
||||
thread = new Thread("!thread_id:server", messageEvent, { room, client });
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
+454
-44
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
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.
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { logger } from "../../src/logger";
|
||||
import { ClientEvent, ITurnServerResponse, MatrixClient, Store } from "../../src/client";
|
||||
import { ClientEvent, IMatrixClientCreateOpts, ITurnServerResponse, MatrixClient, Store } from "../../src/client";
|
||||
import { Filter } from "../../src/filter";
|
||||
import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace";
|
||||
import {
|
||||
@@ -276,7 +276,7 @@ describe("MatrixClient", function () {
|
||||
);
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
function makeClient(opts?: Partial<IMatrixClientCreateOpts>) {
|
||||
client = new MatrixClient({
|
||||
baseUrl: "https://my.home.server",
|
||||
idBaseUrl: identityServerUrl,
|
||||
@@ -285,6 +285,7 @@ describe("MatrixClient", function () {
|
||||
store: store,
|
||||
scheduler: scheduler,
|
||||
userId: userId,
|
||||
...(opts || {}),
|
||||
});
|
||||
// FIXME: We shouldn't be yanking http like this.
|
||||
client.http = (["authedRequest", "getContentUri", "request", "uploadContent"] as const).reduce((r, k) => {
|
||||
@@ -1414,7 +1415,7 @@ describe("MatrixClient", function () {
|
||||
expect(getRoomId).toEqual(roomId);
|
||||
return mockRoom;
|
||||
};
|
||||
client.crypto = {
|
||||
client.crypto = client["cryptoBackend"] = {
|
||||
// mock crypto
|
||||
encryptEvent: () => new Promise(() => {}),
|
||||
stop: jest.fn(),
|
||||
@@ -1436,8 +1437,9 @@ describe("MatrixClient", function () {
|
||||
|
||||
it("should cancel an event which is encrypting", async () => {
|
||||
// @ts-ignore protected method access
|
||||
client.encryptAndSendEvent(null, event);
|
||||
client.encryptAndSendEvent(mockRoom, event);
|
||||
await testUtils.emitPromise(event, "Event.status");
|
||||
expect(event.status).toBe(EventStatus.ENCRYPTING);
|
||||
client.cancelPendingEvent(event);
|
||||
assertCancelled();
|
||||
});
|
||||
@@ -1456,9 +1458,20 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
|
||||
describe("threads", () => {
|
||||
it.each([
|
||||
{ startOpts: {}, hasThreadSupport: false },
|
||||
{ startOpts: { threadSupport: true }, hasThreadSupport: true },
|
||||
{ startOpts: { threadSupport: false }, hasThreadSupport: false },
|
||||
{ startOpts: { experimentalThreadSupport: true }, hasThreadSupport: true },
|
||||
{ startOpts: { experimentalThreadSupport: true, threadSupport: false }, hasThreadSupport: false },
|
||||
])("enabled thread support for the SDK instance ", async ({ startOpts, hasThreadSupport }) => {
|
||||
await client.startClient(startOpts);
|
||||
expect(client.supportsThreads()).toBe(hasThreadSupport);
|
||||
});
|
||||
|
||||
it("partitions root events to room timeline and thread timeline", () => {
|
||||
const supportsExperimentalThreads = client.supportsExperimentalThreads;
|
||||
client.supportsExperimentalThreads = () => true;
|
||||
const supportsThreads = client.supportsThreads;
|
||||
client.supportsThreads = () => true;
|
||||
const room = new Room("!room1:matrix.org", client, userId);
|
||||
|
||||
const rootEvent = new MatrixEvent({
|
||||
@@ -1487,7 +1500,7 @@ describe("MatrixClient", function () {
|
||||
expect(threadEvents).toHaveLength(1);
|
||||
|
||||
// Restore method
|
||||
client.supportsExperimentalThreads = supportsExperimentalThreads;
|
||||
client.supportsThreads = supportsThreads;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2198,14 +2211,14 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVisibleRooms", () => {
|
||||
describe("room lists and history", () => {
|
||||
function roomCreateEvent(newRoomId: string, predecessorRoomId: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
content: {
|
||||
"creator": "@daryl:alexandria.example.com",
|
||||
"m.federate": true,
|
||||
"predecessor": {
|
||||
event_id: "spec_is_not_clear_what_id_this_is",
|
||||
event_id: "id_of_last_event",
|
||||
room_id: predecessorRoomId,
|
||||
},
|
||||
"room_version": "9",
|
||||
@@ -2234,45 +2247,442 @@ 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);
|
||||
function predecessorEvent(newRoomId: string, predecessorRoomId: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
content: {
|
||||
predecessor_room_id: predecessorRoomId,
|
||||
},
|
||||
event_id: `predecessor_event_id_pred_${predecessorRoomId}`,
|
||||
origin_server_ts: 1432735824653,
|
||||
room_id: newRoomId,
|
||||
sender: "@daryl:alexandria.example.com",
|
||||
state_key: "",
|
||||
type: "org.matrix.msc3946.room_predecessor",
|
||||
});
|
||||
}
|
||||
|
||||
describe("getVisibleRooms", () => {
|
||||
function setUpReplacedRooms(): {
|
||||
room1: Room;
|
||||
room2: Room;
|
||||
replacedByCreate1: Room;
|
||||
replacedByCreate2: Room;
|
||||
replacedByDynamicPredecessor1: Room;
|
||||
replacedByDynamicPredecessor2: Room;
|
||||
} {
|
||||
const room1 = new Room("room1", client, "@carol:alexandria.example.com");
|
||||
const replacedByCreate1 = new Room("replacedByCreate1", client, "@carol:alexandria.example.com");
|
||||
const replacedByCreate2 = new Room("replacedByCreate2", client, "@carol:alexandria.example.com");
|
||||
const replacedByDynamicPredecessor1 = new Room("dyn1", client, "@carol:alexandria.example.com");
|
||||
const replacedByDynamicPredecessor2 = new Room("dyn2", client, "@carol:alexandria.example.com");
|
||||
const room2 = new Room("room2", client, "@daryl:alexandria.example.com");
|
||||
client.store = new StubStore();
|
||||
client.store.getRooms = () => [
|
||||
room1,
|
||||
replacedByCreate1,
|
||||
replacedByCreate2,
|
||||
replacedByDynamicPredecessor1,
|
||||
replacedByDynamicPredecessor2,
|
||||
room2,
|
||||
];
|
||||
room1.addLiveEvents(
|
||||
[
|
||||
roomCreateEvent(room1.roomId, replacedByCreate1.roomId),
|
||||
predecessorEvent(room1.roomId, replacedByDynamicPredecessor1.roomId),
|
||||
],
|
||||
{},
|
||||
);
|
||||
room2.addLiveEvents(
|
||||
[
|
||||
roomCreateEvent(room2.roomId, replacedByCreate2.roomId),
|
||||
predecessorEvent(room2.roomId, replacedByDynamicPredecessor2.roomId),
|
||||
],
|
||||
{},
|
||||
);
|
||||
replacedByCreate1.addLiveEvents([tombstoneEvent(room1.roomId, replacedByCreate1.roomId)], {});
|
||||
replacedByCreate2.addLiveEvents([tombstoneEvent(room2.roomId, replacedByCreate2.roomId)], {});
|
||||
replacedByDynamicPredecessor1.addLiveEvents(
|
||||
[tombstoneEvent(room1.roomId, replacedByDynamicPredecessor1.roomId)],
|
||||
{},
|
||||
);
|
||||
replacedByDynamicPredecessor2.addLiveEvents(
|
||||
[tombstoneEvent(room2.roomId, replacedByDynamicPredecessor2.roomId)],
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
room1,
|
||||
room2,
|
||||
replacedByCreate1,
|
||||
replacedByCreate2,
|
||||
replacedByDynamicPredecessor1,
|
||||
replacedByDynamicPredecessor2,
|
||||
};
|
||||
}
|
||||
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("Ignores m.predecessor if we don't ask to use it", () => {
|
||||
// Given 6 rooms, 2 of which have been replaced, and 2 of which WERE
|
||||
// replaced by create events, but are now NOT replaced, because an
|
||||
// m.predecessor event has changed the room's predecessor.
|
||||
const {
|
||||
room1,
|
||||
room2,
|
||||
replacedByCreate1,
|
||||
replacedByCreate2,
|
||||
replacedByDynamicPredecessor1,
|
||||
replacedByDynamicPredecessor2,
|
||||
} = setUpReplacedRooms();
|
||||
|
||||
// When we ask for the visible rooms
|
||||
const rooms = client.getVisibleRooms(); // Don't supply msc3946ProcessDynamicPredecessor
|
||||
|
||||
// Then we only get the ones that have not been replaced
|
||||
expect(rooms).not.toContain(replacedByCreate1);
|
||||
expect(rooms).not.toContain(replacedByCreate2);
|
||||
expect(rooms).toContain(replacedByDynamicPredecessor1);
|
||||
expect(rooms).toContain(replacedByDynamicPredecessor2);
|
||||
expect(rooms).toContain(room1);
|
||||
expect(rooms).toContain(room2);
|
||||
});
|
||||
|
||||
it("Considers rooms replaced with m.predecessor events to be replaced", () => {
|
||||
// Given 6 rooms, 2 of which have been replaced, and 2 of which WERE
|
||||
// replaced by create events, but are now NOT replaced, because an
|
||||
// m.predecessor event has changed the room's predecessor.
|
||||
const {
|
||||
room1,
|
||||
room2,
|
||||
replacedByCreate1,
|
||||
replacedByCreate2,
|
||||
replacedByDynamicPredecessor1,
|
||||
replacedByDynamicPredecessor2,
|
||||
} = setUpReplacedRooms();
|
||||
|
||||
// When we ask for the visible rooms
|
||||
const useMsc3946 = true;
|
||||
const rooms = client.getVisibleRooms(useMsc3946);
|
||||
|
||||
// Then we only get the ones that have not been replaced
|
||||
expect(rooms).not.toContain(replacedByDynamicPredecessor1);
|
||||
expect(rooms).not.toContain(replacedByDynamicPredecessor2);
|
||||
expect(rooms).toContain(replacedByCreate1);
|
||||
expect(rooms).toContain(replacedByCreate2);
|
||||
expect(rooms).toContain(room1);
|
||||
expect(rooms).toContain(room2);
|
||||
});
|
||||
|
||||
it("Ignores m.predecessor if we don't ask to use it", () => {
|
||||
// Given 6 rooms, 2 of which have been replaced, and 2 of which WERE
|
||||
// replaced by create events, but are now NOT replaced, because an
|
||||
// m.predecessor event has changed the room's predecessor.
|
||||
const {
|
||||
room1,
|
||||
room2,
|
||||
replacedByCreate1,
|
||||
replacedByCreate2,
|
||||
replacedByDynamicPredecessor1,
|
||||
replacedByDynamicPredecessor2,
|
||||
} = setUpReplacedRooms();
|
||||
|
||||
// When we ask for the visible rooms
|
||||
const rooms = client.getVisibleRooms(); // Don't supply msc3946ProcessDynamicPredecessor
|
||||
|
||||
// Then we only get the ones that have not been replaced
|
||||
expect(rooms).not.toContain(replacedByCreate1);
|
||||
expect(rooms).not.toContain(replacedByCreate2);
|
||||
expect(rooms).toContain(replacedByDynamicPredecessor1);
|
||||
expect(rooms).toContain(replacedByDynamicPredecessor2);
|
||||
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", () => {
|
||||
/**
|
||||
* Create a chain of room history with create events and tombstones.
|
||||
*
|
||||
* @param creates include create events (default=true)
|
||||
* @param tombstones include tomstone events (default=true)
|
||||
* @returns 4 rooms chained together with tombstones and create
|
||||
* events, in order from oldest to latest.
|
||||
*/
|
||||
function createRoomHistory(creates = true, tombstones = true): [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)], {});
|
||||
if (creates) {
|
||||
room2.addLiveEvents([roomCreateEvent(room2.roomId, room1.roomId)]);
|
||||
room3.addLiveEvents([roomCreateEvent(room3.roomId, room2.roomId)]);
|
||||
room4.addLiveEvents([roomCreateEvent(room4.roomId, room3.roomId)]);
|
||||
}
|
||||
|
||||
// When we ask for the visible rooms
|
||||
const rooms = client.getVisibleRooms();
|
||||
if (tombstones) {
|
||||
room1.addLiveEvents([tombstoneEvent(room2.roomId, room1.roomId)], {});
|
||||
room2.addLiveEvents([tombstoneEvent(room3.roomId, room2.roomId)], {});
|
||||
room3.addLiveEvents([tombstoneEvent(room4.roomId, room3.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);
|
||||
mocked(store.getRoom).mockImplementation((roomId: string) => {
|
||||
return { room1, room2, room3, room4 }[roomId] || null;
|
||||
});
|
||||
|
||||
return [room1, room2, room3, room4];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates 2 alternate chains of room history: one using create
|
||||
* events, and one using MSC2946 predecessor+tombstone events.
|
||||
*
|
||||
* Using create, history looks like:
|
||||
* room1->room2->room3->room4 (but note we do not create tombstones)
|
||||
*
|
||||
* Using predecessor+tombstone, history looks like:
|
||||
* dynRoom1->dynRoom2->room3->dynRoom4->dynRoom4
|
||||
*
|
||||
* @returns [room1, room2, room3, room4, dynRoom1, dynRoom2,
|
||||
* dynRoom4, dynRoom5].
|
||||
*/
|
||||
function createDynamicRoomHistory(): [Room, Room, Room, Room, Room, Room, Room, Room] {
|
||||
// Don't create tombstones for the old versions - we generally
|
||||
// expect only one tombstone in a room, and we are confused by
|
||||
// anything else.
|
||||
const creates = true;
|
||||
const tombstones = false;
|
||||
const [room1, room2, room3, room4] = createRoomHistory(creates, tombstones);
|
||||
const dynRoom1 = new Room("dynRoom1", client, "@rick:grimes.example.com");
|
||||
const dynRoom2 = new Room("dynRoom2", client, "@rick:grimes.example.com");
|
||||
const dynRoom4 = new Room("dynRoom4", client, "@rick:grimes.example.com");
|
||||
const dynRoom5 = new Room("dynRoom5", client, "@rick:grimes.example.com");
|
||||
|
||||
dynRoom1.addLiveEvents([tombstoneEvent(dynRoom2.roomId, dynRoom1.roomId)], {});
|
||||
dynRoom2.addLiveEvents([predecessorEvent(dynRoom2.roomId, dynRoom1.roomId)]);
|
||||
|
||||
dynRoom2.addLiveEvents([tombstoneEvent(room3.roomId, dynRoom2.roomId)], {});
|
||||
room3.addLiveEvents([predecessorEvent(room3.roomId, dynRoom2.roomId)]);
|
||||
|
||||
room3.addLiveEvents([tombstoneEvent(dynRoom4.roomId, room3.roomId)], {});
|
||||
dynRoom4.addLiveEvents([predecessorEvent(dynRoom4.roomId, room3.roomId)]);
|
||||
|
||||
dynRoom4.addLiveEvents([tombstoneEvent(dynRoom5.roomId, dynRoom4.roomId)], {});
|
||||
dynRoom5.addLiveEvents([predecessorEvent(dynRoom5.roomId, dynRoom4.roomId)]);
|
||||
|
||||
mocked(store.getRoom)
|
||||
.mockClear()
|
||||
.mockImplementation((roomId: string) => {
|
||||
return { room1, room2, room3, room4, dynRoom1, dynRoom2, dynRoom4, dynRoom5 }[roomId] || null;
|
||||
});
|
||||
|
||||
return [room1, room2, room3, room4, dynRoom1, dynRoom2, dynRoom4, dynRoom5];
|
||||
}
|
||||
|
||||
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 predecessors of this room (with verify links)", () => {
|
||||
const [room1, room2, room3, room4] = createRoomHistory();
|
||||
const verifyLinks = true;
|
||||
const history = client.getRoomUpgradeHistory(room4.roomId, verifyLinks);
|
||||
expect(history.map((room) => room.roomId)).toEqual([
|
||||
room1.roomId,
|
||||
room2.roomId,
|
||||
room3.roomId,
|
||||
room4.roomId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("With verify links, rejects predecessors that don't point forwards", () => {
|
||||
// Given successors point back with create events, but
|
||||
// predecessors do not point forwards with tombstones
|
||||
const [, , , room4] = createRoomHistory(true, false);
|
||||
|
||||
// When I ask for history with verifyLinks on
|
||||
const verifyLinks = true;
|
||||
const history = client.getRoomUpgradeHistory(room4.roomId, verifyLinks);
|
||||
|
||||
// Then the predecessors are not included in the history
|
||||
expect(history.map((room) => room.roomId)).toEqual([room4.roomId]);
|
||||
});
|
||||
|
||||
it("Without verify links, includes predecessors that don't point forwards", () => {
|
||||
// Given successors point back with create events, but
|
||||
// predecessors do not point forwards with tombstones
|
||||
const [room1, room2, room3, room4] = createRoomHistory(true, false);
|
||||
|
||||
// When I ask for history with verifyLinks off
|
||||
const verifyLinks = false;
|
||||
const history = client.getRoomUpgradeHistory(room4.roomId, verifyLinks);
|
||||
|
||||
// Then the predecessors are included in the history
|
||||
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 subsequent rooms (with verify links)", () => {
|
||||
const [room1, room2, room3, room4] = createRoomHistory();
|
||||
const verifyLinks = true;
|
||||
const history = client.getRoomUpgradeHistory(room1.roomId, verifyLinks);
|
||||
expect(history.map((room) => room.roomId)).toEqual([
|
||||
room1.roomId,
|
||||
room2.roomId,
|
||||
room3.roomId,
|
||||
room4.roomId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("With verify links, rejects successors that don't point backwards", () => {
|
||||
// Given predecessors point forwards with tombstones, but
|
||||
// successors do not point back with create events.
|
||||
const [room1, , ,] = createRoomHistory(false, true);
|
||||
|
||||
// When I ask for history with verifyLinks on
|
||||
const verifyLinks = true;
|
||||
const history = client.getRoomUpgradeHistory(room1.roomId, verifyLinks);
|
||||
|
||||
// Then the successors are not included in the history
|
||||
expect(history.map((room) => room.roomId)).toEqual([room1.roomId]);
|
||||
});
|
||||
|
||||
it("Without verify links, includes predecessors that don't point forwards", () => {
|
||||
// Given predecessors point forwards with tombstones, but
|
||||
// successors do not point back with create events.
|
||||
const [room1, room2, room3, room4] = createRoomHistory(false, true);
|
||||
|
||||
// When I ask for history with verifyLinks off
|
||||
const verifyLinks = false;
|
||||
const history = client.getRoomUpgradeHistory(room1.roomId, verifyLinks);
|
||||
|
||||
// Then the successors are included in the history
|
||||
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,
|
||||
]);
|
||||
});
|
||||
|
||||
it("Returns the predecessors and subsequent rooms (with verify links)", () => {
|
||||
const [room1, room2, room3, room4] = createRoomHistory();
|
||||
const verifyLinks = true;
|
||||
const history = client.getRoomUpgradeHistory(room3.roomId, verifyLinks);
|
||||
expect(history.map((room) => room.roomId)).toEqual([
|
||||
room1.roomId,
|
||||
room2.roomId,
|
||||
room3.roomId,
|
||||
room4.roomId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("Returns the predecessors and subsequent rooms using MSC3945 dynamic room predecessors", () => {
|
||||
const [, , room3, , dynRoom1, dynRoom2, dynRoom4, dynRoom5] = createDynamicRoomHistory();
|
||||
const useMsc3946 = true;
|
||||
const verifyLinks = false;
|
||||
const history = client.getRoomUpgradeHistory(room3.roomId, verifyLinks, useMsc3946);
|
||||
expect(history.map((room) => room.roomId)).toEqual([
|
||||
dynRoom1.roomId,
|
||||
dynRoom2.roomId,
|
||||
room3.roomId,
|
||||
dynRoom4.roomId,
|
||||
dynRoom5.roomId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("When not asking for MSC3946, verified history without tombstones is empty", () => {
|
||||
// There no tombstones to match the create events
|
||||
const [, , room3] = createDynamicRoomHistory();
|
||||
const useMsc3946 = false;
|
||||
const verifyLinks = true;
|
||||
const history = client.getRoomUpgradeHistory(room3.roomId, verifyLinks, useMsc3946);
|
||||
// So we get no history back
|
||||
expect(history.map((room) => room.roomId)).toEqual([room3.roomId]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
/*
|
||||
Copyright 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 { IEvent, MatrixEvent, PollEvent, Room } from "../../../src";
|
||||
import { REFERENCE_RELATION } from "../../../src/@types/extensible_events";
|
||||
import { M_POLL_END, M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE } from "../../../src/@types/polls";
|
||||
import { PollStartEvent } from "../../../src/extensible_events_v1/PollStartEvent";
|
||||
import { Poll } from "../../../src/models/poll";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe("Poll", () => {
|
||||
const userId = "@alice:server.org";
|
||||
const mockClient = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(userId),
|
||||
relations: jest.fn(),
|
||||
});
|
||||
const roomId = "!room:server";
|
||||
const room = new Room(roomId, mockClient, userId);
|
||||
const maySendRedactionForEventSpy = jest.spyOn(room.currentState, "maySendRedactionForEvent");
|
||||
// 14.03.2022 16:15
|
||||
const now = 1647270879403;
|
||||
|
||||
const basePollStartEvent = new MatrixEvent({
|
||||
...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
|
||||
room_id: roomId,
|
||||
sender: userId,
|
||||
});
|
||||
basePollStartEvent.event.event_id = "$12345";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.setSystemTime(now);
|
||||
|
||||
mockClient.relations.mockReset().mockResolvedValue({ events: [] });
|
||||
|
||||
maySendRedactionForEventSpy.mockClear().mockReturnValue(true);
|
||||
});
|
||||
|
||||
let eventId = 1;
|
||||
const makeRelatedEvent = (eventProps: Partial<IEvent>, timestamp = now): MatrixEvent => {
|
||||
const event = new MatrixEvent({
|
||||
...eventProps,
|
||||
content: {
|
||||
...(eventProps.content || {}),
|
||||
"m.relates_to": {
|
||||
rel_type: REFERENCE_RELATION.name,
|
||||
event_id: basePollStartEvent.getId(),
|
||||
},
|
||||
},
|
||||
});
|
||||
event.event.origin_server_ts = timestamp;
|
||||
event.event.event_id = `${eventId++}`;
|
||||
return event;
|
||||
};
|
||||
|
||||
it("initialises with root event", () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
expect(poll.roomId).toEqual(roomId);
|
||||
expect(poll.pollId).toEqual(basePollStartEvent.getId());
|
||||
expect(poll.pollEvent).toEqual(basePollStartEvent.unstableExtensibleEvent);
|
||||
expect(poll.isEnded).toBe(false);
|
||||
});
|
||||
|
||||
it("throws when poll start has no room id", () => {
|
||||
const pollStartEvent = new MatrixEvent(
|
||||
PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
|
||||
);
|
||||
expect(() => new Poll(pollStartEvent, mockClient, room)).toThrowError("Invalid poll start event.");
|
||||
});
|
||||
|
||||
it("throws when poll start has no event id", () => {
|
||||
const pollStartEvent = new MatrixEvent({
|
||||
...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
|
||||
room_id: roomId,
|
||||
});
|
||||
expect(() => new Poll(pollStartEvent, mockClient, room)).toThrowError("Invalid poll start event.");
|
||||
});
|
||||
|
||||
describe("fetching responses", () => {
|
||||
it("calls relations api and emits", async () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
const emitSpy = jest.spyOn(poll, "emit");
|
||||
const fetchResponsePromise = poll.getResponses();
|
||||
expect(poll.isFetchingResponses).toBe(true);
|
||||
const responses = await fetchResponsePromise;
|
||||
expect(poll.isFetchingResponses).toBe(false);
|
||||
expect(mockClient.relations).toHaveBeenCalledWith(
|
||||
roomId,
|
||||
basePollStartEvent.getId(),
|
||||
"m.reference",
|
||||
undefined,
|
||||
{ from: undefined },
|
||||
);
|
||||
expect(emitSpy).toHaveBeenCalledWith(PollEvent.Responses, responses);
|
||||
});
|
||||
|
||||
it("returns existing responses object after initial fetch", async () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
const responses = await poll.getResponses();
|
||||
const responses2 = await poll.getResponses();
|
||||
// only fetched relations once
|
||||
expect(mockClient.relations).toHaveBeenCalledTimes(1);
|
||||
// strictly equal
|
||||
expect(responses).toBe(responses2);
|
||||
});
|
||||
|
||||
it("waits for existing relations request to finish when getting responses", async () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
const firstResponsePromise = poll.getResponses();
|
||||
const secondResponsePromise = poll.getResponses();
|
||||
await firstResponsePromise;
|
||||
expect(firstResponsePromise).toEqual(secondResponsePromise);
|
||||
await secondResponsePromise;
|
||||
expect(mockClient.relations).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("filters relations for relevent response events", async () => {
|
||||
const replyEvent = new MatrixEvent({ type: "m.room.message" });
|
||||
const stableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.stable! });
|
||||
const unstableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.unstable });
|
||||
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [replyEvent, stableResponseEvent, unstableResponseEvent],
|
||||
});
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
const responses = await poll.getResponses();
|
||||
expect(responses.getRelations()).toEqual([stableResponseEvent, unstableResponseEvent]);
|
||||
});
|
||||
|
||||
describe("with multiple pages of relations", () => {
|
||||
const makeResponses = (count = 1, timestamp = now): MatrixEvent[] =>
|
||||
new Array(count)
|
||||
.fill("x")
|
||||
.map((_x, index) =>
|
||||
makeRelatedEvent(
|
||||
{ type: M_POLL_RESPONSE.stable!, sender: "@bob@server.org" },
|
||||
timestamp + index,
|
||||
),
|
||||
);
|
||||
|
||||
it("page relations responses", async () => {
|
||||
const responseEvents = makeResponses(6);
|
||||
mockClient.relations
|
||||
.mockResolvedValueOnce({
|
||||
events: responseEvents.slice(0, 2),
|
||||
nextBatch: "test-next-1",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
events: responseEvents.slice(2, 4),
|
||||
nextBatch: "test-next-2",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
events: responseEvents.slice(4),
|
||||
});
|
||||
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
jest.spyOn(poll, "emit");
|
||||
const responses = await poll.getResponses();
|
||||
|
||||
expect(mockClient.relations.mock.calls).toEqual([
|
||||
[roomId, basePollStartEvent.getId(), "m.reference", undefined, { from: undefined }],
|
||||
[roomId, basePollStartEvent.getId(), "m.reference", undefined, { from: "test-next-1" }],
|
||||
[roomId, basePollStartEvent.getId(), "m.reference", undefined, { from: "test-next-2" }],
|
||||
]);
|
||||
|
||||
expect(poll.emit).toHaveBeenCalledTimes(3);
|
||||
expect(poll.isFetchingResponses).toBeFalsy();
|
||||
expect(responses.getRelations().length).toEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with poll end event", () => {
|
||||
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@bob@server.org" });
|
||||
const unstablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.unstable!, sender: "@bob@server.org" });
|
||||
const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000);
|
||||
const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
|
||||
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [responseEventAfterEnd, responseEventAtEnd, responseEventBeforeEnd, stablePollEndEvent],
|
||||
});
|
||||
});
|
||||
|
||||
it("sets poll end event with stable event type", async () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
jest.spyOn(poll, "emit");
|
||||
await poll.getResponses();
|
||||
|
||||
expect(maySendRedactionForEventSpy).toHaveBeenCalledWith(basePollStartEvent, "@bob@server.org");
|
||||
expect(poll.isEnded).toBe(true);
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
|
||||
});
|
||||
|
||||
it("sets poll end event when endevent sender also created the poll, but does not have redaction rights", async () => {
|
||||
const pollStartEvent = new MatrixEvent({
|
||||
...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
|
||||
room_id: roomId,
|
||||
sender: "@bob:domain.org",
|
||||
});
|
||||
pollStartEvent.event.event_id = "$6789";
|
||||
const poll = new Poll(pollStartEvent, mockClient, room);
|
||||
const pollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@bob:domain.org" });
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [pollEndEvent],
|
||||
});
|
||||
maySendRedactionForEventSpy.mockReturnValue(false);
|
||||
jest.spyOn(poll, "emit");
|
||||
await poll.getResponses();
|
||||
|
||||
expect(maySendRedactionForEventSpy).not.toHaveBeenCalled();
|
||||
expect(poll.isEnded).toBe(true);
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
|
||||
});
|
||||
|
||||
it("sets poll end event with unstable event type", async () => {
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [unstablePollEndEvent],
|
||||
});
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
jest.spyOn(poll, "emit");
|
||||
await poll.getResponses();
|
||||
|
||||
expect(poll.isEnded).toBe(true);
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
|
||||
});
|
||||
|
||||
it("filters out responses that were sent after poll end", async () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
const responses = await poll.getResponses();
|
||||
|
||||
// just response type events
|
||||
// and response with ts after poll end event is excluded
|
||||
expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("onNewRelation()", () => {
|
||||
it("discards response if poll responses have not been initialised", () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
jest.spyOn(poll, "emit");
|
||||
const responseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
|
||||
|
||||
poll.onNewRelation(responseEvent);
|
||||
|
||||
// did not add response -> no emit
|
||||
expect(poll.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets poll end event when responses are not initialised", () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
jest.spyOn(poll, "emit");
|
||||
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: userId });
|
||||
|
||||
poll.onNewRelation(stablePollEndEvent);
|
||||
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
|
||||
});
|
||||
|
||||
it("does not set poll end event when sent by invalid user", async () => {
|
||||
maySendRedactionForEventSpy.mockReturnValue(false);
|
||||
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@charlie:server.org" });
|
||||
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [responseEventAfterEnd],
|
||||
});
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
await poll.getResponses();
|
||||
jest.spyOn(poll, "emit");
|
||||
|
||||
poll.onNewRelation(stablePollEndEvent);
|
||||
|
||||
// didn't end, didn't refilter responses
|
||||
expect(poll.emit).not.toHaveBeenCalled();
|
||||
expect(poll.isEnded).toBeFalsy();
|
||||
expect(maySendRedactionForEventSpy).toHaveBeenCalledWith(basePollStartEvent, "@charlie:server.org");
|
||||
});
|
||||
|
||||
it("does not set poll end event when an earlier end event already exists", async () => {
|
||||
const earlierPollEndEvent = makeRelatedEvent(
|
||||
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
|
||||
now,
|
||||
);
|
||||
const laterPollEndEvent = makeRelatedEvent(
|
||||
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
|
||||
now + 2000,
|
||||
);
|
||||
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
await poll.getResponses();
|
||||
|
||||
poll.onNewRelation(earlierPollEndEvent);
|
||||
|
||||
// first end event set correctly
|
||||
expect(poll.isEnded).toBeTruthy();
|
||||
|
||||
// reset spy count
|
||||
jest.spyOn(poll, "emit").mockClear();
|
||||
|
||||
poll.onNewRelation(laterPollEndEvent);
|
||||
// didn't set new end event, didn't refilter responses
|
||||
expect(poll.emit).not.toHaveBeenCalled();
|
||||
expect(poll.isEnded).toBeTruthy();
|
||||
});
|
||||
|
||||
it("replaces poll end event and refilters when an older end event already exists", async () => {
|
||||
const earlierPollEndEvent = makeRelatedEvent(
|
||||
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
|
||||
now,
|
||||
);
|
||||
const laterPollEndEvent = makeRelatedEvent(
|
||||
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
|
||||
now + 2000,
|
||||
);
|
||||
const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000);
|
||||
const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
|
||||
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [responseEventAfterEnd, responseEventAtEnd, responseEventBeforeEnd, laterPollEndEvent],
|
||||
});
|
||||
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
const responses = await poll.getResponses();
|
||||
|
||||
// all responses have a timestamp < laterPollEndEvent
|
||||
expect(responses.getRelations().length).toEqual(3);
|
||||
// first end event set correctly
|
||||
expect(poll.isEnded).toBeTruthy();
|
||||
|
||||
// reset spy count
|
||||
jest.spyOn(poll, "emit").mockClear();
|
||||
|
||||
// add a valid end event with earlier timestamp
|
||||
poll.onNewRelation(earlierPollEndEvent);
|
||||
|
||||
// emitted new end event
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
|
||||
// filtered responses and emitted
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.Responses, responses);
|
||||
expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]);
|
||||
});
|
||||
|
||||
it("does not set poll end event when sent by invalid user", async () => {
|
||||
maySendRedactionForEventSpy.mockReturnValue(false);
|
||||
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@charlie:server.org" });
|
||||
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [responseEventAfterEnd],
|
||||
});
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
await poll.getResponses();
|
||||
jest.spyOn(poll, "emit");
|
||||
|
||||
poll.onNewRelation(stablePollEndEvent);
|
||||
|
||||
// didn't end, didn't refilter responses
|
||||
expect(poll.emit).not.toHaveBeenCalled();
|
||||
expect(poll.isEnded).toBeFalsy();
|
||||
expect(maySendRedactionForEventSpy).toHaveBeenCalledWith(basePollStartEvent, "@charlie:server.org");
|
||||
});
|
||||
|
||||
it("does not set poll end event when an earlier end event already exists", async () => {
|
||||
const earlierPollEndEvent = makeRelatedEvent(
|
||||
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
|
||||
now,
|
||||
);
|
||||
const laterPollEndEvent = makeRelatedEvent(
|
||||
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
|
||||
now + 2000,
|
||||
);
|
||||
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
await poll.getResponses();
|
||||
|
||||
poll.onNewRelation(earlierPollEndEvent);
|
||||
|
||||
// first end event set correctly
|
||||
expect(poll.isEnded).toBeTruthy();
|
||||
|
||||
// reset spy count
|
||||
jest.spyOn(poll, "emit").mockClear();
|
||||
|
||||
poll.onNewRelation(laterPollEndEvent);
|
||||
// didn't set new end event, didn't refilter responses
|
||||
expect(poll.emit).not.toHaveBeenCalled();
|
||||
expect(poll.isEnded).toBeTruthy();
|
||||
});
|
||||
|
||||
it("replaces poll end event and refilters when an older end event already exists", async () => {
|
||||
const earlierPollEndEvent = makeRelatedEvent(
|
||||
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
|
||||
now,
|
||||
);
|
||||
const laterPollEndEvent = makeRelatedEvent(
|
||||
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
|
||||
now + 2000,
|
||||
);
|
||||
const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000);
|
||||
const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
|
||||
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [responseEventAfterEnd, responseEventAtEnd, responseEventBeforeEnd, laterPollEndEvent],
|
||||
});
|
||||
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
const responses = await poll.getResponses();
|
||||
|
||||
// all responses have a timestamp < laterPollEndEvent
|
||||
expect(responses.getRelations().length).toEqual(3);
|
||||
// first end event set correctly
|
||||
expect(poll.isEnded).toBeTruthy();
|
||||
|
||||
// reset spy count
|
||||
jest.spyOn(poll, "emit").mockClear();
|
||||
|
||||
// add a valid end event with earlier timestamp
|
||||
poll.onNewRelation(earlierPollEndEvent);
|
||||
|
||||
// emitted new end event
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
|
||||
// filtered responses and emitted
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.Responses, responses);
|
||||
expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]);
|
||||
});
|
||||
|
||||
it("sets poll end event and refilters responses based on timestamp", async () => {
|
||||
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: userId });
|
||||
const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000);
|
||||
const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
|
||||
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [responseEventAfterEnd, responseEventAtEnd, responseEventBeforeEnd],
|
||||
});
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
const responses = await poll.getResponses();
|
||||
jest.spyOn(poll, "emit");
|
||||
|
||||
expect(responses.getRelations().length).toEqual(3);
|
||||
poll.onNewRelation(stablePollEndEvent);
|
||||
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.Responses, responses);
|
||||
expect(responses.getRelations().length).toEqual(2);
|
||||
// after end timestamp event is removed
|
||||
expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]);
|
||||
});
|
||||
|
||||
it("filters out irrelevant relations", async () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
// init responses
|
||||
const responses = await poll.getResponses();
|
||||
jest.spyOn(poll, "emit");
|
||||
const replyEvent = new MatrixEvent({ type: "m.room.message" });
|
||||
|
||||
poll.onNewRelation(replyEvent);
|
||||
|
||||
// did not add response -> no emit
|
||||
expect(poll.emit).not.toHaveBeenCalled();
|
||||
expect(responses.getRelations().length).toEqual(0);
|
||||
});
|
||||
|
||||
it("adds poll response relations to responses", async () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
// init responses
|
||||
const responses = await poll.getResponses();
|
||||
jest.spyOn(poll, "emit");
|
||||
const responseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
|
||||
|
||||
poll.onNewRelation(responseEvent);
|
||||
|
||||
// did not add response -> no emit
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.Responses, responses);
|
||||
expect(responses.getRelations()).toEqual([responseEvent]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -84,7 +84,7 @@ describe("Thread", () => {
|
||||
...mockClientMethodsUser(),
|
||||
getRoom: jest.fn().mockImplementation(() => room),
|
||||
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
|
||||
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
|
||||
supportsThreads: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
client.reEmitter = mock(ReEmitter, "ReEmitter");
|
||||
client.canSupport = new Map();
|
||||
@@ -195,7 +195,7 @@ describe("Thread", () => {
|
||||
...mockClientMethodsUser(),
|
||||
getRoom: jest.fn().mockImplementation(() => room),
|
||||
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
|
||||
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
|
||||
supportsThreads: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
client.reEmitter = mock(ReEmitter, "ReEmitter");
|
||||
client.canSupport = new Map();
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("fixNotificationCountOnDecryption", () => {
|
||||
getPushActionsForEvent: jest.fn().mockReturnValue(mkPushAction(true, true)),
|
||||
getRoom: jest.fn().mockImplementation(() => room),
|
||||
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
|
||||
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
|
||||
supportsThreads: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
mockClient.reEmitter = mock(ReEmitter, "ReEmitter");
|
||||
mockClient.canSupport = new Map();
|
||||
|
||||
@@ -18,7 +18,6 @@ import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import { MAIN_ROOM_TIMELINE, ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { Feature, ServerSupport } from "../../src/feature";
|
||||
import { EventType } from "../../src/matrix";
|
||||
import { synthesizeReceipt } from "../../src/models/read-receipt";
|
||||
import { encodeUri } from "../../src/utils";
|
||||
@@ -70,10 +69,6 @@ const roomEvent = utils.mkEvent({
|
||||
},
|
||||
});
|
||||
|
||||
function mockServerSideSupport(client: MatrixClient, serverSideSupport: ServerSupport) {
|
||||
client.canSupport.set(Feature.ThreadUnreadNotifications, serverSideSupport);
|
||||
}
|
||||
|
||||
describe("Read receipt", () => {
|
||||
beforeEach(() => {
|
||||
httpBackend = new MockHttpBackend();
|
||||
@@ -101,7 +96,6 @@ describe("Read receipt", () => {
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
mockServerSideSupport(client, ServerSupport.Stable);
|
||||
client.sendReceipt(threadEvent, ReceiptType.Read, {});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
@@ -123,7 +117,6 @@ describe("Read receipt", () => {
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
mockServerSideSupport(client, ServerSupport.Stable);
|
||||
client.sendReadReceipt(threadEvent, ReceiptType.Read, true);
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
@@ -145,56 +138,11 @@ describe("Read receipt", () => {
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
mockServerSideSupport(client, ServerSupport.Stable);
|
||||
client.sendReceipt(roomEvent, ReceiptType.Read, {});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("sends a room read receipt when there's no server support", async () => {
|
||||
httpBackend
|
||||
.when(
|
||||
"POST",
|
||||
encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
|
||||
$roomId: ROOM_ID,
|
||||
$receiptType: ReceiptType.Read,
|
||||
$eventId: threadEvent.getId()!,
|
||||
}),
|
||||
)
|
||||
.check((request) => {
|
||||
expect(request.data.thread_id).toBeUndefined();
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
mockServerSideSupport(client, ServerSupport.Unsupported);
|
||||
client.sendReceipt(threadEvent, ReceiptType.Read, {});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("sends a valid room read receipt even when body omitted", async () => {
|
||||
httpBackend
|
||||
.when(
|
||||
"POST",
|
||||
encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
|
||||
$roomId: ROOM_ID,
|
||||
$receiptType: ReceiptType.Read,
|
||||
$eventId: threadEvent.getId()!,
|
||||
}),
|
||||
)
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual({});
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
mockServerSideSupport(client, ServerSupport.Unsupported);
|
||||
client.sendReceipt(threadEvent, ReceiptType.Read, undefined);
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await flushPromises();
|
||||
});
|
||||
});
|
||||
|
||||
describe("synthesizeReceipt", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
+155
-12
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
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.
|
||||
@@ -19,6 +19,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, PollStartEvent } from "matrix-events-sdk";
|
||||
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { emitPromise } from "../test-utils/test-utils";
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
PendingEventOrdering,
|
||||
PollEvent,
|
||||
RelationType,
|
||||
RoomEvent,
|
||||
RoomMember,
|
||||
@@ -1622,7 +1624,7 @@ describe("Room", function () {
|
||||
describe("addPendingEvent", function () {
|
||||
it("should add pending events to the pendingEventList if " + "pendingEventOrdering == 'detached'", function () {
|
||||
const client = new TestClient("@alice:example.com", "alicedevice").client;
|
||||
client.supportsExperimentalThreads = () => true;
|
||||
client.supportsThreads = () => true;
|
||||
const room = new Room(roomId, client, userA, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
@@ -2468,7 +2470,7 @@ describe("Room", function () {
|
||||
});
|
||||
|
||||
it("Edits update the lastReply event", async () => {
|
||||
room.client.supportsExperimentalThreads = () => true;
|
||||
room.client.supportsThreads = () => true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
|
||||
const randomMessage = mkMessage();
|
||||
@@ -2539,7 +2541,7 @@ describe("Room", function () {
|
||||
});
|
||||
|
||||
it("Redactions to thread responses decrement the length", async () => {
|
||||
room.client.supportsExperimentalThreads = () => true;
|
||||
room.client.supportsThreads = () => true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
|
||||
const threadRoot = mkMessage();
|
||||
@@ -2606,7 +2608,7 @@ describe("Room", function () {
|
||||
});
|
||||
|
||||
it("Redactions to reactions in threads do not decrement the length", async () => {
|
||||
room.client.supportsExperimentalThreads = () => true;
|
||||
room.client.supportsThreads = () => true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
|
||||
const threadRoot = mkMessage();
|
||||
@@ -2646,7 +2648,7 @@ describe("Room", function () {
|
||||
});
|
||||
|
||||
it("should not decrement the length when the thread root is redacted", async () => {
|
||||
room.client.supportsExperimentalThreads = () => true;
|
||||
room.client.supportsThreads = () => true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
|
||||
const threadRoot = mkMessage();
|
||||
@@ -2687,7 +2689,7 @@ describe("Room", function () {
|
||||
});
|
||||
|
||||
it("Redacting the lastEvent finds a new lastEvent", async () => {
|
||||
room.client.supportsExperimentalThreads = () => true;
|
||||
room.client.supportsThreads = () => true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
|
||||
@@ -2794,7 +2796,7 @@ describe("Room", function () {
|
||||
|
||||
describe("eventShouldLiveIn", () => {
|
||||
const client = new TestClient(userA).client;
|
||||
client.supportsExperimentalThreads = () => true;
|
||||
client.supportsThreads = () => true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
const room = new Room(roomId, client, userA);
|
||||
|
||||
@@ -3228,12 +3230,84 @@ describe("Room", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("processPollEvents()", () => {
|
||||
let room: Room;
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = getMockClientWithEventEmitter({
|
||||
decryptEventIfNeeded: jest.fn(),
|
||||
});
|
||||
room = new Room(roomId, client, userA);
|
||||
jest.spyOn(room, "emit").mockClear();
|
||||
});
|
||||
|
||||
const makePollStart = (id: string): MatrixEvent => {
|
||||
const event = new MatrixEvent({
|
||||
...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
|
||||
room_id: roomId,
|
||||
});
|
||||
event.event.event_id = id;
|
||||
return event;
|
||||
};
|
||||
|
||||
it("adds poll models to room state for a poll start event ", async () => {
|
||||
const pollStartEvent = makePollStart("1");
|
||||
const events = [pollStartEvent];
|
||||
|
||||
await room.processPollEvents(events);
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledWith(pollStartEvent);
|
||||
const pollInstance = room.polls.get(pollStartEvent.getId()!);
|
||||
expect(pollInstance).toBeTruthy();
|
||||
|
||||
expect(room.emit).toHaveBeenCalledWith(PollEvent.New, pollInstance);
|
||||
});
|
||||
|
||||
it("adds related events to poll models", async () => {
|
||||
const pollStartEvent = makePollStart("1");
|
||||
const pollStartEvent2 = makePollStart("2");
|
||||
const events = [pollStartEvent, pollStartEvent2];
|
||||
const pollResponseEvent = new MatrixEvent({
|
||||
type: M_POLL_RESPONSE.name,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Reference,
|
||||
event_id: pollStartEvent.getId(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const messageEvent = new MatrixEvent({
|
||||
type: "m.room.messsage",
|
||||
content: {
|
||||
text: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
// init poll
|
||||
await room.processPollEvents(events);
|
||||
|
||||
const poll = room.polls.get(pollStartEvent.getId()!)!;
|
||||
const poll2 = room.polls.get(pollStartEvent2.getId()!)!;
|
||||
jest.spyOn(poll, "onNewRelation");
|
||||
jest.spyOn(poll2, "onNewRelation");
|
||||
|
||||
await room.processPollEvents([pollResponseEvent, messageEvent]);
|
||||
|
||||
// only called for relevant event
|
||||
expect(poll.onNewRelation).toHaveBeenCalledTimes(1);
|
||||
expect(poll.onNewRelation).toHaveBeenCalledWith(pollResponseEvent);
|
||||
|
||||
// only called on poll with relation
|
||||
expect(poll2.onNewRelation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findPredecessorRoomId", () => {
|
||||
let client: MatrixClient | null = null;
|
||||
beforeEach(() => {
|
||||
client = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(),
|
||||
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
|
||||
supportsThreads: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3266,21 +3340,90 @@ describe("Room", function () {
|
||||
});
|
||||
}
|
||||
|
||||
function predecessorEvent(
|
||||
newRoomId: string,
|
||||
predecessorRoomId: string,
|
||||
tombstoneEventId: string | null = null,
|
||||
): MatrixEvent {
|
||||
const content =
|
||||
tombstoneEventId === null
|
||||
? { predecessor_room_id: predecessorRoomId }
|
||||
: { predecessor_room_id: predecessorRoomId, last_known_event_id: tombstoneEventId };
|
||||
|
||||
return new MatrixEvent({
|
||||
content,
|
||||
event_id: `predecessor_event_id_pred_${predecessorRoomId}`,
|
||||
origin_server_ts: 1432735824653,
|
||||
room_id: newRoomId,
|
||||
sender: "@daryl:alexandria.example.com",
|
||||
state_key: "",
|
||||
type: "org.matrix.msc3946.room_predecessor",
|
||||
});
|
||||
}
|
||||
|
||||
it("Returns null if there is no create event", () => {
|
||||
const room = new Room("roomid", client!, "@u:example.com");
|
||||
expect(room.findPredecessorRoomId()).toBeNull();
|
||||
expect(room.findPredecessor()).toBeNull();
|
||||
});
|
||||
|
||||
it("Returns null if the create event has no predecessor", () => {
|
||||
const room = new Room("roomid", client!, "@u:example.com");
|
||||
room.addLiveEvents([roomCreateEvent("roomid", null)]);
|
||||
expect(room.findPredecessorRoomId()).toBeNull();
|
||||
expect(room.findPredecessor()).toBeNull();
|
||||
});
|
||||
|
||||
it("Returns the predecessor ID if one is provided via create event", () => {
|
||||
const room = new Room("roomid", client!, "@u:example.com");
|
||||
room.addLiveEvents([roomCreateEvent("roomid", "replacedroomid")]);
|
||||
expect(room.findPredecessorRoomId()).toBe("replacedroomid");
|
||||
expect(room.findPredecessor()).toEqual({ roomId: "replacedroomid", eventId: "id_of_last_known_event" });
|
||||
});
|
||||
|
||||
it("Prefers the m.predecessor event if one exists", () => {
|
||||
const room = new Room("roomid", client!, "@u:example.com");
|
||||
room.addLiveEvents([
|
||||
roomCreateEvent("roomid", "replacedroomid"),
|
||||
predecessorEvent("roomid", "otherreplacedroomid"),
|
||||
]);
|
||||
const useMsc3946 = true;
|
||||
expect(room.findPredecessor(useMsc3946)).toEqual({
|
||||
roomId: "otherreplacedroomid",
|
||||
eventId: null, // m.predecessor did not include an event_id
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the m.predecessor event ID if provided", () => {
|
||||
const room = new Room("roomid", client!, "@u:example.com");
|
||||
room.addLiveEvents([
|
||||
roomCreateEvent("roomid", "replacedroomid"),
|
||||
predecessorEvent("roomid", "otherreplacedroomid", "lstevtid"),
|
||||
]);
|
||||
const useMsc3946 = true;
|
||||
expect(room.findPredecessor(useMsc3946)).toEqual({
|
||||
roomId: "otherreplacedroomid",
|
||||
eventId: "lstevtid",
|
||||
});
|
||||
});
|
||||
|
||||
it("Ignores the m.predecessor event if we don't ask to use it", () => {
|
||||
const room = new Room("roomid", client!, "@u:example.com");
|
||||
room.addLiveEvents([
|
||||
roomCreateEvent("roomid", "replacedroomid"),
|
||||
predecessorEvent("roomid", "otherreplacedroomid"),
|
||||
]);
|
||||
// Don't provide an argument for msc3946ProcessDynamicPredecessor -
|
||||
// we should ignore the predecessor event.
|
||||
expect(room.findPredecessor()).toEqual({ roomId: "replacedroomid", eventId: "id_of_last_known_event" });
|
||||
});
|
||||
|
||||
it("Ignores the m.predecessor event and returns null if we don't ask to use it", () => {
|
||||
const room = new Room("roomid", client!, "@u:example.com");
|
||||
room.addLiveEvents([
|
||||
roomCreateEvent("roomid", null), // Create event has no predecessor
|
||||
predecessorEvent("roomid", "otherreplacedroomid", "lastevtid"),
|
||||
]);
|
||||
// Don't provide an argument for msc3946ProcessDynamicPredecessor -
|
||||
// we should ignore the predecessor event.
|
||||
expect(room.findPredecessor()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
Copyright 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 * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import { Mocked } from "jest-mock";
|
||||
import { KeysClaimRequest, UserId } from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { KeyClaimManager } from "../../../src/rust-crypto/KeyClaimManager";
|
||||
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
|
||||
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi } from "../../../src";
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
describe("KeyClaimManager", () => {
|
||||
/* for these tests, we connect a KeyClaimManager to a mock OlmMachine, and a real OutgoingRequestProcessor
|
||||
* (which is connected to a mock fetch implementation)
|
||||
*/
|
||||
|
||||
/** the KeyClaimManager implementation under test */
|
||||
let keyClaimManager: KeyClaimManager;
|
||||
|
||||
/** a mocked-up OlmMachine which the OutgoingRequestProcessor and KeyClaimManager are connected to */
|
||||
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const dummyEventEmitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
const httpApi = new MatrixHttpApi(dummyEventEmitter, {
|
||||
baseUrl: "https://example.com",
|
||||
prefix: "/_matrix",
|
||||
onlyData: true,
|
||||
});
|
||||
|
||||
olmMachine = {
|
||||
getMissingSessions: jest.fn(),
|
||||
markRequestAsSent: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
const outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, httpApi);
|
||||
|
||||
keyClaimManager = new KeyClaimManager(olmMachine, outgoingRequestProcessor);
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns a promise which resolve once olmMachine.markRequestAsSent is called.
|
||||
*
|
||||
* The call itself will block initially.
|
||||
*
|
||||
* The promise returned by this function yields a callback function, which should be called to unblock the
|
||||
* markRequestAsSent call.
|
||||
*/
|
||||
function awaitCallToMarkRequestAsSent(): Promise<() => void> {
|
||||
return new Promise<() => void>((resolveCalledPromise, _reject) => {
|
||||
olmMachine.markRequestAsSent.mockImplementationOnce(async () => {
|
||||
// the mock implementation returns a promise...
|
||||
const completePromise = new Promise<void>((resolveCompletePromise, _reject) => {
|
||||
// ... and we now resolve the original promise with the resolver for that second promise.
|
||||
resolveCalledPromise(resolveCompletePromise);
|
||||
});
|
||||
return completePromise;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("should claim missing keys", async () => {
|
||||
const u1 = new UserId("@alice:example.com");
|
||||
const u2 = new UserId("@bob:example.com");
|
||||
|
||||
// stub out olmMachine.getMissingSessions(), with a result indicating that it needs a keyclaim
|
||||
const keysClaimRequest = new KeysClaimRequest("1234", '{ "k1": "v1" }');
|
||||
olmMachine.getMissingSessions.mockResolvedValueOnce(keysClaimRequest);
|
||||
|
||||
// have the claim request return a 200
|
||||
fetchMock.postOnce("https://example.com/_matrix/client/v3/keys/claim", '{ "k": "v" }');
|
||||
|
||||
// also stub out olmMachine.markRequestAsSent
|
||||
olmMachine.markRequestAsSent.mockResolvedValueOnce(undefined);
|
||||
|
||||
// fire off the request
|
||||
await keyClaimManager.ensureSessionsForUsers([u1, u2]);
|
||||
|
||||
// check that all the calls were made
|
||||
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u1, u2]);
|
||||
expect(fetchMock).toHaveFetched("https://example.com/_matrix/client/v3/keys/claim", {
|
||||
method: "POST",
|
||||
body: { k1: "v1" },
|
||||
});
|
||||
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", keysClaimRequest.type, '{ "k": "v" }');
|
||||
});
|
||||
|
||||
it("should wait for previous claims to complete before making another", async () => {
|
||||
const u1 = new UserId("@alice:example.com");
|
||||
const u2 = new UserId("@bob:example.com");
|
||||
|
||||
// stub out olmMachine.getMissingSessions(), with a result indicating that it needs a keyclaim
|
||||
const keysClaimRequest = new KeysClaimRequest("1234", '{ "k1": "v1" }');
|
||||
olmMachine.getMissingSessions.mockResolvedValue(keysClaimRequest);
|
||||
|
||||
// have the claim request return a 200
|
||||
fetchMock.post("https://example.com/_matrix/client/v3/keys/claim", '{ "k": "v" }');
|
||||
|
||||
// stub out olmMachine.markRequestAsSent, and have it block
|
||||
let markRequestAsSentPromise = awaitCallToMarkRequestAsSent();
|
||||
|
||||
// fire off two requests, and keep track of whether their promises resolve
|
||||
let req1Resolved = false;
|
||||
keyClaimManager.ensureSessionsForUsers([u1]).then(() => {
|
||||
req1Resolved = true;
|
||||
});
|
||||
let req2Resolved = false;
|
||||
const req2 = keyClaimManager.ensureSessionsForUsers([u2]).then(() => {
|
||||
req2Resolved = true;
|
||||
});
|
||||
|
||||
// now: wait for the (first) call to OlmMachine.markRequestAsSent
|
||||
let resolveMarkRequestAsSentCallback = await markRequestAsSentPromise;
|
||||
|
||||
// at this point, there should have been a single call to getMissingSessions, and a single fetch; and neither
|
||||
// call to ensureSessionsAsUsers should have completed
|
||||
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u1]);
|
||||
expect(olmMachine.getMissingSessions).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(req1Resolved).toBe(false);
|
||||
expect(req2Resolved).toBe(false);
|
||||
|
||||
// await the next call to markRequestAsSent, and release the first one
|
||||
markRequestAsSentPromise = awaitCallToMarkRequestAsSent();
|
||||
resolveMarkRequestAsSentCallback();
|
||||
resolveMarkRequestAsSentCallback = await markRequestAsSentPromise;
|
||||
|
||||
// the first request should now have completed, and we should have more calls and fetches
|
||||
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u2]);
|
||||
expect(olmMachine.getMissingSessions).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(req1Resolved).toBe(true);
|
||||
expect(req2Resolved).toBe(false);
|
||||
|
||||
// finally, release the second call to markRequestAsSent and check that the second request completes
|
||||
resolveMarkRequestAsSentCallback();
|
||||
await req2;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
Copyright 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 MockHttpBackend from "matrix-mock-request";
|
||||
import { Mocked } from "jest-mock";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import {
|
||||
KeysBackupRequest,
|
||||
KeysClaimRequest,
|
||||
KeysQueryRequest,
|
||||
KeysUploadRequest,
|
||||
RoomMessageRequest,
|
||||
SignatureUploadRequest,
|
||||
ToDeviceRequest,
|
||||
} from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
|
||||
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi } from "../../../src";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
|
||||
describe("OutgoingRequestProcessor", () => {
|
||||
/** the OutgoingRequestProcessor implementation under test */
|
||||
let processor: OutgoingRequestProcessor;
|
||||
|
||||
/** A mock http backend which processor is connected to */
|
||||
let httpBackend: MockHttpBackend;
|
||||
|
||||
/** a mocked-up OlmMachine which processor is connected to */
|
||||
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
/** wait for a call to olmMachine.markRequestAsSent */
|
||||
function awaitCallToMarkAsSent(): Promise<void> {
|
||||
return new Promise((resolve, _reject) => {
|
||||
olmMachine.markRequestAsSent.mockImplementationOnce(async () => {
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
httpBackend = new MockHttpBackend();
|
||||
|
||||
const dummyEventEmitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
const httpApi = new MatrixHttpApi(dummyEventEmitter, {
|
||||
baseUrl: "https://example.com",
|
||||
prefix: "/_matrix",
|
||||
fetchFn: httpBackend.fetchFn as typeof global.fetch,
|
||||
onlyData: true,
|
||||
});
|
||||
|
||||
olmMachine = {
|
||||
markRequestAsSent: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
processor = new OutgoingRequestProcessor(olmMachine, httpApi);
|
||||
});
|
||||
|
||||
/* simple requests that map directly to the request body */
|
||||
const tests: Array<[string, any, "POST" | "PUT", string]> = [
|
||||
["KeysUploadRequest", KeysUploadRequest, "POST", "https://example.com/_matrix/client/v3/keys/upload"],
|
||||
["KeysQueryRequest", KeysQueryRequest, "POST", "https://example.com/_matrix/client/v3/keys/query"],
|
||||
["KeysClaimRequest", KeysClaimRequest, "POST", "https://example.com/_matrix/client/v3/keys/claim"],
|
||||
[
|
||||
"SignatureUploadRequest",
|
||||
SignatureUploadRequest,
|
||||
"POST",
|
||||
"https://example.com/_matrix/client/v3/keys/signatures/upload",
|
||||
],
|
||||
["KeysBackupRequest", KeysBackupRequest, "PUT", "https://example.com/_matrix/client/v3/room_keys/keys"],
|
||||
];
|
||||
|
||||
test.each(tests)(`should handle %ss`, async (_, RequestClass, expectedMethod, expectedPath) => {
|
||||
// first, mock up a request as we might expect to receive it from the Rust layer ...
|
||||
const testBody = '{ "foo": "bar" }';
|
||||
const outgoingRequest = new RequestClass("1234", testBody);
|
||||
|
||||
// ... then poke it into the OutgoingRequestProcessor under test.
|
||||
const reqProm = processor.makeOutgoingRequest(outgoingRequest);
|
||||
|
||||
// Now: check that it makes a matching HTTP request ...
|
||||
const testResponse = '{ "result": 1 }';
|
||||
httpBackend
|
||||
.when(expectedMethod, "/_matrix")
|
||||
.check((req) => {
|
||||
expect(req.path).toEqual(expectedPath);
|
||||
expect(req.rawData).toEqual(testBody);
|
||||
expect(req.headers["Accept"]).toEqual("application/json");
|
||||
expect(req.headers["Content-Type"]).toEqual("application/json");
|
||||
})
|
||||
.respond(200, testResponse, true);
|
||||
|
||||
// ... and that it calls OlmMachine.markAsSent.
|
||||
const markSentCallPromise = awaitCallToMarkAsSent();
|
||||
await httpBackend.flushAllExpected();
|
||||
|
||||
await Promise.all([reqProm, markSentCallPromise]);
|
||||
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
it("should handle ToDeviceRequests", async () => {
|
||||
// first, mock up the ToDeviceRequest as we might expect to receive it from the Rust layer ...
|
||||
const testBody = '{ "foo": "bar" }';
|
||||
const outgoingRequest = new ToDeviceRequest("1234", "test/type", "test/txnid", testBody);
|
||||
|
||||
// ... then poke it into the OutgoingRequestProcessor under test.
|
||||
const reqProm = processor.makeOutgoingRequest(outgoingRequest);
|
||||
|
||||
// Now: check that it makes a matching HTTP request ...
|
||||
const testResponse = '{ "result": 1 }';
|
||||
httpBackend
|
||||
.when("PUT", "/_matrix")
|
||||
.check((req) => {
|
||||
expect(req.path).toEqual("https://example.com/_matrix/client/v3/sendToDevice/test%2Ftype/test%2Ftxnid");
|
||||
expect(req.rawData).toEqual(testBody);
|
||||
expect(req.headers["Accept"]).toEqual("application/json");
|
||||
expect(req.headers["Content-Type"]).toEqual("application/json");
|
||||
})
|
||||
.respond(200, testResponse, true);
|
||||
|
||||
// ... and that it calls OlmMachine.markAsSent.
|
||||
const markSentCallPromise = awaitCallToMarkAsSent();
|
||||
await httpBackend.flushAllExpected();
|
||||
|
||||
await Promise.all([reqProm, markSentCallPromise]);
|
||||
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
it("should handle RoomMessageRequests", async () => {
|
||||
// first, mock up the RoomMessageRequest as we might expect to receive it from the Rust layer ...
|
||||
const testBody = '{ "foo": "bar" }';
|
||||
const outgoingRequest = new RoomMessageRequest("1234", "test/room", "test/txnid", "test/type", testBody);
|
||||
|
||||
// ... then poke it into the OutgoingRequestProcessor under test.
|
||||
const reqProm = processor.makeOutgoingRequest(outgoingRequest);
|
||||
|
||||
// Now: check that it makes a matching HTTP request ...
|
||||
const testResponse = '{ "result": 1 }';
|
||||
httpBackend
|
||||
.when("PUT", "/_matrix")
|
||||
.check((req) => {
|
||||
expect(req.path).toEqual(
|
||||
"https://example.com/_matrix/client/v3/room/test%2Froom/send/test%2Ftype/test%2Ftxnid",
|
||||
);
|
||||
expect(req.rawData).toEqual(testBody);
|
||||
expect(req.headers["Accept"]).toEqual("application/json");
|
||||
expect(req.headers["Content-Type"]).toEqual("application/json");
|
||||
})
|
||||
.respond(200, testResponse, true);
|
||||
|
||||
// ... and that it calls OlmMachine.markAsSent.
|
||||
const markSentCallPromise = awaitCallToMarkAsSent();
|
||||
await httpBackend.flushAllExpected();
|
||||
|
||||
await Promise.all([reqProm, markSentCallPromise]);
|
||||
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
it("does not explode with unknown requests", async () => {
|
||||
const outgoingRequest = { id: "5678", type: 987 };
|
||||
const markSentCallPromise = awaitCallToMarkAsSent();
|
||||
await Promise.all([processor.makeOutgoingRequest(outgoingRequest), markSentCallPromise]);
|
||||
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("5678", 987, "");
|
||||
});
|
||||
});
|
||||
@@ -17,21 +17,16 @@ limitations under the License.
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import {
|
||||
KeysBackupRequest,
|
||||
KeysClaimRequest,
|
||||
KeysQueryRequest,
|
||||
KeysUploadRequest,
|
||||
OlmMachine,
|
||||
SignatureUploadRequest,
|
||||
} from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import { KeysQueryRequest, OlmMachine } from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import { Mocked } from "jest-mock";
|
||||
import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
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 { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
|
||||
import { initRustCrypto } from "../../../src/rust-crypto";
|
||||
import { IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../../src";
|
||||
import { mkEvent } from "../../test-utils/test-utils";
|
||||
import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
|
||||
import { IEventDecryptionResult } from "../../../src/@types/crypto";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -103,8 +98,8 @@ describe("RustCrypto", () => {
|
||||
/** the RustCrypto implementation under test */
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
/** A mock http backend which rustCrypto is connected to */
|
||||
let httpBackend: MockHttpBackend;
|
||||
/** A mock OutgoingRequestProcessor which rustCrypto is connected to */
|
||||
let outgoingRequestProcessor: Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
/** a mocked-up OlmMachine which rustCrypto is connected to */
|
||||
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
@@ -113,28 +108,25 @@ describe("RustCrypto", () => {
|
||||
* the front of the queue, until it is empty. */
|
||||
let outgoingRequestQueue: Array<Array<any>>;
|
||||
|
||||
/** wait for a call to olmMachine.markRequestAsSent */
|
||||
function awaitCallToMarkAsSent(): Promise<void> {
|
||||
return new Promise((resolve, _reject) => {
|
||||
olmMachine.markRequestAsSent.mockImplementationOnce(async () => {
|
||||
resolve(undefined);
|
||||
/** wait for a call to outgoingRequestProcessor.makeOutgoingRequest.
|
||||
*
|
||||
* The promise resolves to a callback: the makeOutgoingRequest call will not complete until the returned
|
||||
* callback is called.
|
||||
*/
|
||||
function awaitCallToMakeOutgoingRequest(): Promise<() => void> {
|
||||
return new Promise<() => void>((resolveCalledPromise, _reject) => {
|
||||
outgoingRequestProcessor.makeOutgoingRequest.mockImplementationOnce(async () => {
|
||||
const completePromise = new Promise<void>((resolveCompletePromise, _reject) => {
|
||||
resolveCalledPromise(resolveCompletePromise);
|
||||
});
|
||||
return completePromise;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
httpBackend = new MockHttpBackend();
|
||||
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
|
||||
const dummyEventEmitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
const httpApi = new MatrixHttpApi(dummyEventEmitter, {
|
||||
baseUrl: "https://example.com",
|
||||
prefix: "/_matrix",
|
||||
fetchFn: httpBackend.fetchFn as typeof global.fetch,
|
||||
onlyData: true,
|
||||
});
|
||||
|
||||
// for these tests we use a mock OlmMachine, with an implementation of outgoingRequests that
|
||||
// returns objects from outgoingRequestQueue
|
||||
outgoingRequestQueue = [];
|
||||
@@ -142,91 +134,55 @@ describe("RustCrypto", () => {
|
||||
outgoingRequests: jest.fn().mockImplementation(() => {
|
||||
return Promise.resolve(outgoingRequestQueue.shift() ?? []);
|
||||
}),
|
||||
markRequestAsSent: jest.fn(),
|
||||
close: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
rustCrypto = new RustCrypto(olmMachine, httpApi, TEST_USER, TEST_DEVICE_ID);
|
||||
outgoingRequestProcessor = {
|
||||
makeOutgoingRequest: jest.fn(),
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
rustCrypto = new RustCrypto(olmMachine, {} as MatrixHttpApi<any>, TEST_USER, TEST_DEVICE_ID);
|
||||
rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor;
|
||||
});
|
||||
|
||||
it("should poll for outgoing messages", () => {
|
||||
it("should poll for outgoing messages and send them", async () => {
|
||||
const testReq = new KeysQueryRequest("1234", "{}");
|
||||
outgoingRequestQueue.push([testReq]);
|
||||
|
||||
const makeRequestPromise = awaitCallToMakeOutgoingRequest();
|
||||
rustCrypto.onSyncCompleted({});
|
||||
|
||||
await makeRequestPromise;
|
||||
expect(olmMachine.outgoingRequests).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/* simple requests that map directly to the request body */
|
||||
const tests: Array<[any, "POST" | "PUT", string]> = [
|
||||
[KeysUploadRequest, "POST", "https://example.com/_matrix/client/v3/keys/upload"],
|
||||
[KeysQueryRequest, "POST", "https://example.com/_matrix/client/v3/keys/query"],
|
||||
[KeysClaimRequest, "POST", "https://example.com/_matrix/client/v3/keys/claim"],
|
||||
[SignatureUploadRequest, "POST", "https://example.com/_matrix/client/v3/keys/signatures/upload"],
|
||||
[KeysBackupRequest, "PUT", "https://example.com/_matrix/client/v3/room_keys/keys"],
|
||||
];
|
||||
|
||||
for (const [RequestClass, expectedMethod, expectedPath] of tests) {
|
||||
it(`should handle ${RequestClass.name}s`, async () => {
|
||||
const testBody = '{ "foo": "bar" }';
|
||||
const outgoingRequest = new RequestClass("1234", testBody);
|
||||
outgoingRequestQueue.push([outgoingRequest]);
|
||||
|
||||
const testResponse = '{ "result": 1 }';
|
||||
httpBackend
|
||||
.when(expectedMethod, "/_matrix")
|
||||
.check((req) => {
|
||||
expect(req.path).toEqual(expectedPath);
|
||||
expect(req.rawData).toEqual(testBody);
|
||||
expect(req.headers["Accept"]).toEqual("application/json");
|
||||
expect(req.headers["Content-Type"]).toEqual("application/json");
|
||||
})
|
||||
.respond(200, testResponse, true);
|
||||
|
||||
rustCrypto.onSyncCompleted({});
|
||||
|
||||
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(1);
|
||||
|
||||
const markSentCallPromise = awaitCallToMarkAsSent();
|
||||
await httpBackend.flushAllExpected();
|
||||
|
||||
await markSentCallPromise;
|
||||
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
}
|
||||
|
||||
it("does not explode with unknown requests", async () => {
|
||||
const outgoingRequest = { id: "5678", type: 987 };
|
||||
outgoingRequestQueue.push([outgoingRequest]);
|
||||
|
||||
rustCrypto.onSyncCompleted({});
|
||||
|
||||
await awaitCallToMarkAsSent();
|
||||
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("5678", 987, "");
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalledWith(testReq);
|
||||
});
|
||||
|
||||
it("stops looping when stop() is called", async () => {
|
||||
const testResponse = '{ "result": 1 }';
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
outgoingRequestQueue.push([new KeysQueryRequest("1234", "{}")]);
|
||||
httpBackend.when("POST", "/_matrix").respond(200, testResponse, true);
|
||||
}
|
||||
|
||||
let makeRequestPromise = awaitCallToMakeOutgoingRequest();
|
||||
|
||||
rustCrypto.onSyncCompleted({});
|
||||
|
||||
expect(rustCrypto["outgoingRequestLoopRunning"]).toBeTruthy();
|
||||
|
||||
// go a couple of times round the loop
|
||||
await httpBackend.flush("/_matrix", 1);
|
||||
await awaitCallToMarkAsSent();
|
||||
let resolveMakeRequest = await makeRequestPromise;
|
||||
makeRequestPromise = awaitCallToMakeOutgoingRequest();
|
||||
resolveMakeRequest();
|
||||
|
||||
await httpBackend.flush("/_matrix", 1);
|
||||
await awaitCallToMarkAsSent();
|
||||
resolveMakeRequest = await makeRequestPromise;
|
||||
makeRequestPromise = awaitCallToMakeOutgoingRequest();
|
||||
resolveMakeRequest();
|
||||
|
||||
// a second sync while this is going on shouldn't make any difference
|
||||
rustCrypto.onSyncCompleted({});
|
||||
|
||||
await httpBackend.flush("/_matrix", 1);
|
||||
await awaitCallToMarkAsSent();
|
||||
resolveMakeRequest = await makeRequestPromise;
|
||||
outgoingRequestProcessor.makeOutgoingRequest.mockReset();
|
||||
resolveMakeRequest();
|
||||
|
||||
// now stop...
|
||||
rustCrypto.stop();
|
||||
@@ -238,11 +194,40 @@ describe("RustCrypto", () => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
expect(rustCrypto["outgoingRequestLoopRunning"]).toBeFalsy();
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
|
||||
expect(olmMachine.outgoingRequests).not.toHaveBeenCalled();
|
||||
|
||||
// we sent three, so there should be 2 left
|
||||
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,3 +1,19 @@
|
||||
/*
|
||||
Copyright 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 * as utils from "../../src/utils";
|
||||
import {
|
||||
alphabetPad,
|
||||
@@ -587,4 +603,22 @@ describe("utils", function () {
|
||||
expect(utils.isSupportedReceiptType("this is a receipt type")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sleep", () => {
|
||||
it("resolves", async () => {
|
||||
await utils.sleep(0);
|
||||
});
|
||||
|
||||
it("resolves with the provided value", async () => {
|
||||
const expected = Symbol("hi");
|
||||
const result = await utils.sleep(0, expected);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("immediate", () => {
|
||||
it("resolves", async () => {
|
||||
await utils.immediate();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,9 @@ import {
|
||||
MockMediaStreamTrack,
|
||||
installWebRTCMocks,
|
||||
MockRTCPeerConnection,
|
||||
MockRTCRtpTransceiver,
|
||||
SCREENSHARE_STREAM_ID,
|
||||
MockRTCRtpSender,
|
||||
} from "../../test-utils/webrtc";
|
||||
import { CallFeed } from "../../../src/webrtc/callFeed";
|
||||
import { EventType, IContent, ISendEventResponse, MatrixEvent, Room } from "../../../src";
|
||||
@@ -536,8 +538,15 @@ describe("Call", function () {
|
||||
it("if local video", async () => {
|
||||
call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" });
|
||||
|
||||
// since this is testing for the presence of a local sender, we need to add a transciever
|
||||
// rather than just a source track
|
||||
const mockTrack = new MockMediaStreamTrack("track_id", "video");
|
||||
const mockTransceiver = new MockRTCRtpTransceiver(call.peerConn as unknown as MockRTCPeerConnection);
|
||||
mockTransceiver.sender = new MockRTCRtpSender(mockTrack) as unknown as RTCRtpSender;
|
||||
(call as any).transceivers.set("m.usermedia:video", mockTransceiver);
|
||||
|
||||
(call as any).pushNewLocalFeed(
|
||||
new MockMediaStream("remote_stream1", [new MockMediaStreamTrack("track_id", "video")]),
|
||||
new MockMediaStream("remote_stream1", [mockTrack]),
|
||||
SDPStreamMetadataPurpose.Usermedia,
|
||||
false,
|
||||
);
|
||||
@@ -829,6 +838,55 @@ describe("Call", function () {
|
||||
await startVideoCall(client, call);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should not remove video sender on video mute", async () => {
|
||||
await call.setLocalVideoMuted(true);
|
||||
expect((call as any).hasUserMediaVideoSender).toBe(true);
|
||||
});
|
||||
|
||||
it("should release camera after short delay on video mute", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
await call.setLocalVideoMuted(true);
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
expect(call.hasLocalUserMediaVideoTrack).toBe(false);
|
||||
});
|
||||
|
||||
it("should re-request video feed on video unmute if it doesn't have one", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockGetUserMediaStream = jest
|
||||
.fn()
|
||||
.mockReturnValue(client.client.getMediaHandler().getUserMediaStream(true, true));
|
||||
|
||||
client.client.getMediaHandler().getUserMediaStream = mockGetUserMediaStream;
|
||||
|
||||
await call.setLocalVideoMuted(true);
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
await call.setLocalVideoMuted(false);
|
||||
|
||||
expect(mockGetUserMediaStream).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not release camera on fast mute and unmute", async () => {
|
||||
const mockGetUserMediaStream = jest.fn();
|
||||
|
||||
client.client.getMediaHandler().getUserMediaStream = mockGetUserMediaStream;
|
||||
|
||||
await call.setLocalVideoMuted(true);
|
||||
await call.setLocalVideoMuted(false);
|
||||
|
||||
expect(mockGetUserMediaStream).not.toHaveBeenCalled();
|
||||
expect(call.hasLocalUserMediaVideoTrack).toBe(true);
|
||||
});
|
||||
|
||||
describe("sending sdp_stream_metadata_changed events", () => {
|
||||
it("should send sdp_stream_metadata_changed when muting audio", async () => {
|
||||
await call.setMicrophoneMuted(true);
|
||||
@@ -1385,7 +1443,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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -33,6 +33,7 @@ export enum EventType {
|
||||
RoomGuestAccess = "m.room.guest_access",
|
||||
RoomServerAcl = "m.room.server_acl",
|
||||
RoomTombstone = "m.room.tombstone",
|
||||
RoomPredecessor = "org.matrix.msc3946.room_predecessor",
|
||||
|
||||
SpaceChild = "m.space.child",
|
||||
SpaceParent = "m.space.parent",
|
||||
|
||||
@@ -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!))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -47,7 +47,7 @@ interface WellKnownConfig extends Omit<IWellKnownConfig, "error"> {
|
||||
error?: IWellKnownConfig["error"] | null;
|
||||
}
|
||||
|
||||
interface ClientConfig extends Omit<IClientWellKnown, "m.homeserver" | "m.identity_server"> {
|
||||
export interface ClientConfig extends Omit<IClientWellKnown, "m.homeserver" | "m.identity_server"> {
|
||||
"m.homeserver": WellKnownConfig;
|
||||
"m.identity_server": WellKnownConfig;
|
||||
}
|
||||
|
||||
+155
-81
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2015-2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015-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.
|
||||
@@ -209,7 +209,6 @@ import { ToDeviceBatch } from "./models/ToDeviceMessage";
|
||||
import { IgnoredInvites } from "./models/invites-ignorer";
|
||||
import { UIARequest, UIAResponse } from "./@types/uia";
|
||||
import { LocalNotificationSettings } from "./@types/local_notifications";
|
||||
import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync";
|
||||
import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature";
|
||||
import { CryptoBackend } from "./common-crypto/CryptoBackend";
|
||||
import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants";
|
||||
@@ -446,10 +445,16 @@ export interface IStartClientOpts {
|
||||
clientWellKnownPollPeriod?: number;
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* @deprecated use `threadSupport` instead
|
||||
*/
|
||||
experimentalThreadSupport?: boolean;
|
||||
|
||||
/**
|
||||
* Will organises events in threaded conversations when
|
||||
* a thread relation is encountered
|
||||
*/
|
||||
threadSupport?: boolean;
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
@@ -568,6 +573,8 @@ export interface IWellKnownConfig {
|
||||
error?: Error | string;
|
||||
// eslint-disable-next-line
|
||||
base_url?: string | null;
|
||||
// XXX: this is undocumented
|
||||
server_name?: string;
|
||||
}
|
||||
|
||||
export interface IDelegatedAuthConfig {
|
||||
@@ -855,6 +862,7 @@ export enum ClientEvent {
|
||||
SyncUnexpectedError = "sync.unexpectedError",
|
||||
ClientWellKnown = "WellKnown.client",
|
||||
ReceivedVoipEvent = "received_voip_event",
|
||||
UndecryptableToDeviceEvent = "toDeviceEvent.undecryptable",
|
||||
TurnServers = "turnServers",
|
||||
TurnServersError = "turnServers.error",
|
||||
}
|
||||
@@ -1063,6 +1071,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
|
||||
@@ -1435,6 +1455,19 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
|
||||
}
|
||||
|
||||
if (this.clientOpts.hasOwnProperty("experimentalThreadSupport")) {
|
||||
logger.warn("`experimentalThreadSupport` has been deprecated, use `threadSupport` instead");
|
||||
}
|
||||
|
||||
// If `threadSupport` is omitted and the deprecated `experimentalThreadSupport` has been passed
|
||||
// We should fallback to that value for backwards compatibility purposes
|
||||
if (
|
||||
!this.clientOpts.hasOwnProperty("threadSupport") &&
|
||||
this.clientOpts.hasOwnProperty("experimentalThreadSupport")
|
||||
) {
|
||||
this.clientOpts.threadSupport = this.clientOpts.experimentalThreadSupport;
|
||||
}
|
||||
|
||||
this.syncApi.sync();
|
||||
|
||||
if (this.clientOpts.clientWellKnownPollPeriod !== undefined) {
|
||||
@@ -2152,7 +2185,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// importing rust-crypto will download the webassembly, so we delay it until we know it will be
|
||||
// needed.
|
||||
const RustCrypto = await import("./rust-crypto");
|
||||
this.cryptoBackend = await RustCrypto.initRustCrypto(this.http, userId, deviceId);
|
||||
const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId);
|
||||
this.cryptoBackend = rustCrypto;
|
||||
|
||||
// attach the event listeners needed by RustCrypto
|
||||
this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2494,10 +2531,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 +2546,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2575,10 +2612,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param room - the room the event is in
|
||||
*/
|
||||
public prepareToEncrypt(room: Room): void {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
this.crypto.prepareToEncrypt(room);
|
||||
this.cryptoBackend.prepareToEncrypt(room);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2676,10 +2713,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3767,14 +3804,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* This is essentially getRooms() with some rooms filtered out, eg. old versions
|
||||
* of rooms that have been replaced or (in future) other rooms that have been
|
||||
* marked at the protocol level as not to be displayed to the user.
|
||||
*
|
||||
* @param msc3946ProcessDynamicPredecessor - if true, look for an
|
||||
* m.room.predecessor state event and
|
||||
* use it if found (MSC3946).
|
||||
* @returns A list of rooms, or an empty list if there is no data store.
|
||||
*/
|
||||
public getVisibleRooms(): Room[] {
|
||||
public getVisibleRooms(msc3946ProcessDynamicPredecessor = false): Room[] {
|
||||
const allRooms = this.store.getRooms();
|
||||
|
||||
const replacedRooms = new Set();
|
||||
for (const r of allRooms) {
|
||||
const predecessor = r.findPredecessorRoomId();
|
||||
const predecessor = r.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
|
||||
if (predecessor) {
|
||||
replacedRooms.add(predecessor);
|
||||
}
|
||||
@@ -4355,11 +4396,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.isRoomEncrypted(event.getRoomId()!)) {
|
||||
if (!room || !this.isRoomEncrypted(event.getRoomId()!)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.crypto && this.usingExternalCrypto) {
|
||||
if (!this.cryptoBackend && this.usingExternalCrypto) {
|
||||
// The client has opted to allow sending messages to encrypted
|
||||
// rooms even if the room is encrypted, and we haven't setup
|
||||
// crypto. This is useful for users of matrix-org/pantalaimon
|
||||
@@ -4380,13 +4421,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.crypto) {
|
||||
throw new Error(
|
||||
"This room is configured to use encryption, but your client does " + "not support encryption.",
|
||||
);
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("This room is configured to use encryption, but your client does not support encryption.");
|
||||
}
|
||||
|
||||
return this.crypto.encryptEvent(event, room);
|
||||
return this.cryptoBackend.encryptEvent(event, room);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4804,8 +4843,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
$eventId: event.getId()!,
|
||||
});
|
||||
|
||||
const supportsThreadRR = this.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported;
|
||||
if (supportsThreadRR && !unthreaded) {
|
||||
if (!unthreaded) {
|
||||
const isThread = !!event.threadRootId;
|
||||
body = {
|
||||
...body,
|
||||
@@ -4973,70 +5011,83 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* which can be proven to be linked. For example, rooms which have a create
|
||||
* event pointing to an old room which the client is not aware of or doesn't
|
||||
* have a matching tombstone would not be returned.
|
||||
* @param msc3946ProcessDynamicPredecessor - if true, look for
|
||||
* m.room.predecessor state events as well as create events, and prefer
|
||||
* predecessor events where they exist (MSC3946).
|
||||
* @returns An array of rooms representing the upgrade
|
||||
* history.
|
||||
*/
|
||||
public getRoomUpgradeHistory(roomId: string, verifyLinks = false): Room[] {
|
||||
let currentRoom = this.getRoom(roomId);
|
||||
public getRoomUpgradeHistory(
|
||||
roomId: string,
|
||||
verifyLinks = false,
|
||||
msc3946ProcessDynamicPredecessor = false,
|
||||
): Room[] {
|
||||
const currentRoom = this.getRoom(roomId);
|
||||
if (!currentRoom) return [];
|
||||
|
||||
const upgradeHistory = [currentRoom];
|
||||
const before = this.findPredecessorRooms(currentRoom, verifyLinks, msc3946ProcessDynamicPredecessor);
|
||||
const after = this.findSuccessorRooms(currentRoom, verifyLinks, msc3946ProcessDynamicPredecessor);
|
||||
|
||||
// Work backwards first, looking at create events.
|
||||
let createEvent = currentRoom.currentState.getStateEvents(EventType.RoomCreate, "");
|
||||
while (createEvent) {
|
||||
const predecessor = createEvent.getContent()["predecessor"];
|
||||
if (predecessor && predecessor["room_id"]) {
|
||||
const refRoom = this.getRoom(predecessor["room_id"]);
|
||||
if (!refRoom) break; // end of the chain
|
||||
return [...before, currentRoom, ...after];
|
||||
}
|
||||
|
||||
if (verifyLinks) {
|
||||
const tombstone = refRoom.currentState.getStateEvents(EventType.RoomTombstone, "");
|
||||
private findPredecessorRooms(room: Room, verifyLinks: boolean, msc3946ProcessDynamicPredecessor: boolean): Room[] {
|
||||
const ret: Room[] = [];
|
||||
|
||||
if (!tombstone || tombstone.getContent()["replacement_room"] !== refRoom.roomId) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert at the front because we're working backwards from the currentRoom
|
||||
upgradeHistory.splice(0, 0, refRoom);
|
||||
createEvent = refRoom.currentState.getStateEvents(EventType.RoomCreate, "");
|
||||
} else {
|
||||
// No further create events to look at
|
||||
// Work backwards from newer to older rooms
|
||||
let predecessorRoomId = room.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
|
||||
while (predecessorRoomId !== null) {
|
||||
const predecessorRoom = this.getRoom(predecessorRoomId);
|
||||
if (predecessorRoom === null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (verifyLinks) {
|
||||
const tombstone = predecessorRoom.currentState.getStateEvents(EventType.RoomTombstone, "");
|
||||
if (!tombstone || tombstone.getContent()["replacement_room"] !== room.roomId) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Work forwards next, looking at tombstone events
|
||||
let tombstoneEvent = currentRoom.currentState.getStateEvents(EventType.RoomTombstone, "");
|
||||
// Insert at the front because we're working backwards from the currentRoom
|
||||
ret.splice(0, 0, predecessorRoom);
|
||||
|
||||
room = predecessorRoom;
|
||||
predecessorRoomId = room.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
private findSuccessorRooms(room: Room, verifyLinks: boolean, msc3946ProcessDynamicPredecessor: boolean): Room[] {
|
||||
const ret: Room[] = [];
|
||||
|
||||
// Work forwards, looking at tombstone events
|
||||
let tombstoneEvent = room.currentState.getStateEvents(EventType.RoomTombstone, "");
|
||||
while (tombstoneEvent) {
|
||||
const refRoom = this.getRoom(tombstoneEvent.getContent()["replacement_room"]);
|
||||
if (!refRoom) break; // end of the chain
|
||||
if (refRoom.roomId === currentRoom.roomId) break; // Tombstone is referencing it's own room
|
||||
const successorRoom = this.getRoom(tombstoneEvent.getContent()["replacement_room"]);
|
||||
if (!successorRoom) break; // end of the chain
|
||||
if (successorRoom.roomId === room.roomId) break; // Tombstone is referencing its own room
|
||||
|
||||
if (verifyLinks) {
|
||||
createEvent = refRoom.currentState.getStateEvents(EventType.RoomCreate, "");
|
||||
if (!createEvent || !createEvent.getContent()["predecessor"]) break;
|
||||
|
||||
const predecessor = createEvent.getContent()["predecessor"];
|
||||
if (predecessor["room_id"] !== currentRoom.roomId) break;
|
||||
const predecessorRoomId = successorRoom.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
|
||||
if (!predecessorRoomId || predecessorRoomId !== room.roomId) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Push to the end because we're looking forwards
|
||||
upgradeHistory.push(refRoom);
|
||||
const roomIds = new Set(upgradeHistory.map((ref) => ref.roomId));
|
||||
if (roomIds.size < upgradeHistory.length) {
|
||||
ret.push(successorRoom);
|
||||
const roomIds = new Set(ret.map((ref) => ref.roomId));
|
||||
if (roomIds.size < ret.length) {
|
||||
// The last room added to the list introduced a previous roomId
|
||||
// To avoid recursion, return the last rooms - 1
|
||||
return upgradeHistory.slice(0, upgradeHistory.length - 1);
|
||||
return ret.slice(0, ret.length - 1);
|
||||
}
|
||||
|
||||
// Set the current room to the reference room so we know where we're at
|
||||
currentRoom = refRoom;
|
||||
tombstoneEvent = currentRoom.currentState.getStateEvents(EventType.RoomTombstone, "");
|
||||
room = successorRoom;
|
||||
tombstoneEvent = room.currentState.getStateEvents(EventType.RoomTombstone, "");
|
||||
}
|
||||
|
||||
return upgradeHistory;
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5407,7 +5458,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents);
|
||||
|
||||
this.processBeaconEvents(room, timelineEvents);
|
||||
this.processAggregatedTimelineEvents(room, timelineEvents);
|
||||
room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
|
||||
this.processThreadEvents(room, threadedEvents, true);
|
||||
|
||||
@@ -5468,7 +5519,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return timelineSet.getTimelineForEvent(eventId);
|
||||
}
|
||||
|
||||
if (timelineSet.thread && this.supportsExperimentalThreads()) {
|
||||
if (timelineSet.thread && this.supportsThreads()) {
|
||||
return this.getThreadTimeline(timelineSet, eventId);
|
||||
}
|
||||
|
||||
@@ -5522,7 +5573,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start);
|
||||
// The target event is not in a thread but process the contextual events, so we can show any threads around it.
|
||||
this.processThreadEvents(timelineSet.room, threadedEvents, true);
|
||||
this.processBeaconEvents(timelineSet.room, timelineEvents);
|
||||
this.processAggregatedTimelineEvents(timelineSet.room, timelineEvents);
|
||||
|
||||
// There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring
|
||||
// timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up
|
||||
@@ -5535,7 +5586,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
public async getThreadTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<EventTimeline | undefined> {
|
||||
if (!this.supportsExperimentalThreads()) {
|
||||
if (!this.supportsThreads()) {
|
||||
throw new Error("could not get thread timeline: no client support");
|
||||
}
|
||||
|
||||
@@ -5617,7 +5668,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward);
|
||||
timeline.setPaginationToken(resNewer.next_batch ?? null, Direction.Forward);
|
||||
this.processBeaconEvents(timelineSet.room, events);
|
||||
this.processAggregatedTimelineEvents(timelineSet.room, events);
|
||||
|
||||
// There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring
|
||||
// timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up
|
||||
@@ -5674,7 +5725,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward);
|
||||
timeline.setPaginationToken(null, Direction.Forward);
|
||||
this.processBeaconEvents(timelineSet.room, events);
|
||||
this.processAggregatedTimelineEvents(timelineSet.room, events);
|
||||
|
||||
return timeline;
|
||||
}
|
||||
@@ -5927,7 +5978,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// in the notification timeline set
|
||||
const timelineSet = eventTimeline.getTimelineSet();
|
||||
timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
|
||||
this.processBeaconEvents(timelineSet.room, matrixEvents);
|
||||
this.processAggregatedTimelineEvents(timelineSet.room, matrixEvents);
|
||||
|
||||
// if we've hit the end of the timeline, we need to stop trying to
|
||||
// paginate. We need to keep the 'forwards' token though, to make sure
|
||||
@@ -5969,7 +6020,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
const timelineSet = eventTimeline.getTimelineSet();
|
||||
timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
|
||||
this.processBeaconEvents(room, matrixEvents);
|
||||
this.processAggregatedTimelineEvents(room, matrixEvents);
|
||||
this.processThreadRoots(room, matrixEvents, backwards);
|
||||
|
||||
// if we've hit the end of the timeline, we need to stop trying to
|
||||
@@ -6016,7 +6067,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const originalEvent = await this.fetchRoomEvent(eventTimeline.getRoomId() ?? "", thread.id);
|
||||
timelineSet.addEventsToTimeline([mapper(originalEvent)], true, eventTimeline, null);
|
||||
}
|
||||
this.processBeaconEvents(timelineSet.room, matrixEvents);
|
||||
this.processAggregatedTimelineEvents(timelineSet.room, matrixEvents);
|
||||
|
||||
// if we've hit the end of the timeline, we need to stop trying to
|
||||
// paginate. We need to keep the 'forwards' token though, to make sure
|
||||
@@ -6054,7 +6105,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const timelineSet = eventTimeline.getTimelineSet();
|
||||
const [timelineEvents] = room.partitionThreadedEvents(matrixEvents);
|
||||
timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
|
||||
this.processBeaconEvents(room, timelineEvents);
|
||||
this.processAggregatedTimelineEvents(room, timelineEvents);
|
||||
this.processThreadRoots(
|
||||
room,
|
||||
timelineEvents.filter((it) => it.getServerAggregatedRelation(THREAD_RELATION_TYPE.name)),
|
||||
@@ -6996,10 +7047,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const serverVersions = await this.serverVersionsPromise;
|
||||
this.canSupport = await buildFeatureSupportMap(serverVersions);
|
||||
|
||||
// We can set flag values to use their stable or unstable version
|
||||
const support = this.canSupport.get(Feature.ThreadUnreadNotifications);
|
||||
UNREAD_THREAD_NOTIFICATIONS.setPreferUnstable(support === ServerSupport.Unstable);
|
||||
|
||||
return this.serverVersionsPromise;
|
||||
}
|
||||
|
||||
@@ -9307,12 +9354,21 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* @deprecated use supportsThreads() instead
|
||||
*/
|
||||
public supportsExperimentalThreads(): boolean {
|
||||
logger.warn(`supportsExperimentalThreads() is deprecated, use supportThreads() instead`);
|
||||
return this.clientOpts?.experimentalThreadSupport || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper to determine thread support
|
||||
* @returns a boolean to determine if threads are enabled
|
||||
*/
|
||||
public supportsThreads(): boolean {
|
||||
return this.clientOpts?.threadSupport || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the summary of a room as defined by an initial version of MSC3266 and implemented in Synapse
|
||||
* Proposed at https://github.com/matrix-org/matrix-doc/pull/3266
|
||||
@@ -9327,24 +9383,42 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Processes a list of threaded events and adds them to their respective timelines
|
||||
* @param room - the room the adds the threaded events
|
||||
* @param threadedEvents - an array of the threaded events
|
||||
* @param toStartOfTimeline - the direction in which we want to add the events
|
||||
*/
|
||||
public processThreadEvents(room: Room, threadedEvents: MatrixEvent[], toStartOfTimeline: boolean): void {
|
||||
room.processThreadedEvents(threadedEvents, toStartOfTimeline);
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Processes a list of thread roots and creates a thread model
|
||||
* @param room - the room to create the threads in
|
||||
* @param threadedEvents - an array of thread roots
|
||||
* @param toStartOfTimeline - the direction
|
||||
*/
|
||||
public processThreadRoots(room: Room, threadedEvents: MatrixEvent[], toStartOfTimeline: boolean): void {
|
||||
room.processThreadRoots(threadedEvents, toStartOfTimeline);
|
||||
}
|
||||
|
||||
public processBeaconEvents(room?: Room, events?: MatrixEvent[]): void {
|
||||
this.processAggregatedTimelineEvents(room, events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls aggregation functions for event types that are aggregated
|
||||
* Polls and location beacons
|
||||
* @param room - room the events belong to
|
||||
* @param events - timeline events to be processed
|
||||
* @returns
|
||||
*/
|
||||
public processAggregatedTimelineEvents(room?: Room, events?: MatrixEvent[]): void {
|
||||
if (!events?.length) return;
|
||||
if (!room) return;
|
||||
|
||||
room.currentState.processBeaconEvents(events, this);
|
||||
room.processPollEvents(events);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,10 @@ 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 { Room } from "../models/room";
|
||||
import { IEncryptedEventInfo } from "../crypto/api";
|
||||
|
||||
/**
|
||||
* Common interface for the crypto implementations
|
||||
@@ -54,6 +57,45 @@ 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;
|
||||
|
||||
/**
|
||||
* Perform any background tasks that can be done before a message is ready to
|
||||
* send, in order to speed up sending of the message.
|
||||
*
|
||||
* @param room - the room the event is in
|
||||
*/
|
||||
prepareToEncrypt(room: Room): void;
|
||||
|
||||
/**
|
||||
* Encrypt an event according to the configuration of the room.
|
||||
*
|
||||
* @param event - event to be sent
|
||||
*
|
||||
* @param room - destination room.
|
||||
*
|
||||
* @returns Promise which resolves when the event has been
|
||||
* encrypted, or null if nothing was needed
|
||||
*/
|
||||
encryptEvent(event: MatrixEvent, room: Room): Promise<void>;
|
||||
|
||||
/**
|
||||
* Decrypt a received event
|
||||
*
|
||||
@@ -62,6 +104,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
|
||||
*
|
||||
@@ -89,6 +138,20 @@ export interface SyncCryptoCallbacks {
|
||||
*/
|
||||
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
|
||||
|
||||
/**
|
||||
* Called by the /sync loop whenever an m.room.encryption event is received.
|
||||
*
|
||||
* This is called before RoomStateEvents are emitted for any of the events in the /sync
|
||||
* response (even if the other events technically happened first). This works around a problem
|
||||
* if the client uses a RoomStateEvent (typically a membership event) as a trigger to send a message
|
||||
* in a new room (or one where encryption has been newly enabled): that would otherwise leave the
|
||||
* crypto layer confused because it expects crypto to be set up, but it has not yet been.
|
||||
*
|
||||
* @param room - in which the event was received
|
||||
* @param event - encryption event to be processed
|
||||
*/
|
||||
onCryptoEvent(room: Room, event: MatrixEvent): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called by the /sync loop after each /sync response is processed.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
+258
-158
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015 - 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.
|
||||
@@ -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,
|
||||
@@ -43,6 +43,7 @@ import { IMegolmEncryptedContent, IncomingRoomKeyRequest, IEncryptedContent } fr
|
||||
import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager";
|
||||
import { OlmGroupSessionExtraData } from "../../@types/crypto";
|
||||
import { MatrixError } from "../../http-api";
|
||||
import { immediate } from "../../utils";
|
||||
|
||||
// determine whether the key can be shared with invitees
|
||||
export function isRoomSharedHistory(room: Room): boolean {
|
||||
@@ -73,7 +74,6 @@ export interface IOlmDevice<T = DeviceInfo> {
|
||||
deviceInfo: T;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
export interface IOutboundGroupSessionKey {
|
||||
chain_index: number;
|
||||
key: string;
|
||||
@@ -106,7 +106,6 @@ interface IPayload extends Partial<IMessage> {
|
||||
algorithm?: string;
|
||||
sender_key?: string;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
interface SharedWithData {
|
||||
// The identity key of the device we shared with
|
||||
@@ -223,13 +222,16 @@ export class MegolmEncryption extends EncryptionAlgorithm {
|
||||
private encryptionPreparation?: {
|
||||
promise: Promise<void>;
|
||||
startTime: number;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -946,27 +974,36 @@ export class MegolmEncryption extends EncryptionAlgorithm {
|
||||
* send, in order to speed up sending of the message.
|
||||
*
|
||||
* @param room - the room the event is in
|
||||
* @returns A function that, when called, will stop the preparation
|
||||
*/
|
||||
public prepareToEncrypt(room: Room): void {
|
||||
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`,
|
||||
);
|
||||
return;
|
||||
public prepareToEncrypt(room: Room): () => void {
|
||||
if (room.roomId !== this.roomId) {
|
||||
throw new Error("MegolmEncryption.prepareToEncrypt called on unexpected room");
|
||||
}
|
||||
|
||||
logger.debug(`Preparing to encrypt events for ${this.roomId}`);
|
||||
if (this.encryptionPreparation != null) {
|
||||
// We're already preparing something, so don't do anything else.
|
||||
const elapsedTime = Date.now() - this.encryptionPreparation.startTime;
|
||||
this.prefixedLogger.debug(
|
||||
`Already started preparing to encrypt for this room ${elapsedTime}ms ago, skipping`,
|
||||
);
|
||||
return this.encryptionPreparation.cancel;
|
||||
}
|
||||
|
||||
this.prefixedLogger.debug("Preparing to encrypt events");
|
||||
|
||||
let cancelled = false;
|
||||
const isCancelled = (): boolean => cancelled;
|
||||
|
||||
this.encryptionPreparation = {
|
||||
startTime: Date.now(),
|
||||
promise: (async (): Promise<void> => {
|
||||
try {
|
||||
logger.debug(`Getting devices in ${this.roomId}`);
|
||||
const [devicesInRoom, blocked] = await this.getDevicesInRoom(room);
|
||||
// Attempt to enumerate the devices in room, and gracefully
|
||||
// handle cancellation if it occurs.
|
||||
const getDevicesResult = await this.getDevicesInRoom(room, false, isCancelled);
|
||||
if (getDevicesResult === null) return;
|
||||
const [devicesInRoom, blocked] = getDevicesResult;
|
||||
|
||||
if (this.crypto.globalErrorOnUnknownDevices) {
|
||||
// Drop unknown devices for now. When the message gets sent, we'll
|
||||
@@ -975,17 +1012,26 @@ 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;
|
||||
}
|
||||
})(),
|
||||
|
||||
cancel: (): void => {
|
||||
// The caller has indicated that the process should be cancelled,
|
||||
// so tell the promise that we'd like to halt, and reset the preparation state.
|
||||
cancelled = true;
|
||||
delete this.encryptionPreparation;
|
||||
},
|
||||
};
|
||||
|
||||
return this.encryptionPreparation.cancel;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -994,7 +1040,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.
|
||||
@@ -1134,18 +1180,38 @@ export class MegolmEncryption extends EncryptionAlgorithm {
|
||||
*
|
||||
* @param forceDistributeToUnverified - if set to true will include the unverified devices
|
||||
* even if setting is set to block them (useful for verification)
|
||||
* @param isCancelled - will cause the procedure to abort early if and when it starts
|
||||
* returning `true`. If omitted, cancellation won't happen.
|
||||
*
|
||||
* @returns Promise which resolves to an array whose
|
||||
* first element is a map from userId to deviceId to deviceInfo indicating
|
||||
* @returns Promise which resolves to `null`, or an array whose
|
||||
* first element is a {@link DeviceInfoMap} indicating
|
||||
* the devices that messages should be encrypted to, and whose second
|
||||
* element is a map from userId to deviceId to data indicating the devices
|
||||
* that are in the room but that have been blocked
|
||||
* that are in the room but that have been blocked.
|
||||
* If `isCancelled` is provided and returns `true` while processing, `null`
|
||||
* will be returned.
|
||||
* If `isCancelled` is not provided, the Promise will never resolve to `null`.
|
||||
*/
|
||||
private async getDevicesInRoom(
|
||||
room: Room,
|
||||
forceDistributeToUnverified?: boolean,
|
||||
): Promise<[DeviceInfoMap, IBlockedMap]>;
|
||||
private async getDevicesInRoom(
|
||||
room: Room,
|
||||
forceDistributeToUnverified?: boolean,
|
||||
isCancelled?: () => boolean,
|
||||
): Promise<null | [DeviceInfoMap, IBlockedMap]>;
|
||||
private async getDevicesInRoom(
|
||||
room: Room,
|
||||
forceDistributeToUnverified = false,
|
||||
): Promise<[DeviceInfoMap, IBlockedMap]> {
|
||||
isCancelled?: () => boolean,
|
||||
): Promise<null | [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;
|
||||
});
|
||||
@@ -1165,6 +1231,11 @@ export class MegolmEncryption extends EncryptionAlgorithm {
|
||||
// See https://github.com/vector-im/element-web/issues/2305 for details.
|
||||
const devices = await this.crypto.downloadKeys(roomMembers, false);
|
||||
const blocked: IBlockedMap = {};
|
||||
|
||||
if (isCancelled?.() === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// remove any blocked devices
|
||||
for (const userId in devices) {
|
||||
if (!devices.hasOwnProperty(userId)) {
|
||||
@@ -1177,6 +1248,11 @@ export class MegolmEncryption extends EncryptionAlgorithm {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Yield prior to checking each device so that we don't block
|
||||
// updating/rendering for too long.
|
||||
// See https://github.com/vector-im/element-web/issues/21612
|
||||
if (isCancelled !== undefined) await immediate();
|
||||
if (isCancelled?.() === true) return null;
|
||||
const deviceTrust = this.crypto.checkDeviceTrust(userId, deviceId);
|
||||
|
||||
if (
|
||||
@@ -1216,10 +1292,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]`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1266,7 +1344,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
errorCode = "OLM_UNKNOWN_MESSAGE_INDEX";
|
||||
}
|
||||
|
||||
throw new DecryptionError(errorCode, e ? e.toString() : "Unknown Error: Error is undefined", {
|
||||
throw new DecryptionError(errorCode, e instanceof Error ? e.message : "Unknown Error: Error is undefined", {
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
});
|
||||
}
|
||||
@@ -1287,9 +1365,10 @@ 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}`,
|
||||
`recent session problem with that sender:`,
|
||||
problem,
|
||||
);
|
||||
let problemDescription = PROBLEM_DESCRIPTIONS[problem.type as "no_olm"] || PROBLEM_DESCRIPTIONS.unknown;
|
||||
if (problem.fixed) {
|
||||
@@ -1406,12 +1485,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 +1505,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 +1532,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 +1540,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 +1555,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 +1585,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 +1642,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 +1654,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 +1667,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 +1775,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.log(
|
||||
this.prefixedLogger.log(
|
||||
"sharing keys for session " +
|
||||
body.sender_key +
|
||||
"|" +
|
||||
@@ -1778,7 +1874,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 +1908,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 +1958,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 +2000,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;
|
||||
}
|
||||
|
||||
|
||||
+4
-5
@@ -1202,6 +1202,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*/
|
||||
public async storeSessionBackupPrivateKey(key: ArrayLike<number>): Promise<void> {
|
||||
if (!(key instanceof Uint8Array)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`);
|
||||
}
|
||||
const pickleKey = Buffer.from(this.olmDevice.pickleKey);
|
||||
@@ -2808,11 +2809,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
* @returns Promise which resolves when the event has been
|
||||
* encrypted, or null if nothing was needed
|
||||
*/
|
||||
public async encryptEvent(event: MatrixEvent, room?: Room): Promise<void> {
|
||||
if (!room) {
|
||||
throw new Error("Cannot send encrypted messages in unknown rooms");
|
||||
}
|
||||
|
||||
public async encryptEvent(event: MatrixEvent, room: Room): Promise<void> {
|
||||
const roomId = event.getRoomId()!;
|
||||
|
||||
const alg = this.roomEncryptors.get(roomId);
|
||||
@@ -3433,6 +3430,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.
|
||||
|
||||
@@ -159,6 +159,7 @@ function generateSas(sasBytes: Uint8Array, methods: string[]): IGeneratedSas {
|
||||
const macMethods = {
|
||||
"hkdf-hmac-sha256": "calculate_mac",
|
||||
"org.matrix.msc3783.hkdf-hmac-sha256": "calculate_mac_fixed_base64",
|
||||
"hkdf-hmac-sha256.v2": "calculate_mac_fixed_base64",
|
||||
"hmac-sha256": "calculate_mac_long_kdf",
|
||||
} as const;
|
||||
|
||||
@@ -202,7 +203,12 @@ type KeyAgreement = keyof typeof calculateKeyAgreement;
|
||||
*/
|
||||
const KEY_AGREEMENT_LIST: KeyAgreement[] = ["curve25519-hkdf-sha256", "curve25519"];
|
||||
const HASHES_LIST = ["sha256"];
|
||||
const MAC_LIST: MacMethod[] = ["org.matrix.msc3783.hkdf-hmac-sha256", "hkdf-hmac-sha256", "hmac-sha256"];
|
||||
const MAC_LIST: MacMethod[] = [
|
||||
"hkdf-hmac-sha256.v2",
|
||||
"org.matrix.msc3783.hkdf-hmac-sha256",
|
||||
"hkdf-hmac-sha256",
|
||||
"hmac-sha256",
|
||||
];
|
||||
const SAS_LIST = Object.keys(sasGenerators);
|
||||
|
||||
const KEY_AGREEMENT_SET = new Set(KEY_AGREEMENT_LIST);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import { MatrixError } from "./http-api";
|
||||
const EMAIL_STAGE_TYPE = "m.login.email.identity";
|
||||
const MSISDN_STAGE_TYPE = "m.login.msisdn";
|
||||
|
||||
interface IFlow {
|
||||
export interface UIAFlow {
|
||||
stages: AuthType[];
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@ export interface IAuthData {
|
||||
session?: string;
|
||||
type?: string;
|
||||
completed?: string[];
|
||||
flows?: IFlow[];
|
||||
available_flows?: IFlow[];
|
||||
flows?: UIAFlow[];
|
||||
available_flows?: UIAFlow[];
|
||||
stages?: string[];
|
||||
required_stages?: AuthType[];
|
||||
params?: Record<string, Record<string, any>>;
|
||||
@@ -101,7 +101,7 @@ class NoAuthFlowFoundError extends Error {
|
||||
public name = "NoAuthFlowFoundError";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
|
||||
public constructor(m: string, public readonly required_stages: string[], public readonly flows: IFlow[]) {
|
||||
public constructor(m: string, public readonly required_stages: string[], public readonly flows: UIAFlow[]) {
|
||||
super(m);
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ export class InteractiveAuth {
|
||||
private emailSid?: string;
|
||||
private requestingEmailToken = false;
|
||||
private attemptAuthDeferred: IDeferred<IAuthData> | null = null;
|
||||
private chosenFlow: IFlow | null = null;
|
||||
private chosenFlow: UIAFlow | null = null;
|
||||
private currentStage: string | null = null;
|
||||
|
||||
private emailAttempt = 1;
|
||||
@@ -320,7 +320,7 @@ export class InteractiveAuth {
|
||||
return this.data.params?.[loginType];
|
||||
}
|
||||
|
||||
public getChosenFlow(): IFlow | null {
|
||||
public getChosenFlow(): UIAFlow | null {
|
||||
return this.chosenFlow;
|
||||
}
|
||||
|
||||
@@ -573,7 +573,7 @@ export class InteractiveAuth {
|
||||
* @returns flow
|
||||
* @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found
|
||||
*/
|
||||
private chooseFlow(): IFlow {
|
||||
private chooseFlow(): UIAFlow {
|
||||
const flows = this.data.flows || [];
|
||||
|
||||
// we've been given an email or we've already done an email part
|
||||
@@ -610,7 +610,7 @@ export class InteractiveAuth {
|
||||
* @internal
|
||||
* @returns login type
|
||||
*/
|
||||
private firstUncompletedStage(flow: IFlow): AuthType | undefined {
|
||||
private firstUncompletedStage(flow: UIAFlow): AuthType | undefined {
|
||||
const completed = this.data.completed || [];
|
||||
return flow.stages.find((stageType) => !completed.includes(stageType));
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export * from "./models/event";
|
||||
export * from "./models/room";
|
||||
export * from "./models/event-timeline";
|
||||
export * from "./models/event-timeline-set";
|
||||
export * from "./models/poll";
|
||||
export * from "./models/room-member";
|
||||
export * from "./models/room-state";
|
||||
export * from "./models/user";
|
||||
|
||||
+5
-6
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2015 - 2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015 - 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.
|
||||
@@ -267,7 +267,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
private txnId?: string;
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* A reference to the thread this event belongs to
|
||||
*/
|
||||
private thread?: Thread;
|
||||
@@ -546,7 +545,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Get the event ID of the thread head
|
||||
*/
|
||||
public get threadRootId(): string | undefined {
|
||||
@@ -559,7 +557,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* A helper to check if an event is a thread's head or not
|
||||
*/
|
||||
public get isThreadRoot(): boolean {
|
||||
const threadDetails = this.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
|
||||
@@ -1557,7 +1555,8 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Set the instance of a thread associated with the current event
|
||||
* @param thread - the thread
|
||||
*/
|
||||
public setThread(thread?: Thread): void {
|
||||
if (this.thread) {
|
||||
@@ -1571,7 +1570,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Get the instance of the thread associated with the current event
|
||||
*/
|
||||
public getThread(): Thread | undefined {
|
||||
return this.thread;
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
Copyright 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_POLL_END, M_POLL_RESPONSE } from "../@types/polls";
|
||||
import { MatrixClient } from "../client";
|
||||
import { PollStartEvent } from "../extensible_events_v1/PollStartEvent";
|
||||
import { MatrixEvent } from "./event";
|
||||
import { Relations } from "./relations";
|
||||
import { Room } from "./room";
|
||||
import { TypedEventEmitter } from "./typed-event-emitter";
|
||||
|
||||
export enum PollEvent {
|
||||
New = "Poll.new",
|
||||
End = "Poll.end",
|
||||
Update = "Poll.update",
|
||||
Responses = "Poll.Responses",
|
||||
Destroy = "Poll.Destroy",
|
||||
}
|
||||
|
||||
export type PollEventHandlerMap = {
|
||||
[PollEvent.Update]: (event: MatrixEvent, poll: Poll) => void;
|
||||
[PollEvent.Destroy]: (pollIdentifier: string) => void;
|
||||
[PollEvent.End]: () => void;
|
||||
[PollEvent.Responses]: (responses: Relations) => void;
|
||||
};
|
||||
|
||||
const filterResponseRelations = (
|
||||
relationEvents: MatrixEvent[],
|
||||
pollEndTimestamp: number,
|
||||
): {
|
||||
responseEvents: MatrixEvent[];
|
||||
} => {
|
||||
const responseEvents = relationEvents.filter((event) => {
|
||||
if (event.isDecryptionFailure()) {
|
||||
// @TODO(kerrya) PSG-1023 track and return these
|
||||
return;
|
||||
}
|
||||
return (
|
||||
M_POLL_RESPONSE.matches(event.getType()) &&
|
||||
// From MSC3381:
|
||||
// "Votes sent on or before the end event's timestamp are valid votes"
|
||||
event.getTs() <= pollEndTimestamp
|
||||
);
|
||||
});
|
||||
|
||||
return { responseEvents };
|
||||
};
|
||||
|
||||
export class Poll extends TypedEventEmitter<Exclude<PollEvent, PollEvent.New>, PollEventHandlerMap> {
|
||||
public readonly roomId: string;
|
||||
public readonly pollEvent: PollStartEvent;
|
||||
private _isFetchingResponses = false;
|
||||
private relationsNextBatch: string | undefined;
|
||||
private responses: null | Relations = null;
|
||||
private endEvent: MatrixEvent | undefined;
|
||||
|
||||
public constructor(public readonly rootEvent: MatrixEvent, private matrixClient: MatrixClient, private room: Room) {
|
||||
super();
|
||||
if (!this.rootEvent.getRoomId() || !this.rootEvent.getId()) {
|
||||
throw new Error("Invalid poll start event.");
|
||||
}
|
||||
this.roomId = this.rootEvent.getRoomId()!;
|
||||
this.pollEvent = this.rootEvent.unstableExtensibleEvent as unknown as PollStartEvent;
|
||||
}
|
||||
|
||||
public get pollId(): string {
|
||||
return this.rootEvent.getId()!;
|
||||
}
|
||||
|
||||
public get isEnded(): boolean {
|
||||
return !!this.endEvent;
|
||||
}
|
||||
|
||||
public get isFetchingResponses(): boolean {
|
||||
return this._isFetchingResponses;
|
||||
}
|
||||
|
||||
public async getResponses(): Promise<Relations> {
|
||||
// if we have already fetched some responses
|
||||
// just return them
|
||||
if (this.responses) {
|
||||
return this.responses;
|
||||
}
|
||||
|
||||
// if there is no fetching in progress
|
||||
// start fetching
|
||||
if (!this.isFetchingResponses) {
|
||||
await this.fetchResponses();
|
||||
}
|
||||
// return whatever responses we got from the first page
|
||||
return this.responses!;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param event - event with a relation to the rootEvent
|
||||
* @returns void
|
||||
*/
|
||||
public onNewRelation(event: MatrixEvent): void {
|
||||
if (M_POLL_END.matches(event.getType()) && this.validateEndEvent(event)) {
|
||||
this.endEvent = event;
|
||||
this.refilterResponsesOnEnd();
|
||||
this.emit(PollEvent.End);
|
||||
}
|
||||
|
||||
// wait for poll responses to be initialised
|
||||
if (!this.responses) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pollEndTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER;
|
||||
const { responseEvents } = filterResponseRelations([event], pollEndTimestamp);
|
||||
|
||||
if (responseEvents.length) {
|
||||
responseEvents.forEach((event) => {
|
||||
this.responses!.addEvent(event);
|
||||
});
|
||||
this.emit(PollEvent.Responses, this.responses);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchResponses(): Promise<void> {
|
||||
this._isFetchingResponses = true;
|
||||
|
||||
// we want:
|
||||
// - stable and unstable M_POLL_RESPONSE
|
||||
// - stable and unstable M_POLL_END
|
||||
// so make one api call and filter by event type client side
|
||||
const allRelations = await this.matrixClient.relations(
|
||||
this.roomId,
|
||||
this.rootEvent.getId()!,
|
||||
"m.reference",
|
||||
undefined,
|
||||
{
|
||||
from: this.relationsNextBatch || undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const responses =
|
||||
this.responses ||
|
||||
new Relations("m.reference", M_POLL_RESPONSE.name, this.matrixClient, [M_POLL_RESPONSE.altName!]);
|
||||
|
||||
const pollEndEvent = allRelations.events.find((event) => M_POLL_END.matches(event.getType()));
|
||||
if (this.validateEndEvent(pollEndEvent)) {
|
||||
this.endEvent = pollEndEvent;
|
||||
this.refilterResponsesOnEnd();
|
||||
this.emit(PollEvent.End);
|
||||
}
|
||||
|
||||
const pollCloseTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const { responseEvents } = filterResponseRelations(allRelations.events, pollCloseTimestamp);
|
||||
|
||||
responseEvents.forEach((event) => {
|
||||
responses.addEvent(event);
|
||||
});
|
||||
|
||||
this.relationsNextBatch = allRelations.nextBatch ?? undefined;
|
||||
this.responses = responses;
|
||||
|
||||
// while there are more pages of relations
|
||||
// fetch them
|
||||
if (this.relationsNextBatch) {
|
||||
// don't await
|
||||
// we want to return the first page as soon as possible
|
||||
this.fetchResponses();
|
||||
} else {
|
||||
// no more pages
|
||||
this._isFetchingResponses = false;
|
||||
}
|
||||
|
||||
// emit after updating _isFetchingResponses state
|
||||
this.emit(PollEvent.Responses, this.responses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only responses made before the poll ended are valid
|
||||
* Refilter after an end event is recieved
|
||||
* To ensure responses are valid
|
||||
*/
|
||||
private refilterResponsesOnEnd(): void {
|
||||
if (!this.responses) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pollEndTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER;
|
||||
this.responses.getRelations().forEach((event) => {
|
||||
if (event.getTs() > pollEndTimestamp) {
|
||||
this.responses?.removeEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
this.emit(PollEvent.Responses, this.responses);
|
||||
}
|
||||
|
||||
private validateEndEvent(endEvent?: MatrixEvent): boolean {
|
||||
if (!endEvent) {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Repeated end events are ignored -
|
||||
* only the first (valid) closure event by origin_server_ts is counted.
|
||||
*/
|
||||
if (this.endEvent && this.endEvent.getTs() < endEvent.getTs()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* MSC3381
|
||||
* If a m.poll.end event is received from someone other than the poll creator or user with permission to redact
|
||||
* others' messages in the room, the event must be ignored by clients due to being invalid.
|
||||
*/
|
||||
const roomCurrentState = this.room.currentState;
|
||||
const endEventSender = endEvent.getSender();
|
||||
return (
|
||||
!!endEventSender &&
|
||||
(endEventSender === this.rootEvent.getSender() ||
|
||||
roomCurrentState.maySendRedactionForEvent(this.rootEvent, endEventSender))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 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.
|
||||
@@ -122,7 +122,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
|
||||
*
|
||||
* @param event - The relation event to remove.
|
||||
*/
|
||||
private async removeEvent(event: MatrixEvent): Promise<void> {
|
||||
public async removeEvent(event: MatrixEvent): Promise<void> {
|
||||
if (!this.relations.has(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -964,6 +964,55 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
return guestAccessContent["guest_access"] || GuestAccess.Forbidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the predecessor room based on this room state.
|
||||
*
|
||||
* @param msc3946ProcessDynamicPredecessor - if true, look for an
|
||||
* m.room.predecessor state event and use it if found (MSC3946).
|
||||
* @returns null if this room has no predecessor. Otherwise, returns
|
||||
* the roomId and last eventId of the predecessor room.
|
||||
* If msc3946ProcessDynamicPredecessor is true, use m.predecessor events
|
||||
* as well as m.room.create events to find predecessors.
|
||||
* Note: if an m.predecessor event is used, eventId is null since those
|
||||
* events do not include an event_id property.
|
||||
*/
|
||||
public findPredecessor(
|
||||
msc3946ProcessDynamicPredecessor = false,
|
||||
): { roomId: string; eventId: string | null } | null {
|
||||
// Note: the tests for this function are against Room.findPredecessor,
|
||||
// which just calls through to here.
|
||||
|
||||
if (msc3946ProcessDynamicPredecessor) {
|
||||
const predecessorEvent = this.getStateEvents(EventType.RoomPredecessor, "");
|
||||
if (predecessorEvent) {
|
||||
const roomId = predecessorEvent.getContent()["predecessor_room_id"];
|
||||
let eventId = predecessorEvent.getContent()["last_known_event_id"];
|
||||
if (typeof eventId !== "string") {
|
||||
eventId = null;
|
||||
}
|
||||
if (typeof roomId === "string") {
|
||||
return { roomId, eventId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createEvent = this.getStateEvents(EventType.RoomCreate, "");
|
||||
if (createEvent) {
|
||||
const predecessor = createEvent.getContent()["predecessor"];
|
||||
if (predecessor) {
|
||||
const roomId = predecessor["room_id"];
|
||||
if (typeof roomId === "string") {
|
||||
let eventId = predecessor["event_id"];
|
||||
if (typeof eventId !== "string" || eventId === "") {
|
||||
eventId = null;
|
||||
}
|
||||
return { roomId, eventId };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private updateThirdPartyTokenCache(memberEvent: MatrixEvent): void {
|
||||
if (!memberEvent.getContent().third_party_invite) {
|
||||
return;
|
||||
|
||||
+68
-35
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Optional } from "matrix-events-sdk";
|
||||
import { M_POLL_START, Optional } from "matrix-events-sdk";
|
||||
|
||||
import {
|
||||
EventTimelineSet,
|
||||
@@ -64,7 +64,7 @@ import {
|
||||
import { IStateEventWithRoomId } from "../@types/search";
|
||||
import { RelationsContainer } from "./relations-container";
|
||||
import { ReadReceipt, synthesizeReceipt } from "./read-receipt";
|
||||
import { Feature, ServerSupport } from "../feature";
|
||||
import { Poll, PollEvent } from "./poll";
|
||||
|
||||
// These constants are used as sane defaults when the homeserver doesn't support
|
||||
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
|
||||
@@ -162,7 +162,8 @@ export type RoomEmittedEvents =
|
||||
| BeaconEvent.New
|
||||
| BeaconEvent.Update
|
||||
| BeaconEvent.Destroy
|
||||
| BeaconEvent.LivenessChange;
|
||||
| BeaconEvent.LivenessChange
|
||||
| PollEvent.New;
|
||||
|
||||
export type RoomEventHandlerMap = {
|
||||
/**
|
||||
@@ -289,6 +290,11 @@ export type RoomEventHandlerMap = {
|
||||
[RoomEvent.UnreadNotifications]: (unreadNotifications?: NotificationCount, threadId?: string) => void;
|
||||
[RoomEvent.TimelineRefresh]: (room: Room, eventTimelineSet: EventTimelineSet) => void;
|
||||
[ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void;
|
||||
/**
|
||||
* Fires when a new poll instance is added to the room state
|
||||
* @param poll - the new poll
|
||||
*/
|
||||
[PollEvent.New]: (poll: Poll) => void;
|
||||
} & Pick<ThreadHandlerMap, ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete> &
|
||||
EventTimelineSetHandlerMap &
|
||||
Pick<MatrixEventHandlerMap, MatrixEventEvent.BeforeRedaction> &
|
||||
@@ -317,6 +323,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
*/
|
||||
private unthreadedReceipts = new Map<string, Receipt>();
|
||||
private readonly timelineSets: EventTimelineSet[];
|
||||
public readonly polls: Map<string, Poll> = new Map<string, Poll>();
|
||||
public readonly threadsTimelineSets: EventTimelineSet[] = [];
|
||||
// any filtered timeline sets we're maintaining for this room
|
||||
private readonly filteredTimelineSets: Record<string, EventTimelineSet> = {}; // filter_id: timelineSet
|
||||
@@ -375,7 +382,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
public readonly relations = new RelationsContainer(this.client, this);
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* A collection of events known by the client
|
||||
* This is not a comprehensive list of the threads that exist in this room
|
||||
*/
|
||||
private threads = new Map<string, Thread>();
|
||||
public lastThread?: Thread;
|
||||
@@ -475,7 +483,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
return this.threadTimelineSetsPromise;
|
||||
}
|
||||
|
||||
if (this.client?.supportsExperimentalThreads()) {
|
||||
if (this.client?.supportsThreads()) {
|
||||
try {
|
||||
this.threadTimelineSetsPromise = Promise.all([
|
||||
this.createThreadTimelineSet(),
|
||||
@@ -1290,10 +1298,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
*/
|
||||
public getUnreadNotificationCount(type = NotificationCountType.Total): number {
|
||||
let count = this.getRoomUnreadNotificationCount(type);
|
||||
if (this.client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported) {
|
||||
for (const threadNotification of this.threadNotifications.values()) {
|
||||
count += threadNotification[type] ?? 0;
|
||||
}
|
||||
for (const threadNotification of this.threadNotifications.values()) {
|
||||
count += threadNotification[type] ?? 0;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -1322,7 +1328,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Get one of the notification counts for a thread
|
||||
* @param threadId - the root event ID
|
||||
* @param type - The type of notification count to get. default: 'total'
|
||||
@@ -1334,7 +1339,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Checks if the current room has unread thread notifications
|
||||
* @returns
|
||||
*/
|
||||
@@ -1348,7 +1352,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Swet one of the notification count for a thread
|
||||
* @param threadId - the root event ID
|
||||
* @param type - The type of notification count to get. default: 'total'
|
||||
@@ -1369,7 +1372,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* @returns the notification count type for all the threads in the room
|
||||
*/
|
||||
public get threadsAggregateNotificationType(): NotificationCountType | null {
|
||||
@@ -1385,7 +1387,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Resets the thread notifications for this room
|
||||
*/
|
||||
public resetThreadUnreadNotificationCount(notificationsToKeep?: string[]): void {
|
||||
@@ -1545,14 +1546,16 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Get the instance of the thread associated with the current event
|
||||
* @param eventId - the ID of the current event
|
||||
* @returns a thread instance if known
|
||||
*/
|
||||
public getThread(eventId: string): Thread | null {
|
||||
return this.threads.get(eventId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Get all the known threads in the room
|
||||
*/
|
||||
public getThreads(): Thread[] {
|
||||
return Array.from(this.threads.values());
|
||||
@@ -1819,7 +1822,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* Without server support that means fetching as much at once as the server allows us to.
|
||||
*/
|
||||
public async fetchRoomThreads(): Promise<void> {
|
||||
if (this.threadsReady || !this.client.supportsExperimentalThreads()) {
|
||||
if (this.threadsReady || !this.client.supportsThreads()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1890,6 +1893,38 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
this.threadsReady = true;
|
||||
}
|
||||
|
||||
public async processPollEvents(events: MatrixEvent[]): Promise<void> {
|
||||
const processPollStartEvent = (event: MatrixEvent): void => {
|
||||
if (!M_POLL_START.matches(event.getType())) return;
|
||||
try {
|
||||
const poll = new Poll(event, this.client, this);
|
||||
this.polls.set(event.getId()!, poll);
|
||||
this.emit(PollEvent.New, poll);
|
||||
} catch {}
|
||||
// poll creation can fail for malformed poll start events
|
||||
};
|
||||
|
||||
const processPollRelationEvent = (event: MatrixEvent): void => {
|
||||
const relationEventId = event.relationEventId;
|
||||
if (relationEventId && this.polls.has(relationEventId)) {
|
||||
const poll = this.polls.get(relationEventId);
|
||||
poll?.onNewRelation(event);
|
||||
}
|
||||
};
|
||||
|
||||
const processPollEvent = (event: MatrixEvent): void => {
|
||||
processPollStartEvent(event);
|
||||
processPollRelationEvent(event);
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
try {
|
||||
await this.client.decryptEventIfNeeded(event);
|
||||
processPollEvent(event);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single page of threadlist messages for the specific thread filter
|
||||
* @internal
|
||||
@@ -1964,7 +1999,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
shouldLiveInThread: boolean;
|
||||
threadId?: string;
|
||||
} {
|
||||
if (!this.client?.supportsExperimentalThreads()) {
|
||||
if (!this.client?.supportsThreads()) {
|
||||
return {
|
||||
shouldLiveInRoom: true,
|
||||
shouldLiveInThread: false,
|
||||
@@ -2033,7 +2068,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
|
||||
/**
|
||||
* Adds events to a thread's timeline. Will fire "Thread.update"
|
||||
* @experimental
|
||||
*/
|
||||
public processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
|
||||
events.forEach(this.applyRedaction);
|
||||
@@ -2662,7 +2696,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// Indices to the events array, for readability
|
||||
const ROOM = 0;
|
||||
const THREAD = 1;
|
||||
if (this.client.supportsExperimentalThreads()) {
|
||||
if (this.client.supportsThreads()) {
|
||||
const threadRoots = this.findThreadRoots(events);
|
||||
return events.reduce(
|
||||
(memo, event: MatrixEvent) => {
|
||||
@@ -2991,26 +3025,25 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the ID of the room that was this room's predecessor, or null if
|
||||
* this room has no predecessor.
|
||||
* Find the predecessor of this room.
|
||||
*
|
||||
* @param msc3946ProcessDynamicPredecessor - if true, look for an
|
||||
* m.room.predecessor state event and use it if found (MSC3946).
|
||||
* @returns null if this room has no predecessor. Otherwise, returns
|
||||
* the roomId and last eventId of the predecessor room.
|
||||
* If msc3946ProcessDynamicPredecessor is true, use m.predecessor events
|
||||
* as well as m.room.create events to find predecessors.
|
||||
* Note: if an m.predecessor event is used, eventId is null since those
|
||||
* events do not include an event_id property.
|
||||
*/
|
||||
public findPredecessorRoomId(): string | null {
|
||||
public findPredecessor(
|
||||
msc3946ProcessDynamicPredecessor = false,
|
||||
): { roomId: string; eventId: string | null } | null {
|
||||
const currentState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!currentState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createEvent = currentState.getStateEvents(EventType.RoomCreate, "");
|
||||
if (createEvent) {
|
||||
const predecessor = createEvent.getContent()["predecessor"];
|
||||
if (predecessor) {
|
||||
const roomId = predecessor["room_id"];
|
||||
if (roomId) {
|
||||
return roomId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return currentState.findPredecessor(msc3946ProcessDynamicPredecessor);
|
||||
}
|
||||
|
||||
private roomNameGenerator(state: RoomNameState): string {
|
||||
|
||||
@@ -69,9 +69,6 @@ export function determineFeatureSupport(stable: boolean, unstable: boolean): Fea
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
public static hasServerSideSupport = FeatureSupport.None;
|
||||
public static hasServerSideListSupport = FeatureSupport.None;
|
||||
@@ -572,7 +569,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
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
Copyright 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 { OlmMachine, UserId } from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
|
||||
/**
|
||||
* KeyClaimManager: linearises calls to OlmMachine.getMissingSessions to avoid races
|
||||
*
|
||||
* We have one of these per `RustCrypto` (and hence per `MatrixClient`).
|
||||
*/
|
||||
export class KeyClaimManager {
|
||||
private currentClaimPromise: Promise<void>;
|
||||
private stopped = false;
|
||||
|
||||
public constructor(
|
||||
private readonly olmMachine: OlmMachine,
|
||||
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||
) {
|
||||
this.currentClaimPromise = Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the KeyClaimManager to immediately stop processing requests.
|
||||
*
|
||||
* Any further calls, and any still in the queue, will fail with an error.
|
||||
*/
|
||||
public stop(): void {
|
||||
this.stopped = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of users, attempt to ensure that we have Olm Sessions active with each of their devices
|
||||
*
|
||||
* If we don't have an active olm session, we will claim a one-time key and start one.
|
||||
*
|
||||
* @param userList - list of userIDs to claim
|
||||
*/
|
||||
public ensureSessionsForUsers(userList: Array<UserId>): Promise<void> {
|
||||
// The Rust-SDK requires that we only have one getMissingSessions process in flight at once. This little dance
|
||||
// ensures that, by only having one call to ensureSessionsForUsersInner active at once (and making them
|
||||
// queue up in order).
|
||||
const prom = this.currentClaimPromise.finally(() => this.ensureSessionsForUsersInner(userList));
|
||||
this.currentClaimPromise = prom;
|
||||
return prom;
|
||||
}
|
||||
|
||||
private async ensureSessionsForUsersInner(userList: Array<UserId>): Promise<void> {
|
||||
// bail out quickly if we've been stopped.
|
||||
if (this.stopped) {
|
||||
throw new Error(`Cannot ensure Olm sessions: shutting down`);
|
||||
}
|
||||
const claimRequest = await this.olmMachine.getMissingSessions(userList);
|
||||
if (claimRequest) {
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(claimRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
Copyright 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 {
|
||||
OlmMachine,
|
||||
KeysBackupRequest,
|
||||
KeysClaimRequest,
|
||||
KeysQueryRequest,
|
||||
KeysUploadRequest,
|
||||
RoomMessageRequest,
|
||||
SignatureUploadRequest,
|
||||
ToDeviceRequest,
|
||||
} from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { logger } from "../logger";
|
||||
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { QueryDict } from "../utils";
|
||||
|
||||
/**
|
||||
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
|
||||
*/
|
||||
export interface OutgoingRequest {
|
||||
readonly id: string | undefined;
|
||||
readonly type: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* OutgoingRequestManager: turns `OutgoingRequest`s from the rust sdk into HTTP requests
|
||||
*
|
||||
* We have one of these per `RustCrypto` (and hence per `MatrixClient`), not that it does anything terribly complicated.
|
||||
* It's responsible for:
|
||||
*
|
||||
* * holding the reference to the `MatrixHttpApi`
|
||||
* * turning `OutgoingRequest`s from the rust backend into HTTP requests, and sending them
|
||||
* * sending the results of such requests back to the rust backend.
|
||||
*/
|
||||
export class OutgoingRequestProcessor {
|
||||
public constructor(
|
||||
private readonly olmMachine: OlmMachine,
|
||||
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
) {}
|
||||
|
||||
public async makeOutgoingRequest(msg: OutgoingRequest): Promise<void> {
|
||||
let resp: string;
|
||||
|
||||
/* refer https://docs.rs/matrix-sdk-crypto/0.6.0/matrix_sdk_crypto/requests/enum.OutgoingRequests.html
|
||||
* for the complete list of request types
|
||||
*/
|
||||
if (msg instanceof KeysUploadRequest) {
|
||||
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/upload", {}, msg.body);
|
||||
} else if (msg instanceof KeysQueryRequest) {
|
||||
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/query", {}, msg.body);
|
||||
} else if (msg instanceof KeysClaimRequest) {
|
||||
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/claim", {}, msg.body);
|
||||
} else if (msg instanceof SignatureUploadRequest) {
|
||||
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/signatures/upload", {}, msg.body);
|
||||
} else if (msg instanceof KeysBackupRequest) {
|
||||
resp = await this.rawJsonRequest(Method.Put, "/_matrix/client/v3/room_keys/keys", {}, msg.body);
|
||||
} else if (msg instanceof ToDeviceRequest) {
|
||||
const path =
|
||||
`/_matrix/client/v3/sendToDevice/${encodeURIComponent(msg.event_type)}/` +
|
||||
encodeURIComponent(msg.txn_id);
|
||||
resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
|
||||
} else if (msg instanceof RoomMessageRequest) {
|
||||
const path =
|
||||
`/_matrix/client/v3/room/${encodeURIComponent(msg.room_id)}/send/` +
|
||||
`${encodeURIComponent(msg.event_type)}/${encodeURIComponent(msg.txn_id)}`;
|
||||
resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
|
||||
} else {
|
||||
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
|
||||
resp = "";
|
||||
}
|
||||
|
||||
if (msg.id) {
|
||||
await this.olmMachine.markRequestAsSent(msg.id, msg.type, resp);
|
||||
}
|
||||
}
|
||||
|
||||
private async rawJsonRequest(method: Method, path: string, queryParams: QueryDict, body: string): Promise<string> {
|
||||
const opts = {
|
||||
// inhibit the JSON stringification and parsing within HttpApi.
|
||||
json: false,
|
||||
|
||||
// nevertheless, we are sending, and accept, JSON.
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
|
||||
// we use the full prefix
|
||||
prefix: "",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.http.authedRequest<string>(method, path, queryParams, body, opts);
|
||||
logger.info(`rust-crypto: successfully made HTTP request: ${method} ${path}`);
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.warn(`rust-crypto: error making HTTP request: ${method} ${path}: ${e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
Copyright 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 { EncryptionSettings, OlmMachine, RoomId, UserId } from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { EventType } from "../@types/event";
|
||||
import { IContent, MatrixEvent } from "../models/event";
|
||||
import { Room } from "../models/room";
|
||||
import { logger, PrefixedLogger } from "../logger";
|
||||
import { KeyClaimManager } from "./KeyClaimManager";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
|
||||
/**
|
||||
* RoomEncryptor: responsible for encrypting messages to a given room
|
||||
*/
|
||||
export class RoomEncryptor {
|
||||
private readonly prefixedLogger: PrefixedLogger;
|
||||
|
||||
/**
|
||||
* @param olmMachine - The rust-sdk's OlmMachine
|
||||
* @param keyClaimManager - Our KeyClaimManager, which manages the queue of one-time-key claim requests
|
||||
* @param room - The room we want to encrypt for
|
||||
* @param encryptionSettings - body of the m.room.encryption event currently in force in this room
|
||||
*/
|
||||
public constructor(
|
||||
private readonly olmMachine: OlmMachine,
|
||||
private readonly keyClaimManager: KeyClaimManager,
|
||||
private readonly room: Room,
|
||||
private encryptionSettings: IContent,
|
||||
) {
|
||||
this.prefixedLogger = logger.withPrefix(`[${room.roomId} encryption]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new `m.room.encryption` event in this room
|
||||
*
|
||||
* @param config - The content of the encryption event
|
||||
*/
|
||||
public onCryptoEvent(config: IContent): void {
|
||||
if (JSON.stringify(this.encryptionSettings) != JSON.stringify(config)) {
|
||||
this.prefixedLogger.error(`Ignoring m.room.encryption event which requests a change of config`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new `m.room.member` event in this room
|
||||
*
|
||||
* @param member - new membership state
|
||||
*/
|
||||
public onRoomMembership(member: RoomMember): void {
|
||||
this.prefixedLogger.debug(`${member.membership} event for ${member.userId}`);
|
||||
|
||||
if (
|
||||
member.membership == "join" ||
|
||||
(member.membership == "invite" && this.room.shouldEncryptForInvitedMembers())
|
||||
) {
|
||||
// make sure we are tracking the deviceList for this user
|
||||
this.prefixedLogger.debug(`starting to track devices for: ${member.userId}`);
|
||||
this.olmMachine.updateTrackedUsers([new UserId(member.userId)]);
|
||||
}
|
||||
|
||||
// TODO: handle leaves (including our own)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare to encrypt events in this room.
|
||||
*
|
||||
* This ensures that we have a megolm session ready to use and that we have shared its key with all the devices
|
||||
* in the room.
|
||||
*/
|
||||
public async ensureEncryptionSession(): Promise<void> {
|
||||
if (this.encryptionSettings.algorithm !== "m.megolm.v1.aes-sha2") {
|
||||
throw new Error(
|
||||
`Cannot encrypt in ${this.room.roomId} for unsupported algorithm '${this.encryptionSettings.algorithm}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const members = await this.room.getEncryptionTargetMembers();
|
||||
this.prefixedLogger.debug(
|
||||
`Encrypting for users (shouldEncryptForInvitedMembers: ${this.room.shouldEncryptForInvitedMembers()}):`,
|
||||
members.map((u) => `${u.userId} (${u.membership})`),
|
||||
);
|
||||
|
||||
const userList = members.map((u) => new UserId(u.userId));
|
||||
await this.keyClaimManager.ensureSessionsForUsers(userList);
|
||||
|
||||
const rustEncryptionSettings = new EncryptionSettings();
|
||||
/* FIXME historyVisibility, rotation, etc */
|
||||
|
||||
await this.olmMachine.shareRoomKey(new RoomId(this.room.roomId), userList, rustEncryptionSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt an event for this room
|
||||
*
|
||||
* This will ensure that we have a megolm session for this room, share it with the devices in the room, and
|
||||
* then encrypt the event using the session.
|
||||
*
|
||||
* @param event - Event to be encrypted.
|
||||
*/
|
||||
public async encryptEvent(event: MatrixEvent): Promise<void> {
|
||||
await this.ensureEncryptionSession();
|
||||
|
||||
const encryptedContent = await this.olmMachine.encryptRoomEvent(
|
||||
new RoomId(this.room.roomId),
|
||||
event.getType(),
|
||||
JSON.stringify(event.getContent()),
|
||||
);
|
||||
|
||||
event.makeEncrypted(
|
||||
EventType.RoomMessageEncrypted,
|
||||
JSON.parse(encryptedContent),
|
||||
this.olmMachine.identityKeys.curve25519.toBase64(),
|
||||
this.olmMachine.identityKeys.ed25519.toBase64(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { RustCrypto } from "./rust-crypto";
|
||||
import { logger } from "../logger";
|
||||
import { CryptoBackend } from "../common-crypto/CryptoBackend";
|
||||
import { RUST_SDK_STORE_PREFIX } from "./constants";
|
||||
import { IHttpOpts, MatrixHttpApi } from "../http-api";
|
||||
|
||||
@@ -26,7 +25,7 @@ export async function initRustCrypto(
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
): Promise<CryptoBackend> {
|
||||
): Promise<RustCrypto> {
|
||||
// initialise the rust matrix-sdk-crypto-js, if it hasn't already been done
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
|
||||
|
||||
+146
-67
@@ -15,29 +15,20 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import {
|
||||
KeysBackupRequest,
|
||||
KeysClaimRequest,
|
||||
KeysQueryRequest,
|
||||
KeysUploadRequest,
|
||||
SignatureUploadRequest,
|
||||
} from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
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 { Room } from "../models/room";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { logger } from "../logger";
|
||||
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { QueryDict } from "../utils";
|
||||
|
||||
/**
|
||||
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
|
||||
*/
|
||||
interface OutgoingRequest {
|
||||
readonly id: string | undefined;
|
||||
readonly type: number;
|
||||
}
|
||||
import { IHttpOpts, MatrixHttpApi } from "../http-api";
|
||||
import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
|
||||
import { RoomEncryptor } from "./RoomEncryptor";
|
||||
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { KeyClaimManager } from "./KeyClaimManager";
|
||||
|
||||
/**
|
||||
* An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
|
||||
@@ -52,12 +43,27 @@ export class RustCrypto implements CryptoBackend {
|
||||
/** whether {@link outgoingRequestLoop} is currently running */
|
||||
private outgoingRequestLoopRunning = false;
|
||||
|
||||
/** mapping of roomId → encryptor class */
|
||||
private roomEncryptors: Record<string, RoomEncryptor> = {};
|
||||
|
||||
private keyClaimManager: KeyClaimManager;
|
||||
private outgoingRequestProcessor: OutgoingRequestProcessor;
|
||||
|
||||
public constructor(
|
||||
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
|
||||
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
_userId: string,
|
||||
_deviceId: string,
|
||||
) {}
|
||||
) {
|
||||
this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http);
|
||||
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// CryptoBackend implementation
|
||||
//
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
public stop(): void {
|
||||
// stop() may be called multiple times, but attempting to close() the OlmMachine twice
|
||||
@@ -67,15 +73,78 @@ export class RustCrypto implements CryptoBackend {
|
||||
}
|
||||
this.stopped = true;
|
||||
|
||||
this.keyClaimManager.stop();
|
||||
|
||||
// make sure we close() the OlmMachine; doing so means that all the Rust objects will be
|
||||
// cleaned up; in particular, the indexeddb connections will be closed, which means they
|
||||
// can then be deleted.
|
||||
this.olmMachine.close();
|
||||
}
|
||||
|
||||
public prepareToEncrypt(room: Room): void {
|
||||
const encryptor = this.roomEncryptors[room.roomId];
|
||||
|
||||
if (encryptor) {
|
||||
encryptor.ensureEncryptionSession();
|
||||
}
|
||||
}
|
||||
|
||||
public async encryptEvent(event: MatrixEvent, _room: Room): Promise<void> {
|
||||
const roomId = event.getRoomId()!;
|
||||
const encryptor = this.roomEncryptors[roomId];
|
||||
|
||||
if (!encryptor) {
|
||||
throw new Error(`Cannot encrypt event in unconfigured room ${roomId}`);
|
||||
}
|
||||
|
||||
await encryptor.encryptEvent(event);
|
||||
}
|
||||
|
||||
public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
|
||||
await this.olmMachine.decryptRoomEvent("event", new RustSdkCryptoJs.RoomId("room"));
|
||||
throw new Error("not implemented");
|
||||
const roomId = event.getRoomId();
|
||||
if (!roomId) {
|
||||
// presumably, a to-device message. These are normally decrypted in preprocessToDeviceMessages
|
||||
// so the fact it has come back here suggests that decryption failed.
|
||||
//
|
||||
// once we drop support for the libolm crypto implementation, we can stop passing to-device messages
|
||||
// through decryptEvent and hence get rid of this case.
|
||||
throw new Error("to-device event was not decrypted in preprocessToDeviceMessages");
|
||||
}
|
||||
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 RustSdkCryptoJs.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 +157,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
|
||||
@@ -113,6 +192,30 @@ export class RustCrypto implements CryptoBackend {
|
||||
return JSON.parse(result);
|
||||
}
|
||||
|
||||
/** called by the sync loop on m.room.encrypted events
|
||||
*
|
||||
* @param room - in which the event was received
|
||||
* @param event - encryption event to be processed
|
||||
*/
|
||||
public async onCryptoEvent(room: Room, event: MatrixEvent): Promise<void> {
|
||||
const config = event.getContent();
|
||||
|
||||
const existingEncryptor = this.roomEncryptors[room.roomId];
|
||||
if (existingEncryptor) {
|
||||
existingEncryptor.onCryptoEvent(config);
|
||||
} else {
|
||||
this.roomEncryptors[room.roomId] = new RoomEncryptor(this.olmMachine, this.keyClaimManager, room, config);
|
||||
}
|
||||
|
||||
// start tracking devices for any users already known to be in this room.
|
||||
const members = await room.getEncryptionTargetMembers();
|
||||
logger.debug(
|
||||
`[${room.roomId} encryption] starting to track devices for: `,
|
||||
members.map((u) => `${u.userId} (${u.membership})`),
|
||||
);
|
||||
await this.olmMachine.updateTrackedUsers(members.map((u) => new RustSdkCryptoJs.UserId(u.userId)));
|
||||
}
|
||||
|
||||
/** called by the sync loop after processing each sync.
|
||||
*
|
||||
* TODO: figure out something equivalent for sliding sync.
|
||||
@@ -125,6 +228,27 @@ export class RustCrypto implements CryptoBackend {
|
||||
this.outgoingRequestLoop();
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Other public functions
|
||||
//
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/** called by the MatrixClient on a room membership event
|
||||
*
|
||||
* @param event - The matrix event which caused this event to fire.
|
||||
* @param member - The member whose RoomMember.membership changed.
|
||||
* @param oldMembership - The previous membership state. Null if it's a new member.
|
||||
*/
|
||||
public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {
|
||||
const enc = this.roomEncryptors[event.getRoomId()!];
|
||||
if (!enc) {
|
||||
// not encrypting in this room
|
||||
return;
|
||||
}
|
||||
enc.onRoomMembership(member);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Outgoing requests
|
||||
@@ -144,7 +268,7 @@ export class RustCrypto implements CryptoBackend {
|
||||
return;
|
||||
}
|
||||
for (const msg of outgoingRequests) {
|
||||
await this.doOutgoingRequest(msg as OutgoingRequest);
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(msg as OutgoingRequest);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -153,49 +277,4 @@ export class RustCrypto implements CryptoBackend {
|
||||
this.outgoingRequestLoopRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async doOutgoingRequest(msg: OutgoingRequest): Promise<void> {
|
||||
let resp: string;
|
||||
|
||||
/* refer https://docs.rs/matrix-sdk-crypto/0.6.0/matrix_sdk_crypto/requests/enum.OutgoingRequests.html
|
||||
* for the complete list of request types
|
||||
*/
|
||||
if (msg instanceof KeysUploadRequest) {
|
||||
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/upload", {}, msg.body);
|
||||
} else if (msg instanceof KeysQueryRequest) {
|
||||
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/query", {}, msg.body);
|
||||
} else if (msg instanceof KeysClaimRequest) {
|
||||
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/claim", {}, msg.body);
|
||||
} else if (msg instanceof SignatureUploadRequest) {
|
||||
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/signatures/upload", {}, msg.body);
|
||||
} else if (msg instanceof KeysBackupRequest) {
|
||||
resp = await this.rawJsonRequest(Method.Put, "/_matrix/client/v3/room_keys/keys", {}, msg.body);
|
||||
} else {
|
||||
// TODO: ToDeviceRequest, RoomMessageRequest
|
||||
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
|
||||
resp = "";
|
||||
}
|
||||
|
||||
if (msg.id) {
|
||||
await this.olmMachine.markRequestAsSent(msg.id, msg.type, resp);
|
||||
}
|
||||
}
|
||||
|
||||
private async rawJsonRequest(method: Method, path: string, queryParams: QueryDict, body: string): Promise<string> {
|
||||
const opts = {
|
||||
// inhibit the JSON stringification and parsing within HttpApi.
|
||||
json: false,
|
||||
|
||||
// nevertheless, we are sending, and accept, JSON.
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
|
||||
// we use the full prefix
|
||||
prefix: "",
|
||||
};
|
||||
|
||||
return await this.http.authedRequest<string>(method, path, queryParams, body, opts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -736,8 +736,8 @@ export class SlidingSyncSdk {
|
||||
|
||||
const processRoomEvent = async (e: MatrixEvent): Promise<void> => {
|
||||
client.emit(ClientEvent.Event, e);
|
||||
if (e.isState() && e.getType() == EventType.RoomEncryption && this.syncOpts.crypto) {
|
||||
await this.syncOpts.crypto.onCryptoEvent(room, e);
|
||||
if (e.isState() && e.getType() == EventType.RoomEncryption && this.syncOpts.cryptoCallbacks) {
|
||||
await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+111
-84
@@ -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);
|
||||
|
||||
+8
-7
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2015 - 2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015 - 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.
|
||||
@@ -198,7 +198,7 @@ export function defaultClientOpts(opts?: IStoredClientOpts): IStoredClientOpts {
|
||||
resolveInvitesToProfiles: false,
|
||||
pollTimeout: 30 * 1000,
|
||||
pendingEventOrdering: PendingEventOrdering.Chronological,
|
||||
experimentalThreadSupport: false,
|
||||
threadSupport: false,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
@@ -1254,11 +1254,12 @@ export class SyncApi {
|
||||
|
||||
const inviter = room.currentState.getStateEvents(EventType.RoomMember, client.getUserId()!)?.getSender();
|
||||
|
||||
if (client.isCryptoEnabled()) {
|
||||
const parkedHistory = await client.crypto!.cryptoStore.takeParkedSharedHistory(room.roomId);
|
||||
const crypto = client.crypto;
|
||||
if (crypto) {
|
||||
const parkedHistory = await crypto.cryptoStore.takeParkedSharedHistory(room.roomId);
|
||||
for (const parked of parkedHistory) {
|
||||
if (parked.senderId === inviter) {
|
||||
await client.crypto!.olmDevice.addInboundGroupSession(
|
||||
await crypto.olmDevice.addInboundGroupSession(
|
||||
room.roomId,
|
||||
parked.senderKey,
|
||||
parked.forwardingCurve25519KeyChain,
|
||||
@@ -1408,10 +1409,10 @@ export class SyncApi {
|
||||
// avoids a race condition if the application tries to send a message after the
|
||||
// state event is processed, but before crypto is enabled, which then causes the
|
||||
// crypto layer to complain.
|
||||
if (this.syncOpts.crypto) {
|
||||
if (this.syncOpts.cryptoCallbacks) {
|
||||
for (const e of stateEvents.concat(events)) {
|
||||
if (e.isState() && e.getType() === EventType.RoomEncryption && e.getStateKey() === "") {
|
||||
await this.syncOpts.crypto.onCryptoEvent(room, e);
|
||||
await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -1,6 +1,5 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015, 2016, 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.
|
||||
@@ -392,13 +391,22 @@ export function ensureNoTrailingSlash(url?: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a promise which resolves with a given value after the given number of ms
|
||||
/**
|
||||
* Returns a promise which resolves with a given value after the given number of ms
|
||||
*/
|
||||
export function sleep<T>(ms: number, value?: T): Promise<T> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms, value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise/async version of {@link setImmediate}.
|
||||
*/
|
||||
export function immediate(): Promise<void> {
|
||||
return new Promise(setImmediate);
|
||||
}
|
||||
|
||||
export function isNullOrUndefined(val: any): boolean {
|
||||
return val === null || val === undefined;
|
||||
}
|
||||
|
||||
+78
-36
@@ -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
|
||||
@@ -387,6 +387,10 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
private opponentSessionId?: string;
|
||||
public groupCallId?: string;
|
||||
|
||||
// Used to keep the timer for the delay before actually stopping our
|
||||
// video track after muting (see setLocalVideoMuted)
|
||||
private stopVideoTrackTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
/**
|
||||
* Construct a new Matrix Call.
|
||||
* @param opts - Config options.
|
||||
@@ -480,7 +484,9 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
}
|
||||
|
||||
public get type(): CallType {
|
||||
return this.hasLocalUserMediaVideoTrack || this.hasRemoteUserMediaVideoTrack ? CallType.Video : CallType.Voice;
|
||||
// we may want to look for a video receiver here rather than a track to match the
|
||||
// sender behaviour, although in practice they should be the same thing
|
||||
return this.hasUserMediaVideoSender || this.hasRemoteUserMediaVideoTrack ? CallType.Video : CallType.Voice;
|
||||
}
|
||||
|
||||
public get hasLocalUserMediaVideoTrack(): boolean {
|
||||
@@ -503,6 +509,14 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
});
|
||||
}
|
||||
|
||||
private get hasUserMediaAudioSender(): boolean {
|
||||
return Boolean(this.transceivers.get(getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, "audio"))?.sender);
|
||||
}
|
||||
|
||||
private get hasUserMediaVideoSender(): boolean {
|
||||
return Boolean(this.transceivers.get(getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, "video"))?.sender);
|
||||
}
|
||||
|
||||
public get localUsermediaFeed(): CallFeed | undefined {
|
||||
return this.getLocalFeeds().find((feed) => feed.purpose === SDPStreamMetadataPurpose.Usermedia);
|
||||
}
|
||||
@@ -786,9 +800,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!");
|
||||
}
|
||||
@@ -1292,18 +1306,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
this.localUsermediaStream!.addTrack(track);
|
||||
}
|
||||
|
||||
// Secondly, we remove tracks that we no longer need from the peer
|
||||
// connection, if any. This only happens when we mute the video atm.
|
||||
// This will change the transceiver direction to "inactive" and
|
||||
// therefore cause re-negotiation.
|
||||
for (const kind of ["audio", "video"]) {
|
||||
const sender = this.transceivers.get(getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, kind))?.sender;
|
||||
// Only remove the track if we aren't going to immediately replace it
|
||||
if (sender && !stream.getTracks().find((t) => t.kind === kind)) {
|
||||
this.peerConn?.removeTrack(sender);
|
||||
}
|
||||
}
|
||||
// Thirdly, we replace the old tracks, if possible.
|
||||
// Then replace the old tracks, if possible.
|
||||
for (const track of stream.getTracks()) {
|
||||
const tKey = getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, track.kind);
|
||||
|
||||
@@ -1344,9 +1347,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!");
|
||||
}
|
||||
@@ -1361,22 +1364,51 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
*/
|
||||
public async setLocalVideoMuted(muted: boolean): Promise<boolean> {
|
||||
logger.log(`call ${this.callId} setLocalVideoMuted ${muted}`);
|
||||
|
||||
// if we were still thinking about stopping and removing the video
|
||||
// track: don't, because we want it back.
|
||||
if (!muted && this.stopVideoTrackTimer !== undefined) {
|
||||
clearTimeout(this.stopVideoTrackTimer);
|
||||
this.stopVideoTrackTimer = undefined;
|
||||
}
|
||||
|
||||
if (!(await this.client.getMediaHandler().hasVideoDevice())) {
|
||||
return this.isLocalVideoMuted();
|
||||
}
|
||||
|
||||
if (!this.hasLocalUserMediaVideoTrack && !muted) {
|
||||
if (!this.hasUserMediaVideoSender && !muted) {
|
||||
await this.upgradeCall(false, true);
|
||||
return this.isLocalVideoMuted();
|
||||
}
|
||||
if (this.opponentSupportsSDPStreamMetadata()) {
|
||||
const stream = await this.client.getMediaHandler().getUserMediaStream(true, !muted);
|
||||
|
||||
// we may not have a video track - if not, re-request usermedia
|
||||
if (!muted && this.localUsermediaStream!.getVideoTracks().length === 0) {
|
||||
const stream = await this.client.getMediaHandler().getUserMediaStream(true, true);
|
||||
await this.updateLocalUsermediaStream(stream);
|
||||
} else {
|
||||
this.localUsermediaFeed?.setAudioVideoMuted(null, muted);
|
||||
}
|
||||
|
||||
this.localUsermediaFeed?.setAudioVideoMuted(null, muted);
|
||||
|
||||
this.updateMuteStatus();
|
||||
await this.sendMetadataUpdate();
|
||||
|
||||
// if we're muting video, set a timeout to stop & remove the video track so we release
|
||||
// the camera. We wait a short time to do this because when we disable a track, WebRTC
|
||||
// will send black video for it. If we just stop and remove it straight away, the video
|
||||
// will just freeze which means that when we unmute video, the other side will briefly
|
||||
// get a static frame of us from before we muted. This way, the still frame is just black.
|
||||
// A very small delay is not always enough so the theory here is that it needs to be long
|
||||
// enough for WebRTC to encode a frame: 120ms should be long enough even if we're only
|
||||
// doing 10fps.
|
||||
if (muted) {
|
||||
this.stopVideoTrackTimer = setTimeout(() => {
|
||||
for (const t of this.localUsermediaStream!.getVideoTracks()) {
|
||||
t.stop();
|
||||
this.localUsermediaStream!.removeTrack(t);
|
||||
}
|
||||
}, 120);
|
||||
}
|
||||
|
||||
return this.isLocalVideoMuted();
|
||||
}
|
||||
|
||||
@@ -1404,7 +1436,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
return this.isMicrophoneMuted();
|
||||
}
|
||||
|
||||
if (!this.hasLocalUserMediaAudioTrack && !muted) {
|
||||
if (!muted && (!this.hasUserMediaAudioSender || !this.hasLocalUserMediaAudioTrack)) {
|
||||
await this.upgradeCall(true, false);
|
||||
return this.isMicrophoneMuted();
|
||||
}
|
||||
@@ -1496,6 +1528,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
this.localUsermediaStream!.id
|
||||
} micShouldBeMuted ${micShouldBeMuted} vidShouldBeMuted ${vidShouldBeMuted}`,
|
||||
);
|
||||
|
||||
setTracksEnabled(this.localUsermediaStream!.getAudioTracks(), !micShouldBeMuted);
|
||||
setTracksEnabled(this.localUsermediaStream!.getVideoTracks(), !vidShouldBeMuted);
|
||||
}
|
||||
@@ -1610,25 +1643,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(";"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2302,11 +2335,16 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
|
||||
const userId = this.invitee || this.getOpponentMember()!.userId;
|
||||
if (this.client.getUseE2eForGroupCall()) {
|
||||
if (!this.opponentDeviceInfo) {
|
||||
logger.warn(`Call ${this.callId} sendVoipEvent() failed: we do not have opponentDeviceInfo`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.client.encryptAndSendToDevices(
|
||||
[
|
||||
{
|
||||
userId,
|
||||
deviceInfo: this.opponentDeviceInfo!,
|
||||
deviceInfo: this.opponentDeviceInfo,
|
||||
},
|
||||
],
|
||||
{
|
||||
@@ -2417,7 +2455,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 +2496,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> {
|
||||
@@ -2477,6 +2515,10 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
clearInterval(this.callLengthInterval);
|
||||
this.callLengthInterval = undefined;
|
||||
}
|
||||
if (this.stopVideoTrackTimer !== undefined) {
|
||||
clearTimeout(this.stopVideoTrackTimer);
|
||||
this.stopVideoTrackTimer = undefined;
|
||||
}
|
||||
|
||||
for (const [stream, listener] of this.removeTrackListeners) {
|
||||
stream.removeEventListener("removetrack", listener);
|
||||
@@ -2504,7 +2546,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:
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user