Compare commits

..

34 Commits

Author SHA1 Message Date
RiotRobot 7a51798acb v34.0.0 2024-07-08 12:18:02 +00:00
RiotRobot 24c589923b v34.0.0-rc.1 2024-07-04 12:49:22 +00:00
ElementRobot 1586de44bd [Backport staging] Fix "Unable to restore session" error (#4299)
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Fixes https://github.com/element-hq/element-web/issues/27666
2024-07-03 20:56:40 +01:00
David Baker 04ea2a4e5d Merge pull request #4297 from matrix-org/backport-4296-to-staging
[Backport staging] Fix error when sending encrypted messages in large rooms
2024-07-03 15:22:31 +01:00
Richard van der Hoff b71099d0f8 Bump matrix-rust-sdk to 6.2.0 (#4296)
Fixes https://github.com/element-hq/element-web/issues/27658

(cherry picked from commit ccc2fb5663)
2024-07-03 14:04:38 +00:00
RiotRobot ab6260074d v34.0.0-rc.0 2024-06-25 12:50:01 +00:00
Andrew Ferrazzutti 25a7c9e140 Prefix the user+device state key if needed (#4262)
* Prefix the user+device state key if needed

No need to prefix it for rooms that use MSC3779.
Otherwise, prefix it to bypass the auth rule for state events with keys
starting with @.

* Use RegExp.exec() method instead

Sonar typescript:S6594

* Split nested ternary operator into method

Sonar typescript:S3358

* Add test coverage
2024-06-21 17:31:42 +00:00
Michael Telatynski 78b6b878bd Remove domexception polyfill, has been native in NodeJS since v17 (#4253)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-06-21 14:15:54 +00:00
Richard van der Hoff 4ccb72c0f2 Element-R: Fix resource leaks in verification logic (#4263)
* Move `RustVerificationRequest.onChange` out to a method

The only reason it was an inner function in the first place was to avoid
storing a reference in the class to `outgoingRequestProcessor`. That changed
with d1dec4cd08.

* Fix reference cycles in rust verification code
2024-06-21 12:55:43 +00:00
Richard van der Hoff 9f1aebbdcb Bump ES target version to ES2022 (#4264)
* Bump ES target version to ES2022

I want to be able to use `WeakRef`, and per
https://github.com/element-hq/element-web/issues/24913#issuecomment-2182448007,
I believe this should be safe.

* room.ts: Fix initialisation order

It seems that ES2022 causes typescript to change the initialization order of
regular properties vs parameter properties
(https://github.com/microsoft/TypeScript/issues/45995), so we need to rearrange
the initializations to avoid an error.

In practice, it might be fine because we have enabled
`babel-plugin-transform-class-properties`, which moves the initialization back
after the parameter property, but we shoudn't rely on that, and anyway it
upsets the linter.
2024-06-21 11:50:28 +00:00
Andrew Ferrazzutti 6a15e8f1a0 Use legacy call membership if anyone else is (#4260)
* Use legacy call membership if anyone else is

* Convert nullish to boolean

* Update tests

* Lint

* Use computed decision to use legacy events or not

* Check if discovered legacy sessions are ongoing

* Lint

* Lint again

* Increase test coverage
2024-06-21 11:40:27 +00:00
Richard van der Hoff 238eea0ef5 Upgrade Rust Crypto SDK to 6.1.0 (#4261)
Fixes https://github.com/element-hq/element-web/issues/27590
2024-06-20 08:28:17 +00:00
Michael Telatynski ab6f86536f Replace deprecated babel proposal plugins (#4254)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-06-19 15:11:38 +00:00
David Baker 819fc75202 Fetch capabilities in the background (#4246)
* Fetch capabilities in the background

& keep them up to date

* Add missed await

* Replace some more runAllTimers

and round down the wait time for sanity

* Remove double comment

* Typo

* Add a method back that will fetch capabilities if they're not already there

* Add tests

* Catch exception here too

* Add test for room version code
2024-06-19 10:24:56 +00:00
renovate[bot] c70aa33367 Update typescript-eslint monorepo to v7.13.1 (#4257)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-19 09:50:23 +00:00
renovate[bot] 240b43b652 Update typedoc (#4258)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-19 08:57:01 +00:00
renovate[bot] 697d5d31d1 Update all non-major dependencies (#4256)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-19 08:54:29 +00:00
Robin b1701ff571 Correctly transform base64 with multiple instances of + or / (#4252)
String.replace only replaces a single instance of the search pattern by default; we need a regex in g mode if we want to replace them all.
2024-06-18 14:58:16 +00:00
Michael Telatynski c55289ec65 Use server name instead of homeserver url to allow well-known lookups during QR OIDC reciprocation (#4233)
* Use server name instead of homeserver url to allow well-known lookups during QR OIDC reciprocation

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-06-18 13:03:14 +00:00
RiotRobot 987ec1e62f Resetting package fields for development 2024-06-18 11:55:23 +00:00
RiotRobot 76b240cf57 Merge branch 'master' into develop 2024-06-18 11:55:21 +00:00
RiotRobot a4c4e7e275 v33.1.0 2024-06-18 11:54:47 +00:00
Johannes Marbach 3f5a994a24 Add via parameter for MSC4156 (#4247)
* Add via parameter for MSC4156

Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>

* Always include both parameters

* Fix tests

---------

Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2024-06-18 08:14:48 +00:00
Timo d754392410 Make the js-sdk compatible with MSC preferred foci and active focus. (#4195)
* Refactor to preferred and active foci.

Signed-off-by: Timo K <toger5@hotmail.de>

* make the sdk compatible with MSC4143 but still be backwards compatible

* comment fixes

* also fallback to legacy if the current member event is legacy

* use XOR types

* use EitherAnd

* make livekit Foucs types simpler

* review

* fix tests

* test work

* more review + more tests

* remove unnecassary await that is in conflict with the comment

* make joinRoomSession sync

* Update src/matrixrtc/MatrixRTCSession.ts

Co-authored-by: Andrew Ferrazzutti <af_0_af@hotmail.com>

* review

* fix

* test

* review

* review

* comment clarification

* typo

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Andrew Ferrazzutti <af_0_af@hotmail.com>
2024-06-17 13:02:29 +00:00
Michael Telatynski 7ecaa53e34 Work around spec bug for m.room.avatar state event content type (#4245)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-06-17 09:30:13 +00:00
David Baker 9218f6380c Fix screen sharing in recent Chrome (#4241)
* Fix screen sharing in recent Chrome

Dreadful hack to work around a bug in recent chrome/electron's
WebRTC, as explained.

I'm not sure which is the least hideous out of this (ie. repeatedly
calling setCodecPreferences and seeing if it crashes each time) or
hardcoding the bad codec and skipping it. Opinions welcome.

* Unused import

* Remove commented line
2024-06-13 18:44:30 +00:00
Travis Ralston 661ba76763 Use stable endpoints for MSC3916 (#4239)
* Use stable endpoints for MSC3916

* appease the linter
2024-06-13 17:03:25 +00:00
Michael Telatynski 4cb851c51a Replace usages of setImmediate with setTimeout for wider compatibility (#4240)
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-06-13 16:01:33 +01:00
renovate[bot] 5a3d24abc2 Update typescript-eslint monorepo to v7.12.0 (#4237)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-11 13:15:29 +00:00
renovate[bot] 3eed74f1a6 Update dependency uuid to v10 (#4238)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-11 12:26:43 +00:00
renovate[bot] 10e7a2d997 Update dependency typedoc-plugin-mdn-links to v3.1.28 (#4236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-11 12:17:02 +00:00
renovate[bot] 969ecdb6fb Update dependency @types/node to v18.19.34 (#4235)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-11 12:16:52 +00:00
renovate[bot] e8b91f2729 Update babel monorepo to v7.24.7 (#4234)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-11 12:16:38 +00:00
renovate[bot] f80366ff30 Update all non-major dependencies (#4229)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-11 12:09:01 +00:00
44 changed files with 2205 additions and 1400 deletions
+16
View File
@@ -49,6 +49,22 @@ module.exports = {
},
],
"no-restricted-properties": [
"error",
{
object: "window",
property: "setImmediate",
message: "Use setTimeout instead.",
},
],
"no-restricted-globals": [
"error",
{
name: "setImmediate",
message: "Use setTimeout instead.",
},
],
"import/no-restricted-paths": [
"error",
{
+40
View File
@@ -1,3 +1,43 @@
Changes in [34.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v34.0.0) (2024-07-08)
==================================================================================================
## 🚨 BREAKING CHANGES
* Fetch capabilities in the background ([#4246](https://github.com/matrix-org/matrix-js-sdk/pull/4246)). Contributed by @dbkr.
## ✨ Features
* Prefix the user+device state key if needed ([#4262](https://github.com/matrix-org/matrix-js-sdk/pull/4262)). Contributed by @AndrewFerr.
* Use legacy call membership if anyone else is ([#4260](https://github.com/matrix-org/matrix-js-sdk/pull/4260)). Contributed by @AndrewFerr.
* Fetch capabilities in the background ([#4246](https://github.com/matrix-org/matrix-js-sdk/pull/4246)). Contributed by @dbkr.
* Use server name instead of homeserver url to allow well-known lookups during QR OIDC reciprocation ([#4233](https://github.com/matrix-org/matrix-js-sdk/pull/4233)). Contributed by @t3chguy.
* Add via parameter for MSC4156 ([#4247](https://github.com/matrix-org/matrix-js-sdk/pull/4247)). Contributed by @Johennes.
* Make the js-sdk compatible with MSC preferred foci and active focus. ([#4195](https://github.com/matrix-org/matrix-js-sdk/pull/4195)). Contributed by @toger5.
* Replace usages of setImmediate with setTimeout for wider compatibility ([#4240](https://github.com/matrix-org/matrix-js-sdk/pull/4240)). Contributed by @t3chguy.
## 🐛 Bug Fixes
* [Backport staging] Fix "Unable to restore session" error ([#4299](https://github.com/matrix-org/matrix-js-sdk/pull/4299)). Contributed by @RiotRobot.
* [Backport staging] Fix error when sending encrypted messages in large rooms ([#4297](https://github.com/matrix-org/matrix-js-sdk/pull/4297)). Contributed by @RiotRobot.
* Element-R: Fix resource leaks in verification logic ([#4263](https://github.com/matrix-org/matrix-js-sdk/pull/4263)). Contributed by @richvdh.
* Upgrade Rust Crypto SDK to 6.1.0 ([#4261](https://github.com/matrix-org/matrix-js-sdk/pull/4261)). Contributed by @richvdh.
* Correctly transform base64 with multiple instances of + or / ([#4252](https://github.com/matrix-org/matrix-js-sdk/pull/4252)). Contributed by @robintown.
* Work around spec bug for m.room.avatar state event content type ([#4245](https://github.com/matrix-org/matrix-js-sdk/pull/4245)). Contributed by @t3chguy.
Changes in [33.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v33.1.0) (2024-06-18)
==================================================================================================
## ✨ Features
* MSC4108 support OIDC QR code login ([#4134](https://github.com/matrix-org/matrix-js-sdk/pull/4134)). Contributed by @t3chguy.
* Add crypto methods for export and import of secrets bundle ([#4227](https://github.com/matrix-org/matrix-js-sdk/pull/4227)). Contributed by @t3chguy.
## 🐛 Bug Fixes
* Fix screen sharing in recent Chrome ([#4243](https://github.com/matrix-org/matrix-js-sdk/pull/4243)). Contributed by @RiotRobot.
* Fix incorrect assumptions about required fields in /search response ([#4228](https://github.com/matrix-org/matrix-js-sdk/pull/4228)). Contributed by @t3chguy.
* Fix the queueToDevice tests for the new fakeindexeddb ([#4225](https://github.com/matrix-org/matrix-js-sdk/pull/4225)). Contributed by @dbkr.
Changes in [33.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v33.0.0) (2024-06-04)
==================================================================================================
## 🚨 BREAKING CHANGES
+1 -1
View File
@@ -95,7 +95,7 @@ Object.keys(client.store.rooms).forEach((roomId) => {
## Authenticated media
Servers supporting [MSC3916](https://github.com/matrix-org/matrix-spec-proposals/pull/3916) will require clients, like
Servers supporting [MSC3916](https://github.com/matrix-org/matrix-spec-proposals/pull/3916) (Matrix 1.11) will require clients, like
yours, to include an `Authorization` header when `/download`ing or `/thumbnail`ing media. For NodeJS environments this
may be as easy as the following code snippet, though web browsers may need to use [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
to append the header when using the endpoints in `<img />` elements and similar.
+3 -3
View File
@@ -17,9 +17,9 @@ module.exports = {
"@babel/preset-typescript",
],
plugins: [
"@babel/plugin-proposal-numeric-separator",
"@babel/plugin-proposal-class-properties",
"@babel/plugin-proposal-object-rest-spread",
"@babel/plugin-transform-numeric-separator",
"@babel/plugin-transform-class-properties",
"@babel/plugin-transform-object-rest-spread",
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-transform-runtime",
],
+7 -9
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "33.1.0-rc.1",
"version": "34.0.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=18.0.0"
@@ -53,7 +53,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^5.0.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^6.0.0",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
@@ -65,7 +65,7 @@
"p-retry": "4",
"sdp-transform": "^2.14.1",
"unhomoglyph": "^1.0.6",
"uuid": "9"
"uuid": "10"
},
"devDependencies": {
"@action-validator/cli": "^0.6.0",
@@ -74,10 +74,10 @@
"@babel/core": "^7.12.10",
"@babel/eslint-parser": "^7.12.10",
"@babel/eslint-plugin": "^7.12.10",
"@babel/plugin-proposal-class-properties": "^7.12.1",
"@babel/plugin-proposal-numeric-separator": "^7.12.7",
"@babel/plugin-proposal-object-rest-spread": "^7.12.1",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-class-properties": "^7.12.1",
"@babel/plugin-transform-numeric-separator": "^7.12.7",
"@babel/plugin-transform-object-rest-spread": "^7.12.1",
"@babel/plugin-transform-runtime": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@babel/preset-typescript": "^7.12.7",
@@ -87,7 +87,6 @@
"@types/bs58": "^4.0.1",
"@types/content-type": "^1.1.5",
"@types/debug": "^4.1.7",
"@types/domexception": "^4.0.0",
"@types/jest": "^29.0.0",
"@types/node": "18",
"@types/sdp-transform": "^2.4.5",
@@ -96,7 +95,6 @@
"@typescript-eslint/parser": "^7.0.0",
"babel-jest": "^29.0.0",
"debug": "^4.3.4",
"domexception": "^4.0.0",
"eslint": "8.57.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^9.0.0",
@@ -119,7 +117,7 @@
"lint-staged": "^15.0.2",
"matrix-mock-request": "^2.5.0",
"node-fetch": "^2.7.0",
"prettier": "3.3.0",
"prettier": "3.3.2",
"rimraf": "^5.0.0",
"ts-node": "^10.9.2",
"typedoc": "^0.25.10",
+2 -2
View File
@@ -796,7 +796,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
const result = await aliceCrypto.checkKeyBackupAndEnable();
expect(result).toBeTruthy();
jest.runAllTimers();
jest.advanceTimersByTime(10 * 60 * 1000);
await failurePromise;
// Fix the endpoint to do successful uploads
@@ -829,7 +829,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
});
// run the timers, which will make the backup loop redo the request
await jest.runAllTimersAsync();
await jest.advanceTimersByTimeAsync(10 * 60 * 1000);
await successPromise;
await allKeysUploadedPromise;
});
+100 -9
View File
@@ -257,7 +257,7 @@ describe("MatrixClient", function () {
.when("POST", "/knock/" + encodeURIComponent(roomId))
.check((request) => {
expect(request.data).toEqual({ reason: opts.reason });
expect(request.queryParams).toEqual({ server_name: opts.viaServers });
expect(request.queryParams).toEqual({ server_name: opts.viaServers, via: opts.viaServers });
})
.respond(200, { room_id: roomId });
@@ -1293,18 +1293,109 @@ describe("MatrixClient", function () {
});
describe("getCapabilities", () => {
it("should cache by default", async () => {
it("should return cached capabilities if present", async () => {
const capsObject = {
"m.change_password": false,
};
httpBackend!.when("GET", "/versions").respond(200, {});
httpBackend!.when("GET", "/pushrules").respond(200, {});
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: {
"m.change_password": false,
},
capabilities: capsObject,
});
const prom = httpBackend.flushAllExpected();
const capabilities1 = await client.getCapabilities();
const capabilities2 = await client.getCapabilities();
client.startClient();
await httpBackend!.flushAllExpected();
expect(await client.getCapabilities()).toEqual(capsObject);
});
it("should fetch capabilities if cache not present", async () => {
const capsObject = {
"m.change_password": false,
};
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: capsObject,
});
const capsPromise = client.getCapabilities();
await httpBackend!.flushAllExpected();
expect(await capsPromise).toEqual(capsObject);
});
});
describe("getCachedCapabilities", () => {
it("should return cached capabilities or undefined", async () => {
const capsObject = {
"m.change_password": false,
};
httpBackend!.when("GET", "/versions").respond(200, {});
httpBackend!.when("GET", "/pushrules").respond(200, {});
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: capsObject,
});
expect(client.getCachedCapabilities()).toBeUndefined();
client.startClient();
await httpBackend!.flushAllExpected();
expect(client.getCachedCapabilities()).toEqual(capsObject);
});
});
describe("fetchCapabilities", () => {
const capsObject = {
"m.change_password": false,
};
beforeEach(() => {
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: capsObject,
});
});
afterEach(() => {
jest.useRealTimers();
});
it("should always fetch capabilities and then cache", async () => {
const prom = client.fetchCapabilities();
await httpBackend.flushAllExpected();
const caps = await prom;
expect(caps).toEqual(capsObject);
});
it("should write-through the cache", async () => {
httpBackend!.when("GET", "/versions").respond(200, {});
httpBackend!.when("GET", "/pushrules").respond(200, {});
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
client.startClient();
await httpBackend!.flushAllExpected();
expect(client.getCachedCapabilities()).toEqual(capsObject);
const newCapsObject = {
"m.change_password": true,
};
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: newCapsObject,
});
const prom = client.fetchCapabilities();
await httpBackend.flushAllExpected();
await prom;
expect(capabilities1).toStrictEqual(capabilities2);
expect(client.getCachedCapabilities()).toEqual(newCapsObject);
});
});
@@ -105,13 +105,13 @@ describe("MatrixClient syncing errors", () => {
await client!.startClient();
expect(await syncEvents[0].promise).toBe(SyncState.Error);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[1].promise).toBe(SyncState.Error);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[2].promise).toBe(SyncState.Prepared);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[3].promise).toBe(SyncState.Syncing);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[4].promise).toBe(SyncState.Syncing);
});
@@ -119,6 +119,7 @@ describe("MatrixClient syncing errors", () => {
jest.useFakeTimers();
fetchMock.config.overwriteRoutes = false;
fetchMock
.get("end:capabilities", {})
.getOnce("end:versions", {}) // first version check without credentials needs to succeed
.get("end:versions", unknownTokenErrorData) // further version checks fails with 401
.get("end:pushrules/", 401) // fails with 401 without an error. This does happen in practice e.g. with Synapse
@@ -54,9 +54,7 @@ function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled
return opts.deviceId;
},
baseUrl,
getHomeserverUrl() {
return baseUrl;
},
getDomain: () => "example.com",
getDevice: jest.fn(),
getCrypto: jest.fn(() => crypto),
getAuthIssuer: jest.fn().mockResolvedValue({ issuer: "https://issuer/" }),
@@ -157,19 +155,19 @@ describe("MSC4108SignInWithQR", () => {
client = makeMockClient({ userId: "@alice:example.com", deviceId: "alice", msc4108Enabled: true });
const ourChannel = new MSC4108SecureChannel(ourMockSession);
const qrCodeData = QrCodeData.from_bytes(
await ourChannel.generateCode(QrCodeMode.Reciprocate, client.getHomeserverUrl()),
const qrCodeData = QrCodeData.fromBytes(
await ourChannel.generateCode(QrCodeMode.Reciprocate, client.getDomain()!),
);
const opponentChannel = new MSC4108SecureChannel(opponentMockSession, qrCodeData.public_key);
const opponentChannel = new MSC4108SecureChannel(opponentMockSession, qrCodeData.publicKey);
ourLogin = new MSC4108SignInWithQR(ourChannel, true, client);
opponentLogin = new MSC4108SignInWithQR(opponentChannel, false);
});
it("should be able to connect with opponent and share homeserver url & check code", async () => {
it("should be able to connect with opponent and share server name & check code", async () => {
await Promise.all([
expect(ourLogin.negotiateProtocols()).resolves.toEqual({}),
expect(opponentLogin.negotiateProtocols()).resolves.toEqual({ homeserverBaseUrl: client.baseUrl }),
expect(opponentLogin.negotiateProtocols()).resolves.toEqual({ serverName: client.getDomain() }),
]);
expect(ourLogin.checkCode).toBe(opponentLogin.checkCode);
-4
View File
@@ -14,10 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import DOMException from "domexception";
global.DOMException = DOMException as typeof global.DOMException;
jest.mock("../src/http-api/utils", () => ({
...jest.requireActual("../src/http-api/utils"),
// We mock timeoutSignal otherwise it causes tests to leave timers running
+1 -1
View File
@@ -88,6 +88,6 @@ export const mockClientMethodsEvents = () => ({
export const mockClientMethodsServer = (): Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> => ({
getIdentityServerUrl: jest.fn(),
getHomeserverUrl: jest.fn(),
getCapabilities: jest.fn().mockReturnValue({}),
getCachedCapabilities: jest.fn().mockReturnValue({}),
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
});
+5 -4
View File
@@ -50,17 +50,18 @@ describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => {
});
it("Should encode unpadded URL-safe base64", () => {
const toEncode = "?????";
// Chosen to have padding and multiple instances of / and + in the base64
const toEncode = "???????⊕⊗⊗";
const data = new TextEncoder().encode(toEncode);
const encoded = encodeUnpaddedBase64Url(data);
expect(encoded).toEqual("Pz8_Pz8");
expect(encoded).toEqual("Pz8_Pz8_P-KKleKKl-KKlw");
});
it("Should decode URL-safe base64", () => {
const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8="));
const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8_P-KKleKKl-KKlw=="));
expect(decoded).toStrictEqual("?????");
expect(decoded).toStrictEqual("???????⊕⊗⊗");
});
it("Encode unpadded should not have padding", () => {
+4 -6
View File
@@ -80,24 +80,22 @@ describe("ContentRepo", function () {
it("should return an authenticated URL when requested", function () {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri, undefined, undefined, undefined, undefined, true, true)).toEqual(
baseUrl +
"/_matrix/client/unstable/org.matrix.msc3916/media/download/server.name/resourceid?allow_redirect=true",
baseUrl + "/_matrix/client/v1/media/download/server.name/resourceid?allow_redirect=true",
);
expect(getHttpUriForMxc(baseUrl, mxcUri, 64, 64, "scale", undefined, true, true)).toEqual(
baseUrl +
"/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
"/_matrix/client/v1/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
);
});
it("should force-enable allow_redirects when useAuthentication is set true", function () {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri, undefined, undefined, undefined, undefined, false, true)).toEqual(
baseUrl +
"/_matrix/client/unstable/org.matrix.msc3916/media/download/server.name/resourceid?allow_redirect=true",
baseUrl + "/_matrix/client/v1/media/download/server.name/resourceid?allow_redirect=true",
);
expect(getHttpUriForMxc(baseUrl, mxcUri, 64, 64, "scale", undefined, false, true)).toEqual(
baseUrl +
"/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
"/_matrix/client/v1/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
);
});
});
+155 -85
View File
@@ -15,16 +15,7 @@ limitations under the License.
*/
import { MatrixEvent } from "../../../src";
import { CallMembership, CallMembershipData } from "../../../src/matrixrtc/CallMembership";
const membershipTemplate: CallMembershipData = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA",
expires: 5000,
membershipID: "bloop",
};
import { CallMembership, CallMembershipDataLegacy, SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
function makeMockEvent(originTs = 0): MatrixEvent {
return {
@@ -34,96 +25,175 @@ function makeMockEvent(originTs = 0): MatrixEvent {
}
describe("CallMembership", () => {
it("rejects membership with no expiry and no expires_ts", () => {
expect(() => {
new CallMembership(
makeMockEvent(),
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: undefined }),
describe("CallMembershipDataLegacy", () => {
const membershipTemplate: CallMembershipDataLegacy = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA",
expires: 5000,
membershipID: "bloop",
foci_active: [{ type: "livekit" }],
};
it("rejects membership with no expiry and no expires_ts", () => {
expect(() => {
new CallMembership(
makeMockEvent(),
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: undefined }),
);
}).toThrow();
});
it("rejects membership with no device_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
}).toThrow();
});
it("rejects membership with no call_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
}).toThrow();
});
it("allow membership with no scope", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
}).not.toThrow();
});
it("rejects with malformatted expires_ts", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires_ts: "string" }));
}).toThrow();
});
it("rejects with malformatted expires", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: "string" }));
}).toThrow();
});
it("uses event timestamp if no created_ts", () => {
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
expect(membership.createdTs()).toEqual(12345);
});
it("uses created_ts if present", () => {
const membership = new CallMembership(
makeMockEvent(12345),
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
);
}).toThrow();
expect(membership.createdTs()).toEqual(67890);
});
it("computes absolute expiry time based on expires", () => {
const membership = new CallMembership(makeMockEvent(1000), membershipTemplate);
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
});
it("computes absolute expiry time based on expires_ts", () => {
const membership = new CallMembership(
makeMockEvent(1000),
Object.assign({}, membershipTemplate, { expires_ts: 6000 }),
);
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
});
it("considers memberships unexpired if local age low enough", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000);
const membership = new CallMembership(fakeEvent, membershipTemplate);
expect(membership.isExpired()).toEqual(false);
});
it("considers memberships expired when local age large", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.localTimestamp = Date.now() - 6000;
const membership = new CallMembership(fakeEvent, membershipTemplate);
expect(membership.isExpired()).toEqual(true);
});
it("returns preferred foci", () => {
const fakeEvent = makeMockEvent();
const mockFocus = { type: "this_is_a_mock_focus" };
const membership = new CallMembership(
fakeEvent,
Object.assign({}, membershipTemplate, { foci_active: [mockFocus] }),
);
expect(membership.getPreferredFoci()).toEqual([mockFocus]);
});
});
it("rejects membership with no device_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
}).toThrow();
});
describe("SessionMembershipData", () => {
const membershipTemplate: SessionMembershipData = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA",
focus_active: { type: "livekit" },
foci_preferred: [{ type: "livekit" }],
};
it("rejects membership with no call_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
}).toThrow();
});
it("rejects membership with no device_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
}).toThrow();
});
it("rejects membership with no scope", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
}).toThrow();
});
it("rejects with malformatted expires_ts", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires_ts: "string" }));
}).toThrow();
});
it("rejects with malformatted expires", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: "string" }));
}).toThrow();
});
it("rejects membership with no call_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
}).toThrow();
});
it("uses event timestamp if no created_ts", () => {
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
expect(membership.createdTs()).toEqual(12345);
});
it("allow membership with no scope", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
}).not.toThrow();
});
it("uses created_ts if present", () => {
const membership = new CallMembership(
makeMockEvent(12345),
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
);
expect(membership.createdTs()).toEqual(67890);
});
it("uses event timestamp if no created_ts", () => {
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
expect(membership.createdTs()).toEqual(12345);
});
it("computes absolute expiry time based on expires", () => {
const membership = new CallMembership(makeMockEvent(1000), membershipTemplate);
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
});
it("uses created_ts if present", () => {
const membership = new CallMembership(
makeMockEvent(12345),
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
);
expect(membership.createdTs()).toEqual(67890);
});
it("computes absolute expiry time based on expires_ts", () => {
const membership = new CallMembership(
makeMockEvent(1000),
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: 6000 }),
);
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
});
it("considers memberships unexpired if local age low enough", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000);
const membership = new CallMembership(fakeEvent, membershipTemplate);
expect(membership.isExpired()).toEqual(false);
});
it("considers memberships unexpired if local age low enough", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000);
const membership = new CallMembership(fakeEvent, membershipTemplate);
expect(membership.isExpired()).toEqual(false);
});
it("considers memberships expired when local age large", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.localTimestamp = Date.now() - 6000;
const membership = new CallMembership(fakeEvent, membershipTemplate);
expect(membership.isExpired()).toEqual(true);
});
it("returns active foci", () => {
const fakeEvent = makeMockEvent();
const mockFocus = { type: "this_is_a_mock_focus" };
const membership = new CallMembership(
fakeEvent,
Object.assign({}, membershipTemplate, { foci_active: [mockFocus] }),
);
expect(membership.getActiveFoci()).toEqual([mockFocus]);
it("returns preferred foci", () => {
const fakeEvent = makeMockEvent();
const mockFocus = { type: "this_is_a_mock_focus" };
const membership = new CallMembership(
fakeEvent,
Object.assign({}, membershipTemplate, { foci_preferred: [mockFocus] }),
);
expect(membership.getPreferredFoci()).toEqual([mockFocus]);
});
});
describe("expiry calculation", () => {
let fakeEvent: MatrixEvent;
let membership: CallMembership;
const membershipTemplate: CallMembershipDataLegacy = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA",
expires: 5000,
membershipID: "bloop",
foci_active: [{ type: "livekit" }],
};
beforeEach(() => {
// server origin timestamp for this event is 1000
+60
View File
@@ -0,0 +1,60 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus";
describe("LivekitFocus", () => {
it("isLivekitFocus", () => {
expect(
isLivekitFocus({
type: "livekit",
livekit_service_url: "http://test.com",
livekit_alias: "test",
}),
).toBeTruthy();
expect(isLivekitFocus({ type: "livekit" })).toBeFalsy();
expect(
isLivekitFocus({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
).toBeFalsy();
expect(
isLivekitFocus({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
).toBeFalsy();
expect(
isLivekitFocus({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
).toBeFalsy();
});
it("isLivekitFocusActive", () => {
expect(
isLivekitFocusActive({
type: "livekit",
focus_selection: "oldest_membership",
}),
).toBeTruthy();
expect(isLivekitFocusActive({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusActive({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
});
it("isLivekitFocusConfig", () => {
expect(
isLivekitFocusConfig({
type: "livekit",
livekit_service_url: "http://test.com",
}),
).toBeTruthy();
expect(isLivekitFocusConfig({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
expect(isLivekitFocusConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
});
});
+237 -42
View File
@@ -16,7 +16,11 @@ limitations under the License.
import { EventTimeline, EventType, MatrixClient, MatrixError, MatrixEvent, Room } from "../../../src";
import { KnownMembership } from "../../../src/@types/membership";
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
import {
CallMembershipData,
CallMembershipDataLegacy,
SessionMembershipData,
} from "../../../src/matrixrtc/CallMembership";
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
import { EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
import { randomString } from "../../../src/randomstring";
@@ -29,6 +33,7 @@ const membershipTemplate: CallMembershipData = {
device_id: "AAAAAAA",
expires: 60 * 60 * 1000,
membershipID: "bloop",
foci_active: [{ type: "livekit", livekit_service_url: "https://lk.url" }],
};
const mockFocus = { type: "mock" };
@@ -98,22 +103,33 @@ describe("MatrixRTCSession", () => {
});
it("safely ignores events with no memberships section", () => {
const roomId = randomString(8);
const event = {
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue({}),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getTs: jest.fn().mockReturnValue(1000),
getLocalAge: jest.fn().mockReturnValue(0),
};
const mockRoom = {
...makeMockRoom([]),
roomId: randomString(8),
roomId,
getLiveTimeline: jest.fn().mockReturnValue({
getState: jest.fn().mockReturnValue({
on: jest.fn(),
off: jest.fn(),
getStateEvents: (_type: string, _stateKey: string) => [
{
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue({}),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getTs: jest.fn().mockReturnValue(1000),
getLocalAge: jest.fn().mockReturnValue(0),
},
],
getStateEvents: (_type: string, _stateKey: string) => [event],
events: new Map([
[
EventType.GroupCallMemberPrefix,
{
size: () => true,
has: (_stateKey: string) => true,
get: (_stateKey: string) => event,
values: () => [event],
},
],
]),
}),
}),
};
@@ -122,22 +138,33 @@ describe("MatrixRTCSession", () => {
});
it("safely ignores events with junk memberships section", () => {
const roomId = randomString(8);
const event = {
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getTs: jest.fn().mockReturnValue(1000),
getLocalAge: jest.fn().mockReturnValue(0),
};
const mockRoom = {
...makeMockRoom([]),
roomId: randomString(8),
roomId,
getLiveTimeline: jest.fn().mockReturnValue({
getState: jest.fn().mockReturnValue({
on: jest.fn(),
off: jest.fn(),
getStateEvents: (_type: string, _stateKey: string) => [
{
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue({ memberships: "i am a fish" }),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getTs: jest.fn().mockReturnValue(1000),
getLocalAge: jest.fn().mockReturnValue(0),
},
],
getStateEvents: (_type: string, _stateKey: string) => [event],
events: new Map([
[
EventType.GroupCallMemberPrefix,
{
size: () => true,
has: (_stateKey: string) => true,
get: (_stateKey: string) => event,
values: () => [event],
},
],
]),
}),
}),
};
@@ -185,6 +212,67 @@ describe("MatrixRTCSession", () => {
expect(sess.memberships).toHaveLength(0);
});
describe("updateCallMembershipEvent", () => {
const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" };
const joinSessionConfig = { useLegacyMemberEvents: false };
const legacyMembershipData: CallMembershipDataLegacy = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA_legacy",
expires: 60 * 60 * 1000,
membershipID: "bloop",
foci_active: [mockFocus],
};
const expiredLegacyMembershipData: CallMembershipDataLegacy = {
...legacyMembershipData,
device_id: "AAAAAAA_legacy_expired",
expires: 0,
};
const sessionMembershipData: SessionMembershipData = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA_session",
focus_active: mockFocus,
foci_preferred: [mockFocus],
};
function testSession(
membershipData: CallMembershipData[] | SessionMembershipData,
shouldUseLegacy: boolean,
): void {
sess = MatrixRTCSession.roomSessionForRoom(client, makeMockRoom(membershipData));
const makeNewLegacyMembershipsMock = jest.spyOn(sess as any, "makeNewLegacyMemberships");
const makeNewMembershipMock = jest.spyOn(sess as any, "makeNewMembership");
sess.joinRoomSession([mockFocus], mockFocus, joinSessionConfig);
expect(makeNewLegacyMembershipsMock).toHaveBeenCalledTimes(shouldUseLegacy ? 1 : 0);
expect(makeNewMembershipMock).toHaveBeenCalledTimes(shouldUseLegacy ? 0 : 1);
}
it("uses legacy events if there are any active legacy calls", () => {
testSession([expiredLegacyMembershipData, legacyMembershipData, sessionMembershipData], true);
});
it('uses legacy events if a non-legacy call is in a "memberships" array', () => {
testSession([sessionMembershipData], true);
});
it("uses non-legacy events if all legacy calls are expired", () => {
testSession([expiredLegacyMembershipData], false);
});
it("uses non-legacy events if there are only non-legacy calls", () => {
testSession(sessionMembershipData, false);
});
});
describe("getOldestMembership", () => {
it("returns the oldest membership event", () => {
const mockRoom = makeMockRoom([
@@ -198,6 +286,64 @@ describe("MatrixRTCSession", () => {
});
});
describe("getsActiveFocus", () => {
const activeFociConfig = { type: "livekit", livekit_service_url: "https://active.url" };
it("gets the correct active focus with oldest_membership", () => {
const mockRoom = makeMockRoom([
Object.assign({}, membershipTemplate, {
device_id: "foo",
created_ts: 500,
foci_active: [activeFociConfig],
}),
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
type: "livekit",
focus_selection: "oldest_membership",
});
expect(sess.getActiveFocus()).toBe(activeFociConfig);
});
it("does not provide focus if the selction method is unknown", () => {
const mockRoom = makeMockRoom([
Object.assign({}, membershipTemplate, {
device_id: "foo",
created_ts: 500,
foci_active: [activeFociConfig],
}),
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
type: "livekit",
focus_selection: "unknown",
});
expect(sess.getActiveFocus()).toBe(undefined);
});
it("gets the correct active focus legacy", () => {
const mockRoom = makeMockRoom([
Object.assign({}, membershipTemplate, {
device_id: "foo",
created_ts: 500,
foci_active: [activeFociConfig],
}),
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }]);
expect(sess.getActiveFocus()).toBe(activeFociConfig);
});
});
describe("joining", () => {
let mockRoom: Room;
let sendStateEventMock: jest.Mock;
@@ -223,13 +369,13 @@ describe("MatrixRTCSession", () => {
});
it("shows joined once join is called", () => {
sess!.joinRoomSession([mockFocus]);
sess!.joinRoomSession([mockFocus], mockFocus);
expect(sess!.isJoined()).toEqual(true);
});
it("sends a membership event when joining a call", () => {
jest.useFakeTimers();
sess!.joinRoomSession([mockFocus]);
sess!.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoom!.roomId,
EventType.GroupCallMemberPrefix,
@@ -242,7 +388,8 @@ describe("MatrixRTCSession", () => {
device_id: "AAAAAAA",
expires: 3600000,
expires_ts: Date.now() + 3600000,
foci_active: [{ type: "mock" }],
foci_active: [mockFocus],
membershipID: expect.stringMatching(".*"),
},
],
@@ -252,12 +399,48 @@ describe("MatrixRTCSession", () => {
jest.useRealTimers();
});
describe("non-legacy calls", () => {
const activeFocusConfig = { type: "livekit", livekit_service_url: "https://active.url" };
const activeFocus = { type: "livekit", focus_selection: "oldest_membership" };
function testJoin(useOwnedStateEvents: boolean): void {
if (useOwnedStateEvents) {
mockRoom.getVersion = jest.fn().mockReturnValue("org.matrix.msc3779.default");
}
jest.useFakeTimers();
sess!.joinRoomSession([activeFocusConfig], activeFocus, { useLegacyMemberEvents: false });
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoom!.roomId,
EventType.GroupCallMemberPrefix,
{
application: "m.call",
scope: "m.room",
call_id: "",
device_id: "AAAAAAA",
foci_preferred: [activeFocusConfig],
focus_active: activeFocus,
} satisfies SessionMembershipData,
`${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA`,
);
jest.useRealTimers();
}
it("sends a membership event with session payload when joining a non-legacy call", () => {
testJoin(false);
});
it("does not prefix the state key with _ for rooms that support user-owned state events", () => {
testJoin(true);
});
});
it("does nothing if join called when already joined", () => {
sess!.joinRoomSession([mockFocus]);
sess!.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
sess!.joinRoomSession([mockFocus]);
sess!.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
@@ -274,15 +457,26 @@ describe("MatrixRTCSession", () => {
const sendStateEventMock = jest.fn().mockImplementation(resolveFn);
client.sendStateEvent = sendStateEventMock;
sess!.joinRoomSession([mockFocus]);
sess!.joinRoomSession([mockFocus], mockFocus);
const eventContent = await eventSentPromise;
// definitely should have renewed by 1 second before the expiry!
const timeElapsed = 60 * 60 * 1000 - 1000;
mockRoom.getLiveTimeline().getState(EventTimeline.FORWARDS)!.getStateEvents = jest
.fn()
.mockReturnValue(mockRTCEvent(eventContent.memberships, mockRoom.roomId, timeElapsed));
const event = mockRTCEvent(eventContent.memberships, mockRoom.roomId, timeElapsed);
const getState = mockRoom.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
getState.getStateEvents = jest.fn().mockReturnValue(event);
getState.events = new Map([
[
event.getType(),
{
size: () => true,
has: (_stateKey: string) => true,
get: (_stateKey: string) => event,
values: () => [event],
} as unknown as Map<string, MatrixEvent>,
],
]);
const eventReSentPromise = new Promise<Record<string, any>>((r) => {
resolveFn = (_roomId: string, _type: string, val: Record<string, any>) => {
@@ -308,7 +502,7 @@ describe("MatrixRTCSession", () => {
device_id: "AAAAAAA",
expires: 3600000 * 2,
expires_ts: 1000 + 3600000 * 2,
foci_active: [{ type: "mock" }],
foci_active: [mockFocus],
created_ts: 1000,
membershipID: expect.stringMatching(".*"),
},
@@ -322,7 +516,7 @@ describe("MatrixRTCSession", () => {
});
it("creates a key when joining", () => {
sess!.joinRoomSession([mockFocus], true);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
const keys = sess?.getKeysForParticipant("@alice:example.org", "AAAAAAA");
expect(keys).toHaveLength(1);
@@ -336,7 +530,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation(resolve);
});
sess!.joinRoomSession([mockFocus], true);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await eventSentPromise;
@@ -372,7 +566,7 @@ describe("MatrixRTCSession", () => {
});
});
sess!.joinRoomSession([mockFocus], true);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
jest.advanceTimersByTime(10000);
await eventSentPromise;
@@ -394,7 +588,7 @@ describe("MatrixRTCSession", () => {
throw e;
});
sess!.joinRoomSession([mockFocus], true);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel);
});
@@ -409,7 +603,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation(resolve);
});
sess.joinRoomSession([mockFocus], true);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1;
sendEventMock.mockClear();
@@ -462,7 +656,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
});
sess.joinRoomSession([mockFocus], true);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
const firstKeysPayload = await keysSentPromise1;
expect(firstKeysPayload.keys).toHaveLength(1);
@@ -489,7 +683,7 @@ describe("MatrixRTCSession", () => {
});
it("Doesn't re-send key immediately", async () => {
const realSetImmediate = setImmediate;
const realSetTimeout = setTimeout;
jest.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
@@ -499,7 +693,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation(resolve);
});
sess.joinRoomSession([mockFocus], true);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1;
sendEventMock.mockClear();
@@ -517,7 +711,7 @@ describe("MatrixRTCSession", () => {
sess.onMembershipUpdate();
await new Promise((resolve) => {
realSetImmediate(resolve);
realSetTimeout(resolve);
});
expect(sendEventMock).not.toHaveBeenCalled();
@@ -595,7 +789,7 @@ describe("MatrixRTCSession", () => {
jest.advanceTimersByTime(10000);
sess.joinRoomSession([mockFocus]);
sess.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoomNoExpired!.roomId,
@@ -631,7 +825,7 @@ describe("MatrixRTCSession", () => {
]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.joinRoomSession([mockFocus]);
sess.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoom!.roomId,
@@ -645,6 +839,7 @@ describe("MatrixRTCSession", () => {
device_id: "OTHERDEVICE",
expires: 3600000,
created_ts: 1000,
foci_active: [{ type: "livekit", livekit_service_url: "https://lk.url" }],
membershipID: expect.stringMatching(".*"),
},
{
@@ -35,6 +35,7 @@ const membershipTemplate: CallMembershipData = {
device_id: "AAAAAAA",
expires: 60 * 60 * 1000,
membershipID: "bloop",
foci_active: [{ type: "test" }],
};
describe("MatrixRTCSessionManager", () => {
+27 -9
View File
@@ -15,24 +15,27 @@ limitations under the License.
*/
import { EventType, MatrixEvent, Room } from "../../../src";
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
import { CallMembershipData, SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
import { randomString } from "../../../src/randomstring";
export function makeMockRoom(memberships: CallMembershipData[], localAge: number | null = null): Room {
type MembershipData = CallMembershipData[] | SessionMembershipData;
export function makeMockRoom(membershipData: MembershipData, localAge: number | null = null): Room {
const roomId = randomString(8);
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
const roomState = makeMockRoomState(memberships, roomId, localAge);
const roomState = makeMockRoomState(membershipData, roomId, localAge);
return {
roomId: roomId,
hasMembershipState: jest.fn().mockReturnValue(true),
getLiveTimeline: jest.fn().mockReturnValue({
getState: jest.fn().mockReturnValue(roomState),
}),
getVersion: jest.fn().mockReturnValue("default"),
} as unknown as Room;
}
export function makeMockRoomState(memberships: CallMembershipData[], roomId: string, localAge: number | null = null) {
const event = mockRTCEvent(memberships, roomId, localAge);
export function makeMockRoomState(membershipData: MembershipData, roomId: string, localAge: number | null = null) {
const event = mockRTCEvent(membershipData, roomId, localAge);
return {
on: jest.fn(),
off: jest.fn(),
@@ -40,15 +43,30 @@ export function makeMockRoomState(memberships: CallMembershipData[], roomId: str
if (stateKey !== undefined) return event;
return [event];
},
events: new Map([
[
event.getType(),
{
size: () => true,
has: (_stateKey: string) => true,
get: (_stateKey: string) => event,
values: () => [event],
},
],
]),
};
}
export function mockRTCEvent(memberships: CallMembershipData[], roomId: string, localAge: number | null): MatrixEvent {
export function mockRTCEvent(membershipData: MembershipData, roomId: string, localAge: number | null): MatrixEvent {
return {
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue({
memberships: memberships,
}),
getContent: jest.fn().mockReturnValue(
!Array.isArray(membershipData)
? membershipData
: {
memberships: membershipData,
},
),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getTs: jest.fn().mockReturnValue(1000),
localTimestamp: Date.now() - (localAge ?? 10),
@@ -44,9 +44,9 @@ describe("MSC4108SecureChannel", () => {
} as unknown as MSC4108RendezvousSession;
const channel = new MSC4108SecureChannel(mockSession);
const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.public_key,
qrCodeData.publicKey,
"MATRIX_QR_CODE_LOGIN_INITIATE",
);
mocked(mockSession.receive).mockResolvedValue(ciphertext);
@@ -65,9 +65,9 @@ describe("MSC4108SecureChannel", () => {
mocked(mockSession.receive).mockResolvedValue("");
await expect(channel.connect()).rejects.toThrow("No response from other device");
const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.public_key,
qrCodeData.publicKey,
"NOT_REAL_MATRIX_QR_CODE_LOGIN_INITIATE",
);
@@ -88,9 +88,9 @@ describe("MSC4108SecureChannel", () => {
} as unknown as MSC4108RendezvousSession;
channel = new MSC4108SecureChannel(mockSession);
const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const { channel: _opponentChannel, initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.public_key,
qrCodeData.publicKey,
"MATRIX_QR_CODE_LOGIN_INITIATE",
);
opponentChannel = _opponentChannel;
+1 -1
View File
@@ -116,7 +116,7 @@ function makeMockClient(opts: {
},
};
},
getCapabilities() {
getCachedCapabilities() {
return opts.msc3882r0Only
? {}
: {
+43
View File
@@ -4034,4 +4034,47 @@ describe("Room", function () {
expect(room.getLastThread()).toBe(thread2);
});
});
describe("getRecommendedVersion", () => {
it("returns the server's recommended version from capabilities", async () => {
const client = new TestClient(userA).client;
client.getCapabilities = jest.fn().mockReturnValue({
["m.room_versions"]: {
default: "1",
available: ["1", "2"],
},
});
const room = new Room(roomId, client, userA);
expect(await room.getRecommendedVersion()).toEqual({
version: "1",
needsUpgrade: false,
urgent: false,
});
});
it("force-refreshes versions to make sure an upgrade is necessary", async () => {
const client = new TestClient(userA).client;
client.getCapabilities = jest.fn().mockReturnValue({
["m.room_versions"]: {
default: "5",
available: ["5"],
},
});
client.fetchCapabilities = jest.fn().mockResolvedValue({
["m.room_versions"]: {
default: "1",
available: ["1"],
},
});
const room = new Room(roomId, client, userA);
expect(await room.getRecommendedVersion()).toEqual({
version: "1",
needsUpgrade: false,
urgent: false,
});
expect(client.fetchCapabilities).toHaveBeenCalled();
});
});
});
+5 -1
View File
@@ -57,6 +57,7 @@ import {
} from "../webrtc/callEventTypes";
import { EncryptionKeysEventContent, ICallNotifyContent } from "../matrixrtc/types";
import { M_POLL_END, M_POLL_START, PollEndEventContent, PollStartEventContent } from "./polls";
import { SessionMembershipData } from "../matrixrtc/CallMembership";
export enum EventType {
// Room state events
@@ -356,7 +357,10 @@ export interface StateEvents {
// MSC3401
[EventType.GroupCallPrefix]: IGroupCallRoomState;
[EventType.GroupCallMemberPrefix]: XOR<IGroupCallRoomMemberState, ExperimentalGroupCallRoomMemberState>;
[EventType.GroupCallMemberPrefix]: XOR<
XOR<IGroupCallRoomMemberState, ExperimentalGroupCallRoomMemberState>,
XOR<SessionMembershipData, {}>
>;
// MSC3089
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
+4
View File
@@ -37,4 +37,8 @@ declare module "@matrix-org/matrix-sdk-crypto-wasm" {
};
}>;
}
interface Device {
requestVerification(methods?: any[]): [RustSdkCryptoJs.VerificationRequest, RustSdkCryptoJs.ToDeviceRequest];
}
}
+3 -1
View File
@@ -95,7 +95,9 @@ export interface RoomTopicEventContent {
export interface RoomAvatarEventContent {
url?: string;
info?: ImageInfo;
// The spec says that an encrypted file can be used for the thumbnail but this isn't true
// https://github.com/matrix-org/matrix-spec/issues/562 so omit those fields
info?: Omit<ImageInfo, "thumbnail_file">;
}
export interface RoomPinnedEventsEventContent {
+2 -2
View File
@@ -59,7 +59,7 @@ export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): stri
* @returns The unpadded base64.
*/
export function encodeUnpaddedBase64Url(uint8Array: ArrayBuffer | Uint8Array): string {
return encodeUnpaddedBase64(uint8Array).replace("+", "-").replace("/", "_");
return encodeUnpaddedBase64(uint8Array).replace(/\+/g, "-").replace(/\//g, "_");
}
/**
@@ -75,7 +75,7 @@ export function decodeBase64(base64: string): Uint8Array {
const itFunc = function* (): Generator<number> {
const decoded = atob(
// built-in atob doesn't support base64url: convert so we support either
base64.replace("-", "+").replace("_", "/"),
base64.replace(/-/g, "+").replace(/_/g, "/"),
);
for (let i = 0; i < decoded.length; ++i) {
yield decoded.charCodeAt(i);
+46 -80
View File
@@ -226,6 +226,7 @@ import { getRelationsThreadFilter } from "./thread-utils";
import { KnownMembership, Membership } from "./@types/membership";
import { RoomMessageEventContent, StickerEventContent } from "./@types/events";
import { ImageInfo } from "./@types/media";
import { Capabilities, ServerCapabilities } from "./serverCapabilities";
export type Store = IStore;
@@ -233,7 +234,6 @@ export type ResetTimelineCallback = (roomId: string) => boolean;
const SCROLLBACK_DELAY_MS = 3000;
export const CRYPTO_ENABLED: boolean = isCryptoAvailable();
const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value
const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes
export const UNSTABLE_MSC3852_LAST_SEEN_UA = new UnstableValue(
@@ -518,26 +518,6 @@ export interface IStartClientOpts {
export interface IStoredClientOpts extends IStartClientOpts {}
export enum RoomVersionStability {
Stable = "stable",
Unstable = "unstable",
}
export interface IRoomVersionsCapability {
default: string;
available: Record<string, RoomVersionStability>;
}
export interface ICapability {
enabled: boolean;
}
export interface IChangePasswordCapability extends ICapability {}
export interface IThreadsCapability extends ICapability {}
export interface IGetLoginTokenCapability extends ICapability {}
export const GET_LOGIN_TOKEN_CAPABILITY = new NamespacedValue(
"m.get_login_token",
"org.matrix.msc3882.get_login_token",
@@ -547,19 +527,6 @@ export const UNSTABLE_MSC2666_SHARED_ROOMS = "uk.half-shot.msc2666";
export const UNSTABLE_MSC2666_MUTUAL_ROOMS = "uk.half-shot.msc2666.mutual_rooms";
export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_mutual_rooms";
/**
* A representation of the capabilities advertised by a homeserver as defined by
* [Capabilities negotiation](https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3capabilities).
*/
export interface Capabilities {
[key: string]: any;
"m.change_password"?: IChangePasswordCapability;
"m.room_versions"?: IRoomVersionsCapability;
"io.element.thread"?: IThreadsCapability;
"m.get_login_token"?: IGetLoginTokenCapability;
"org.matrix.msc3882.get_login_token"?: IGetLoginTokenCapability;
}
enum CrossSigningKeyType {
MasterKey = "master_key",
SelfSigningKey = "self_signing_key",
@@ -1293,10 +1260,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020
protected serverVersionsPromise?: Promise<IServerVersions>;
public cachedCapabilities?: {
capabilities: Capabilities;
expiration: number;
};
protected clientWellKnown?: IClientWellKnown;
protected clientWellKnownPromise?: Promise<IClientWellKnown>;
protected turnServers: ITurnServer[] = [];
@@ -1325,6 +1288,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public readonly matrixRTC: MatrixRTCSessionManager;
private serverCapabilitiesService: ServerCapabilities;
public constructor(opts: IMatrixClientCreateOpts) {
super();
@@ -1418,6 +1383,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// the underlying session management and doesn't use any actual media capabilities
this.matrixRTC = new MatrixRTCSessionManager(this);
this.serverCapabilitiesService = new ServerCapabilities(this.http);
this.on(ClientEvent.Sync, this.fixupRoomNotifications);
this.timelineSupport = Boolean(opts.timelineSupport);
@@ -1540,6 +1507,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
this.toDeviceMessageQueue.start();
this.serverCapabilitiesService.start();
}
/**
@@ -1593,6 +1561,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.toDeviceMessageQueue.stop();
this.matrixRTC.stop();
this.serverCapabilitiesService.stop();
}
/**
@@ -2095,47 +2065,35 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* Gets the capabilities of the homeserver. Always returns an object of
* capability keys and their options, which may be empty.
* @param fresh - True to ignore any cached values.
* @returns Promise which resolves to the capabilities of the homeserver
* @returns Rejects: with an error response.
* Gets the cached capabilities of the homeserver, returning cached ones if available.
* If there are no cached capabilities and none can be fetched, throw an exception.
*
* @returns Promise resolving with The capabilities of the homeserver
*/
public getCapabilities(fresh = false): Promise<Capabilities> {
const now = new Date().getTime();
public async getCapabilities(): Promise<Capabilities> {
const caps = this.serverCapabilitiesService.getCachedCapabilities();
if (caps) return caps;
return this.serverCapabilitiesService.fetchCapabilities();
}
if (this.cachedCapabilities && !fresh) {
if (now < this.cachedCapabilities.expiration) {
this.logger.debug("Returning cached capabilities");
return Promise.resolve(this.cachedCapabilities.capabilities);
}
}
/**
* Gets the cached capabilities of the homeserver. If none have been fetched yet,
* return undefined.
*
* @returns The capabilities of the homeserver
*/
public getCachedCapabilities(): Capabilities | undefined {
return this.serverCapabilitiesService.getCachedCapabilities();
}
type Response = {
capabilities?: Capabilities;
};
return this.http
.authedRequest<Response>(Method.Get, "/capabilities")
.catch((e: Error): Response => {
// We swallow errors because we need a default object anyhow
this.logger.error(e);
return {};
})
.then((r = {}) => {
const capabilities = r["capabilities"] || {};
// If the capabilities missed the cache, cache it for a shorter amount
// of time to try and refresh them later.
const cacheMs = Object.keys(capabilities).length ? CAPABILITIES_CACHE_MS : 60000 + Math.random() * 5000;
this.cachedCapabilities = {
capabilities,
expiration: now + cacheMs,
};
this.logger.debug("Caching capabilities: ", capabilities);
return capabilities;
});
/**
* Fetches the latest capabilities from the homeserver, ignoring any cached
* versions. The newly returned version is cached.
*
* @returns A promise which resolves to the capabilities of the homeserver
*/
public fetchCapabilities(): Promise<Capabilities> {
return this.serverCapabilitiesService.fetchCapabilities();
}
/**
@@ -4319,9 +4277,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
signPromise = this.http.requestOtherUrl<IThirdPartySigned>(Method.Post, url);
}
const queryString: Record<string, string | string[]> = {};
let queryParams: QueryDict = {};
if (opts.viaServers) {
queryString["server_name"] = opts.viaServers;
queryParams.server_name = opts.viaServers;
queryParams.via = opts.viaServers;
if (this.canSupport.get(Feature.MigrateServerNameToVia) === ServerSupport.Unstable) {
queryParams = replaceParam("via", "org.matrix.msc4156.via", queryParams);
}
}
const data: IJoinRequestBody = {};
@@ -4331,7 +4293,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias });
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryParams, data);
const roomId = res.room_id;
// In case we were originally given an alias, check the room cache again
@@ -4364,9 +4326,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const path = utils.encodeUri("/knock/$roomIdOrAlias", { $roomIdOrAlias: roomIdOrAlias });
const queryParams: Record<string, string | string[]> = {};
let queryParams: QueryDict = {};
if (opts.viaServers) {
queryParams.server_name = opts.viaServers;
queryParams.via = opts.viaServers;
if (this.canSupport.get(Feature.MigrateServerNameToVia) === ServerSupport.Unstable) {
queryParams = replaceParam("via", "org.matrix.msc4156.via", queryParams);
}
}
const body: Record<string, string> = {};
+3 -5
View File
@@ -62,7 +62,7 @@ export function getHttpUriForMxc(
if (useAuthentication) {
allowRedirects = true; // per docs (MSC3916 always expects redirects)
// Dev note: MSC3916 (as of writing) removes `allow_redirect` entirely, but
// Dev note: MSC3916 removes `allow_redirect` entirely, but
// for explicitness we set it here. This makes it slightly more obvious to
// callers, hopefully.
}
@@ -70,8 +70,7 @@ export function getHttpUriForMxc(
let serverAndMediaId = mxc.slice(6); // strips mxc://
let prefix: string;
if (useAuthentication) {
// TODO: Use stable once available (requires FCP on MSC3916).
prefix = "/_matrix/client/unstable/org.matrix.msc3916/media/download/";
prefix = "/_matrix/client/v1/media/download/";
} else {
prefix = "/_matrix/media/v3/download/";
}
@@ -90,8 +89,7 @@ export function getHttpUriForMxc(
// these are thumbnailing params so they probably want the
// thumbnailing API...
if (useAuthentication) {
// TODO: Use stable once available (requires FCP on MSC3916).
prefix = "/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail/";
prefix = "/_matrix/client/v1/media/thumbnail/";
} else {
prefix = "/_matrix/media/v3/thumbnail/";
}
+4
View File
@@ -33,6 +33,7 @@ export enum Feature {
AccountDataDeletion = "AccountDataDeletion",
RelationsRecursion = "RelationsRecursion",
IntentionalMentions = "IntentionalMentions",
MigrateServerNameToVia = "MigrateServerNameToVia",
}
type FeatureSupportCondition = {
@@ -65,6 +66,9 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
unstablePrefixes: ["org.matrix.msc3952_intentional_mentions"],
matrixVersion: "v1.7",
},
[Feature.MigrateServerNameToVia]: {
unstablePrefixes: ["org.matrix.msc4156"],
},
};
export async function buildFeatureSupportMap(versions: IServerVersions): Promise<Map<Feature, ServerSupport>> {
+1
View File
@@ -24,6 +24,7 @@ import { RoomWidgetClient, ICapabilities } from "./embedded";
import { CryptoStore } from "./crypto/store/base";
export * from "./client";
export * from "./serverCapabilities";
export * from "./embedded";
export * from "./http-api";
export * from "./autodiscovery";
+136 -47
View File
@@ -14,52 +14,114 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EitherAnd } from "matrix-events-sdk/lib/types";
import { MatrixEvent } from "../matrix";
import { deepCompare } from "../utils";
import { Focus } from "./focus";
import { isLivekitFocusActive } from "./LivekitFocus";
type CallScope = "m.room" | "m.user";
// Represents an entry in the memberships section of an m.call.member event as it is on the wire
export interface CallMembershipData {
application?: string;
// There are two different data interfaces. One for the Legacy types and one compliant with MSC4143
// MSC4143 (MatrixRTC) session membership data
export type SessionMembershipData = {
application: string;
call_id: string;
device_id: string;
focus_active: Focus;
foci_preferred: Focus[];
created_ts?: number;
// Application specific data
scope?: CallScope;
};
export const isSessionMembershipData = (data: CallMembershipData): data is SessionMembershipData =>
"focus_active" in data;
const checkSessionsMembershipData = (data: any, errors: string[]): data is SessionMembershipData => {
const prefix = "Malformed session membership event: ";
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
if (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array");
// optional parameters
if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number");
// application specific data (we first need to check if they exist)
if (data.scope && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
return errors.length === 0;
};
// Legacy session membership data
export type CallMembershipDataLegacy = {
application: string;
call_id: string;
scope: CallScope;
device_id: string;
created_ts?: number;
expires?: number;
expires_ts?: number;
foci_active?: Focus[];
membershipID: string;
}
created_ts?: number;
foci_active?: Focus[];
} & EitherAnd<{ expires: number }, { expires_ts: number }>;
export const isLegacyCallMembershipData = (data: CallMembershipData): data is CallMembershipDataLegacy =>
"membershipID" in data;
const checkCallMembershipDataLegacy = (data: any, errors: string[]): data is CallMembershipDataLegacy => {
const prefix = "Malformed legacy rtc membership event: ";
if (!("expires" in data || "expires_ts" in data)) {
errors.push(prefix + "expires_ts or expires must be present");
}
if ("expires" in data) {
if (typeof data.expires !== "number") {
errors.push(prefix + "expires must be numeric");
}
}
if ("expires_ts" in data) {
if (typeof data.expires_ts !== "number") {
errors.push(prefix + "expires_ts must be numeric");
}
}
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
if (typeof data.membershipID !== "string") errors.push(prefix + "membershipID must be a string");
// optional elements
if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number");
// application specific data (we first need to check if they exist)
if (data.scope && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
return errors.length === 0;
};
export type CallMembershipData = CallMembershipDataLegacy | SessionMembershipData;
export class CallMembership {
public static equal(a: CallMembership, b: CallMembership): boolean {
return deepCompare(a.data, b.data);
return deepCompare(a.membershipData, b.membershipData);
}
private membershipData: CallMembershipData;
public constructor(
private parentEvent: MatrixEvent,
private data: CallMembershipData,
data: any,
) {
if (!(data.expires || data.expires_ts)) {
throw new Error("Malformed membership: expires_ts or expires must be present");
const sessionErrors: string[] = [];
const legacyErrors: string[] = [];
if (!checkSessionsMembershipData(data, sessionErrors) && !checkCallMembershipDataLegacy(data, legacyErrors)) {
throw Error(
`unknown CallMembership data. Does not match legacy call.member (${legacyErrors.join(" & ")}) events nor MSC4143 (${sessionErrors.join(" & ")})`,
);
} else {
this.membershipData = data;
}
if (data.expires) {
if (typeof data.expires !== "number") {
throw new Error("Malformed membership: expires must be numeric");
}
}
if (data.expires_ts) {
if (typeof data.expires_ts !== "number") {
throw new Error("Malformed membership: expires_ts must be numeric");
}
}
if (typeof data.device_id !== "string") throw new Error("Malformed membership event: device_id must be string");
if (typeof data.call_id !== "string") throw new Error("Malformed membership event: call_id must be string");
if (typeof data.scope !== "string") throw new Error("Malformed membership event: scope must be string");
if (!parentEvent.getSender()) throw new Error("Invalid parent event: sender is null");
}
public get sender(): string | undefined {
@@ -67,62 +129,89 @@ export class CallMembership {
}
public get callId(): string {
return this.data.call_id;
return this.membershipData.call_id;
}
public get deviceId(): string {
return this.data.device_id;
return this.membershipData.device_id;
}
public get application(): string | undefined {
return this.data.application;
return this.membershipData.application;
}
public get scope(): CallScope {
return this.data.scope;
public get scope(): CallScope | undefined {
return this.membershipData.scope;
}
public get membershipID(): string {
return this.data.membershipID;
if (isLegacyCallMembershipData(this.membershipData)) return this.membershipData.membershipID;
// the createdTs behaves equivalent to the membershipID.
// we only need the field for the legacy member envents where we needed to update them
// synapse ignores sending state events if they have the same content.
else return this.createdTs().toString();
}
public createdTs(): number {
return this.data.created_ts ?? this.parentEvent.getTs();
return this.membershipData.created_ts ?? this.parentEvent.getTs();
}
public getAbsoluteExpiry(): number {
if (this.data.expires) {
return this.createdTs() + this.data.expires;
public getAbsoluteExpiry(): number | undefined {
if (!isLegacyCallMembershipData(this.membershipData)) return undefined;
if ("expires" in this.membershipData) {
// we know createdTs exists since we already do the isLegacyCallMembershipData check
return this.createdTs() + this.membershipData.expires;
} else {
// We know it exists because we checked for this in the constructor.
return this.data.expires_ts!;
return this.membershipData.expires_ts;
}
}
// gets the expiry time of the event, converted into the device's local time
public getLocalExpiry(): number {
if (this.data.expires) {
public getLocalExpiry(): number | undefined {
if (!isLegacyCallMembershipData(this.membershipData)) return undefined;
if ("expires" in this.membershipData) {
// we know createdTs exists since we already do the isLegacyCallMembershipData check
const relativeCreationTime = this.parentEvent.getTs() - this.createdTs();
const localCreationTs = this.parentEvent.localTimestamp - relativeCreationTime;
return localCreationTs + this.data.expires;
return localCreationTs + this.membershipData.expires;
} else {
// With expires_ts we cannot convert to local time.
// TODO: Check the server timestamp and compute a diff to local time.
return this.data.expires_ts!;
return this.membershipData.expires_ts;
}
}
public getMsUntilExpiry(): number {
return this.getLocalExpiry() - Date.now();
public getMsUntilExpiry(): number | undefined {
if (isLegacyCallMembershipData(this.membershipData)) return this.getLocalExpiry()! - Date.now();
}
public isExpired(): boolean {
return this.getMsUntilExpiry() <= 0;
if (isLegacyCallMembershipData(this.membershipData)) return this.getMsUntilExpiry()! <= 0;
// MSC4143 events expire by being updated. So if the event exists, its not expired.
return false;
}
public getActiveFoci(): Focus[] {
return this.data.foci_active ?? [];
public getPreferredFoci(): Focus[] {
// To support both, the new and the old MatrixRTC memberships have two cases based
// on the availablitiy of `foci_preferred`
if (isLegacyCallMembershipData(this.membershipData)) return this.membershipData.foci_active ?? [];
// MSC4143 style membership
return this.membershipData.foci_preferred;
}
public getFocusSelection(): string | undefined {
if (isLegacyCallMembershipData(this.membershipData)) {
return "oldest_membership";
} else {
const focusActive = this.membershipData.focus_active;
if (isLivekitFocusActive(focusActive)) {
return focusActive.focus_selection;
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
/*
Copyright 2023 New Vector Ltd
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 { Focus } from "./focus";
export interface LivekitFocusConfig extends Focus {
type: "livekit";
livekit_service_url: string;
}
export const isLivekitFocusConfig = (object: any): object is LivekitFocusConfig =>
object.type === "livekit" && "livekit_service_url" in object;
export interface LivekitFocus extends LivekitFocusConfig {
livekit_alias: string;
}
export const isLivekitFocus = (object: any): object is LivekitFocus =>
isLivekitFocusConfig(object) && "livekit_alias" in object;
export interface LivekitFocusActive extends Focus {
type: "livekit";
focus_selection: "oldest_membership";
}
export const isLivekitFocusActive = (object: any): object is LivekitFocusActive =>
object.type === "livekit" && "focus_selection" in object;
+202 -86
View File
@@ -20,7 +20,13 @@ import { EventTimeline } from "../models/event-timeline";
import { Room } from "../models/room";
import { MatrixClient } from "../client";
import { EventType } from "../@types/event";
import { CallMembership, CallMembershipData } from "./CallMembership";
import {
CallMembership,
CallMembershipData,
CallMembershipDataLegacy,
SessionMembershipData,
isLegacyCallMembershipData,
} from "./CallMembership";
import { RoomStateEvent } from "../models/room-state";
import { Focus } from "./focus";
import { randomString, secureRandomBase64Url } from "../randomstring";
@@ -29,6 +35,8 @@ import { decodeBase64, encodeUnpaddedBase64 } from "../base64";
import { KnownMembership } from "../@types/membership";
import { MatrixError } from "../http-api/errors";
import { MatrixEvent } from "../models/event";
import { isLivekitFocusActive } from "./LivekitFocus";
import { ExperimentalGroupCallRoomMemberState } from "../webrtc/groupCall";
const MEMBERSHIP_EXPIRY_TIME = 60 * 60 * 1000;
const MEMBER_EVENT_CHECK_PERIOD = 2 * 60 * 1000; // How often we check to see if we need to re-send our member event
@@ -57,7 +65,7 @@ export enum MatrixRTCSessionEvent {
MembershipsChanged = "memberships_changed",
// We joined or left the session: our own local idea of whether we are joined,
// separate from MembershipsChanged, ie. independent of whether our member event
// has succesfully gone through.
// has successfully gone through.
JoinStateChanged = "join_state_changed",
// The key used to encrypt media has changed
EncryptionKeyChanged = "encryption_key_changed",
@@ -75,7 +83,20 @@ export type MatrixRTCSessionEventHandlerMap = {
participantId: string,
) => void;
};
export interface JoinSessionConfig {
/** If true, generate and share a media key for this participant,
* and emit MatrixRTCSessionEvent.EncryptionKeyChanged when
* media keys for other participants become available.
*/
manageMediaKeys?: boolean;
/** Lets you configure how the events for the session are formatted.
* - legacy: use one event with a membership array.
* - MSC4143: use one event per membership (with only one membership per event)
* More details can be found in MSC4143 and by checking the types:
* `CallMembershipDataLegacy` and `SessionMembershipData`
*/
useLegacyMemberEvents?: boolean;
}
/**
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
* This class doesn't deal with media at all, just membership & properties of a session.
@@ -102,12 +123,16 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
private makeNewKeyTimeout?: ReturnType<typeof setTimeout>;
private setNewKeyTimeouts = new Set<ReturnType<typeof setTimeout>>();
private activeFoci: Focus[] | undefined;
// This is a Focus with the specified fields for an ActiveFocus (e.g. LivekitFocusActive for type="livekit")
private ownFocusActive?: Focus;
// This is a Foci array that contains the Focus objects this user is aware of and proposes to use.
private ownFociPreferred?: Focus[];
private updateCallMembershipRunning = false;
private needCallMembershipUpdate = false;
private manageMediaKeys = false;
private useLegacyMemberEvents = true;
// userId:deviceId => array of keys
private encryptionKeys = new Map<string, Array<Uint8Array>>();
private lastEncryptionKeyUpdateRequest?: number;
@@ -134,21 +159,33 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
const callMemberships: CallMembership[] = [];
for (const memberEvent of callMemberEvents) {
const eventMemberships: CallMembershipData[] = memberEvent.getContent()["memberships"];
if (eventMemberships === undefined) {
continue;
const content = memberEvent.getContent();
let membershipContents: any[] = [];
// We first decide if its a MSC4143 event (per device state key)
if ("memberships" in content) {
// we have a legacy (one event for all devices) event
if (!Array.isArray(content["memberships"])) {
logger.warn(`Malformed member event from ${memberEvent.getSender()}: memberships is not an array`);
continue;
}
membershipContents = content["memberships"];
} else {
// We have a MSC4143 event membership event
if (Object.keys(content).length !== 0) {
// We checked for empty content to not try to construct CallMembership's with {}.
membershipContents.push(content);
}
}
if (!Array.isArray(eventMemberships)) {
logger.warn(`Malformed member event from ${memberEvent.getSender()}: memberships is not an array`);
if (membershipContents.length === 0) {
continue;
}
for (const membershipData of eventMemberships) {
for (const membershipData of membershipContents) {
try {
const membership = new CallMembership(memberEvent, membershipData);
if (membership.callId !== "" || membership.scope !== "m.room") {
// for now, just ignore anything that isn't the a room scope call
// for now, just ignore anything that isn't a room scope call
logger.info(`Ignoring user-scoped call`);
continue;
}
@@ -202,6 +239,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
/*
* Returns true if we intend to be participating in the MatrixRTC session.
* This is determined by checking if the relativeExpiry has been set.
*/
public isJoined(): boolean {
return this.relativeExpiry !== undefined;
@@ -232,30 +270,34 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
* desired.
* This method will return immediately and the session will be joined in the background.
*
* @param activeFoci - The list of foci to set as currently active in the call member event
* @param manageMediaKeys - If true, generate and share a a media key for this participant,
* and emit MatrixRTCSessionEvent.EncryptionKeyChanged when
* media keys for other participants become available.
* @param fociActive - The object representing the active focus. (This depends on the focus type.)
* @param fociPreferred - The list of preferred foci this member proposes to use/knows/has access to.
* For the livekit case this is a list of foci generated from the homeserver well-known, the current rtc session,
* or optionally other room members homeserver well known.
* @param joinConfig - Additional configuration for the joined session.
*/
public joinRoomSession(activeFoci: Focus[], manageMediaKeys?: boolean): void {
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
if (this.isJoined()) {
logger.info(`Already joined to session in room ${this.room.roomId}: ignoring join call`);
return;
}
logger.info(`Joining call session in room ${this.room.roomId} with manageMediaKeys=${manageMediaKeys}`);
this.activeFoci = activeFoci;
this.ownFocusActive = fociActive;
this.ownFociPreferred = fociPreferred;
this.relativeExpiry = MEMBERSHIP_EXPIRY_TIME;
this.manageMediaKeys = manageMediaKeys ?? false;
this.manageMediaKeys = joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
this.useLegacyMemberEvents = joinConfig?.useLegacyMemberEvents ?? this.useLegacyMemberEvents;
this.membershipId = randomString(5);
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
if (manageMediaKeys) {
logger.info(`Joining call session in room ${this.room.roomId} with manageMediaKeys=${this.manageMediaKeys}`);
if (joinConfig?.manageMediaKeys) {
this.makeNewSenderKey();
this.requestKeyEventSend();
}
// We don't wait for this, mostly because it may fail and schedule a retry, so this
// function returning doesn't really mean anything at all.
this.triggerCallMembershipEventUpdate();
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
}
/**
@@ -295,7 +337,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
logger.info(`Leaving call session in room ${this.room.roomId}`);
this.relativeExpiry = undefined;
this.activeFoci = undefined;
this.ownFocusActive = undefined;
this.manageMediaKeys = false;
this.membershipId = undefined;
this.emit(MatrixRTCSessionEvent.JoinStateChanged, false);
@@ -315,6 +357,21 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
});
}
public getActiveFocus(): Focus | undefined {
if (this.ownFocusActive && isLivekitFocusActive(this.ownFocusActive)) {
// A livekit active focus
if (this.ownFocusActive.focus_selection === "oldest_membership") {
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
}
if (!this.ownFocusActive) {
// we use the legacy call.member events so default to oldest member
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
}
public getKeysForParticipant(userId: string, deviceId: string): Array<Uint8Array> | undefined {
return this.encryptionKeys.get(getParticipantId(userId, deviceId));
}
@@ -344,7 +401,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
* @param userId - The user ID of the participant
* @param deviceId - Device ID of the participant
* @param encryptionKeyIndex - The index of the key to set
* @param encryptionKeyString - The string represenation of the key to set in base64
* @param encryptionKeyString - The string representation of the key to set in base64
* @param delayBeforeuse - If true, delay before emitting a key changed event. Useful when setting
* encryption keys for the local participant to allow time for the key to
* be distributed.
@@ -379,7 +436,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
/**
* Generate a new sender key and add it at the next available index
* @param delayBeforeUse - If true, wait for a short period before settign the key for the
* @param delayBeforeUse - If true, wait for a short period before setting the key for the
* media encryptor to use. If false, set the key immediately.
*/
private makeNewSenderKey(delayBeforeUse = false): void {
@@ -488,7 +545,9 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
let soonestExpiry;
for (const membership of this.memberships) {
const thisExpiry = membership.getMsUntilExpiry();
if (soonestExpiry === undefined || thisExpiry < soonestExpiry) {
// If getMsUntilExpiry is undefined we have a MSC4143 (MatrixRTC) compliant event - it never expires
// but will be reliably resent on disconnect.
if (thisExpiry !== undefined && (soonestExpiry === undefined || thisExpiry < soonestExpiry)) {
soonestExpiry = thisExpiry;
}
}
@@ -502,6 +561,13 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
return this.memberships[0];
}
public getFocusInUse(): Focus | undefined {
const oldestMembership = this.getOldestMembership();
if (oldestMembership?.getFocusSelection() === "oldest_membership") {
return oldestMembership.getPreferredFoci()[0];
}
}
public onCallEncryption = (event: MatrixEvent): void => {
const userId = event.getSender();
const content = event.getContent<EncryptionKeysEventContent>();
@@ -613,30 +679,41 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
* Constructs our own membership
* @param prevMembership - The previous value of our call membership, if any
*/
private makeMyMembership(prevMembership?: CallMembership): CallMembershipData {
private makeMyMembershipLegacy(deviceId: string, prevMembership?: CallMembership): CallMembershipDataLegacy {
if (this.relativeExpiry === undefined) {
throw new Error("Tried to create our own membership event when we're not joined!");
}
if (this.membershipId === undefined) {
throw new Error("Tried to create our own membership event when we have no membership ID!");
}
const m: CallMembershipData = {
const createdTs = prevMembership?.createdTs();
return {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: this.client.getDeviceId()!,
device_id: deviceId,
expires: this.relativeExpiry,
foci_active: this.activeFoci,
// TODO: Date.now() should be the origin_server_ts (now).
expires_ts: this.relativeExpiry + (createdTs ?? Date.now()),
// we use the fociPreferred since this is the list of foci.
// it is named wrong in the Legacy events.
foci_active: this.ownFociPreferred,
membershipID: this.membershipId,
...(createdTs ? { created_ts: createdTs } : {}),
};
}
/**
* Constructs our own membership
*/
private makeMyMembership(deviceId: string): SessionMembershipData {
return {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: deviceId,
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
foci_preferred: this.ownFociPreferred ?? [],
};
if (prevMembership) m.created_ts = prevMembership.createdTs();
if (m.created_ts) m.expires_ts = m.created_ts + (m.expires ?? 0);
// TODO: Date.now() should be the origin_server_ts (now).
else m.expires_ts = Date.now() + (m.expires ?? 0);
return m;
}
/**
@@ -646,36 +723,41 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
myPrevMembershipData?: CallMembershipData,
myPrevMembership?: CallMembership,
): boolean {
// work out if we need to update our membership event
let needsUpdate = false;
if (myPrevMembership && myPrevMembership.getMsUntilExpiry() === undefined) return false;
// Need to update if there's a membership for us but we're not joined (valid or otherwise)
if (!this.isJoined() && myPrevMembershipData) needsUpdate = true;
if (this.isJoined()) {
// ...or if we are joined, but there's no valid membership event
if (!myPrevMembership) {
needsUpdate = true;
} else if (myPrevMembership.getMsUntilExpiry() < MEMBERSHIP_EXPIRY_TIME / 2) {
// ...or if the expiry time needs bumping
needsUpdate = true;
this.relativeExpiry! += MEMBERSHIP_EXPIRY_TIME;
}
if (!this.isJoined()) return !!myPrevMembershipData;
// ...or if we are joined, but there's no valid membership event
if (!myPrevMembership) return true;
const expiryTime = myPrevMembership.getMsUntilExpiry();
if (expiryTime !== undefined && expiryTime < MEMBERSHIP_EXPIRY_TIME / 2) {
// ...or if the expiry time needs bumping
this.relativeExpiry! += MEMBERSHIP_EXPIRY_TIME;
return true;
}
return needsUpdate;
return false;
}
private makeNewMembership(deviceId: string): SessionMembershipData | {} {
// If we're joined, add our own
if (this.isJoined()) {
return this.makeMyMembership(deviceId);
}
return {};
}
/**
* Makes a new membership list given the old list alonng with this user's previous membership event
* (if any) and this device's previous membership (if any)
*/
private makeNewMemberships(
private makeNewLegacyMemberships(
oldMemberships: CallMembershipData[],
localDeviceId: string,
myCallMemberEvent?: MatrixEvent,
myPrevMembership?: CallMembership,
): CallMembershipData[] {
const localDeviceId = this.client.getDeviceId();
if (!localDeviceId) throw new Error("Local device ID is null!");
): ExperimentalGroupCallRoomMemberState {
const filterExpired = (m: CallMembershipData): boolean => {
let membershipObj;
try {
@@ -704,10 +786,10 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
// If we're joined, add our own
if (this.isJoined()) {
newMemberships.push(this.makeMyMembership(myPrevMembership));
newMemberships.push(this.makeMyMembershipLegacy(localDeviceId, myPrevMembership));
}
return newMemberships;
return { memberships: newMemberships };
}
private triggerCallMembershipEventUpdate = async (): Promise<void> => {
@@ -741,47 +823,58 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
const localDeviceId = this.client.getDeviceId();
if (!localUserId || !localDeviceId) throw new Error("User ID or device ID was null!");
const myCallMemberEvent = roomState.getStateEvents(EventType.GroupCallMemberPrefix, localUserId) ?? undefined;
const content = myCallMemberEvent?.getContent<Record<any, unknown>>() ?? {};
const memberships: CallMembershipData[] = Array.isArray(content["memberships"]) ? content["memberships"] : [];
const myPrevMembershipData = memberships.find((m) => m.device_id === localDeviceId);
let myPrevMembership;
try {
if (myCallMemberEvent && myPrevMembershipData && myPrevMembershipData.membershipID === this.membershipId) {
myPrevMembership = new CallMembership(myCallMemberEvent, myPrevMembershipData);
const callMemberEvents = roomState.events.get(EventType.GroupCallMemberPrefix);
const legacy =
!!this.useLegacyMemberEvents ||
(callMemberEvents?.size && this.stateEventsContainOngoingLegacySession(callMemberEvents));
let newContent: {} | ExperimentalGroupCallRoomMemberState | SessionMembershipData = {};
if (legacy) {
const myCallMemberEvent = callMemberEvents?.get(localUserId);
const content = myCallMemberEvent?.getContent() ?? {};
let myPrevMembership: CallMembership | undefined;
// We know its CallMembershipDataLegacy
const memberships: CallMembershipDataLegacy[] = Array.isArray(content["memberships"])
? content["memberships"]
: [];
const myPrevMembershipData = memberships.find((m) => m.device_id === localDeviceId);
try {
if (
myCallMemberEvent &&
myPrevMembershipData &&
isLegacyCallMembershipData(myPrevMembershipData) &&
myPrevMembershipData.membershipID === this.membershipId
) {
myPrevMembership = new CallMembership(myCallMemberEvent, myPrevMembershipData);
}
} catch (e) {
// This would indicate a bug or something weird if our own call membership
// wasn't valid
logger.warn("Our previous call membership was invalid - this shouldn't happen.", e);
}
} catch (e) {
// This would indicate a bug or something weird if our own call membership
// wasn't valid
logger.warn("Our previous call membership was invalid - this shouldn't happen.", e);
if (myPrevMembership) {
logger.debug(`${myPrevMembership.getMsUntilExpiry()} until our membership expires`);
}
if (!this.membershipEventNeedsUpdate(myPrevMembershipData, myPrevMembership)) {
// nothing to do - reschedule the check again
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
return;
}
newContent = this.makeNewLegacyMemberships(memberships, localDeviceId, myCallMemberEvent, myPrevMembership);
} else {
newContent = this.makeNewMembership(localDeviceId);
}
if (myPrevMembership) {
logger.debug(`${myPrevMembership.getMsUntilExpiry()} until our membership expires`);
}
if (!this.membershipEventNeedsUpdate(myPrevMembershipData, myPrevMembership)) {
// nothing to do - reschedule the check again
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
return;
}
const newContent = {
memberships: this.makeNewMemberships(memberships, myCallMemberEvent, myPrevMembership),
};
try {
await this.client.sendStateEvent(
this.room.roomId,
EventType.GroupCallMemberPrefix,
newContent,
localUserId,
legacy ? localUserId : this.makeMembershipStateKey(localUserId, localDeviceId),
);
logger.info(`Sent updated call member event.`);
// check periodically to see if we need to refresh our member event
if (this.isJoined()) {
if (this.isJoined() && legacy) {
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
}
} catch (e) {
@@ -792,6 +885,29 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
}
}
private stateEventsContainOngoingLegacySession(callMemberEvents: Map<string, MatrixEvent>): boolean {
for (const callMemberEvent of callMemberEvents.values()) {
const content = callMemberEvent.getContent();
if (Array.isArray(content["memberships"])) {
for (const membership of content.memberships) {
if (!new CallMembership(callMemberEvent, membership).isExpired()) {
return true;
}
}
}
}
return false;
}
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
const stateKey = `${localUserId}_${localDeviceId}`;
if (/^org\.matrix\.msc3779\b/.exec(this.room.getVersion())) {
return stateKey;
} else {
return `_${stateKey}`;
}
}
private onRotateKeyTimeout = (): void => {
if (!this.manageMediaKeys) return;
+3 -3
View File
@@ -73,9 +73,9 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
}
this.roomSessions.clear();
this.client.removeListener(ClientEvent.Room, this.onRoom);
this.client.removeListener(RoomEvent.Timeline, this.onTimeline);
this.client.removeListener(RoomStateEvent.Events, this.onRoomState);
this.client.off(ClientEvent.Room, this.onRoom);
this.client.off(RoomEvent.Timeline, this.onTimeline);
this.client.off(RoomStateEvent.Events, this.onRoomState);
}
/**
+1
View File
@@ -21,4 +21,5 @@ limitations under the License.
*/
export interface Focus {
type: string;
[key: string]: unknown;
}
+17 -5
View File
@@ -41,7 +41,7 @@ import {
RelationType,
UNSIGNED_THREAD_ID_FIELD,
} from "../@types/event";
import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client";
import { MatrixClient, PendingEventOrdering } from "../client";
import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
import { Filter, IFilterDefinition } from "../filter";
import { RoomState, RoomStateEvent, RoomStateEventHandlerMap } from "./room-state";
@@ -70,6 +70,7 @@ import { RoomReceipts } from "./room-receipts";
import { compareEventOrdering } from "./compare-event-ordering";
import * as utils from "../utils";
import { KnownMembership, Membership } from "../@types/membership";
import { Capabilities, IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities";
// These constants are used as sane defaults when the homeserver doesn't support
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@@ -397,7 +398,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* Use getLiveTimeline().getState(EventTimeline.FORWARDS) instead.
*/
public currentState!: RoomState;
public readonly relations = new RelationsContainer(this.client, this);
public readonly relations;
/**
* A collection of events known by the client
@@ -459,6 +461,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
private readonly opts: IOpts = {},
) {
super();
// In some cases, we add listeners for every displayed Matrix event, so it's
// common to have quite a few more than the default limit.
this.setMaxListeners(100);
@@ -469,6 +472,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
this.name = roomId;
this.normalizedName = roomId;
this.relations = new RelationsContainer(this.client, this);
// Listen to our own receipt event as a more modular way of processing our own
// receipts. No need to remove the listener: it's on ourself anyway.
this.on(RoomEvent.Receipt, this.onReceipt);
@@ -611,7 +616,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* Resolves to the version the room should be upgraded to.
*/
public async getRecommendedVersion(): Promise<IRecommendedVersion> {
const capabilities = await this.client.getCapabilities();
let capabilities: Capabilities = {};
try {
capabilities = await this.client.getCapabilities();
} catch (e) {}
let versionCap = capabilities["m.room_versions"];
if (!versionCap) {
versionCap = {
@@ -636,8 +644,12 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
"to be supporting a newer room version we don't know about.",
);
const caps = await this.client.getCapabilities(true);
versionCap = caps["m.room_versions"];
try {
capabilities = await this.client.fetchCapabilities();
} catch (e) {
logger.warn("Failed to refresh room version capabilities", e);
}
versionCap = capabilities["m.room_versions"];
if (!versionCap) {
logger.warn("No room version capability - assuming upgrade required.");
return result;
+6 -3
View File
@@ -22,12 +22,12 @@ import {
LegacyRendezvousFailureReason as RendezvousFailureReason,
RendezvousIntent,
} from ".";
import { IGetLoginTokenCapability, MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "../client";
import { MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "../client";
import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
import { logger } from "../logger";
import { sleep } from "../utils";
import { CrossSigningKey } from "../crypto-api";
import { Device } from "../matrix";
import { Capabilities, Device, IGetLoginTokenCapability } from "../matrix";
enum PayloadType {
Start = "m.login.start",
@@ -109,7 +109,10 @@ export class MSC3906Rendezvous {
logger.info(`Connected to secure channel with checksum: ${checksum} our intent is ${this.ourIntent}`);
// in stable and unstable r1 the availability is exposed as a capability
const capabilities = await this.client.getCapabilities();
let capabilities: Capabilities = {};
try {
capabilities = await this.client.getCapabilities();
} catch (e) {}
// in r0 of MSC3882 the availability is exposed as a feature flag
const features = await buildFeatureSupportMap(await this.client.getVersions());
const capability = GET_LOGIN_TOKEN_CAPABILITY.findIn<IGetLoginTokenCapability>(capabilities);
+4 -4
View File
@@ -145,7 +145,7 @@ export class MSC4108SignInWithQR {
}
if (this.ourIntent === QrCodeMode.Reciprocate && this.client) {
this._code = await this.channel.generateCode(this.ourIntent, this.client.getHomeserverUrl());
this._code = await this.channel.generateCode(this.ourIntent, this.client.getDomain()!);
} else if (this.ourIntent === QrCodeMode.Login) {
this._code = await this.channel.generateCode(this.ourIntent);
}
@@ -171,7 +171,7 @@ export class MSC4108SignInWithQR {
* The scanning device has to discover the homeserver details, if they scanned the code then they already have it.
* If the new device is the one rendering the QR code then it has to wait be sent the homeserver details via the rendezvous channel.
*/
public async negotiateProtocols(): Promise<{ homeserverBaseUrl?: string }> {
public async negotiateProtocols(): Promise<{ serverName?: string }> {
logger.info(`negotiateProtocols(isNewDevice=${this.isNewDevice} didScanCode=${this.didScanCode})`);
await this.channel.connect();
@@ -194,7 +194,7 @@ export class MSC4108SignInWithQR {
await this.send<ProtocolsPayload>({
type: PayloadType.Protocols,
protocols: ["device_authorization_grant"],
homeserver: this.client?.getHomeserverUrl() ?? "",
homeserver: this.client!.getDomain()!,
});
} else {
await this.send<FailurePayload>({
@@ -227,7 +227,7 @@ export class MSC4108SignInWithQR {
);
}
return { homeserverBaseUrl: payload.homeserver };
return { serverName: payload.homeserver };
} else {
// MSC4108-Flow: NewScanned - nothing to do
}
@@ -54,11 +54,11 @@ export class MSC4108SecureChannel {
/**
* Generate a QR code for the current session.
* @param mode the mode to generate the QR code in, either `Login` or `Reciprocate`.
* @param homeserverBaseUrl the base URL of the homeserver to connect to, required for `Reciprocate` mode.
* @param serverName the name of the homeserver to connect to, as defined by server discovery in the spec, required for `Reciprocate` mode.
*/
public async generateCode(mode: QrCodeMode.Login): Promise<Uint8Array>;
public async generateCode(mode: QrCodeMode.Reciprocate, homeserverBaseUrl: string): Promise<Uint8Array>;
public async generateCode(mode: QrCodeMode, homeserverBaseUrl?: string): Promise<Uint8Array> {
public async generateCode(mode: QrCodeMode.Reciprocate, serverName: string): Promise<Uint8Array>;
public async generateCode(mode: QrCodeMode, serverName?: string): Promise<Uint8Array> {
const { url } = this.rendezvousSession;
if (!url) {
@@ -68,8 +68,8 @@ export class MSC4108SecureChannel {
return new QrCodeData(
this.secureChannel.public_key(),
url,
mode === QrCodeMode.Reciprocate ? homeserverBaseUrl : undefined,
).to_bytes();
mode === QrCodeMode.Reciprocate ? serverName : undefined,
).toBytes();
}
/**
+3 -4
View File
@@ -1100,10 +1100,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
}
try {
const [request, outgoingRequest]: [RustSdkCryptoJs.VerificationRequest, RustSdkCryptoJs.ToDeviceRequest] =
await device.requestVerification(
this._supportedVerificationMethods.map(verificationMethodIdentifierToMethod),
);
const [request, outgoingRequest] = device.requestVerification(
this._supportedVerificationMethods.map(verificationMethodIdentifierToMethod),
);
await this.outgoingRequestProcessor.makeOutgoingRequest(outgoingRequest);
return new RustVerificationRequest(
this.olmMachine,
+41 -25
View File
@@ -72,30 +72,40 @@ export class RustVerificationRequest
private readonly supportedVerificationMethods: string[],
) {
super();
this.reEmitter = new TypedReEmitter(this);
const onChange = async (): Promise<void> => {
const verification: RustSdkCryptoJs.Qr | RustSdkCryptoJs.Sas | undefined = this.inner.getVerification();
// Obviously, the Rust object maintains a reference to the callback function. If the callback function maintains
// a reference to the Rust object, then we have a reference cycle which means that `RustVerificationRequest`
// will never be garbage-collected, and hence the underlying rust object will never be freed.
//
// To avoid this reference cycle, use a weak reference in the callback function. If the `RustVerificationRequest`
// gets garbage-collected, then there is nothing to update!
const weakThis = new WeakRef(this);
inner.registerChangesCallback(async () => weakThis.deref()?.onChange());
}
// Set the _verifier object (wrapping the rust `Verification` as a js-sdk Verifier) if:
// - we now have a `Verification` where we lacked one before
// - we have transitioned from QR to SAS
// - we are verifying with SAS, but we need to replace our verifier with a new one because both parties
// tried to start verification at the same time, and we lost the tie breaking
if (verification instanceof RustSdkCryptoJs.Sas) {
if (this._verifier === undefined || this._verifier instanceof RustQrCodeVerifier) {
this.setVerifier(new RustSASVerifier(verification, this, outgoingRequestProcessor));
} else if (this._verifier instanceof RustSASVerifier) {
this._verifier.replaceInner(verification);
}
} else if (verification instanceof RustSdkCryptoJs.Qr && this._verifier === undefined) {
this.setVerifier(new RustQrCodeVerifier(verification, outgoingRequestProcessor));
/**
* Hook which is called when the underlying rust class notifies us that there has been a change.
*/
private onChange(): void {
const verification: RustSdkCryptoJs.Qr | RustSdkCryptoJs.Sas | undefined = this.inner.getVerification();
// Set the _verifier object (wrapping the rust `Verification` as a js-sdk Verifier) if:
// - we now have a `Verification` where we lacked one before
// - we have transitioned from QR to SAS
// - we are verifying with SAS, but we need to replace our verifier with a new one because both parties
// tried to start verification at the same time, and we lost the tie breaking
if (verification instanceof RustSdkCryptoJs.Sas) {
if (this._verifier === undefined || this._verifier instanceof RustQrCodeVerifier) {
this.setVerifier(new RustSASVerifier(verification, this, this.outgoingRequestProcessor));
} else if (this._verifier instanceof RustSASVerifier) {
this._verifier.replaceInner(verification);
}
} else if (verification instanceof RustSdkCryptoJs.Qr && this._verifier === undefined) {
this.setVerifier(new RustQrCodeVerifier(verification, this.outgoingRequestProcessor));
}
this.emit(VerificationRequestEvent.Change);
};
inner.registerChangesCallback(onChange);
this.emit(VerificationRequestEvent.Change);
}
private setVerifier(verifier: RustSASVerifier | RustQrCodeVerifier): void {
@@ -473,9 +483,12 @@ abstract class BaseRustVerifer<InnerType extends RustSdkCryptoJs.Qr | RustSdkCry
super();
this.completionDeferred = defer();
inner.registerChangesCallback(async () => {
this.onChange();
});
// As with RustVerificationRequest, we need to avoid a reference cycle.
// See the comments in RustVerificationRequest.
const weakThis = new WeakRef(this);
inner.registerChangesCallback(async () => weakThis.deref()?.onChange());
// stop the runtime complaining if nobody catches a failure
this.completionDeferred.promise.catch(() => null);
}
@@ -752,9 +765,12 @@ export class RustSASVerifier extends BaseRustVerifer<RustSdkCryptoJs.Sas> implem
public replaceInner(inner: RustSdkCryptoJs.Sas): void {
if (this.inner != inner) {
this.inner = inner;
inner.registerChangesCallback(async () => {
this.onChange();
});
// As with RustVerificationRequest, we need to avoid a reference cycle.
// See the comments in RustVerificationRequest.
const weakThis = new WeakRef(this);
inner.registerChangesCallback(async () => weakThis.deref()?.onChange());
// replaceInner will only get called if we started the verification at the same time as the other side, and we lost
// the tie breaker. So we need to re-accept their verification.
this.sendAccept();
+136
View File
@@ -0,0 +1,136 @@
/*
Copyright 2024 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 { IHttpOpts, MatrixHttpApi, Method } from "./http-api";
import { logger } from "./logger";
// How often we update the server capabilities.
// 6 hours - an arbitrary value, but they should change very infrequently.
const CAPABILITIES_CACHE_MS = 6 * 60 * 60 * 1000;
// How long we want before retrying if we couldn't fetch
const CAPABILITIES_RETRY_MS = 30 * 1000;
export interface ICapability {
enabled: boolean;
}
export interface IChangePasswordCapability extends ICapability {}
export interface IThreadsCapability extends ICapability {}
export interface IGetLoginTokenCapability extends ICapability {}
export interface ISetDisplayNameCapability extends ICapability {}
export interface ISetAvatarUrlCapability extends ICapability {}
export enum RoomVersionStability {
Stable = "stable",
Unstable = "unstable",
}
export interface IRoomVersionsCapability {
default: string;
available: Record<string, RoomVersionStability>;
}
/**
* A representation of the capabilities advertised by a homeserver as defined by
* [Capabilities negotiation](https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3capabilities).
*/
export interface Capabilities {
[key: string]: any;
"m.change_password"?: IChangePasswordCapability;
"m.room_versions"?: IRoomVersionsCapability;
"io.element.thread"?: IThreadsCapability;
"m.get_login_token"?: IGetLoginTokenCapability;
"org.matrix.msc3882.get_login_token"?: IGetLoginTokenCapability;
"m.set_displayname"?: ISetDisplayNameCapability;
"m.set_avatar_url"?: ISetAvatarUrlCapability;
}
type CapabilitiesResponse = {
capabilities: Capabilities;
};
/**
* Manages storing and periodically refreshing the server capabilities.
*/
export class ServerCapabilities {
private capabilities?: Capabilities;
private retryTimeout?: ReturnType<typeof setTimeout>;
private refreshTimeout?: ReturnType<typeof setInterval>;
public constructor(private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>) {}
/**
* Starts periodically fetching the server capabilities.
*/
public start(): void {
this.poll().then();
}
/**
* Stops the service
*/
public stop(): void {
this.clearTimeouts();
}
/**
* Returns the cached capabilities, or undefined if none are cached.
* @returns the current capabilities, if any.
*/
public getCachedCapabilities(): Capabilities | undefined {
return this.capabilities;
}
/**
* Fetches the latest server capabilities from the homeserver and returns them, or rejects
* on failure.
*/
public fetchCapabilities = async (): Promise<Capabilities> => {
const resp = await this.http.authedRequest<CapabilitiesResponse>(Method.Get, "/capabilities");
this.capabilities = resp["capabilities"];
return this.capabilities;
};
private poll = async (): Promise<void> => {
try {
await this.fetchCapabilities();
this.clearTimeouts();
this.refreshTimeout = setTimeout(this.poll, CAPABILITIES_CACHE_MS);
logger.debug("Fetched new server capabilities");
} catch (e) {
this.clearTimeouts();
const howLong = Math.floor(CAPABILITIES_RETRY_MS + Math.random() * 5000);
this.retryTimeout = setTimeout(this.poll, howLong);
logger.warn(`Failed to refresh capabilities: retrying in ${howLong}ms`, e);
}
};
private clearTimeouts(): void {
if (this.refreshTimeout) {
clearInterval(this.refreshTimeout);
this.refreshTimeout = undefined;
}
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
this.retryTimeout = undefined;
}
}
}
+4 -1
View File
@@ -407,9 +407,12 @@ export async function logDuration<T>(logger: BaseLogger, name: string, block: ()
/**
* Promise/async version of {@link setImmediate}.
*
* Implementation is based on `setTimeout` for wider compatibility.
* @deprecated Use {@link sleep} instead.
*/
export function immediate(): Promise<void> {
return new Promise(setImmediate);
return new Promise((resolve) => setTimeout(resolve));
}
export function isNullOrUndefined(val: any): boolean {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es2016",
"target": "es2022",
"experimentalDecorators": true,
"esModuleInterop": true,
"module": "commonjs",
+819 -933
View File
File diff suppressed because it is too large Load Diff