Compare commits
94 Commits
v23.0.0
...
v23.1.0-rc.2
| Author | SHA1 | Date | |
|---|---|---|---|
| c10152e098 | |||
| 6b7efbcd91 | |||
| f35298a326 | |||
| 1d87f5b163 | |||
| 3e97067b3e | |||
| 3ce582d004 | |||
| 3e48c76a77 | |||
| 8e29f8ead0 | |||
| 185ded4ebc | |||
| 3564a3546f | |||
| c6090325b3 | |||
| 999e355136 | |||
| 7de4164444 | |||
| e2ce379b56 | |||
| 424212cd65 | |||
| c7c16256df | |||
| 8a4c95ee72 | |||
| c3d422f5fb | |||
| fdb80ad259 | |||
| 981acf0044 | |||
| f00f70bfb8 | |||
| 12cc7be31c | |||
| 628bcbf33a | |||
| c4ca0b2e07 | |||
| d7442147b9 | |||
| b1566ee540 | |||
| ca98d9ff11 | |||
| bba4a35665 | |||
| 896f6227a0 | |||
| 4d10cf3074 | |||
| bb23df9423 | |||
| d02559cf3c | |||
| ec6272aa3d | |||
| 695b773f8b | |||
| 030abe1563 | |||
| 22f10f71b8 | |||
| 9ca3e7272e | |||
| 64119ef915 | |||
| 9ac7165e99 | |||
| 6168cedf32 | |||
| 7c34deecb6 | |||
| cef5507ab1 | |||
| 9b372d23ca | |||
| e9fef19c8f | |||
| 9ebc91aa5a | |||
| fcb75d547e | |||
| 33c9af952e | |||
| 3b66b28e71 | |||
| 7d37bb1edb | |||
| 0f717a9306 | |||
| ce776b9989 | |||
| ff1b0e51ea | |||
| 21e66a5c34 | |||
| aead401005 | |||
| af9525ed5f | |||
| 1ebcac37cc | |||
| 51a4cc5e18 | |||
| 48baa6315c | |||
| ec2405ac99 | |||
| 61e2606bc4 | |||
| 45f6c5b079 | |||
| b83c372848 | |||
| 6d58a54039 | |||
| 3872c5f099 | |||
| 4f86eee250 | |||
| 618242ef3c | |||
| 96ee5b1256 | |||
| 8af0ff111b | |||
| a04800f030 | |||
| 4683fbe848 | |||
| c973b26fa2 | |||
| 7b96c730b8 | |||
| f8bf6083de | |||
| 447319737a | |||
| 39e127b4e3 | |||
| 15ef8fabb7 | |||
| df42014ef5 | |||
| b765b18381 | |||
| b2a10e6db3 | |||
| a0aa5074ed | |||
| 70a033c2fd | |||
| fcf12b49e3 | |||
| 11ac3d9e58 | |||
| 071d5e71e4 | |||
| 9841f92415 | |||
| ed91bd9c11 | |||
| c953fc9fb7 | |||
| bf78a64d82 | |||
| 9a98e8008f | |||
| ad8bb5d2cd | |||
| 9a731cdf4f | |||
| d1ede036e2 | |||
| d3f08fec03 | |||
| 3a1897629a |
@@ -38,4 +38,5 @@ cee7f7a280a8c20bafc21c0a2911f60851f7a7ca
|
||||
7ed65407e6cdf292ce3cf659310c68d19dcd52b2
|
||||
# Switch to ESLint from JSHint (Google eslint rules as a base)
|
||||
e057956ede9ad1a931ff8050c411aca7907e0394
|
||||
|
||||
# prettier
|
||||
349c2c2587c2885bb69eda4aa078b5383724cf5e
|
||||
|
||||
@@ -32,9 +32,10 @@ jobs:
|
||||
ref: gh-pages
|
||||
|
||||
- name: 🔪 Prepare
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
tag="${{ github.ref_name }}"
|
||||
VERSION="${tag#v}"
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
[ ! -e "$VERSION" ] || rm -r $VERSION
|
||||
cp -r $RUNNER_TEMP/_docs/ $VERSION
|
||||
|
||||
|
||||
@@ -1,3 +1,37 @@
|
||||
Changes in [23.1.0-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.1.0-rc.2) (2023-01-12)
|
||||
============================================================================================================
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix browser entrypoint ([\#3051](https://github.com/matrix-org/matrix-js-sdk/pull/3051)). Fixes #3013.
|
||||
|
||||
Changes in [23.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.1.0-rc.1) (2023-01-11)
|
||||
============================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Remove extensible events v1 field population on legacy events ([\#3040](https://github.com/matrix-org/matrix-js-sdk/pull/3040)).
|
||||
|
||||
## ✨ Features
|
||||
* Improve hasUserReadEvent and getUserReadUpTo realibility with threads ([\#3031](https://github.com/matrix-org/matrix-js-sdk/pull/3031)). Fixes vector-im/element-web#24164.
|
||||
* Remove video track when muting video ([\#3028](https://github.com/matrix-org/matrix-js-sdk/pull/3028)). Fixes vector-im/element-call#209.
|
||||
* Make poll start event type available (PSG-962) ([\#3034](https://github.com/matrix-org/matrix-js-sdk/pull/3034)).
|
||||
* Add alt event type matching in Relations model ([\#3018](https://github.com/matrix-org/matrix-js-sdk/pull/3018)).
|
||||
* Remove usage of v1 Identity Server API ([\#3003](https://github.com/matrix-org/matrix-js-sdk/pull/3003)).
|
||||
* Add `device_id` to `/account/whoami` types ([\#3005](https://github.com/matrix-org/matrix-js-sdk/pull/3005)).
|
||||
* Implement MSC3912: Relation-based redactions ([\#2954](https://github.com/matrix-org/matrix-js-sdk/pull/2954)).
|
||||
* Introduce a mechanism for using the rust-crypto-sdk ([\#2969](https://github.com/matrix-org/matrix-js-sdk/pull/2969)).
|
||||
* Support MSC3391: Account data deletion ([\#2967](https://github.com/matrix-org/matrix-js-sdk/pull/2967)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix threaded cache receipt when event holds multiple receipts ([\#3026](https://github.com/matrix-org/matrix-js-sdk/pull/3026)).
|
||||
* Fix false key requests after verifying new device ([\#3029](https://github.com/matrix-org/matrix-js-sdk/pull/3029)). Fixes vector-im/element-web#24167 and vector-im/element-web#23333.
|
||||
* Avoid triggering decryption errors when decrypting redacted events ([\#3004](https://github.com/matrix-org/matrix-js-sdk/pull/3004)). Fixes vector-im/element-web#24084.
|
||||
* bugfix: upload OTKs in sliding sync mode ([\#3008](https://github.com/matrix-org/matrix-js-sdk/pull/3008)).
|
||||
* Apply edits discovered from sync after thread is initialised ([\#3002](https://github.com/matrix-org/matrix-js-sdk/pull/3002)). Fixes vector-im/element-web#23921.
|
||||
* sliding sync: Fix issue where no unsubs are sent when switching rooms ([\#2991](https://github.com/matrix-org/matrix-js-sdk/pull/2991)).
|
||||
* Threads are missing from the timeline ([\#2996](https://github.com/matrix-org/matrix-js-sdk/pull/2996)). Fixes vector-im/element-web#24036.
|
||||
* Close all streams when a call ends ([\#2992](https://github.com/matrix-org/matrix-js-sdk/pull/2992)). Fixes vector-im/element-call#742.
|
||||
* Resume to-device message queue after resumed sync ([\#2920](https://github.com/matrix-org/matrix-js-sdk/pull/2920)). Fixes matrix-org/element-web-rageshakes#17170.
|
||||
|
||||
Changes in [23.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.0.0) (2022-12-21)
|
||||
==================================================================================================
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ if you do not have it already.
|
||||
|
||||
```javascript
|
||||
import * as sdk from "matrix-js-sdk";
|
||||
const client = sdk.createClient("https://matrix.org");
|
||||
const client = sdk.createClient({ baseUrl: "https://matrix.org" });
|
||||
client.publicRooms(function (err, data) {
|
||||
console.log("Public Rooms: %s", JSON.stringify(data));
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
To try it out, **you must build the SDK first** and then host this folder:
|
||||
|
||||
```
|
||||
$ yarn install
|
||||
$ yarn build
|
||||
$ cd examples/browser
|
||||
$ python -m http.server 8003
|
||||
|
||||
+8
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "23.0.0",
|
||||
"version": "23.1.0-rc.2",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
@@ -14,7 +14,7 @@
|
||||
"build:dev": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types",
|
||||
"build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly",
|
||||
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
|
||||
"build:compile-browser": "mkdirp dist && browserify -d src/browser-index.ts -p [ tsify -p ./tsconfig-build.json ] -t [ babelify --sourceMaps=inline --presets [ @babel/preset-env @babel/preset-typescript ] ] | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js",
|
||||
"build:compile-browser": "mkdir dist && browserify -d src/browser-index.ts -p [ tsify -p ./tsconfig-build.json ] -t [ babelify --sourceMaps=inline --presets [ @babel/preset-env @babel/preset-typescript ] ] | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js",
|
||||
"build:minify-browser": "terser dist/browser-matrix.js --compress --mangle --source-map --output dist/browser-matrix.min.js",
|
||||
"gendoc": "typedoc",
|
||||
"lint": "yarn lint:types && yarn lint:js",
|
||||
@@ -33,10 +33,11 @@
|
||||
"matrix-org"
|
||||
],
|
||||
"main": "./lib/index.js",
|
||||
"browser": "./lib/browser-index.ts",
|
||||
"browser": "./lib/browser-index.js",
|
||||
"matrix_src_main": "./src/index.ts",
|
||||
"matrix_src_browser": "./src/browser-index.ts",
|
||||
"matrix_lib_main": "./lib/index.js",
|
||||
"matrix_lib_browser": "./lib/browser-index.js",
|
||||
"matrix_lib_typings": "./lib/index.d.ts",
|
||||
"author": "matrix.org",
|
||||
"license": "Apache-2.0",
|
||||
@@ -54,6 +55,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.2",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^5.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -61,10 +63,9 @@
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-widget-api": "^1.0.0",
|
||||
"p-retry": "4",
|
||||
"qs": "^6.9.6",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"unhomoglyph": "^1.0.6",
|
||||
"uuid": "7"
|
||||
"uuid": "9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.12.10",
|
||||
@@ -97,7 +98,7 @@
|
||||
"browserify": "^17.0.0",
|
||||
"docdash": "^2.0.0",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.28.0",
|
||||
"eslint": "8.29.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
@@ -113,7 +114,7 @@
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-mock": "^29.0.0",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"prettier": "2.8.0",
|
||||
"prettier": "2.8.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"terser": "^5.5.1",
|
||||
"tsify": "^5.0.2",
|
||||
|
||||
+2
-2
@@ -21,9 +21,9 @@ if [ "$(git branch -lr | grep origin/develop -c)" -ge 1 ]; then
|
||||
# to the TypeScript source.
|
||||
src_value=$(jq -r ".matrix_src_$i" package.json)
|
||||
if [ "$src_value" != "null" ]; then
|
||||
jq ".$i = .matrix_src_$i" package.json > package.json.new && mv package.json.new package.json
|
||||
jq ".$i = .matrix_src_$i" package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json
|
||||
else
|
||||
jq "del(.$i)" package.json > package.json.new && mv package.json.new package.json
|
||||
jq "del(.$i)" package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
+8
-3
@@ -24,7 +24,7 @@ import MockHttpBackend from "matrix-mock-request";
|
||||
import { LocalStorageCryptoStore } from "../src/crypto/store/localStorage-crypto-store";
|
||||
import { logger } from "../src/logger";
|
||||
import { syncPromise } from "./test-utils/test-utils";
|
||||
import { createClient } from "../src/matrix";
|
||||
import { createClient, IStartClientOpts } from "../src/matrix";
|
||||
import { ICreateClientOpts, IDownloadKeyResult, MatrixClient, PendingEventOrdering } from "../src/client";
|
||||
import { MockStorageApi } from "./MockStorageApi";
|
||||
import { encodeUri } from "../src/utils";
|
||||
@@ -79,9 +79,12 @@ export class TestClient {
|
||||
/**
|
||||
* start the client, and wait for it to initialise.
|
||||
*/
|
||||
public start(): Promise<void> {
|
||||
public start(opts: IStartClientOpts = {}): Promise<void> {
|
||||
logger.log(this + ": starting");
|
||||
this.httpBackend.when("GET", "/versions").respond(200, {});
|
||||
this.httpBackend.when("GET", "/versions").respond(200, {
|
||||
// we have tests that rely on support for lazy-loading members
|
||||
versions: ["r0.5.0"],
|
||||
});
|
||||
this.httpBackend.when("GET", "/pushrules").respond(200, {});
|
||||
this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
|
||||
this.expectDeviceKeyUpload();
|
||||
@@ -93,6 +96,8 @@ export class TestClient {
|
||||
this.client.startClient({
|
||||
// set this so that we can get hold of failed events
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
|
||||
...opts,
|
||||
});
|
||||
|
||||
return Promise.all([this.httpBackend.flushAllExpected(), syncPromise(this.client)]).then(() => {
|
||||
|
||||
@@ -1073,6 +1073,97 @@ describe("MatrixClient event timelines", function () {
|
||||
});
|
||||
});
|
||||
|
||||
it("should ensure thread events are ordered correctly", async () => {
|
||||
// Test data for a second reply to the first thread
|
||||
const THREAD_REPLY2 = utils.mkEvent({
|
||||
room: roomId,
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": "thread reply 2",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
// We can't use the const here because we change server support mode for test
|
||||
rel_type: "io.element.thread",
|
||||
event_id: THREAD_ROOT.event_id,
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
THREAD_REPLY2.localTimestamp += 1000;
|
||||
|
||||
// Test data for a second reply to the first thread
|
||||
const THREAD_REPLY3 = utils.mkEvent({
|
||||
room: roomId,
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": "thread reply 3",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
// We can't use the const here because we change server support mode for test
|
||||
rel_type: "io.element.thread",
|
||||
event_id: THREAD_ROOT.event_id,
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
THREAD_REPLY3.localTimestamp += 2000;
|
||||
|
||||
// Test data for the first thread, with the second reply
|
||||
const THREAD_ROOT_UPDATED = {
|
||||
...THREAD_ROOT,
|
||||
unsigned: {
|
||||
...THREAD_ROOT.unsigned,
|
||||
"m.relations": {
|
||||
...THREAD_ROOT.unsigned!["m.relations"],
|
||||
"io.element.thread": {
|
||||
...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"],
|
||||
count: 3,
|
||||
latest_event: THREAD_REPLY3.event,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
|
||||
|
||||
client.fetchRoomEvent = () => Promise.resolve(THREAD_ROOT_UPDATED);
|
||||
|
||||
await client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
|
||||
const prom = emitPromise(room, ThreadEvent.Update);
|
||||
// Assume we're seeing the reply while loading backlog
|
||||
room.addLiveEvents([THREAD_REPLY2]);
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name),
|
||||
)
|
||||
.respond(200, {
|
||||
chunk: [THREAD_REPLY3.event, THREAD_REPLY2.event, THREAD_REPLY],
|
||||
});
|
||||
await flushHttp(prom);
|
||||
// but while loading the metadata, a new reply has arrived
|
||||
room.addLiveEvents([THREAD_REPLY3]);
|
||||
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
|
||||
// then the events should still be all in the right order
|
||||
expect(thread.events.map((it) => it.getId())).toEqual([
|
||||
THREAD_ROOT.event_id,
|
||||
THREAD_REPLY.event_id,
|
||||
THREAD_REPLY2.getId(),
|
||||
THREAD_REPLY3.getId(),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("paginateEventTimeline for thread list timeline", function () {
|
||||
const RANDOM_TOKEN = "7280349c7bee430f91defe2a38a0a08c";
|
||||
|
||||
|
||||
@@ -35,9 +35,7 @@ describe("MatrixClient", function () {
|
||||
let store: MemoryStore | undefined;
|
||||
|
||||
const defaultClientOpts: IStoredClientOpts = {
|
||||
canResetEntireTimeline: (roomId) => false,
|
||||
experimentalThreadSupport: false,
|
||||
crypto: {} as unknown as IStoredClientOpts["crypto"],
|
||||
};
|
||||
const setupTests = (): [MatrixClient, HttpBackend, MemoryStore] => {
|
||||
const store = new MemoryStore();
|
||||
@@ -1179,11 +1177,10 @@ describe("MatrixClient", function () {
|
||||
.when("PUT", "/send")
|
||||
.check((req) => {
|
||||
expect(req.data).toStrictEqual({
|
||||
"msgtype": "m.emote",
|
||||
"body": "Body",
|
||||
"formatted_body": "<h1>Body</h1>",
|
||||
"format": "org.matrix.custom.html",
|
||||
"org.matrix.msc1767.message": expect.anything(),
|
||||
msgtype: "m.emote",
|
||||
body: "Body",
|
||||
formatted_body: "<h1>Body</h1>",
|
||||
format: "org.matrix.custom.html",
|
||||
});
|
||||
})
|
||||
.respond(200, { event_id: "$foobar" });
|
||||
@@ -1199,11 +1196,10 @@ describe("MatrixClient", function () {
|
||||
.when("PUT", "/send")
|
||||
.check((req) => {
|
||||
expect(req.data).toStrictEqual({
|
||||
"msgtype": "m.text",
|
||||
"body": "Body",
|
||||
"formatted_body": "<h1>Body</h1>",
|
||||
"format": "org.matrix.custom.html",
|
||||
"org.matrix.msc1767.message": expect.anything(),
|
||||
msgtype: "m.text",
|
||||
body: "Body",
|
||||
formatted_body: "<h1>Body</h1>",
|
||||
format: "org.matrix.custom.html",
|
||||
});
|
||||
})
|
||||
.respond(200, { event_id: "$foobar" });
|
||||
|
||||
@@ -1543,6 +1543,52 @@ describe("MatrixClient syncing", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("only replays receipts relevant to the current context", async () => {
|
||||
const THREAD_ID = "$unknownthread:localhost";
|
||||
|
||||
const receipt = {
|
||||
type: "m.receipt",
|
||||
room_id: "!foo:bar",
|
||||
content: {
|
||||
"$event1:localhost": {
|
||||
[ReceiptType.Read]: {
|
||||
"@alice:localhost": { ts: 666, thread_id: THREAD_ID },
|
||||
},
|
||||
},
|
||||
"$otherevent:localhost": {
|
||||
[ReceiptType.Read]: {
|
||||
"@alice:localhost": { ts: 999, thread_id: "$otherthread:localhost" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
syncData.rooms.join[roomOne].ephemeral.events = [receipt];
|
||||
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
client!.startClient();
|
||||
|
||||
return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]).then(() => {
|
||||
const room = client?.getRoom(roomOne);
|
||||
expect(room).toBeInstanceOf(Room);
|
||||
|
||||
expect(room?.cachedThreadReadReceipts.has(THREAD_ID)).toBe(true);
|
||||
|
||||
const thread = room!.createThread(THREAD_ID, undefined, [], true);
|
||||
|
||||
expect(room?.cachedThreadReadReceipts.has(THREAD_ID)).toBe(false);
|
||||
|
||||
const receipt = thread.getReadReceiptForUserId("@alice:localhost");
|
||||
|
||||
expect(receipt).toStrictEqual({
|
||||
data: {
|
||||
thread_id: "$unknownthread:localhost",
|
||||
ts: 666,
|
||||
},
|
||||
eventId: "$event1:localhost",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("of a room", () => {
|
||||
|
||||
@@ -1590,4 +1590,92 @@ describe("megolm", () => {
|
||||
aliceTestClient.httpBackend.flush("/send/m.room.encrypted/", 1),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("Lazy-loading member lists", () => {
|
||||
let p2pSession: Olm.Session;
|
||||
|
||||
beforeEach(async () => {
|
||||
// set up the aliceTestClient so that it is a room with no known members
|
||||
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await aliceTestClient.start({ lazyLoadMembers: true });
|
||||
aliceTestClient.client.setGlobalErrorOnUnknownDevices(false);
|
||||
|
||||
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse([]));
|
||||
await aliceTestClient.flushSync();
|
||||
|
||||
p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount);
|
||||
});
|
||||
|
||||
async function expectMembershipRequest(roomId: string, members: string[]): Promise<void> {
|
||||
const membersPath = `/rooms/${encodeURIComponent(roomId)}/members?not_membership=leave`;
|
||||
aliceTestClient.httpBackend.when("GET", membersPath).respond(200, {
|
||||
chunk: [
|
||||
testUtils.mkMembershipCustom({
|
||||
membership: "join",
|
||||
sender: "@bob:xyz",
|
||||
}),
|
||||
],
|
||||
});
|
||||
await aliceTestClient.httpBackend.flush(membersPath, 1);
|
||||
}
|
||||
|
||||
it("Sending an event initiates a member list sync", async () => {
|
||||
// we expect a call to the /members list...
|
||||
const memberListPromise = expectMembershipRequest(ROOM_ID, ["@bob:xyz"]);
|
||||
|
||||
// then a request for bob's devices...
|
||||
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz"));
|
||||
|
||||
// then a to-device with the room_key
|
||||
const inboundGroupSessionPromise = expectSendRoomKey(
|
||||
aliceTestClient.httpBackend,
|
||||
"@bob:xyz",
|
||||
testOlmAccount,
|
||||
p2pSession,
|
||||
);
|
||||
|
||||
// and finally the megolm message
|
||||
const megolmMessagePromise = expectSendMegolmMessage(
|
||||
aliceTestClient.httpBackend,
|
||||
inboundGroupSessionPromise,
|
||||
);
|
||||
|
||||
// kick it off
|
||||
const sendPromise = aliceTestClient.client.sendTextMessage(ROOM_ID, "test");
|
||||
|
||||
await Promise.all([
|
||||
sendPromise,
|
||||
megolmMessagePromise,
|
||||
memberListPromise,
|
||||
aliceTestClient.httpBackend.flush("/keys/query", 1),
|
||||
]);
|
||||
});
|
||||
|
||||
it("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"])]);
|
||||
|
||||
// expect a request for bob's devices...
|
||||
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz"));
|
||||
|
||||
// then a to-device with the room_key
|
||||
const inboundGroupSessionPromise = expectSendRoomKey(
|
||||
aliceTestClient.httpBackend,
|
||||
"@bob:xyz",
|
||||
testOlmAccount,
|
||||
p2pSession,
|
||||
);
|
||||
|
||||
// and finally the megolm message
|
||||
const megolmMessagePromise = expectSendMegolmMessage(
|
||||
aliceTestClient.httpBackend,
|
||||
inboundGroupSessionPromise,
|
||||
);
|
||||
|
||||
// kick it off
|
||||
const sendPromise = aliceTestClient.client.sendTextMessage(ROOM_ID, "test");
|
||||
|
||||
await Promise.all([sendPromise, megolmMessagePromise, aliceTestClient.httpBackend.flush("/keys/query", 1)]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { createClient } from "../../src";
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
describe("MatrixClient.initRustCrypto", () => {
|
||||
it("should raise if userId or deviceId is unknown", async () => {
|
||||
const unknownUserClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
deviceId: "aliceDevice",
|
||||
});
|
||||
await expect(() => unknownUserClient.initRustCrypto()).rejects.toThrow("unknown userId");
|
||||
|
||||
const unknownDeviceClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:test",
|
||||
});
|
||||
await expect(() => unknownDeviceClient.initRustCrypto()).rejects.toThrow("unknown deviceId");
|
||||
});
|
||||
|
||||
it("should create the indexed dbs", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
});
|
||||
|
||||
// No databases.
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
// should have two dbs now
|
||||
const databaseNames = (await indexedDB.databases()).map((db) => db.name);
|
||||
expect(databaseNames).toEqual(
|
||||
expect.arrayContaining(["matrix-js-sdk::matrix-sdk-crypto", "matrix-js-sdk::matrix-sdk-crypto-meta"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should ignore a second call", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
});
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
await matrixClient.initRustCrypto();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MatrixClient.clearStores", () => {
|
||||
it("should clear the indexeddbs", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
});
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
expect(await indexedDB.databases()).toHaveLength(2);
|
||||
await matrixClient.stopClient();
|
||||
|
||||
await matrixClient.clearStores();
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
IRoomTimelineData,
|
||||
} from "../../src";
|
||||
import { SlidingSyncSdk } from "../../src/sliding-sync-sdk";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import { SyncApiOptions, SyncState } from "../../src/sync";
|
||||
import { IStoredClientOpts } from "../../src/client";
|
||||
import { logger } from "../../src/logger";
|
||||
import { emitPromise } from "../test-utils/test-utils";
|
||||
@@ -111,6 +111,7 @@ describe("SlidingSyncSdk", () => {
|
||||
// assign client/httpBackend globals
|
||||
const setupClient = async (testOpts?: Partial<IStoredClientOpts & { withCrypto: boolean }>) => {
|
||||
testOpts = testOpts || {};
|
||||
const syncOpts: SyncApiOptions = {};
|
||||
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
|
||||
httpBackend = testClient.httpBackend;
|
||||
client = testClient.client;
|
||||
@@ -118,10 +119,10 @@ describe("SlidingSyncSdk", () => {
|
||||
if (testOpts.withCrypto) {
|
||||
httpBackend!.when("GET", "/room_keys/version").respond(404, {});
|
||||
await client!.initCrypto();
|
||||
testOpts.crypto = client!.crypto;
|
||||
syncOpts.cryptoCallbacks = syncOpts.crypto = client!.crypto;
|
||||
}
|
||||
httpBackend!.when("GET", "/_matrix/client/r0/pushrules").respond(200, {});
|
||||
sdk = new SlidingSyncSdk(mockSlidingSync, client, testOpts);
|
||||
sdk = new SlidingSyncSdk(mockSlidingSync, client, testOpts, syncOpts);
|
||||
};
|
||||
|
||||
// tear down client/httpBackend globals
|
||||
|
||||
@@ -1418,6 +1418,102 @@ describe("SlidingSync", () => {
|
||||
await httpBackend!.flushAllExpected();
|
||||
slidingSync.stop();
|
||||
});
|
||||
|
||||
it("should not be possible to add/modify an already added custom subscription", async () => {
|
||||
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
|
||||
slidingSync.addCustomSubscription(customSubName1, customSub1);
|
||||
slidingSync.addCustomSubscription(customSubName1, customSub2);
|
||||
slidingSync.useCustomSubscription(roomA, customSubName1);
|
||||
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
|
||||
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.log("custom subs", body);
|
||||
expect(body.room_subscriptions).toBeTruthy();
|
||||
expect(body.room_subscriptions[roomA]).toEqual(customSub1);
|
||||
})
|
||||
.respond(200, {
|
||||
pos: "b",
|
||||
lists: [],
|
||||
extensions: {},
|
||||
rooms: {},
|
||||
});
|
||||
slidingSync.start();
|
||||
await httpBackend!.flushAllExpected();
|
||||
slidingSync.stop();
|
||||
});
|
||||
|
||||
it("should change the custom subscription if they are different", async () => {
|
||||
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
|
||||
slidingSync.addCustomSubscription(customSubName1, customSub1);
|
||||
slidingSync.addCustomSubscription(customSubName2, customSub2);
|
||||
slidingSync.useCustomSubscription(roomA, customSubName1);
|
||||
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
|
||||
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.log("custom subs", body);
|
||||
expect(body.room_subscriptions).toBeTruthy();
|
||||
expect(body.room_subscriptions[roomA]).toEqual(customSub1);
|
||||
expect(body.unsubscribe_rooms).toBeUndefined();
|
||||
})
|
||||
.respond(200, {
|
||||
pos: "b",
|
||||
lists: [],
|
||||
extensions: {},
|
||||
rooms: {},
|
||||
});
|
||||
slidingSync.start();
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
// using the same subscription doesn't unsub nor changes subscriptions
|
||||
slidingSync.useCustomSubscription(roomA, customSubName1);
|
||||
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
|
||||
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.log("custom subs", body);
|
||||
expect(body.room_subscriptions).toBeUndefined();
|
||||
expect(body.unsubscribe_rooms).toBeUndefined();
|
||||
})
|
||||
.respond(200, {
|
||||
pos: "b",
|
||||
lists: [],
|
||||
extensions: {},
|
||||
rooms: {},
|
||||
});
|
||||
slidingSync.start();
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
// Changing the subscription works
|
||||
slidingSync.useCustomSubscription(roomA, customSubName2);
|
||||
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
|
||||
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.log("custom subs", body);
|
||||
expect(body.room_subscriptions).toBeTruthy();
|
||||
expect(body.room_subscriptions[roomA]).toEqual(customSub2);
|
||||
expect(body.unsubscribe_rooms).toBeUndefined();
|
||||
})
|
||||
.respond(200, {
|
||||
pos: "b",
|
||||
lists: [],
|
||||
extensions: {},
|
||||
rooms: {},
|
||||
});
|
||||
slidingSync.start();
|
||||
await httpBackend!.flushAllExpected();
|
||||
slidingSync.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extensions", () => {
|
||||
|
||||
@@ -513,9 +513,6 @@ export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandle
|
||||
|
||||
public sendMetadataUpdate = jest.fn<void, []>();
|
||||
|
||||
public on = jest.fn();
|
||||
public removeListener = jest.fn();
|
||||
|
||||
public getOpponentMember(): Partial<RoomMember> {
|
||||
return this.opponentMember;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ConnectionError } from "../../src/http-api/errors";
|
||||
import { ClientEvent, MatrixClient, Store } from "../../src/client";
|
||||
import { ToDeviceMessageQueue } from "../../src/ToDeviceMessageQueue";
|
||||
import { getMockClientWithEventEmitter } from "../test-utils/client";
|
||||
import { StubStore } from "../../src/store/stub";
|
||||
import { IndexedToDeviceBatch } from "../../src/models/ToDeviceMessage";
|
||||
import { SyncState } from "../../src/sync";
|
||||
|
||||
describe("onResumedSync", () => {
|
||||
let batch: IndexedToDeviceBatch | null;
|
||||
let shouldFailSendToDevice: Boolean;
|
||||
let onSendToDeviceFailure: () => void;
|
||||
let onSendToDeviceSuccess: () => void;
|
||||
let resumeSync: (newState: SyncState, oldState: SyncState) => void;
|
||||
|
||||
let store: Store;
|
||||
let mockClient: MatrixClient;
|
||||
let queue: ToDeviceMessageQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
batch = {
|
||||
id: 0,
|
||||
txnId: "123",
|
||||
eventType: "m.dummy",
|
||||
batch: [],
|
||||
};
|
||||
|
||||
shouldFailSendToDevice = true;
|
||||
onSendToDeviceFailure = () => {};
|
||||
onSendToDeviceSuccess = () => {};
|
||||
resumeSync = (newState, oldState) => {
|
||||
shouldFailSendToDevice = false;
|
||||
mockClient.emit(ClientEvent.Sync, newState, oldState);
|
||||
};
|
||||
|
||||
store = new StubStore();
|
||||
store.getOldestToDeviceBatch = jest.fn().mockImplementation(() => {
|
||||
return batch;
|
||||
});
|
||||
store.removeToDeviceBatch = jest.fn().mockImplementation(() => {
|
||||
batch = null;
|
||||
});
|
||||
|
||||
mockClient = getMockClientWithEventEmitter({});
|
||||
mockClient.store = store;
|
||||
mockClient.sendToDevice = jest.fn().mockImplementation(async () => {
|
||||
if (shouldFailSendToDevice) {
|
||||
await Promise.reject(new ConnectionError("")).finally(() => {
|
||||
setTimeout(onSendToDeviceFailure, 0);
|
||||
});
|
||||
} else {
|
||||
await Promise.resolve({}).finally(() => {
|
||||
setTimeout(onSendToDeviceSuccess, 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
queue = new ToDeviceMessageQueue(mockClient);
|
||||
});
|
||||
|
||||
it("resends queue after connectivity restored", (done) => {
|
||||
onSendToDeviceFailure = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
expect(store.removeToDeviceBatch).not.toHaveBeenCalled();
|
||||
|
||||
resumeSync(SyncState.Syncing, SyncState.Catchup);
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(2);
|
||||
};
|
||||
|
||||
onSendToDeviceSuccess = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(3);
|
||||
expect(store.removeToDeviceBatch).toHaveBeenCalled();
|
||||
done();
|
||||
};
|
||||
|
||||
queue.start();
|
||||
});
|
||||
|
||||
it("does not resend queue if client sync still catching up", (done) => {
|
||||
onSendToDeviceFailure = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
expect(store.removeToDeviceBatch).not.toHaveBeenCalled();
|
||||
|
||||
resumeSync(SyncState.Catchup, SyncState.Catchup);
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
done();
|
||||
};
|
||||
|
||||
queue.start();
|
||||
});
|
||||
|
||||
it("does not resend queue if connectivity restored after queue stopped", (done) => {
|
||||
onSendToDeviceFailure = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
expect(store.removeToDeviceBatch).not.toHaveBeenCalled();
|
||||
|
||||
queue.stop();
|
||||
|
||||
resumeSync(SyncState.Syncing, SyncState.Catchup);
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
done();
|
||||
};
|
||||
|
||||
queue.start();
|
||||
});
|
||||
});
|
||||
@@ -544,7 +544,7 @@ describe("AutoDiscovery", function () {
|
||||
.respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
});
|
||||
httpBackend.when("GET", "/_matrix/identity/api/v1").respond(404, {});
|
||||
httpBackend.when("GET", "/_matrix/identity/v2").respond(404, {});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
// Note: we also expect this test to trim the trailing slash
|
||||
@@ -591,7 +591,7 @@ describe("AutoDiscovery", function () {
|
||||
.respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
});
|
||||
httpBackend.when("GET", "/_matrix/identity/api/v1").respond(500, {});
|
||||
httpBackend.when("GET", "/_matrix/identity/v2").respond(500, {});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
// Note: we also expect this test to trim the trailing slash
|
||||
@@ -636,9 +636,9 @@ describe("AutoDiscovery", function () {
|
||||
versions: ["r0.0.1"],
|
||||
});
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/identity/api/v1")
|
||||
.when("GET", "/_matrix/identity/v2")
|
||||
.check((req) => {
|
||||
expect(req.path).toEqual("https://identity.example.org/_matrix/identity/api/v1");
|
||||
expect(req.path).toEqual("https://identity.example.org/_matrix/identity/v2");
|
||||
})
|
||||
.respond(200, {});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
@@ -682,9 +682,9 @@ describe("AutoDiscovery", function () {
|
||||
versions: ["r0.0.1"],
|
||||
});
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/identity/api/v1")
|
||||
.when("GET", "/_matrix/identity/v2")
|
||||
.check((req) => {
|
||||
expect(req.path).toEqual("https://identity.example.org/_matrix/identity/api/v1");
|
||||
expect(req.path).toEqual("https://identity.example.org/_matrix/identity/v2");
|
||||
})
|
||||
.respond(200, {});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
|
||||
@@ -167,6 +167,38 @@ describe("Crypto", function () {
|
||||
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
it("doesn't throw an error when attempting to decrypt a redacted event", async () => {
|
||||
const client = new TestClient("@alice:example.com", "deviceid").client;
|
||||
await client.initCrypto();
|
||||
|
||||
const event = new MatrixEvent({
|
||||
content: {},
|
||||
event_id: "$event_id",
|
||||
room_id: "!room_id",
|
||||
sender: "@bob:example.com",
|
||||
type: "m.room.encrypted",
|
||||
unsigned: {
|
||||
redacted_because: {
|
||||
content: {},
|
||||
event_id: "$redaction_event_id",
|
||||
redacts: "$event_id",
|
||||
room_id: "!room_id",
|
||||
origin_server_ts: 1234567890,
|
||||
sender: "@bob:example.com",
|
||||
type: "m.room.redaction",
|
||||
unsigned: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
await event.attemptDecryption(client.crypto!);
|
||||
expect(event.isDecryptionFailure()).toBeFalsy();
|
||||
// since the redaction event isn't encrypted, the redacted_because
|
||||
// should be the same as in the original event
|
||||
expect(event.getRedactionEvent()).toEqual(event.getUnsigned().redacted_because);
|
||||
|
||||
client.stopClient();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Session management", function () {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import "../../../olm-loader";
|
||||
|
||||
import { CRYPTO_ENABLED, MatrixClient } from "../../../../src/client";
|
||||
import { TestClient } from "../../../TestClient";
|
||||
|
||||
const Olm = global.Olm;
|
||||
|
||||
describe("crypto.setDeviceVerification", () => {
|
||||
const userId = "@alice:example.com";
|
||||
const deviceId1 = "device1";
|
||||
let client: MatrixClient;
|
||||
|
||||
if (!CRYPTO_ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await Olm.init();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
client = new TestClient(userId, deviceId1).client;
|
||||
await client.initCrypto();
|
||||
});
|
||||
|
||||
it("client should provide crypto", () => {
|
||||
expect(client.crypto).not.toBeUndefined();
|
||||
});
|
||||
|
||||
describe("when setting an own device as verified", () => {
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(client.crypto!, "cancelAndResendAllOutgoingKeyRequests");
|
||||
await client.crypto!.setDeviceVerification(userId, deviceId1, true);
|
||||
});
|
||||
|
||||
it("cancelAndResendAllOutgoingKeyRequests should be called", () => {
|
||||
expect(client.crypto!.cancelAndResendAllOutgoingKeyRequests).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,10 +24,13 @@ import {
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
} from "../../src";
|
||||
import { Thread } from "../../src/models/thread";
|
||||
import { FeatureSupport, Thread } from "../../src/models/thread";
|
||||
import { ReEmitter } from "../../src/ReEmitter";
|
||||
import { eventMapperFor } from "../../src/event-mapper";
|
||||
|
||||
describe("EventTimelineSet", () => {
|
||||
const roomId = "!foo:bar";
|
||||
@@ -202,6 +205,88 @@ describe("EventTimelineSet", () => {
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
});
|
||||
|
||||
it("should allow edits to be added to thread timeline", async () => {
|
||||
jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true);
|
||||
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
|
||||
Thread.hasServerSideSupport = FeatureSupport.Stable;
|
||||
|
||||
const sender = "@alice:matrix.org";
|
||||
|
||||
const root = utils.mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
body: "Thread root",
|
||||
},
|
||||
type: EventType.RoomMessage,
|
||||
sender,
|
||||
});
|
||||
room.addLiveEvents([root]);
|
||||
|
||||
const threadReply = utils.mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
"body": "Thread reply",
|
||||
"m.relates_to": {
|
||||
event_id: root.getId()!,
|
||||
rel_type: RelationType.Thread,
|
||||
},
|
||||
},
|
||||
type: EventType.RoomMessage,
|
||||
sender,
|
||||
});
|
||||
|
||||
root.setUnsigned({
|
||||
"m.relations": {
|
||||
[RelationType.Thread]: {
|
||||
count: 1,
|
||||
latest_event: {
|
||||
content: threadReply.getContent(),
|
||||
origin_server_ts: 5,
|
||||
room_id: room.roomId,
|
||||
sender,
|
||||
type: EventType.RoomMessage,
|
||||
event_id: threadReply.getId()!,
|
||||
user_id: sender,
|
||||
age: 1,
|
||||
},
|
||||
current_user_participated: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const editToThreadReply = utils.mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
"body": " * edit",
|
||||
"m.new_content": {
|
||||
"body": "edit",
|
||||
"msgtype": "m.text",
|
||||
"org.matrix.msc1767.text": "edit",
|
||||
},
|
||||
"m.relates_to": {
|
||||
event_id: threadReply.getId()!,
|
||||
rel_type: RelationType.Replace,
|
||||
},
|
||||
},
|
||||
type: EventType.RoomMessage,
|
||||
sender,
|
||||
});
|
||||
|
||||
jest.spyOn(client, "paginateEventTimeline").mockImplementation(async () => {
|
||||
thread.timelineSet.getLiveTimeline().addEvent(threadReply, { toStartOfTimeline: true });
|
||||
return true;
|
||||
});
|
||||
jest.spyOn(client, "relations").mockResolvedValue({
|
||||
events: [],
|
||||
});
|
||||
|
||||
const thread = room.createThread(root.getId()!, root, [threadReply, editToThreadReply], false);
|
||||
thread.once(RoomEvent.TimelineReset, () => {
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
expect(lastEvent.getContent().body).toBe(" * edit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-room timeline", () => {
|
||||
it("Adds event to timeline", () => {
|
||||
const nonRoomEventTimelineSet = new EventTimelineSet(
|
||||
|
||||
+460
-13
@@ -22,11 +22,13 @@ import { Filter } from "../../src/filter";
|
||||
import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace";
|
||||
import {
|
||||
EventType,
|
||||
RelationType,
|
||||
RoomCreateTypeField,
|
||||
RoomType,
|
||||
UNSTABLE_MSC3088_ENABLED,
|
||||
UNSTABLE_MSC3088_PURPOSE,
|
||||
UNSTABLE_MSC3089_TREE_SUBTYPE,
|
||||
MSC3912_RELATION_BASED_REDACTIONS_PROP,
|
||||
} from "../../src/@types/event";
|
||||
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
|
||||
import { Crypto } from "../../src/crypto";
|
||||
@@ -38,6 +40,8 @@ import { makeBeaconInfoContent } from "../../src/content-helpers";
|
||||
import { M_BEACON_INFO } from "../../src/@types/beacon";
|
||||
import {
|
||||
ContentHelpers,
|
||||
ClientPrefix,
|
||||
Direction,
|
||||
EventTimeline,
|
||||
ICreateRoomOpts,
|
||||
IRequestOpts,
|
||||
@@ -57,6 +61,8 @@ import {
|
||||
import { IOlmDevice } from "../../src/crypto/algorithms/megolm";
|
||||
import { QueryDict } from "../../src/utils";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import * as featureUtils from "../../src/feature";
|
||||
import { StubStore } from "../../src/store/stub";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
@@ -65,9 +71,20 @@ jest.mock("../../src/webrtc/call", () => ({
|
||||
supportsMatrixCall: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
// Utility function to ease the transition from our QueryDict type to a Map
|
||||
// which we can use to build a URLSearchParams
|
||||
function convertQueryDictToMap(queryDict?: QueryDict): Map<string, string> {
|
||||
if (!queryDict) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
return new Map(Object.entries(queryDict).map(([k, v]) => [k, String(v)]));
|
||||
}
|
||||
|
||||
type HttpLookup = {
|
||||
method: string;
|
||||
path: string;
|
||||
prefix?: string;
|
||||
data?: Record<string, any>;
|
||||
error?: object;
|
||||
expectBody?: Record<string, any>;
|
||||
@@ -84,6 +101,42 @@ type WrappedRoom = Room & {
|
||||
_state: Map<string, any>;
|
||||
};
|
||||
|
||||
describe("convertQueryDictToMap", () => {
|
||||
it("returns an empty map when dict is undefined", () => {
|
||||
expect(convertQueryDictToMap(undefined)).toEqual(new Map());
|
||||
});
|
||||
|
||||
it("converts an empty QueryDict to an empty map", () => {
|
||||
expect(convertQueryDictToMap({})).toEqual(new Map());
|
||||
});
|
||||
|
||||
it("converts a QueryDict of strings to the equivalent map", () => {
|
||||
expect(convertQueryDictToMap({ a: "b", c: "d" })).toEqual(
|
||||
new Map([
|
||||
["a", "b"],
|
||||
["c", "d"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("converts the values of the supplied QueryDict to strings", () => {
|
||||
expect(convertQueryDictToMap({ arr: ["b", "c"], num: 45, boo: true, und: undefined })).toEqual(
|
||||
new Map([
|
||||
["arr", "b,c"],
|
||||
["num", "45"],
|
||||
["boo", "true"],
|
||||
["und", "undefined"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("produces sane URLSearchParams conversions", () => {
|
||||
expect(new URLSearchParams(Array.from(convertQueryDictToMap({ a: "b", c: "d" }))).toString()).toEqual(
|
||||
"a=b&c=d",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MatrixClient", function () {
|
||||
const userId = "@alice:bar";
|
||||
const identityServerUrl = "https://identity.server";
|
||||
@@ -120,6 +173,10 @@ describe("MatrixClient", function () {
|
||||
data: SYNC_DATA,
|
||||
};
|
||||
|
||||
const unstableFeatures: Record<string, boolean> = {
|
||||
"org.matrix.msc3440.stable": true,
|
||||
};
|
||||
|
||||
// items are popped off when processed and block if no items left.
|
||||
let httpLookups: HttpLookup[] = [];
|
||||
let acceptKeepalives: boolean;
|
||||
@@ -128,12 +185,17 @@ describe("MatrixClient", function () {
|
||||
method: string;
|
||||
path: string;
|
||||
} | null = null;
|
||||
function httpReq(method: Method, path: string, qp?: QueryDict, data?: BodyInit, opts?: IRequestOpts) {
|
||||
function httpReq(
|
||||
method: Method,
|
||||
path: string,
|
||||
queryParams?: QueryDict,
|
||||
body?: BodyInit,
|
||||
requestOpts: IRequestOpts = {},
|
||||
) {
|
||||
const { prefix } = requestOpts;
|
||||
if (path === KEEP_ALIVE_PATH && acceptKeepalives) {
|
||||
return Promise.resolve({
|
||||
unstable_features: {
|
||||
"org.matrix.msc3440.stable": true,
|
||||
},
|
||||
unstable_features: unstableFeatures,
|
||||
versions: ["r0.6.0", "r0.6.1"],
|
||||
});
|
||||
}
|
||||
@@ -166,14 +228,17 @@ describe("MatrixClient", function () {
|
||||
};
|
||||
return pendingLookup.promise;
|
||||
}
|
||||
if (next.path === path && next.method === method) {
|
||||
// Either we don't care about the prefix if it wasn't defined in the expected
|
||||
// lookup or it should match.
|
||||
const doesMatchPrefix = !next.prefix || next.prefix === prefix;
|
||||
if (doesMatchPrefix && next.path === path && next.method === method) {
|
||||
logger.log("MatrixClient[UT] Matched. Returning " + (next.error ? "BAD" : "GOOD") + " response");
|
||||
if (next.expectBody) {
|
||||
expect(data).toEqual(next.expectBody);
|
||||
expect(body).toEqual(next.expectBody);
|
||||
}
|
||||
if (next.expectQueryParams) {
|
||||
Object.keys(next.expectQueryParams).forEach(function (k) {
|
||||
expect(qp?.[k]).toEqual(next.expectQueryParams![k]);
|
||||
expect(queryParams?.[k]).toEqual(next.expectQueryParams![k]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -193,12 +258,22 @@ describe("MatrixClient", function () {
|
||||
}
|
||||
return Promise.resolve(next.data);
|
||||
}
|
||||
// Jest doesn't let us have custom expectation errors, so if you're seeing this then
|
||||
// you forgot to handle at least 1 pending request. Check your tests to ensure your
|
||||
// number of expectations lines up with your number of requests made, and that those
|
||||
// requests match your expectations.
|
||||
expect(true).toBe(false);
|
||||
return new Promise(() => {});
|
||||
|
||||
const receivedRequestQueryString = new URLSearchParams(
|
||||
Array.from(convertQueryDictToMap(queryParams)),
|
||||
).toString();
|
||||
const receivedRequestDebugString = `${method} ${prefix}${path}${receivedRequestQueryString}`;
|
||||
const expectedQueryString = new URLSearchParams(
|
||||
Array.from(convertQueryDictToMap(next.expectQueryParams)),
|
||||
).toString();
|
||||
const expectedRequestDebugString = `${next.method} ${next.prefix ?? ""}${next.path}${expectedQueryString}`;
|
||||
// If you're seeing this then you forgot to handle at least 1 pending request.
|
||||
throw new Error(
|
||||
`A pending request was not handled: ${receivedRequestDebugString} ` +
|
||||
`(next request expected was ${expectedRequestDebugString})\n` +
|
||||
`Check your tests to ensure your number of expectations lines up with your number of requests ` +
|
||||
`made, and that those requests match your expectations.`,
|
||||
);
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
@@ -281,6 +356,183 @@ describe("MatrixClient", function () {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
describe("timestampToEvent", () => {
|
||||
const roomId = "!room:server.org";
|
||||
const eventId = "$eventId:example.org";
|
||||
const unstableMSC3030Prefix = "/_matrix/client/unstable/org.matrix.msc3030";
|
||||
|
||||
async function assertRequestsMade(
|
||||
responses: {
|
||||
prefix?: string;
|
||||
error?: { httpStatus: Number; errcode: string };
|
||||
data?: { event_id: string };
|
||||
}[],
|
||||
expectRejects = false,
|
||||
) {
|
||||
const queryParams = {
|
||||
ts: "0",
|
||||
dir: "f",
|
||||
};
|
||||
const path = `/rooms/${encodeURIComponent(roomId)}/timestamp_to_event`;
|
||||
// Set up the responses we are going to send back
|
||||
httpLookups = responses.map((res) => {
|
||||
return {
|
||||
method: "GET",
|
||||
path,
|
||||
expectQueryParams: queryParams,
|
||||
...res,
|
||||
};
|
||||
});
|
||||
|
||||
// When we ask for the event timestamp (this is what we are testing)
|
||||
const answer = client.timestampToEvent(roomId, 0, Direction.Forward);
|
||||
|
||||
if (expectRejects) {
|
||||
await expect(answer).rejects.toBeDefined();
|
||||
} else {
|
||||
await answer;
|
||||
}
|
||||
|
||||
// Then the number of requests me made matches our expectation
|
||||
const calls = mocked(client.http.authedRequest).mock.calls;
|
||||
expect(calls.length).toStrictEqual(responses.length);
|
||||
|
||||
// And each request was as we expected
|
||||
let i = 0;
|
||||
for (const call of calls) {
|
||||
const response = responses[i];
|
||||
const [callMethod, callPath, callQueryParams, , callOpts] = call;
|
||||
const callPrefix = callOpts?.prefix;
|
||||
|
||||
expect(callMethod).toStrictEqual("GET");
|
||||
if (response.prefix) {
|
||||
expect(callPrefix).toStrictEqual(response.prefix);
|
||||
}
|
||||
expect(callPath).toStrictEqual(path);
|
||||
expect(callQueryParams).toStrictEqual(queryParams);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
it("should call stable endpoint", async () => {
|
||||
await assertRequestsMade([
|
||||
{
|
||||
data: { event_id: eventId },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should fallback to unstable endpoint when stable endpoint 400s", async () => {
|
||||
await assertRequestsMade([
|
||||
{
|
||||
prefix: ClientPrefix.V1,
|
||||
error: {
|
||||
httpStatus: 400,
|
||||
errcode: "M_UNRECOGNIZED",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: unstableMSC3030Prefix,
|
||||
data: { event_id: eventId },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should fallback to unstable endpoint when stable endpoint 404s", async () => {
|
||||
await assertRequestsMade([
|
||||
{
|
||||
prefix: ClientPrefix.V1,
|
||||
error: {
|
||||
httpStatus: 404,
|
||||
errcode: "M_UNRECOGNIZED",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: unstableMSC3030Prefix,
|
||||
data: { event_id: eventId },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should fallback to unstable endpoint when stable endpoint 405s", async () => {
|
||||
await assertRequestsMade([
|
||||
{
|
||||
prefix: ClientPrefix.V1,
|
||||
error: {
|
||||
httpStatus: 405,
|
||||
errcode: "M_UNRECOGNIZED",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: unstableMSC3030Prefix,
|
||||
data: { event_id: eventId },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not fallback to unstable endpoint when stable endpoint returns an error (500)", async () => {
|
||||
await assertRequestsMade(
|
||||
[
|
||||
{
|
||||
prefix: ClientPrefix.V1,
|
||||
error: {
|
||||
httpStatus: 500,
|
||||
errcode: "Fake response error",
|
||||
},
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not fallback to unstable endpoint when stable endpoint is rate-limiting (429)", async () => {
|
||||
await assertRequestsMade(
|
||||
[
|
||||
{
|
||||
prefix: ClientPrefix.V1,
|
||||
error: {
|
||||
httpStatus: 429,
|
||||
errcode: "M_UNRECOGNIZED", // Still refuses even if the errcode claims unrecognised
|
||||
},
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not fallback to unstable endpoint when stable endpoint says bad gateway (502)", async () => {
|
||||
await assertRequestsMade(
|
||||
[
|
||||
{
|
||||
prefix: ClientPrefix.V1,
|
||||
error: {
|
||||
httpStatus: 502,
|
||||
errcode: "Fake response error",
|
||||
},
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSafeUserId()", () => {
|
||||
it("returns the logged in user id", () => {
|
||||
expect(client.getSafeUserId()).toEqual(userId);
|
||||
});
|
||||
|
||||
it("throws when there is not logged in user", () => {
|
||||
const notLoggedInClient = new MatrixClient({
|
||||
baseUrl: "https://my.home.server",
|
||||
idBaseUrl: identityServerUrl,
|
||||
fetchFn: function () {} as any, // NOP
|
||||
store: store,
|
||||
scheduler: scheduler,
|
||||
});
|
||||
expect(() => notLoggedInClient.getSafeUserId()).toThrow("Expected logged in user but found none.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendEvent", () => {
|
||||
const roomId = "!room:example.org";
|
||||
const body = "This is the body";
|
||||
@@ -1067,6 +1319,59 @@ describe("MatrixClient", function () {
|
||||
|
||||
await client.redactEvent(roomId, eventId, txnId, { reason });
|
||||
});
|
||||
|
||||
describe("when calling with with_relations", () => {
|
||||
const eventId = "$event42:example.org";
|
||||
|
||||
it("should raise an error if server has no support for relation based redactions", async () => {
|
||||
// load supported features
|
||||
await client.getVersions();
|
||||
|
||||
const txnId = client.makeTxnId();
|
||||
|
||||
expect(() => {
|
||||
client.redactEvent(roomId, eventId, txnId, {
|
||||
with_relations: [RelationType.Reference],
|
||||
});
|
||||
}).toThrowError(
|
||||
new Error(
|
||||
"Server does not support relation based redactions " +
|
||||
`roomId ${roomId} eventId ${eventId} txnId: ${txnId} threadId null`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe("and the server supports relation based redactions (unstable)", () => {
|
||||
beforeEach(async () => {
|
||||
unstableFeatures["org.matrix.msc3912"] = true;
|
||||
// load supported features
|
||||
await client.getVersions();
|
||||
});
|
||||
|
||||
it("should send with_relations in the request body", async () => {
|
||||
const txnId = client.makeTxnId();
|
||||
|
||||
httpLookups = [
|
||||
{
|
||||
method: "PUT",
|
||||
path:
|
||||
`/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}` +
|
||||
`/${encodeURIComponent(txnId)}`,
|
||||
expectBody: {
|
||||
reason: "redaction test",
|
||||
[MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!]: [RelationType.Reference],
|
||||
},
|
||||
data: { event_id: eventId },
|
||||
},
|
||||
];
|
||||
|
||||
await client.redactEvent(roomId, eventId, txnId, {
|
||||
reason: "redaction test",
|
||||
with_relations: [RelationType.Reference],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelPendingEvent", () => {
|
||||
@@ -1828,4 +2133,146 @@ describe("MatrixClient", function () {
|
||||
expect(client.getUseE2eForGroupCall()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete account data", () => {
|
||||
afterEach(() => {
|
||||
jest.spyOn(featureUtils, "buildFeatureSupportMap").mockRestore();
|
||||
});
|
||||
it("makes correct request when deletion is supported by server in unstable versions", async () => {
|
||||
const eventType = "im.vector.test";
|
||||
const versionsResponse = {
|
||||
versions: ["1"],
|
||||
unstable_features: {
|
||||
"org.matrix.msc3391": true,
|
||||
},
|
||||
};
|
||||
jest.spyOn(client.http, "request").mockResolvedValue(versionsResponse);
|
||||
const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve());
|
||||
const unstablePrefix = "/_matrix/client/unstable/org.matrix.msc3391";
|
||||
const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`;
|
||||
|
||||
// populate version support
|
||||
await client.getVersions();
|
||||
await client.deleteAccountData(eventType);
|
||||
|
||||
expect(requestSpy).toHaveBeenCalledWith(Method.Delete, path, undefined, undefined, {
|
||||
prefix: unstablePrefix,
|
||||
});
|
||||
});
|
||||
|
||||
it("makes correct request when deletion is supported by server based on matrix version", async () => {
|
||||
const eventType = "im.vector.test";
|
||||
// we don't have a stable version for account data deletion yet to test this code path with
|
||||
// so mock the support map to fake stable support
|
||||
const stableSupportedDeletionMap = new Map();
|
||||
stableSupportedDeletionMap.set(featureUtils.Feature.AccountDataDeletion, featureUtils.ServerSupport.Stable);
|
||||
jest.spyOn(featureUtils, "buildFeatureSupportMap").mockResolvedValue(new Map());
|
||||
const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve());
|
||||
const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`;
|
||||
|
||||
// populate version support
|
||||
await client.getVersions();
|
||||
await client.deleteAccountData(eventType);
|
||||
|
||||
expect(requestSpy).toHaveBeenCalledWith(Method.Delete, path, undefined, undefined, undefined);
|
||||
});
|
||||
|
||||
it("makes correct request when deletion is not supported by server", async () => {
|
||||
const eventType = "im.vector.test";
|
||||
const versionsResponse = {
|
||||
versions: ["1"],
|
||||
unstable_features: {
|
||||
"org.matrix.msc3391": false,
|
||||
},
|
||||
};
|
||||
jest.spyOn(client.http, "request").mockResolvedValue(versionsResponse);
|
||||
const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve());
|
||||
const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`;
|
||||
|
||||
// populate version support
|
||||
await client.getVersions();
|
||||
await client.deleteAccountData(eventType);
|
||||
|
||||
// account data updated with empty content
|
||||
expect(requestSpy).toHaveBeenCalledWith(Method.Put, path, undefined, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVisibleRooms", () => {
|
||||
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",
|
||||
room_id: predecessorRoomId,
|
||||
},
|
||||
"room_version": "9",
|
||||
},
|
||||
event_id: `create_event_id_pred_${predecessorRoomId}`,
|
||||
origin_server_ts: 1432735824653,
|
||||
room_id: newRoomId,
|
||||
sender: "@daryl:alexandria.example.com",
|
||||
state_key: "",
|
||||
type: "m.room.create",
|
||||
});
|
||||
}
|
||||
|
||||
function tombstoneEvent(newRoomId: string, predecessorRoomId: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
content: {
|
||||
body: "This room has been replaced",
|
||||
replacement_room: newRoomId,
|
||||
},
|
||||
event_id: `tombstone_event_id_pred_${predecessorRoomId}`,
|
||||
origin_server_ts: 1432735824653,
|
||||
room_id: predecessorRoomId,
|
||||
sender: "@daryl:alexandria.example.com",
|
||||
state_key: "",
|
||||
type: "m.room.tombstone",
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ limitations under the License.
|
||||
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { EventType } from "../../../src";
|
||||
import { Crypto } from "../../../src/crypto";
|
||||
|
||||
describe("MatrixEvent", () => {
|
||||
@@ -88,22 +87,6 @@ describe("MatrixEvent", () => {
|
||||
expect(ev.getWireContent().ciphertext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should abort decryption if fails with an error other than a DecryptionError", async () => {
|
||||
const ev = new MatrixEvent({
|
||||
type: EventType.RoomMessageEncrypted,
|
||||
content: {
|
||||
body: "Test",
|
||||
},
|
||||
event_id: "$event1:server",
|
||||
});
|
||||
await ev.attemptDecryption({
|
||||
decryptEvent: jest.fn().mockRejectedValue(new Error("Not a DecryptionError")),
|
||||
} as unknown as Crypto);
|
||||
expect(ev.isEncrypted()).toBeTruthy();
|
||||
expect(ev.isBeingDecrypted()).toBeFalsy();
|
||||
expect(ev.isDecryptionFailure()).toBeFalsy();
|
||||
});
|
||||
|
||||
describe("applyVisibilityEvent", () => {
|
||||
it("should emit VisibilityChange if a change was made", async () => {
|
||||
const ev = new MatrixEvent({
|
||||
@@ -134,6 +117,21 @@ describe("MatrixEvent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should report decryption errors", async () => {
|
||||
const crypto = {
|
||||
decryptEvent: jest.fn().mockRejectedValue(new Error("test error")),
|
||||
} as unknown as Crypto;
|
||||
|
||||
await encryptedEvent.attemptDecryption(crypto);
|
||||
expect(encryptedEvent.isEncrypted()).toBeTruthy();
|
||||
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
|
||||
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
|
||||
expect(encryptedEvent.getContent()).toEqual({
|
||||
msgtype: "m.bad.encrypted",
|
||||
body: "** Unable to decrypt: Error: test error **",
|
||||
});
|
||||
});
|
||||
|
||||
it("should retry decryption if a retry is queued", async () => {
|
||||
const eventAttemptDecryptionSpy = jest.spyOn(encryptedEvent, "attemptDecryption");
|
||||
|
||||
|
||||
@@ -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,8 +19,12 @@ import { Room } from "../../../src/models/room";
|
||||
import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread";
|
||||
import { mkThread } from "../../test-utils/thread";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { emitPromise, mkMessage } from "../../test-utils/test-utils";
|
||||
import { EventStatus } from "../../../src";
|
||||
import { emitPromise, mkMessage, mock } from "../../test-utils/test-utils";
|
||||
import { EventStatus, MatrixEvent } from "../../../src";
|
||||
import { ReceiptType } from "../../../src/@types/read_receipts";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
|
||||
import { ReEmitter } from "../../../src/ReEmitter";
|
||||
import { Feature, ServerSupport } from "../../../src/feature";
|
||||
|
||||
describe("Thread", () => {
|
||||
describe("constructor", () => {
|
||||
@@ -71,17 +75,54 @@ describe("Thread", () => {
|
||||
});
|
||||
|
||||
describe("hasUserReadEvent", () => {
|
||||
const myUserId = "@bob:example.org";
|
||||
let myUserId: string;
|
||||
let client: MatrixClient;
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
|
||||
timelineSupport: false,
|
||||
client = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(),
|
||||
getRoom: jest.fn().mockImplementation(() => room),
|
||||
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
|
||||
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
client = testClient.client;
|
||||
client.reEmitter = mock(ReEmitter, "ReEmitter");
|
||||
client.canSupport = new Map();
|
||||
Object.keys(Feature).forEach((feature) => {
|
||||
client.canSupport.set(feature as Feature, ServerSupport.Stable);
|
||||
});
|
||||
|
||||
myUserId = client.getUserId()!;
|
||||
|
||||
room = new Room("123", client, myUserId);
|
||||
|
||||
const receipt = new MatrixEvent({
|
||||
type: "m.receipt",
|
||||
room_id: "!foo:bar",
|
||||
content: {
|
||||
// first threaded receipt
|
||||
"$event0:localhost": {
|
||||
[ReceiptType.Read]: {
|
||||
[client.getUserId()!]: { ts: 100, thread_id: "$threadId:localhost" },
|
||||
},
|
||||
},
|
||||
// last unthreaded receipt
|
||||
"$event1:localhost": {
|
||||
[ReceiptType.Read]: {
|
||||
[client.getUserId()!]: { ts: 200 },
|
||||
["@alice:example.org"]: { ts: 200 },
|
||||
},
|
||||
},
|
||||
// last threaded receipt
|
||||
"$event2:localhost": {
|
||||
[ReceiptType.Read]: {
|
||||
[client.getUserId()!]: { ts: 300, thread_id: "$threadId" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
room.addReceipt(receipt);
|
||||
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room);
|
||||
});
|
||||
|
||||
@@ -98,6 +139,7 @@ describe("Thread", () => {
|
||||
length: 2,
|
||||
});
|
||||
|
||||
// The event is automatically considered read as the current user is the sender
|
||||
expect(thread.hasUserReadEvent(myUserId, events.at(-1)!.getId() ?? "")).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -106,11 +148,139 @@ describe("Thread", () => {
|
||||
room,
|
||||
client,
|
||||
authorId: myUserId,
|
||||
participantUserIds: ["@alice:example.org"],
|
||||
length: 2,
|
||||
participantUserIds: [myUserId],
|
||||
length: 25,
|
||||
ts: 190,
|
||||
});
|
||||
|
||||
// Before alice's last unthreaded receipt
|
||||
expect(thread.hasUserReadEvent("@alice:example.org", events.at(1)!.getId() ?? "")).toBeTruthy();
|
||||
|
||||
// After alice's last unthreaded receipt
|
||||
expect(thread.hasUserReadEvent("@alice:example.org", events.at(-1)!.getId() ?? "")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("considers event as read if there's a more recent unthreaded receipt", () => {
|
||||
const { thread, events } = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: myUserId,
|
||||
participantUserIds: ["@alice:example.org"],
|
||||
length: 2,
|
||||
ts: 150, // before the latest unthreaded receipt
|
||||
});
|
||||
expect(thread.hasUserReadEvent(client.getUserId()!, events.at(-1)!.getId() ?? "")).toBe(true);
|
||||
});
|
||||
|
||||
it("considers event as unread if there's no more recent unthreaded receipt", () => {
|
||||
const { thread, events } = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: myUserId,
|
||||
participantUserIds: ["@alice:example.org"],
|
||||
length: 2,
|
||||
ts: 1000,
|
||||
});
|
||||
expect(thread.hasUserReadEvent(client.getUserId()!, events.at(-1)!.getId() ?? "")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventReadUpTo", () => {
|
||||
let myUserId: string;
|
||||
let client: MatrixClient;
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
client = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(),
|
||||
getRoom: jest.fn().mockImplementation(() => room),
|
||||
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
|
||||
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
client.reEmitter = mock(ReEmitter, "ReEmitter");
|
||||
client.canSupport = new Map();
|
||||
Object.keys(Feature).forEach((feature) => {
|
||||
client.canSupport.set(feature as Feature, ServerSupport.Stable);
|
||||
});
|
||||
|
||||
myUserId = client.getUserId()!;
|
||||
|
||||
room = new Room("123", client, myUserId);
|
||||
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("uses unthreaded receipt to figure out read up to", () => {
|
||||
const receipt = new MatrixEvent({
|
||||
type: "m.receipt",
|
||||
room_id: "!foo:bar",
|
||||
content: {
|
||||
// last unthreaded receipt
|
||||
"$event1:localhost": {
|
||||
[ReceiptType.Read]: {
|
||||
["@alice:example.org"]: { ts: 200 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
room.addReceipt(receipt);
|
||||
|
||||
const { thread, events } = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: myUserId,
|
||||
participantUserIds: [myUserId],
|
||||
length: 25,
|
||||
ts: 190,
|
||||
});
|
||||
|
||||
// The 10th event has been read, as alice's last unthreaded receipt is at ts 200
|
||||
// and `mkThread` increment every thread response by 1ms.
|
||||
expect(thread.getEventReadUpTo("@alice:example.org")).toBe(events.at(9)!.getId());
|
||||
});
|
||||
|
||||
it("considers thread created before the first threaded receipt to be read", () => {
|
||||
const receipt = new MatrixEvent({
|
||||
type: "m.receipt",
|
||||
room_id: "!foo:bar",
|
||||
content: {
|
||||
// last unthreaded receipt
|
||||
"$event1:localhost": {
|
||||
[ReceiptType.Read]: {
|
||||
[myUserId]: { ts: 200, thread_id: "$threadId" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
room.addReceipt(receipt);
|
||||
|
||||
const { thread, events } = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: "@alice:example.org",
|
||||
participantUserIds: ["@alice:example.org"],
|
||||
length: 2,
|
||||
ts: 10,
|
||||
});
|
||||
|
||||
// This is marked as read as it is before alice's first threaded receipt...
|
||||
expect(thread.getEventReadUpTo(myUserId)).toBe(events.at(-1)!.getId());
|
||||
|
||||
const { thread: thread2 } = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: "@alice:example.org",
|
||||
participantUserIds: ["@alice:example.org"],
|
||||
length: 2,
|
||||
ts: 1000,
|
||||
});
|
||||
|
||||
// Nothing has been read, this thread is after the first threaded receipt...
|
||||
expect(thread2.getEventReadUpTo(myUserId)).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { Feature, ServerSupport } from "../../src/feature";
|
||||
import {
|
||||
EventType,
|
||||
@@ -64,6 +65,30 @@ describe("fixNotificationCountOnDecryption", () => {
|
||||
});
|
||||
|
||||
room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "");
|
||||
|
||||
const receipt = new MatrixEvent({
|
||||
type: "m.receipt",
|
||||
room_id: "!foo:bar",
|
||||
content: {
|
||||
"$event0:localhost": {
|
||||
[ReceiptType.Read]: {
|
||||
[mockClient.getUserId()!]: { ts: 123 },
|
||||
},
|
||||
},
|
||||
"$event1:localhost": {
|
||||
[ReceiptType.Read]: {
|
||||
[mockClient.getUserId()!]: { ts: 666, thread_id: THREAD_ID },
|
||||
},
|
||||
},
|
||||
"$otherevent:localhost": {
|
||||
[ReceiptType.Read]: {
|
||||
[mockClient.getUserId()!]: { ts: 999, thread_id: "$otherthread:localhost" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
room.addReceipt(receipt);
|
||||
|
||||
room.setUnreadNotificationCount(NotificationCountType.Total, 1);
|
||||
room.setUnreadNotificationCount(NotificationCountType.Highlight, 0);
|
||||
|
||||
@@ -75,6 +100,7 @@ describe("fixNotificationCountOnDecryption", () => {
|
||||
body: "Hello world!",
|
||||
},
|
||||
event: true,
|
||||
ts: 1234,
|
||||
},
|
||||
mockClient,
|
||||
);
|
||||
@@ -90,6 +116,7 @@ describe("fixNotificationCountOnDecryption", () => {
|
||||
"msgtype": MsgType.Text,
|
||||
"body": "Thread reply",
|
||||
},
|
||||
ts: 5678,
|
||||
event: true,
|
||||
});
|
||||
room.createThread(THREAD_ID, event, [threadEvent], false);
|
||||
@@ -155,6 +182,7 @@ describe("fixNotificationCountOnDecryption", () => {
|
||||
"msgtype": MsgType.Text,
|
||||
"body": "Thread reply",
|
||||
},
|
||||
ts: 8901,
|
||||
event: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -14,13 +14,21 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { M_POLL_START } from "matrix-events-sdk";
|
||||
|
||||
import { EventTimelineSet } from "../../src/models/event-timeline-set";
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { Room } from "../../src/models/room";
|
||||
import { Relations } from "../../src/models/relations";
|
||||
import { Relations, RelationsEvent } from "../../src/models/relations";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { RelationType } from "../../src";
|
||||
import { logger } from "../../src/logger";
|
||||
|
||||
describe("Relations", function () {
|
||||
afterEach(() => {
|
||||
jest.spyOn(logger, "error").mockRestore();
|
||||
});
|
||||
|
||||
it("should deduplicate annotations", function () {
|
||||
const room = new Room("room123", null!, null!);
|
||||
const relations = new Relations("m.annotation", "m.reaction", room);
|
||||
@@ -75,6 +83,92 @@ describe("Relations", function () {
|
||||
}
|
||||
});
|
||||
|
||||
describe("addEvent()", () => {
|
||||
const relationType = RelationType.Reference;
|
||||
const eventType = M_POLL_START.stable!;
|
||||
const altEventTypes = [M_POLL_START.unstable!];
|
||||
const room = new Room("room123", null!, null!);
|
||||
|
||||
it("should not add events without a relation", async () => {
|
||||
// dont pollute console
|
||||
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
|
||||
const relations = new Relations(relationType, eventType, room);
|
||||
const emitSpy = jest.spyOn(relations, "emit");
|
||||
const event = new MatrixEvent({ type: eventType });
|
||||
|
||||
await relations.addEvent(event);
|
||||
expect(logSpy).toHaveBeenCalledWith("Event must have relation info");
|
||||
// event not added
|
||||
expect(relations.getRelations().length).toBe(0);
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not add events of incorrect event type", async () => {
|
||||
// dont pollute console
|
||||
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
|
||||
const relations = new Relations(relationType, eventType, room);
|
||||
const emitSpy = jest.spyOn(relations, "emit");
|
||||
const event = new MatrixEvent({
|
||||
type: "different-event-type",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o",
|
||||
rel_type: relationType,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await relations.addEvent(event);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(`Event relation info doesn't match this container`);
|
||||
// event not added
|
||||
expect(relations.getRelations().length).toBe(0);
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds events that match alt event types", async () => {
|
||||
const relations = new Relations(relationType, eventType, room, altEventTypes);
|
||||
const emitSpy = jest.spyOn(relations, "emit");
|
||||
const event = new MatrixEvent({
|
||||
type: M_POLL_START.unstable!,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o",
|
||||
rel_type: relationType,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await relations.addEvent(event);
|
||||
|
||||
// event added
|
||||
expect(relations.getRelations()).toEqual([event]);
|
||||
expect(emitSpy).toHaveBeenCalledWith(RelationsEvent.Add, event);
|
||||
});
|
||||
|
||||
it("should not add events of incorrect relation type", async () => {
|
||||
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
|
||||
const relations = new Relations(relationType, eventType, room);
|
||||
const event = new MatrixEvent({
|
||||
type: eventType,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o",
|
||||
rel_type: "m.annotation",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await relations.addEvent(event);
|
||||
const emitSpy = jest.spyOn(relations, "emit");
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(`Event relation info doesn't match this container`);
|
||||
// event not added
|
||||
expect(relations.getRelations().length).toBe(0);
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit created regardless of ordering", async function () {
|
||||
const targetEvent = new MatrixEvent({
|
||||
sender: "@bob:example.com",
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
IRelationsRequestOpts,
|
||||
IStateEventWithRoomId,
|
||||
JoinRule,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
PendingEventOrdering,
|
||||
@@ -49,6 +50,7 @@ import { ReceiptType, WrappedReceipt } from "../../src/@types/read_receipts";
|
||||
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread";
|
||||
import { Crypto } from "../../src/crypto";
|
||||
import { mkThread } from "../test-utils/thread";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../test-utils/client";
|
||||
|
||||
describe("Room", function () {
|
||||
const roomId = "!foo:bar";
|
||||
@@ -3225,4 +3227,60 @@ describe("Room", function () {
|
||||
expect(room.getBlacklistUnverifiedDevices()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findPredecessorRoomId", () => {
|
||||
let client: MatrixClient | null = null;
|
||||
beforeEach(() => {
|
||||
client = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(),
|
||||
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
});
|
||||
|
||||
function roomCreateEvent(newRoomId: string, predecessorRoomId: string | null): MatrixEvent {
|
||||
const content: {
|
||||
creator: string;
|
||||
["m.federate"]: boolean;
|
||||
room_version: string;
|
||||
predecessor: { event_id: string; room_id: string } | undefined;
|
||||
} = {
|
||||
"creator": "@daryl:alexandria.example.com",
|
||||
"predecessor": undefined,
|
||||
"m.federate": true,
|
||||
"room_version": "9",
|
||||
};
|
||||
if (predecessorRoomId) {
|
||||
content.predecessor = {
|
||||
event_id: "id_of_last_known_event",
|
||||
room_id: predecessorRoomId,
|
||||
};
|
||||
}
|
||||
return new MatrixEvent({
|
||||
content,
|
||||
event_id: `create_event_id_pred_${predecessorRoomId}`,
|
||||
origin_server_ts: 1432735824653,
|
||||
room_id: newRoomId,
|
||||
sender: "@daryl:alexandria.example.com",
|
||||
state_key: "",
|
||||
type: "m.room.create",
|
||||
});
|
||||
}
|
||||
|
||||
it("Returns null if there is no create event", () => {
|
||||
const room = new Room("roomid", client!, "@u:example.com");
|
||||
expect(room.findPredecessorRoomId()).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();
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import "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 { 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";
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
describe("RustCrypto", () => {
|
||||
const TEST_USER = "@alice:example.com";
|
||||
const TEST_DEVICE_ID = "TEST_DEVICE";
|
||||
|
||||
describe(".exportRoomKeys", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockHttpApi = {} as MatrixClient["http"];
|
||||
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
|
||||
});
|
||||
|
||||
it("should return a list", async () => {
|
||||
const keys = await rustCrypto.exportRoomKeys();
|
||||
expect(Array.isArray(keys)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("to-device messages", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockHttpApi = {} as MatrixClient["http"];
|
||||
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
|
||||
});
|
||||
|
||||
it("should pass through unencrypted to-device messages", async () => {
|
||||
const inputs: IToDeviceEvent[] = [
|
||||
{ content: { key: "value" }, type: "org.matrix.test", sender: "@alice:example.com" },
|
||||
];
|
||||
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
|
||||
expect(res).toEqual(inputs);
|
||||
});
|
||||
|
||||
it("should pass through bad encrypted messages", async () => {
|
||||
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
|
||||
const keys = olmMachine.identityKeys;
|
||||
const inputs: IToDeviceEvent[] = [
|
||||
{
|
||||
type: "m.room.encrypted",
|
||||
content: {
|
||||
algorithm: "m.olm.v1.curve25519-aes-sha2",
|
||||
sender_key: "IlRMeOPX2e0MurIyfWEucYBRVOEEUMrOHqn/8mLqMjA",
|
||||
ciphertext: {
|
||||
[keys.curve25519.toBase64()]: {
|
||||
type: 0,
|
||||
body: "ajyjlghi",
|
||||
},
|
||||
},
|
||||
},
|
||||
sender: "@alice:example.com",
|
||||
},
|
||||
];
|
||||
|
||||
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
|
||||
expect(res).toEqual(inputs);
|
||||
});
|
||||
});
|
||||
|
||||
describe("outgoing requests", () => {
|
||||
/** the RustCrypto implementation under test */
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
/** A mock http backend which rustCrypto is connected to */
|
||||
let httpBackend: MockHttpBackend;
|
||||
|
||||
/** a mocked-up OlmMachine which rustCrypto is connected to */
|
||||
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
/** A list of results to be returned from olmMachine.outgoingRequest. Each call will shift a result off
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 = [];
|
||||
olmMachine = {
|
||||
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);
|
||||
});
|
||||
|
||||
it("should poll for outgoing messages", () => {
|
||||
rustCrypto.onSyncCompleted({});
|
||||
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, "");
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
rustCrypto.onSyncCompleted({});
|
||||
|
||||
expect(rustCrypto["outgoingRequestLoopRunning"]).toBeTruthy();
|
||||
|
||||
// go a couple of times round the loop
|
||||
await httpBackend.flush("/_matrix", 1);
|
||||
await awaitCallToMarkAsSent();
|
||||
|
||||
await httpBackend.flush("/_matrix", 1);
|
||||
await awaitCallToMarkAsSent();
|
||||
|
||||
// a second sync while this is going on shouldn't make any difference
|
||||
rustCrypto.onSyncCompleted({});
|
||||
|
||||
await httpBackend.flush("/_matrix", 1);
|
||||
await awaitCallToMarkAsSent();
|
||||
|
||||
// now stop...
|
||||
rustCrypto.stop();
|
||||
|
||||
// which should (eventually) cause the loop to stop with no further calls to outgoingRequests
|
||||
olmMachine.outgoingRequests.mockReset();
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
expect(rustCrypto["outgoingRequestLoopRunning"]).toBeFalsy();
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
expect(olmMachine.outgoingRequests).not.toHaveBeenCalled();
|
||||
|
||||
// we sent three, so there should be 2 left
|
||||
expect(outgoingRequestQueue.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent, MemoryStore } from "../../../src";
|
||||
|
||||
describe("MemoryStore", () => {
|
||||
const event1 = new MatrixEvent({ type: "event1-type", content: { test: 1 } });
|
||||
const event2 = new MatrixEvent({ type: "event2-type", content: { test: 1 } });
|
||||
const event3 = new MatrixEvent({ type: "event3-type", content: { test: 1 } });
|
||||
const event4 = new MatrixEvent({ type: "event4-type", content: { test: 1 } });
|
||||
const event4Updated = new MatrixEvent({ type: "event4-type", content: { test: 2 } });
|
||||
const event1Empty = new MatrixEvent({ type: "event1-type", content: {} });
|
||||
|
||||
describe("account data", () => {
|
||||
it("sets account data events correctly", () => {
|
||||
const store = new MemoryStore();
|
||||
store.storeAccountDataEvents([event1, event2]);
|
||||
expect(store.getAccountData(event1.getType())).toEqual(event1);
|
||||
expect(store.getAccountData(event2.getType())).toEqual(event2);
|
||||
});
|
||||
|
||||
it("returns undefined when no account data event exists for type", () => {
|
||||
const store = new MemoryStore();
|
||||
expect(store.getAccountData("my-event-type")).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("updates account data events correctly", () => {
|
||||
const store = new MemoryStore();
|
||||
// init store with event1, event2
|
||||
store.storeAccountDataEvents([event1, event2, event4]);
|
||||
// remove event1, add event3
|
||||
store.storeAccountDataEvents([event1Empty, event3, event4Updated]);
|
||||
// removed
|
||||
expect(store.getAccountData(event1.getType())).toEqual(undefined);
|
||||
// not removed
|
||||
expect(store.getAccountData(event2.getType())).toEqual(event2);
|
||||
// added
|
||||
expect(store.getAccountData(event3.getType())).toEqual(event3);
|
||||
// updated
|
||||
expect(store.getAccountData(event4.getType())).toEqual(event4Updated);
|
||||
});
|
||||
|
||||
it("removes all account data from state on deleteAllData", async () => {
|
||||
const store = new MemoryStore();
|
||||
store.storeAccountDataEvents([event1, event2]);
|
||||
await store.deleteAllData();
|
||||
|
||||
// empty object
|
||||
expect(store.accountData).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -142,6 +142,15 @@ describe("Group Call", function () {
|
||||
} as unknown as RoomMember;
|
||||
});
|
||||
|
||||
it.each(Object.values(GroupCallState).filter((v) => v !== GroupCallState.LocalCallFeedUninitialized))(
|
||||
"throws when initializing local call feed in %s state",
|
||||
async (state: GroupCallState) => {
|
||||
// @ts-ignore
|
||||
groupCall.state = state;
|
||||
await expect(groupCall.initLocalCallFeed()).rejects.toThrowError();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not initialize local call feed, if it already is", async () => {
|
||||
await groupCall.initLocalCallFeed();
|
||||
jest.spyOn(groupCall, "initLocalCallFeed");
|
||||
@@ -308,6 +317,17 @@ describe("Group Call", function () {
|
||||
}
|
||||
});
|
||||
|
||||
describe("hasLocalParticipant()", () => {
|
||||
it("should return false, if we don't have a local participant", () => {
|
||||
expect(groupCall.hasLocalParticipant()).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should return true, if we do have local participant", async () => {
|
||||
await groupCall.enter();
|
||||
expect(groupCall.hasLocalParticipant()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("call feeds changing", () => {
|
||||
let call: MockMatrixCall;
|
||||
const currentFeed = new MockCallFeed(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, new MockMediaStream("current"));
|
||||
@@ -475,7 +495,7 @@ describe("Group Call", function () {
|
||||
const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId);
|
||||
// @ts-ignore
|
||||
groupCall.calls.set(
|
||||
mockCall.getOpponentMember() as RoomMember,
|
||||
mockCall.getOpponentMember().userId!,
|
||||
new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]),
|
||||
);
|
||||
|
||||
@@ -501,7 +521,7 @@ describe("Group Call", function () {
|
||||
const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId);
|
||||
// @ts-ignore
|
||||
groupCall.calls.set(
|
||||
mockCall.getOpponentMember() as RoomMember,
|
||||
mockCall.getOpponentMember().userId!,
|
||||
new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]),
|
||||
);
|
||||
|
||||
@@ -663,9 +683,7 @@ describe("Group Call", function () {
|
||||
expect(client1.sendToDevice).toHaveBeenCalled();
|
||||
|
||||
// @ts-ignore
|
||||
const oldCall = groupCall1.calls
|
||||
.get(groupCall1.room.getMember(client2.userId)!)!
|
||||
.get(client2.deviceId)!;
|
||||
const oldCall = groupCall1.calls.get(client2.userId)!.get(client2.deviceId)!;
|
||||
oldCall.emit(CallEvent.Hangup, oldCall!);
|
||||
|
||||
client1.sendToDevice.mockClear();
|
||||
@@ -685,9 +703,7 @@ describe("Group Call", function () {
|
||||
let newCall: MatrixCall | undefined;
|
||||
while (
|
||||
// @ts-ignore
|
||||
(newCall = groupCall1.calls
|
||||
.get(groupCall1.room.getMember(client2.userId)!)
|
||||
?.get(client2.deviceId)) === undefined ||
|
||||
(newCall = groupCall1.calls.get(client2.userId)?.get(client2.deviceId)) === undefined ||
|
||||
newCall.peerConn === undefined ||
|
||||
newCall.callId == oldCall.callId
|
||||
) {
|
||||
@@ -730,7 +746,7 @@ describe("Group Call", function () {
|
||||
groupCall1.setLocalVideoMuted(false);
|
||||
|
||||
// @ts-ignore
|
||||
const call = groupCall1.calls.get(groupCall1.room.getMember(client2.userId)!)!.get(client2.deviceId)!;
|
||||
const call = groupCall1.calls.get(client2.userId)!.get(client2.deviceId)!;
|
||||
call.isMicrophoneMuted = jest.fn().mockReturnValue(true);
|
||||
call.setMicrophoneMuted = jest.fn();
|
||||
call.isLocalVideoMuted = jest.fn().mockReturnValue(true);
|
||||
@@ -794,7 +810,10 @@ describe("Group Call", function () {
|
||||
it("should mute local video when calling setLocalVideoMuted()", async () => {
|
||||
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
||||
|
||||
groupCall.localCallFeed!.setAudioVideoMuted = jest.fn();
|
||||
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream");
|
||||
jest.spyOn(groupCall, "updateLocalUsermediaStream");
|
||||
jest.spyOn(groupCall.localCallFeed!, "setAudioVideoMuted");
|
||||
|
||||
const setAVMutedArray: ((audioMuted: boolean | null, videoMuted: boolean | null) => void)[] = [];
|
||||
const tracksArray: MediaStreamTrack[] = [];
|
||||
const sendMetadataUpdateArray: (() => Promise<void>)[] = [];
|
||||
@@ -808,7 +827,8 @@ describe("Group Call", function () {
|
||||
await groupCall.setLocalVideoMuted(true);
|
||||
|
||||
groupCall.localCallFeed!.stream.getVideoTracks().forEach((track) => expect(track.enabled).toBe(false));
|
||||
expect(groupCall.localCallFeed!.setAudioVideoMuted).toHaveBeenCalledWith(null, true);
|
||||
expect(mockClient.getMediaHandler().getUserMediaStream).toHaveBeenCalledWith(true, false);
|
||||
expect(groupCall.updateLocalUsermediaStream).toHaveBeenCalled();
|
||||
setAVMutedArray.forEach((f) => expect(f).toHaveBeenCalledWith(null, true));
|
||||
tracksArray.forEach((track) => expect(track.enabled).toBe(false));
|
||||
sendMetadataUpdateArray.forEach((f) => expect(f).toHaveBeenCalled());
|
||||
@@ -839,7 +859,7 @@ describe("Group Call", function () {
|
||||
await sleep(10);
|
||||
|
||||
// @ts-ignore
|
||||
const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!;
|
||||
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
|
||||
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
|
||||
// @ts-ignore Mock
|
||||
call.pushRemoteFeed(
|
||||
@@ -866,7 +886,7 @@ describe("Group Call", function () {
|
||||
await sleep(10);
|
||||
|
||||
// @ts-ignore
|
||||
const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!;
|
||||
const call = groupCall.calls.get(FAKE_USER_ID_2).get(FAKE_DEVICE_ID_2)!;
|
||||
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
|
||||
// @ts-ignore Mock
|
||||
call.pushRemoteFeed(
|
||||
@@ -943,9 +963,7 @@ describe("Group Call", function () {
|
||||
expect(mockCall.reject).not.toHaveBeenCalled();
|
||||
expect(mockCall.answerWithCallFeeds).toHaveBeenCalled();
|
||||
// @ts-ignore
|
||||
expect(groupCall.calls).toEqual(
|
||||
new Map([[groupCall.room.getMember(FAKE_USER_ID_1)!, new Map([[FAKE_DEVICE_ID_1, mockCall]])]]),
|
||||
);
|
||||
expect(groupCall.calls).toEqual(new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_1, mockCall]])]]));
|
||||
});
|
||||
|
||||
it("replaces calls if it already has one with the same user", async () => {
|
||||
@@ -960,9 +978,7 @@ describe("Group Call", function () {
|
||||
expect(oldMockCall.hangup).toHaveBeenCalled();
|
||||
expect(newMockCall.answerWithCallFeeds).toHaveBeenCalled();
|
||||
// @ts-ignore
|
||||
expect(groupCall.calls).toEqual(
|
||||
new Map([[groupCall.room.getMember(FAKE_USER_ID_1)!, new Map([[FAKE_DEVICE_ID_1, newMockCall]])]]),
|
||||
);
|
||||
expect(groupCall.calls).toEqual(new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_1, newMockCall]])]]));
|
||||
});
|
||||
|
||||
it("starts to process incoming calls when we've entered", async () => {
|
||||
@@ -975,6 +991,83 @@ describe("Group Call", function () {
|
||||
|
||||
expect(call.answerWithCallFeeds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("handles call being replaced", () => {
|
||||
let callChangedListener: jest.Mock;
|
||||
let oldMockCall: MockMatrixCall;
|
||||
let newMockCall: MockMatrixCall;
|
||||
let newCallsMap: Map<string, Map<string, MatrixCall>>;
|
||||
|
||||
beforeEach(() => {
|
||||
callChangedListener = jest.fn();
|
||||
groupCall.addListener(GroupCallEvent.CallsChanged, callChangedListener);
|
||||
|
||||
oldMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
||||
newMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
||||
newCallsMap = new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_1, newMockCall.typed()]])]]);
|
||||
|
||||
newMockCall.opponentMember = oldMockCall.opponentMember; // Ensure referential equality
|
||||
newMockCall.callId = "not " + oldMockCall.callId;
|
||||
mockClient.emit(CallEventHandlerEvent.Incoming, oldMockCall.typed());
|
||||
});
|
||||
|
||||
it("handles regular case", () => {
|
||||
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed());
|
||||
|
||||
expect(oldMockCall.hangup).toHaveBeenCalled();
|
||||
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);
|
||||
// @ts-ignore
|
||||
expect(groupCall.calls).toEqual(newCallsMap);
|
||||
});
|
||||
|
||||
it("handles case where call is missing from the calls map", () => {
|
||||
// @ts-ignore
|
||||
groupCall.calls = new Map();
|
||||
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed());
|
||||
|
||||
expect(oldMockCall.hangup).toHaveBeenCalled();
|
||||
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);
|
||||
// @ts-ignore
|
||||
expect(groupCall.calls).toEqual(newCallsMap);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles call being hangup", () => {
|
||||
let callChangedListener: jest.Mock;
|
||||
let mockCall: MockMatrixCall;
|
||||
|
||||
beforeEach(() => {
|
||||
callChangedListener = jest.fn();
|
||||
groupCall.addListener(GroupCallEvent.CallsChanged, callChangedListener);
|
||||
mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
||||
});
|
||||
|
||||
it("doesn't throw when calls map is empty", () => {
|
||||
// @ts-ignore
|
||||
expect(() => groupCall.onCallHangup(mockCall)).not.toThrow();
|
||||
});
|
||||
|
||||
it("clears map completely when we're the last users device left", () => {
|
||||
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall.typed());
|
||||
mockCall.emit(CallEvent.Hangup, mockCall.typed());
|
||||
// @ts-ignore
|
||||
expect(groupCall.calls).toEqual(new Map());
|
||||
});
|
||||
|
||||
it("doesn't remove another call of the same user", () => {
|
||||
const anotherCallOfTheSameUser = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
||||
anotherCallOfTheSameUser.callId = "another call id";
|
||||
anotherCallOfTheSameUser.getOpponentDeviceId = () => FAKE_DEVICE_ID_2;
|
||||
mockClient.emit(CallEventHandlerEvent.Incoming, anotherCallOfTheSameUser.typed());
|
||||
|
||||
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall.typed());
|
||||
mockCall.emit(CallEvent.Hangup, mockCall.typed());
|
||||
// @ts-ignore
|
||||
expect(groupCall.calls).toEqual(
|
||||
new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_2, anotherCallOfTheSameUser.typed()]])]]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("screensharing", () => {
|
||||
@@ -1039,7 +1132,7 @@ describe("Group Call", function () {
|
||||
await sleep(10);
|
||||
|
||||
// @ts-ignore
|
||||
const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!;
|
||||
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
|
||||
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
|
||||
call.onNegotiateReceived({
|
||||
getContent: () => ({
|
||||
|
||||
@@ -308,20 +308,18 @@ describe("Media Handler", function () {
|
||||
expect(stream2.isCloneOf(stream1)).toEqual(false);
|
||||
});
|
||||
|
||||
it("strips unwanted audio tracks from re-used stream", async () => {
|
||||
const stream1 = await mediaHandler.getUserMediaStream(true, true);
|
||||
const stream2 = (await mediaHandler.getUserMediaStream(false, true)) as unknown as MockMediaStream;
|
||||
it("creates new stream when we no longer want audio", async () => {
|
||||
await mediaHandler.getUserMediaStream(true, true);
|
||||
const stream = await mediaHandler.getUserMediaStream(false, true);
|
||||
|
||||
expect(stream2.isCloneOf(stream1)).toEqual(true);
|
||||
expect(stream2.getAudioTracks().length).toEqual(0);
|
||||
expect(stream.getAudioTracks().length).toEqual(0);
|
||||
});
|
||||
|
||||
it("strips unwanted video tracks from re-used stream", async () => {
|
||||
const stream1 = await mediaHandler.getUserMediaStream(true, true);
|
||||
const stream2 = (await mediaHandler.getUserMediaStream(true, false)) as unknown as MockMediaStream;
|
||||
it("creates new stream when we no longer want video", async () => {
|
||||
await mediaHandler.getUserMediaStream(true, true);
|
||||
const stream = await mediaHandler.getUserMediaStream(true, false);
|
||||
|
||||
expect(stream2.isCloneOf(stream1)).toEqual(true);
|
||||
expect(stream2.getVideoTracks().length).toEqual(0);
|
||||
expect(stream.getVideoTracks().length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -44,3 +44,29 @@ export interface IEventDecryptionResult {
|
||||
claimedEd25519Key?: string;
|
||||
untrusted?: boolean;
|
||||
}
|
||||
|
||||
interface Extensible {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
/** The result of a call to {@link MatrixClient.exportRoomKeys} */
|
||||
export interface IMegolmSessionData extends Extensible {
|
||||
/** Sender's Curve25519 device key */
|
||||
sender_key: string;
|
||||
/** Devices which forwarded this session to us (normally empty). */
|
||||
forwarding_curve25519_key_chain: string[];
|
||||
/** Other keys the sender claims. */
|
||||
sender_claimed_keys: Record<string, string>;
|
||||
/** Room this session is used in */
|
||||
room_id: string;
|
||||
/** Unique id for the session */
|
||||
session_id: string;
|
||||
/** Base64'ed key data */
|
||||
session_key: string;
|
||||
algorithm?: string;
|
||||
untrusted?: boolean;
|
||||
}
|
||||
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
+11
-1
@@ -61,11 +61,12 @@ export enum EventType {
|
||||
KeyVerificationDone = "m.key.verification.done",
|
||||
KeyVerificationKey = "m.key.verification.key",
|
||||
KeyVerificationAccept = "m.key.verification.accept",
|
||||
// XXX this event is not yet supported by js-sdk
|
||||
// Not used directly - see READY_TYPE in VerificationRequest.
|
||||
KeyVerificationReady = "m.key.verification.ready",
|
||||
// use of this is discouraged https://matrix.org/docs/spec/client_server/r0.6.1#m-room-message-feedback
|
||||
RoomMessageFeedback = "m.room.message.feedback",
|
||||
Reaction = "m.reaction",
|
||||
PollStart = "org.matrix.msc3381.poll.start",
|
||||
|
||||
// Room ephemeral events
|
||||
Typing = "m.typing",
|
||||
@@ -165,6 +166,15 @@ export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix
|
||||
*/
|
||||
export const UNSTABLE_MSC2716_MARKER = new UnstableValue("m.room.marker", "org.matrix.msc2716.marker");
|
||||
|
||||
/**
|
||||
* Name of the "with_relations" request property for relation based redactions.
|
||||
* {@link https://github.com/matrix-org/matrix-spec-proposals/pull/3912}
|
||||
*/
|
||||
export const MSC3912_RELATION_BASED_REDACTIONS_PROP = new UnstableValue(
|
||||
"with_relations",
|
||||
"org.matrix.msc3912.with_relations",
|
||||
);
|
||||
|
||||
/**
|
||||
* Functional members type for declaring a purpose of room members (e.g. helpful bots).
|
||||
* Note that this reference is UNSTABLE and subject to breaking changes, including its
|
||||
|
||||
@@ -54,3 +54,11 @@ export type Receipts = {
|
||||
[userId: string]: [WrappedReceipt | null, WrappedReceipt | null]; // Pair<real receipt, synthetic receipt> (both nullable)
|
||||
};
|
||||
};
|
||||
|
||||
export type CachedReceiptStructure = {
|
||||
eventId: string;
|
||||
receiptType: string | ReceiptType;
|
||||
userId: string;
|
||||
receipt: Receipt;
|
||||
synthetic: boolean;
|
||||
};
|
||||
|
||||
+13
-1
@@ -21,7 +21,7 @@ import { IRoomEventFilter } from "../filter";
|
||||
import { Direction } from "../models/event-timeline";
|
||||
import { PushRuleAction } from "./PushRules";
|
||||
import { IRoomEvent } from "../sync-accumulator";
|
||||
import { EventType, RoomType } from "./event";
|
||||
import { EventType, RelationType, RoomType } from "./event";
|
||||
|
||||
// allow camelcase as these are things that go onto the wire
|
||||
/* eslint-disable camelcase */
|
||||
@@ -47,6 +47,18 @@ export interface IJoinRoomOpts {
|
||||
|
||||
export interface IRedactOpts {
|
||||
reason?: string;
|
||||
/**
|
||||
* Whether events related to the redacted event should be redacted.
|
||||
*
|
||||
* If specified, then any events which relate to the event being redacted with
|
||||
* any of the relationship types listed will also be redacted.
|
||||
*
|
||||
* <b>Raises an Error if the server does not support it.</b>
|
||||
* Check for server-side support before using this param with
|
||||
* <code>client.canSupport.get(Feature.RelationBasedRedactions)</code>.
|
||||
* {@link https://github.com/matrix-org/matrix-spec-proposals/pull/3912}
|
||||
*/
|
||||
with_relations?: Array<RelationType | string>;
|
||||
}
|
||||
|
||||
export interface ISendEventResponse {
|
||||
|
||||
@@ -16,9 +16,11 @@ limitations under the License.
|
||||
|
||||
import { ToDeviceMessageId } from "./@types/event";
|
||||
import { logger } from "./logger";
|
||||
import { MatrixError, MatrixClient } from "./matrix";
|
||||
import { MatrixClient, ClientEvent } from "./client";
|
||||
import { MatrixError } from "./http-api";
|
||||
import { IndexedToDeviceBatch, ToDeviceBatch, ToDeviceBatchWithTxnId, ToDevicePayload } from "./models/ToDeviceMessage";
|
||||
import { MatrixScheduler } from "./scheduler";
|
||||
import { SyncState } from "./sync";
|
||||
|
||||
const MAX_BATCH_SIZE = 20;
|
||||
|
||||
@@ -37,12 +39,14 @@ export class ToDeviceMessageQueue {
|
||||
public start(): void {
|
||||
this.running = true;
|
||||
this.sendQueue();
|
||||
this.client.on(ClientEvent.Sync, this.onResumedSync);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.running = false;
|
||||
if (this.retryTimeout !== null) clearTimeout(this.retryTimeout);
|
||||
this.retryTimeout = null;
|
||||
this.client.removeListener(ClientEvent.Sync, this.onResumedSync);
|
||||
}
|
||||
|
||||
public async queueBatch(batch: ToDeviceBatch): Promise<void> {
|
||||
@@ -132,4 +136,15 @@ export class ToDeviceMessageQueue {
|
||||
|
||||
await this.client.sendToDevice(batch.eventType, contentMap, batch.txnId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to sync state changes and automatically resend any pending events
|
||||
* once syncing is resumed
|
||||
*/
|
||||
private onResumedSync = (state: SyncState | null, oldState: SyncState | null): void => {
|
||||
if (state === SyncState.Syncing && oldState !== SyncState.Syncing) {
|
||||
logger.info(`Resuming queue after resumed sync`);
|
||||
this.sendQueue();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,9 +214,9 @@ export class AutoDiscovery {
|
||||
|
||||
// Step 5b: Verify there is an identity server listening on the provided
|
||||
// URL.
|
||||
const isResponse = await this.fetchWellKnownObject(`${isUrl}/_matrix/identity/api/v1`);
|
||||
const isResponse = await this.fetchWellKnownObject(`${isUrl}/_matrix/identity/v2`);
|
||||
if (!isResponse?.raw || isResponse.action !== AutoDiscoveryAction.SUCCESS) {
|
||||
logger.error("Invalid /api/v1 response");
|
||||
logger.error("Invalid /v2 response");
|
||||
failingClientConfig["m.identity_server"].error = AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER;
|
||||
|
||||
// Supply the base_url to the caller because they may be ignoring
|
||||
|
||||
+220
-96
@@ -18,9 +18,10 @@ limitations under the License.
|
||||
* This is an internal module. See {@link MatrixClient} for the public class.
|
||||
*/
|
||||
|
||||
import { EmoteEvent, IPartialEvent, MessageEvent, NoticeEvent, Optional } from "matrix-events-sdk";
|
||||
import { Optional } from "matrix-events-sdk";
|
||||
|
||||
import { ISyncStateData, SyncApi, SyncState } from "./sync";
|
||||
import type { IMegolmSessionData } from "./@types/crypto";
|
||||
import { ISyncStateData, SyncApi, SyncApiOptions, SyncState } from "./sync";
|
||||
import {
|
||||
EventStatus,
|
||||
IContent,
|
||||
@@ -74,7 +75,6 @@ import {
|
||||
ICryptoCallbacks,
|
||||
IBootstrapCrossSigningOpts,
|
||||
ICheckOwnCrossSigningTrustOpts,
|
||||
IMegolmSessionData,
|
||||
isCryptoAvailable,
|
||||
VerificationMethod,
|
||||
IRoomKeyRequestBody,
|
||||
@@ -154,6 +154,7 @@ import {
|
||||
UNSTABLE_MSC3088_ENABLED,
|
||||
UNSTABLE_MSC3088_PURPOSE,
|
||||
UNSTABLE_MSC3089_TREE_SUBTYPE,
|
||||
MSC3912_RELATION_BASED_REDACTIONS_PROP,
|
||||
} from "./@types/event";
|
||||
import { IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials";
|
||||
import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper";
|
||||
@@ -211,6 +212,7 @@ 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";
|
||||
|
||||
export type Store = IStore;
|
||||
|
||||
@@ -454,17 +456,7 @@ export interface IStartClientOpts {
|
||||
slidingSync?: SlidingSync;
|
||||
}
|
||||
|
||||
export interface IStoredClientOpts extends IStartClientOpts {
|
||||
// Crypto manager
|
||||
crypto?: Crypto;
|
||||
/**
|
||||
* A function which is called
|
||||
* with a room ID and returns a boolean. It should return 'true' if the SDK can
|
||||
* SAFELY remove events from this room. It may not be safe to remove events if
|
||||
* there are other references to the timelines for this room.
|
||||
*/
|
||||
canResetEntireTimeline: ResetTimelineCallback;
|
||||
}
|
||||
export interface IStoredClientOpts extends IStartClientOpts {}
|
||||
|
||||
export enum RoomVersionStability {
|
||||
Stable = "stable",
|
||||
@@ -840,6 +832,11 @@ interface ITimestampToEventResponse {
|
||||
event_id: string;
|
||||
origin_server_ts: string;
|
||||
}
|
||||
|
||||
interface IWhoamiResponse {
|
||||
user_id: string;
|
||||
device_id?: string;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
// We're using this constant for methods overloading and inspect whether a variable
|
||||
@@ -1426,20 +1423,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
logger.error("Can't fetch server versions, continuing to initialise sync, this will be retried later", e);
|
||||
}
|
||||
|
||||
// shallow-copy the opts dict before modifying and storing it
|
||||
this.clientOpts = Object.assign({}, opts) as IStoredClientOpts;
|
||||
this.clientOpts.crypto = this.crypto;
|
||||
this.clientOpts.canResetEntireTimeline = (roomId): boolean => {
|
||||
if (!this.canResetTimelineCallback) {
|
||||
return false;
|
||||
}
|
||||
return this.canResetTimelineCallback(roomId);
|
||||
};
|
||||
this.clientOpts = opts ?? {};
|
||||
if (this.clientOpts.slidingSync) {
|
||||
this.syncApi = new SlidingSyncSdk(this.clientOpts.slidingSync, this, this.clientOpts);
|
||||
this.syncApi = new SlidingSyncSdk(
|
||||
this.clientOpts.slidingSync,
|
||||
this,
|
||||
this.clientOpts,
|
||||
this.buildSyncApiOptions(),
|
||||
);
|
||||
} else {
|
||||
this.syncApi = new SyncApi(this, this.clientOpts);
|
||||
this.syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
|
||||
}
|
||||
|
||||
this.syncApi.sync();
|
||||
|
||||
if (this.clientOpts.clientWellKnownPollPeriod !== undefined) {
|
||||
@@ -1452,6 +1447,22 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.toDeviceMessageQueue.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a SyncApiOptions for this client, suitable for passing into the SyncApi constructor
|
||||
*/
|
||||
protected buildSyncApiOptions(): SyncApiOptions {
|
||||
return {
|
||||
crypto: this.crypto,
|
||||
cryptoCallbacks: this.cryptoBackend,
|
||||
canResetEntireTimeline: (roomId: string): boolean => {
|
||||
if (!this.canResetTimelineCallback) {
|
||||
return false;
|
||||
}
|
||||
return this.canResetTimelineCallback(roomId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* High level helper method to stop the client from polling and allow a
|
||||
* clean shutdown.
|
||||
@@ -1657,6 +1668,41 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (this.cryptoStore) {
|
||||
promises.push(this.cryptoStore.deleteAllData());
|
||||
}
|
||||
|
||||
// delete the stores used by the rust matrix-sdk-crypto, in case they were used
|
||||
const deleteRustSdkStore = async (): Promise<void> => {
|
||||
let indexedDB: IDBFactory;
|
||||
try {
|
||||
indexedDB = global.indexedDB;
|
||||
} catch (e) {
|
||||
// No indexeddb support
|
||||
return;
|
||||
}
|
||||
for (const dbname of [
|
||||
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`,
|
||||
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
|
||||
]) {
|
||||
const prom = new Promise((resolve, reject) => {
|
||||
logger.info(`Removing IndexedDB instance ${dbname}`);
|
||||
const req = indexedDB.deleteDatabase(dbname);
|
||||
req.onsuccess = (_): void => {
|
||||
logger.info(`Removed IndexedDB instance ${dbname}`);
|
||||
resolve(0);
|
||||
};
|
||||
req.onerror = (e): void => {
|
||||
logger.error(`Failed to remove IndexedDB instance ${dbname}: ${e}`);
|
||||
reject(new Error(`Error clearing storage: ${e}`));
|
||||
};
|
||||
req.onblocked = (e): void => {
|
||||
logger.info(`cannot yet remove IndexedDB instance ${dbname}`);
|
||||
//reject(new Error(`Error clearing storage: ${e}`));
|
||||
};
|
||||
});
|
||||
await prom;
|
||||
}
|
||||
};
|
||||
promises.push(deleteRustSdkStore());
|
||||
|
||||
return Promise.all(promises).then(); // .then to fix types
|
||||
}
|
||||
|
||||
@@ -1672,6 +1718,20 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user-id of the logged-in user
|
||||
*
|
||||
* @returns MXID for the logged-in user
|
||||
* @throws Error if not logged in
|
||||
*/
|
||||
public getSafeUserId(): string {
|
||||
const userId = this.getUserId();
|
||||
if (!userId) {
|
||||
throw new Error("Expected logged in user but found none.");
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the domain for this client's MXID
|
||||
* @returns Domain of this MXID
|
||||
@@ -2051,6 +2111,46 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise support for end-to-end encryption in this client, using the rust matrix-sdk-crypto.
|
||||
*
|
||||
* An alternative to {@link initCrypto}.
|
||||
*
|
||||
* *WARNING*: this API is very experimental, should not be used in production, and may change without notice!
|
||||
* Eventually it will be deprecated and `initCrypto` will do the same thing.
|
||||
*
|
||||
* @experimental
|
||||
*
|
||||
* @returns a Promise which will resolve when the crypto layer has been
|
||||
* successfully initialised.
|
||||
*/
|
||||
public async initRustCrypto(): Promise<void> {
|
||||
if (this.cryptoBackend) {
|
||||
logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = this.getUserId();
|
||||
if (userId === null) {
|
||||
throw new Error(
|
||||
`Cannot enable encryption on MatrixClient with unknown userId: ` +
|
||||
`ensure userId is passed in createClient().`,
|
||||
);
|
||||
}
|
||||
const deviceId = this.getDeviceId();
|
||||
if (deviceId === null) {
|
||||
throw new Error(
|
||||
`Cannot enable encryption on MatrixClient with unknown deviceId: ` +
|
||||
`ensure deviceId is passed in createClient().`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is end-to-end crypto enabled for this client.
|
||||
* @returns True if end-to-end is enabled.
|
||||
@@ -2943,10 +3043,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* session export objects
|
||||
*/
|
||||
public exportRoomKeys(): Promise<IMegolmSessionData[]> {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
return Promise.reject(new Error("End-to-end encryption disabled"));
|
||||
}
|
||||
return this.crypto.exportRoomKeys();
|
||||
return this.cryptoBackend.exportRoomKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3670,13 +3770,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
const replacedRooms = new Set();
|
||||
for (const r of allRooms) {
|
||||
const createEvent = r.currentState.getStateEvents(EventType.RoomCreate, "");
|
||||
// invites are included in this list and we don't know their create events yet
|
||||
if (createEvent) {
|
||||
const predecessor = createEvent.getContent()["predecessor"];
|
||||
if (predecessor && predecessor["room_id"]) {
|
||||
replacedRooms.add(predecessor["room_id"]);
|
||||
}
|
||||
const predecessor = r.findPredecessorRoomId();
|
||||
if (predecessor) {
|
||||
replacedRooms.add(predecessor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3766,6 +3862,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteAccountData(eventType: string): Promise<void> {
|
||||
const msc3391DeleteAccountDataServerSupport = this.canSupport.get(Feature.AccountDataDeletion);
|
||||
// if deletion is not supported overwrite with empty content
|
||||
if (msc3391DeleteAccountDataServerSupport === ServerSupport.Unsupported) {
|
||||
await this.setAccountData(eventType, {});
|
||||
return;
|
||||
}
|
||||
const path = utils.encodeUri("/user/$userId/account_data/$type", {
|
||||
$userId: this.getSafeUserId(),
|
||||
$type: eventType,
|
||||
});
|
||||
const options =
|
||||
msc3391DeleteAccountDataServerSupport === ServerSupport.Unstable
|
||||
? { prefix: "/_matrix/client/unstable/org.matrix.msc3391" }
|
||||
: undefined;
|
||||
return await this.http.authedRequest(Method.Delete, path, undefined, undefined, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the users that are ignored by this client
|
||||
* @returns The array of users that are ignored (empty if none)
|
||||
@@ -3840,7 +3954,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
|
||||
|
||||
const roomId = res.room_id;
|
||||
const syncApi = new SyncApi(this, this.clientOpts);
|
||||
const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
|
||||
const room = syncApi.createRoom(roomId);
|
||||
if (opts.syncRoom) {
|
||||
// v2 will do this for us
|
||||
@@ -4336,9 +4450,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
/**
|
||||
* @param txnId - transaction id. One will be made up if not supplied.
|
||||
* @param opts - Options to pass on, may contain `reason`.
|
||||
* @param opts - Options to pass on, may contain `reason` and `with_relations` (MSC3912)
|
||||
* @returns Promise which resolves: TODO
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws Error if called with `with_relations` (MSC3912) but the server does not support it.
|
||||
* Callers should check whether the server supports MSC3912 via `MatrixClient.canSupport`.
|
||||
*/
|
||||
public redactEvent(
|
||||
roomId: string,
|
||||
@@ -4367,12 +4483,34 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
threadId = null;
|
||||
}
|
||||
const reason = opts?.reason;
|
||||
|
||||
if (
|
||||
opts?.with_relations &&
|
||||
this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Unsupported
|
||||
) {
|
||||
throw new Error(
|
||||
"Server does not support relation based redactions " +
|
||||
`roomId ${roomId} eventId ${eventId} txnId: ${txnId} threadId ${threadId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const withRelations = opts?.with_relations
|
||||
? {
|
||||
[this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Stable
|
||||
? MSC3912_RELATION_BASED_REDACTIONS_PROP.stable!
|
||||
: MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!]: opts?.with_relations,
|
||||
}
|
||||
: {};
|
||||
|
||||
return this.sendCompleteEvent(
|
||||
roomId,
|
||||
threadId,
|
||||
{
|
||||
type: EventType.RoomRedaction,
|
||||
content: { reason },
|
||||
content: {
|
||||
...withRelations,
|
||||
reason,
|
||||
},
|
||||
redacts: eventId,
|
||||
},
|
||||
txnId as string,
|
||||
@@ -4403,44 +4541,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
threadId = null;
|
||||
}
|
||||
|
||||
// Populate all outbound events with Extensible Events metadata to ensure there's a
|
||||
// reasonably large pool of messages to parse.
|
||||
let eventType: string = EventType.RoomMessage;
|
||||
let sendContent: IContent = content as IContent;
|
||||
const makeContentExtensible = (content: IContent = {}, recurse = true): IPartialEvent<object> | undefined => {
|
||||
let newEvent: IPartialEvent<IContent> | undefined;
|
||||
|
||||
if (content["msgtype"] === MsgType.Text) {
|
||||
newEvent = MessageEvent.from(content["body"], content["formatted_body"]).serialize();
|
||||
} else if (content["msgtype"] === MsgType.Emote) {
|
||||
newEvent = EmoteEvent.from(content["body"], content["formatted_body"]).serialize();
|
||||
} else if (content["msgtype"] === MsgType.Notice) {
|
||||
newEvent = NoticeEvent.from(content["body"], content["formatted_body"]).serialize();
|
||||
}
|
||||
|
||||
if (newEvent && content["m.new_content"] && recurse) {
|
||||
const newContent = makeContentExtensible(content["m.new_content"], false);
|
||||
if (newContent) {
|
||||
newEvent.content["m.new_content"] = newContent.content;
|
||||
}
|
||||
}
|
||||
|
||||
if (newEvent) {
|
||||
// copy over all other fields we don't know about
|
||||
for (const [k, v] of Object.entries(content)) {
|
||||
if (!newEvent.content.hasOwnProperty(k)) {
|
||||
newEvent.content[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newEvent;
|
||||
};
|
||||
const result = makeContentExtensible(sendContent);
|
||||
if (result) {
|
||||
eventType = result.type;
|
||||
sendContent = result.content;
|
||||
}
|
||||
const eventType: string = EventType.RoomMessage;
|
||||
const sendContent: IContent = content as IContent;
|
||||
|
||||
return this.sendEvent(roomId, threadId as string | null, eventType, sendContent, txnId);
|
||||
}
|
||||
@@ -6016,7 +6118,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*/
|
||||
public peekInRoom(roomId: string): Promise<Room> {
|
||||
this.peekSync?.stopPeeking();
|
||||
this.peekSync = new SyncApi(this, this.clientOpts);
|
||||
this.peekSync = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
|
||||
return this.peekSync.peek(roomId);
|
||||
}
|
||||
|
||||
@@ -6523,7 +6625,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (this.syncLeftRoomsPromise) {
|
||||
return this.syncLeftRoomsPromise; // return the ongoing request
|
||||
}
|
||||
const syncApi = new SyncApi(this, this.clientOpts);
|
||||
const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
|
||||
this.syncLeftRoomsPromise = syncApi.syncLeftRooms();
|
||||
|
||||
// cleanup locks
|
||||
@@ -9242,36 +9344,58 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user_id of the configured access token.
|
||||
* Fetches information about the user for the configured access token.
|
||||
*/
|
||||
public async whoami(): Promise<{ user_id: string }> {
|
||||
// eslint-disable-line camelcase
|
||||
public async whoami(): Promise<IWhoamiResponse> {
|
||||
return this.http.authedRequest(Method.Get, "/account/whoami");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the event_id closest to the given timestamp in the given direction.
|
||||
* @returns A promise of an object containing the event_id and
|
||||
* origin_server_ts of the closest event to the timestamp in the given
|
||||
* direction
|
||||
* @returns Resolves: A promise of an object containing the event_id and
|
||||
* origin_server_ts of the closest event to the timestamp in the given direction
|
||||
* @returns Rejects: when the request fails (module:http-api.MatrixError)
|
||||
*/
|
||||
public timestampToEvent(roomId: string, timestamp: number, dir: Direction): Promise<ITimestampToEventResponse> {
|
||||
public async timestampToEvent(
|
||||
roomId: string,
|
||||
timestamp: number,
|
||||
dir: Direction,
|
||||
): Promise<ITimestampToEventResponse> {
|
||||
const path = utils.encodeUri("/rooms/$roomId/timestamp_to_event", {
|
||||
$roomId: roomId,
|
||||
});
|
||||
const queryParams = {
|
||||
ts: timestamp.toString(),
|
||||
dir: dir,
|
||||
};
|
||||
|
||||
return this.http.authedRequest(
|
||||
Method.Get,
|
||||
path,
|
||||
{
|
||||
ts: timestamp.toString(),
|
||||
dir: dir,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
prefix: "/_matrix/client/unstable/org.matrix.msc3030",
|
||||
},
|
||||
);
|
||||
try {
|
||||
return await this.http.authedRequest(Method.Get, path, queryParams, undefined, {
|
||||
prefix: ClientPrefix.V1,
|
||||
});
|
||||
} catch (err) {
|
||||
// Fallback to the prefixed unstable endpoint. Since the stable endpoint is
|
||||
// new, we should also try the unstable endpoint before giving up. We can
|
||||
// remove this fallback request in a year (remove after 2023-11-28).
|
||||
if (
|
||||
(<MatrixError>err).errcode === "M_UNRECOGNIZED" &&
|
||||
// XXX: The 400 status code check should be removed in the future
|
||||
// when Synapse is compliant with MSC3743.
|
||||
((<MatrixError>err).httpStatus === 400 ||
|
||||
// This the correct standard status code for an unsupported
|
||||
// endpoint according to MSC3743. Not Found and Method Not Allowed
|
||||
// both indicate that this endpoint+verb combination is
|
||||
// not supported.
|
||||
(<MatrixError>err).httpStatus === 404 ||
|
||||
(<MatrixError>err).httpStatus === 405)
|
||||
) {
|
||||
return await this.http.authedRequest(Method.Get, path, queryParams, undefined, {
|
||||
prefix: "/_matrix/client/unstable/org.matrix.msc3030",
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IEventDecryptionResult } from "../@types/crypto";
|
||||
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
|
||||
import type { IToDeviceEvent } from "../sync-accumulator";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
|
||||
/**
|
||||
* Common interface for the crypto implementations
|
||||
*/
|
||||
export interface CryptoBackend {
|
||||
export interface CryptoBackend extends SyncCryptoCallbacks {
|
||||
/**
|
||||
* Global override for whether the client should ever send encrypted
|
||||
* messages to unverified devices. This provides the default for rooms which
|
||||
@@ -60,4 +61,52 @@ export interface CryptoBackend {
|
||||
* Rejects with an error if there is a problem decrypting the event.
|
||||
*/
|
||||
decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult>;
|
||||
|
||||
/**
|
||||
* Get a list containing all of the room keys
|
||||
*
|
||||
* This should be encrypted before returning it to the user.
|
||||
*
|
||||
* @returns a promise which resolves to a list of
|
||||
* session export objects
|
||||
*/
|
||||
exportRoomKeys(): Promise<IMegolmSessionData[]>;
|
||||
}
|
||||
|
||||
/** The methods which crypto implementations should expose to the Sync api */
|
||||
export interface SyncCryptoCallbacks {
|
||||
/**
|
||||
* Called by the /sync loop whenever there are incoming to-device messages.
|
||||
*
|
||||
* The implementation may preprocess the received messages (eg, decrypt them) and return an
|
||||
* updated list of messages for dispatch to the rest of the system.
|
||||
*
|
||||
* Note that, unlike {@link ClientEvent.ToDeviceEvent} events, this is called on the raw to-device
|
||||
* messages, rather than the results of any decryption attempts.
|
||||
*
|
||||
* @param events - the received to-device messages
|
||||
* @returns A list of preprocessed to-device messages.
|
||||
*/
|
||||
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
|
||||
|
||||
/**
|
||||
* Called by the /sync loop after each /sync response is processed.
|
||||
*
|
||||
* Used to complete batch processing, or to initiate background processes
|
||||
*
|
||||
* @param syncState - information about the completed sync.
|
||||
*/
|
||||
onSyncCompleted(syncState: OnSyncCompletedData): void;
|
||||
}
|
||||
|
||||
export interface OnSyncCompletedData {
|
||||
/**
|
||||
* The 'next_batch' result from /sync, which will become the 'since' token for the next call to /sync.
|
||||
*/
|
||||
nextSyncToken?: string;
|
||||
|
||||
/**
|
||||
* True if we are working our way through a backlog of events after connecting.
|
||||
*/
|
||||
catchingUp?: boolean;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
|
||||
import * as algorithms from "./algorithms";
|
||||
import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base";
|
||||
import { IOlmDevice, IOutboundGroupSessionKey } from "./algorithms/megolm";
|
||||
import { IMegolmSessionData } from "./index";
|
||||
import { OlmGroupSessionExtraData } from "../@types/crypto";
|
||||
import { IMegolmSessionData, OlmGroupSessionExtraData } from "../@types/crypto";
|
||||
import { IMessage } from "./algorithms/olm";
|
||||
|
||||
// The maximum size of an event is 65K, and we base64 the content, so this is a
|
||||
|
||||
@@ -18,11 +18,12 @@ limitations under the License.
|
||||
* Internal module. Defines the base classes of the encryption implementations
|
||||
*/
|
||||
|
||||
import type { IMegolmSessionData } from "../../@types/crypto";
|
||||
import { MatrixClient } from "../../client";
|
||||
import { Room } from "../../models/room";
|
||||
import { OlmDevice } from "../OlmDevice";
|
||||
import { IContent, MatrixEvent, RoomMember } from "../../matrix";
|
||||
import { Crypto, IEncryptedContent, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "..";
|
||||
import { Crypto, IEncryptedContent, IEventDecryptionResult, IncomingRoomKeyRequest } from "..";
|
||||
import { DeviceInfo } from "../deviceinfo";
|
||||
import { IRoomEncryption } from "../RoomList";
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ limitations under the License.
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import type { IEventDecryptionResult } from "../../@types/crypto";
|
||||
import type { IEventDecryptionResult, IMegolmSessionData } from "../../@types/crypto";
|
||||
import { logger } from "../../logger";
|
||||
import * as olmlib from "../olmlib";
|
||||
import {
|
||||
@@ -39,7 +39,7 @@ import { IOlmSessionResult } from "../olmlib";
|
||||
import { DeviceInfoMap } from "../DeviceList";
|
||||
import { IContent, MatrixEvent } from "../../models/event";
|
||||
import { EventType, MsgType, ToDeviceMessageId } from "../../@types/event";
|
||||
import { IMegolmEncryptedContent, IMegolmSessionData, IncomingRoomKeyRequest, IEncryptedContent } from "../index";
|
||||
import { IMegolmEncryptedContent, IncomingRoomKeyRequest, IEncryptedContent } from "../index";
|
||||
import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager";
|
||||
import { OlmGroupSessionExtraData } from "../../@types/crypto";
|
||||
import { MatrixError } from "../../http-api";
|
||||
|
||||
@@ -18,6 +18,7 @@ limitations under the License.
|
||||
* Classes for dealing with key backup.
|
||||
*/
|
||||
|
||||
import type { IMegolmSessionData } from "../@types/crypto";
|
||||
import { MatrixClient } from "../client";
|
||||
import { logger } from "../logger";
|
||||
import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib";
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
IKeyBackupSession,
|
||||
} from "./keybackup";
|
||||
import { UnstableValue } from "../NamespacedValue";
|
||||
import { CryptoEvent, IMegolmSessionData } from "./index";
|
||||
import { CryptoEvent } from "./index";
|
||||
import { crypto } from "./crypto";
|
||||
import { HTTPError, MatrixError } from "../http-api";
|
||||
|
||||
|
||||
+51
-36
@@ -20,7 +20,7 @@ limitations under the License.
|
||||
import anotherjson from "another-json";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import type { IEventDecryptionResult } from "../@types/crypto";
|
||||
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
|
||||
import type { PkDecryption, PkSigning } from "@matrix-org/olm";
|
||||
import { EventType, ToDeviceMessageId } from "../@types/event";
|
||||
import { TypedReEmitter } from "../ReEmitter";
|
||||
@@ -85,10 +85,11 @@ import { CryptoStore } from "./store/base";
|
||||
import { IVerificationChannel } from "./verification/request/Channel";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { IContent } from "../models/event";
|
||||
import { ISyncResponse } from "../sync-accumulator";
|
||||
import { ISyncResponse, IToDeviceEvent } from "../sync-accumulator";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { IMessage } from "./algorithms/olm";
|
||||
import { CryptoBackend } from "../common-crypto/CryptoBackend";
|
||||
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { RoomState, RoomStateEvent } from "../models/room-state";
|
||||
|
||||
const DeviceVerification = DeviceInfo.DeviceVerification;
|
||||
|
||||
@@ -171,26 +172,6 @@ export interface IRoomKeyRequestBody extends IRoomKey {
|
||||
sender_key: string;
|
||||
}
|
||||
|
||||
interface Extensible {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface IMegolmSessionData extends Extensible {
|
||||
// Sender's Curve25519 device key
|
||||
sender_key: string;
|
||||
// Devices which forwarded this session to us (normally empty).
|
||||
forwarding_curve25519_key_chain: string[];
|
||||
// Other keys the sender claims.
|
||||
sender_claimed_keys: Record<string, string>;
|
||||
// Room this session is used in
|
||||
room_id: string;
|
||||
// Unique id for the session
|
||||
session_id: string;
|
||||
// Base64'ed key data
|
||||
session_key: string;
|
||||
algorithm?: string;
|
||||
untrusted?: boolean;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
interface IDeviceVerificationUpgrade {
|
||||
@@ -2244,9 +2225,6 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
await upload({ shouldEmit: true });
|
||||
// XXX: we'll need to wait for the device list to be updated
|
||||
}
|
||||
|
||||
// redo key requests after verification
|
||||
this.cancelAndResendAllOutgoingKeyRequests();
|
||||
}
|
||||
|
||||
const deviceObj = DeviceInfo.fromStorage(dev, deviceId);
|
||||
@@ -2626,14 +2604,23 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
await storeConfigPromise;
|
||||
}
|
||||
|
||||
if (!this.lazyLoadMembers) {
|
||||
logger.log(
|
||||
"Enabling encryption in " + roomId + "; " + "starting to track device lists for all users therein",
|
||||
);
|
||||
logger.log(`Enabling encryption in ${roomId}`);
|
||||
|
||||
// we don't want to force a download of the full membership list of this room, but as soon as we have that
|
||||
// list we can start tracking the device list.
|
||||
if (room.membersLoaded()) {
|
||||
await this.trackRoomDevicesImpl(room);
|
||||
} else {
|
||||
logger.log("Enabling encryption in " + roomId);
|
||||
// wait for the membership list to be loaded
|
||||
const onState = (_state: RoomState): void => {
|
||||
room.off(RoomStateEvent.Update, onState);
|
||||
if (room.membersLoaded()) {
|
||||
this.trackRoomDevicesImpl(room).catch((e) => {
|
||||
logger.error(`Error enabling device tracking in ${roomId}`, e);
|
||||
});
|
||||
}
|
||||
};
|
||||
room.on(RoomStateEvent.Update, onState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2642,6 +2629,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @param roomId - The room ID to start tracking devices in.
|
||||
* @returns when all devices for the room have been fetched and marked to track
|
||||
* @deprecated there's normally no need to call this function: device list tracking
|
||||
* will be enabled as soon as we have the full membership list.
|
||||
*/
|
||||
public trackRoomDevices(roomId: string): Promise<void> {
|
||||
const room = this.clientStore.getRoom(roomId);
|
||||
@@ -2886,11 +2875,22 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*/
|
||||
public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
|
||||
if (event.isRedacted()) {
|
||||
// Try to decrypt the redaction event, to support encrypted
|
||||
// redaction reasons. If we can't decrypt, just fall back to using
|
||||
// the original redacted_because.
|
||||
const redactionEvent = new MatrixEvent({
|
||||
room_id: event.getRoomId(),
|
||||
...event.getUnsigned().redacted_because,
|
||||
});
|
||||
const decryptedEvent = await this.decryptEvent(redactionEvent);
|
||||
let redactedBecause: IEvent = event.getUnsigned().redacted_because!;
|
||||
if (redactionEvent.isEncrypted()) {
|
||||
try {
|
||||
const decryptedEvent = await this.decryptEvent(redactionEvent);
|
||||
redactedBecause = decryptedEvent.clearEvent as IEvent;
|
||||
} catch (e) {
|
||||
logger.warn("Decryption of redaction failed. Falling back to unencrypted event.", e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clearEvent: {
|
||||
@@ -2898,7 +2898,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
type: "m.room.message",
|
||||
content: {},
|
||||
unsigned: {
|
||||
redacted_because: decryptedEvent.clearEvent as IEvent,
|
||||
redacted_because: redactedBecause,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -3021,7 +3021,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @param syncData - the data from the 'MatrixClient.sync' event
|
||||
*/
|
||||
public async onSyncCompleted(syncData: ISyncStateData): Promise<void> {
|
||||
public async onSyncCompleted(syncData: OnSyncCompletedData): Promise<void> {
|
||||
this.deviceList.setSyncToken(syncData.nextSyncToken ?? null);
|
||||
this.deviceList.saveIfDirty();
|
||||
|
||||
@@ -3195,6 +3195,21 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
};
|
||||
|
||||
public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> {
|
||||
// all we do here is filter out encrypted to-device messages with the wrong algorithm. Decryption
|
||||
// happens later in decryptEvent, via the EventMapper
|
||||
return events.filter((toDevice) => {
|
||||
if (
|
||||
toDevice.type === EventType.RoomMessageEncrypted &&
|
||||
!["m.olm.v1.curve25519-aes-sha2"].includes(toDevice.content?.algorithm)
|
||||
) {
|
||||
logger.log("Ignoring invalid encrypted to-device event from " + toDevice.sender);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private onToDeviceEvent = (event: MatrixEvent): void => {
|
||||
try {
|
||||
logger.log(
|
||||
@@ -3894,5 +3909,5 @@ class IncomingRoomKeyRequestCancellation {
|
||||
}
|
||||
}
|
||||
|
||||
// IEventDecryptionResult is re-exported for backwards compatibility, in case any applications are referencing it.
|
||||
export type { IEventDecryptionResult } from "../@types/crypto";
|
||||
// a number of types are re-exported for backwards compatibility, in case any applications are referencing it.
|
||||
export type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
|
||||
|
||||
+2
-2
@@ -167,9 +167,9 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
// still has some valuable helper methods that we make use of, so we
|
||||
// instantiate it anyways
|
||||
if (opts.slidingSync) {
|
||||
this.syncApi = new SlidingSyncSdk(opts.slidingSync, this, opts);
|
||||
this.syncApi = new SlidingSyncSdk(opts.slidingSync, this, opts, this.buildSyncApiOptions());
|
||||
} else {
|
||||
this.syncApi = new SyncApi(this, opts);
|
||||
this.syncApi = new SyncApi(this, opts, this.buildSyncApiOptions());
|
||||
}
|
||||
|
||||
this.room = this.syncApi.createRoom(this.roomId);
|
||||
|
||||
@@ -60,6 +60,9 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event
|
||||
event.setThread(thread);
|
||||
}
|
||||
|
||||
// TODO: once we get rid of the old libolm-backed crypto, we can restrict this to room events (rather than
|
||||
// to-device events), because the rust implementation decrypts to-device messages at a higher level.
|
||||
// Generally we probably want to use a different eventMapper implementation for to-device events because
|
||||
if (event.isEncrypted()) {
|
||||
if (!preventReEmit) {
|
||||
client.reEmitter.reEmit(event, [MatrixEventEvent.Decrypted]);
|
||||
|
||||
@@ -26,6 +26,8 @@ export enum Feature {
|
||||
Thread = "Thread",
|
||||
ThreadUnreadNotifications = "ThreadUnreadNotifications",
|
||||
LoginTokenRequest = "LoginTokenRequest",
|
||||
RelationBasedRedactions = "RelationBasedRedactions",
|
||||
AccountDataDeletion = "AccountDataDeletion",
|
||||
}
|
||||
|
||||
type FeatureSupportCondition = {
|
||||
@@ -45,6 +47,12 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
|
||||
[Feature.LoginTokenRequest]: {
|
||||
unstablePrefixes: ["org.matrix.msc3882"],
|
||||
},
|
||||
[Feature.RelationBasedRedactions]: {
|
||||
unstablePrefixes: ["org.matrix.msc3912"],
|
||||
},
|
||||
[Feature.AccountDataDeletion]: {
|
||||
unstablePrefixes: ["org.matrix.msc3391"],
|
||||
},
|
||||
};
|
||||
|
||||
export async function buildFeatureSupportMap(versions: IServerVersions): Promise<Map<Feature, ServerSupport>> {
|
||||
|
||||
@@ -34,11 +34,6 @@ export enum ClientPrefix {
|
||||
}
|
||||
|
||||
export enum IdentityPrefix {
|
||||
/**
|
||||
* URI path for v1 of the identity API
|
||||
* @deprecated Use v2.
|
||||
*/
|
||||
V1 = "/_matrix/identity/api/v1",
|
||||
/**
|
||||
* URI path for the v2 identity API
|
||||
*/
|
||||
|
||||
+4
-17
@@ -828,17 +828,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if ((<Error>e).name !== "DecryptionError") {
|
||||
// not a decryption error: log the whole exception as an error
|
||||
// (and don't bother with a retry)
|
||||
const re = options.isRetry ? "re" : "";
|
||||
// For find results: this can produce "Error decrypting event (id=$ev)" and
|
||||
// "Error redecrypting event (id=$ev)".
|
||||
logger.error(`Error ${re}decrypting event (${this.getDetails()})`, e);
|
||||
this.decryptionPromise = null;
|
||||
this.retryDecryption = false;
|
||||
return;
|
||||
}
|
||||
const detailedError = e instanceof DecryptionError ? (<DecryptionError>e).detailedString : String(e);
|
||||
|
||||
err = e as Error;
|
||||
|
||||
@@ -858,10 +848,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
//
|
||||
if (this.retryDecryption) {
|
||||
// decryption error, but we have a retry queued.
|
||||
logger.log(
|
||||
`Error decrypting event (${this.getDetails()}), but retrying: ` +
|
||||
(<DecryptionError>e).detailedString,
|
||||
);
|
||||
logger.log(`Error decrypting event (${this.getDetails()}), but retrying: ${detailedError}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -870,9 +857,9 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
//
|
||||
// the detailedString already includes the name and message of the error, and the stack isn't much use,
|
||||
// so we don't bother to log `e` separately.
|
||||
logger.warn(`Error decrypting event (${this.getDetails()}): ` + (<DecryptionError>e).detailedString);
|
||||
logger.warn(`Error decrypting event (${this.getDetails()}): ${detailedError}`);
|
||||
|
||||
res = this.badEncryptedMessage((<DecryptionError>e).message);
|
||||
res = this.badEncryptedMessage(String(e));
|
||||
}
|
||||
|
||||
// at this point, we've either successfully decrypted the event, or have given up
|
||||
|
||||
+6
-15
@@ -33,6 +33,9 @@ export type EventHandlerMap = {
|
||||
[RelationsEvent.Redaction]: (event: MatrixEvent) => void;
|
||||
};
|
||||
|
||||
const matchesEventType = (eventType: string, targetEventType: string, altTargetEventTypes: string[] = []): boolean =>
|
||||
[targetEventType, ...altTargetEventTypes].includes(eventType);
|
||||
|
||||
/**
|
||||
* A container for relation events that supports easy access to common ways of
|
||||
* aggregating such events. Each instance holds events that of a single relation
|
||||
@@ -55,11 +58,13 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
|
||||
* @param relationType - The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc.
|
||||
* @param eventType - The relation event's type, such as "m.reaction", etc.
|
||||
* @param client - The client which created this instance. For backwards compatibility also accepts a Room.
|
||||
* @param altEventTypes - alt event types for relation events, for example to support unstable prefixed event types
|
||||
*/
|
||||
public constructor(
|
||||
public readonly relationType: RelationType | string,
|
||||
public readonly eventType: string,
|
||||
client: MatrixClient | Room,
|
||||
public readonly altEventTypes?: string[],
|
||||
) {
|
||||
super();
|
||||
this.client = client instanceof Room ? client.client : client;
|
||||
@@ -84,7 +89,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
|
||||
const relationType = relation.rel_type;
|
||||
const eventType = event.getType();
|
||||
|
||||
if (this.relationType !== relationType || this.eventType !== eventType) {
|
||||
if (this.relationType !== relationType || !matchesEventType(eventType, this.eventType, this.altEventTypes)) {
|
||||
logger.error("Event relation info doesn't match this container");
|
||||
return;
|
||||
}
|
||||
@@ -122,20 +127,6 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
|
||||
return;
|
||||
}
|
||||
|
||||
const relation = event.getRelation();
|
||||
if (!relation) {
|
||||
logger.error("Event must have relation info");
|
||||
return;
|
||||
}
|
||||
|
||||
const relationType = relation.rel_type;
|
||||
const eventType = event.getType();
|
||||
|
||||
if (this.relationType !== relationType || this.eventType !== eventType) {
|
||||
logger.error("Event relation info doesn't match this container");
|
||||
return;
|
||||
}
|
||||
|
||||
this.relations.delete(event);
|
||||
|
||||
if (this.relationType === RelationType.Annotation) {
|
||||
|
||||
@@ -623,6 +623,16 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
return this.oobMemberFlags.status === OobStatus.NotStarted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if loading of out-of-band-members has completed
|
||||
*
|
||||
* @returns true if the full membership list of this room has been loaded. False if it is not started or is in
|
||||
* progress.
|
||||
*/
|
||||
public outOfBandMembersReady(): boolean {
|
||||
return this.oobMemberFlags.status === OobStatus.Finished;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this room state as waiting for out-of-band members,
|
||||
* ensuring it doesn't ask for them to be requested again
|
||||
|
||||
+89
-15
@@ -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.
|
||||
@@ -54,7 +54,13 @@ import {
|
||||
FILTER_RELATED_BY_SENDERS,
|
||||
ThreadFilterType,
|
||||
} from "./thread";
|
||||
import { MAIN_ROOM_TIMELINE, Receipt, ReceiptContent, ReceiptType } from "../@types/read_receipts";
|
||||
import {
|
||||
CachedReceiptStructure,
|
||||
MAIN_ROOM_TIMELINE,
|
||||
Receipt,
|
||||
ReceiptContent,
|
||||
ReceiptType,
|
||||
} from "../@types/read_receipts";
|
||||
import { IStateEventWithRoomId } from "../@types/search";
|
||||
import { RelationsContainer } from "./relations-container";
|
||||
import { ReadReceipt, synthesizeReceipt } from "./read-receipt";
|
||||
@@ -302,7 +308,14 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
private txnToEvent: Record<string, MatrixEvent> = {}; // Pending in-flight requests { string: MatrixEvent }
|
||||
private notificationCounts: NotificationCount = {};
|
||||
private readonly threadNotifications = new Map<string, NotificationCount>();
|
||||
public readonly cachedThreadReadReceipts = new Map<string, { event: MatrixEvent; synthetic: boolean }[]>();
|
||||
public readonly cachedThreadReadReceipts = new Map<string, CachedReceiptStructure[]>();
|
||||
// Useful to know at what point the current user has started using threads in this room
|
||||
private oldestThreadedReceiptTs = Infinity;
|
||||
/**
|
||||
* A record of the latest unthread receipts per user
|
||||
* This is useful in determining whether a user has read a thread or not
|
||||
*/
|
||||
private unthreadedReceipts = new Map<string, Receipt>();
|
||||
private readonly timelineSets: EventTimelineSet[];
|
||||
public readonly threadsTimelineSets: EventTimelineSet[] = [];
|
||||
// any filtered timeline sets we're maintaining for this room
|
||||
@@ -441,9 +454,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
});
|
||||
events.forEach(async (serializedEvent: Partial<IEvent>) => {
|
||||
const event = mapper(serializedEvent);
|
||||
if (event.getType() === EventType.RoomMessageEncrypted && this.client.isCryptoEnabled()) {
|
||||
await event.attemptDecryption(this.client.crypto!);
|
||||
}
|
||||
await client.decryptEventIfNeeded(event);
|
||||
event.setStatus(EventStatus.NOT_SENT);
|
||||
this.addPendingEvent(event, event.getTxnId()!);
|
||||
});
|
||||
@@ -503,9 +514,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
|
||||
const decryptionPromises = events
|
||||
.slice(readReceiptTimelineIndex)
|
||||
.filter((event) => event.shouldAttemptDecryption())
|
||||
.reverse()
|
||||
.map((event) => event.attemptDecryption(this.client.crypto!, { isRetry: true }));
|
||||
.map((event) => this.client.decryptEventIfNeeded(event, { isRetry: true }));
|
||||
|
||||
await Promise.allSettled(decryptionPromises);
|
||||
}
|
||||
@@ -521,9 +531,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
const decryptionPromises = this.getUnfilteredTimelineSet()
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
.filter((event) => event.shouldAttemptDecryption())
|
||||
.slice(0) // copy before reversing
|
||||
.reverse()
|
||||
.map((event) => event.attemptDecryption(this.client.crypto!, { isRetry: true }));
|
||||
.map((event) => this.client.decryptEventIfNeeded(event, { isRetry: true }));
|
||||
|
||||
await Promise.allSettled(decryptionPromises);
|
||||
}
|
||||
@@ -888,6 +898,20 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
return { memberEvents, fromServer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if loading of out-of-band-members has completed
|
||||
*
|
||||
* @returns true if the full membership list of this room has been loaded (including if lazy-loading is disabled).
|
||||
* False if the load is not started or is in progress.
|
||||
*/
|
||||
public membersLoaded(): boolean {
|
||||
if (!this.opts.lazyLoadMembers) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.currentState.outOfBandMembersReady();
|
||||
}
|
||||
|
||||
/**
|
||||
* Preloads the member list in case lazy loading
|
||||
* of memberships is in use. Can be called multiple times,
|
||||
@@ -909,10 +933,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
const inMemoryUpdate = this.loadMembers()
|
||||
.then((result) => {
|
||||
this.currentState.setOutOfBandMembers(result.memberEvents);
|
||||
// now the members are loaded, start to track the e2e devices if needed
|
||||
if (this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId)) {
|
||||
this.client.crypto!.trackRoomDevices(this.roomId);
|
||||
}
|
||||
return result.fromServer;
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -2711,9 +2731,20 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// when the thread is created
|
||||
this.cachedThreadReadReceipts.set(receipt.thread_id!, [
|
||||
...(this.cachedThreadReadReceipts.get(receipt.thread_id!) ?? []),
|
||||
{ event, synthetic },
|
||||
{ eventId, receiptType, userId, receipt, synthetic },
|
||||
]);
|
||||
}
|
||||
|
||||
const me = this.client.getUserId();
|
||||
// Track the time of the current user's oldest threaded receipt in the room.
|
||||
if (userId === me && !receiptForMainTimeline && receipt.ts < this.oldestThreadedReceiptTs) {
|
||||
this.oldestThreadedReceiptTs = receipt.ts;
|
||||
}
|
||||
|
||||
// Track each user's unthreaded read receipt.
|
||||
if (!receipt.thread_id && receipt.ts > (this.unthreadedReceipts.get(userId)?.ts ?? 0)) {
|
||||
this.unthreadedReceipts.set(userId, receipt);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2956,6 +2987,29 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
return this.getType() === RoomType.ElementVideo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the ID of the room that was this room's predecessor, or null if
|
||||
* this room has no predecessor.
|
||||
*/
|
||||
public findPredecessorRoomId(): string | 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;
|
||||
}
|
||||
|
||||
private roomNameGenerator(state: RoomNameState): string {
|
||||
if (this.client.roomNameGenerator) {
|
||||
const name = this.client.roomNameGenerator(this.roomId, state);
|
||||
@@ -3264,6 +3318,26 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
event.applyVisibilityEvent(visibilityChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find when a client has gained thread capabilities by inspecting the oldest
|
||||
* threaded receipt
|
||||
* @returns the timestamp of the oldest threaded receipt
|
||||
*/
|
||||
public getOldestThreadedReceiptTs(): number {
|
||||
return this.oldestThreadedReceiptTs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the most recent unthreaded receipt for a given user
|
||||
* @param userId - the MxID of the User
|
||||
* @returns an unthreaded Receipt. Can be undefined if receipts have been disabled
|
||||
* or a user chooses to use private read receipts (or we have simply not received
|
||||
* a receipt from this user yet).
|
||||
*/
|
||||
public getLastUnthreadedReceiptFor(userId: string): Receipt | undefined {
|
||||
return this.unthreadedReceipts.get(userId);
|
||||
}
|
||||
}
|
||||
|
||||
// a map from current event status to a list of allowed next statuses
|
||||
|
||||
+102
-24
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2021-2022 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.
|
||||
@@ -22,12 +22,12 @@ import { RelationType } from "../@types/event";
|
||||
import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "./event";
|
||||
import { Direction, EventTimeline } from "./event-timeline";
|
||||
import { EventTimelineSet, EventTimelineSetHandlerMap } from "./event-timeline-set";
|
||||
import { NotificationCountType, Room, RoomEvent } from "./room";
|
||||
import { Room, RoomEvent } from "./room";
|
||||
import { RoomState } from "./room-state";
|
||||
import { ServerControlledNamespacedValue } from "../NamespacedValue";
|
||||
import { logger } from "../logger";
|
||||
import { ReadReceipt } from "./read-receipt";
|
||||
import { Receipt, ReceiptContent, ReceiptType } from "../@types/read_receipts";
|
||||
import { CachedReceiptStructure, ReceiptType } from "../@types/read_receipts";
|
||||
|
||||
export enum ThreadEvent {
|
||||
New = "Thread.new",
|
||||
@@ -50,7 +50,7 @@ interface IThreadOpts {
|
||||
room: Room;
|
||||
client: MatrixClient;
|
||||
pendingEventOrdering?: PendingEventOrdering;
|
||||
receipts?: { event: MatrixEvent; synthetic: boolean }[];
|
||||
receipts?: CachedReceiptStructure[];
|
||||
}
|
||||
|
||||
export enum FeatureSupport {
|
||||
@@ -97,6 +97,11 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
private readonly pendingEventOrdering: PendingEventOrdering;
|
||||
|
||||
public initialEventsFetched = !Thread.hasServerSideSupport;
|
||||
/**
|
||||
* An array of events to add to the timeline once the thread has been initialised
|
||||
* with server suppport.
|
||||
*/
|
||||
public replayEvents: MatrixEvent[] | null = [];
|
||||
|
||||
public constructor(public readonly id: string, public rootEvent: MatrixEvent | undefined, opts: IThreadOpts) {
|
||||
super();
|
||||
@@ -266,6 +271,20 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
this.addEventToTimeline(event, false);
|
||||
this.fetchEditsWhereNeeded(event);
|
||||
} else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) {
|
||||
if (!this.initialEventsFetched) {
|
||||
/**
|
||||
* A thread can be fully discovered via a single sync response
|
||||
* And when that's the case we still ask the server to do an initialisation
|
||||
* as it's the safest to ensure we have everything.
|
||||
* However when we are in that scenario we might loose annotation or edits
|
||||
*
|
||||
* This fix keeps a reference to those events and replay them once the thread
|
||||
* has been initialised properly.
|
||||
*/
|
||||
this.replayEvents?.push(event);
|
||||
} else {
|
||||
this.addEventToTimeline(event, toStartOfTimeline);
|
||||
}
|
||||
// Apply annotations and replace relations to the relations of the timeline only
|
||||
this.timelineSet.relations?.aggregateParentEvent(event);
|
||||
this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
|
||||
@@ -298,17 +317,9 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
* and apply them to the current thread
|
||||
* @param receipts - A collection of the receipts cached from initial sync
|
||||
*/
|
||||
private processReceipts(receipts: { event: MatrixEvent; synthetic: boolean }[] = []): void {
|
||||
for (const { event, synthetic } of receipts) {
|
||||
const content = event.getContent<ReceiptContent>();
|
||||
Object.keys(content).forEach((eventId: string) => {
|
||||
Object.keys(content[eventId]).forEach((receiptType: ReceiptType | string) => {
|
||||
Object.keys(content[eventId][receiptType]).forEach((userId: string) => {
|
||||
const receipt = content[eventId][receiptType][userId] as Receipt;
|
||||
this.addReceiptToStructure(eventId, receiptType as ReceiptType, userId, receipt, synthetic);
|
||||
});
|
||||
});
|
||||
});
|
||||
private processReceipts(receipts: CachedReceiptStructure[] = []): void {
|
||||
for (const { eventId, receiptType, userId, receipt, synthetic } of receipts) {
|
||||
this.addReceiptToStructure(eventId, receiptType as ReceiptType, userId, receipt, synthetic);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,6 +386,10 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
limit: Math.max(1, this.length),
|
||||
});
|
||||
}
|
||||
for (const event of this.replayEvents!) {
|
||||
this.addEvent(event, false);
|
||||
}
|
||||
this.replayEvents = null;
|
||||
// just to make sure that, if we've created a timeline window for this thread before the thread itself
|
||||
// existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly.
|
||||
this.emit(RoomEvent.TimelineReset, this.room, this.timelineSet, true);
|
||||
@@ -489,17 +504,80 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
throw new Error("Unsupported function on the thread model");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the event that a given user has read up to within this thread,
|
||||
* or null if we have received no read receipt (at all) from them.
|
||||
* @param userId - The user ID to get read receipt event ID for
|
||||
* @param ignoreSynthesized - If true, return only receipts that have been
|
||||
* sent by the server, not implicit ones generated
|
||||
* by the JS SDK.
|
||||
* @returns ID of the latest event that the given user has read, or null.
|
||||
*/
|
||||
public getEventReadUpTo(userId: string, ignoreSynthesized?: boolean): string | null {
|
||||
const isCurrentUser = userId === this.client.getUserId();
|
||||
const lastReply = this.timeline.at(-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
|
||||
// using a client that supported threaded read receipts), we want to
|
||||
// consider this thread as read.
|
||||
const beforeFirstThreadedReceipt = lastReply.getTs() < this.room.getOldestThreadedReceiptTs();
|
||||
const lastReplyId = lastReply.getId();
|
||||
// Some unsent events do not have an ID, we do not want to consider them read
|
||||
if (beforeFirstThreadedReceipt && lastReplyId) {
|
||||
return lastReplyId;
|
||||
}
|
||||
}
|
||||
|
||||
const readUpToId = super.getEventReadUpTo(userId, ignoreSynthesized);
|
||||
|
||||
// Check whether the unthreaded read receipt for that user is more recent
|
||||
// than the read receipt inside that thread.
|
||||
if (lastReply) {
|
||||
const unthreadedReceipt = this.room.getLastUnthreadedReceiptFor(userId);
|
||||
if (!unthreadedReceipt) {
|
||||
return readUpToId;
|
||||
}
|
||||
|
||||
for (let i = this.timeline?.length - 1; i >= 0; --i) {
|
||||
const ev = this.timeline[i];
|
||||
// If we encounter the `readUpToId` we do not need to look further
|
||||
// there is no "more recent" unthreaded read receipt
|
||||
if (ev.getId() === readUpToId) return readUpToId;
|
||||
|
||||
// Inspecting events from most recent to oldest, we're checking
|
||||
// whether an unthreaded read receipt is more recent that the current event.
|
||||
// We usually prefer relying on the order of the DAG but in this scenario
|
||||
// it is not possible and we have to rely on timestamp
|
||||
if (ev.getTs() < unthreadedReceipt.ts) return ev.getId() ?? readUpToId;
|
||||
}
|
||||
}
|
||||
|
||||
return readUpToId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the given user has read a particular event.
|
||||
*
|
||||
* It is invalid to call this method with an event that is not part of this thread.
|
||||
*
|
||||
* This is not a definitive check as it only checks the events that have been
|
||||
* loaded client-side at the time of execution.
|
||||
* @param userId - The user ID to check the read state of.
|
||||
* @param eventId - The event ID to check if the user read.
|
||||
* @returns True if the user has read the event, false otherwise.
|
||||
*/
|
||||
public hasUserReadEvent(userId: string, eventId: string): boolean {
|
||||
if (userId === this.client.getUserId()) {
|
||||
const publicReadReceipt = this.getReadReceiptForUserId(userId, false, ReceiptType.Read);
|
||||
const privateReadReceipt = this.getReadReceiptForUserId(userId, false, ReceiptType.ReadPrivate);
|
||||
const hasUnreads = this.room.getThreadUnreadNotificationCount(this.id, NotificationCountType.Total) > 0;
|
||||
|
||||
if (!publicReadReceipt && !privateReadReceipt && !hasUnreads) {
|
||||
// Consider an event read if it's part of a thread that has no
|
||||
// read receipts and has no notifications. It is likely that it is
|
||||
// part of a thread that was created before read receipts for threads
|
||||
// were supported (via MSC3771)
|
||||
// Consider an event read if it's part of a thread that is before the
|
||||
// first threaded receipt sent in that room. It is likely that it is
|
||||
// part of a thread that was created before MSC3771 was implemented.
|
||||
// Or before the last unthreaded receipt for the logged in user
|
||||
const beforeFirstThreadedReceipt =
|
||||
(this.lastReply()?.getTs() ?? 0) < this.room.getOldestThreadedReceiptTs();
|
||||
const unthreadedReceiptTs = this.room.getLastUnthreadedReceiptFor(userId)?.ts ?? 0;
|
||||
const beforeLastUnthreadedReceipt = (this?.lastReply()?.getTs() ?? 0) < unthreadedReceiptTs;
|
||||
if (beforeFirstThreadedReceipt || beforeLastUnthreadedReceipt) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/** The prefix used on indexeddbs created by rust-crypto */
|
||||
export const RUST_SDK_STORE_PREFIX = "matrix-js-sdk";
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * 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";
|
||||
|
||||
export async function initRustCrypto(
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
): Promise<CryptoBackend> {
|
||||
// initialise the rust matrix-sdk-crypto-js, if it hasn't already been done
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
|
||||
// enable tracing in the rust-sdk
|
||||
new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Debug).turnOn();
|
||||
|
||||
const u = new RustSdkCryptoJs.UserId(userId);
|
||||
const d = new RustSdkCryptoJs.DeviceId(deviceId);
|
||||
logger.info("Init OlmMachine");
|
||||
|
||||
// TODO: use the pickle key for the passphrase
|
||||
const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(u, d, RUST_SDK_STORE_PREFIX, "test pass");
|
||||
const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId);
|
||||
|
||||
logger.info("Completed rust crypto-sdk setup");
|
||||
return rustCrypto;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * 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 { MatrixEvent } from "../models/event";
|
||||
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { logger } from "../logger";
|
||||
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { QueryDict } from "../utils";
|
||||
|
||||
/**
|
||||
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
|
||||
*/
|
||||
interface OutgoingRequest {
|
||||
readonly id: string | undefined;
|
||||
readonly type: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
|
||||
*/
|
||||
export class RustCrypto implements CryptoBackend {
|
||||
public globalBlacklistUnverifiedDevices = false;
|
||||
public globalErrorOnUnknownDevices = false;
|
||||
|
||||
/** whether {@link stop} has been called */
|
||||
private stopped = false;
|
||||
|
||||
/** whether {@link outgoingRequestLoop} is currently running */
|
||||
private outgoingRequestLoopRunning = false;
|
||||
|
||||
public constructor(
|
||||
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
|
||||
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
_userId: string,
|
||||
_deviceId: string,
|
||||
) {}
|
||||
|
||||
public stop(): void {
|
||||
// stop() may be called multiple times, but attempting to close() the OlmMachine twice
|
||||
// will cause an error.
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
this.stopped = true;
|
||||
|
||||
// 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 async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
|
||||
await this.olmMachine.decryptRoomEvent("event", new RustSdkCryptoJs.RoomId("room"));
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
public async userHasCrossSigningKeys(): Promise<boolean> {
|
||||
// TODO
|
||||
return false;
|
||||
}
|
||||
|
||||
public async exportRoomKeys(): Promise<IMegolmSessionData[]> {
|
||||
// TODO
|
||||
return [];
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// SyncCryptoCallbacks implementation
|
||||
//
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/** called by the sync loop to preprocess incoming to-device messages
|
||||
*
|
||||
* @param events - the received to-device messages
|
||||
* @returns A list of preprocessed to-device messages.
|
||||
*/
|
||||
public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> {
|
||||
// send the received to-device messages into receiveSyncChanges. We have no info on device-list changes,
|
||||
// one-time-keys, or fallback keys, so just pass empty data.
|
||||
const result = await this.olmMachine.receiveSyncChanges(
|
||||
JSON.stringify(events),
|
||||
new RustSdkCryptoJs.DeviceLists(),
|
||||
new Map(),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// receiveSyncChanges returns a JSON-encoded list of decrypted to-device messages.
|
||||
return JSON.parse(result);
|
||||
}
|
||||
|
||||
/** called by the sync loop after processing each sync.
|
||||
*
|
||||
* TODO: figure out something equivalent for sliding sync.
|
||||
*
|
||||
* @param syncState - information on the completed sync.
|
||||
*/
|
||||
public onSyncCompleted(syncState: OnSyncCompletedData): void {
|
||||
// Processing the /sync may have produced new outgoing requests which need sending, so kick off the outgoing
|
||||
// request loop, if it's not already running.
|
||||
this.outgoingRequestLoop();
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Outgoing requests
|
||||
//
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private async outgoingRequestLoop(): Promise<void> {
|
||||
if (this.outgoingRequestLoopRunning) {
|
||||
return;
|
||||
}
|
||||
this.outgoingRequestLoopRunning = true;
|
||||
try {
|
||||
while (!this.stopped) {
|
||||
const outgoingRequests: Object[] = await this.olmMachine.outgoingRequests();
|
||||
if (outgoingRequests.length == 0 || this.stopped) {
|
||||
// no more messages to send (or we have been told to stop): exit the loop
|
||||
return;
|
||||
}
|
||||
for (const msg of outgoingRequests) {
|
||||
await this.doOutgoingRequest(msg as OutgoingRequest);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("Error processing outgoing-message requests from rust crypto-sdk", e);
|
||||
} finally {
|
||||
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);
|
||||
}
|
||||
}
|
||||
+30
-23
@@ -14,12 +14,20 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend";
|
||||
import { NotificationCountType, Room, RoomEvent } from "./models/room";
|
||||
import { logger } from "./logger";
|
||||
import * as utils from "./utils";
|
||||
import { EventTimeline } from "./models/event-timeline";
|
||||
import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering } from "./client";
|
||||
import { ISyncStateData, SyncState, _createAndReEmitRoom } from "./sync";
|
||||
import { ClientEvent, IStoredClientOpts, MatrixClient } from "./client";
|
||||
import {
|
||||
ISyncStateData,
|
||||
SyncState,
|
||||
_createAndReEmitRoom,
|
||||
SyncApiOptions,
|
||||
defaultClientOpts,
|
||||
defaultSyncApiOpts,
|
||||
} from "./sync";
|
||||
import { MatrixEvent } from "./models/event";
|
||||
import { Crypto } from "./crypto";
|
||||
import { IMinimalEvent, IRoomEvent, IStateEvent, IStrippedState, ISyncResponse } from "./sync-accumulator";
|
||||
@@ -102,6 +110,7 @@ class ExtensionE2EE implements Extension<ExtensionE2EERequest, ExtensionE2EEResp
|
||||
Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"),
|
||||
);
|
||||
}
|
||||
this.crypto.onSyncCompleted({});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +128,7 @@ type ExtensionToDeviceResponse = {
|
||||
class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, ExtensionToDeviceResponse> {
|
||||
private nextBatch: string | null = null;
|
||||
|
||||
public constructor(private readonly client: MatrixClient) {}
|
||||
public constructor(private readonly client: MatrixClient, private readonly cryptoCallbacks?: SyncCryptoCallbacks) {}
|
||||
|
||||
public name(): string {
|
||||
return "to_device";
|
||||
@@ -142,8 +151,12 @@ class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, Extension
|
||||
|
||||
public async onResponse(data: ExtensionToDeviceResponse): Promise<void> {
|
||||
const cancelledKeyVerificationTxns: string[] = [];
|
||||
data.events
|
||||
?.map(this.client.getEventMapper())
|
||||
let events = data["events"] || [];
|
||||
if (events.length > 0 && this.cryptoCallbacks) {
|
||||
events = await this.cryptoCallbacks.preprocessToDeviceMessages(events);
|
||||
}
|
||||
events
|
||||
.map(this.client.getEventMapper())
|
||||
.map((toDeviceEvent) => {
|
||||
// map is a cheap inline forEach
|
||||
// We want to flag m.key.verification.start events as cancelled
|
||||
@@ -341,6 +354,8 @@ class ExtensionReceipts implements Extension<ExtensionReceiptsRequest, Extension
|
||||
* sliding sync API, see sliding-sync.ts or the class SlidingSync.
|
||||
*/
|
||||
export class SlidingSyncSdk {
|
||||
private readonly opts: IStoredClientOpts;
|
||||
private readonly syncOpts: SyncApiOptions;
|
||||
private syncState: SyncState | null = null;
|
||||
private syncStateData?: ISyncStateData;
|
||||
private lastPos: string | null = null;
|
||||
@@ -350,19 +365,11 @@ export class SlidingSyncSdk {
|
||||
public constructor(
|
||||
private readonly slidingSync: SlidingSync,
|
||||
private readonly client: MatrixClient,
|
||||
private readonly opts: Partial<IStoredClientOpts> = {},
|
||||
opts?: IStoredClientOpts,
|
||||
syncOpts?: SyncApiOptions,
|
||||
) {
|
||||
this.opts.initialSyncLimit = this.opts.initialSyncLimit ?? 8;
|
||||
this.opts.resolveInvitesToProfiles = this.opts.resolveInvitesToProfiles || false;
|
||||
this.opts.pollTimeout = this.opts.pollTimeout || 30 * 1000;
|
||||
this.opts.pendingEventOrdering = this.opts.pendingEventOrdering || PendingEventOrdering.Chronological;
|
||||
this.opts.experimentalThreadSupport = this.opts.experimentalThreadSupport === true;
|
||||
|
||||
if (!opts.canResetEntireTimeline) {
|
||||
opts.canResetEntireTimeline = (_roomId: string): boolean => {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
this.opts = defaultClientOpts(opts);
|
||||
this.syncOpts = defaultSyncApiOpts(syncOpts);
|
||||
|
||||
if (client.getNotifTimelineSet()) {
|
||||
client.reEmitter.reEmit(client.getNotifTimelineSet()!, [RoomEvent.Timeline, RoomEvent.TimelineReset]);
|
||||
@@ -371,13 +378,13 @@ export class SlidingSyncSdk {
|
||||
this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle.bind(this));
|
||||
this.slidingSync.on(SlidingSyncEvent.RoomData, this.onRoomData.bind(this));
|
||||
const extensions: Extension<any, any>[] = [
|
||||
new ExtensionToDevice(this.client),
|
||||
new ExtensionToDevice(this.client, this.syncOpts.cryptoCallbacks),
|
||||
new ExtensionAccountData(this.client),
|
||||
new ExtensionTyping(this.client),
|
||||
new ExtensionReceipts(this.client),
|
||||
];
|
||||
if (this.opts.crypto) {
|
||||
extensions.push(new ExtensionE2EE(this.opts.crypto));
|
||||
if (this.syncOpts.crypto) {
|
||||
extensions.push(new ExtensionE2EE(this.syncOpts.crypto));
|
||||
}
|
||||
extensions.forEach((ext) => {
|
||||
this.slidingSync.registerExtension(ext);
|
||||
@@ -697,7 +704,7 @@ export class SlidingSyncSdk {
|
||||
if (limited) {
|
||||
room.resetLiveTimeline(
|
||||
roomData.prev_batch,
|
||||
null, // TODO this.opts.canResetEntireTimeline(room.roomId) ? null : syncEventData.oldSyncToken,
|
||||
null, // TODO this.syncOpts.canResetEntireTimeline(room.roomId) ? null : syncEventData.oldSyncToken,
|
||||
);
|
||||
|
||||
// We have to assume any gap in any timeline is
|
||||
@@ -729,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.opts.crypto) {
|
||||
await this.opts.crypto.onCryptoEvent(room, e);
|
||||
if (e.isState() && e.getType() == EventType.RoomEncryption && this.syncOpts.crypto) {
|
||||
await this.syncOpts.crypto.onCryptoEvent(room, e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -397,6 +397,10 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
* @param sub - The subscription information.
|
||||
*/
|
||||
public addCustomSubscription(name: string, sub: MSC3575RoomSubscription): void {
|
||||
if (this.customSubscriptions.has(name)) {
|
||||
logger.warn(`addCustomSubscription: ${name} already exists as a custom subscription, ignoring.`);
|
||||
return;
|
||||
}
|
||||
this.customSubscriptions.set(name, sub);
|
||||
}
|
||||
|
||||
@@ -408,6 +412,11 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
* will be used.
|
||||
*/
|
||||
public useCustomSubscription(roomId: string, name: string): void {
|
||||
// We already know about this custom subscription, as it is immutable,
|
||||
// we don't need to unconfirm the subscription.
|
||||
if (this.roomIdToCustomSubscription.get(roomId) === name) {
|
||||
return;
|
||||
}
|
||||
this.roomIdToCustomSubscription.set(roomId, name);
|
||||
// unconfirm this subscription so a resend() will send it up afresh.
|
||||
this.confirmedRoomSubscriptions.delete(roomId);
|
||||
|
||||
+7
-1
@@ -286,7 +286,13 @@ export class MemoryStore implements IStore {
|
||||
*/
|
||||
public storeAccountDataEvents(events: MatrixEvent[]): void {
|
||||
events.forEach((event) => {
|
||||
this.accountData[event.getType()] = event;
|
||||
// MSC3391: an event with content of {} should be interpreted as deleted
|
||||
const isDeleted = !Object.keys(event.getContent()).length;
|
||||
if (isDeleted) {
|
||||
delete this.accountData[event.getType()];
|
||||
} else {
|
||||
this.accountData[event.getType()] = event;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+80
-42
@@ -25,6 +25,7 @@ limitations under the License.
|
||||
|
||||
import { Optional } from "matrix-events-sdk";
|
||||
|
||||
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend";
|
||||
import { User, UserEvent } from "./models/user";
|
||||
import { NotificationCountType, Room, RoomEvent } from "./models/room";
|
||||
import * as utils from "./utils";
|
||||
@@ -34,7 +35,7 @@ import { EventTimeline } from "./models/event-timeline";
|
||||
import { PushProcessor } from "./pushprocessor";
|
||||
import { logger } from "./logger";
|
||||
import { InvalidStoreError, InvalidStoreState } from "./errors";
|
||||
import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering } from "./client";
|
||||
import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering, ResetTimelineCallback } from "./client";
|
||||
import {
|
||||
IEphemeral,
|
||||
IInvitedRoom,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
IStrippedState,
|
||||
ISyncResponse,
|
||||
ITimeline,
|
||||
IToDeviceEvent,
|
||||
} from "./sync-accumulator";
|
||||
import { MatrixEvent } from "./models/event";
|
||||
import { MatrixError, Method } from "./http-api";
|
||||
@@ -59,6 +61,7 @@ import { BeaconEvent } from "./models/beacon";
|
||||
import { IEventsResponse } from "./@types/requests";
|
||||
import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync";
|
||||
import { Feature, ServerSupport } from "./feature";
|
||||
import { Crypto } from "./crypto";
|
||||
|
||||
const DEBUG = true;
|
||||
|
||||
@@ -110,6 +113,31 @@ function debuglog(...params: any[]): void {
|
||||
logger.log(...params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Options passed into the constructor of SyncApi by MatrixClient
|
||||
*/
|
||||
export interface SyncApiOptions {
|
||||
/**
|
||||
* Crypto manager
|
||||
*
|
||||
* @deprecated in favour of cryptoCallbacks
|
||||
*/
|
||||
crypto?: Crypto;
|
||||
|
||||
/**
|
||||
* If crypto is enabled on our client, callbacks into the crypto module
|
||||
*/
|
||||
cryptoCallbacks?: SyncCryptoCallbacks;
|
||||
|
||||
/**
|
||||
* A function which is called
|
||||
* with a room ID and returns a boolean. It should return 'true' if the SDK can
|
||||
* SAFELY remove events from this room. It may not be safe to remove events if
|
||||
* there are other references to the timelines for this room.
|
||||
*/
|
||||
canResetEntireTimeline?: ResetTimelineCallback;
|
||||
}
|
||||
|
||||
interface ISyncOptions {
|
||||
filter?: string;
|
||||
hasSyncedBefore?: boolean;
|
||||
@@ -163,7 +191,29 @@ type WrappedRoom<T> = T & {
|
||||
isBrandNewRoom: boolean;
|
||||
};
|
||||
|
||||
/** add default settings to an IStoredClientOpts */
|
||||
export function defaultClientOpts(opts?: IStoredClientOpts): IStoredClientOpts {
|
||||
return {
|
||||
initialSyncLimit: 8,
|
||||
resolveInvitesToProfiles: false,
|
||||
pollTimeout: 30 * 1000,
|
||||
pendingEventOrdering: PendingEventOrdering.Chronological,
|
||||
experimentalThreadSupport: false,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultSyncApiOpts(syncOpts?: SyncApiOptions): SyncApiOptions {
|
||||
return {
|
||||
canResetEntireTimeline: (_roomId): boolean => false,
|
||||
...syncOpts,
|
||||
};
|
||||
}
|
||||
|
||||
export class SyncApi {
|
||||
private readonly opts: IStoredClientOpts;
|
||||
private readonly syncOpts: SyncApiOptions;
|
||||
|
||||
private _peekRoom: Optional<Room> = null;
|
||||
private currentSyncRequest?: Promise<ISyncResponse>;
|
||||
private abortController?: AbortController;
|
||||
@@ -180,21 +230,13 @@ export class SyncApi {
|
||||
/**
|
||||
* Construct an entity which is able to sync with a homeserver.
|
||||
* @param client - The matrix client instance to use.
|
||||
* @param opts - Config options
|
||||
* @param opts - client config options
|
||||
* @param syncOpts - sync-specific options passed by the client
|
||||
* @internal
|
||||
*/
|
||||
public constructor(private readonly client: MatrixClient, private readonly opts: Partial<IStoredClientOpts> = {}) {
|
||||
this.opts.initialSyncLimit = this.opts.initialSyncLimit ?? 8;
|
||||
this.opts.resolveInvitesToProfiles = this.opts.resolveInvitesToProfiles || false;
|
||||
this.opts.pollTimeout = this.opts.pollTimeout || 30 * 1000;
|
||||
this.opts.pendingEventOrdering = this.opts.pendingEventOrdering || PendingEventOrdering.Chronological;
|
||||
this.opts.experimentalThreadSupport = this.opts.experimentalThreadSupport === true;
|
||||
|
||||
if (!opts.canResetEntireTimeline) {
|
||||
opts.canResetEntireTimeline = (roomId: string): boolean => {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
public constructor(private readonly client: MatrixClient, opts?: IStoredClientOpts, syncOpts?: SyncApiOptions) {
|
||||
this.opts = defaultClientOpts(opts);
|
||||
this.syncOpts = defaultSyncApiOpts(syncOpts);
|
||||
|
||||
if (client.getNotifTimelineSet()) {
|
||||
client.reEmitter.reEmit(client.getNotifTimelineSet()!, [RoomEvent.Timeline, RoomEvent.TimelineReset]);
|
||||
@@ -632,7 +674,7 @@ export class SyncApi {
|
||||
return;
|
||||
}
|
||||
if (this.opts.lazyLoadMembers) {
|
||||
this.opts.crypto?.enableLazyLoading();
|
||||
this.syncOpts.crypto?.enableLazyLoading();
|
||||
}
|
||||
try {
|
||||
debuglog("Storing client options...");
|
||||
@@ -866,10 +908,10 @@ export class SyncApi {
|
||||
catchingUp: this.catchingUp,
|
||||
};
|
||||
|
||||
if (this.opts.crypto) {
|
||||
if (this.syncOpts.crypto) {
|
||||
// tell the crypto module we're about to process a sync
|
||||
// response
|
||||
await this.opts.crypto.onSyncWillProcess(syncEventData);
|
||||
await this.syncOpts.crypto.onSyncWillProcess(syncEventData);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -894,8 +936,8 @@ export class SyncApi {
|
||||
|
||||
// tell the crypto module to do its processing. It may block (to do a
|
||||
// /keys/changes request).
|
||||
if (this.opts.crypto) {
|
||||
await this.opts.crypto.onSyncCompleted(syncEventData);
|
||||
if (this.syncOpts.cryptoCallbacks) {
|
||||
await this.syncOpts.cryptoCallbacks.onSyncCompleted(syncEventData);
|
||||
}
|
||||
|
||||
// keep emitting SYNCING -> SYNCING for clients who want to do bulk updates
|
||||
@@ -907,8 +949,8 @@ export class SyncApi {
|
||||
// stored sync data which means we don't have to worry that we may have missed
|
||||
// device changes. We can also skip the delay since we're not calling this very
|
||||
// frequently (and we don't really want to delay the sync for it).
|
||||
if (this.opts.crypto) {
|
||||
await this.opts.crypto.saveDeviceList(0);
|
||||
if (this.syncOpts.crypto) {
|
||||
await this.syncOpts.crypto.saveDeviceList(0);
|
||||
}
|
||||
|
||||
// tell databases that everything is now in a consistent state and can be saved.
|
||||
@@ -1129,19 +1171,15 @@ export class SyncApi {
|
||||
}
|
||||
|
||||
// handle to-device events
|
||||
if (Array.isArray(data.to_device?.events) && data.to_device!.events.length > 0) {
|
||||
const cancelledKeyVerificationTxns: string[] = [];
|
||||
data.to_device!.events.filter((eventJSON) => {
|
||||
if (
|
||||
eventJSON.type === EventType.RoomMessageEncrypted &&
|
||||
!["m.olm.v1.curve25519-aes-sha2"].includes(eventJSON.content?.algorithm)
|
||||
) {
|
||||
logger.log("Ignoring invalid encrypted to-device event from " + eventJSON.sender);
|
||||
return false;
|
||||
}
|
||||
if (data.to_device && Array.isArray(data.to_device.events) && data.to_device.events.length > 0) {
|
||||
let toDeviceMessages: IToDeviceEvent[] = data.to_device.events;
|
||||
|
||||
return true;
|
||||
})
|
||||
if (this.syncOpts.cryptoCallbacks) {
|
||||
toDeviceMessages = await this.syncOpts.cryptoCallbacks.preprocessToDeviceMessages(toDeviceMessages);
|
||||
}
|
||||
|
||||
const cancelledKeyVerificationTxns: string[] = [];
|
||||
toDeviceMessages
|
||||
.map(client.getEventMapper({ toDevice: true }))
|
||||
.map((toDeviceEvent) => {
|
||||
// map is a cheap inline forEach
|
||||
@@ -1356,7 +1394,7 @@ export class SyncApi {
|
||||
if (limited) {
|
||||
room.resetLiveTimeline(
|
||||
joinObj.timeline.prev_batch,
|
||||
this.opts.canResetEntireTimeline!(room.roomId) ? null : syncEventData.oldSyncToken ?? null,
|
||||
this.syncOpts.canResetEntireTimeline!(room.roomId) ? null : syncEventData.oldSyncToken ?? null,
|
||||
);
|
||||
|
||||
// We have to assume any gap in any timeline is
|
||||
@@ -1370,10 +1408,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.opts.crypto) {
|
||||
if (this.syncOpts.crypto) {
|
||||
for (const e of stateEvents.concat(events)) {
|
||||
if (e.isState() && e.getType() === EventType.RoomEncryption && e.getStateKey() === "") {
|
||||
await this.opts.crypto.onCryptoEvent(room, e);
|
||||
await this.syncOpts.crypto.onCryptoEvent(room, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1462,8 +1500,8 @@ export class SyncApi {
|
||||
|
||||
// Handle device list updates
|
||||
if (data.device_lists) {
|
||||
if (this.opts.crypto) {
|
||||
await this.opts.crypto.handleDeviceListChanges(syncEventData, data.device_lists);
|
||||
if (this.syncOpts.crypto) {
|
||||
await this.syncOpts.crypto.handleDeviceListChanges(syncEventData, data.device_lists);
|
||||
} else {
|
||||
// FIXME if we *don't* have a crypto module, we still need to
|
||||
// invalidate the device lists. But that would require a
|
||||
@@ -1472,12 +1510,12 @@ export class SyncApi {
|
||||
}
|
||||
|
||||
// Handle one_time_keys_count
|
||||
if (this.opts.crypto && data.device_one_time_keys_count) {
|
||||
if (this.syncOpts.crypto && data.device_one_time_keys_count) {
|
||||
const currentCount = data.device_one_time_keys_count.signed_curve25519 || 0;
|
||||
this.opts.crypto.updateOneTimeKeyCount(currentCount);
|
||||
this.syncOpts.crypto.updateOneTimeKeyCount(currentCount);
|
||||
}
|
||||
if (
|
||||
this.opts.crypto &&
|
||||
this.syncOpts.crypto &&
|
||||
(data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"])
|
||||
) {
|
||||
// The presence of device_unused_fallback_key_types indicates that the
|
||||
@@ -1485,7 +1523,7 @@ export class SyncApi {
|
||||
// signed_curve25519 fallback key we need a new one.
|
||||
const unusedFallbackKeys =
|
||||
data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"];
|
||||
this.opts.crypto.setNeedsNewFallback(
|
||||
this.syncOpts.crypto.setNeedsNewFallback(
|
||||
Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"),
|
||||
);
|
||||
}
|
||||
|
||||
+38
-11
@@ -1280,7 +1280,10 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
setTracksEnabled(stream.getAudioTracks(), audioEnabled);
|
||||
setTracksEnabled(stream.getVideoTracks(), videoEnabled);
|
||||
|
||||
// We want to keep the same stream id, so we replace the tracks rather than the whole stream
|
||||
// We want to keep the same stream id, so we replace the tracks rather
|
||||
// than the whole stream.
|
||||
|
||||
// Firstly, we replace the tracks in our localUsermediaStream.
|
||||
for (const track of this.localUsermediaStream!.getTracks()) {
|
||||
this.localUsermediaStream!.removeTrack(track);
|
||||
track.stop();
|
||||
@@ -1289,10 +1292,23 @@ 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.
|
||||
for (const track of stream.getTracks()) {
|
||||
const tKey = getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, track.kind);
|
||||
|
||||
const oldSender = this.transceivers.get(tKey)?.sender;
|
||||
const transceiver = this.transceivers.get(tKey);
|
||||
const oldSender = transceiver?.sender;
|
||||
let added = false;
|
||||
if (oldSender) {
|
||||
try {
|
||||
@@ -1306,6 +1322,10 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
`) to peer connection`,
|
||||
);
|
||||
await oldSender.replaceTrack(track);
|
||||
// Set the direction to indicate we're going to be sending.
|
||||
// This is only necessary in the cases where we're upgrading
|
||||
// the call to video after downgrading it.
|
||||
transceiver.direction = transceiver.direction === "inactive" ? "sendonly" : "sendrecv";
|
||||
added = true;
|
||||
} catch (error) {
|
||||
logger.warn(`replaceTrack failed: adding new transceiver instead`, error);
|
||||
@@ -1349,7 +1369,12 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
await this.upgradeCall(false, true);
|
||||
return this.isLocalVideoMuted();
|
||||
}
|
||||
this.localUsermediaFeed?.setAudioVideoMuted(null, muted);
|
||||
if (this.opponentSupportsSDPStreamMetadata()) {
|
||||
const stream = await this.client.getMediaHandler().getUserMediaStream(true, !muted);
|
||||
await this.updateLocalUsermediaStream(stream);
|
||||
} else {
|
||||
this.localUsermediaFeed?.setAudioVideoMuted(null, muted);
|
||||
}
|
||||
this.updateMuteStatus();
|
||||
await this.sendMetadataUpdate();
|
||||
return this.isLocalVideoMuted();
|
||||
@@ -2475,18 +2500,20 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
}
|
||||
|
||||
private stopAllMedia(): void {
|
||||
logger.debug(
|
||||
!this.groupCallId
|
||||
? `Call ${this.callId} stopping all media`
|
||||
: `Call ${this.callId} stopping all media except local feeds`,
|
||||
);
|
||||
logger.debug(`Call ${this.callId} stopping all media`);
|
||||
|
||||
for (const feed of this.feeds) {
|
||||
if (feed.isLocal() && feed.purpose === SDPStreamMetadataPurpose.Usermedia && !this.groupCallId) {
|
||||
// Slightly awkward as local feed need to go via the correct method on
|
||||
// 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:
|
||||
// the other clones will continue fine.
|
||||
if (feed.isLocal() && feed.purpose === SDPStreamMetadataPurpose.Usermedia) {
|
||||
this.client.getMediaHandler().stopUserMediaStream(feed.stream);
|
||||
} else if (feed.isLocal() && feed.purpose === SDPStreamMetadataPurpose.Screenshare && !this.groupCallId) {
|
||||
} else if (feed.isLocal() && feed.purpose === SDPStreamMetadataPurpose.Screenshare) {
|
||||
this.client.getMediaHandler().stopScreensharingStream(feed.stream);
|
||||
} else if (!feed.isLocal() || !this.groupCallId) {
|
||||
} else if (!feed.isLocal()) {
|
||||
logger.debug("Stopping remote stream", feed.stream.id);
|
||||
for (const track of feed.stream.getTracks()) {
|
||||
track.stop();
|
||||
|
||||
+38
-35
@@ -55,7 +55,7 @@ export enum GroupCallEvent {
|
||||
export type GroupCallEventHandlerMap = {
|
||||
[GroupCallEvent.GroupCallStateChanged]: (newState: GroupCallState, oldState: GroupCallState) => void;
|
||||
[GroupCallEvent.ActiveSpeakerChanged]: (activeSpeaker: CallFeed | undefined) => void;
|
||||
[GroupCallEvent.CallsChanged]: (calls: Map<RoomMember, Map<string, MatrixCall>>) => void;
|
||||
[GroupCallEvent.CallsChanged]: (calls: Map<string, Map<string, MatrixCall>>) => void;
|
||||
[GroupCallEvent.UserMediaFeedsChanged]: (feeds: CallFeed[]) => void;
|
||||
[GroupCallEvent.ScreenshareFeedsChanged]: (feeds: CallFeed[]) => void;
|
||||
[GroupCallEvent.LocalScreenshareStateChanged]: (
|
||||
@@ -197,11 +197,11 @@ export class GroupCall extends TypedEventEmitter<
|
||||
public readonly screenshareFeeds: CallFeed[] = [];
|
||||
public groupCallId: string;
|
||||
|
||||
private readonly calls = new Map<RoomMember, Map<string, MatrixCall>>(); // RoomMember -> device ID -> MatrixCall
|
||||
private callHandlers = new Map<string, Map<string, ICallHandlers>>(); // User ID -> device ID -> handlers
|
||||
private readonly calls = new Map<string, Map<string, MatrixCall>>(); // user_id -> device_id -> MatrixCall
|
||||
private callHandlers = new Map<string, Map<string, ICallHandlers>>(); // user_id -> device_id -> ICallHandlers
|
||||
private activeSpeakerLoopInterval?: ReturnType<typeof setTimeout>;
|
||||
private retryCallLoopInterval?: ReturnType<typeof setTimeout>;
|
||||
private retryCallCounts: Map<RoomMember, Map<string, number>> = new Map();
|
||||
private retryCallCounts: Map<string, Map<string, number>> = new Map(); // user_id -> device_id -> count
|
||||
private reEmitter: ReEmitter;
|
||||
private transmitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private participantsExpirationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -609,6 +609,9 @@ export class GroupCall extends TypedEventEmitter<
|
||||
logger.log(
|
||||
`groupCall ${this.groupCallId} setLocalVideoMuted stream ${this.localCallFeed.stream.id} muted ${muted}`,
|
||||
);
|
||||
|
||||
const stream = await this.client.getMediaHandler().getUserMediaStream(true, !muted);
|
||||
await this.updateLocalUsermediaStream(stream);
|
||||
this.localCallFeed.setAudioVideoMuted(null, muted);
|
||||
setTracksEnabled(this.localCallFeed.stream.getVideoTracks(), !muted);
|
||||
} else {
|
||||
@@ -728,18 +731,18 @@ export class GroupCall extends TypedEventEmitter<
|
||||
return;
|
||||
}
|
||||
|
||||
const opponent = newCall.getOpponentMember();
|
||||
if (opponent === undefined) {
|
||||
const opponentUserId = newCall.getOpponentMember()?.userId;
|
||||
if (opponentUserId === undefined) {
|
||||
logger.warn("Incoming call with no member. Ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceMap = this.calls.get(opponent) ?? new Map<string, MatrixCall>();
|
||||
const deviceMap = this.calls.get(opponentUserId) ?? new Map<string, MatrixCall>();
|
||||
const prevCall = deviceMap.get(newCall.getOpponentDeviceId()!);
|
||||
|
||||
if (prevCall?.callId === newCall.callId) return;
|
||||
|
||||
logger.log(`GroupCall: incoming call from ${opponent.userId} with ID ${newCall.callId}`);
|
||||
logger.log(`GroupCall: incoming call from ${opponentUserId} with ID ${newCall.callId}`);
|
||||
|
||||
if (prevCall) this.disposeCall(prevCall, CallErrorCode.Replaced);
|
||||
|
||||
@@ -747,7 +750,7 @@ export class GroupCall extends TypedEventEmitter<
|
||||
newCall.answerWithCallFeeds(this.getLocalFeeds().map((feed) => feed.clone()));
|
||||
|
||||
deviceMap.set(newCall.getOpponentDeviceId()!, newCall);
|
||||
this.calls.set(opponent, deviceMap);
|
||||
this.calls.set(opponentUserId, deviceMap);
|
||||
this.emit(GroupCallEvent.CallsChanged, this.calls);
|
||||
};
|
||||
|
||||
@@ -775,38 +778,38 @@ export class GroupCall extends TypedEventEmitter<
|
||||
private placeOutgoingCalls(): void {
|
||||
let callsChanged = false;
|
||||
|
||||
for (const [member, participantMap] of this.participants) {
|
||||
const callMap = this.calls.get(member) ?? new Map<string, MatrixCall>();
|
||||
for (const [{ userId }, participantMap] of this.participants) {
|
||||
const callMap = this.calls.get(userId) ?? new Map<string, MatrixCall>();
|
||||
|
||||
for (const [deviceId, participant] of participantMap) {
|
||||
const prevCall = callMap.get(deviceId);
|
||||
|
||||
if (
|
||||
prevCall?.getOpponentSessionId() !== participant.sessionId &&
|
||||
this.wantsOutgoingCall(member.userId, deviceId)
|
||||
this.wantsOutgoingCall(userId, deviceId)
|
||||
) {
|
||||
callsChanged = true;
|
||||
|
||||
if (prevCall !== undefined) {
|
||||
logger.debug(`Replacing call ${prevCall.callId} to ${member.userId} ${deviceId}`);
|
||||
logger.debug(`Replacing call ${prevCall.callId} to ${userId} ${deviceId}`);
|
||||
this.disposeCall(prevCall, CallErrorCode.NewSession);
|
||||
}
|
||||
|
||||
const newCall = createNewMatrixCall(this.client, this.room.roomId, {
|
||||
invitee: member.userId,
|
||||
invitee: userId,
|
||||
opponentDeviceId: deviceId,
|
||||
opponentSessionId: participant.sessionId,
|
||||
groupCallId: this.groupCallId,
|
||||
});
|
||||
|
||||
if (newCall === null) {
|
||||
logger.error(`Failed to create call with ${member.userId} ${deviceId}`);
|
||||
logger.error(`Failed to create call with ${userId} ${deviceId}`);
|
||||
callMap.delete(deviceId);
|
||||
} else {
|
||||
this.initCall(newCall);
|
||||
callMap.set(deviceId, newCall);
|
||||
|
||||
logger.debug(`Placing call to ${member.userId} ${deviceId} (session ${participant.sessionId})`);
|
||||
logger.debug(`Placing call to ${userId} ${deviceId} (session ${participant.sessionId})`);
|
||||
|
||||
newCall
|
||||
.placeCallWithCallFeeds(
|
||||
@@ -819,7 +822,7 @@ export class GroupCall extends TypedEventEmitter<
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.warn(`Failed to place call to ${member.userId}`, e);
|
||||
logger.warn(`Failed to place call to ${userId}`, e);
|
||||
|
||||
if (e instanceof CallError && e.code === GroupCallErrorCode.UnknownDevice) {
|
||||
this.emit(GroupCallEvent.Error, e);
|
||||
@@ -828,7 +831,7 @@ export class GroupCall extends TypedEventEmitter<
|
||||
GroupCallEvent.Error,
|
||||
new GroupCallError(
|
||||
GroupCallErrorCode.PlaceCallFailed,
|
||||
`Failed to place call to ${member.userId}`,
|
||||
`Failed to place call to ${userId}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -841,9 +844,9 @@ export class GroupCall extends TypedEventEmitter<
|
||||
}
|
||||
|
||||
if (callMap.size > 0) {
|
||||
this.calls.set(member, callMap);
|
||||
this.calls.set(userId, callMap);
|
||||
} else {
|
||||
this.calls.delete(member);
|
||||
this.calls.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,9 +868,9 @@ export class GroupCall extends TypedEventEmitter<
|
||||
private onRetryCallLoop = (): void => {
|
||||
let needsRetry = false;
|
||||
|
||||
for (const [member, participantMap] of this.participants) {
|
||||
const callMap = this.calls.get(member);
|
||||
let retriesMap = this.retryCallCounts.get(member);
|
||||
for (const [{ userId }, participantMap] of this.participants) {
|
||||
const callMap = this.calls.get(userId);
|
||||
let retriesMap = this.retryCallCounts.get(userId);
|
||||
|
||||
for (const [deviceId, participant] of participantMap) {
|
||||
const call = callMap?.get(deviceId);
|
||||
@@ -875,12 +878,12 @@ export class GroupCall extends TypedEventEmitter<
|
||||
|
||||
if (
|
||||
call?.getOpponentSessionId() !== participant.sessionId &&
|
||||
this.wantsOutgoingCall(member.userId, deviceId) &&
|
||||
this.wantsOutgoingCall(userId, deviceId) &&
|
||||
retries < 3
|
||||
) {
|
||||
if (retriesMap === undefined) {
|
||||
retriesMap = new Map();
|
||||
this.retryCallCounts.set(member, retriesMap);
|
||||
this.retryCallCounts.set(userId, retriesMap);
|
||||
}
|
||||
retriesMap.set(deviceId, retries + 1);
|
||||
needsRetry = true;
|
||||
@@ -1020,36 +1023,36 @@ export class GroupCall extends TypedEventEmitter<
|
||||
call.setLocalVideoMuted(videoMuted);
|
||||
}
|
||||
|
||||
if (state === CallState.Connected) {
|
||||
const opponent = call.getOpponentMember()!;
|
||||
const retriesMap = this.retryCallCounts.get(opponent);
|
||||
const opponentUserId = call.getOpponentMember()?.userId;
|
||||
if (state === CallState.Connected && opponentUserId) {
|
||||
const retriesMap = this.retryCallCounts.get(opponentUserId);
|
||||
retriesMap?.delete(call.getOpponentDeviceId()!);
|
||||
if (retriesMap?.size === 0) this.retryCallCounts.delete(opponent);
|
||||
if (retriesMap?.size === 0) this.retryCallCounts.delete(opponentUserId);
|
||||
}
|
||||
};
|
||||
|
||||
private onCallHangup = (call: MatrixCall): void => {
|
||||
if (call.hangupReason === CallErrorCode.Replaced) return;
|
||||
|
||||
const opponent = call.getOpponentMember() ?? this.room.getMember(call.invitee!)!;
|
||||
const deviceMap = this.calls.get(opponent);
|
||||
const opponentUserId = call.getOpponentMember()?.userId ?? this.room.getMember(call.invitee!)!.userId;
|
||||
const deviceMap = this.calls.get(opponentUserId);
|
||||
|
||||
// Sanity check that this call is in fact in the map
|
||||
if (deviceMap?.get(call.getOpponentDeviceId()!) === call) {
|
||||
this.disposeCall(call, call.hangupReason as CallErrorCode);
|
||||
deviceMap.delete(call.getOpponentDeviceId()!);
|
||||
if (deviceMap.size === 0) this.calls.delete(opponent);
|
||||
if (deviceMap.size === 0) this.calls.delete(opponentUserId);
|
||||
this.emit(GroupCallEvent.CallsChanged, this.calls);
|
||||
}
|
||||
};
|
||||
|
||||
private onCallReplaced = (prevCall: MatrixCall, newCall: MatrixCall): void => {
|
||||
const opponent = prevCall.getOpponentMember()!;
|
||||
const opponentUserId = prevCall.getOpponentMember()!.userId;
|
||||
|
||||
let deviceMap = this.calls.get(opponent);
|
||||
let deviceMap = this.calls.get(opponentUserId);
|
||||
if (deviceMap === undefined) {
|
||||
deviceMap = new Map();
|
||||
this.calls.set(opponent, deviceMap);
|
||||
this.calls.set(opponentUserId, deviceMap);
|
||||
}
|
||||
|
||||
this.disposeCall(prevCall, CallErrorCode.Replaced);
|
||||
|
||||
+20
-14
@@ -203,24 +203,30 @@ export class MediaHandler extends TypedEventEmitter<
|
||||
|
||||
let canReuseStream = true;
|
||||
if (this.localUserMediaStream) {
|
||||
// This figures out if we can reuse the current localUsermediaStream
|
||||
// based on whether or not the "mute state" (presence of tracks of a
|
||||
// given kind) matches what is being requested
|
||||
if (shouldRequestAudio !== this.localUserMediaStream.getAudioTracks().length > 0) {
|
||||
canReuseStream = false;
|
||||
}
|
||||
if (shouldRequestVideo !== this.localUserMediaStream.getVideoTracks().length > 0) {
|
||||
canReuseStream = false;
|
||||
}
|
||||
|
||||
// This code checks that the device ID is the same as the localUserMediaStream stream, but we update
|
||||
// the localUserMediaStream whenever the device ID changes (apart from when restoring) so it's not
|
||||
// clear why this would ever be different, unless there's a race.
|
||||
if (shouldRequestAudio) {
|
||||
if (
|
||||
this.localUserMediaStream.getAudioTracks().length === 0 ||
|
||||
this.localUserMediaStream.getAudioTracks()[0]?.getSettings()?.deviceId !== this.audioInput
|
||||
) {
|
||||
canReuseStream = false;
|
||||
}
|
||||
if (
|
||||
shouldRequestAudio &&
|
||||
this.localUserMediaStream.getAudioTracks()[0]?.getSettings()?.deviceId !== this.audioInput
|
||||
) {
|
||||
canReuseStream = false;
|
||||
}
|
||||
if (shouldRequestVideo) {
|
||||
if (
|
||||
this.localUserMediaStream.getVideoTracks().length === 0 ||
|
||||
this.localUserMediaStream.getVideoTracks()[0]?.getSettings()?.deviceId !== this.videoInput
|
||||
) {
|
||||
canReuseStream = false;
|
||||
}
|
||||
if (
|
||||
shouldRequestVideo &&
|
||||
this.localUserMediaStream.getVideoTracks()[0]?.getSettings()?.deviceId !== this.videoInput
|
||||
) {
|
||||
canReuseStream = false;
|
||||
}
|
||||
} else {
|
||||
canReuseStream = false;
|
||||
|
||||
Reference in New Issue
Block a user