Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a51798acb | |||
| 24c589923b | |||
| 1586de44bd | |||
| 04ea2a4e5d | |||
| b71099d0f8 | |||
| ab6260074d | |||
| 25a7c9e140 | |||
| 78b6b878bd | |||
| 4ccb72c0f2 | |||
| 9f1aebbdcb | |||
| 6a15e8f1a0 | |||
| 238eea0ef5 | |||
| ab6f86536f | |||
| 819fc75202 | |||
| c70aa33367 | |||
| 240b43b652 | |||
| 697d5d31d1 | |||
| b1701ff571 | |||
| c55289ec65 | |||
| 987ec1e62f | |||
| 76b240cf57 | |||
| a4c4e7e275 | |||
| 3f5a994a24 | |||
| d754392410 | |||
| 7ecaa53e34 | |||
| 9218f6380c | |||
| 661ba76763 | |||
| 4cb851c51a | |||
| 5a3d24abc2 | |||
| 3eed74f1a6 | |||
| 10e7a2d997 | |||
| 969ecdb6fb | |||
| e8b91f2729 | |||
| f80366ff30 |
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -116,7 +116,7 @@ function makeMockClient(opts: {
|
||||
},
|
||||
};
|
||||
},
|
||||
getCapabilities() {
|
||||
getCachedCapabilities() {
|
||||
return opts.msc3882r0Only
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -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
@@ -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
@@ -37,4 +37,8 @@ declare module "@matrix-org/matrix-sdk-crypto-wasm" {
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Device {
|
||||
requestVerification(methods?: any[]): [RustSdkCryptoJs.VerificationRequest, RustSdkCryptoJs.ToDeviceRequest];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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/";
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,4 +21,5 @@ limitations under the License.
|
||||
*/
|
||||
export interface Focus {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
+17
-5
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"target": "es2022",
|
||||
"experimentalDecorators": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "commonjs",
|
||||
|
||||
Reference in New Issue
Block a user