Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d53d2dff45 | |||
| 3657eb61d8 | |||
| 1def88eb35 | |||
| 6f9e68c8f3 | |||
| 5a65c8436d | |||
| 2090319bdd | |||
| f4de8837fd | |||
| 1e92c13a75 | |||
| 8061fa924d | |||
| 0760c5f64c | |||
| e2bdb2f057 | |||
| fd47a189e0 | |||
| c233334f27 | |||
| 029ab5f109 | |||
| 366f686827 | |||
| 26387287af | |||
| ba0bbe1cba | |||
| 3c37d7b0fb | |||
| 097d645c19 | |||
| e62aabccd9 | |||
| 0f3bcf3736 | |||
| 8395919f0f | |||
| 6cdc68d19d | |||
| c879258545 | |||
| b14cc82682 | |||
| 9f9be701e7 | |||
| f552370c26 | |||
| db7e3e3cf3 | |||
| 3e512711d7 | |||
| f636976f15 | |||
| 905df2f7ca | |||
| c1c473e6c6 | |||
| 4835ca61ec | |||
| abdc8b2c2e | |||
| 89fe90d27c | |||
| 86e0eb11cc |
@@ -27,7 +27,7 @@ jobs:
|
||||
steps:
|
||||
# We create the status here and then update it to success/failure in the `report` stage
|
||||
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
|
||||
- uses: guibranco/github-status-action-v2@7ca807c2ba3401be532d29a876b93262108099fb
|
||||
- uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: pending
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
- name: "🩻 SonarCloud Scan"
|
||||
id: sonarcloud
|
||||
uses: matrix-org/sonarcloud-workflow-action@v3.3
|
||||
uses: matrix-org/sonarcloud-workflow-action@v4.0
|
||||
# workflow_run fails report against the develop commit always, we don't want that for PRs
|
||||
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
|
||||
with:
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
revision: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
- uses: guibranco/github-status-action-v2@7ca807c2ba3401be532d29a876b93262108099fb
|
||||
- uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4
|
||||
if: always()
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
steps:
|
||||
- name: Skip SonarCloud on merge queues
|
||||
if: env.ENABLE_COVERAGE == 'false'
|
||||
uses: guibranco/github-status-action-v2@7ca807c2ba3401be532d29a876b93262108099fb
|
||||
uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
Changes in [37.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.2.0) (2025-03-25)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Add reportRoom API ([#4753](https://github.com/matrix-org/matrix-js-sdk/pull/4753)). Contributed by @Half-Shot.
|
||||
* MatrixRTC: New membership manager ([#4726](https://github.com/matrix-org/matrix-js-sdk/pull/4726)). Contributed by @toger5.
|
||||
* Add disableKeyStorage() to crypto API ([#4742](https://github.com/matrix-org/matrix-js-sdk/pull/4742)). Contributed by @dbkr.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Allow port differing in OIDC dynamic registration URIs ([#4749](https://github.com/matrix-org/matrix-js-sdk/pull/4749)). Contributed by @t3chguy.
|
||||
* OIDC: only pass logo\_uri, policy\_uri, tos\_uri if they conform to "common base" ([#4748](https://github.com/matrix-org/matrix-js-sdk/pull/4748)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [37.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.1.0) (2025-03-11)
|
||||
==================================================================================================
|
||||
## 🦖 Deprecations
|
||||
|
||||
+9
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "37.1.0",
|
||||
"version": "37.3.0-rc.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/matrix-org/matrix-js-sdk"
|
||||
"url": "git+https://github.com/matrix-org/matrix-js-sdk.git"
|
||||
},
|
||||
"keywords": [
|
||||
"matrix-org"
|
||||
@@ -82,7 +82,7 @@
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||
"@peculiar/webcrypto": "^1.4.5",
|
||||
"@stylistic/eslint-plugin": "^3.0.0",
|
||||
"@stylistic/eslint-plugin": "^4.0.0",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/jest": "^29.0.0",
|
||||
@@ -97,11 +97,11 @@
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-import-resolver-typescript": "^4.0.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^28.0.0",
|
||||
"eslint-plugin-jsdoc": "^50.0.0",
|
||||
"eslint-plugin-matrix-org": "^2.1.0",
|
||||
"eslint-plugin-matrix-org": "2.1.0",
|
||||
"eslint-plugin-n": "^14.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.4.0",
|
||||
"eslint-plugin-unicorn": "^56.0.0",
|
||||
@@ -117,13 +117,13 @@
|
||||
"lint-staged": "^15.0.2",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"prettier": "3.5.1",
|
||||
"prettier": "3.5.3",
|
||||
"rimraf": "^6.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typedoc": "^0.27.0",
|
||||
"typedoc": "^0.28.1",
|
||||
"typedoc-plugin-coverage": "^3.0.0",
|
||||
"typedoc-plugin-mdn-links": "^4.0.0",
|
||||
"typedoc-plugin-missing-exports": "^3.0.0",
|
||||
"typedoc-plugin-mdn-links": "^5.0.0",
|
||||
"typedoc-plugin-missing-exports": "^4.0.0",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"@casualbot/jest-sonar-reporter": {
|
||||
|
||||
@@ -157,6 +157,30 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediaConfig", function () {
|
||||
it("should get media config on unauthenticated media call", async () => {
|
||||
httpBackend.when("GET", "/_matrix/media/v3/config").respond(200, '{"m.upload.size": 50000000}', true);
|
||||
|
||||
const prom = client.getMediaConfig();
|
||||
|
||||
httpBackend.flushAllExpected();
|
||||
|
||||
expect((await prom)["m.upload.size"]).toEqual(50000000);
|
||||
});
|
||||
|
||||
it("should get media config on authenticated media call", async () => {
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/client/v1/media/config")
|
||||
.respond(200, '{"m.upload.size": 50000000}', true);
|
||||
|
||||
const prom = client.getMediaConfig(true);
|
||||
|
||||
httpBackend.flushAllExpected();
|
||||
|
||||
expect((await prom)["m.upload.size"]).toEqual(50000000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinRoom", function () {
|
||||
it("should no-op given the ID of a room you've already joined", async () => {
|
||||
const roomId = "!foo:bar";
|
||||
|
||||
@@ -649,11 +649,13 @@ describe("SlidingSyncSdk", () => {
|
||||
ext = findExtension("e2ee");
|
||||
});
|
||||
|
||||
it("gets enabled on the initial request only", () => {
|
||||
expect(ext.onRequest(true)).toEqual({
|
||||
it("gets enabled all the time", async () => {
|
||||
expect(await ext.onRequest(true)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(await ext.onRequest(false)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(ext.onRequest(false)).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("can update device lists", () => {
|
||||
@@ -695,11 +697,13 @@ describe("SlidingSyncSdk", () => {
|
||||
ext = findExtension("account_data");
|
||||
});
|
||||
|
||||
it("gets enabled on the initial request only", () => {
|
||||
expect(ext.onRequest(true)).toEqual({
|
||||
it("gets enabled all the time", async () => {
|
||||
expect(await ext.onRequest(true)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(await ext.onRequest(false)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(ext.onRequest(false)).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("processes global account data", async () => {
|
||||
@@ -823,8 +827,12 @@ describe("SlidingSyncSdk", () => {
|
||||
ext = findExtension("to_device");
|
||||
});
|
||||
|
||||
it("gets enabled with a limit on the initial request only", () => {
|
||||
const reqJson: any = ext.onRequest(true);
|
||||
it("gets enabled all the time", async () => {
|
||||
let reqJson: any = await ext.onRequest(true);
|
||||
expect(reqJson.enabled).toEqual(true);
|
||||
expect(reqJson.limit).toBeGreaterThan(0);
|
||||
expect(reqJson.since).toBeUndefined();
|
||||
reqJson = await ext.onRequest(false);
|
||||
expect(reqJson.enabled).toEqual(true);
|
||||
expect(reqJson.limit).toBeGreaterThan(0);
|
||||
expect(reqJson.since).toBeUndefined();
|
||||
@@ -835,7 +843,7 @@ describe("SlidingSyncSdk", () => {
|
||||
next_batch: "12345",
|
||||
events: [],
|
||||
});
|
||||
expect(ext.onRequest(false)).toEqual({
|
||||
expect(await ext.onRequest(false)).toMatchObject({
|
||||
since: "12345",
|
||||
});
|
||||
});
|
||||
@@ -919,11 +927,13 @@ describe("SlidingSyncSdk", () => {
|
||||
ext = findExtension("typing");
|
||||
});
|
||||
|
||||
it("gets enabled on the initial request only", () => {
|
||||
expect(ext.onRequest(true)).toEqual({
|
||||
it("gets enabled all the time", async () => {
|
||||
expect(await ext.onRequest(true)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(await ext.onRequest(false)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(ext.onRequest(false)).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("processes typing notifications", async () => {
|
||||
@@ -1042,11 +1052,13 @@ describe("SlidingSyncSdk", () => {
|
||||
ext = findExtension("receipts");
|
||||
});
|
||||
|
||||
it("gets enabled on the initial request only", () => {
|
||||
expect(ext.onRequest(true)).toEqual({
|
||||
it("gets enabled all the time", async () => {
|
||||
expect(await ext.onRequest(true)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(await ext.onRequest(false)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(ext.onRequest(false)).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("processes receipts", async () => {
|
||||
|
||||
+30
-747
@@ -41,7 +41,7 @@ describe("SlidingSync", () => {
|
||||
const selfUserId = "@alice:localhost";
|
||||
const selfAccessToken = "aseukfgwef";
|
||||
const proxyBaseUrl = "http://localhost:8008";
|
||||
const syncUrl = proxyBaseUrl + "/_matrix/client/unstable/org.matrix.msc3575/sync";
|
||||
const syncUrl = proxyBaseUrl + "/_matrix/client/unstable/org.matrix.simplified_msc3575/sync";
|
||||
|
||||
// assign client/httpBackend globals
|
||||
const setupClient = () => {
|
||||
@@ -103,8 +103,8 @@ describe("SlidingSync", () => {
|
||||
};
|
||||
const ext: Extension<any, any> = {
|
||||
name: () => "custom_extension",
|
||||
onRequest: (initial) => {
|
||||
return { initial: initial };
|
||||
onRequest: async (_) => {
|
||||
return { initial: true };
|
||||
},
|
||||
onResponse: async (res) => {
|
||||
return;
|
||||
@@ -143,18 +143,16 @@ describe("SlidingSync", () => {
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
// expect nothing but ranges and non-initial extensions to be sent
|
||||
// expect all params to be sent TODO: check MSC4186
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeFalsy();
|
||||
expect(body.lists["a"]).toEqual({
|
||||
ranges: [[0, 10]],
|
||||
});
|
||||
expect(body.lists["a"]).toEqual(listInfo);
|
||||
expect(body.extensions).toBeTruthy();
|
||||
expect(body.extensions["custom_extension"]).toEqual({ initial: false });
|
||||
expect(body.extensions["custom_extension"]).toEqual({ initial: true });
|
||||
expect(req.queryParams!["pos"]).toEqual("11");
|
||||
})
|
||||
.respond(200, function () {
|
||||
@@ -332,6 +330,7 @@ describe("SlidingSync", () => {
|
||||
await p;
|
||||
});
|
||||
|
||||
// TODO: this does not exist in MSC4186
|
||||
it("should be able to unsubscribe from a room", async () => {
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
@@ -389,18 +388,19 @@ describe("SlidingSync", () => {
|
||||
[3, 5],
|
||||
];
|
||||
|
||||
// request first 3 rooms
|
||||
const listReq = {
|
||||
ranges: [[0, 2]],
|
||||
sort: ["by_name"],
|
||||
timeline_limit: 1,
|
||||
required_state: [["m.room.topic", ""]],
|
||||
filters: {
|
||||
is_dm: true,
|
||||
},
|
||||
};
|
||||
|
||||
let slidingSync: SlidingSync;
|
||||
it("should be possible to subscribe to a list", async () => {
|
||||
// request first 3 rooms
|
||||
const listReq = {
|
||||
ranges: [[0, 2]],
|
||||
sort: ["by_name"],
|
||||
timeline_limit: 1,
|
||||
required_state: [["m.room.topic", ""]],
|
||||
filters: {
|
||||
is_dm: true,
|
||||
},
|
||||
};
|
||||
slidingSync = new SlidingSync(proxyBaseUrl, new Map([["a", listReq]]), {}, client!, 1);
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
@@ -452,11 +452,6 @@ describe("SlidingSync", () => {
|
||||
expect(slidingSync.getListData("b")).toBeNull();
|
||||
const syncData = slidingSync.getListData("a")!;
|
||||
expect(syncData.joinedCount).toEqual(500); // from previous test
|
||||
expect(syncData.roomIndexToRoomId).toEqual({
|
||||
0: roomA,
|
||||
1: roomB,
|
||||
2: roomC,
|
||||
});
|
||||
});
|
||||
|
||||
it("should be possible to adjust list ranges", async () => {
|
||||
@@ -467,10 +462,9 @@ describe("SlidingSync", () => {
|
||||
const body = req.data;
|
||||
logger.log("next ranges", body.lists["a"].ranges);
|
||||
expect(body.lists).toBeTruthy();
|
||||
expect(body.lists["a"]).toEqual({
|
||||
// only the ranges should be sent as the rest are unchanged and sticky
|
||||
ranges: newRanges,
|
||||
});
|
||||
// list range should be changed
|
||||
listReq.ranges = newRanges;
|
||||
expect(body.lists["a"]).toEqual(listReq); // resend all values TODO: check MSC4186
|
||||
})
|
||||
.respond(200, {
|
||||
pos: "b",
|
||||
@@ -495,7 +489,9 @@ describe("SlidingSync", () => {
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
// setListRanges for an invalid list key returns an error
|
||||
await expect(slidingSync.setListRanges("idontexist", newRanges)).rejects.toBeTruthy();
|
||||
expect(() => {
|
||||
slidingSync.setListRanges("idontexist", newRanges);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("should be possible to add an extra list", async () => {
|
||||
@@ -513,10 +509,7 @@ describe("SlidingSync", () => {
|
||||
const body = req.data;
|
||||
logger.log("extra list", body);
|
||||
expect(body.lists).toBeTruthy();
|
||||
expect(body.lists["a"]).toEqual({
|
||||
// only the ranges should be sent as the rest are unchanged and sticky
|
||||
ranges: newRanges,
|
||||
});
|
||||
expect(body.lists["a"]).toEqual(listReq); // resend all values TODO: check MSC4186
|
||||
expect(body.lists["b"]).toEqual(extraListReq);
|
||||
})
|
||||
.respond(200, {
|
||||
@@ -537,16 +530,6 @@ describe("SlidingSync", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("b");
|
||||
expect(joinedCount).toEqual(50);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomA,
|
||||
1: roomB,
|
||||
2: roomC,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
@@ -554,706 +537,6 @@ describe("SlidingSync", () => {
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
});
|
||||
|
||||
it("should be possible to get list DELETE/INSERTs", async () => {
|
||||
// move C (2) to A (0)
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "e",
|
||||
lists: {
|
||||
a: {
|
||||
count: 500,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 0,
|
||||
room_id: roomC,
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
let listPromise = listenUntil(
|
||||
slidingSync,
|
||||
"SlidingSync.List",
|
||||
(listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(500);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
1: roomA,
|
||||
2: roomB,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
);
|
||||
let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
|
||||
// move C (0) back to A (2)
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "f",
|
||||
lists: {
|
||||
a: {
|
||||
count: 500,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 2,
|
||||
room_id: roomC,
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
listPromise = listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(500);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomA,
|
||||
1: roomB,
|
||||
2: roomC,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
});
|
||||
|
||||
it("should ignore invalid list indexes", async () => {
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "e",
|
||||
lists: {
|
||||
a: {
|
||||
count: 500,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 2324324,
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
const listPromise = listenUntil(
|
||||
slidingSync,
|
||||
"SlidingSync.List",
|
||||
(listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(500);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomA,
|
||||
1: roomB,
|
||||
2: roomC,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
});
|
||||
|
||||
it("should be possible to update a list", async () => {
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "g",
|
||||
lists: {
|
||||
a: {
|
||||
count: 42,
|
||||
ops: [
|
||||
{
|
||||
op: "INVALIDATE",
|
||||
range: [0, 2],
|
||||
},
|
||||
{
|
||||
op: "SYNC",
|
||||
range: [0, 1],
|
||||
room_ids: [roomB, roomC],
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
// update the list with a new filter
|
||||
slidingSync.setList("a", {
|
||||
filters: {
|
||||
is_encrypted: true,
|
||||
},
|
||||
ranges: [[0, 100]],
|
||||
});
|
||||
const listPromise = listenUntil(
|
||||
slidingSync,
|
||||
"SlidingSync.List",
|
||||
(listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(42);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomB,
|
||||
1: roomC,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
});
|
||||
|
||||
// this refers to a set of operations where the end result is no change.
|
||||
it("should handle net zero operations correctly", async () => {
|
||||
const indexToRoomId = {
|
||||
0: roomB,
|
||||
1: roomC,
|
||||
};
|
||||
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual(indexToRoomId);
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "f",
|
||||
// currently the list is [B,C] so we will insert D then immediately delete it
|
||||
lists: {
|
||||
a: {
|
||||
count: 500,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 0,
|
||||
room_id: roomA,
|
||||
},
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
const listPromise = listenUntil(
|
||||
slidingSync,
|
||||
"SlidingSync.List",
|
||||
(listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(500);
|
||||
expect(roomIndexToRoomId).toEqual(indexToRoomId);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
});
|
||||
|
||||
it("should handle deletions correctly", async () => {
|
||||
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
|
||||
0: roomB,
|
||||
1: roomC,
|
||||
});
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "g",
|
||||
lists: {
|
||||
a: {
|
||||
count: 499,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
const listPromise = listenUntil(
|
||||
slidingSync,
|
||||
"SlidingSync.List",
|
||||
(listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(499);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
});
|
||||
|
||||
it("should handle insertions correctly", async () => {
|
||||
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
});
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "h",
|
||||
lists: {
|
||||
a: {
|
||||
count: 500,
|
||||
ops: [
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 1,
|
||||
room_id: roomA,
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
let listPromise = listenUntil(
|
||||
slidingSync,
|
||||
"SlidingSync.List",
|
||||
(listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(500);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
1: roomA,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
);
|
||||
let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "h",
|
||||
lists: {
|
||||
a: {
|
||||
count: 501,
|
||||
ops: [
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 1,
|
||||
room_id: roomB,
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
listPromise = listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(501);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
1: roomB,
|
||||
2: roomA,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
slidingSync.stop();
|
||||
});
|
||||
|
||||
// Regression test to make sure things like DELETE 0 INSERT 0 work correctly and we don't
|
||||
// end up losing room IDs.
|
||||
it("should handle insertions with a spurious DELETE correctly", async () => {
|
||||
slidingSync = new SlidingSync(
|
||||
proxyBaseUrl,
|
||||
new Map([
|
||||
[
|
||||
"a",
|
||||
{
|
||||
ranges: [[0, 20]],
|
||||
},
|
||||
],
|
||||
]),
|
||||
{},
|
||||
client!,
|
||||
1,
|
||||
);
|
||||
// initially start with nothing
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "a",
|
||||
lists: {
|
||||
a: {
|
||||
count: 0,
|
||||
ops: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
slidingSync.start();
|
||||
await httpBackend!.flushAllExpected();
|
||||
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({});
|
||||
|
||||
// insert a room
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "b",
|
||||
lists: {
|
||||
a: {
|
||||
count: 1,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 0,
|
||||
room_id: roomA,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
|
||||
0: roomA,
|
||||
});
|
||||
|
||||
// insert another room
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "c",
|
||||
lists: {
|
||||
a: {
|
||||
count: 1,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 0,
|
||||
room_id: roomB,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
|
||||
0: roomB,
|
||||
1: roomA,
|
||||
});
|
||||
|
||||
// insert a final room
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "c",
|
||||
lists: {
|
||||
a: {
|
||||
count: 1,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 0,
|
||||
room_id: roomC,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
1: roomB,
|
||||
2: roomA,
|
||||
});
|
||||
slidingSync.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transaction IDs", () => {
|
||||
beforeAll(setupClient);
|
||||
afterAll(teardownClient);
|
||||
const roomId = "!foo:bar";
|
||||
|
||||
let slidingSync: SlidingSync;
|
||||
|
||||
// really this applies to them all but it's easier to just test one
|
||||
it("should resolve modifyRoomSubscriptions after SlidingSync.start() is called", async () => {
|
||||
const roomSubInfo = {
|
||||
timeline_limit: 1,
|
||||
required_state: [["m.room.name", ""]],
|
||||
};
|
||||
// add the subscription
|
||||
slidingSync = new SlidingSync(proxyBaseUrl, new Map(), roomSubInfo, client!, 1);
|
||||
// modification before SlidingSync.start()
|
||||
const subscribePromise = slidingSync.modifyRoomSubscriptions(new Set([roomId]));
|
||||
let txnId: string | undefined;
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeTruthy();
|
||||
expect(body.room_subscriptions[roomId]).toEqual(roomSubInfo);
|
||||
expect(body.txn_id).toBeTruthy();
|
||||
txnId = body.txn_id;
|
||||
})
|
||||
.respond(200, function () {
|
||||
return {
|
||||
pos: "aaa",
|
||||
txn_id: txnId,
|
||||
lists: {},
|
||||
extensions: {},
|
||||
rooms: {
|
||||
[roomId]: {
|
||||
name: "foo bar",
|
||||
required_state: [],
|
||||
timeline: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
slidingSync.start();
|
||||
await httpBackend!.flushAllExpected();
|
||||
await subscribePromise;
|
||||
});
|
||||
it("should resolve setList during a connection", async () => {
|
||||
const newList = {
|
||||
ranges: [[0, 20]],
|
||||
};
|
||||
const promise = slidingSync.setList("a", newList);
|
||||
let txnId: string | undefined;
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeFalsy();
|
||||
expect(body.lists["a"]).toEqual(newList);
|
||||
expect(body.txn_id).toBeTruthy();
|
||||
txnId = body.txn_id;
|
||||
})
|
||||
.respond(200, function () {
|
||||
return {
|
||||
pos: "bbb",
|
||||
txn_id: txnId,
|
||||
lists: { a: { count: 5 } },
|
||||
extensions: {},
|
||||
};
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await promise;
|
||||
expect(txnId).toBeDefined();
|
||||
});
|
||||
it("should resolve setListRanges during a connection", async () => {
|
||||
const promise = slidingSync.setListRanges("a", [[20, 40]]);
|
||||
let txnId: string | undefined;
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeFalsy();
|
||||
expect(body.lists["a"]).toEqual({
|
||||
ranges: [[20, 40]],
|
||||
});
|
||||
expect(body.txn_id).toBeTruthy();
|
||||
txnId = body.txn_id;
|
||||
})
|
||||
.respond(200, function () {
|
||||
return {
|
||||
pos: "ccc",
|
||||
txn_id: txnId,
|
||||
lists: { a: { count: 5 } },
|
||||
extensions: {},
|
||||
};
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await promise;
|
||||
expect(txnId).toBeDefined();
|
||||
});
|
||||
it("should resolve modifyRoomSubscriptionInfo during a connection", async () => {
|
||||
const promise = slidingSync.modifyRoomSubscriptionInfo({
|
||||
timeline_limit: 99,
|
||||
});
|
||||
let txnId: string | undefined;
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeTruthy();
|
||||
expect(body.room_subscriptions[roomId]).toEqual({
|
||||
timeline_limit: 99,
|
||||
});
|
||||
expect(body.txn_id).toBeTruthy();
|
||||
txnId = body.txn_id;
|
||||
})
|
||||
.respond(200, function () {
|
||||
return {
|
||||
pos: "ddd",
|
||||
txn_id: txnId,
|
||||
extensions: {},
|
||||
};
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await promise;
|
||||
expect(txnId).toBeDefined();
|
||||
});
|
||||
it("should reject earlier pending promises if a later transaction is acknowledged", async () => {
|
||||
// i.e if we have [A,B,C] and see txn_id=C then A,B should be rejected.
|
||||
const gotTxnIds: any[] = [];
|
||||
const pushTxn = function (req: MockHttpBackend["requests"][0]) {
|
||||
gotTxnIds.push(req.data.txn_id);
|
||||
};
|
||||
const failPromise = slidingSync.setListRanges("a", [[20, 40]]);
|
||||
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "e" }); // missing txn_id
|
||||
await httpBackend!.flushAllExpected();
|
||||
const failPromise2 = slidingSync.setListRanges("a", [[60, 70]]);
|
||||
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "f" }); // missing txn_id
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
const okPromise = slidingSync.setListRanges("a", [[0, 20]]);
|
||||
let txnId: string | undefined;
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check((req) => {
|
||||
txnId = req.data.txn_id;
|
||||
})
|
||||
.respond(200, () => {
|
||||
// include the txn_id, earlier requests should now be reject()ed.
|
||||
return {
|
||||
pos: "g",
|
||||
txn_id: txnId,
|
||||
};
|
||||
});
|
||||
await Promise.all([
|
||||
expect(failPromise).rejects.toEqual(gotTxnIds[0]),
|
||||
expect(failPromise2).rejects.toEqual(gotTxnIds[1]),
|
||||
httpBackend!.flushAllExpected(),
|
||||
okPromise,
|
||||
]);
|
||||
|
||||
expect(txnId).toBeDefined();
|
||||
});
|
||||
it("should not reject later pending promises if an earlier transaction is acknowledged", async () => {
|
||||
// i.e if we have [A,B,C] and see txn_id=B then C should not be rejected but A should.
|
||||
const gotTxnIds: any[] = [];
|
||||
const pushTxn = function (req: MockHttpBackend["requests"][0]) {
|
||||
gotTxnIds.push(req.data?.txn_id);
|
||||
};
|
||||
const A = slidingSync.setListRanges("a", [[20, 40]]);
|
||||
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "A" });
|
||||
await httpBackend!.flushAllExpected();
|
||||
const B = slidingSync.setListRanges("a", [[60, 70]]);
|
||||
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "B" }); // missing txn_id
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
// attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection
|
||||
// which is a fail.
|
||||
|
||||
const C = slidingSync.setListRanges("a", [[0, 20]]);
|
||||
let pendingC = true;
|
||||
C.finally(() => {
|
||||
pendingC = false;
|
||||
});
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(pushTxn)
|
||||
.respond(200, () => {
|
||||
// include the txn_id for B, so C's promise is outstanding
|
||||
return {
|
||||
pos: "C",
|
||||
txn_id: gotTxnIds[1],
|
||||
};
|
||||
});
|
||||
await Promise.all([
|
||||
expect(A).rejects.toEqual(gotTxnIds[0]),
|
||||
httpBackend!.flushAllExpected(),
|
||||
// A is rejected, see above
|
||||
expect(B).resolves.toEqual(gotTxnIds[1]), // B is resolved
|
||||
]);
|
||||
expect(pendingC).toBe(true); // C is pending still
|
||||
});
|
||||
it("should do nothing for unknown txn_ids", async () => {
|
||||
const promise = slidingSync.setListRanges("a", [[20, 40]]);
|
||||
let pending = true;
|
||||
promise.finally(() => {
|
||||
pending = false;
|
||||
});
|
||||
let txnId: string | undefined;
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeFalsy();
|
||||
expect(body.lists["a"]).toEqual({
|
||||
ranges: [[20, 40]],
|
||||
});
|
||||
expect(body.txn_id).toBeTruthy();
|
||||
txnId = body.txn_id;
|
||||
})
|
||||
.respond(200, function () {
|
||||
return {
|
||||
pos: "ccc",
|
||||
txn_id: "bogus transaction id",
|
||||
lists: { a: { count: 5 } },
|
||||
extensions: {},
|
||||
};
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
expect(txnId).toBeDefined();
|
||||
expect(pending).toBe(true);
|
||||
slidingSync.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom room subscriptions", () => {
|
||||
@@ -1543,7 +826,7 @@ describe("SlidingSync", () => {
|
||||
|
||||
const extPre: Extension<any, any> = {
|
||||
name: () => preExtName,
|
||||
onRequest: (initial) => {
|
||||
onRequest: async (initial) => {
|
||||
return onPreExtensionRequest(initial);
|
||||
},
|
||||
onResponse: (res) => {
|
||||
@@ -1553,7 +836,7 @@ describe("SlidingSync", () => {
|
||||
};
|
||||
const extPost: Extension<any, any> = {
|
||||
name: () => postExtName,
|
||||
onRequest: (initial) => {
|
||||
onRequest: async (initial) => {
|
||||
return onPostExtensionRequest(initial);
|
||||
},
|
||||
onResponse: (res) => {
|
||||
@@ -1568,7 +851,7 @@ describe("SlidingSync", () => {
|
||||
|
||||
const callbackOrder: string[] = [];
|
||||
let extensionOnResponseCalled = false;
|
||||
onPreExtensionRequest = () => {
|
||||
onPreExtensionRequest = async () => {
|
||||
return extReq;
|
||||
};
|
||||
onPreExtensionResponse = async (resp) => {
|
||||
@@ -1608,7 +891,7 @@ describe("SlidingSync", () => {
|
||||
});
|
||||
|
||||
it("should be able to send nothing in an extension request/response", async () => {
|
||||
onPreExtensionRequest = () => {
|
||||
onPreExtensionRequest = async () => {
|
||||
return undefined;
|
||||
};
|
||||
let responseCalled = false;
|
||||
@@ -1643,7 +926,7 @@ describe("SlidingSync", () => {
|
||||
|
||||
it("is possible to register extensions after start() has been called", async () => {
|
||||
slidingSync.registerExtension(extPost);
|
||||
onPostExtensionRequest = () => {
|
||||
onPostExtensionRequest = async () => {
|
||||
return extReq;
|
||||
};
|
||||
let responseCalled = false;
|
||||
|
||||
+20
-20
@@ -49,26 +49,26 @@ const testOIDCToken = {
|
||||
token_type: "Bearer",
|
||||
};
|
||||
class MockWidgetApi extends EventEmitter {
|
||||
public start = jest.fn();
|
||||
public requestCapability = jest.fn();
|
||||
public requestCapabilities = jest.fn();
|
||||
public requestCapabilityForRoomTimeline = jest.fn();
|
||||
public requestCapabilityToSendEvent = jest.fn();
|
||||
public requestCapabilityToReceiveEvent = jest.fn();
|
||||
public requestCapabilityToSendMessage = jest.fn();
|
||||
public requestCapabilityToReceiveMessage = jest.fn();
|
||||
public requestCapabilityToSendState = jest.fn();
|
||||
public requestCapabilityToReceiveState = jest.fn();
|
||||
public requestCapabilityToSendToDevice = jest.fn();
|
||||
public requestCapabilityToReceiveToDevice = jest.fn();
|
||||
public start = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapability = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilities = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityForRoomTimeline = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToSendEvent = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToReceiveEvent = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToSendMessage = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToReceiveMessage = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToSendState = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToReceiveState = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToSendToDevice = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToReceiveToDevice = jest.fn().mockResolvedValue(undefined);
|
||||
public sendRoomEvent = jest.fn(
|
||||
(eventType: string, content: unknown, roomId?: string, delay?: number, parentDelayId?: string) =>
|
||||
async (eventType: string, content: unknown, roomId?: string, delay?: number, parentDelayId?: string) =>
|
||||
delay === undefined && parentDelayId === undefined
|
||||
? { event_id: `$${Math.random()}` }
|
||||
: { delay_id: `id-${Math.random()}` },
|
||||
);
|
||||
public sendStateEvent = jest.fn(
|
||||
(
|
||||
async (
|
||||
eventType: string,
|
||||
stateKey: string,
|
||||
content: unknown,
|
||||
@@ -80,17 +80,17 @@ class MockWidgetApi extends EventEmitter {
|
||||
? { event_id: `$${Math.random()}` }
|
||||
: { delay_id: `id-${Math.random()}` },
|
||||
);
|
||||
public updateDelayedEvent = jest.fn();
|
||||
public sendToDevice = jest.fn();
|
||||
public requestOpenIDConnectToken = jest.fn(() => {
|
||||
public updateDelayedEvent = jest.fn().mockResolvedValue(undefined);
|
||||
public sendToDevice = jest.fn().mockResolvedValue(undefined);
|
||||
public requestOpenIDConnectToken = jest.fn(async () => {
|
||||
return testOIDCToken;
|
||||
return new Promise<IOpenIDCredentials>(() => {
|
||||
return testOIDCToken;
|
||||
});
|
||||
});
|
||||
public readStateEvents = jest.fn(() => []);
|
||||
public getTurnServers = jest.fn(() => []);
|
||||
public sendContentLoaded = jest.fn();
|
||||
public readStateEvents = jest.fn(async () => []);
|
||||
public getTurnServers = jest.fn(async () => []);
|
||||
public sendContentLoaded = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
public transport = {
|
||||
reply: jest.fn(),
|
||||
|
||||
@@ -607,6 +607,26 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportRoom", function () {
|
||||
const roomId = "!foo:bar";
|
||||
const reason = "rubbish room";
|
||||
|
||||
it("should send an invite HTTP POST", async function () {
|
||||
httpLookups = [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/rooms/!foo%3Abar/report",
|
||||
data: {},
|
||||
expectBody: {
|
||||
reason,
|
||||
},
|
||||
},
|
||||
];
|
||||
await client.reportRoom(roomId, reason);
|
||||
expect(httpLookups.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendEvent", () => {
|
||||
const roomId = "!room:example.org";
|
||||
const body = "This is the body";
|
||||
|
||||
@@ -19,10 +19,17 @@ limitations under the License.
|
||||
|
||||
import { type MockedFunction, type Mock } from "jest-mock";
|
||||
|
||||
import { EventType, HTTPError, MatrixError, type Room } from "../../../src";
|
||||
import { type Focus, type LivekitFocusActive, type SessionMembershipData } from "../../../src/matrixrtc";
|
||||
import { LegacyMembershipManager } from "../../../src/matrixrtc/MembershipManager";
|
||||
import { EventType, HTTPError, MatrixError, UnsupportedDelayedEventsEndpointError, type Room } from "../../../src";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
Status,
|
||||
type Focus,
|
||||
type LivekitFocusActive,
|
||||
type SessionMembershipData,
|
||||
} from "../../../src/matrixrtc";
|
||||
import { LegacyMembershipManager } from "../../../src/matrixrtc/LegacyMembershipManager";
|
||||
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
|
||||
import { MembershipManager } from "../../../src/matrixrtc/NewMembershipManager";
|
||||
import { defer } from "../../../src/utils";
|
||||
|
||||
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>) {
|
||||
@@ -33,6 +40,14 @@ function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>)
|
||||
});
|
||||
});
|
||||
}
|
||||
function waitForMockCallOnce(method: MockedFunction<any>, returnVal?: Promise<any>) {
|
||||
return new Promise<void>((resolve) => {
|
||||
method.mockImplementationOnce(() => {
|
||||
resolve();
|
||||
return returnVal ?? Promise.resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createAsyncHandle(method: MockedFunction<any>) {
|
||||
const { reject, resolve, promise } = defer();
|
||||
@@ -44,9 +59,10 @@ function createAsyncHandle(method: MockedFunction<any>) {
|
||||
* Tests different MembershipManager implementations. Some tests don't apply to `LegacyMembershipManager`
|
||||
* use !FailsForLegacy to skip those. See: testEnvironment for more details.
|
||||
*/
|
||||
|
||||
describe.each([
|
||||
{ TestMembershipManager: LegacyMembershipManager, description: "LegacyMembershipManager" },
|
||||
// { TestMembershipManager: MembershipManager, description: "MembershipManager" },
|
||||
{ TestMembershipManager: MembershipManager, description: "MembershipManager" },
|
||||
])("$description", ({ TestMembershipManager }) => {
|
||||
let client: MockClient;
|
||||
let room: Room;
|
||||
@@ -76,16 +92,16 @@ describe.each([
|
||||
// There is no need to clean up mocks since we will recreate the client.
|
||||
});
|
||||
|
||||
describe("isJoined()", () => {
|
||||
describe("isActivated()", () => {
|
||||
it("defaults to false", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
expect(manager.isJoined()).toEqual(false);
|
||||
expect(manager.isActivated()).toEqual(false);
|
||||
});
|
||||
|
||||
it("returns true after join()", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
manager.join([]);
|
||||
expect(manager.isJoined()).toEqual(true);
|
||||
expect(manager.isActivated()).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +139,23 @@ describe.each([
|
||||
{},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reschedules delayed leave event if sending state cancels it", async () => {
|
||||
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
|
||||
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
||||
const waitForUpdateDelaye = waitForMockCallOnce(
|
||||
client._unstable_updateDelayedEvent,
|
||||
Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })),
|
||||
);
|
||||
memberManager.join([focus], focusActive);
|
||||
await waitForSendState;
|
||||
await waitForUpdateDelaye;
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// Once for the initial event and once because of the errcode: "M_NOT_FOUND"
|
||||
// Different to "sends a membership event and schedules delayed leave when joining a call" where its only called once (1)
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe("does not prefix the state key with _ for rooms that support user-owned state events", () => {
|
||||
@@ -244,7 +277,12 @@ describe.each([
|
||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
delayedHandle.reject?.(Error("Server does not support the delayed events API"));
|
||||
delayedHandle.reject?.(
|
||||
new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"sendDelayedStateEvent",
|
||||
),
|
||||
);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("does try to schedule a delayed leave event again if rate limited", async () => {
|
||||
@@ -328,6 +366,7 @@ describe.each([
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
|
||||
await manager.leave();
|
||||
|
||||
// We send a normal leave event since we failed using updateDelayedEvent with the "send" action.
|
||||
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
|
||||
room.roomId,
|
||||
@@ -337,9 +376,9 @@ describe.each([
|
||||
);
|
||||
});
|
||||
// FailsForLegacy because legacy implementation always sends the empty state event even though it isn't needed
|
||||
it("does nothing if not joined !FailsForLegacy", async () => {
|
||||
it("does nothing if not joined !FailsForLegacy", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
await manager.leave();
|
||||
expect(async () => await manager.leave()).not.toThrow();
|
||||
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -470,10 +509,10 @@ describe.each([
|
||||
// !FailsForLegacy because the expires logic was removed for the legacy call manager.
|
||||
// Delayed events should replace it entirely but before they have wide adoption
|
||||
// the expiration logic still makes sense.
|
||||
// TODO: add git commit when we removed it.
|
||||
it("extends `expires` when call still active !FailsForLegacy", async () => {
|
||||
// TODO: Add git commit when we removed it.
|
||||
async function testExpires(expire: number, headroom?: number) {
|
||||
const manager = new TestMembershipManager(
|
||||
{ membershipExpiryTimeout: 10_000 },
|
||||
{ membershipExpiryTimeout: expire, membershipExpiryTimeoutHeadroom: headroom },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
@@ -482,16 +521,54 @@ describe.each([
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
const sentMembership = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData;
|
||||
expect(sentMembership.expires).toBe(10_000);
|
||||
expect(sentMembership.expires).toBe(expire);
|
||||
for (let i = 2; i <= 12; i++) {
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
await jest.advanceTimersByTimeAsync(expire);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(i);
|
||||
const sentMembership = (client.sendStateEvent as Mock).mock.lastCall![2] as SessionMembershipData;
|
||||
expect(sentMembership.expires).toBe(10_000 * i);
|
||||
expect(sentMembership.expires).toBe(expire * i);
|
||||
}
|
||||
}
|
||||
it("extends `expires` when call still active !FailsForLegacy", async () => {
|
||||
await testExpires(10_000);
|
||||
});
|
||||
it("extends `expires` using headroom configuration !FailsForLegacy", async () => {
|
||||
await testExpires(10_000, 1_000);
|
||||
});
|
||||
});
|
||||
describe("status updates", () => {
|
||||
it("starts 'Disconnected' !FailsForLegacy", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
});
|
||||
it("emits 'Connection' and 'Connected' after join !FailsForLegacy", async () => {
|
||||
const handleDelayedEvent = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
const handleStateEvent = createAsyncHandle(client.sendStateEvent);
|
||||
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
manager.join([focus], focusActive);
|
||||
expect(manager.status).toBe(Status.Connecting);
|
||||
handleDelayedEvent.resolve();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(connectEmit).toHaveBeenCalledWith(Status.Disconnected, Status.Connecting);
|
||||
handleStateEvent.resolve();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
|
||||
});
|
||||
it("emits 'Disconnecting' and 'Disconnected' after leave !FailsForLegacy", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
await manager.leave();
|
||||
expect(connectEmit).toHaveBeenCalledWith(Status.Connected, Status.Disconnecting);
|
||||
expect(connectEmit).toHaveBeenCalledWith(Status.Disconnecting, Status.Disconnected);
|
||||
});
|
||||
});
|
||||
describe("server error handling", () => {
|
||||
// Types of server error: 429 rate limit with no retry-after header, 429 with retry-after, 50x server error (maybe retry every second), connection/socket timeout
|
||||
describe("retries sending delayed leave event", () => {
|
||||
@@ -544,7 +621,7 @@ describe.each([
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// FailsForLegacy as implementation does not re-check membership before retrying.
|
||||
it("abandons retry loop if leave() was called !FailsForLegacy", async () => {
|
||||
it("abandons retry loop if leave() was called before sending state event !FailsForLegacy", async () => {
|
||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
@@ -565,7 +642,6 @@ describe.each([
|
||||
await manager.leave();
|
||||
|
||||
// Wait for all timers to be setup
|
||||
// await flushPromises();
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// No new events should have been sent:
|
||||
@@ -603,4 +679,87 @@ describe.each([
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("unrecoverable errors", () => {
|
||||
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries for initial delayed event creation !FailsForLegacy", async () => {
|
||||
const delayEventSendError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||
429,
|
||||
undefined,
|
||||
undefined,
|
||||
new Headers({ "Retry-After": "2" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, delayEventSendError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
}
|
||||
expect(delayEventSendError).toHaveBeenCalled();
|
||||
});
|
||||
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries !FailsForLegacy", async () => {
|
||||
const delayEventRestartError = jest.fn();
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||
429,
|
||||
undefined,
|
||||
undefined,
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, delayEventRestartError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
}
|
||||
expect(delayEventRestartError).toHaveBeenCalled();
|
||||
});
|
||||
it("falls back to using pure state events when some error occurs while sending delayed events !FailsForLegacy", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(unrecoverableError).not.toHaveBeenCalledWith();
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
});
|
||||
it("retries before failing in case its a network error !FailsForLegacy", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 501));
|
||||
const manager = new TestMembershipManager(
|
||||
{ callMemberEventRetryDelayMinimum: 1000, maximumNetworkErrorRetryCount: 7 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
for (let retries = 0; retries < 7; retries++) {
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(retries + 1);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
}
|
||||
expect(unrecoverableError).toHaveBeenCalled();
|
||||
expect(unrecoverableError.mock.lastCall![0].message).toMatch(
|
||||
"The MembershipManager shut down because of the end condition",
|
||||
);
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events !FailsForLegacy", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(unrecoverableError).not.toHaveBeenCalled();
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ export const membershipTemplate: SessionMembershipData = {
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
scope: "m.room",
|
||||
focus_active: { type: "livekit", livekit_service_url: "https://lk.url" },
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
foci_preferred: [
|
||||
{
|
||||
livekit_alias: "!alias:something.org",
|
||||
|
||||
@@ -29,9 +29,10 @@ describe("registerOidcClient()", () => {
|
||||
redirectUris: [baseUrl],
|
||||
clientName,
|
||||
applicationType: "web",
|
||||
tosUri: "http://tos-uri",
|
||||
policyUri: "http://policy-uri",
|
||||
tosUri: "https://just.testing/tos",
|
||||
policyUri: "https://policy.just.testing",
|
||||
contacts: ["admin@example.com"],
|
||||
logoUri: `${baseUrl}:8443/logo.png`,
|
||||
};
|
||||
const dynamicClientId = "xyz789";
|
||||
|
||||
@@ -67,6 +68,9 @@ describe("registerOidcClient()", () => {
|
||||
id_token_signed_response_alg: "RS256",
|
||||
token_endpoint_auth_method: "none",
|
||||
application_type: "web",
|
||||
tos_uri: "https://just.testing/tos",
|
||||
policy_uri: "https://policy.just.testing",
|
||||
logo_uri: `${baseUrl}:8443/logo.png`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -114,4 +118,24 @@ describe("registerOidcClient()", () => {
|
||||
),
|
||||
).rejects.toThrow(OidcError.DynamicRegistrationNotSupported);
|
||||
});
|
||||
|
||||
it("should filter out invalid URIs", async () => {
|
||||
fetchMockJest.post(delegatedAuthConfig.registration_endpoint!, {
|
||||
status: 200,
|
||||
body: JSON.stringify({ client_id: dynamicClientId }),
|
||||
});
|
||||
expect(
|
||||
await registerOidcClient(delegatedAuthConfig, {
|
||||
...metadata,
|
||||
tosUri: "http://just.testing/tos",
|
||||
policyUri: "https://policy-uri/",
|
||||
}),
|
||||
).toEqual(dynamicClientId);
|
||||
expect(JSON.parse(fetchMockJest.mock.calls[0][1]!.body as string)).not.toEqual(
|
||||
expect.objectContaining({
|
||||
tos_uri: "http://just.testing/tos",
|
||||
policy_uri: "https://policy-uri/",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ limitations under the License.
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { OidcTokenRefresher } from "../../../src";
|
||||
import { OidcTokenRefresher, TokenRefreshLogoutError } from "../../../src";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
|
||||
|
||||
@@ -266,5 +266,27 @@ describe("OidcTokenRefresher", () => {
|
||||
refreshToken: "second-new-refresh-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw TokenRefreshLogoutError when expired", async () => {
|
||||
fetchMock.post(
|
||||
config.token_endpoint,
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: {
|
||||
error: "invalid_grant",
|
||||
error_description: "The provided access grant is invalid, expired, or revoked.",
|
||||
},
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
|
||||
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow(TokenRefreshLogoutError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { type IActionsObject, PushProcessor } from "../../src/pushprocessor";
|
||||
import {
|
||||
@@ -1004,3 +1020,25 @@ describe("rewriteDefaultRules", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPushRuleGlobRegex", () => {
|
||||
it("should not confuse flags in cache", () => {
|
||||
const pattern = "Test";
|
||||
const regex1 = PushProcessor.getPushRuleGlobRegex(pattern, false, "i");
|
||||
const regex2 = PushProcessor.getPushRuleGlobRegex(pattern, false, "g");
|
||||
const regex3 = PushProcessor.getPushRuleGlobRegex(pattern, false, "i");
|
||||
|
||||
expect(regex1.flags).toBe("i");
|
||||
expect(regex2.flags).toBe("g");
|
||||
|
||||
expect(regex1).not.toEqual(regex2);
|
||||
expect(regex1).toEqual(regex3);
|
||||
});
|
||||
|
||||
it("should not include word boundary in match", () => {
|
||||
const pattern = "@room";
|
||||
const regex = PushProcessor.getPushRuleGlobRegex(pattern, true);
|
||||
const input = "Foo @room Bar";
|
||||
expect(input.split(regex)).toEqual(["Foo ", "@room", " Bar"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1234,6 +1234,16 @@ describe("Room", function () {
|
||||
expect(room.name).toEqual(nameB);
|
||||
});
|
||||
|
||||
it("supports MSC4186 style heroes", () => {
|
||||
const nameB = "Bertha Bobbington";
|
||||
const nameC = "Clarissa Harissa";
|
||||
addMember(userB, KnownMembership.Join, { name: nameB });
|
||||
addMember(userC, KnownMembership.Join, { name: nameC });
|
||||
room.setMSC4186SummaryData([{ user_id: userB }, { user_id: userC }], undefined, undefined);
|
||||
room.recalculate();
|
||||
expect(room.name).toEqual(`${nameB} and ${nameC}`);
|
||||
});
|
||||
|
||||
it("reverts to empty room in case of self chat", function () {
|
||||
room.setSummary({
|
||||
"m.heroes": [],
|
||||
@@ -4276,4 +4286,9 @@ describe("Room", function () {
|
||||
expect(filteredEvents[0].getContent().body).toEqual("ev2");
|
||||
});
|
||||
});
|
||||
|
||||
it("saves and retrieves the bump stamp", () => {
|
||||
room.setBumpStamp(123456789);
|
||||
expect(room.getBumpStamp()).toEqual(123456789);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2318,6 +2318,43 @@ describe("RustCrypto", () => {
|
||||
expect(dehydratedDeviceIsDeleted).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("disableKeyStorage", () => {
|
||||
it("should disable key storage", async () => {
|
||||
const secretStorage = {
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("bloop"),
|
||||
setDefaultKeyId: jest.fn(),
|
||||
store: jest.fn(),
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
let backupIsDeleted = false;
|
||||
fetchMock.delete("path:/_matrix/client/v3/room_keys/version/1", () => {
|
||||
backupIsDeleted = true;
|
||||
return {};
|
||||
});
|
||||
|
||||
let dehydratedDeviceIsDeleted = false;
|
||||
fetchMock.delete("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => {
|
||||
dehydratedDeviceIsDeleted = true;
|
||||
return { device_id: "ADEVICEID" };
|
||||
});
|
||||
|
||||
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), undefined, undefined, secretStorage);
|
||||
await rustCrypto.disableKeyStorage();
|
||||
|
||||
expect(secretStorage.store).toHaveBeenCalledWith("m.cross_signing.master", null);
|
||||
expect(secretStorage.store).toHaveBeenCalledWith("m.cross_signing.self_signing", null);
|
||||
expect(secretStorage.store).toHaveBeenCalledWith("m.cross_signing.user_signing", null);
|
||||
expect(secretStorage.store).toHaveBeenCalledWith("m.megolm_backup.v1", null);
|
||||
expect(secretStorage.store).toHaveBeenCalledWith("m.secret_storage.key.bloop", null);
|
||||
expect(secretStorage.setDefaultKeyId).toHaveBeenCalledWith(null);
|
||||
|
||||
expect(backupIsDeleted).toBeTruthy();
|
||||
expect(dehydratedDeviceIsDeleted).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Build a MatrixHttpApi instance */
|
||||
|
||||
+47
-10
@@ -240,6 +240,7 @@ import {
|
||||
validateAuthMetadataAndKeys,
|
||||
} from "./oidc/index.ts";
|
||||
import { type EmptyObject } from "./@types/common.ts";
|
||||
import { UnsupportedDelayedEventsEndpointError } from "./errors.ts";
|
||||
|
||||
export type Store = IStore;
|
||||
|
||||
@@ -1233,7 +1234,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public canSupport = new Map<Feature, ServerSupport>();
|
||||
|
||||
// The pushprocessor caches useful things, so keep one and re-use it
|
||||
protected pushProcessor = new PushProcessor(this);
|
||||
public readonly pushProcessor = new PushProcessor(this);
|
||||
|
||||
// Promise to a response of the server's /versions response
|
||||
// TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020
|
||||
@@ -2068,11 +2069,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
/**
|
||||
* Get the config for the media repository.
|
||||
*
|
||||
* @param useAuthenticatedMedia - If true, the caller supports authenticated
|
||||
* media and wants an authentication-required URL. Note that server support
|
||||
* for authenticated media will *not* be checked - it is the caller's responsibility
|
||||
* to do so before calling this function.
|
||||
*
|
||||
* @returns Promise which resolves with an object containing the config.
|
||||
*/
|
||||
public getMediaConfig(): Promise<IMediaConfig> {
|
||||
return this.http.authedRequest(Method.Get, "/config", undefined, undefined, {
|
||||
prefix: MediaPrefix.V3,
|
||||
public getMediaConfig(useAuthenticatedMedia: boolean = false): Promise<IMediaConfig> {
|
||||
const path = useAuthenticatedMedia ? "/media/config" : "/config";
|
||||
return this.http.authedRequest(Method.Get, path, undefined, undefined, {
|
||||
prefix: useAuthenticatedMedia ? ClientPrefix.V1 : MediaPrefix.V3,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3351,7 +3359,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
txnId?: string,
|
||||
): Promise<SendDelayedEventResponse> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw Error("Server does not support the delayed events API");
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"sendDelayedEvent",
|
||||
);
|
||||
}
|
||||
|
||||
this.addThreadRelationIfNeeded(content, threadId, roomId);
|
||||
@@ -3374,7 +3385,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
opts: IRequestOpts = {},
|
||||
): Promise<SendDelayedEventResponse> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw Error("Server does not support the delayed events API");
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"sendDelayedStateEvent",
|
||||
);
|
||||
}
|
||||
|
||||
const pathParams = {
|
||||
@@ -3398,7 +3412,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// eslint-disable-next-line
|
||||
public async _unstable_getDelayedEvents(fromToken?: string): Promise<DelayedEventInfo> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw Error("Server does not support the delayed events API");
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"getDelayedEvents",
|
||||
);
|
||||
}
|
||||
|
||||
const queryDict = fromToken ? { from: fromToken } : undefined;
|
||||
@@ -3420,7 +3437,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
requestOptions: IRequestOpts = {},
|
||||
): Promise<EmptyObject> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw Error("Server does not support the delayed events API");
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"updateDelayedEvent",
|
||||
);
|
||||
}
|
||||
|
||||
const path = utils.encodeUri("/delayed_events/$delayId", {
|
||||
@@ -8028,7 +8048,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches or paginates a room hierarchy as defined by MSC2946.
|
||||
* Reports a room as inappropriate to the server, which may then notify the appropriate people.
|
||||
*
|
||||
* This API was introduced in Matrix v1.13.
|
||||
*
|
||||
* @param roomId - The room being reported.
|
||||
* @param reason - The reason the room is being reported. May be blank.
|
||||
* @returns Promise which resolves to an empty object if successful
|
||||
*/
|
||||
public reportRoom(roomId: string, reason: string): Promise<EmptyObject> {
|
||||
const path = utils.encodeUri("/rooms/$roomId/report", {
|
||||
$roomId: roomId,
|
||||
});
|
||||
|
||||
return this.http.authedRequest(Method.Post, path, undefined, { reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches or paginates a room hierarchy asmatrix-js-sdk/spec/unit/matrix-client.spec.ts defined by MSC2946.
|
||||
* Falls back gracefully to sourcing its data from `getSpaceSummary` if this API is not yet supported by the server.
|
||||
* @param roomId - The ID of the space-room to use as the root of the summary.
|
||||
* @param limit - The maximum number of rooms to return per page.
|
||||
@@ -8164,7 +8201,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const clientTimeout = req.clientTimeout;
|
||||
delete req.clientTimeout;
|
||||
return this.http.authedRequest<MSC3575SlidingSyncResponse>(Method.Post, "/sync", qps, req, {
|
||||
prefix: "/_matrix/client/unstable/org.matrix.msc3575",
|
||||
prefix: "/_matrix/client/unstable/org.matrix.simplified_msc3575",
|
||||
baseUrl: proxyBaseUrl,
|
||||
localTimeoutMs: clientTimeout,
|
||||
abortSignal,
|
||||
|
||||
@@ -138,6 +138,15 @@ export interface SyncCryptoCallbacks {
|
||||
* @param syncState - information about the completed sync.
|
||||
*/
|
||||
onSyncCompleted(syncState: OnSyncCompletedData): void;
|
||||
|
||||
/**
|
||||
* Mark all tracked users' device lists as dirty.
|
||||
*
|
||||
* This method will cause additional `/keys/query` requests on the server, so should be used only
|
||||
* when the client has desynced tracking device list deltas from the server.
|
||||
* In MSC4186: Simplified Sliding Sync, this can happen when the server expires the connection.
|
||||
*/
|
||||
markAllTrackedUsersAsDirty(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -629,6 +629,16 @@ export interface CryptoApi {
|
||||
*/
|
||||
resetKeyBackup(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Disables server-side key storage and deletes server-side backups.
|
||||
* * Deletes the current key backup version, if any (but not any previous versions).
|
||||
* * Disables 4S, deleting the info for the default key, the default key pointer itself and any
|
||||
* known 4S data (cross-signing keys and the megolm key backup key).
|
||||
* * Deletes any dehydrated devices.
|
||||
* * Sets the "m.org.matrix.custom.backup_disabled" account data flag to indicate that the user has disabled backups.
|
||||
*/
|
||||
disableKeyStorage(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deletes the given key backup.
|
||||
*
|
||||
|
||||
+62
-27
@@ -50,12 +50,12 @@ import {
|
||||
} from "./client.ts";
|
||||
import { SyncApi, SyncState } from "./sync.ts";
|
||||
import { SlidingSyncSdk } from "./sliding-sync-sdk.ts";
|
||||
import { MatrixError } from "./http-api/errors.ts";
|
||||
import { ConnectionError, MatrixError } from "./http-api/errors.ts";
|
||||
import { User } from "./models/user.ts";
|
||||
import { type Room } from "./models/room.ts";
|
||||
import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts";
|
||||
import { MapWithDefault, recursiveMapToObject } from "./utils.ts";
|
||||
import { type EmptyObject, TypedEventEmitter } from "./matrix.ts";
|
||||
import { type EmptyObject, TypedEventEmitter, UnsupportedDelayedEventsEndpointError } from "./matrix.ts";
|
||||
|
||||
interface IStateEventRequest {
|
||||
eventType: string;
|
||||
@@ -358,13 +358,15 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
// Delayed event special case.
|
||||
if (delayOpts) {
|
||||
// TODO: updatePendingEvent for delayed events?
|
||||
const response = await this.widgetApi.sendRoomEvent(
|
||||
event.getType(),
|
||||
content,
|
||||
room.roomId,
|
||||
"delay" in delayOpts ? delayOpts.delay : undefined,
|
||||
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
|
||||
);
|
||||
const response = await this.widgetApi
|
||||
.sendRoomEvent(
|
||||
event.getType(),
|
||||
content,
|
||||
room.roomId,
|
||||
"delay" in delayOpts ? delayOpts.delay : undefined,
|
||||
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
|
||||
)
|
||||
.catch(timeoutToConnectionError);
|
||||
return this.validateSendDelayedEventResponse(response);
|
||||
}
|
||||
|
||||
@@ -374,7 +376,9 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
|
||||
let response: ISendEventFromWidgetResponseData;
|
||||
try {
|
||||
response = await this.widgetApi.sendRoomEvent(event.getType(), content, room.roomId);
|
||||
response = await this.widgetApi
|
||||
.sendRoomEvent(event.getType(), content, room.roomId)
|
||||
.catch(timeoutToConnectionError);
|
||||
} catch (e) {
|
||||
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
|
||||
throw e;
|
||||
@@ -397,7 +401,9 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
content: any,
|
||||
stateKey = "",
|
||||
): Promise<ISendEventResponse> {
|
||||
const response = await this.widgetApi.sendStateEvent(eventType, stateKey, content, roomId);
|
||||
const response = await this.widgetApi
|
||||
.sendStateEvent(eventType, stateKey, content, roomId)
|
||||
.catch(timeoutToConnectionError);
|
||||
if (response.event_id === undefined) {
|
||||
throw new Error("'event_id' absent from response to an event request");
|
||||
}
|
||||
@@ -416,17 +422,22 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
stateKey = "",
|
||||
): Promise<SendDelayedEventResponse> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw Error("Server does not support the delayed events API");
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"sendDelayedStateEvent",
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.widgetApi.sendStateEvent(
|
||||
eventType,
|
||||
stateKey,
|
||||
content,
|
||||
roomId,
|
||||
"delay" in delayOpts ? delayOpts.delay : undefined,
|
||||
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
|
||||
);
|
||||
const response = await this.widgetApi
|
||||
.sendStateEvent(
|
||||
eventType,
|
||||
stateKey,
|
||||
content,
|
||||
roomId,
|
||||
"delay" in delayOpts ? delayOpts.delay : undefined,
|
||||
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
|
||||
)
|
||||
.catch(timeoutToConnectionError);
|
||||
return this.validateSendDelayedEventResponse(response);
|
||||
}
|
||||
|
||||
@@ -443,20 +454,25 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
// eslint-disable-next-line
|
||||
public async _unstable_updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise<EmptyObject> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw Error("Server does not support the delayed events API");
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"updateDelayedEvent",
|
||||
);
|
||||
}
|
||||
|
||||
await this.widgetApi.updateDelayedEvent(delayId, action);
|
||||
await this.widgetApi.updateDelayedEvent(delayId, action).catch(timeoutToConnectionError);
|
||||
return {};
|
||||
}
|
||||
|
||||
public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<EmptyObject> {
|
||||
await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap));
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, false, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getOpenIdToken(): Promise<IOpenIDToken> {
|
||||
const token = await this.widgetApi.requestOpenIDConnectToken();
|
||||
const token = await this.widgetApi.requestOpenIDConnectToken().catch(timeoutToConnectionError);
|
||||
// the IOpenIDCredentials from the widget-api and IOpenIDToken form the matrix-js-sdk are compatible.
|
||||
// we still recreate the token to make this transparent and catch'able by the linter in case the types change in the future.
|
||||
return <IOpenIDToken>{
|
||||
@@ -474,7 +490,9 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
contentMap.getOrCreate(userId).set(deviceId, payload);
|
||||
}
|
||||
|
||||
await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap));
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, false, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
public async encryptAndSendToDevices(userDeviceInfoArr: OlmDevice[], payload: object): Promise<void> {
|
||||
@@ -484,7 +502,9 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
contentMap.getOrCreate(userId).set(deviceId, payload);
|
||||
}
|
||||
|
||||
await this.widgetApi.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap));
|
||||
await this.widgetApi
|
||||
.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -505,7 +525,9 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
encrypted: boolean,
|
||||
contentMap: SendToDeviceContentMap,
|
||||
): Promise<void> {
|
||||
await this.widgetApi.sendToDevice(eventType, encrypted, recursiveMapToObject(contentMap));
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, encrypted, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
// Overridden since we get TURN servers automatically over the widget API,
|
||||
@@ -670,3 +692,16 @@ function processAndThrow(error: unknown): never {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This converts an "Request timed out" error from the PostmessageTransport into a ConnectionError.
|
||||
* It either throws the original error or a new ConnectionError.
|
||||
**/
|
||||
function timeoutToConnectionError(error: unknown): never {
|
||||
// TODO: this should not check on error.message but instead it should be a specific type
|
||||
// error instanceof WidgetTimeoutError
|
||||
if (error instanceof Error && error.message === "Request timed out") {
|
||||
throw new ConnectionError("widget api timeout");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -52,3 +52,16 @@ export class ClientStoppedError extends Error {
|
||||
super("MatrixClient has been stopped");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This error is thrown when the Homeserver does not support the delayed events enpdpoints.
|
||||
*/
|
||||
export class UnsupportedDelayedEventsEndpointError extends Error {
|
||||
public constructor(
|
||||
message: string,
|
||||
public clientEndpoint: "sendDelayedEvent" | "updateDelayedEvent" | "sendDelayedStateEvent" | "getDelayedEvents",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "UnsupportedDelayedEventsEndpointError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,3 +212,17 @@ export class TokenRefreshError extends Error {
|
||||
return "TokenRefreshError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a TokenRefreshError. This indicates that a request failed due to the token being expired,
|
||||
* and attempting to refresh said token failed in a way indicative of token invalidation.
|
||||
*/
|
||||
export class TokenRefreshLogoutError extends Error {
|
||||
public constructor(cause?: Error) {
|
||||
super(cause?.message ?? "");
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return "TokenRefreshLogoutError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,10 @@ limitations under the License.
|
||||
* This is an internal module. See {@link MatrixHttpApi} for the public class.
|
||||
*/
|
||||
|
||||
import { ErrorResponse as OidcAuthError } from "oidc-client-ts";
|
||||
|
||||
import { checkObjectHasKeys, encodeParams } from "../utils.ts";
|
||||
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { Method } from "./method.ts";
|
||||
import { ConnectionError, MatrixError, TokenRefreshError } from "./errors.ts";
|
||||
import { ConnectionError, MatrixError, TokenRefreshError, TokenRefreshLogoutError } from "./errors.ts";
|
||||
import {
|
||||
HttpApiEvent,
|
||||
type HttpApiEventHandlerMap,
|
||||
@@ -39,9 +37,9 @@ interface TypedResponse<T> extends Response {
|
||||
json(): Promise<T>;
|
||||
}
|
||||
|
||||
export type ResponseType<T, O extends IHttpOpts> = O extends undefined
|
||||
? T
|
||||
: O extends { onlyData: true }
|
||||
export type ResponseType<T, O extends IHttpOpts> = O extends { json: false }
|
||||
? string
|
||||
: O extends { onlyData: true } | undefined
|
||||
? T
|
||||
: TypedResponse<T>;
|
||||
|
||||
@@ -234,7 +232,8 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
return TokenRefreshOutcome.Success;
|
||||
} catch (error) {
|
||||
this.opts.logger?.warn("Failed to refresh token", error);
|
||||
if (error instanceof OidcAuthError || error instanceof MatrixError) {
|
||||
// If we get a TokenError or MatrixError, we should log out, otherwise assume transient
|
||||
if (error instanceof TokenRefreshLogoutError || error instanceof MatrixError) {
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
return TokenRefreshOutcome.Failure;
|
||||
@@ -371,7 +370,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
}
|
||||
|
||||
if (this.opts.onlyData) {
|
||||
return json ? res.json() : res.text();
|
||||
return (json ? res.json() : res.text()) as ResponseType<T, O>;
|
||||
}
|
||||
return res as ResponseType<T, O>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
/*
|
||||
Copyright 2025 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 { EventType } from "../@types/event.ts";
|
||||
import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
||||
import type { MatrixClient } from "../client.ts";
|
||||
@@ -11,44 +27,7 @@ import { type Focus } from "./focus.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type MembershipConfig } from "./MatrixRTCSession.ts";
|
||||
import { type EmptyObject } from "../@types/common.ts";
|
||||
/**
|
||||
* This interface defines what a MembershipManager uses and exposes.
|
||||
* This interface is what we use to write tests and allows to change the actual implementation
|
||||
* Without breaking tests because of some internal method renaming.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface IMembershipManager {
|
||||
/**
|
||||
* If we are trying to join the session.
|
||||
* It does not reflect if the room state is already configures to represent us being joined.
|
||||
* It only means that the Manager is running.
|
||||
* @returns true if we intend to be participating in the MatrixRTC session
|
||||
*/
|
||||
isJoined(): boolean;
|
||||
/**
|
||||
* Start sending all necessary events to make this user participant in the RTC session.
|
||||
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||
* @param fociActive the active focus to use in the joined RTC membership event.
|
||||
*/
|
||||
join(fociPreferred: Focus[], fociActive?: Focus): void;
|
||||
/**
|
||||
* Send all necessary events to make this user leave the RTC session.
|
||||
* @param timeout the maximum duration in ms until the promise is forced to resolve.
|
||||
* @returns It resolves with true in case the leave was sent successfully.
|
||||
* It resolves with false in case we hit the timeout before sending successfully.
|
||||
*/
|
||||
leave(timeout?: number): Promise<boolean>;
|
||||
/**
|
||||
* Call this if the MatrixRTC session members have changed.
|
||||
*/
|
||||
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
|
||||
/**
|
||||
* The used active focus in the currently joined session.
|
||||
* @returns the used active focus in the currently joined session or undefined if not joined.
|
||||
*/
|
||||
getActiveFocus(): Focus | undefined;
|
||||
}
|
||||
import { type IMembershipManager, type MembershipManagerEvent, Status } from "./types.ts";
|
||||
|
||||
/**
|
||||
* This internal class is used by the MatrixRTCSession to manage the local user's own membership of the session.
|
||||
@@ -64,7 +43,8 @@ export interface IMembershipManager {
|
||||
*
|
||||
* It is recommended to only use this interface for testing to allow replacing this class.
|
||||
*
|
||||
* @internal
|
||||
* @internal
|
||||
* @deprecated Use {@link MembershipManager} instead
|
||||
*/
|
||||
export class LegacyMembershipManager implements IMembershipManager {
|
||||
private relativeExpiry: number | undefined;
|
||||
@@ -123,9 +103,35 @@ export class LegacyMembershipManager implements IMembershipManager {
|
||||
private getOldestMembership: () => CallMembership | undefined,
|
||||
) {}
|
||||
|
||||
public off(
|
||||
event: MembershipManagerEvent.StatusChanged,
|
||||
listener: (oldStatus: Status, newStatus: Status) => void,
|
||||
): this {
|
||||
logger.error("off is not implemented on LegacyMembershipManager");
|
||||
return this;
|
||||
}
|
||||
|
||||
public on(
|
||||
event: MembershipManagerEvent.StatusChanged,
|
||||
listener: (oldStatus: Status, newStatus: Status) => void,
|
||||
): this {
|
||||
logger.error("on is not implemented on LegacyMembershipManager");
|
||||
return this;
|
||||
}
|
||||
|
||||
public isJoined(): boolean {
|
||||
return this.relativeExpiry !== undefined;
|
||||
}
|
||||
public isActivated(): boolean {
|
||||
return this.isJoined();
|
||||
}
|
||||
/**
|
||||
* Unimplemented
|
||||
* @returns Status.Unknown
|
||||
*/
|
||||
public get status(): Status {
|
||||
return Status.Unknown;
|
||||
}
|
||||
|
||||
public join(fociPreferred: Focus[], fociActive?: Focus): void {
|
||||
this.ownFocusActive = fociActive;
|
||||
@@ -25,8 +25,11 @@ import { RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { KnownMembership } from "../@types/membership.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { LegacyMembershipManager, type IMembershipManager } from "./MembershipManager.ts";
|
||||
import { MembershipManager } from "./NewMembershipManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager, type Statistics } from "./EncryptionManager.ts";
|
||||
import { LegacyMembershipManager } from "./LegacyMembershipManager.ts";
|
||||
import { logDurationSync } from "../utils.ts";
|
||||
import type { IMembershipManager } from "./types.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
|
||||
@@ -39,6 +42,8 @@ export enum MatrixRTCSessionEvent {
|
||||
JoinStateChanged = "join_state_changed",
|
||||
// The key used to encrypt media has changed
|
||||
EncryptionKeyChanged = "encryption_key_changed",
|
||||
/** The membership manager had to shut down caused by an unrecoverable error */
|
||||
MembershipManagerError = "membership_manager_error",
|
||||
}
|
||||
|
||||
export type MatrixRTCSessionEventHandlerMap = {
|
||||
@@ -52,9 +57,17 @@ export type MatrixRTCSessionEventHandlerMap = {
|
||||
encryptionKeyIndex: number,
|
||||
participantId: string,
|
||||
) => void;
|
||||
[MatrixRTCSessionEvent.MembershipManagerError]: (error: unknown) => void;
|
||||
};
|
||||
|
||||
export interface MembershipConfig {
|
||||
/**
|
||||
* Use the new Manager.
|
||||
*
|
||||
* Default: `false`.
|
||||
*/
|
||||
useNewMembershipManager?: boolean;
|
||||
|
||||
/**
|
||||
* The timeout (in milliseconds) after we joined the call, that our membership should expire
|
||||
* unless we have explicitly updated it.
|
||||
@@ -63,6 +76,17 @@ export interface MembershipConfig {
|
||||
*/
|
||||
membershipExpiryTimeout?: number;
|
||||
|
||||
/**
|
||||
* The time in (in milliseconds) which the manager will prematurely send the updated state event before the membership `expires` time to make sure it
|
||||
* sends the updated state event early enough.
|
||||
*
|
||||
* A headroom of 1000ms and a `membershipExpiryTimeout` of 10000ms would result in the first membership event update after 9s and
|
||||
* a membership event that would be considered expired after 10s.
|
||||
*
|
||||
* This value does not have an effect on the value of `SessionMembershipData.expires`.
|
||||
*/
|
||||
membershipExpiryTimeoutHeadroom?: number;
|
||||
|
||||
/**
|
||||
* The period (in milliseconds) with which we check that our membership event still exists on the
|
||||
* server. If it is not found we create it again.
|
||||
@@ -90,6 +114,16 @@ export interface MembershipConfig {
|
||||
* @deprecated It should be possible to make it stable without this.
|
||||
*/
|
||||
callMemberEventRetryJitter?: number;
|
||||
|
||||
/**
|
||||
* The maximum number of retries that the manager will do for delayed event sending/updating and state event sending when a server rate limit has been hit.
|
||||
*/
|
||||
maximumRateLimitRetryCount?: number;
|
||||
|
||||
/**
|
||||
* The maximum number of retries that the manager will do for delayed event sending/updating and state event sending when a network error occurs.
|
||||
*/
|
||||
maximumNetworkErrorRetryCount?: number;
|
||||
}
|
||||
|
||||
export interface EncryptionConfig {
|
||||
@@ -318,18 +352,28 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
* @param joinConfig - Additional configuration for the joined session.
|
||||
*/
|
||||
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
|
||||
// create MembershipManager
|
||||
if (this.isJoined()) {
|
||||
logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
|
||||
return;
|
||||
} else {
|
||||
this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () =>
|
||||
this.getOldestMembership(),
|
||||
);
|
||||
// Create MembershipManager
|
||||
if (joinConfig?.useNewMembershipManager ?? false) {
|
||||
this.membershipManager = new MembershipManager(joinConfig, this.roomSubset, this.client, () =>
|
||||
this.getOldestMembership(),
|
||||
);
|
||||
} else {
|
||||
this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () =>
|
||||
this.getOldestMembership(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Join!
|
||||
this.membershipManager!.join(fociPreferred, fociActive);
|
||||
this.membershipManager!.join(fociPreferred, fociActive, (e) => {
|
||||
logger.error("MembershipManager encountered an unrecoverable error: ", e);
|
||||
this.emit(MatrixRTCSessionEvent.MembershipManagerError, e);
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, this.isJoined());
|
||||
});
|
||||
this.encryptionManager!.join(joinConfig);
|
||||
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
|
||||
@@ -492,7 +536,9 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
|
||||
if (changed) {
|
||||
logger.info(`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting`);
|
||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
|
||||
logDurationSync(logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => {
|
||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
|
||||
});
|
||||
|
||||
void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,898 @@
|
||||
/*
|
||||
Copyright 2025 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 { EventType } from "../@types/event.ts";
|
||||
import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
||||
import { type MatrixClient } from "../client.ts";
|
||||
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
|
||||
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { defer, type IDeferred } from "../utils.ts";
|
||||
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import {
|
||||
type IMembershipManager,
|
||||
type MembershipManagerEventHandlerMap,
|
||||
MembershipManagerEvent,
|
||||
Status,
|
||||
} from "./types.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type MembershipConfig } from "./MatrixRTCSession.ts";
|
||||
import { ActionScheduler, type ActionUpdate } from "./NewMembershipManagerActionScheduler.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
|
||||
/* MembershipActionTypes:
|
||||
|
||||
On Join: ───────────────┐ ┌───────────────(1)───────────┐
|
||||
▼ ▼ │
|
||||
┌────────────────┐ │
|
||||
│SendDelayedEvent│ ──────(2)───┐ │
|
||||
└────────────────┘ │ │
|
||||
│(3) │ │
|
||||
▼ │ │
|
||||
┌─────────────┐ │ │
|
||||
┌──────(4)───│SendJoinEvent│────(4)─────┐ │ │
|
||||
│ └─────────────┘ │ │ │
|
||||
│ ┌─────┐ ┌──────┐ │ │ │
|
||||
▼ ▼ │ │ ▼ ▼ ▼ │
|
||||
┌────────────┐ │ │ ┌───────────────────┐ │
|
||||
│UpdateExpiry│ (s) (s)|RestartDelayedEvent│ │
|
||||
└────────────┘ │ │ └───────────────────┘ │
|
||||
│ │ │ │ │ │
|
||||
└─────┘ └──────┘ └───────┘
|
||||
|
||||
On Leave: ───────── STOP ALL ABOVE
|
||||
▼
|
||||
┌────────────────────────────────┐
|
||||
│ SendScheduledDelayedLeaveEvent │
|
||||
└────────────────────────────────┘
|
||||
│(5)
|
||||
▼
|
||||
┌──────────────┐
|
||||
│SendLeaveEvent│
|
||||
└──────────────┘
|
||||
(1) [Not found error] results in resending the delayed event
|
||||
(2) [hasMemberEvent = true] Sending the delayed event if we
|
||||
already have a call member event results jumping to the
|
||||
RestartDelayedEvent loop directly
|
||||
(3) [hasMemberEvent = false] if there is not call member event
|
||||
sending it is the next step
|
||||
(4) Both (UpdateExpiry and RestartDelayedEvent) actions are
|
||||
scheduled when successfully sending the state event
|
||||
(5) Only if delayed event sending failed (fallback)
|
||||
(s) Successful restart/resend
|
||||
*/
|
||||
|
||||
/**
|
||||
* The different types of actions the MembershipManager can take.
|
||||
* @internal
|
||||
*/
|
||||
export enum MembershipActionType {
|
||||
SendDelayedEvent = "SendDelayedEvent",
|
||||
// -> MembershipActionType.SendJoinEvent if successful
|
||||
// -> DelayedLeaveActionType.SendDelayedEvent on error, retry sending the first delayed event.
|
||||
// -> DelayedLeaveActionType.RestartDelayedEvent on success start updating the delayed event
|
||||
SendJoinEvent = "SendJoinEvent",
|
||||
// -> MembershipActionType.SendJoinEvent if we run into a rate limit and need to retry
|
||||
// -> MembershipActionType.Update if we successfully send the join event then schedule the expire event update
|
||||
// -> DelayedLeaveActionType.RestartDelayedEvent to recheck the delayed event
|
||||
RestartDelayedEvent = "RestartDelayedEvent",
|
||||
// -> DelayedLeaveActionType.SendMainDelayedEvent on missing delay id but there is a rtc state event
|
||||
// -> DelayedLeaveActionType.SendDelayedEvent on missing delay id and there is no state event
|
||||
// -> DelayedLeaveActionType.RestartDelayedEvent on success we schedule the next restart
|
||||
UpdateExpiry = "UpdateExpiry",
|
||||
// -> MembershipActionType.Update if the timeout has passed so the next update is required.
|
||||
SendScheduledDelayedLeaveEvent = "SendScheduledDelayedLeaveEvent",
|
||||
// -> MembershipActionType.SendLeaveEvent on failiour (not found) we need to send the leave manually and cannot use the scheduled delayed event
|
||||
// -> DelayedLeaveActionType.SendScheduledDelayedLeaveEvent on error we try again.
|
||||
SendLeaveEvent = "SendLeaveEvent",
|
||||
// -> MembershipActionType.SendLeaveEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface MembershipManagerState {
|
||||
/** The delayId we got when successfully sending the delayed leave event.
|
||||
* Gets set to undefined if the server claims it cannot find the delayed event anymore. */
|
||||
delayId?: string;
|
||||
/** Stores how often we have update the `expires` field.
|
||||
* `expireUpdateIterations` * `membershipEventExpiryTimeout` resolves to the value the expires field should contain next */
|
||||
expireUpdateIterations: number;
|
||||
/** The time at which we send the first state event. The time the call started from the DAG point of view.
|
||||
* This is used to compute the local sleep timestamps when to next update the member event with a new expires value. */
|
||||
startTime: number;
|
||||
/** The manager is in the state where its actually connected to the session. */
|
||||
hasMemberStateEvent: boolean;
|
||||
// There can be multiple retries at once so we need to store counters per action
|
||||
// e.g. the send update membership and the restart delayed could be rate limited at the same time.
|
||||
/** Retry counter for rate limits */
|
||||
rateLimitRetries: Map<MembershipActionType, number>;
|
||||
/** Retry counter for other errors */
|
||||
networkErrorRetries: Map<MembershipActionType, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is responsible for sending all events relating to the own membership of a matrixRTC call.
|
||||
* It has the following tasks:
|
||||
* - Send the users leave delayed event before sending the membership
|
||||
* - Send the users membership if the state machine is started
|
||||
* - Check if the delayed event was canceled due to sending the membership
|
||||
* - update the delayed event (`restart`)
|
||||
* - Update the state event every ~5h = `DEFAULT_EXPIRE_DURATION` (so it does not get treated as expired)
|
||||
* - When the state machine is stopped:
|
||||
* - Disconnect the member
|
||||
* - Stop the timer for the delay refresh
|
||||
* - Stop the timer for updating the state event
|
||||
*/
|
||||
export class MembershipManager
|
||||
extends TypedEventEmitter<MembershipManagerEvent, MembershipManagerEventHandlerMap>
|
||||
implements IMembershipManager
|
||||
{
|
||||
private activated = false;
|
||||
public isActivated(): boolean {
|
||||
return this.activated;
|
||||
}
|
||||
// DEPRECATED use isActivated
|
||||
public isJoined(): boolean {
|
||||
return this.isActivated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the MembershipManager in a state where it tries to be joined.
|
||||
* It will send delayed events and membership events
|
||||
* @param fociPreferred
|
||||
* @param focusActive
|
||||
* @param onError This will be called once the membership manager encounters an unrecoverable error.
|
||||
* This should bubble up the the frontend to communicate that the call does not work in the current environment.
|
||||
*/
|
||||
public join(fociPreferred: Focus[], focusActive?: Focus, onError?: (error: unknown) => void): void {
|
||||
if (this.scheduler.running) {
|
||||
logger.error("MembershipManager is already running. Ignoring join request.");
|
||||
return;
|
||||
}
|
||||
this.fociPreferred = fociPreferred;
|
||||
this.focusActive = focusActive;
|
||||
this.leavePromiseDefer = undefined;
|
||||
this.activated = true;
|
||||
this.oldStatus = this.status;
|
||||
this.state = MembershipManager.defaultState;
|
||||
|
||||
this.scheduler
|
||||
.startWithJoin()
|
||||
.catch((e) => {
|
||||
logger.error("MembershipManager stopped because: ", e);
|
||||
onError?.(e);
|
||||
})
|
||||
.finally(() => {
|
||||
// Should already be set to false when calling `leave` in non error cases.
|
||||
this.activated = false;
|
||||
// Here the scheduler is not running anymore so we the `membershipLoopHandler` is not called to emit.
|
||||
if (this.oldStatus && this.oldStatus !== this.status) {
|
||||
this.emit(MembershipManagerEvent.StatusChanged, this.oldStatus, this.status);
|
||||
}
|
||||
if (!this.scheduler.running) {
|
||||
this.leavePromiseDefer?.resolve(true);
|
||||
this.leavePromiseDefer = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave from the call (Send an rtc session event with content: `{}`)
|
||||
* @param timeout the maximum duration this promise will take to resolve
|
||||
* @returns true if it managed to leave and false if the timeout condition happened.
|
||||
*/
|
||||
public leave(timeout?: number): Promise<boolean> {
|
||||
if (!this.scheduler.running) {
|
||||
logger.warn("Called MembershipManager.leave() even though the MembershipManager is not running");
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
// We use the promise to track if we already scheduled a leave event
|
||||
// So we do not check scheduler.actions/scheduler.insertions
|
||||
if (!this.leavePromiseDefer) {
|
||||
// reset scheduled actions so we will not do any new actions.
|
||||
this.leavePromiseDefer = defer<boolean>();
|
||||
this.activated = false;
|
||||
this.scheduler.initiateLeave();
|
||||
if (timeout) setTimeout(() => this.leavePromiseDefer?.resolve(false), timeout);
|
||||
}
|
||||
return this.leavePromiseDefer.promise;
|
||||
}
|
||||
private leavePromiseDefer?: IDeferred<boolean>;
|
||||
|
||||
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
|
||||
const isMyMembership = (m: CallMembership): boolean =>
|
||||
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
|
||||
|
||||
if (this.isJoined() && !memberships.some(isMyMembership)) {
|
||||
// If one of these actions are scheduled or are getting inserted in the next iteration, we should already
|
||||
// take care of our missing membership.
|
||||
const sendingMembershipActions = [
|
||||
MembershipActionType.SendDelayedEvent,
|
||||
MembershipActionType.SendJoinEvent,
|
||||
];
|
||||
logger.warn("Missing own membership: force re-join");
|
||||
if (this.scheduler.actions.find((a) => sendingMembershipActions.includes(a.type as MembershipActionType))) {
|
||||
logger.error(
|
||||
"NewMembershipManger tried adding another `SendFirstDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:",
|
||||
this.scheduler.actions,
|
||||
);
|
||||
} else {
|
||||
// Only react to our own membership missing if we have not already scheduled sending a new membership DirectMembershipManagerAction.Join
|
||||
this.state.hasMemberStateEvent = false;
|
||||
this.scheduler.initiateJoin();
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public getActiveFocus(): Focus | undefined {
|
||||
if (this.focusActive) {
|
||||
// A livekit active focus
|
||||
if (isLivekitFocusActive(this.focusActive)) {
|
||||
if (this.focusActive.focus_selection === "oldest_membership") {
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
} else {
|
||||
logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
|
||||
}
|
||||
} else {
|
||||
// We do not understand the membership format (could be legacy). We default to oldestMembership
|
||||
// Once there are other methods this is a hard error!
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws if the client does not return user or device id.
|
||||
* @param joinConfig
|
||||
* @param room
|
||||
* @param client
|
||||
* @param getOldestMembership
|
||||
*/
|
||||
public constructor(
|
||||
private joinConfig: MembershipConfig | undefined,
|
||||
private room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
|
||||
private client: Pick<
|
||||
MatrixClient,
|
||||
| "getUserId"
|
||||
| "getDeviceId"
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
>,
|
||||
private getOldestMembership: () => CallMembership | undefined,
|
||||
) {
|
||||
super();
|
||||
const [userId, deviceId] = [this.client.getUserId(), this.client.getDeviceId()];
|
||||
if (userId === null) throw Error("Missing userId in client");
|
||||
if (deviceId === null) throw Error("Missing deviceId in client");
|
||||
this.deviceId = deviceId;
|
||||
this.stateKey = this.makeMembershipStateKey(userId, deviceId);
|
||||
this.state = MembershipManager.defaultState;
|
||||
}
|
||||
|
||||
// MembershipManager mutable state.
|
||||
private state: MembershipManagerState;
|
||||
private static get defaultState(): MembershipManagerState {
|
||||
return {
|
||||
hasMemberStateEvent: false,
|
||||
delayId: undefined,
|
||||
|
||||
startTime: 0,
|
||||
rateLimitRetries: new Map(),
|
||||
networkErrorRetries: new Map(),
|
||||
expireUpdateIterations: 1,
|
||||
};
|
||||
}
|
||||
// Membership Event static parameters:
|
||||
private deviceId: string;
|
||||
private stateKey: string;
|
||||
private fociPreferred?: Focus[];
|
||||
private focusActive?: Focus;
|
||||
|
||||
// Config:
|
||||
private membershipServerSideExpiryTimeoutOverride?: number;
|
||||
|
||||
private get callMemberEventRetryDelayMinimum(): number {
|
||||
return this.joinConfig?.callMemberEventRetryDelayMinimum ?? 3_000;
|
||||
}
|
||||
private get membershipEventExpiryTimeout(): number {
|
||||
return this.joinConfig?.membershipExpiryTimeout ?? DEFAULT_EXPIRE_DURATION;
|
||||
}
|
||||
private get membershipEventExpiryTimeoutHeadroom(): number {
|
||||
return this.joinConfig?.membershipExpiryTimeoutHeadroom ?? 5_000;
|
||||
}
|
||||
private computeNextExpiryActionTs(iteration: number): number {
|
||||
return (
|
||||
this.state.startTime +
|
||||
this.membershipEventExpiryTimeout * iteration -
|
||||
this.membershipEventExpiryTimeoutHeadroom
|
||||
);
|
||||
}
|
||||
private get membershipServerSideExpiryTimeout(): number {
|
||||
return (
|
||||
this.membershipServerSideExpiryTimeoutOverride ??
|
||||
this.joinConfig?.membershipServerSideExpiryTimeout ??
|
||||
8_000
|
||||
);
|
||||
}
|
||||
private get membershipKeepAlivePeriod(): number {
|
||||
return this.joinConfig?.membershipKeepAlivePeriod ?? 5_000;
|
||||
}
|
||||
private get maximumRateLimitRetryCount(): number {
|
||||
return this.joinConfig?.maximumRateLimitRetryCount ?? 10;
|
||||
}
|
||||
private get maximumNetworkErrorRetryCount(): number {
|
||||
return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10;
|
||||
}
|
||||
|
||||
// Scheduler:
|
||||
private oldStatus?: Status;
|
||||
private scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
|
||||
if (this.oldStatus) {
|
||||
// we put this at the beginning of the actions scheduler loop handle callback since it is a loop this
|
||||
// is equivalent to running it at the end of the loop. (just after applying the status/action list changes)
|
||||
// This order is required because this method needs to return the action updates.
|
||||
logger.debug(`MembershipManager applied action changes. Status: ${this.oldStatus} -> ${this.status}`);
|
||||
if (this.oldStatus !== this.status) {
|
||||
this.emit(MembershipManagerEvent.StatusChanged, this.oldStatus, this.status);
|
||||
}
|
||||
}
|
||||
this.oldStatus = this.status;
|
||||
logger.debug(`MembershipManager before processing action. status=${this.oldStatus}`);
|
||||
return this.membershipLoopHandler(type);
|
||||
});
|
||||
|
||||
// LOOP HANDLER:
|
||||
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
|
||||
switch (type) {
|
||||
case MembershipActionType.SendDelayedEvent: {
|
||||
// Before we start we check if we come from a state where we have a delay id.
|
||||
if (!this.state.delayId) {
|
||||
return this.sendOrResendDelayedLeaveEvent(); // Normal case without any previous delayed id.
|
||||
} else {
|
||||
// This can happen if someone else (or another client) removes our own membership event.
|
||||
// It will trigger `onRTCSessionMemberUpdate` queue `MembershipActionType.SendFirstDelayedEvent`.
|
||||
// We might still have our delayed event from the previous participation and dependent on the server this might not
|
||||
// get removed automatically if the state changes. Hence, it would remove our membership unexpectedly shortly after the rejoin.
|
||||
//
|
||||
// In this block we will try to cancel this delayed event before setting up a new one.
|
||||
|
||||
return this.cancelKnownDelayIdBeforeSendFirstDelayedEvent(this.state.delayId);
|
||||
}
|
||||
}
|
||||
case MembershipActionType.RestartDelayedEvent: {
|
||||
if (!this.state.delayId) {
|
||||
// Delay id got reset. This action was used to check if the hs canceled the delayed event when the join state got sent.
|
||||
return createInsertActionUpdate(MembershipActionType.SendDelayedEvent);
|
||||
}
|
||||
return this.restartDelayedEvent(this.state.delayId);
|
||||
}
|
||||
case MembershipActionType.SendScheduledDelayedLeaveEvent: {
|
||||
// We are already good
|
||||
if (!this.state.hasMemberStateEvent) {
|
||||
return { replace: [] };
|
||||
}
|
||||
if (this.state.delayId) {
|
||||
return this.sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(this.state.delayId);
|
||||
} else {
|
||||
return createInsertActionUpdate(MembershipActionType.SendLeaveEvent);
|
||||
}
|
||||
}
|
||||
case MembershipActionType.SendJoinEvent: {
|
||||
return this.sendJoinEvent();
|
||||
}
|
||||
case MembershipActionType.UpdateExpiry: {
|
||||
return this.updateExpiryOnJoinedEvent();
|
||||
}
|
||||
case MembershipActionType.SendLeaveEvent: {
|
||||
// We are good already
|
||||
if (!this.state.hasMemberStateEvent) {
|
||||
return { replace: [] };
|
||||
}
|
||||
// This is only a fallback in case we do not have working delayed events support.
|
||||
// first we should try to just send the scheduled leave event
|
||||
return this.sendFallbackLeaveEvent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HANDLERS (used in the membershipLoopHandler)
|
||||
private async sendOrResendDelayedLeaveEvent(): Promise<ActionUpdate> {
|
||||
// We can reach this at the start of a call (where we do not yet have a membership: state.hasMemberStateEvent=false)
|
||||
// or during a call if the state event canceled our delayed event or caused by an unexpected error that removed our delayed event.
|
||||
// (Another client could have canceled it, the homeserver might have removed/lost it due to a restart, ...)
|
||||
// In the `then` and `catch` block we treat both cases differently. "if (this.state.hasMemberStateEvent) {} else {}"
|
||||
return await this.client
|
||||
._unstable_sendDelayedStateEvent(
|
||||
this.room.roomId,
|
||||
{
|
||||
delay: this.membershipServerSideExpiryTimeout,
|
||||
},
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{}, // leave event
|
||||
this.stateKey,
|
||||
)
|
||||
.then((response) => {
|
||||
// On success we reset retries and set delayId.
|
||||
this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
|
||||
this.state.delayId = response.delay_id;
|
||||
if (this.state.hasMemberStateEvent) {
|
||||
// This action was scheduled because the previous delayed event was cancelled
|
||||
// due to lack of https://github.com/element-hq/synapse/pull/17810
|
||||
return createInsertActionUpdate(
|
||||
MembershipActionType.RestartDelayedEvent,
|
||||
this.membershipKeepAlivePeriod,
|
||||
);
|
||||
} else {
|
||||
// This action was scheduled because we are in the process of joining
|
||||
return createInsertActionUpdate(MembershipActionType.SendJoinEvent);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
const repeatActionType = MembershipActionType.SendDelayedEvent;
|
||||
if (this.manageMaxDelayExceededSituation(e)) {
|
||||
return createInsertActionUpdate(repeatActionType);
|
||||
}
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "sendDelayedStateEvent");
|
||||
if (update) return update;
|
||||
|
||||
if (this.state.hasMemberStateEvent) {
|
||||
// This action was scheduled because the previous delayed event was cancelled
|
||||
// due to lack of https://github.com/element-hq/synapse/pull/17810
|
||||
|
||||
// Don't do any other delayed event work if its not supported.
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) return {};
|
||||
throw Error("Could not send delayed event, even though delayed events are supported. " + e);
|
||||
} else {
|
||||
// This action was scheduled because we are in the process of joining
|
||||
// log and fall through
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) {
|
||||
logger.info("Not using delayed event because the endpoint is not supported");
|
||||
} else {
|
||||
logger.info("Not using delayed event because: " + e);
|
||||
}
|
||||
// On any other error we fall back to not using delayed events and send the join state event immediately
|
||||
return createInsertActionUpdate(MembershipActionType.SendJoinEvent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async cancelKnownDelayIdBeforeSendFirstDelayedEvent(delayId: string): Promise<ActionUpdate> {
|
||||
// Remove all running updates and restarts
|
||||
return await this.client
|
||||
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel)
|
||||
.then(() => {
|
||||
this.state.delayId = undefined;
|
||||
this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
|
||||
return createReplaceActionUpdate(MembershipActionType.SendDelayedEvent);
|
||||
})
|
||||
.catch((e) => {
|
||||
const repeatActionType = MembershipActionType.SendDelayedEvent;
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
|
||||
if (update) return update;
|
||||
|
||||
if (this.isNotFoundError(e)) {
|
||||
// If we get a M_NOT_FOUND we know that the delayed event got already removed.
|
||||
// This means we are good and can set it to undefined and run this again.
|
||||
this.state.delayId = undefined;
|
||||
return createReplaceActionUpdate(repeatActionType);
|
||||
}
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) {
|
||||
return createReplaceActionUpdate(MembershipActionType.SendJoinEvent);
|
||||
}
|
||||
// We do not just ignore and log this error since we would also need to reset the delayId.
|
||||
|
||||
// This becomes an unrecoverable error case since something is significantly off if we don't hit any of the above cases
|
||||
// when state.delayId !== undefined
|
||||
// We do not just ignore and log this error since we would also need to reset the delayId.
|
||||
// It is cleaner if we, the frontend, rejoins instead of resetting the delayId here and behaving like in the success case.
|
||||
throw Error(
|
||||
"We failed to cancel a delayed event where we already had a delay id with an error we cannot automatically handle",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async restartDelayedEvent(delayId: string): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart)
|
||||
.then(() => {
|
||||
this.resetRateLimitCounter(MembershipActionType.RestartDelayedEvent);
|
||||
return createInsertActionUpdate(
|
||||
MembershipActionType.RestartDelayedEvent,
|
||||
this.membershipKeepAlivePeriod,
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
const repeatActionType = MembershipActionType.RestartDelayedEvent;
|
||||
if (this.isNotFoundError(e)) {
|
||||
this.state.delayId = undefined;
|
||||
return createInsertActionUpdate(MembershipActionType.SendDelayedEvent);
|
||||
}
|
||||
// If the HS does not support delayed events we wont reschedule.
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) return {};
|
||||
|
||||
// TODO this also needs a test: get rate limit while checking id delayed event is scheduled
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
|
||||
if (update) return update;
|
||||
|
||||
// In other error cases we have no idea what is happening
|
||||
throw Error("Could not restart delayed event, even though delayed events are supported. " + e);
|
||||
});
|
||||
}
|
||||
|
||||
private async sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(delayId: string): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Send)
|
||||
.then(() => {
|
||||
this.state.hasMemberStateEvent = false;
|
||||
this.resetRateLimitCounter(MembershipActionType.SendScheduledDelayedLeaveEvent);
|
||||
|
||||
return { replace: [] };
|
||||
})
|
||||
.catch((e) => {
|
||||
const repeatActionType = MembershipActionType.SendLeaveEvent;
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) return {};
|
||||
if (this.isNotFoundError(e)) {
|
||||
this.state.delayId = undefined;
|
||||
return createInsertActionUpdate(repeatActionType);
|
||||
}
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
|
||||
if (update) return update;
|
||||
|
||||
// On any other error we fall back to SendLeaveEvent (this includes hard errors from rate limiting)
|
||||
logger.warn(
|
||||
"Encountered unexpected error during SendScheduledDelayedLeaveEvent. Falling back to SendLeaveEvent",
|
||||
e,
|
||||
);
|
||||
return createInsertActionUpdate(repeatActionType);
|
||||
});
|
||||
}
|
||||
|
||||
private async sendJoinEvent(): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
this.makeMyMembership(this.membershipEventExpiryTimeout),
|
||||
this.stateKey,
|
||||
)
|
||||
.then(() => {
|
||||
this.state.startTime = Date.now();
|
||||
// The next update should already use twice the membershipEventExpiryTimeout
|
||||
this.state.expireUpdateIterations = 1;
|
||||
this.state.hasMemberStateEvent = true;
|
||||
this.resetRateLimitCounter(MembershipActionType.SendJoinEvent);
|
||||
return {
|
||||
insert: [
|
||||
{ ts: Date.now(), type: MembershipActionType.RestartDelayedEvent },
|
||||
{
|
||||
ts: this.computeNextExpiryActionTs(this.state.expireUpdateIterations),
|
||||
type: MembershipActionType.UpdateExpiry,
|
||||
},
|
||||
],
|
||||
};
|
||||
})
|
||||
.catch((e) => {
|
||||
const update = this.actionUpdateFromErrors(e, MembershipActionType.SendJoinEvent, "sendStateEvent");
|
||||
if (update) return update;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
private async updateExpiryOnJoinedEvent(): Promise<ActionUpdate> {
|
||||
const nextExpireUpdateIteration = this.state.expireUpdateIterations + 1;
|
||||
return await this.client
|
||||
.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
this.makeMyMembership(this.membershipEventExpiryTimeout * nextExpireUpdateIteration),
|
||||
this.stateKey,
|
||||
)
|
||||
.then(() => {
|
||||
// Success, we reset retries and schedule update.
|
||||
this.resetRateLimitCounter(MembershipActionType.UpdateExpiry);
|
||||
this.state.expireUpdateIterations = nextExpireUpdateIteration;
|
||||
return {
|
||||
insert: [
|
||||
{
|
||||
ts: this.computeNextExpiryActionTs(nextExpireUpdateIteration),
|
||||
type: MembershipActionType.UpdateExpiry,
|
||||
},
|
||||
],
|
||||
};
|
||||
})
|
||||
.catch((e) => {
|
||||
const update = this.actionUpdateFromErrors(e, MembershipActionType.UpdateExpiry, "sendStateEvent");
|
||||
if (update) return update;
|
||||
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
private async sendFallbackLeaveEvent(): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.stateKey)
|
||||
.then(() => {
|
||||
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
|
||||
this.state.hasMemberStateEvent = false;
|
||||
return { replace: [] };
|
||||
})
|
||||
.catch((e) => {
|
||||
const update = this.actionUpdateFromErrors(e, MembershipActionType.SendLeaveEvent, "sendStateEvent");
|
||||
if (update) return update;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
// HELPERS
|
||||
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
|
||||
const stateKey = `${localUserId}_${localDeviceId}`;
|
||||
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
|
||||
return stateKey;
|
||||
} else {
|
||||
return `_${stateKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs our own membership
|
||||
*/
|
||||
private makeMyMembership(expires: number): SessionMembershipData {
|
||||
return {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: this.deviceId,
|
||||
expires,
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
foci_preferred: this.fociPreferred ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// Error checks and handlers
|
||||
|
||||
/**
|
||||
* Check if its a NOT_FOUND error
|
||||
* @param error the error causing this handler check/execution
|
||||
* @returns true if its a not found error
|
||||
*/
|
||||
private isNotFoundError(error: unknown): boolean {
|
||||
return error instanceof MatrixError && error.errcode === "M_NOT_FOUND";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a DelayExceeded timeout and update the TimeoutOverride for the next try
|
||||
* @param error the error causing this handler check/execution
|
||||
* @returns true if its a delay exceeded error and we updated the local TimeoutOverride
|
||||
*/
|
||||
private manageMaxDelayExceededSituation(error: unknown): boolean {
|
||||
if (
|
||||
error instanceof MatrixError &&
|
||||
error.errcode === "M_UNKNOWN" &&
|
||||
error.data["org.matrix.msc4140.errcode"] === "M_MAX_DELAY_EXCEEDED"
|
||||
) {
|
||||
const maxDelayAllowed = error.data["org.matrix.msc4140.max_delay"];
|
||||
if (typeof maxDelayAllowed === "number" && this.membershipServerSideExpiryTimeout > maxDelayAllowed) {
|
||||
this.membershipServerSideExpiryTimeoutOverride = maxDelayAllowed;
|
||||
}
|
||||
logger.warn("Retry sending delayed disconnection event due to server timeout limitations:", error);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private actionUpdateFromErrors(
|
||||
error: unknown,
|
||||
type: MembershipActionType,
|
||||
method: string,
|
||||
): ActionUpdate | undefined {
|
||||
const updateLimit = this.actionUpdateFromRateLimitError(error, method, type);
|
||||
if (updateLimit) return updateLimit;
|
||||
const updateNetwork = this.actionUpdateFromNetworkErrorRetry(error, type);
|
||||
if (updateNetwork) return updateNetwork;
|
||||
}
|
||||
/**
|
||||
* Check if we have a rate limit error and schedule the same action again if we dont exceed the rate limit retry count yet.
|
||||
* @param error the error causing this handler check/execution
|
||||
* @param method the method used for the throw message
|
||||
* @param type which MembershipActionType we reschedule because of a rate limit.
|
||||
* @throws If it is a rate limit error and the retry count got exceeded
|
||||
* @returns Returns true if we handled the error by rescheduling the correct next action.
|
||||
* Returns false if it is not a network error.
|
||||
*/
|
||||
private actionUpdateFromRateLimitError(
|
||||
error: unknown,
|
||||
method: string,
|
||||
type: MembershipActionType,
|
||||
): ActionUpdate | undefined {
|
||||
// "Is rate limit"-boundary
|
||||
if (!((error instanceof HTTPError || error instanceof MatrixError) && error.isRateLimitError())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// retry boundary
|
||||
const rateLimitRetries = this.state.rateLimitRetries.get(type) ?? 0;
|
||||
if (rateLimitRetries < this.maximumRateLimitRetryCount) {
|
||||
let resendDelay: number;
|
||||
const defaultMs = 5000;
|
||||
try {
|
||||
resendDelay = error.getRetryAfterMs() ?? defaultMs;
|
||||
logger.info(`Rate limited by server, retrying in ${resendDelay}ms`);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`Error while retrieving a rate-limit retry delay, retrying after default delay of ${defaultMs}`,
|
||||
e,
|
||||
);
|
||||
resendDelay = defaultMs;
|
||||
}
|
||||
this.state.rateLimitRetries.set(type, rateLimitRetries + 1);
|
||||
return createInsertActionUpdate(type, resendDelay);
|
||||
}
|
||||
|
||||
throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + "): " + (error as Error));
|
||||
}
|
||||
|
||||
/**
|
||||
* FIXME Don't Check the error and retry the same MembershipAction again in the configured time and for the configured retry count.
|
||||
* @param error the error causing this handler check/execution
|
||||
* @param type the action type that we need to repeat because of the error
|
||||
* @throws If it is a network error and the retry count got exceeded
|
||||
* @returns
|
||||
* Returns true if we handled the error by rescheduling the correct next action.
|
||||
* Returns false if it is not a network error.
|
||||
*/
|
||||
private actionUpdateFromNetworkErrorRetry(error: unknown, type: MembershipActionType): ActionUpdate | undefined {
|
||||
// "Is a network error"-boundary
|
||||
const retries = this.state.networkErrorRetries.get(type) ?? 0;
|
||||
const retryDurationString = this.callMemberEventRetryDelayMinimum / 1000 + "s";
|
||||
const retryCounterString = "(" + retries + "/" + this.maximumNetworkErrorRetryCount + ")";
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
logger.warn(
|
||||
"Network local timeout error while sending event, retrying in " +
|
||||
retryDurationString +
|
||||
" " +
|
||||
retryCounterString,
|
||||
error,
|
||||
);
|
||||
} else if (error instanceof Error && error.message.includes("updating delayed event")) {
|
||||
// TODO: We do not want error message matching here but instead the error should be a typed HTTPError
|
||||
// and be handled below automatically (the same as in the SPA case).
|
||||
//
|
||||
// The error originates because of https://github.com/matrix-org/matrix-widget-api/blob/5d81d4a26ff69e4bd3ddc79a884c9527999fb2f4/src/ClientWidgetApi.ts#L698-L701
|
||||
// uses `e` instance of HttpError (and not MatrixError)
|
||||
// The element web widget driver (only checks for MatrixError) is then failing to process (`processError`) it as a typed error: https://github.com/element-hq/element-web/blob/471712cbf06a067e5499bd5d2d7a75f693d9a12d/src/stores/widgets/StopGapWidgetDriver.ts#L711-L715
|
||||
// So it will not call: `error.asWidgetApiErrorData()` which is also missing for `HttpError`
|
||||
//
|
||||
// A proper fix would be to either find a place to convert the `HttpError` into a `MatrixError` and the `processError`
|
||||
// method to handle it as expected or to adjust `processError` to also process `HttpError`'s.
|
||||
logger.warn(
|
||||
"delayed event update timeout error, retrying in " + retryDurationString + " " + retryCounterString,
|
||||
error,
|
||||
);
|
||||
} else if (error instanceof ConnectionError) {
|
||||
logger.warn(
|
||||
"Network connection error while sending event, retrying in " +
|
||||
retryDurationString +
|
||||
" " +
|
||||
retryCounterString,
|
||||
error,
|
||||
);
|
||||
} else if (
|
||||
(error instanceof HTTPError || error instanceof MatrixError) &&
|
||||
typeof error.httpStatus === "number" &&
|
||||
error.httpStatus >= 500 &&
|
||||
error.httpStatus < 600
|
||||
) {
|
||||
logger.warn(
|
||||
"Server error while sending event, retrying in " + retryDurationString + " " + retryCounterString,
|
||||
error,
|
||||
);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// retry boundary
|
||||
if (retries < this.maximumNetworkErrorRetryCount) {
|
||||
this.state.networkErrorRetries.set(type, retries + 1);
|
||||
return createInsertActionUpdate(type, this.callMemberEventRetryDelayMinimum);
|
||||
}
|
||||
|
||||
// Failure
|
||||
throw Error(
|
||||
"Reached maximum (" + this.maximumNetworkErrorRetryCount + ") retries cause by: " + (error as Error),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if its an UnsupportedDelayedEventsEndpointError and which implies that we cannot do any delayed event logic
|
||||
* @param error The error to check
|
||||
* @returns true it its an UnsupportedDelayedEventsEndpointError
|
||||
*/
|
||||
private isUnsupportedDelayedEndpoint(error: unknown): boolean {
|
||||
return error instanceof UnsupportedDelayedEventsEndpointError;
|
||||
}
|
||||
|
||||
private resetRateLimitCounter(type: MembershipActionType): void {
|
||||
this.state.rateLimitRetries.set(type, 0);
|
||||
this.state.networkErrorRetries.set(type, 0);
|
||||
}
|
||||
|
||||
public get status(): Status {
|
||||
const actions = this.scheduler.actions;
|
||||
if (actions.length === 1) {
|
||||
const { type } = actions[0];
|
||||
switch (type) {
|
||||
case MembershipActionType.SendDelayedEvent:
|
||||
case MembershipActionType.SendJoinEvent:
|
||||
return Status.Connecting;
|
||||
case MembershipActionType.UpdateExpiry: // where no delayed events
|
||||
return Status.Connected;
|
||||
case MembershipActionType.SendScheduledDelayedLeaveEvent:
|
||||
case MembershipActionType.SendLeaveEvent:
|
||||
return Status.Disconnecting;
|
||||
default:
|
||||
// pass through as not expected
|
||||
}
|
||||
} else if (actions.length === 2) {
|
||||
const types = actions.map((a) => a.type);
|
||||
// normal state for connected with delayed events
|
||||
if (
|
||||
(types.includes(MembershipActionType.RestartDelayedEvent) ||
|
||||
(types.includes(MembershipActionType.SendDelayedEvent) && this.state.hasMemberStateEvent)) &&
|
||||
types.includes(MembershipActionType.UpdateExpiry)
|
||||
) {
|
||||
return Status.Connected;
|
||||
}
|
||||
} else if (actions.length === 3) {
|
||||
const types = actions.map((a) => a.type);
|
||||
// It is a correct connected state if we already schedule the next Restart but have not yet cleaned up
|
||||
// the current restart.
|
||||
if (
|
||||
types.filter((t) => t === MembershipActionType.RestartDelayedEvent).length === 2 &&
|
||||
types.includes(MembershipActionType.UpdateExpiry)
|
||||
) {
|
||||
return Status.Connected;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.scheduler.running) {
|
||||
return Status.Disconnected;
|
||||
}
|
||||
|
||||
logger.error("MembershipManager has an unknown state. Actions: ", actions);
|
||||
return Status.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
insert: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
};
|
||||
}
|
||||
|
||||
function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
replace: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { type EmptyObject } from "../matrix.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
import { MembershipActionType } from "./NewMembershipManager.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
|
||||
/** @internal */
|
||||
export interface Action {
|
||||
/**
|
||||
* When this action should be executed
|
||||
*/
|
||||
ts: number;
|
||||
/**
|
||||
* The state of the different loops
|
||||
* can also be thought of as the type of the action
|
||||
*/
|
||||
type: MembershipActionType;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export type ActionUpdate =
|
||||
| {
|
||||
/** Replace all existing scheduled actions with this new array */
|
||||
replace: Action[];
|
||||
}
|
||||
| {
|
||||
/** Add these actions to the existing scheduled actions */
|
||||
insert: Action[];
|
||||
}
|
||||
| EmptyObject;
|
||||
|
||||
/**
|
||||
* This scheduler tracks the state of the current membership participation
|
||||
* and runs one central timer that wakes up a handler callback with the correct action + state
|
||||
* whenever necessary.
|
||||
*
|
||||
* It can also be awakened whenever a new action is added which is
|
||||
* earlier then the current "next awake".
|
||||
* @internal
|
||||
*/
|
||||
export class ActionScheduler {
|
||||
/**
|
||||
* This is tracking the state of the scheduler loop.
|
||||
* Only used to prevent starting the loop twice.
|
||||
*/
|
||||
public running = false;
|
||||
|
||||
public constructor(
|
||||
/** This is the callback called for each scheduled action (`this.addAction()`) */
|
||||
private membershipLoopHandler: (type: MembershipActionType) => Promise<ActionUpdate>,
|
||||
) {}
|
||||
|
||||
// function for the wakeup mechanism (in case we add an action externally and need to leave the current sleep)
|
||||
private wakeup: (update: ActionUpdate) => void = (update: ActionUpdate): void => {
|
||||
logger.error("Cannot call wakeup before calling `startWithJoin()`");
|
||||
};
|
||||
private _actions: Action[] = [];
|
||||
public get actions(): Action[] {
|
||||
return this._actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* This starts the main loop of the membership manager that handles event sending, delayed event sending and delayed event restarting.
|
||||
* @param initialActions The initial actions the manager will start with. It should be enough to pass: DelayedLeaveActionType.Initial
|
||||
* @returns Promise that resolves once all actions have run and no more are scheduled.
|
||||
* @throws This throws an error if one of the actions throws.
|
||||
* In most other error cases the manager will try to handle any server errors by itself.
|
||||
*/
|
||||
public async startWithJoin(): Promise<void> {
|
||||
if (this.running) {
|
||||
logger.error("Cannot call startWithJoin() on NewMembershipActionScheduler while already running");
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
this._actions = [{ ts: Date.now(), type: MembershipActionType.SendDelayedEvent }];
|
||||
try {
|
||||
while (this._actions.length > 0) {
|
||||
// Sort so next (smallest ts) action is at the beginning
|
||||
this._actions.sort((a, b) => a.ts - b.ts);
|
||||
const nextAction = this._actions[0];
|
||||
let wakeupUpdate: ActionUpdate | undefined = undefined;
|
||||
|
||||
// while we await for the next action, wakeup has to resolve the wakeupPromise
|
||||
const wakeupPromise = new Promise<void>((resolve) => {
|
||||
this.wakeup = (update: ActionUpdate): void => {
|
||||
wakeupUpdate = update;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
if (nextAction.ts > Date.now()) await Promise.race([wakeupPromise, sleep(nextAction.ts - Date.now())]);
|
||||
|
||||
let handlerResult: ActionUpdate = {};
|
||||
if (!wakeupUpdate) {
|
||||
logger.debug(
|
||||
`Current MembershipManager processing: ${nextAction.type}\nQueue:`,
|
||||
this._actions,
|
||||
`\nDate.now: "${Date.now()}`,
|
||||
);
|
||||
try {
|
||||
// `this.wakeup` can also be called and sets the `wakeupUpdate` object while we are in the handler.
|
||||
handlerResult = await this.membershipLoopHandler(nextAction.type as MembershipActionType);
|
||||
} catch (e) {
|
||||
throw Error(`The MembershipManager shut down because of the end condition: ${e}`);
|
||||
}
|
||||
}
|
||||
// remove the processed action only after we are done processing
|
||||
this._actions.splice(0, 1);
|
||||
// The wakeupUpdate always wins since that is a direct external update.
|
||||
const actionUpdate = wakeupUpdate ?? handlerResult;
|
||||
|
||||
if ("replace" in actionUpdate) {
|
||||
this._actions = actionUpdate.replace;
|
||||
} else if ("insert" in actionUpdate) {
|
||||
this._actions.push(...actionUpdate.insert);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Set the rtc session running state since we cannot recover from here and the consumer user of the
|
||||
// MatrixRTCSession class needs to manually rejoin.
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
logger.debug("Leave MembershipManager ActionScheduler loop (no more actions)");
|
||||
}
|
||||
|
||||
public initiateJoin(): void {
|
||||
this.wakeup?.({ replace: [{ ts: Date.now(), type: MembershipActionType.SendDelayedEvent }] });
|
||||
}
|
||||
public initiateLeave(): void {
|
||||
this.wakeup?.({ replace: [{ ts: Date.now(), type: MembershipActionType.SendScheduledDelayedLeaveEvent }] });
|
||||
}
|
||||
}
|
||||
@@ -20,3 +20,4 @@ export * from "./LivekitFocus.ts";
|
||||
export * from "./MatrixRTCSession.ts";
|
||||
export * from "./MatrixRTCSessionManager.ts";
|
||||
export type * from "./types.ts";
|
||||
export { Status, MembershipManagerEvent } from "./types.ts";
|
||||
|
||||
+84
-1
@@ -13,7 +13,10 @@ 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 { type IMentions } from "../matrix.ts";
|
||||
import type { IMentions } from "../matrix.ts";
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
import type { Focus } from "./focus.ts";
|
||||
|
||||
export interface EncryptionKeyEntry {
|
||||
index: number;
|
||||
key: string;
|
||||
@@ -34,3 +37,83 @@ export interface ICallNotifyContent {
|
||||
"notify_type": CallNotifyType;
|
||||
"call_id": string;
|
||||
}
|
||||
|
||||
export enum Status {
|
||||
Disconnected = "Disconnected",
|
||||
Connecting = "Connecting",
|
||||
ConnectingFailed = "ConnectingFailed",
|
||||
Connected = "Connected",
|
||||
Reconnecting = "Reconnecting",
|
||||
Disconnecting = "Disconnecting",
|
||||
Stuck = "Stuck",
|
||||
Unknown = "Unknown",
|
||||
}
|
||||
|
||||
export enum MembershipManagerEvent {
|
||||
StatusChanged = "StatusChanged",
|
||||
}
|
||||
|
||||
export type MembershipManagerEventHandlerMap = {
|
||||
[MembershipManagerEvent.StatusChanged]: (prefStatus: Status, newStatus: Status) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* This interface defines what a MembershipManager uses and exposes.
|
||||
* This interface is what we use to write tests and allows changing the actual implementation
|
||||
* without breaking tests because of some internal method renaming.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface IMembershipManager {
|
||||
/**
|
||||
* If we are trying to join, or have successfully joined the session.
|
||||
* It does not reflect if the room state is already configured to represent us being joined.
|
||||
* It only means that the Manager should be trying to connect or to disconnect running.
|
||||
* The Manager is still running right after isJoined becomes false to send the disconnect events.
|
||||
* @returns true if we intend to be participating in the MatrixRTC session
|
||||
* @deprecated This name is confusing and replaced by `isActivated()`. (Returns the same as `isActivated()`)
|
||||
*/
|
||||
isJoined(): boolean;
|
||||
/**
|
||||
* If the manager is activated. This means it tries to do its job to join the call, resend state events...
|
||||
* It does not imply that the room state is already configured to represent being joined.
|
||||
* It means that the Manager tries to connect or is connected. ("the manager is still active")
|
||||
* Once `leave()` is called the manager is not activated anymore but still running until `leave()` resolves.
|
||||
* @returns `true` if we intend to be participating in the MatrixRTC session
|
||||
*/
|
||||
isActivated(): boolean;
|
||||
/**
|
||||
* Get the actual connection status of the manager.
|
||||
*/
|
||||
get status(): Status;
|
||||
/**
|
||||
* The current status while the manager is activated
|
||||
*/
|
||||
/**
|
||||
* Start sending all necessary events to make this user participate in the RTC session.
|
||||
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||
* @param fociActive the active focus to use in the joined RTC membership event.
|
||||
* @throws can throw if it exceeds a configured maximum retry.
|
||||
*/
|
||||
join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void;
|
||||
/**
|
||||
* Send all necessary events to make this user leave the RTC session.
|
||||
* @param timeout the maximum duration in ms until the promise is forced to resolve.
|
||||
* @returns It resolves with true in case the leave was sent successfully.
|
||||
* It resolves with false in case we hit the timeout before sending successfully.
|
||||
*/
|
||||
leave(timeout?: number): Promise<boolean>;
|
||||
/**
|
||||
* Call this if the MatrixRTC session members have changed.
|
||||
*/
|
||||
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
|
||||
/**
|
||||
* The used active focus in the currently joined session.
|
||||
* @returns the used active focus in the currently joined session or undefined if not joined.
|
||||
*/
|
||||
getActiveFocus(): Focus | undefined;
|
||||
|
||||
// TypedEventEmitter methods:
|
||||
on(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
|
||||
off(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
|
||||
}
|
||||
|
||||
@@ -14,9 +14,40 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A stripped m.room.member event which contains the key renderable fields from the event,
|
||||
* sent only in simplified sliding sync (not `/v3/sync`).
|
||||
* This is very similar to MSC4186Hero from sliding-sync.ts but an internal format with
|
||||
* camelCase rather than underscores.
|
||||
*/
|
||||
export type Hero = {
|
||||
userId: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
/**
|
||||
* If true, the hero is from an MSC4186 summary, in which case `displayName` and `avatarUrl` will
|
||||
* have been set by the server if available. If false, the `Hero` has been constructed from a `/v3/sync` response,
|
||||
* so these fields will always be undefined.
|
||||
*/
|
||||
fromMSC4186: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* High level summary information for a room, as returned by `/v3/sync`.
|
||||
*/
|
||||
export interface IRoomSummary {
|
||||
/**
|
||||
* The room heroes: a selected set of members that can be used when summarising or
|
||||
* generating a name for a room. List of user IDs.
|
||||
*/
|
||||
"m.heroes": string[];
|
||||
/**
|
||||
* The number of joined members in the room.
|
||||
*/
|
||||
"m.joined_member_count"?: number;
|
||||
/**
|
||||
* The number of invited members in the room.
|
||||
*/
|
||||
"m.invited_member_count"?: number;
|
||||
}
|
||||
|
||||
|
||||
+133
-23
@@ -35,7 +35,7 @@ import {
|
||||
} from "./event.ts";
|
||||
import { EventStatus } from "./event-status.ts";
|
||||
import { RoomMember } from "./room-member.ts";
|
||||
import { type IRoomSummary, RoomSummary } from "./room-summary.ts";
|
||||
import { type IRoomSummary, type Hero, RoomSummary } from "./room-summary.ts";
|
||||
import { logger } from "../logger.ts";
|
||||
import { TypedReEmitter } from "../ReEmitter.ts";
|
||||
import {
|
||||
@@ -77,6 +77,7 @@ import { compareEventOrdering } from "./compare-event-ordering.ts";
|
||||
import * as utils from "../utils.ts";
|
||||
import { KnownMembership, type Membership } from "../@types/membership.ts";
|
||||
import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts";
|
||||
import { type MSC4186Hero } from "../sliding-sync.ts";
|
||||
|
||||
// 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
|
||||
@@ -335,6 +336,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
public readonly reEmitter: TypedReEmitter<RoomEmittedEvents, RoomEventHandlerMap>;
|
||||
private txnToEvent: Map<string, MatrixEvent> = new Map(); // Pending in-flight requests { string: MatrixEvent }
|
||||
private notificationCounts: NotificationCount = {};
|
||||
private bumpStamp: number | undefined = undefined;
|
||||
private readonly threadNotifications = new Map<string, NotificationCount>();
|
||||
public readonly cachedThreadReadReceipts = new Map<string, CachedReceiptStructure[]>();
|
||||
// Useful to know at what point the current user has started using threads in this room
|
||||
@@ -361,7 +363,16 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// read by megolm via getter; boolean value - null indicates "use global value"
|
||||
private blacklistUnverifiedDevices?: boolean;
|
||||
private selfMembership?: Membership;
|
||||
private summaryHeroes: string[] | null = null;
|
||||
/**
|
||||
* A `Hero` is a stripped `m.room.member` event which contains the important renderable fields from the event.
|
||||
*
|
||||
* It is used in MSC4186 (Simplified Sliding Sync) as a replacement for the old `summary` field.
|
||||
*
|
||||
* When we are doing old-style (`/v3/sync`) sync, we simulate the SSS behaviour by constructing
|
||||
* a `Hero` object based on the user id we get from the summary. Obviously, in that case,
|
||||
* the `Hero` will lack a `displayName` or `avatarUrl`.
|
||||
*/
|
||||
private heroes: Hero[] | null = null;
|
||||
// flags to stop logspam about missing m.room.create events
|
||||
private getTypeWarning = false;
|
||||
private getVersionWarning = false;
|
||||
@@ -879,7 +890,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// fall back to summary information
|
||||
const memberCount = this.getInvitedAndJoinedMemberCount();
|
||||
if (memberCount === 2) {
|
||||
return this.summaryHeroes?.[0];
|
||||
return this.heroes?.[0]?.userId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,8 +908,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
}
|
||||
// Remember, we're assuming this room is a DM, so returning the first member we find should be fine
|
||||
if (Array.isArray(this.summaryHeroes) && this.summaryHeroes.length) {
|
||||
return this.summaryHeroes[0];
|
||||
if (Array.isArray(this.heroes) && this.heroes.length) {
|
||||
return this.heroes[0].userId;
|
||||
}
|
||||
const members = this.currentState.getMembers();
|
||||
const anyMember = members.find((m) => m.userId !== this.myUserId);
|
||||
@@ -940,12 +951,45 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
if (nonFunctionalMemberCount > 2) return;
|
||||
|
||||
// Prefer the list of heroes, if present. It should only include the single other user in the DM.
|
||||
const nonFunctionalHeroes = this.summaryHeroes?.filter((h) => !functionalMembers.includes(h));
|
||||
const nonFunctionalHeroes = this.heroes?.filter((h) => !functionalMembers.includes(h.userId));
|
||||
const hasHeroes = Array.isArray(nonFunctionalHeroes) && nonFunctionalHeroes.length;
|
||||
if (hasHeroes) {
|
||||
// use first hero that has a display name or avatar url, or whose user ID
|
||||
// can be looked up as a member of the room
|
||||
for (const hero of nonFunctionalHeroes) {
|
||||
// If the hero was from a legacy sync (`/v3/sync`), we will need to look the user ID up in the room
|
||||
// the display name and avatar URL will not be set.
|
||||
if (!hero.fromMSC4186) {
|
||||
// attempt to look up renderable fields from the m.room.member event if it exists
|
||||
const member = this.getMember(hero.userId);
|
||||
if (member) {
|
||||
return member;
|
||||
}
|
||||
} else {
|
||||
// use the Hero supplied values for the room member.
|
||||
// TODO: It's unfortunate that this function, which clearly only cares about the
|
||||
// avatar url, returns the entire RoomMember event. We need to fake an event
|
||||
// to meet this API shape.
|
||||
const heroMember = new RoomMember(this.roomId, hero.userId);
|
||||
// set the display name and avatar url
|
||||
heroMember.setMembershipEvent(
|
||||
new MatrixEvent({
|
||||
// ensure it's unique even if we hit the same millisecond
|
||||
event_id: "$" + this.roomId + hero.userId + new Date().getTime(),
|
||||
type: EventType.RoomMember,
|
||||
state_key: hero.userId,
|
||||
content: {
|
||||
displayname: hero.displayName,
|
||||
avatar_url: hero.avatarUrl,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return heroMember;
|
||||
}
|
||||
}
|
||||
const availableMember = nonFunctionalHeroes
|
||||
.map((userId) => {
|
||||
return this.getMember(userId);
|
||||
.map((hero) => {
|
||||
return this.getMember(hero.userId);
|
||||
})
|
||||
.find((member) => !!member);
|
||||
if (availableMember) {
|
||||
@@ -970,8 +1014,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// trust and try falling back to a hero, creating a one-off member for it
|
||||
if (hasHeroes) {
|
||||
const availableUser = nonFunctionalHeroes
|
||||
.map((userId) => {
|
||||
return this.client.getUser(userId);
|
||||
.map((hero) => {
|
||||
return this.client.getUser(hero.userId);
|
||||
})
|
||||
.find((user) => !!user);
|
||||
if (availableUser) {
|
||||
@@ -1602,6 +1646,24 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
this.emit(RoomEvent.UnreadNotifications);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the bump stamp for this room. This can be used for sorting rooms when the timeline
|
||||
* entries are unknown. Used in MSC4186: Simplified Sliding Sync.
|
||||
* @param bumpStamp The bump_stamp value from the server
|
||||
*/
|
||||
public setBumpStamp(bumpStamp: number): void {
|
||||
this.bumpStamp = bumpStamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bump stamp for this room. This can be used for sorting rooms when the timeline
|
||||
* entries are unknown. Used in MSC4186: Simplified Sliding Sync.
|
||||
* @returns The bump stamp for the room, if it exists.
|
||||
*/
|
||||
public getBumpStamp(): number | undefined {
|
||||
return this.bumpStamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set one of the notification counts for this room
|
||||
* @param type - The type of notification count to set.
|
||||
@@ -1616,8 +1678,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
return this.setUnreadNotificationCount(type, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a legacy room summary (/v3/sync as opposed to MSC4186) and updates the room with it.
|
||||
*
|
||||
* @param summary - The room summary to update the room with
|
||||
*/
|
||||
public setSummary(summary: IRoomSummary): void {
|
||||
const heroes = summary["m.heroes"];
|
||||
const heroes = summary["m.heroes"]?.map((h) => ({ userId: h, fromMSC4186: false }));
|
||||
const joinedCount = summary["m.joined_member_count"];
|
||||
const invitedCount = summary["m.invited_member_count"];
|
||||
if (Number.isInteger(joinedCount)) {
|
||||
@@ -1627,17 +1694,53 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
this.currentState.setInvitedMemberCount(invitedCount!);
|
||||
}
|
||||
if (Array.isArray(heroes)) {
|
||||
// be cautious about trusting server values,
|
||||
// and make sure heroes doesn't contain our own id
|
||||
// just to be sure
|
||||
this.summaryHeroes = heroes.filter((userId) => {
|
||||
return userId !== this.myUserId;
|
||||
// filter out ourselves just in case
|
||||
this.heroes = heroes.filter((h) => {
|
||||
return h.userId != this.myUserId;
|
||||
});
|
||||
}
|
||||
|
||||
this.emit(RoomEvent.Summary, summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes information from the MSC4186 room summary and updates the room with it.
|
||||
*
|
||||
* @param heroes - The room's hero members
|
||||
* @param joinedCount - The number of joined members
|
||||
* @param invitedCount - The number of invited members
|
||||
*/
|
||||
public setMSC4186SummaryData(
|
||||
heroes: MSC4186Hero[] | undefined,
|
||||
joinedCount: number | undefined,
|
||||
invitedCount: number | undefined,
|
||||
): void {
|
||||
if (heroes) {
|
||||
this.heroes = heroes
|
||||
.filter((h) => h.user_id !== this.myUserId)
|
||||
.map((h) => ({
|
||||
userId: h.user_id,
|
||||
displayName: h.displayname,
|
||||
avatarUrl: h.avatar_url,
|
||||
fromMSC4186: true,
|
||||
}));
|
||||
}
|
||||
if (joinedCount !== undefined && Number.isInteger(joinedCount)) {
|
||||
this.currentState.setJoinedMemberCount(joinedCount);
|
||||
}
|
||||
if (invitedCount !== undefined && Number.isInteger(invitedCount)) {
|
||||
this.currentState.setInvitedMemberCount(invitedCount);
|
||||
}
|
||||
|
||||
// Construct a summary object to emit as the event wants the info in a single object
|
||||
// more like old-style (/v3/sync) summaries.
|
||||
this.emit(RoomEvent.Summary, {
|
||||
"m.heroes": this.heroes ? this.heroes.map((h) => h.userId) : [],
|
||||
"m.joined_member_count": joinedCount,
|
||||
"m.invited_member_count": invitedCount,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to send encrypted messages to devices within this room.
|
||||
* @param value - true to blacklist unverified devices, null
|
||||
@@ -3459,18 +3562,25 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// get service members (e.g. helper bots) for exclusion
|
||||
const excludedUserIds = this.getFunctionalMembers();
|
||||
|
||||
// get members that are NOT ourselves and are actually in the room.
|
||||
// get members from heroes that are NOT ourselves
|
||||
let otherNames: string[] = [];
|
||||
if (this.summaryHeroes) {
|
||||
// if we have a summary, the member state events should be in the room state
|
||||
this.summaryHeroes.forEach((userId) => {
|
||||
if (this.heroes) {
|
||||
// if we have heroes, use those as the names
|
||||
this.heroes.forEach((hero) => {
|
||||
// filter service members
|
||||
if (excludedUserIds.includes(userId)) {
|
||||
if (excludedUserIds.includes(hero.userId)) {
|
||||
inviteJoinCount--;
|
||||
return;
|
||||
}
|
||||
const member = this.getMember(userId);
|
||||
otherNames.push(member ? member.name : userId);
|
||||
// If the hero has a display name, use that.
|
||||
// Otherwise, look their user ID up in the membership and use
|
||||
// the name from there, or the user ID as a last resort.
|
||||
if (hero.displayName) {
|
||||
otherNames.push(hero.displayName);
|
||||
} else {
|
||||
const member = this.getMember(hero.userId);
|
||||
otherNames.push(member ? member.name : hero.userId);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let otherMembers = this.currentState.getMembers().filter((m) => {
|
||||
|
||||
+17
-4
@@ -54,8 +54,18 @@ interface OidcRegistrationRequestBody {
|
||||
|
||||
export const DEVICE_CODE_SCOPE = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
// Check that URIs have a common base, as per the MSC2966 definition
|
||||
const urlHasCommonBase = (base: URL, urlStr?: string): boolean => {
|
||||
if (!urlStr) return false;
|
||||
const url = new URL(urlStr);
|
||||
if (url.protocol !== base.protocol) return false;
|
||||
if (url.hostname !== base.hostname && !url.hostname.endsWith(`.${base.hostname}`)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts dynamic registration against the configured registration endpoint
|
||||
* Attempts dynamic registration against the configured registration endpoint.
|
||||
* Will ignore any URIs that do not use client_uri as a common base as per the spec.
|
||||
* @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}
|
||||
* @param clientMetadata - The metadata for the client which to register
|
||||
* @returns Promise<string> resolved with registered clientId
|
||||
@@ -74,6 +84,8 @@ export const registerOidcClient = async (
|
||||
throw new Error(OidcError.DynamicRegistrationNotSupported);
|
||||
}
|
||||
|
||||
const commonBase = new URL(clientMetadata.clientUri);
|
||||
|
||||
// https://openid.net/specs/openid-connect-registration-1_0.html
|
||||
const metadata: OidcRegistrationRequestBody = {
|
||||
client_name: clientMetadata.clientName,
|
||||
@@ -84,11 +96,12 @@ export const registerOidcClient = async (
|
||||
id_token_signed_response_alg: "RS256",
|
||||
token_endpoint_auth_method: "none",
|
||||
application_type: clientMetadata.applicationType,
|
||||
logo_uri: clientMetadata.logoUri,
|
||||
contacts: clientMetadata.contacts,
|
||||
policy_uri: clientMetadata.policyUri,
|
||||
tos_uri: clientMetadata.tosUri,
|
||||
logo_uri: urlHasCommonBase(commonBase, clientMetadata.logoUri) ? clientMetadata.logoUri : undefined,
|
||||
policy_uri: urlHasCommonBase(commonBase, clientMetadata.policyUri) ? clientMetadata.policyUri : undefined,
|
||||
tos_uri: urlHasCommonBase(commonBase, clientMetadata.tosUri) ? clientMetadata.tosUri : undefined,
|
||||
};
|
||||
|
||||
const headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type IdTokenClaims, OidcClient, WebStorageStateStore } from "oidc-client-ts";
|
||||
import { type IdTokenClaims, OidcClient, WebStorageStateStore, ErrorResponse } from "oidc-client-ts";
|
||||
|
||||
import { type AccessTokens } from "../http-api/index.ts";
|
||||
import { type AccessTokens, TokenRefreshLogoutError } from "../http-api/index.ts";
|
||||
import { generateScope } from "./authorize.ts";
|
||||
import { discoverAndValidateOIDCIssuerWellKnown } from "./discovery.ts";
|
||||
import { logger } from "../logger.ts";
|
||||
@@ -104,6 +104,12 @@ export class OidcTokenRefresher {
|
||||
try {
|
||||
const tokens = await this.inflightRefreshRequest;
|
||||
return tokens;
|
||||
} catch (e) {
|
||||
// If we encounter an OIDC error then signal that it should cause a logout by upgrading it to a TokenRefreshLogoutError
|
||||
if (e instanceof ErrorResponse) {
|
||||
throw new TokenRefreshLogoutError(e);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
this.inflightRefreshRequest = undefined;
|
||||
}
|
||||
|
||||
+25
-16
@@ -308,6 +308,28 @@ export class PushProcessor {
|
||||
return newRules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a RegExp object for the given glob pattern with a single capture group around the pattern itself, caching the result.
|
||||
* No cache invalidation is present currently,
|
||||
* as this will be inherently bounded to the size of the user's own push rules.
|
||||
* @param pattern - the glob pattern to convert to a RegExp
|
||||
* @param alignToWordBoundary - whether to align the pattern to word boundaries,
|
||||
* as specified for `content.body` matches, will use lookaround assertions to ensure the match only includes the pattern
|
||||
* @param flags - the flags to pass to the RegExp constructor, defaults to case-insensitive
|
||||
*/
|
||||
public static getPushRuleGlobRegex(pattern: string, alignToWordBoundary = false, flags = "i"): RegExp {
|
||||
const [prefix, suffix] = alignToWordBoundary ? ["(?<=^|\\W)", "(?=\\W|$)"] : ["^", "$"];
|
||||
const cacheKey = `${alignToWordBoundary}-${flags}-${pattern}`;
|
||||
|
||||
if (!PushProcessor.cachedGlobToRegex[cacheKey]) {
|
||||
PushProcessor.cachedGlobToRegex[cacheKey] = new RegExp(
|
||||
prefix + "(" + globToRegexp(pattern) + ")" + suffix,
|
||||
flags,
|
||||
);
|
||||
}
|
||||
return PushProcessor.cachedGlobToRegex[cacheKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-caches the parsed keys for push rules and cleans out any obsolete cache
|
||||
* entries. Should be called after push rules are updated.
|
||||
@@ -567,11 +589,9 @@ export class PushProcessor {
|
||||
return false;
|
||||
}
|
||||
|
||||
const regex =
|
||||
cond.key === "content.body"
|
||||
? this.createCachedRegex("(^|\\W)", cond.pattern, "(\\W|$)")
|
||||
: this.createCachedRegex("^", cond.pattern, "$");
|
||||
|
||||
// Align to word boundary on `content.body` matches, whole string otherwise
|
||||
// https://spec.matrix.org/v1.13/client-server-api/#conditions-1
|
||||
const regex = PushProcessor.getPushRuleGlobRegex(cond.pattern, cond.key === "content.body");
|
||||
return !!val.match(regex);
|
||||
}
|
||||
|
||||
@@ -621,17 +641,6 @@ export class PushProcessor {
|
||||
);
|
||||
}
|
||||
|
||||
private createCachedRegex(prefix: string, glob: string, suffix: string): RegExp {
|
||||
if (PushProcessor.cachedGlobToRegex[glob]) {
|
||||
return PushProcessor.cachedGlobToRegex[glob];
|
||||
}
|
||||
PushProcessor.cachedGlobToRegex[glob] = new RegExp(
|
||||
prefix + globToRegexp(glob) + suffix,
|
||||
"i", // Case insensitive
|
||||
);
|
||||
return PushProcessor.cachedGlobToRegex[glob];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the key into the separate fields to search by splitting on
|
||||
* unescaped ".", and then removing any escape characters.
|
||||
|
||||
@@ -219,6 +219,12 @@ export class OutgoingRequestProcessor {
|
||||
|
||||
// we use the full prefix
|
||||
prefix: "",
|
||||
|
||||
// We set a timeout of 60 seconds to guard against requests getting stuck forever and wedging the
|
||||
// request loop (cf https://github.com/element-hq/element-web/issues/29534).
|
||||
//
|
||||
// (XXX: should we do this in the whole of the js-sdk?)
|
||||
localTimeoutMs: 60000,
|
||||
};
|
||||
|
||||
return await this.http.authedRequest<string>(method, path, queryParams, body, opts);
|
||||
|
||||
@@ -1276,6 +1276,24 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
this.checkKeyBackupAndEnable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#disableKeyStorage}.
|
||||
*/
|
||||
public async disableKeyStorage(): Promise<void> {
|
||||
// Get the key backup version we're using
|
||||
const info = await this.getKeyBackupInfo();
|
||||
if (info?.version) {
|
||||
await this.deleteKeyBackupVersion(info.version);
|
||||
} else {
|
||||
logger.error("Can't delete key backup version: no version available");
|
||||
}
|
||||
|
||||
// also turn off 4S, since this is also storing keys on the server.
|
||||
await this.deleteSecretStorage();
|
||||
|
||||
await this.dehydratedDeviceManager.delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs the given object with the current device and current identity (if available).
|
||||
* As defined in {@link https://spec.matrix.org/v1.8/appendices/#signing-json | Signing JSON}.
|
||||
@@ -1447,17 +1465,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
// Disable backup, and delete all the backups from the server
|
||||
await this.backupManager.deleteAllKeyBackupVersions();
|
||||
|
||||
// Remove the stored secrets in the secret storage
|
||||
await this.secretStorage.store("m.cross_signing.master", null);
|
||||
await this.secretStorage.store("m.cross_signing.self_signing", null);
|
||||
await this.secretStorage.store("m.cross_signing.user_signing", null);
|
||||
await this.secretStorage.store("m.megolm_backup.v1", null);
|
||||
|
||||
// Remove the recovery key
|
||||
const defaultKeyId = await this.secretStorage.getDefaultKeyId();
|
||||
if (defaultKeyId) await this.secretStorage.store(`m.secret_storage.key.${defaultKeyId}`, null);
|
||||
// Disable the recovery key and the secret storage
|
||||
await this.secretStorage.setDefaultKeyId(null);
|
||||
this.deleteSecretStorage();
|
||||
|
||||
// Reset the cross-signing keys
|
||||
await this.crossSigningIdentity.bootstrapCrossSigning({
|
||||
@@ -1471,6 +1479,24 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
this.logger.debug("resetEncryption: ended");
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the secret storage key, default key pointer and all (known) secret storage data
|
||||
* from the user's account data
|
||||
*/
|
||||
private async deleteSecretStorage(): Promise<void> {
|
||||
// Remove the stored secrets in the secret storage
|
||||
await this.secretStorage.store("m.cross_signing.master", null);
|
||||
await this.secretStorage.store("m.cross_signing.self_signing", null);
|
||||
await this.secretStorage.store("m.cross_signing.user_signing", null);
|
||||
await this.secretStorage.store("m.megolm_backup.v1", null);
|
||||
|
||||
// Remove the recovery key
|
||||
const defaultKeyId = await this.secretStorage.getDefaultKeyId();
|
||||
if (defaultKeyId) await this.secretStorage.store(`m.secret_storage.key.${defaultKeyId}`, null);
|
||||
// Disable the recovery key and the secret storage
|
||||
await this.secretStorage.setDefaultKeyId(null);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// SyncCryptoCallbacks implementation
|
||||
@@ -1609,7 +1635,6 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
|
||||
/** called by the sync loop after processing each sync.
|
||||
*
|
||||
* TODO: figure out something equivalent for sliding sync.
|
||||
*
|
||||
* @param syncState - information on the completed sync.
|
||||
*/
|
||||
@@ -1621,6 +1646,13 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#markAllTrackedUsersAsDirty}.
|
||||
*/
|
||||
public async markAllTrackedUsersAsDirty(): Promise<void> {
|
||||
await this.olmMachine.markAllTrackedUsersAsDirty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming m.key.verification.request event, received either in-room or in a to-device message.
|
||||
*
|
||||
|
||||
+28
-28
@@ -82,9 +82,16 @@ class ExtensionE2EE implements Extension<ExtensionE2EERequest, ExtensionE2EEResp
|
||||
return ExtensionState.PreProcess;
|
||||
}
|
||||
|
||||
public onRequest(isInitial: boolean): ExtensionE2EERequest | undefined {
|
||||
if (!isInitial) {
|
||||
return undefined;
|
||||
public async onRequest(isInitial: boolean): Promise<ExtensionE2EERequest> {
|
||||
if (isInitial) {
|
||||
// In SSS, the `?pos=` contains the stream position for device list updates.
|
||||
// If we do not have a `?pos=` (e.g because we forgot it, or because the server
|
||||
// invalidated our connection) then we MUST invlaidate all device lists because
|
||||
// the server will not tell us the delta. This will then cause UTDs as we will fail
|
||||
// to encrypt for new devices. This is an expensive call, so we should
|
||||
// really really remember `?pos=` wherever possible.
|
||||
logger.log("ExtensionE2EE: invalidating all device lists due to missing 'pos'");
|
||||
await this.crypto.markAllTrackedUsersAsDirty();
|
||||
}
|
||||
return {
|
||||
enabled: true, // this is sticky so only send it on the initial request
|
||||
@@ -134,15 +141,12 @@ class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, Extension
|
||||
return ExtensionState.PreProcess;
|
||||
}
|
||||
|
||||
public onRequest(isInitial: boolean): ExtensionToDeviceRequest {
|
||||
const extReq: ExtensionToDeviceRequest = {
|
||||
public async onRequest(isInitial: boolean): Promise<ExtensionToDeviceRequest> {
|
||||
return {
|
||||
since: this.nextBatch !== null ? this.nextBatch : undefined,
|
||||
limit: 100,
|
||||
enabled: true,
|
||||
};
|
||||
if (isInitial) {
|
||||
extReq["limit"] = 100;
|
||||
extReq["enabled"] = true;
|
||||
}
|
||||
return extReq;
|
||||
}
|
||||
|
||||
public async onResponse(data: ExtensionToDeviceResponse): Promise<void> {
|
||||
@@ -216,10 +220,7 @@ class ExtensionAccountData implements Extension<ExtensionAccountDataRequest, Ext
|
||||
return ExtensionState.PostProcess;
|
||||
}
|
||||
|
||||
public onRequest(isInitial: boolean): ExtensionAccountDataRequest | undefined {
|
||||
if (!isInitial) {
|
||||
return undefined;
|
||||
}
|
||||
public async onRequest(isInitial: boolean): Promise<ExtensionAccountDataRequest> {
|
||||
return {
|
||||
enabled: true,
|
||||
};
|
||||
@@ -286,10 +287,7 @@ class ExtensionTyping implements Extension<ExtensionTypingRequest, ExtensionTypi
|
||||
return ExtensionState.PostProcess;
|
||||
}
|
||||
|
||||
public onRequest(isInitial: boolean): ExtensionTypingRequest | undefined {
|
||||
if (!isInitial) {
|
||||
return undefined; // don't send a JSON object for subsequent requests, we don't need to.
|
||||
}
|
||||
public async onRequest(isInitial: boolean): Promise<ExtensionTypingRequest> {
|
||||
return {
|
||||
enabled: true,
|
||||
};
|
||||
@@ -325,13 +323,10 @@ class ExtensionReceipts implements Extension<ExtensionReceiptsRequest, Extension
|
||||
return ExtensionState.PostProcess;
|
||||
}
|
||||
|
||||
public onRequest(isInitial: boolean): ExtensionReceiptsRequest | undefined {
|
||||
if (isInitial) {
|
||||
return {
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
return undefined; // don't send a JSON object for subsequent requests, we don't need to.
|
||||
public async onRequest(isInitial: boolean): Promise<ExtensionReceiptsRequest> {
|
||||
return {
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
public async onResponse(data: ExtensionReceiptsResponse): Promise<void> {
|
||||
@@ -442,6 +437,7 @@ export class SlidingSyncSdk {
|
||||
}
|
||||
} else {
|
||||
this.failCount = 0;
|
||||
logger.log(`SlidingSyncState.RequestFinished with ${Object.keys(resp?.rooms || []).length} rooms`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -580,7 +576,7 @@ export class SlidingSyncSdk {
|
||||
|
||||
// TODO: handle threaded / beacon events
|
||||
|
||||
if (roomData.initial) {
|
||||
if (roomData.limited || roomData.initial) {
|
||||
// we should not know about any of these timeline entries if this is a genuinely new room.
|
||||
// If we do, then we've effectively done scrollback (e.g requesting timeline_limit: 1 for
|
||||
// this room, then timeline_limit: 50).
|
||||
@@ -637,6 +633,9 @@ export class SlidingSyncSdk {
|
||||
room.setUnreadNotificationCount(NotificationCountType.Highlight, roomData.highlight_count);
|
||||
}
|
||||
}
|
||||
if (roomData.bump_stamp) {
|
||||
room.setBumpStamp(roomData.bump_stamp);
|
||||
}
|
||||
|
||||
if (Number.isInteger(roomData.invited_count)) {
|
||||
room.currentState.setInvitedMemberCount(roomData.invited_count!);
|
||||
@@ -656,11 +655,10 @@ export class SlidingSyncSdk {
|
||||
inviteStateEvents.forEach((e) => {
|
||||
this.client.emit(ClientEvent.Event, e);
|
||||
});
|
||||
room.updateMyMembership(KnownMembership.Invite);
|
||||
return;
|
||||
}
|
||||
|
||||
if (roomData.initial) {
|
||||
if (roomData.limited) {
|
||||
// set the back-pagination token. Do this *before* adding any
|
||||
// events so that clients can start back-paginating.
|
||||
room.getLiveTimeline().setPaginationToken(roomData.prev_batch ?? null, EventTimeline.BACKWARDS);
|
||||
@@ -728,6 +726,8 @@ export class SlidingSyncSdk {
|
||||
// synchronous execution prior to emitting SlidingSyncState.Complete
|
||||
room.updateMyMembership(KnownMembership.Join);
|
||||
|
||||
room.setMSC4186SummaryData(roomData.heroes, roomData.joined_count, roomData.invited_count);
|
||||
|
||||
room.recalculate();
|
||||
if (roomData.initial) {
|
||||
client.store.storeRoom(room);
|
||||
|
||||
+38
-329
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2022-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.
|
||||
@@ -18,7 +18,7 @@ import { logger } from "./logger.ts";
|
||||
import { type MatrixClient } from "./client.ts";
|
||||
import { type IRoomEvent, type IStateEvent } from "./sync-accumulator.ts";
|
||||
import { TypedEventEmitter } from "./models/typed-event-emitter.ts";
|
||||
import { sleep, type IDeferred, defer } from "./utils.ts";
|
||||
import { sleep } from "./utils.ts";
|
||||
import { type HTTPError } from "./http-api/index.ts";
|
||||
|
||||
// /sync requests allow you to set a timeout= but the request may continue
|
||||
@@ -82,10 +82,23 @@ export interface MSC3575SlidingSyncRequest {
|
||||
clientTimeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* New format of hero introduced in MSC4186 with display name and avatar URL
|
||||
* in addition to just user_id (as it is on the wire, with underscores)
|
||||
* as opposed to Hero in room-summary.ts which has fields in camelCase
|
||||
* (and also a flag to note what format the hero came from).
|
||||
*/
|
||||
export interface MSC4186Hero {
|
||||
user_id: string;
|
||||
displayname?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export interface MSC3575RoomData {
|
||||
name: string;
|
||||
required_state: IStateEvent[];
|
||||
timeline: (IRoomEvent | IStateEvent)[];
|
||||
heroes?: MSC4186Hero[];
|
||||
notification_count?: number;
|
||||
highlight_count?: number;
|
||||
joined_count?: number;
|
||||
@@ -96,41 +109,13 @@ export interface MSC3575RoomData {
|
||||
is_dm?: boolean;
|
||||
prev_batch?: string;
|
||||
num_live?: number;
|
||||
bump_stamp?: number;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
count: number;
|
||||
ops: Operation[];
|
||||
}
|
||||
|
||||
interface BaseOperation {
|
||||
op: string;
|
||||
}
|
||||
|
||||
interface DeleteOperation extends BaseOperation {
|
||||
op: "DELETE";
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface InsertOperation extends BaseOperation {
|
||||
op: "INSERT";
|
||||
index: number;
|
||||
room_id: string;
|
||||
}
|
||||
|
||||
interface InvalidateOperation extends BaseOperation {
|
||||
op: "INVALIDATE";
|
||||
range: [number, number];
|
||||
}
|
||||
|
||||
interface SyncOperation extends BaseOperation {
|
||||
op: "SYNC";
|
||||
range: [number, number];
|
||||
room_ids: string[];
|
||||
}
|
||||
|
||||
type Operation = DeleteOperation | InsertOperation | InvalidateOperation | SyncOperation;
|
||||
|
||||
/**
|
||||
* A complete Sliding Sync response
|
||||
*/
|
||||
@@ -163,7 +148,6 @@ class SlidingList {
|
||||
private isModified?: boolean;
|
||||
|
||||
// returned data
|
||||
public roomIndexToRoomId: Record<number, string> = {};
|
||||
public joinedCount = 0;
|
||||
|
||||
/**
|
||||
@@ -204,9 +188,6 @@ class SlidingList {
|
||||
// reset values as the join count may be very different (if filters changed) including the rooms
|
||||
// (e.g. sort orders or sliding window ranges changed)
|
||||
|
||||
// the constantly changing sliding window ranges. Not an array for performance reasons
|
||||
// E.g. tracking ranges 0-99, 500-599, we don't want to have a 600 element array
|
||||
this.roomIndexToRoomId = {};
|
||||
// the total number of joined rooms according to the server, always >= len(roomIndexToRoomId)
|
||||
this.joinedCount = 0;
|
||||
}
|
||||
@@ -226,26 +207,6 @@ class SlidingList {
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given index is within the list range. This is required even though the /sync API
|
||||
* provides explicit updates with index positions because of the following situation:
|
||||
* 0 1 2 3 4 5 6 7 8 indexes
|
||||
* a b c d e f COMMANDS: SYNC 0 2 a b c; SYNC 6 8 d e f;
|
||||
* a b c d _ f COMMAND: DELETE 7;
|
||||
* e a b c d f COMMAND: INSERT 0 e;
|
||||
* c=3 is wrong as we are not tracking it, ergo we need to see if `i` is in range else drop it
|
||||
* @param i - The index to check
|
||||
* @returns True if the index is within a sliding window
|
||||
*/
|
||||
public isIndexInRange(i: number): boolean {
|
||||
for (const r of this.list.ranges) {
|
||||
if (r[0] <= i && i <= r[1]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,10 +235,10 @@ export interface Extension<Req extends object, Res extends object> {
|
||||
/**
|
||||
* A function which is called when the request JSON is being formed.
|
||||
* Returns the data to insert under this key.
|
||||
* @param isInitial - True when this is part of the initial request (send sticky params)
|
||||
* @param isInitial - True when this is part of the initial request.
|
||||
* @returns The request JSON to send.
|
||||
*/
|
||||
onRequest(isInitial: boolean): Req | undefined;
|
||||
onRequest(isInitial: boolean): Promise<Req>;
|
||||
/**
|
||||
* A function which is called when there is response JSON under this extension.
|
||||
* @param data - The response JSON under the extension name.
|
||||
@@ -295,12 +256,10 @@ export interface Extension<Req extends object, Res extends object> {
|
||||
* of information when processing sync responses.
|
||||
* - RoomData: concerns rooms, useful for SlidingSyncSdk to update its knowledge of rooms.
|
||||
* - Lifecycle: concerns callbacks at various well-defined points in the sync process.
|
||||
* - List: concerns lists, useful for UI layers to re-render room lists.
|
||||
* Specifically, the order of event invocation is:
|
||||
* - Lifecycle (state=RequestFinished)
|
||||
* - RoomData (N times)
|
||||
* - Lifecycle (state=Complete)
|
||||
* - List (at most once per list)
|
||||
*/
|
||||
export enum SlidingSyncEvent {
|
||||
/**
|
||||
@@ -313,16 +272,9 @@ export enum SlidingSyncEvent {
|
||||
* - SlidingSyncState.RequestFinished: Fires after we receive a valid response but before the
|
||||
* response has been processed. Perform any pre-process steps here. If there was a problem syncing,
|
||||
* `err` will be set (e.g network errors).
|
||||
* - SlidingSyncState.Complete: Fires after all SlidingSyncEvent.RoomData have been fired but before
|
||||
* SlidingSyncEvent.List.
|
||||
* - SlidingSyncState.Complete: Fires after the response has been processed.
|
||||
*/
|
||||
Lifecycle = "SlidingSync.Lifecycle",
|
||||
/**
|
||||
* This event fires whenever there has been a change to this list index. It fires exactly once
|
||||
* per list, even if there were multiple operations for the list.
|
||||
* It fires AFTER Lifecycle and RoomData events.
|
||||
*/
|
||||
List = "SlidingSync.List",
|
||||
}
|
||||
|
||||
export type SlidingSyncEventHandlerMap = {
|
||||
@@ -332,7 +284,6 @@ export type SlidingSyncEventHandlerMap = {
|
||||
resp: MSC3575SlidingSyncResponse | null,
|
||||
err?: Error,
|
||||
) => void;
|
||||
[SlidingSyncEvent.List]: (listKey: string, joinedCount: number, roomIndexToRoomId: Record<number, string>) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -347,11 +298,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
private terminated = false;
|
||||
// flag set when resend() is called because we cannot rely on detecting AbortError in JS SDK :(
|
||||
private needsResend = false;
|
||||
// the txn_id to send with the next request.
|
||||
private txnId: string | null = null;
|
||||
// a list (in chronological order of when they were sent) of objects containing the txn ID and
|
||||
// a defer to resolve/reject depending on whether they were successfully sent or not.
|
||||
private txnIdDefers: (IDeferred<string> & { txnId: string })[] = [];
|
||||
// map of extension name to req/resp handler
|
||||
private extensions: Record<string, Extension<any, any>> = {};
|
||||
|
||||
@@ -426,14 +372,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
* @param key - The list key
|
||||
* @returns The list data which contains the rooms in this list
|
||||
*/
|
||||
public getListData(key: string): { joinedCount: number; roomIndexToRoomId: Record<number, string> } | null {
|
||||
public getListData(key: string): { joinedCount: number } | null {
|
||||
const data = this.lists.get(key);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
joinedCount: data.joinedCount,
|
||||
roomIndexToRoomId: Object.assign({}, data.roomIndexToRoomId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -461,13 +406,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
|
||||
* immediately after sending, in which case the action will be applied in the subsequent request)
|
||||
*/
|
||||
public setListRanges(key: string, ranges: number[][]): Promise<string> {
|
||||
public setListRanges(key: string, ranges: number[][]): void {
|
||||
const list = this.lists.get(key);
|
||||
if (!list) {
|
||||
return Promise.reject(new Error("no list with key " + key));
|
||||
throw new Error("no list with key " + key);
|
||||
}
|
||||
list.updateListRange(ranges);
|
||||
return this.resend();
|
||||
this.resend();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -479,7 +424,7 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
|
||||
* immediately after sending, in which case the action will be applied in the subsequent request)
|
||||
*/
|
||||
public setList(key: string, list: MSC3575List): Promise<string> {
|
||||
public setList(key: string, list: MSC3575List): void {
|
||||
const existingList = this.lists.get(key);
|
||||
if (existingList) {
|
||||
existingList.replaceList(list);
|
||||
@@ -488,7 +433,7 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
this.lists.set(key, new SlidingList(list));
|
||||
}
|
||||
this.listModifiedCount += 1;
|
||||
return this.resend();
|
||||
this.resend();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -504,27 +449,21 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
* /sync request to resend new subscriptions. If the /sync stream has not started, this will
|
||||
* prepare the room subscriptions for when start() is called.
|
||||
* @param s - The new desired room subscriptions.
|
||||
* @returns A promise which resolves to the transaction ID when it has been received down sync
|
||||
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
|
||||
* immediately after sending, in which case the action will be applied in the subsequent request)
|
||||
*/
|
||||
public modifyRoomSubscriptions(s: Set<string>): Promise<string> {
|
||||
public modifyRoomSubscriptions(s: Set<string>): void {
|
||||
this.desiredRoomSubscriptions = s;
|
||||
return this.resend();
|
||||
this.resend();
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify which events to retrieve for room subscriptions. Invalidates all room subscriptions
|
||||
* such that they will be sent up afresh.
|
||||
* @param rs - The new room subscription fields to fetch.
|
||||
* @returns A promise which resolves to the transaction ID when it has been received down sync
|
||||
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
|
||||
* immediately after sending, in which case the action will be applied in the subsequent request)
|
||||
*/
|
||||
public modifyRoomSubscriptionInfo(rs: MSC3575RoomSubscription): Promise<string> {
|
||||
public modifyRoomSubscriptionInfo(rs: MSC3575RoomSubscription): void {
|
||||
this.roomSubscriptionInfo = rs;
|
||||
this.confirmedRoomSubscriptions = new Set<string>();
|
||||
return this.resend();
|
||||
this.resend();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -538,11 +477,11 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
this.extensions[ext.name()] = ext;
|
||||
}
|
||||
|
||||
private getExtensionRequest(isInitial: boolean): Record<string, object | undefined> {
|
||||
private async getExtensionRequest(isInitial: boolean): Promise<Record<string, object | undefined>> {
|
||||
const ext: Record<string, object | undefined> = {};
|
||||
Object.keys(this.extensions).forEach((extName) => {
|
||||
ext[extName] = this.extensions[extName].onRequest(isInitial);
|
||||
});
|
||||
for (const extName in this.extensions) {
|
||||
ext[extName] = await this.extensions[extName].onRequest(isInitial);
|
||||
}
|
||||
return ext;
|
||||
}
|
||||
|
||||
@@ -595,203 +534,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
this.emit(SlidingSyncEvent.Lifecycle, state, resp, err);
|
||||
}
|
||||
|
||||
private shiftRight(listKey: string, hi: number, low: number): void {
|
||||
const list = this.lists.get(listKey);
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
// l h
|
||||
// 0,1,2,3,4 <- before
|
||||
// 0,1,2,2,3 <- after, hi is deleted and low is duplicated
|
||||
for (let i = hi; i > low; i--) {
|
||||
if (list.isIndexInRange(i)) {
|
||||
list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i - 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shiftLeft(listKey: string, hi: number, low: number): void {
|
||||
const list = this.lists.get(listKey);
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
// l h
|
||||
// 0,1,2,3,4 <- before
|
||||
// 0,1,3,4,4 <- after, low is deleted and hi is duplicated
|
||||
for (let i = low; i < hi; i++) {
|
||||
if (list.isIndexInRange(i)) {
|
||||
list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeEntry(listKey: string, index: number): void {
|
||||
const list = this.lists.get(listKey);
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
// work out the max index
|
||||
let max = -1;
|
||||
for (const n in list.roomIndexToRoomId) {
|
||||
if (Number(n) > max) {
|
||||
max = Number(n);
|
||||
}
|
||||
}
|
||||
if (max < 0 || index > max) {
|
||||
return;
|
||||
}
|
||||
// Everything higher than the gap needs to be shifted left.
|
||||
this.shiftLeft(listKey, max, index);
|
||||
delete list.roomIndexToRoomId[max];
|
||||
}
|
||||
|
||||
private addEntry(listKey: string, index: number): void {
|
||||
const list = this.lists.get(listKey);
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
// work out the max index
|
||||
let max = -1;
|
||||
for (const n in list.roomIndexToRoomId) {
|
||||
if (Number(n) > max) {
|
||||
max = Number(n);
|
||||
}
|
||||
}
|
||||
if (max < 0 || index > max) {
|
||||
return;
|
||||
}
|
||||
// Everything higher than the gap needs to be shifted right, +1 so we don't delete the highest element
|
||||
this.shiftRight(listKey, max + 1, index);
|
||||
}
|
||||
|
||||
private processListOps(list: ListResponse, listKey: string): void {
|
||||
let gapIndex = -1;
|
||||
const listData = this.lists.get(listKey);
|
||||
if (!listData) {
|
||||
return;
|
||||
}
|
||||
list.ops.forEach((op: Operation) => {
|
||||
if (!listData) {
|
||||
return;
|
||||
}
|
||||
switch (op.op) {
|
||||
case "DELETE": {
|
||||
logger.debug("DELETE", listKey, op.index, ";");
|
||||
delete listData.roomIndexToRoomId[op.index];
|
||||
if (gapIndex !== -1) {
|
||||
// we already have a DELETE operation to process, so process it.
|
||||
this.removeEntry(listKey, gapIndex);
|
||||
}
|
||||
gapIndex = op.index;
|
||||
break;
|
||||
}
|
||||
case "INSERT": {
|
||||
logger.debug("INSERT", listKey, op.index, op.room_id, ";");
|
||||
if (listData.roomIndexToRoomId[op.index]) {
|
||||
// something is in this space, shift items out of the way
|
||||
if (gapIndex < 0) {
|
||||
// we haven't been told where to shift from, so make way for a new room entry.
|
||||
this.addEntry(listKey, op.index);
|
||||
} else if (gapIndex > op.index) {
|
||||
// the gap is further down the list, shift every element to the right
|
||||
// starting at the gap so we can just shift each element in turn:
|
||||
// [A,B,C,_] gapIndex=3, op.index=0
|
||||
// [A,B,C,C] i=3
|
||||
// [A,B,B,C] i=2
|
||||
// [A,A,B,C] i=1
|
||||
// Terminate. We'll assign into op.index next.
|
||||
this.shiftRight(listKey, gapIndex, op.index);
|
||||
} else if (gapIndex < op.index) {
|
||||
// the gap is further up the list, shift every element to the left
|
||||
// starting at the gap so we can just shift each element in turn
|
||||
this.shiftLeft(listKey, op.index, gapIndex);
|
||||
}
|
||||
}
|
||||
// forget the gap, we don't need it anymore. This is outside the check for
|
||||
// a room being present in this index position because INSERTs always universally
|
||||
// forget the gap, not conditionally based on the presence of a room in the INSERT
|
||||
// position. Without this, DELETE 0; INSERT 0; would do the wrong thing.
|
||||
gapIndex = -1;
|
||||
listData.roomIndexToRoomId[op.index] = op.room_id;
|
||||
break;
|
||||
}
|
||||
case "INVALIDATE": {
|
||||
const startIndex = op.range[0];
|
||||
for (let i = startIndex; i <= op.range[1]; i++) {
|
||||
delete listData.roomIndexToRoomId[i];
|
||||
}
|
||||
logger.debug("INVALIDATE", listKey, op.range[0], op.range[1], ";");
|
||||
break;
|
||||
}
|
||||
case "SYNC": {
|
||||
const startIndex = op.range[0];
|
||||
for (let i = startIndex; i <= op.range[1]; i++) {
|
||||
const roomId = op.room_ids[i - startIndex];
|
||||
if (!roomId) {
|
||||
break; // we are at the end of list
|
||||
}
|
||||
listData.roomIndexToRoomId[i] = roomId;
|
||||
}
|
||||
logger.debug("SYNC", listKey, op.range[0], op.range[1], (op.room_ids || []).join(" "), ";");
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (gapIndex !== -1) {
|
||||
// we already have a DELETE operation to process, so process it
|
||||
// Everything higher than the gap needs to be shifted left.
|
||||
this.removeEntry(listKey, gapIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend a Sliding Sync request. Used when something has changed in the request. Resolves with
|
||||
* the transaction ID of this request on success. Rejects with the transaction ID of this request
|
||||
* on failure.
|
||||
* Resend a Sliding Sync request. Used when something has changed in the request.
|
||||
*/
|
||||
public resend(): Promise<string> {
|
||||
if (this.needsResend && this.txnIdDefers.length > 0) {
|
||||
// we already have a resend queued, so just return the same promise
|
||||
return this.txnIdDefers[this.txnIdDefers.length - 1].promise;
|
||||
}
|
||||
public resend(): void {
|
||||
this.needsResend = true;
|
||||
this.txnId = this.client.makeTxnId();
|
||||
const d = defer<string>();
|
||||
this.txnIdDefers.push({
|
||||
...d,
|
||||
txnId: this.txnId,
|
||||
});
|
||||
this.abortController?.abort();
|
||||
this.abortController = new AbortController();
|
||||
return d.promise;
|
||||
}
|
||||
|
||||
private resolveTransactionDefers(txnId?: string): void {
|
||||
if (!txnId) {
|
||||
return;
|
||||
}
|
||||
// find the matching index
|
||||
let txnIndex = -1;
|
||||
for (let i = 0; i < this.txnIdDefers.length; i++) {
|
||||
if (this.txnIdDefers[i].txnId === txnId) {
|
||||
txnIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (txnIndex === -1) {
|
||||
// this shouldn't happen; we shouldn't be seeing txn_ids for things we don't know about,
|
||||
// whine about it.
|
||||
logger.warn(`resolveTransactionDefers: seen ${txnId} but it isn't a pending txn, ignoring.`);
|
||||
return;
|
||||
}
|
||||
// This list is sorted in time, so if the input txnId ACKs in the middle of this array,
|
||||
// then everything before it that hasn't been ACKed yet never will and we should reject them.
|
||||
for (let i = 0; i < txnIndex; i++) {
|
||||
this.txnIdDefers[i].reject(this.txnIdDefers[i].txnId);
|
||||
}
|
||||
this.txnIdDefers[txnIndex].resolve(txnId);
|
||||
// clear out settled promises, including the one we resolved.
|
||||
this.txnIdDefers = this.txnIdDefers.slice(txnIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -802,7 +551,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
this.abortController?.abort();
|
||||
// remove all listeners so things can be GC'd
|
||||
this.removeAllListeners(SlidingSyncEvent.Lifecycle);
|
||||
this.removeAllListeners(SlidingSyncEvent.List);
|
||||
this.removeAllListeners(SlidingSyncEvent.RoomData);
|
||||
}
|
||||
|
||||
@@ -811,20 +559,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
*/
|
||||
private resetup(): void {
|
||||
logger.warn("SlidingSync: resetting connection info");
|
||||
// any pending txn ID defers will be forgotten already by the server, so clear them out
|
||||
this.txnIdDefers.forEach((d) => {
|
||||
d.reject(d.txnId);
|
||||
});
|
||||
this.txnIdDefers = [];
|
||||
// resend sticky params and de-confirm all subscriptions
|
||||
this.lists.forEach((l) => {
|
||||
l.setModified(true);
|
||||
});
|
||||
this.confirmedRoomSubscriptions = new Set<string>(); // leave desired ones alone though!
|
||||
// reset the connection as we might be wedged
|
||||
this.needsResend = true;
|
||||
this.abortController?.abort();
|
||||
this.abortController = new AbortController();
|
||||
this.resend();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -836,20 +577,18 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
let currentPos: string | undefined;
|
||||
while (!this.terminated) {
|
||||
this.needsResend = false;
|
||||
let doNotUpdateList = false;
|
||||
let resp: MSC3575SlidingSyncResponse | undefined;
|
||||
try {
|
||||
const listModifiedCount = this.listModifiedCount;
|
||||
const reqLists: Record<string, MSC3575List> = {};
|
||||
this.lists.forEach((l: SlidingList, key: string) => {
|
||||
reqLists[key] = l.getList(false);
|
||||
reqLists[key] = l.getList(true);
|
||||
});
|
||||
const reqBody: MSC3575SlidingSyncRequest = {
|
||||
lists: reqLists,
|
||||
pos: currentPos,
|
||||
timeout: this.timeoutMS,
|
||||
clientTimeout: this.timeoutMS + BUFFER_PERIOD_MS,
|
||||
extensions: this.getExtensionRequest(currentPos === undefined),
|
||||
extensions: await this.getExtensionRequest(currentPos === undefined),
|
||||
};
|
||||
// check if we are (un)subscribing to a room and modify request this one time for it
|
||||
const newSubscriptions = difference(this.desiredRoomSubscriptions, this.confirmedRoomSubscriptions);
|
||||
@@ -868,10 +607,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
reqBody.room_subscriptions[roomId] = sub;
|
||||
}
|
||||
}
|
||||
if (this.txnId) {
|
||||
reqBody.txn_id = this.txnId;
|
||||
this.txnId = null;
|
||||
}
|
||||
this.pendingReq = this.client.slidingSync(reqBody, this.proxyBaseUrl, this.abortController.signal);
|
||||
resp = await this.pendingReq;
|
||||
currentPos = resp.pos;
|
||||
@@ -882,13 +617,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
for (const roomId of unsubscriptions) {
|
||||
this.confirmedRoomSubscriptions.delete(roomId);
|
||||
}
|
||||
if (listModifiedCount !== this.listModifiedCount) {
|
||||
// the lists have been modified whilst we were waiting for 'await' to return, but the abort()
|
||||
// call did nothing. It is NOT SAFE to modify the list array now. We'll process the response but
|
||||
// not update list pointers.
|
||||
logger.debug("list modified during await call, not updating list");
|
||||
doNotUpdateList = true;
|
||||
}
|
||||
// mark all these lists as having been sent as sticky so we don't keep sending sticky params
|
||||
this.lists.forEach((l) => {
|
||||
l.setModified(false);
|
||||
@@ -931,27 +659,8 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
await this.invokeRoomDataListeners(roomId, resp!.rooms[roomId]);
|
||||
}
|
||||
|
||||
const listKeysWithUpdates: Set<string> = new Set();
|
||||
if (!doNotUpdateList) {
|
||||
for (const [key, list] of Object.entries(resp.lists)) {
|
||||
list.ops = list.ops ?? [];
|
||||
if (list.ops.length > 0) {
|
||||
listKeysWithUpdates.add(key);
|
||||
}
|
||||
this.processListOps(list, key);
|
||||
}
|
||||
}
|
||||
this.invokeLifecycleListeners(SlidingSyncState.Complete, resp);
|
||||
await this.onPostExtensionsResponse(resp.extensions);
|
||||
listKeysWithUpdates.forEach((listKey: string) => {
|
||||
const list = this.lists.get(listKey);
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
this.emit(SlidingSyncEvent.List, listKey, list.joinedCount, Object.assign({}, list.roomIndexToRoomId));
|
||||
});
|
||||
|
||||
this.resolveTransactionDefers(resp.txn_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +405,23 @@ export async function logDuration<T>(logger: BaseLogger, name: string, block: ()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to log the duration of a synchronous block.
|
||||
*
|
||||
* @param logger - The logger to log to.
|
||||
* @param name - The name of the operation.
|
||||
* @param block - The block to execute.
|
||||
*/
|
||||
export function logDurationSync<T>(logger: BaseLogger, name: string, block: () => T): T {
|
||||
const start = Date.now();
|
||||
try {
|
||||
return block();
|
||||
} finally {
|
||||
const end = Date.now();
|
||||
logger.debug(`[Perf]: ${name} took ${end - start}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise/async version of {@link setImmediate}.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user