1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-30 04:23:07 +03:00

Merge branch 'develop' into madlittlemods/stablize-msc3030-timestamp-to-event

This commit is contained in:
Andy Balaam
2023-01-06 15:38:08 +00:00
committed by GitHub
53 changed files with 5895 additions and 4735 deletions

View File

@ -32,9 +32,10 @@ jobs:
ref: gh-pages ref: gh-pages
- name: 🔪 Prepare - name: 🔪 Prepare
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
run: | run: |
tag="${{ github.ref_name }}" VERSION="${GITHUB_REF_NAME#v}"
VERSION="${tag#v}"
[ ! -e "$VERSION" ] || rm -r $VERSION [ ! -e "$VERSION" ] || rm -r $VERSION
cp -r $RUNNER_TEMP/_docs/ $VERSION cp -r $RUNNER_TEMP/_docs/ $VERSION

View File

@ -21,3 +21,6 @@ out
.vscode .vscode
.vscode/ .vscode/
# This file is owned, parsed, and generated by allchange, which doesn't comply with prettier
/CHANGELOG.md

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
To try it out, **you must build the SDK first** and then host this folder: To try it out, **you must build the SDK first** and then host this folder:
``` ```
$ yarn install
$ yarn build $ yarn build
$ cd examples/browser $ cd examples/browser
$ python -m http.server 8003 $ python -m http.server 8003

View File

@ -1,6 +1,6 @@
{ {
"name": "matrix-js-sdk", "name": "matrix-js-sdk",
"version": "22.0.0", "version": "23.0.0",
"description": "Matrix Client-Server SDK for Javascript", "description": "Matrix Client-Server SDK for Javascript",
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"

View File

@ -21,9 +21,9 @@ if [ "$(git branch -lr | grep origin/develop -c)" -ge 1 ]; then
# to the TypeScript source. # to the TypeScript source.
src_value=$(jq -r ".matrix_src_$i" package.json) src_value=$(jq -r ".matrix_src_$i" package.json)
if [ "$src_value" != "null" ]; then if [ "$src_value" != "null" ]; then
jq ".$i = .matrix_src_$i" package.json > package.json.new && mv package.json.new package.json jq ".$i = .matrix_src_$i" package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json
else else
jq "del(.$i)" package.json > package.json.new && mv package.json.new package.json jq "del(.$i)" package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json
fi fi
fi fi
done done

View File

@ -184,7 +184,7 @@ for i in main typings
do do
lib_value=$(jq -r ".matrix_lib_$i" package.json) lib_value=$(jq -r ".matrix_lib_$i" package.json)
if [ "$lib_value" != "null" ]; then if [ "$lib_value" != "null" ]; then
jq ".$i = .matrix_lib_$i" package.json > package.json.new && mv package.json.new package.json jq ".$i = .matrix_lib_$i" package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json
fi fi
done done

View File

@ -24,7 +24,7 @@ import MockHttpBackend from "matrix-mock-request";
import { LocalStorageCryptoStore } from "../src/crypto/store/localStorage-crypto-store"; import { LocalStorageCryptoStore } from "../src/crypto/store/localStorage-crypto-store";
import { logger } from "../src/logger"; import { logger } from "../src/logger";
import { syncPromise } from "./test-utils/test-utils"; import { syncPromise } from "./test-utils/test-utils";
import { createClient } from "../src/matrix"; import { createClient, IStartClientOpts } from "../src/matrix";
import { ICreateClientOpts, IDownloadKeyResult, MatrixClient, PendingEventOrdering } from "../src/client"; import { ICreateClientOpts, IDownloadKeyResult, MatrixClient, PendingEventOrdering } from "../src/client";
import { MockStorageApi } from "./MockStorageApi"; import { MockStorageApi } from "./MockStorageApi";
import { encodeUri } from "../src/utils"; import { encodeUri } from "../src/utils";
@ -79,9 +79,12 @@ export class TestClient {
/** /**
* start the client, and wait for it to initialise. * start the client, and wait for it to initialise.
*/ */
public start(): Promise<void> { public start(opts: IStartClientOpts = {}): Promise<void> {
logger.log(this + ": starting"); logger.log(this + ": starting");
this.httpBackend.when("GET", "/versions").respond(200, {}); this.httpBackend.when("GET", "/versions").respond(200, {
// we have tests that rely on support for lazy-loading members
versions: ["r0.5.0"],
});
this.httpBackend.when("GET", "/pushrules").respond(200, {}); this.httpBackend.when("GET", "/pushrules").respond(200, {});
this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
this.expectDeviceKeyUpload(); this.expectDeviceKeyUpload();
@ -93,6 +96,8 @@ export class TestClient {
this.client.startClient({ this.client.startClient({
// set this so that we can get hold of failed events // set this so that we can get hold of failed events
pendingEventOrdering: PendingEventOrdering.Detached, pendingEventOrdering: PendingEventOrdering.Detached,
...opts,
}); });
return Promise.all([this.httpBackend.flushAllExpected(), syncPromise(this.client)]).then(() => { return Promise.all([this.httpBackend.flushAllExpected(), syncPromise(this.client)]).then(() => {

View File

@ -1016,6 +1016,61 @@ describe("MatrixClient event timelines", function () {
httpBackend.flushAllExpected(), httpBackend.flushAllExpected(),
]); ]);
}); });
it("should create threads for thread roots discovered", function () {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!))
.respond(200, function () {
return {
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
};
});
httpBackend
.when("GET", "/rooms/!foo%3Abar/messages")
.check(function (req) {
const params = req.queryParams!;
expect(params.dir).toEqual("b");
expect(params.from).toEqual("start_token0");
expect(params.limit).toEqual("30");
})
.respond(200, function () {
return {
chunk: [EVENTS[1], EVENTS[2], THREAD_ROOT],
end: "start_token1",
};
});
let tl: EventTimeline;
return Promise.all([
client
.getEventTimeline(timelineSet, EVENTS[0].event_id!)
.then(function (tl0) {
tl = tl0!;
return client.paginateEventTimeline(tl, { backwards: true });
})
.then(function (success) {
expect(success).toBeTruthy();
expect(tl!.getEvents().length).toEqual(4);
expect(tl!.getEvents()[0].event).toEqual(THREAD_ROOT);
expect(tl!.getEvents()[1].event).toEqual(EVENTS[2]);
expect(tl!.getEvents()[2].event).toEqual(EVENTS[1]);
expect(tl!.getEvents()[3].event).toEqual(EVENTS[0]);
expect(room.getThreads().map((it) => it.id)).toEqual([THREAD_ROOT.event_id!]);
expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("start_token1");
expect(tl!.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token0");
}),
httpBackend.flushAllExpected(),
]);
});
}); });
it("should ensure thread events are ordered correctly", async () => { it("should ensure thread events are ordered correctly", async () => {

View File

@ -35,9 +35,7 @@ describe("MatrixClient", function () {
let store: MemoryStore | undefined; let store: MemoryStore | undefined;
const defaultClientOpts: IStoredClientOpts = { const defaultClientOpts: IStoredClientOpts = {
canResetEntireTimeline: (roomId) => false,
experimentalThreadSupport: false, experimentalThreadSupport: false,
crypto: {} as unknown as IStoredClientOpts["crypto"],
}; };
const setupTests = (): [MatrixClient, HttpBackend, MemoryStore] => { const setupTests = (): [MatrixClient, HttpBackend, MemoryStore] => {
const store = new MemoryStore(); const store = new MemoryStore();

View File

@ -1543,6 +1543,52 @@ describe("MatrixClient syncing", () => {
}); });
}); });
}); });
it("only replays receipts relevant to the current context", async () => {
const THREAD_ID = "$unknownthread:localhost";
const receipt = {
type: "m.receipt",
room_id: "!foo:bar",
content: {
"$event1:localhost": {
[ReceiptType.Read]: {
"@alice:localhost": { ts: 666, thread_id: THREAD_ID },
},
},
"$otherevent:localhost": {
[ReceiptType.Read]: {
"@alice:localhost": { ts: 999, thread_id: "$otherthread:localhost" },
},
},
},
};
syncData.rooms.join[roomOne].ephemeral.events = [receipt];
httpBackend!.when("GET", "/sync").respond(200, syncData);
client!.startClient();
return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]).then(() => {
const room = client?.getRoom(roomOne);
expect(room).toBeInstanceOf(Room);
expect(room?.cachedThreadReadReceipts.has(THREAD_ID)).toBe(true);
const thread = room!.createThread(THREAD_ID, undefined, [], true);
expect(room?.cachedThreadReadReceipts.has(THREAD_ID)).toBe(false);
const receipt = thread.getReadReceiptForUserId("@alice:localhost");
expect(receipt).toStrictEqual({
data: {
thread_id: "$unknownthread:localhost",
ts: 666,
},
eventId: "$event1:localhost",
});
});
});
}); });
describe("of a room", () => { describe("of a room", () => {

View File

@ -1590,4 +1590,92 @@ describe("megolm", () => {
aliceTestClient.httpBackend.flush("/send/m.room.encrypted/", 1), aliceTestClient.httpBackend.flush("/send/m.room.encrypted/", 1),
]); ]);
}); });
describe("Lazy-loading member lists", () => {
let p2pSession: Olm.Session;
beforeEach(async () => {
// set up the aliceTestClient so that it is a room with no known members
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await aliceTestClient.start({ lazyLoadMembers: true });
aliceTestClient.client.setGlobalErrorOnUnknownDevices(false);
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse([]));
await aliceTestClient.flushSync();
p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount);
});
async function expectMembershipRequest(roomId: string, members: string[]): Promise<void> {
const membersPath = `/rooms/${encodeURIComponent(roomId)}/members?not_membership=leave`;
aliceTestClient.httpBackend.when("GET", membersPath).respond(200, {
chunk: [
testUtils.mkMembershipCustom({
membership: "join",
sender: "@bob:xyz",
}),
],
});
await aliceTestClient.httpBackend.flush(membersPath, 1);
}
it("Sending an event initiates a member list sync", async () => {
// we expect a call to the /members list...
const memberListPromise = expectMembershipRequest(ROOM_ID, ["@bob:xyz"]);
// then a request for bob's devices...
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz"));
// then a to-device with the room_key
const inboundGroupSessionPromise = expectSendRoomKey(
aliceTestClient.httpBackend,
"@bob:xyz",
testOlmAccount,
p2pSession,
);
// and finally the megolm message
const megolmMessagePromise = expectSendMegolmMessage(
aliceTestClient.httpBackend,
inboundGroupSessionPromise,
);
// kick it off
const sendPromise = aliceTestClient.client.sendTextMessage(ROOM_ID, "test");
await Promise.all([
sendPromise,
megolmMessagePromise,
memberListPromise,
aliceTestClient.httpBackend.flush("/keys/query", 1),
]);
});
it("loading the membership list inhibits a later load", async () => {
const room = aliceTestClient.client.getRoom(ROOM_ID)!;
await Promise.all([room.loadMembersIfNeeded(), expectMembershipRequest(ROOM_ID, ["@bob:xyz"])]);
// expect a request for bob's devices...
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz"));
// then a to-device with the room_key
const inboundGroupSessionPromise = expectSendRoomKey(
aliceTestClient.httpBackend,
"@bob:xyz",
testOlmAccount,
p2pSession,
);
// and finally the megolm message
const megolmMessagePromise = expectSendMegolmMessage(
aliceTestClient.httpBackend,
inboundGroupSessionPromise,
);
// kick it off
const sendPromise = aliceTestClient.client.sendTextMessage(ROOM_ID, "test");
await Promise.all([sendPromise, megolmMessagePromise, aliceTestClient.httpBackend.flush("/keys/query", 1)]);
});
});
}); });

View File

@ -38,7 +38,7 @@ import {
IRoomTimelineData, IRoomTimelineData,
} from "../../src"; } from "../../src";
import { SlidingSyncSdk } from "../../src/sliding-sync-sdk"; import { SlidingSyncSdk } from "../../src/sliding-sync-sdk";
import { SyncState } from "../../src/sync"; import { SyncApiOptions, SyncState } from "../../src/sync";
import { IStoredClientOpts } from "../../src/client"; import { IStoredClientOpts } from "../../src/client";
import { logger } from "../../src/logger"; import { logger } from "../../src/logger";
import { emitPromise } from "../test-utils/test-utils"; import { emitPromise } from "../test-utils/test-utils";
@ -111,6 +111,7 @@ describe("SlidingSyncSdk", () => {
// assign client/httpBackend globals // assign client/httpBackend globals
const setupClient = async (testOpts?: Partial<IStoredClientOpts & { withCrypto: boolean }>) => { const setupClient = async (testOpts?: Partial<IStoredClientOpts & { withCrypto: boolean }>) => {
testOpts = testOpts || {}; testOpts = testOpts || {};
const syncOpts: SyncApiOptions = {};
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken); const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
httpBackend = testClient.httpBackend; httpBackend = testClient.httpBackend;
client = testClient.client; client = testClient.client;
@ -118,10 +119,10 @@ describe("SlidingSyncSdk", () => {
if (testOpts.withCrypto) { if (testOpts.withCrypto) {
httpBackend!.when("GET", "/room_keys/version").respond(404, {}); httpBackend!.when("GET", "/room_keys/version").respond(404, {});
await client!.initCrypto(); await client!.initCrypto();
testOpts.crypto = client!.crypto; syncOpts.cryptoCallbacks = syncOpts.crypto = client!.crypto;
} }
httpBackend!.when("GET", "/_matrix/client/r0/pushrules").respond(200, {}); httpBackend!.when("GET", "/_matrix/client/r0/pushrules").respond(200, {});
sdk = new SlidingSyncSdk(mockSlidingSync, client, testOpts); sdk = new SlidingSyncSdk(mockSlidingSync, client, testOpts, syncOpts);
}; };
// tear down client/httpBackend globals // tear down client/httpBackend globals

View File

@ -1418,6 +1418,102 @@ describe("SlidingSync", () => {
await httpBackend!.flushAllExpected(); await httpBackend!.flushAllExpected();
slidingSync.stop(); slidingSync.stop();
}); });
it("should not be possible to add/modify an already added custom subscription", async () => {
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
slidingSync.addCustomSubscription(customSubName1, customSub1);
slidingSync.addCustomSubscription(customSubName1, customSub2);
slidingSync.useCustomSubscription(roomA, customSubName1);
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomA]).toEqual(customSub1);
})
.respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
slidingSync.stop();
});
it("should change the custom subscription if they are different", async () => {
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
slidingSync.addCustomSubscription(customSubName1, customSub1);
slidingSync.addCustomSubscription(customSubName2, customSub2);
slidingSync.useCustomSubscription(roomA, customSubName1);
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomA]).toEqual(customSub1);
expect(body.unsubscribe_rooms).toBeUndefined();
})
.respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
// using the same subscription doesn't unsub nor changes subscriptions
slidingSync.useCustomSubscription(roomA, customSubName1);
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeUndefined();
expect(body.unsubscribe_rooms).toBeUndefined();
})
.respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
// Changing the subscription works
slidingSync.useCustomSubscription(roomA, customSubName2);
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomA]).toEqual(customSub2);
expect(body.unsubscribe_rooms).toBeUndefined();
})
.respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
slidingSync.stop();
});
}); });
describe("extensions", () => { describe("extensions", () => {

View File

@ -513,9 +513,6 @@ export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandle
public sendMetadataUpdate = jest.fn<void, []>(); public sendMetadataUpdate = jest.fn<void, []>();
public on = jest.fn();
public removeListener = jest.fn();
public getOpponentMember(): Partial<RoomMember> { public getOpponentMember(): Partial<RoomMember> {
return this.opponentMember; return this.opponentMember;
} }

View File

@ -544,7 +544,7 @@ describe("AutoDiscovery", function () {
.respond(200, { .respond(200, {
versions: ["r0.0.1"], versions: ["r0.0.1"],
}); });
httpBackend.when("GET", "/_matrix/identity/api/v1").respond(404, {}); httpBackend.when("GET", "/_matrix/identity/v2").respond(404, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": { "m.homeserver": {
// Note: we also expect this test to trim the trailing slash // Note: we also expect this test to trim the trailing slash
@ -591,7 +591,7 @@ describe("AutoDiscovery", function () {
.respond(200, { .respond(200, {
versions: ["r0.0.1"], versions: ["r0.0.1"],
}); });
httpBackend.when("GET", "/_matrix/identity/api/v1").respond(500, {}); httpBackend.when("GET", "/_matrix/identity/v2").respond(500, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": { "m.homeserver": {
// Note: we also expect this test to trim the trailing slash // Note: we also expect this test to trim the trailing slash
@ -636,9 +636,9 @@ describe("AutoDiscovery", function () {
versions: ["r0.0.1"], versions: ["r0.0.1"],
}); });
httpBackend httpBackend
.when("GET", "/_matrix/identity/api/v1") .when("GET", "/_matrix/identity/v2")
.check((req) => { .check((req) => {
expect(req.path).toEqual("https://identity.example.org/_matrix/identity/api/v1"); expect(req.path).toEqual("https://identity.example.org/_matrix/identity/v2");
}) })
.respond(200, {}); .respond(200, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
@ -682,9 +682,9 @@ describe("AutoDiscovery", function () {
versions: ["r0.0.1"], versions: ["r0.0.1"],
}); });
httpBackend httpBackend
.when("GET", "/_matrix/identity/api/v1") .when("GET", "/_matrix/identity/v2")
.check((req) => { .check((req) => {
expect(req.path).toEqual("https://identity.example.org/_matrix/identity/api/v1"); expect(req.path).toEqual("https://identity.example.org/_matrix/identity/v2");
}) })
.respond(200, {}); .respond(200, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {

View File

@ -167,6 +167,38 @@ describe("Crypto", function () {
client.stopClient(); client.stopClient();
}); });
it("doesn't throw an error when attempting to decrypt a redacted event", async () => {
const client = new TestClient("@alice:example.com", "deviceid").client;
await client.initCrypto();
const event = new MatrixEvent({
content: {},
event_id: "$event_id",
room_id: "!room_id",
sender: "@bob:example.com",
type: "m.room.encrypted",
unsigned: {
redacted_because: {
content: {},
event_id: "$redaction_event_id",
redacts: "$event_id",
room_id: "!room_id",
origin_server_ts: 1234567890,
sender: "@bob:example.com",
type: "m.room.redaction",
unsigned: {},
},
},
});
await event.attemptDecryption(client.crypto!);
expect(event.isDecryptionFailure()).toBeFalsy();
// since the redaction event isn't encrypted, the redacted_because
// should be the same as in the original event
expect(event.getRedactionEvent()).toEqual(event.getUnsigned().redacted_because);
client.stopClient();
});
}); });
describe("Session management", function () { describe("Session management", function () {

View File

@ -1,56 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "../../../olm-loader";
import { CRYPTO_ENABLED, MatrixClient } from "../../../../src/client";
import { TestClient } from "../../../TestClient";
const Olm = global.Olm;
describe("crypto.setDeviceVerification", () => {
const userId = "@alice:example.com";
const deviceId1 = "device1";
let client: MatrixClient;
if (!CRYPTO_ENABLED) {
return;
}
beforeAll(async () => {
await Olm.init();
});
beforeEach(async () => {
client = new TestClient(userId, deviceId1).client;
await client.initCrypto();
});
it("client should provide crypto", () => {
expect(client.crypto).not.toBeUndefined();
});
describe("when setting an own device as verified", () => {
beforeEach(async () => {
jest.spyOn(client.crypto!, "cancelAndResendAllOutgoingKeyRequests");
await client.crypto!.setDeviceVerification(userId, deviceId1, true);
});
it("cancelAndResendAllOutgoingKeyRequests should be called", () => {
expect(client.crypto!.cancelAndResendAllOutgoingKeyRequests).toHaveBeenCalled();
});
});
});

View File

@ -24,10 +24,13 @@ import {
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
MatrixEventEvent, MatrixEventEvent,
RelationType,
Room, Room,
RoomEvent,
} from "../../src"; } from "../../src";
import { Thread } from "../../src/models/thread"; import { FeatureSupport, Thread } from "../../src/models/thread";
import { ReEmitter } from "../../src/ReEmitter"; import { ReEmitter } from "../../src/ReEmitter";
import { eventMapperFor } from "../../src/event-mapper";
describe("EventTimelineSet", () => { describe("EventTimelineSet", () => {
const roomId = "!foo:bar"; const roomId = "!foo:bar";
@ -202,6 +205,88 @@ describe("EventTimelineSet", () => {
expect(liveTimeline.getEvents().length).toStrictEqual(0); expect(liveTimeline.getEvents().length).toStrictEqual(0);
}); });
it("should allow edits to be added to thread timeline", async () => {
jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true);
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
Thread.hasServerSideSupport = FeatureSupport.Stable;
const sender = "@alice:matrix.org";
const root = utils.mkEvent({
event: true,
content: {
body: "Thread root",
},
type: EventType.RoomMessage,
sender,
});
room.addLiveEvents([root]);
const threadReply = utils.mkEvent({
event: true,
content: {
"body": "Thread reply",
"m.relates_to": {
event_id: root.getId()!,
rel_type: RelationType.Thread,
},
},
type: EventType.RoomMessage,
sender,
});
root.setUnsigned({
"m.relations": {
[RelationType.Thread]: {
count: 1,
latest_event: {
content: threadReply.getContent(),
origin_server_ts: 5,
room_id: room.roomId,
sender,
type: EventType.RoomMessage,
event_id: threadReply.getId()!,
user_id: sender,
age: 1,
},
current_user_participated: true,
},
},
});
const editToThreadReply = utils.mkEvent({
event: true,
content: {
"body": " * edit",
"m.new_content": {
"body": "edit",
"msgtype": "m.text",
"org.matrix.msc1767.text": "edit",
},
"m.relates_to": {
event_id: threadReply.getId()!,
rel_type: RelationType.Replace,
},
},
type: EventType.RoomMessage,
sender,
});
jest.spyOn(client, "paginateEventTimeline").mockImplementation(async () => {
thread.timelineSet.getLiveTimeline().addEvent(threadReply, { toStartOfTimeline: true });
return true;
});
jest.spyOn(client, "relations").mockResolvedValue({
events: [],
});
const thread = room.createThread(root.getId()!, root, [threadReply, editToThreadReply], false);
thread.once(RoomEvent.TimelineReset, () => {
const lastEvent = thread.timeline.at(-1)!;
expect(lastEvent.getContent().body).toBe(" * edit");
});
});
describe("non-room timeline", () => { describe("non-room timeline", () => {
it("Adds event to timeline", () => { it("Adds event to timeline", () => {
const nonRoomEventTimelineSet = new EventTimelineSet( const nonRoomEventTimelineSet = new EventTimelineSet(

View File

@ -22,11 +22,13 @@ import { Filter } from "../../src/filter";
import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace"; import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace";
import { import {
EventType, EventType,
RelationType,
RoomCreateTypeField, RoomCreateTypeField,
RoomType, RoomType,
UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_ENABLED,
UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3088_PURPOSE,
UNSTABLE_MSC3089_TREE_SUBTYPE, UNSTABLE_MSC3089_TREE_SUBTYPE,
MSC3912_RELATION_BASED_REDACTIONS_PROP,
} from "../../src/@types/event"; } from "../../src/@types/event";
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib"; import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
import { Crypto } from "../../src/crypto"; import { Crypto } from "../../src/crypto";
@ -170,6 +172,10 @@ describe("MatrixClient", function () {
data: SYNC_DATA, data: SYNC_DATA,
}; };
const unstableFeatures: Record<string, boolean> = {
"org.matrix.msc3440.stable": true,
};
// items are popped off when processed and block if no items left. // items are popped off when processed and block if no items left.
let httpLookups: HttpLookup[] = []; let httpLookups: HttpLookup[] = [];
let acceptKeepalives: boolean; let acceptKeepalives: boolean;
@ -188,9 +194,7 @@ describe("MatrixClient", function () {
const { prefix } = requestOpts; const { prefix } = requestOpts;
if (path === KEEP_ALIVE_PATH && acceptKeepalives) { if (path === KEEP_ALIVE_PATH && acceptKeepalives) {
return Promise.resolve({ return Promise.resolve({
unstable_features: { unstable_features: unstableFeatures,
"org.matrix.msc3440.stable": true,
},
versions: ["r0.6.0", "r0.6.1"], versions: ["r0.6.0", "r0.6.1"],
}); });
} }
@ -1314,6 +1318,59 @@ describe("MatrixClient", function () {
await client.redactEvent(roomId, eventId, txnId, { reason }); await client.redactEvent(roomId, eventId, txnId, { reason });
}); });
describe("when calling with with_relations", () => {
const eventId = "$event42:example.org";
it("should raise an error if server has no support for relation based redactions", async () => {
// load supported features
await client.getVersions();
const txnId = client.makeTxnId();
expect(() => {
client.redactEvent(roomId, eventId, txnId, {
with_relations: [RelationType.Reference],
});
}).toThrowError(
new Error(
"Server does not support relation based redactions " +
`roomId ${roomId} eventId ${eventId} txnId: ${txnId} threadId null`,
),
);
});
describe("and the server supports relation based redactions (unstable)", () => {
beforeEach(async () => {
unstableFeatures["org.matrix.msc3912"] = true;
// load supported features
await client.getVersions();
});
it("should send with_relations in the request body", async () => {
const txnId = client.makeTxnId();
httpLookups = [
{
method: "PUT",
path:
`/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}` +
`/${encodeURIComponent(txnId)}`,
expectBody: {
reason: "redaction test",
[MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!]: [RelationType.Reference],
},
data: { event_id: eventId },
},
];
await client.redactEvent(roomId, eventId, txnId, {
reason: "redaction test",
with_relations: [RelationType.Reference],
});
});
});
});
}); });
describe("cancelPendingEvent", () => { describe("cancelPendingEvent", () => {

View File

@ -16,7 +16,6 @@ limitations under the License.
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event"; import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
import { emitPromise } from "../../test-utils/test-utils"; import { emitPromise } from "../../test-utils/test-utils";
import { EventType } from "../../../src";
import { Crypto } from "../../../src/crypto"; import { Crypto } from "../../../src/crypto";
describe("MatrixEvent", () => { describe("MatrixEvent", () => {
@ -88,22 +87,6 @@ describe("MatrixEvent", () => {
expect(ev.getWireContent().ciphertext).toBeUndefined(); expect(ev.getWireContent().ciphertext).toBeUndefined();
}); });
it("should abort decryption if fails with an error other than a DecryptionError", async () => {
const ev = new MatrixEvent({
type: EventType.RoomMessageEncrypted,
content: {
body: "Test",
},
event_id: "$event1:server",
});
await ev.attemptDecryption({
decryptEvent: jest.fn().mockRejectedValue(new Error("Not a DecryptionError")),
} as unknown as Crypto);
expect(ev.isEncrypted()).toBeTruthy();
expect(ev.isBeingDecrypted()).toBeFalsy();
expect(ev.isDecryptionFailure()).toBeFalsy();
});
describe("applyVisibilityEvent", () => { describe("applyVisibilityEvent", () => {
it("should emit VisibilityChange if a change was made", async () => { it("should emit VisibilityChange if a change was made", async () => {
const ev = new MatrixEvent({ const ev = new MatrixEvent({
@ -134,6 +117,21 @@ describe("MatrixEvent", () => {
}); });
}); });
it("should report decryption errors", async () => {
const crypto = {
decryptEvent: jest.fn().mockRejectedValue(new Error("test error")),
} as unknown as Crypto;
await encryptedEvent.attemptDecryption(crypto);
expect(encryptedEvent.isEncrypted()).toBeTruthy();
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
expect(encryptedEvent.getContent()).toEqual({
msgtype: "m.bad.encrypted",
body: "** Unable to decrypt: Error: test error **",
});
});
it("should retry decryption if a retry is queued", async () => { it("should retry decryption if a retry is queued", async () => {
const eventAttemptDecryptionSpy = jest.spyOn(encryptedEvent, "attemptDecryption"); const eventAttemptDecryptionSpy = jest.spyOn(encryptedEvent, "attemptDecryption");

View File

@ -14,13 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { M_POLL_START } from "matrix-events-sdk";
import { EventTimelineSet } from "../../src/models/event-timeline-set"; import { EventTimelineSet } from "../../src/models/event-timeline-set";
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event"; import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { Room } from "../../src/models/room"; import { Room } from "../../src/models/room";
import { Relations } from "../../src/models/relations"; import { Relations, RelationsEvent } from "../../src/models/relations";
import { TestClient } from "../TestClient"; import { TestClient } from "../TestClient";
import { RelationType } from "../../src";
import { logger } from "../../src/logger";
describe("Relations", function () { describe("Relations", function () {
afterEach(() => {
jest.spyOn(logger, "error").mockRestore();
});
it("should deduplicate annotations", function () { it("should deduplicate annotations", function () {
const room = new Room("room123", null!, null!); const room = new Room("room123", null!, null!);
const relations = new Relations("m.annotation", "m.reaction", room); const relations = new Relations("m.annotation", "m.reaction", room);
@ -75,6 +83,92 @@ describe("Relations", function () {
} }
}); });
describe("addEvent()", () => {
const relationType = RelationType.Reference;
const eventType = M_POLL_START.stable!;
const altEventTypes = [M_POLL_START.unstable!];
const room = new Room("room123", null!, null!);
it("should not add events without a relation", async () => {
// dont pollute console
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
const relations = new Relations(relationType, eventType, room);
const emitSpy = jest.spyOn(relations, "emit");
const event = new MatrixEvent({ type: eventType });
await relations.addEvent(event);
expect(logSpy).toHaveBeenCalledWith("Event must have relation info");
// event not added
expect(relations.getRelations().length).toBe(0);
expect(emitSpy).not.toHaveBeenCalled();
});
it("should not add events of incorrect event type", async () => {
// dont pollute console
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
const relations = new Relations(relationType, eventType, room);
const emitSpy = jest.spyOn(relations, "emit");
const event = new MatrixEvent({
type: "different-event-type",
content: {
"m.relates_to": {
event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o",
rel_type: relationType,
},
},
});
await relations.addEvent(event);
expect(logSpy).toHaveBeenCalledWith(`Event relation info doesn't match this container`);
// event not added
expect(relations.getRelations().length).toBe(0);
expect(emitSpy).not.toHaveBeenCalled();
});
it("adds events that match alt event types", async () => {
const relations = new Relations(relationType, eventType, room, altEventTypes);
const emitSpy = jest.spyOn(relations, "emit");
const event = new MatrixEvent({
type: M_POLL_START.unstable!,
content: {
"m.relates_to": {
event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o",
rel_type: relationType,
},
},
});
await relations.addEvent(event);
// event added
expect(relations.getRelations()).toEqual([event]);
expect(emitSpy).toHaveBeenCalledWith(RelationsEvent.Add, event);
});
it("should not add events of incorrect relation type", async () => {
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
const relations = new Relations(relationType, eventType, room);
const event = new MatrixEvent({
type: eventType,
content: {
"m.relates_to": {
event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o",
rel_type: "m.annotation",
},
},
});
await relations.addEvent(event);
const emitSpy = jest.spyOn(relations, "emit");
expect(logSpy).toHaveBeenCalledWith(`Event relation info doesn't match this container`);
// event not added
expect(relations.getRelations().length).toBe(0);
expect(emitSpy).not.toHaveBeenCalled();
});
});
it("should emit created regardless of ordering", async function () { it("should emit created regardless of ordering", async function () {
const targetEvent = new MatrixEvent({ const targetEvent = new MatrixEvent({
sender: "@bob:example.com", sender: "@bob:example.com",

View File

@ -0,0 +1,248 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import {
KeysBackupRequest,
KeysClaimRequest,
KeysQueryRequest,
KeysUploadRequest,
OlmMachine,
SignatureUploadRequest,
} from "@matrix-org/matrix-sdk-crypto-js";
import { Mocked } from "jest-mock";
import MockHttpBackend from "matrix-mock-request";
import { RustCrypto } from "../../src/rust-crypto/rust-crypto";
import { initRustCrypto } from "../../src/rust-crypto";
import { HttpApiEvent, HttpApiEventHandlerMap, IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../src";
import { TypedEventEmitter } from "../../src/models/typed-event-emitter";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
});
describe("RustCrypto", () => {
const TEST_USER = "@alice:example.com";
const TEST_DEVICE_ID = "TEST_DEVICE";
describe(".exportRoomKeys", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
});
it("should return a list", async () => {
const keys = await rustCrypto.exportRoomKeys();
expect(Array.isArray(keys)).toBeTruthy();
});
});
describe("to-device messages", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
});
it("should pass through unencrypted to-device messages", async () => {
const inputs: IToDeviceEvent[] = [
{ content: { key: "value" }, type: "org.matrix.test", sender: "@alice:example.com" },
];
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
expect(res).toEqual(inputs);
});
it("should pass through bad encrypted messages", async () => {
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
const keys = olmMachine.identityKeys;
const inputs: IToDeviceEvent[] = [
{
type: "m.room.encrypted",
content: {
algorithm: "m.olm.v1.curve25519-aes-sha2",
sender_key: "IlRMeOPX2e0MurIyfWEucYBRVOEEUMrOHqn/8mLqMjA",
ciphertext: {
[keys.curve25519.toBase64()]: {
type: 0,
body: "ajyjlghi",
},
},
},
sender: "@alice:example.com",
},
];
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
expect(res).toEqual(inputs);
});
});
describe("outgoing requests", () => {
/** the RustCrypto implementation under test */
let rustCrypto: RustCrypto;
/** A mock http backend which rustCrypto is connected to */
let httpBackend: MockHttpBackend;
/** a mocked-up OlmMachine which rustCrypto is connected to */
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
/** A list of results to be returned from olmMachine.outgoingRequest. Each call will shift a result off
* the front of the queue, until it is empty. */
let outgoingRequestQueue: Array<Array<any>>;
/** wait for a call to olmMachine.markRequestAsSent */
function awaitCallToMarkAsSent(): Promise<void> {
return new Promise((resolve, _reject) => {
olmMachine.markRequestAsSent.mockImplementationOnce(async () => {
resolve(undefined);
});
});
}
beforeEach(async () => {
httpBackend = new MockHttpBackend();
await RustSdkCryptoJs.initAsync();
const dummyEventEmitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
const httpApi = new MatrixHttpApi(dummyEventEmitter, {
baseUrl: "https://example.com",
prefix: "/_matrix",
fetchFn: httpBackend.fetchFn as typeof global.fetch,
onlyData: true,
});
// for these tests we use a mock OlmMachine, with an implementation of outgoingRequests that
// returns objects from outgoingRequestQueue
outgoingRequestQueue = [];
olmMachine = {
outgoingRequests: jest.fn().mockImplementation(() => {
return Promise.resolve(outgoingRequestQueue.shift() ?? []);
}),
markRequestAsSent: jest.fn(),
close: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
rustCrypto = new RustCrypto(olmMachine, httpApi, TEST_USER, TEST_DEVICE_ID);
});
it("should poll for outgoing messages", () => {
rustCrypto.onSyncCompleted({});
expect(olmMachine.outgoingRequests).toHaveBeenCalled();
});
/* simple requests that map directly to the request body */
const tests: Array<[any, "POST" | "PUT", string]> = [
[KeysUploadRequest, "POST", "https://example.com/_matrix/client/v3/keys/upload"],
[KeysQueryRequest, "POST", "https://example.com/_matrix/client/v3/keys/query"],
[KeysClaimRequest, "POST", "https://example.com/_matrix/client/v3/keys/claim"],
[SignatureUploadRequest, "POST", "https://example.com/_matrix/client/v3/keys/signatures/upload"],
[KeysBackupRequest, "PUT", "https://example.com/_matrix/client/v3/room_keys/keys"],
];
for (const [RequestClass, expectedMethod, expectedPath] of tests) {
it(`should handle ${RequestClass.name}s`, async () => {
const testBody = '{ "foo": "bar" }';
const outgoingRequest = new RequestClass("1234", testBody);
outgoingRequestQueue.push([outgoingRequest]);
const testResponse = '{ "result": 1 }';
httpBackend
.when(expectedMethod, "/_matrix")
.check((req) => {
expect(req.path).toEqual(expectedPath);
expect(req.rawData).toEqual(testBody);
expect(req.headers["Accept"]).toEqual("application/json");
expect(req.headers["Content-Type"]).toEqual("application/json");
})
.respond(200, testResponse, true);
rustCrypto.onSyncCompleted({});
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(1);
const markSentCallPromise = awaitCallToMarkAsSent();
await httpBackend.flushAllExpected();
await markSentCallPromise;
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
httpBackend.verifyNoOutstandingRequests();
});
}
it("does not explode with unknown requests", async () => {
const outgoingRequest = { id: "5678", type: 987 };
outgoingRequestQueue.push([outgoingRequest]);
rustCrypto.onSyncCompleted({});
await awaitCallToMarkAsSent();
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("5678", 987, "");
});
it("stops looping when stop() is called", async () => {
const testResponse = '{ "result": 1 }';
for (let i = 0; i < 5; i++) {
outgoingRequestQueue.push([new KeysQueryRequest("1234", "{}")]);
httpBackend.when("POST", "/_matrix").respond(200, testResponse, true);
}
rustCrypto.onSyncCompleted({});
expect(rustCrypto["outgoingRequestLoopRunning"]).toBeTruthy();
// go a couple of times round the loop
await httpBackend.flush("/_matrix", 1);
await awaitCallToMarkAsSent();
await httpBackend.flush("/_matrix", 1);
await awaitCallToMarkAsSent();
// a second sync while this is going on shouldn't make any difference
rustCrypto.onSyncCompleted({});
await httpBackend.flush("/_matrix", 1);
await awaitCallToMarkAsSent();
// now stop...
rustCrypto.stop();
// which should (eventually) cause the loop to stop with no further calls to outgoingRequests
olmMachine.outgoingRequests.mockReset();
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
expect(rustCrypto["outgoingRequestLoopRunning"]).toBeFalsy();
httpBackend.verifyNoOutstandingRequests();
expect(olmMachine.outgoingRequests).not.toHaveBeenCalled();
// we sent three, so there should be 2 left
expect(outgoingRequestQueue.length).toEqual(2);
});
});
});

View File

@ -142,6 +142,15 @@ describe("Group Call", function () {
} as unknown as RoomMember; } as unknown as RoomMember;
}); });
it.each(Object.values(GroupCallState).filter((v) => v !== GroupCallState.LocalCallFeedUninitialized))(
"throws when initializing local call feed in %s state",
async (state: GroupCallState) => {
// @ts-ignore
groupCall.state = state;
await expect(groupCall.initLocalCallFeed()).rejects.toThrowError();
},
);
it("does not initialize local call feed, if it already is", async () => { it("does not initialize local call feed, if it already is", async () => {
await groupCall.initLocalCallFeed(); await groupCall.initLocalCallFeed();
jest.spyOn(groupCall, "initLocalCallFeed"); jest.spyOn(groupCall, "initLocalCallFeed");
@ -308,6 +317,17 @@ describe("Group Call", function () {
} }
}); });
describe("hasLocalParticipant()", () => {
it("should return false, if we don't have a local participant", () => {
expect(groupCall.hasLocalParticipant()).toBeFalsy();
});
it("should return true, if we do have local participant", async () => {
await groupCall.enter();
expect(groupCall.hasLocalParticipant()).toBeTruthy();
});
});
describe("call feeds changing", () => { describe("call feeds changing", () => {
let call: MockMatrixCall; let call: MockMatrixCall;
const currentFeed = new MockCallFeed(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, new MockMediaStream("current")); const currentFeed = new MockCallFeed(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, new MockMediaStream("current"));
@ -475,7 +495,7 @@ describe("Group Call", function () {
const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId); const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId);
// @ts-ignore // @ts-ignore
groupCall.calls.set( groupCall.calls.set(
mockCall.getOpponentMember() as RoomMember, mockCall.getOpponentMember().userId!,
new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]), new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]),
); );
@ -501,7 +521,7 @@ describe("Group Call", function () {
const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId); const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId);
// @ts-ignore // @ts-ignore
groupCall.calls.set( groupCall.calls.set(
mockCall.getOpponentMember() as RoomMember, mockCall.getOpponentMember().userId!,
new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]), new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]),
); );
@ -663,9 +683,7 @@ describe("Group Call", function () {
expect(client1.sendToDevice).toHaveBeenCalled(); expect(client1.sendToDevice).toHaveBeenCalled();
// @ts-ignore // @ts-ignore
const oldCall = groupCall1.calls const oldCall = groupCall1.calls.get(client2.userId)!.get(client2.deviceId)!;
.get(groupCall1.room.getMember(client2.userId)!)!
.get(client2.deviceId)!;
oldCall.emit(CallEvent.Hangup, oldCall!); oldCall.emit(CallEvent.Hangup, oldCall!);
client1.sendToDevice.mockClear(); client1.sendToDevice.mockClear();
@ -685,9 +703,7 @@ describe("Group Call", function () {
let newCall: MatrixCall | undefined; let newCall: MatrixCall | undefined;
while ( while (
// @ts-ignore // @ts-ignore
(newCall = groupCall1.calls (newCall = groupCall1.calls.get(client2.userId)?.get(client2.deviceId)) === undefined ||
.get(groupCall1.room.getMember(client2.userId)!)
?.get(client2.deviceId)) === undefined ||
newCall.peerConn === undefined || newCall.peerConn === undefined ||
newCall.callId == oldCall.callId newCall.callId == oldCall.callId
) { ) {
@ -730,7 +746,7 @@ describe("Group Call", function () {
groupCall1.setLocalVideoMuted(false); groupCall1.setLocalVideoMuted(false);
// @ts-ignore // @ts-ignore
const call = groupCall1.calls.get(groupCall1.room.getMember(client2.userId)!)!.get(client2.deviceId)!; const call = groupCall1.calls.get(client2.userId)!.get(client2.deviceId)!;
call.isMicrophoneMuted = jest.fn().mockReturnValue(true); call.isMicrophoneMuted = jest.fn().mockReturnValue(true);
call.setMicrophoneMuted = jest.fn(); call.setMicrophoneMuted = jest.fn();
call.isLocalVideoMuted = jest.fn().mockReturnValue(true); call.isLocalVideoMuted = jest.fn().mockReturnValue(true);
@ -839,7 +855,7 @@ describe("Group Call", function () {
await sleep(10); await sleep(10);
// @ts-ignore // @ts-ignore
const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!; const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember); call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
// @ts-ignore Mock // @ts-ignore Mock
call.pushRemoteFeed( call.pushRemoteFeed(
@ -866,7 +882,7 @@ describe("Group Call", function () {
await sleep(10); await sleep(10);
// @ts-ignore // @ts-ignore
const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!; const call = groupCall.calls.get(FAKE_USER_ID_2).get(FAKE_DEVICE_ID_2)!;
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember); call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
// @ts-ignore Mock // @ts-ignore Mock
call.pushRemoteFeed( call.pushRemoteFeed(
@ -943,9 +959,7 @@ describe("Group Call", function () {
expect(mockCall.reject).not.toHaveBeenCalled(); expect(mockCall.reject).not.toHaveBeenCalled();
expect(mockCall.answerWithCallFeeds).toHaveBeenCalled(); expect(mockCall.answerWithCallFeeds).toHaveBeenCalled();
// @ts-ignore // @ts-ignore
expect(groupCall.calls).toEqual( expect(groupCall.calls).toEqual(new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_1, mockCall]])]]));
new Map([[groupCall.room.getMember(FAKE_USER_ID_1)!, new Map([[FAKE_DEVICE_ID_1, mockCall]])]]),
);
}); });
it("replaces calls if it already has one with the same user", async () => { it("replaces calls if it already has one with the same user", async () => {
@ -960,9 +974,7 @@ describe("Group Call", function () {
expect(oldMockCall.hangup).toHaveBeenCalled(); expect(oldMockCall.hangup).toHaveBeenCalled();
expect(newMockCall.answerWithCallFeeds).toHaveBeenCalled(); expect(newMockCall.answerWithCallFeeds).toHaveBeenCalled();
// @ts-ignore // @ts-ignore
expect(groupCall.calls).toEqual( expect(groupCall.calls).toEqual(new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_1, newMockCall]])]]));
new Map([[groupCall.room.getMember(FAKE_USER_ID_1)!, new Map([[FAKE_DEVICE_ID_1, newMockCall]])]]),
);
}); });
it("starts to process incoming calls when we've entered", async () => { it("starts to process incoming calls when we've entered", async () => {
@ -975,6 +987,83 @@ describe("Group Call", function () {
expect(call.answerWithCallFeeds).toHaveBeenCalled(); expect(call.answerWithCallFeeds).toHaveBeenCalled();
}); });
describe("handles call being replaced", () => {
let callChangedListener: jest.Mock;
let oldMockCall: MockMatrixCall;
let newMockCall: MockMatrixCall;
let newCallsMap: Map<string, Map<string, MatrixCall>>;
beforeEach(() => {
callChangedListener = jest.fn();
groupCall.addListener(GroupCallEvent.CallsChanged, callChangedListener);
oldMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
newMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
newCallsMap = new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_1, newMockCall.typed()]])]]);
newMockCall.opponentMember = oldMockCall.opponentMember; // Ensure referential equality
newMockCall.callId = "not " + oldMockCall.callId;
mockClient.emit(CallEventHandlerEvent.Incoming, oldMockCall.typed());
});
it("handles regular case", () => {
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed());
expect(oldMockCall.hangup).toHaveBeenCalled();
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);
// @ts-ignore
expect(groupCall.calls).toEqual(newCallsMap);
});
it("handles case where call is missing from the calls map", () => {
// @ts-ignore
groupCall.calls = new Map();
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed());
expect(oldMockCall.hangup).toHaveBeenCalled();
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);
// @ts-ignore
expect(groupCall.calls).toEqual(newCallsMap);
});
});
describe("handles call being hangup", () => {
let callChangedListener: jest.Mock;
let mockCall: MockMatrixCall;
beforeEach(() => {
callChangedListener = jest.fn();
groupCall.addListener(GroupCallEvent.CallsChanged, callChangedListener);
mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
});
it("doesn't throw when calls map is empty", () => {
// @ts-ignore
expect(() => groupCall.onCallHangup(mockCall)).not.toThrow();
});
it("clears map completely when we're the last users device left", () => {
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall.typed());
mockCall.emit(CallEvent.Hangup, mockCall.typed());
// @ts-ignore
expect(groupCall.calls).toEqual(new Map());
});
it("doesn't remove another call of the same user", () => {
const anotherCallOfTheSameUser = new MockMatrixCall(room.roomId, groupCall.groupCallId);
anotherCallOfTheSameUser.callId = "another call id";
anotherCallOfTheSameUser.getOpponentDeviceId = () => FAKE_DEVICE_ID_2;
mockClient.emit(CallEventHandlerEvent.Incoming, anotherCallOfTheSameUser.typed());
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall.typed());
mockCall.emit(CallEvent.Hangup, mockCall.typed());
// @ts-ignore
expect(groupCall.calls).toEqual(
new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_2, anotherCallOfTheSameUser.typed()]])]]),
);
});
});
}); });
describe("screensharing", () => { describe("screensharing", () => {
@ -1039,7 +1128,7 @@ describe("Group Call", function () {
await sleep(10); await sleep(10);
// @ts-ignore // @ts-ignore
const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!; const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember); call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
call.onNegotiateReceived({ call.onNegotiateReceived({
getContent: () => ({ getContent: () => ({

View File

@ -44,3 +44,29 @@ export interface IEventDecryptionResult {
claimedEd25519Key?: string; claimedEd25519Key?: string;
untrusted?: boolean; untrusted?: boolean;
} }
interface Extensible {
[key: string]: any;
}
/* eslint-disable camelcase */
/** The result of a call to {@link MatrixClient.exportRoomKeys} */
export interface IMegolmSessionData extends Extensible {
/** Sender's Curve25519 device key */
sender_key: string;
/** Devices which forwarded this session to us (normally empty). */
forwarding_curve25519_key_chain: string[];
/** Other keys the sender claims. */
sender_claimed_keys: Record<string, string>;
/** Room this session is used in */
room_id: string;
/** Unique id for the session */
session_id: string;
/** Base64'ed key data */
session_key: string;
algorithm?: string;
untrusted?: boolean;
}
/* eslint-enable camelcase */

View File

@ -61,11 +61,12 @@ export enum EventType {
KeyVerificationDone = "m.key.verification.done", KeyVerificationDone = "m.key.verification.done",
KeyVerificationKey = "m.key.verification.key", KeyVerificationKey = "m.key.verification.key",
KeyVerificationAccept = "m.key.verification.accept", KeyVerificationAccept = "m.key.verification.accept",
// XXX this event is not yet supported by js-sdk // Not used directly - see READY_TYPE in VerificationRequest.
KeyVerificationReady = "m.key.verification.ready", KeyVerificationReady = "m.key.verification.ready",
// use of this is discouraged https://matrix.org/docs/spec/client_server/r0.6.1#m-room-message-feedback // use of this is discouraged https://matrix.org/docs/spec/client_server/r0.6.1#m-room-message-feedback
RoomMessageFeedback = "m.room.message.feedback", RoomMessageFeedback = "m.room.message.feedback",
Reaction = "m.reaction", Reaction = "m.reaction",
PollStart = "org.matrix.msc3381.poll.start",
// Room ephemeral events // Room ephemeral events
Typing = "m.typing", Typing = "m.typing",
@ -165,6 +166,15 @@ export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix
*/ */
export const UNSTABLE_MSC2716_MARKER = new UnstableValue("m.room.marker", "org.matrix.msc2716.marker"); export const UNSTABLE_MSC2716_MARKER = new UnstableValue("m.room.marker", "org.matrix.msc2716.marker");
/**
* Name of the "with_relations" request property for relation based redactions.
* {@link https://github.com/matrix-org/matrix-spec-proposals/pull/3912}
*/
export const MSC3912_RELATION_BASED_REDACTIONS_PROP = new UnstableValue(
"with_relations",
"org.matrix.msc3912.with_relations",
);
/** /**
* Functional members type for declaring a purpose of room members (e.g. helpful bots). * Functional members type for declaring a purpose of room members (e.g. helpful bots).
* Note that this reference is UNSTABLE and subject to breaking changes, including its * Note that this reference is UNSTABLE and subject to breaking changes, including its

View File

@ -54,3 +54,11 @@ export type Receipts = {
[userId: string]: [WrappedReceipt | null, WrappedReceipt | null]; // Pair<real receipt, synthetic receipt> (both nullable) [userId: string]: [WrappedReceipt | null, WrappedReceipt | null]; // Pair<real receipt, synthetic receipt> (both nullable)
}; };
}; };
export type CachedReceiptStructure = {
eventId: string;
receiptType: string | ReceiptType;
userId: string;
receipt: Receipt;
synthetic: boolean;
};

View File

@ -21,7 +21,7 @@ import { IRoomEventFilter } from "../filter";
import { Direction } from "../models/event-timeline"; import { Direction } from "../models/event-timeline";
import { PushRuleAction } from "./PushRules"; import { PushRuleAction } from "./PushRules";
import { IRoomEvent } from "../sync-accumulator"; import { IRoomEvent } from "../sync-accumulator";
import { EventType, RoomType } from "./event"; import { EventType, RelationType, RoomType } from "./event";
// allow camelcase as these are things that go onto the wire // allow camelcase as these are things that go onto the wire
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -47,6 +47,18 @@ export interface IJoinRoomOpts {
export interface IRedactOpts { export interface IRedactOpts {
reason?: string; reason?: string;
/**
* Whether events related to the redacted event should be redacted.
*
* If specified, then any events which relate to the event being redacted with
* any of the relationship types listed will also be redacted.
*
* <b>Raises an Error if the server does not support it.</b>
* Check for server-side support before using this param with
* <code>client.canSupport.get(Feature.RelationBasedRedactions)</code>.
* {@link https://github.com/matrix-org/matrix-spec-proposals/pull/3912}
*/
with_relations?: Array<RelationType | string>;
} }
export interface ISendEventResponse { export interface ISendEventResponse {

View File

@ -214,9 +214,9 @@ export class AutoDiscovery {
// Step 5b: Verify there is an identity server listening on the provided // Step 5b: Verify there is an identity server listening on the provided
// URL. // URL.
const isResponse = await this.fetchWellKnownObject(`${isUrl}/_matrix/identity/api/v1`); const isResponse = await this.fetchWellKnownObject(`${isUrl}/_matrix/identity/v2`);
if (!isResponse?.raw || isResponse.action !== AutoDiscoveryAction.SUCCESS) { if (!isResponse?.raw || isResponse.action !== AutoDiscoveryAction.SUCCESS) {
logger.error("Invalid /api/v1 response"); logger.error("Invalid /v2 response");
failingClientConfig["m.identity_server"].error = AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER; failingClientConfig["m.identity_server"].error = AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER;
// Supply the base_url to the caller because they may be ignoring // Supply the base_url to the caller because they may be ignoring

View File

@ -20,7 +20,8 @@ limitations under the License.
import { EmoteEvent, IPartialEvent, MessageEvent, NoticeEvent, Optional } from "matrix-events-sdk"; import { EmoteEvent, IPartialEvent, MessageEvent, NoticeEvent, Optional } from "matrix-events-sdk";
import { ISyncStateData, SyncApi, SyncState } from "./sync"; import type { IMegolmSessionData } from "./@types/crypto";
import { ISyncStateData, SyncApi, SyncApiOptions, SyncState } from "./sync";
import { import {
EventStatus, EventStatus,
IContent, IContent,
@ -74,7 +75,6 @@ import {
ICryptoCallbacks, ICryptoCallbacks,
IBootstrapCrossSigningOpts, IBootstrapCrossSigningOpts,
ICheckOwnCrossSigningTrustOpts, ICheckOwnCrossSigningTrustOpts,
IMegolmSessionData,
isCryptoAvailable, isCryptoAvailable,
VerificationMethod, VerificationMethod,
IRoomKeyRequestBody, IRoomKeyRequestBody,
@ -154,6 +154,7 @@ import {
UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_ENABLED,
UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3088_PURPOSE,
UNSTABLE_MSC3089_TREE_SUBTYPE, UNSTABLE_MSC3089_TREE_SUBTYPE,
MSC3912_RELATION_BASED_REDACTIONS_PROP,
} from "./@types/event"; } from "./@types/event";
import { IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials"; import { IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials";
import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper"; import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper";
@ -455,17 +456,7 @@ export interface IStartClientOpts {
slidingSync?: SlidingSync; slidingSync?: SlidingSync;
} }
export interface IStoredClientOpts extends IStartClientOpts { export interface IStoredClientOpts extends IStartClientOpts {}
// Crypto manager
crypto?: Crypto;
/**
* A function which is called
* with a room ID and returns a boolean. It should return 'true' if the SDK can
* SAFELY remove events from this room. It may not be safe to remove events if
* there are other references to the timelines for this room.
*/
canResetEntireTimeline: ResetTimelineCallback;
}
export enum RoomVersionStability { export enum RoomVersionStability {
Stable = "stable", Stable = "stable",
@ -841,6 +832,11 @@ interface ITimestampToEventResponse {
event_id: string; event_id: string;
origin_server_ts: string; origin_server_ts: string;
} }
interface IWhoamiResponse {
user_id: string;
device_id?: string;
}
/* eslint-enable camelcase */ /* eslint-enable camelcase */
// We're using this constant for methods overloading and inspect whether a variable // We're using this constant for methods overloading and inspect whether a variable
@ -1427,20 +1423,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
logger.error("Can't fetch server versions, continuing to initialise sync, this will be retried later", e); logger.error("Can't fetch server versions, continuing to initialise sync, this will be retried later", e);
} }
// shallow-copy the opts dict before modifying and storing it this.clientOpts = opts ?? {};
this.clientOpts = Object.assign({}, opts) as IStoredClientOpts;
this.clientOpts.crypto = this.crypto;
this.clientOpts.canResetEntireTimeline = (roomId): boolean => {
if (!this.canResetTimelineCallback) {
return false;
}
return this.canResetTimelineCallback(roomId);
};
if (this.clientOpts.slidingSync) { if (this.clientOpts.slidingSync) {
this.syncApi = new SlidingSyncSdk(this.clientOpts.slidingSync, this, this.clientOpts); this.syncApi = new SlidingSyncSdk(
this.clientOpts.slidingSync,
this,
this.clientOpts,
this.buildSyncApiOptions(),
);
} else { } else {
this.syncApi = new SyncApi(this, this.clientOpts); this.syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
} }
this.syncApi.sync(); this.syncApi.sync();
if (this.clientOpts.clientWellKnownPollPeriod !== undefined) { if (this.clientOpts.clientWellKnownPollPeriod !== undefined) {
@ -1453,6 +1447,22 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.toDeviceMessageQueue.start(); this.toDeviceMessageQueue.start();
} }
/**
* Construct a SyncApiOptions for this client, suitable for passing into the SyncApi constructor
*/
protected buildSyncApiOptions(): SyncApiOptions {
return {
crypto: this.crypto,
cryptoCallbacks: this.cryptoBackend,
canResetEntireTimeline: (roomId: string): boolean => {
if (!this.canResetTimelineCallback) {
return false;
}
return this.canResetTimelineCallback(roomId);
},
};
}
/** /**
* High level helper method to stop the client from polling and allow a * High level helper method to stop the client from polling and allow a
* clean shutdown. * clean shutdown.
@ -2138,7 +2148,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// importing rust-crypto will download the webassembly, so we delay it until we know it will be // importing rust-crypto will download the webassembly, so we delay it until we know it will be
// needed. // needed.
const RustCrypto = await import("./rust-crypto"); const RustCrypto = await import("./rust-crypto");
this.cryptoBackend = await RustCrypto.initRustCrypto(userId, deviceId); this.cryptoBackend = await RustCrypto.initRustCrypto(this.http, userId, deviceId);
} }
/** /**
@ -3033,10 +3043,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* session export objects * session export objects
*/ */
public exportRoomKeys(): Promise<IMegolmSessionData[]> { public exportRoomKeys(): Promise<IMegolmSessionData[]> {
if (!this.crypto) { if (!this.cryptoBackend) {
return Promise.reject(new Error("End-to-end encryption disabled")); return Promise.reject(new Error("End-to-end encryption disabled"));
} }
return this.crypto.exportRoomKeys(); return this.cryptoBackend.exportRoomKeys();
} }
/** /**
@ -3948,7 +3958,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data); const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
const roomId = res.room_id; const roomId = res.room_id;
const syncApi = new SyncApi(this, this.clientOpts); const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
const room = syncApi.createRoom(roomId); const room = syncApi.createRoom(roomId);
if (opts.syncRoom) { if (opts.syncRoom) {
// v2 will do this for us // v2 will do this for us
@ -4444,9 +4454,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/** /**
* @param txnId - transaction id. One will be made up if not supplied. * @param txnId - transaction id. One will be made up if not supplied.
* @param opts - Options to pass on, may contain `reason`. * @param opts - Options to pass on, may contain `reason` and `with_relations` (MSC3912)
* @returns Promise which resolves: TODO * @returns Promise which resolves: TODO
* @returns Rejects: with an error response. * @returns Rejects: with an error response.
* @throws Error if called with `with_relations` (MSC3912) but the server does not support it.
* Callers should check whether the server supports MSC3912 via `MatrixClient.canSupport`.
*/ */
public redactEvent( public redactEvent(
roomId: string, roomId: string,
@ -4475,12 +4487,34 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
threadId = null; threadId = null;
} }
const reason = opts?.reason; const reason = opts?.reason;
if (
opts?.with_relations &&
this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Unsupported
) {
throw new Error(
"Server does not support relation based redactions " +
`roomId ${roomId} eventId ${eventId} txnId: ${txnId} threadId ${threadId}`,
);
}
const withRelations = opts?.with_relations
? {
[this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Stable
? MSC3912_RELATION_BASED_REDACTIONS_PROP.stable!
: MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!]: opts?.with_relations,
}
: {};
return this.sendCompleteEvent( return this.sendCompleteEvent(
roomId, roomId,
threadId, threadId,
{ {
type: EventType.RoomRedaction, type: EventType.RoomRedaction,
content: { reason }, content: {
...withRelations,
reason,
},
redacts: eventId, redacts: eventId,
}, },
txnId as string, txnId as string,
@ -6059,7 +6093,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.processBeaconEvents(room, timelineEvents); this.processBeaconEvents(room, timelineEvents);
this.processThreadRoots( this.processThreadRoots(
room, room,
timelineEvents.filter((it) => it.isRelation(THREAD_RELATION_TYPE.name)), timelineEvents.filter((it) => it.getServerAggregatedRelation(THREAD_RELATION_TYPE.name)),
false, false,
); );
@ -6124,7 +6158,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/ */
public peekInRoom(roomId: string): Promise<Room> { public peekInRoom(roomId: string): Promise<Room> {
this.peekSync?.stopPeeking(); this.peekSync?.stopPeeking();
this.peekSync = new SyncApi(this, this.clientOpts); this.peekSync = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
return this.peekSync.peek(roomId); return this.peekSync.peek(roomId);
} }
@ -6631,7 +6665,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (this.syncLeftRoomsPromise) { if (this.syncLeftRoomsPromise) {
return this.syncLeftRoomsPromise; // return the ongoing request return this.syncLeftRoomsPromise; // return the ongoing request
} }
const syncApi = new SyncApi(this, this.clientOpts); const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
this.syncLeftRoomsPromise = syncApi.syncLeftRooms(); this.syncLeftRoomsPromise = syncApi.syncLeftRooms();
// cleanup locks // cleanup locks
@ -9350,10 +9384,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} }
/** /**
* Fetches the user_id of the configured access token. * Fetches information about the user for the configured access token.
*/ */
public async whoami(): Promise<{ user_id: string }> { public async whoami(): Promise<IWhoamiResponse> {
// eslint-disable-line camelcase
return this.http.authedRequest(Method.Get, "/account/whoami"); return this.http.authedRequest(Method.Get, "/account/whoami");
} }

View File

@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import type { IEventDecryptionResult } from "../@types/crypto"; import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
import type { IToDeviceEvent } from "../sync-accumulator";
import { MatrixEvent } from "../models/event"; import { MatrixEvent } from "../models/event";
/** /**
* Common interface for the crypto implementations * Common interface for the crypto implementations
*/ */
export interface CryptoBackend { export interface CryptoBackend extends SyncCryptoCallbacks {
/** /**
* Global override for whether the client should ever send encrypted * Global override for whether the client should ever send encrypted
* messages to unverified devices. This provides the default for rooms which * messages to unverified devices. This provides the default for rooms which
@ -60,4 +61,52 @@ export interface CryptoBackend {
* Rejects with an error if there is a problem decrypting the event. * Rejects with an error if there is a problem decrypting the event.
*/ */
decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult>; decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult>;
/**
* Get a list containing all of the room keys
*
* This should be encrypted before returning it to the user.
*
* @returns a promise which resolves to a list of
* session export objects
*/
exportRoomKeys(): Promise<IMegolmSessionData[]>;
}
/** The methods which crypto implementations should expose to the Sync api */
export interface SyncCryptoCallbacks {
/**
* Called by the /sync loop whenever there are incoming to-device messages.
*
* The implementation may preprocess the received messages (eg, decrypt them) and return an
* updated list of messages for dispatch to the rest of the system.
*
* Note that, unlike {@link ClientEvent.ToDeviceEvent} events, this is called on the raw to-device
* messages, rather than the results of any decryption attempts.
*
* @param events - the received to-device messages
* @returns A list of preprocessed to-device messages.
*/
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
/**
* Called by the /sync loop after each /sync response is processed.
*
* Used to complete batch processing, or to initiate background processes
*
* @param syncState - information about the completed sync.
*/
onSyncCompleted(syncState: OnSyncCompletedData): void;
}
export interface OnSyncCompletedData {
/**
* The 'next_batch' result from /sync, which will become the 'since' token for the next call to /sync.
*/
nextSyncToken?: string;
/**
* True if we are working our way through a backlog of events after connecting.
*/
catchingUp?: boolean;
} }

View File

@ -21,8 +21,7 @@ import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
import * as algorithms from "./algorithms"; import * as algorithms from "./algorithms";
import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base"; import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base";
import { IOlmDevice, IOutboundGroupSessionKey } from "./algorithms/megolm"; import { IOlmDevice, IOutboundGroupSessionKey } from "./algorithms/megolm";
import { IMegolmSessionData } from "./index"; import { IMegolmSessionData, OlmGroupSessionExtraData } from "../@types/crypto";
import { OlmGroupSessionExtraData } from "../@types/crypto";
import { IMessage } from "./algorithms/olm"; import { IMessage } from "./algorithms/olm";
// The maximum size of an event is 65K, and we base64 the content, so this is a // The maximum size of an event is 65K, and we base64 the content, so this is a

View File

@ -18,11 +18,12 @@ limitations under the License.
* Internal module. Defines the base classes of the encryption implementations * Internal module. Defines the base classes of the encryption implementations
*/ */
import type { IMegolmSessionData } from "../../@types/crypto";
import { MatrixClient } from "../../client"; import { MatrixClient } from "../../client";
import { Room } from "../../models/room"; import { Room } from "../../models/room";
import { OlmDevice } from "../OlmDevice"; import { OlmDevice } from "../OlmDevice";
import { IContent, MatrixEvent, RoomMember } from "../../matrix"; import { IContent, MatrixEvent, RoomMember } from "../../matrix";
import { Crypto, IEncryptedContent, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from ".."; import { Crypto, IEncryptedContent, IEventDecryptionResult, IncomingRoomKeyRequest } from "..";
import { DeviceInfo } from "../deviceinfo"; import { DeviceInfo } from "../deviceinfo";
import { IRoomEncryption } from "../RoomList"; import { IRoomEncryption } from "../RoomList";

View File

@ -20,7 +20,7 @@ limitations under the License.
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import type { IEventDecryptionResult } from "../../@types/crypto"; import type { IEventDecryptionResult, IMegolmSessionData } from "../../@types/crypto";
import { logger } from "../../logger"; import { logger } from "../../logger";
import * as olmlib from "../olmlib"; import * as olmlib from "../olmlib";
import { import {
@ -39,7 +39,7 @@ import { IOlmSessionResult } from "../olmlib";
import { DeviceInfoMap } from "../DeviceList"; import { DeviceInfoMap } from "../DeviceList";
import { IContent, MatrixEvent } from "../../models/event"; import { IContent, MatrixEvent } from "../../models/event";
import { EventType, MsgType, ToDeviceMessageId } from "../../@types/event"; import { EventType, MsgType, ToDeviceMessageId } from "../../@types/event";
import { IMegolmEncryptedContent, IMegolmSessionData, IncomingRoomKeyRequest, IEncryptedContent } from "../index"; import { IMegolmEncryptedContent, IncomingRoomKeyRequest, IEncryptedContent } from "../index";
import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager"; import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager";
import { OlmGroupSessionExtraData } from "../../@types/crypto"; import { OlmGroupSessionExtraData } from "../../@types/crypto";
import { MatrixError } from "../../http-api"; import { MatrixError } from "../../http-api";

View File

@ -18,6 +18,7 @@ limitations under the License.
* Classes for dealing with key backup. * Classes for dealing with key backup.
*/ */
import type { IMegolmSessionData } from "../@types/crypto";
import { MatrixClient } from "../client"; import { MatrixClient } from "../client";
import { logger } from "../logger"; import { logger } from "../logger";
import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib"; import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib";
@ -36,7 +37,7 @@ import {
IKeyBackupSession, IKeyBackupSession,
} from "./keybackup"; } from "./keybackup";
import { UnstableValue } from "../NamespacedValue"; import { UnstableValue } from "../NamespacedValue";
import { CryptoEvent, IMegolmSessionData } from "./index"; import { CryptoEvent } from "./index";
import { crypto } from "./crypto"; import { crypto } from "./crypto";
import { HTTPError, MatrixError } from "../http-api"; import { HTTPError, MatrixError } from "../http-api";

View File

@ -20,7 +20,7 @@ limitations under the License.
import anotherjson from "another-json"; import anotherjson from "another-json";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import type { IEventDecryptionResult } from "../@types/crypto"; import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
import type { PkDecryption, PkSigning } from "@matrix-org/olm"; import type { PkDecryption, PkSigning } from "@matrix-org/olm";
import { EventType, ToDeviceMessageId } from "../@types/event"; import { EventType, ToDeviceMessageId } from "../@types/event";
import { TypedReEmitter } from "../ReEmitter"; import { TypedReEmitter } from "../ReEmitter";
@ -85,10 +85,11 @@ import { CryptoStore } from "./store/base";
import { IVerificationChannel } from "./verification/request/Channel"; import { IVerificationChannel } from "./verification/request/Channel";
import { TypedEventEmitter } from "../models/typed-event-emitter"; import { TypedEventEmitter } from "../models/typed-event-emitter";
import { IContent } from "../models/event"; import { IContent } from "../models/event";
import { ISyncResponse } from "../sync-accumulator"; import { ISyncResponse, IToDeviceEvent } from "../sync-accumulator";
import { ISignatures } from "../@types/signed"; import { ISignatures } from "../@types/signed";
import { IMessage } from "./algorithms/olm"; import { IMessage } from "./algorithms/olm";
import { CryptoBackend } from "../common-crypto/CryptoBackend"; import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
import { RoomState, RoomStateEvent } from "../models/room-state";
const DeviceVerification = DeviceInfo.DeviceVerification; const DeviceVerification = DeviceInfo.DeviceVerification;
@ -171,26 +172,6 @@ export interface IRoomKeyRequestBody extends IRoomKey {
sender_key: string; sender_key: string;
} }
interface Extensible {
[key: string]: any;
}
export interface IMegolmSessionData extends Extensible {
// Sender's Curve25519 device key
sender_key: string;
// Devices which forwarded this session to us (normally empty).
forwarding_curve25519_key_chain: string[];
// Other keys the sender claims.
sender_claimed_keys: Record<string, string>;
// Room this session is used in
room_id: string;
// Unique id for the session
session_id: string;
// Base64'ed key data
session_key: string;
algorithm?: string;
untrusted?: boolean;
}
/* eslint-enable camelcase */ /* eslint-enable camelcase */
interface IDeviceVerificationUpgrade { interface IDeviceVerificationUpgrade {
@ -2244,9 +2225,6 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
await upload({ shouldEmit: true }); await upload({ shouldEmit: true });
// XXX: we'll need to wait for the device list to be updated // XXX: we'll need to wait for the device list to be updated
} }
// redo key requests after verification
this.cancelAndResendAllOutgoingKeyRequests();
} }
const deviceObj = DeviceInfo.fromStorage(dev, deviceId); const deviceObj = DeviceInfo.fromStorage(dev, deviceId);
@ -2626,14 +2604,23 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
await storeConfigPromise; await storeConfigPromise;
} }
if (!this.lazyLoadMembers) { logger.log(`Enabling encryption in ${roomId}`);
logger.log(
"Enabling encryption in " + roomId + "; " + "starting to track device lists for all users therein",
);
// we don't want to force a download of the full membership list of this room, but as soon as we have that
// list we can start tracking the device list.
if (room.membersLoaded()) {
await this.trackRoomDevicesImpl(room); await this.trackRoomDevicesImpl(room);
} else { } else {
logger.log("Enabling encryption in " + roomId); // wait for the membership list to be loaded
const onState = (_state: RoomState): void => {
room.off(RoomStateEvent.Update, onState);
if (room.membersLoaded()) {
this.trackRoomDevicesImpl(room).catch((e) => {
logger.error(`Error enabling device tracking in ${roomId}`, e);
});
}
};
room.on(RoomStateEvent.Update, onState);
} }
} }
@ -2642,6 +2629,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* *
* @param roomId - The room ID to start tracking devices in. * @param roomId - The room ID to start tracking devices in.
* @returns when all devices for the room have been fetched and marked to track * @returns when all devices for the room have been fetched and marked to track
* @deprecated there's normally no need to call this function: device list tracking
* will be enabled as soon as we have the full membership list.
*/ */
public trackRoomDevices(roomId: string): Promise<void> { public trackRoomDevices(roomId: string): Promise<void> {
const room = this.clientStore.getRoom(roomId); const room = this.clientStore.getRoom(roomId);
@ -2886,11 +2875,22 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*/ */
public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> { public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
if (event.isRedacted()) { if (event.isRedacted()) {
// Try to decrypt the redaction event, to support encrypted
// redaction reasons. If we can't decrypt, just fall back to using
// the original redacted_because.
const redactionEvent = new MatrixEvent({ const redactionEvent = new MatrixEvent({
room_id: event.getRoomId(), room_id: event.getRoomId(),
...event.getUnsigned().redacted_because, ...event.getUnsigned().redacted_because,
}); });
const decryptedEvent = await this.decryptEvent(redactionEvent); let redactedBecause: IEvent = event.getUnsigned().redacted_because!;
if (redactionEvent.isEncrypted()) {
try {
const decryptedEvent = await this.decryptEvent(redactionEvent);
redactedBecause = decryptedEvent.clearEvent as IEvent;
} catch (e) {
logger.warn("Decryption of redaction failed. Falling back to unencrypted event.", e);
}
}
return { return {
clearEvent: { clearEvent: {
@ -2898,7 +2898,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
type: "m.room.message", type: "m.room.message",
content: {}, content: {},
unsigned: { unsigned: {
redacted_because: decryptedEvent.clearEvent as IEvent, redacted_because: redactedBecause,
}, },
}, },
}; };
@ -3021,7 +3021,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* *
* @param syncData - the data from the 'MatrixClient.sync' event * @param syncData - the data from the 'MatrixClient.sync' event
*/ */
public async onSyncCompleted(syncData: ISyncStateData): Promise<void> { public async onSyncCompleted(syncData: OnSyncCompletedData): Promise<void> {
this.deviceList.setSyncToken(syncData.nextSyncToken ?? null); this.deviceList.setSyncToken(syncData.nextSyncToken ?? null);
this.deviceList.saveIfDirty(); this.deviceList.saveIfDirty();
@ -3195,6 +3195,21 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
} }
}; };
public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> {
// all we do here is filter out encrypted to-device messages with the wrong algorithm. Decryption
// happens later in decryptEvent, via the EventMapper
return events.filter((toDevice) => {
if (
toDevice.type === EventType.RoomMessageEncrypted &&
!["m.olm.v1.curve25519-aes-sha2"].includes(toDevice.content?.algorithm)
) {
logger.log("Ignoring invalid encrypted to-device event from " + toDevice.sender);
return false;
}
return true;
});
}
private onToDeviceEvent = (event: MatrixEvent): void => { private onToDeviceEvent = (event: MatrixEvent): void => {
try { try {
logger.log( logger.log(
@ -3894,5 +3909,5 @@ class IncomingRoomKeyRequestCancellation {
} }
} }
// IEventDecryptionResult is re-exported for backwards compatibility, in case any applications are referencing it. // a number of types are re-exported for backwards compatibility, in case any applications are referencing it.
export type { IEventDecryptionResult } from "../@types/crypto"; export type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";

View File

@ -167,9 +167,9 @@ export class RoomWidgetClient extends MatrixClient {
// still has some valuable helper methods that we make use of, so we // still has some valuable helper methods that we make use of, so we
// instantiate it anyways // instantiate it anyways
if (opts.slidingSync) { if (opts.slidingSync) {
this.syncApi = new SlidingSyncSdk(opts.slidingSync, this, opts); this.syncApi = new SlidingSyncSdk(opts.slidingSync, this, opts, this.buildSyncApiOptions());
} else { } else {
this.syncApi = new SyncApi(this, opts); this.syncApi = new SyncApi(this, opts, this.buildSyncApiOptions());
} }
this.room = this.syncApi.createRoom(this.roomId); this.room = this.syncApi.createRoom(this.roomId);

View File

@ -60,6 +60,9 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event
event.setThread(thread); event.setThread(thread);
} }
// TODO: once we get rid of the old libolm-backed crypto, we can restrict this to room events (rather than
// to-device events), because the rust implementation decrypts to-device messages at a higher level.
// Generally we probably want to use a different eventMapper implementation for to-device events because
if (event.isEncrypted()) { if (event.isEncrypted()) {
if (!preventReEmit) { if (!preventReEmit) {
client.reEmitter.reEmit(event, [MatrixEventEvent.Decrypted]); client.reEmitter.reEmit(event, [MatrixEventEvent.Decrypted]);

View File

@ -26,6 +26,7 @@ export enum Feature {
Thread = "Thread", Thread = "Thread",
ThreadUnreadNotifications = "ThreadUnreadNotifications", ThreadUnreadNotifications = "ThreadUnreadNotifications",
LoginTokenRequest = "LoginTokenRequest", LoginTokenRequest = "LoginTokenRequest",
RelationBasedRedactions = "RelationBasedRedactions",
AccountDataDeletion = "AccountDataDeletion", AccountDataDeletion = "AccountDataDeletion",
} }
@ -46,6 +47,9 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
[Feature.LoginTokenRequest]: { [Feature.LoginTokenRequest]: {
unstablePrefixes: ["org.matrix.msc3882"], unstablePrefixes: ["org.matrix.msc3882"],
}, },
[Feature.RelationBasedRedactions]: {
unstablePrefixes: ["org.matrix.msc3912"],
},
[Feature.AccountDataDeletion]: { [Feature.AccountDataDeletion]: {
unstablePrefixes: ["org.matrix.msc3391"], unstablePrefixes: ["org.matrix.msc3391"],
}, },

View File

@ -34,11 +34,6 @@ export enum ClientPrefix {
} }
export enum IdentityPrefix { export enum IdentityPrefix {
/**
* URI path for v1 of the identity API
* @deprecated Use v2.
*/
V1 = "/_matrix/identity/api/v1",
/** /**
* URI path for the v2 identity API * URI path for the v2 identity API
*/ */

View File

@ -828,17 +828,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
} }
} }
} catch (e) { } catch (e) {
if ((<Error>e).name !== "DecryptionError") { const detailedError = e instanceof DecryptionError ? (<DecryptionError>e).detailedString : String(e);
// not a decryption error: log the whole exception as an error
// (and don't bother with a retry)
const re = options.isRetry ? "re" : "";
// For find results: this can produce "Error decrypting event (id=$ev)" and
// "Error redecrypting event (id=$ev)".
logger.error(`Error ${re}decrypting event (${this.getDetails()})`, e);
this.decryptionPromise = null;
this.retryDecryption = false;
return;
}
err = e as Error; err = e as Error;
@ -858,10 +848,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
// //
if (this.retryDecryption) { if (this.retryDecryption) {
// decryption error, but we have a retry queued. // decryption error, but we have a retry queued.
logger.log( logger.log(`Error decrypting event (${this.getDetails()}), but retrying: ${detailedError}`);
`Error decrypting event (${this.getDetails()}), but retrying: ` +
(<DecryptionError>e).detailedString,
);
continue; continue;
} }
@ -870,9 +857,9 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
// //
// the detailedString already includes the name and message of the error, and the stack isn't much use, // the detailedString already includes the name and message of the error, and the stack isn't much use,
// so we don't bother to log `e` separately. // so we don't bother to log `e` separately.
logger.warn(`Error decrypting event (${this.getDetails()}): ` + (<DecryptionError>e).detailedString); logger.warn(`Error decrypting event (${this.getDetails()}): ${detailedError}`);
res = this.badEncryptedMessage((<DecryptionError>e).message); res = this.badEncryptedMessage(String(e));
} }
// at this point, we've either successfully decrypted the event, or have given up // at this point, we've either successfully decrypted the event, or have given up

View File

@ -33,6 +33,9 @@ export type EventHandlerMap = {
[RelationsEvent.Redaction]: (event: MatrixEvent) => void; [RelationsEvent.Redaction]: (event: MatrixEvent) => void;
}; };
const matchesEventType = (eventType: string, targetEventType: string, altTargetEventTypes: string[] = []): boolean =>
[targetEventType, ...altTargetEventTypes].includes(eventType);
/** /**
* A container for relation events that supports easy access to common ways of * A container for relation events that supports easy access to common ways of
* aggregating such events. Each instance holds events that of a single relation * aggregating such events. Each instance holds events that of a single relation
@ -55,11 +58,13 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
* @param relationType - The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc. * @param relationType - The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc.
* @param eventType - The relation event's type, such as "m.reaction", etc. * @param eventType - The relation event's type, such as "m.reaction", etc.
* @param client - The client which created this instance. For backwards compatibility also accepts a Room. * @param client - The client which created this instance. For backwards compatibility also accepts a Room.
* @param altEventTypes - alt event types for relation events, for example to support unstable prefixed event types
*/ */
public constructor( public constructor(
public readonly relationType: RelationType | string, public readonly relationType: RelationType | string,
public readonly eventType: string, public readonly eventType: string,
client: MatrixClient | Room, client: MatrixClient | Room,
public readonly altEventTypes?: string[],
) { ) {
super(); super();
this.client = client instanceof Room ? client.client : client; this.client = client instanceof Room ? client.client : client;
@ -84,7 +89,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
const relationType = relation.rel_type; const relationType = relation.rel_type;
const eventType = event.getType(); const eventType = event.getType();
if (this.relationType !== relationType || this.eventType !== eventType) { if (this.relationType !== relationType || !matchesEventType(eventType, this.eventType, this.altEventTypes)) {
logger.error("Event relation info doesn't match this container"); logger.error("Event relation info doesn't match this container");
return; return;
} }
@ -122,20 +127,6 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
return; return;
} }
const relation = event.getRelation();
if (!relation) {
logger.error("Event must have relation info");
return;
}
const relationType = relation.rel_type;
const eventType = event.getType();
if (this.relationType !== relationType || this.eventType !== eventType) {
logger.error("Event relation info doesn't match this container");
return;
}
this.relations.delete(event); this.relations.delete(event);
if (this.relationType === RelationType.Annotation) { if (this.relationType === RelationType.Annotation) {

View File

@ -623,6 +623,16 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
return this.oobMemberFlags.status === OobStatus.NotStarted; return this.oobMemberFlags.status === OobStatus.NotStarted;
} }
/**
* Check if loading of out-of-band-members has completed
*
* @returns true if the full membership list of this room has been loaded. False if it is not started or is in
* progress.
*/
public outOfBandMembersReady(): boolean {
return this.oobMemberFlags.status === OobStatus.Finished;
}
/** /**
* Mark this room state as waiting for out-of-band members, * Mark this room state as waiting for out-of-band members,
* ensuring it doesn't ask for them to be requested again * ensuring it doesn't ask for them to be requested again

View File

@ -54,7 +54,13 @@ import {
FILTER_RELATED_BY_SENDERS, FILTER_RELATED_BY_SENDERS,
ThreadFilterType, ThreadFilterType,
} from "./thread"; } from "./thread";
import { MAIN_ROOM_TIMELINE, Receipt, ReceiptContent, ReceiptType } from "../@types/read_receipts"; import {
CachedReceiptStructure,
MAIN_ROOM_TIMELINE,
Receipt,
ReceiptContent,
ReceiptType,
} from "../@types/read_receipts";
import { IStateEventWithRoomId } from "../@types/search"; import { IStateEventWithRoomId } from "../@types/search";
import { RelationsContainer } from "./relations-container"; import { RelationsContainer } from "./relations-container";
import { ReadReceipt, synthesizeReceipt } from "./read-receipt"; import { ReadReceipt, synthesizeReceipt } from "./read-receipt";
@ -302,7 +308,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
private txnToEvent: Record<string, MatrixEvent> = {}; // Pending in-flight requests { string: MatrixEvent } private txnToEvent: Record<string, MatrixEvent> = {}; // Pending in-flight requests { string: MatrixEvent }
private notificationCounts: NotificationCount = {}; private notificationCounts: NotificationCount = {};
private readonly threadNotifications = new Map<string, NotificationCount>(); private readonly threadNotifications = new Map<string, NotificationCount>();
public readonly cachedThreadReadReceipts = new Map<string, { event: MatrixEvent; synthetic: boolean }[]>(); public readonly cachedThreadReadReceipts = new Map<string, CachedReceiptStructure[]>();
private readonly timelineSets: EventTimelineSet[]; private readonly timelineSets: EventTimelineSet[];
public readonly threadsTimelineSets: EventTimelineSet[] = []; public readonly threadsTimelineSets: EventTimelineSet[] = [];
// any filtered timeline sets we're maintaining for this room // any filtered timeline sets we're maintaining for this room
@ -441,9 +447,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}); });
events.forEach(async (serializedEvent: Partial<IEvent>) => { events.forEach(async (serializedEvent: Partial<IEvent>) => {
const event = mapper(serializedEvent); const event = mapper(serializedEvent);
if (event.getType() === EventType.RoomMessageEncrypted && this.client.isCryptoEnabled()) { await client.decryptEventIfNeeded(event);
await event.attemptDecryption(this.client.crypto!);
}
event.setStatus(EventStatus.NOT_SENT); event.setStatus(EventStatus.NOT_SENT);
this.addPendingEvent(event, event.getTxnId()!); this.addPendingEvent(event, event.getTxnId()!);
}); });
@ -503,9 +507,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
const decryptionPromises = events const decryptionPromises = events
.slice(readReceiptTimelineIndex) .slice(readReceiptTimelineIndex)
.filter((event) => event.shouldAttemptDecryption())
.reverse() .reverse()
.map((event) => event.attemptDecryption(this.client.crypto!, { isRetry: true })); .map((event) => this.client.decryptEventIfNeeded(event, { isRetry: true }));
await Promise.allSettled(decryptionPromises); await Promise.allSettled(decryptionPromises);
} }
@ -521,9 +524,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
const decryptionPromises = this.getUnfilteredTimelineSet() const decryptionPromises = this.getUnfilteredTimelineSet()
.getLiveTimeline() .getLiveTimeline()
.getEvents() .getEvents()
.filter((event) => event.shouldAttemptDecryption()) .slice(0) // copy before reversing
.reverse() .reverse()
.map((event) => event.attemptDecryption(this.client.crypto!, { isRetry: true })); .map((event) => this.client.decryptEventIfNeeded(event, { isRetry: true }));
await Promise.allSettled(decryptionPromises); await Promise.allSettled(decryptionPromises);
} }
@ -888,6 +891,20 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return { memberEvents, fromServer }; return { memberEvents, fromServer };
} }
/**
* Check if loading of out-of-band-members has completed
*
* @returns true if the full membership list of this room has been loaded (including if lazy-loading is disabled).
* False if the load is not started or is in progress.
*/
public membersLoaded(): boolean {
if (!this.opts.lazyLoadMembers) {
return true;
}
return this.currentState.outOfBandMembersReady();
}
/** /**
* Preloads the member list in case lazy loading * Preloads the member list in case lazy loading
* of memberships is in use. Can be called multiple times, * of memberships is in use. Can be called multiple times,
@ -909,10 +926,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
const inMemoryUpdate = this.loadMembers() const inMemoryUpdate = this.loadMembers()
.then((result) => { .then((result) => {
this.currentState.setOutOfBandMembers(result.memberEvents); this.currentState.setOutOfBandMembers(result.memberEvents);
// now the members are loaded, start to track the e2e devices if needed
if (this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId)) {
this.client.crypto!.trackRoomDevices(this.roomId);
}
return result.fromServer; return result.fromServer;
}) })
.catch((err) => { .catch((err) => {
@ -2711,7 +2724,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// when the thread is created // when the thread is created
this.cachedThreadReadReceipts.set(receipt.thread_id!, [ this.cachedThreadReadReceipts.set(receipt.thread_id!, [
...(this.cachedThreadReadReceipts.get(receipt.thread_id!) ?? []), ...(this.cachedThreadReadReceipts.get(receipt.thread_id!) ?? []),
{ event, synthetic }, { eventId, receiptType, userId, receipt, synthetic },
]); ]);
} }
}); });

View File

@ -27,7 +27,7 @@ import { RoomState } from "./room-state";
import { ServerControlledNamespacedValue } from "../NamespacedValue"; import { ServerControlledNamespacedValue } from "../NamespacedValue";
import { logger } from "../logger"; import { logger } from "../logger";
import { ReadReceipt } from "./read-receipt"; import { ReadReceipt } from "./read-receipt";
import { Receipt, ReceiptContent, ReceiptType } from "../@types/read_receipts"; import { CachedReceiptStructure, ReceiptType } from "../@types/read_receipts";
export enum ThreadEvent { export enum ThreadEvent {
New = "Thread.new", New = "Thread.new",
@ -50,7 +50,7 @@ interface IThreadOpts {
room: Room; room: Room;
client: MatrixClient; client: MatrixClient;
pendingEventOrdering?: PendingEventOrdering; pendingEventOrdering?: PendingEventOrdering;
receipts?: { event: MatrixEvent; synthetic: boolean }[]; receipts?: CachedReceiptStructure[];
} }
export enum FeatureSupport { export enum FeatureSupport {
@ -97,6 +97,11 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
private readonly pendingEventOrdering: PendingEventOrdering; private readonly pendingEventOrdering: PendingEventOrdering;
public initialEventsFetched = !Thread.hasServerSideSupport; public initialEventsFetched = !Thread.hasServerSideSupport;
/**
* An array of events to add to the timeline once the thread has been initialised
* with server suppport.
*/
public replayEvents: MatrixEvent[] | null = [];
public constructor(public readonly id: string, public rootEvent: MatrixEvent | undefined, opts: IThreadOpts) { public constructor(public readonly id: string, public rootEvent: MatrixEvent | undefined, opts: IThreadOpts) {
super(); super();
@ -266,6 +271,20 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
this.addEventToTimeline(event, false); this.addEventToTimeline(event, false);
this.fetchEditsWhereNeeded(event); this.fetchEditsWhereNeeded(event);
} else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) { } else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) {
if (!this.initialEventsFetched) {
/**
* A thread can be fully discovered via a single sync response
* And when that's the case we still ask the server to do an initialisation
* as it's the safest to ensure we have everything.
* However when we are in that scenario we might loose annotation or edits
*
* This fix keeps a reference to those events and replay them once the thread
* has been initialised properly.
*/
this.replayEvents?.push(event);
} else {
this.addEventToTimeline(event, toStartOfTimeline);
}
// Apply annotations and replace relations to the relations of the timeline only // Apply annotations and replace relations to the relations of the timeline only
this.timelineSet.relations?.aggregateParentEvent(event); this.timelineSet.relations?.aggregateParentEvent(event);
this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet); this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
@ -298,17 +317,9 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
* and apply them to the current thread * and apply them to the current thread
* @param receipts - A collection of the receipts cached from initial sync * @param receipts - A collection of the receipts cached from initial sync
*/ */
private processReceipts(receipts: { event: MatrixEvent; synthetic: boolean }[] = []): void { private processReceipts(receipts: CachedReceiptStructure[] = []): void {
for (const { event, synthetic } of receipts) { for (const { eventId, receiptType, userId, receipt, synthetic } of receipts) {
const content = event.getContent<ReceiptContent>(); this.addReceiptToStructure(eventId, receiptType as ReceiptType, userId, receipt, synthetic);
Object.keys(content).forEach((eventId: string) => {
Object.keys(content[eventId]).forEach((receiptType: ReceiptType | string) => {
Object.keys(content[eventId][receiptType]).forEach((userId: string) => {
const receipt = content[eventId][receiptType][userId] as Receipt;
this.addReceiptToStructure(eventId, receiptType as ReceiptType, userId, receipt, synthetic);
});
});
});
} }
} }
@ -375,6 +386,10 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
limit: Math.max(1, this.length), limit: Math.max(1, this.length),
}); });
} }
for (const event of this.replayEvents!) {
this.addEvent(event, false);
}
this.replayEvents = null;
// just to make sure that, if we've created a timeline window for this thread before the thread itself // just to make sure that, if we've created a timeline window for this thread before the thread itself
// existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly. // existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly.
this.emit(RoomEvent.TimelineReset, this.room, this.timelineSet, true); this.emit(RoomEvent.TimelineReset, this.room, this.timelineSet, true);

View File

@ -20,8 +20,13 @@ import { RustCrypto } from "./rust-crypto";
import { logger } from "../logger"; import { logger } from "../logger";
import { CryptoBackend } from "../common-crypto/CryptoBackend"; import { CryptoBackend } from "../common-crypto/CryptoBackend";
import { RUST_SDK_STORE_PREFIX } from "./constants"; import { RUST_SDK_STORE_PREFIX } from "./constants";
import { IHttpOpts, MatrixHttpApi } from "../http-api";
export async function initRustCrypto(userId: string, deviceId: string): Promise<CryptoBackend> { export async function initRustCrypto(
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
userId: string,
deviceId: string,
): Promise<CryptoBackend> {
// initialise the rust matrix-sdk-crypto-js, if it hasn't already been done // initialise the rust matrix-sdk-crypto-js, if it hasn't already been done
await RustSdkCryptoJs.initAsync(); await RustSdkCryptoJs.initAsync();
@ -34,7 +39,7 @@ export async function initRustCrypto(userId: string, deviceId: string): Promise<
// TODO: use the pickle key for the passphrase // TODO: use the pickle key for the passphrase
const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(u, d, RUST_SDK_STORE_PREFIX, "test pass"); const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(u, d, RUST_SDK_STORE_PREFIX, "test pass");
const rustCrypto = new RustCrypto(olmMachine, userId, deviceId); const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId);
logger.info("Completed rust crypto-sdk setup"); logger.info("Completed rust crypto-sdk setup");
return rustCrypto; return rustCrypto;

View File

@ -15,12 +15,29 @@ limitations under the License.
*/ */
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js"; import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import {
KeysBackupRequest,
KeysClaimRequest,
KeysQueryRequest,
KeysUploadRequest,
SignatureUploadRequest,
} from "@matrix-org/matrix-sdk-crypto-js";
import { IEventDecryptionResult } from "../@types/crypto"; import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
import type { IToDeviceEvent } from "../sync-accumulator";
import { MatrixEvent } from "../models/event"; import { MatrixEvent } from "../models/event";
import { CryptoBackend } from "../common-crypto/CryptoBackend"; import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
import { logger } from "../logger";
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
import { QueryDict } from "../utils";
// import { logger } from "../logger"; /**
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
*/
interface OutgoingRequest {
readonly id: string | undefined;
readonly type: number;
}
/** /**
* An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto. * An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
@ -29,10 +46,18 @@ export class RustCrypto implements CryptoBackend {
public globalBlacklistUnverifiedDevices = false; public globalBlacklistUnverifiedDevices = false;
public globalErrorOnUnknownDevices = false; public globalErrorOnUnknownDevices = false;
/** whether stop() has been called */ /** whether {@link stop} has been called */
private stopped = false; private stopped = false;
public constructor(private readonly olmMachine: RustSdkCryptoJs.OlmMachine, _userId: string, _deviceId: string) {} /** whether {@link outgoingRequestLoop} is currently running */
private outgoingRequestLoopRunning = false;
public constructor(
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
_userId: string,
_deviceId: string,
) {}
public stop(): void { public stop(): void {
// stop() may be called multiple times, but attempting to close() the OlmMachine twice // stop() may be called multiple times, but attempting to close() the OlmMachine twice
@ -57,4 +82,120 @@ export class RustCrypto implements CryptoBackend {
// TODO // TODO
return false; return false;
} }
public async exportRoomKeys(): Promise<IMegolmSessionData[]> {
// TODO
return [];
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// SyncCryptoCallbacks implementation
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/** called by the sync loop to preprocess incoming to-device messages
*
* @param events - the received to-device messages
* @returns A list of preprocessed to-device messages.
*/
public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> {
// send the received to-device messages into receiveSyncChanges. We have no info on device-list changes,
// one-time-keys, or fallback keys, so just pass empty data.
const result = await this.olmMachine.receiveSyncChanges(
JSON.stringify(events),
new RustSdkCryptoJs.DeviceLists(),
new Map(),
new Set(),
);
// receiveSyncChanges returns a JSON-encoded list of decrypted to-device messages.
return JSON.parse(result);
}
/** called by the sync loop after processing each sync.
*
* TODO: figure out something equivalent for sliding sync.
*
* @param syncState - information on the completed sync.
*/
public onSyncCompleted(syncState: OnSyncCompletedData): void {
// Processing the /sync may have produced new outgoing requests which need sending, so kick off the outgoing
// request loop, if it's not already running.
this.outgoingRequestLoop();
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Outgoing requests
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
private async outgoingRequestLoop(): Promise<void> {
if (this.outgoingRequestLoopRunning) {
return;
}
this.outgoingRequestLoopRunning = true;
try {
while (!this.stopped) {
const outgoingRequests: Object[] = await this.olmMachine.outgoingRequests();
if (outgoingRequests.length == 0 || this.stopped) {
// no more messages to send (or we have been told to stop): exit the loop
return;
}
for (const msg of outgoingRequests) {
await this.doOutgoingRequest(msg as OutgoingRequest);
}
}
} catch (e) {
logger.error("Error processing outgoing-message requests from rust crypto-sdk", e);
} finally {
this.outgoingRequestLoopRunning = false;
}
}
private async doOutgoingRequest(msg: OutgoingRequest): Promise<void> {
let resp: string;
/* refer https://docs.rs/matrix-sdk-crypto/0.6.0/matrix_sdk_crypto/requests/enum.OutgoingRequests.html
* for the complete list of request types
*/
if (msg instanceof KeysUploadRequest) {
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/upload", {}, msg.body);
} else if (msg instanceof KeysQueryRequest) {
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/query", {}, msg.body);
} else if (msg instanceof KeysClaimRequest) {
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/claim", {}, msg.body);
} else if (msg instanceof SignatureUploadRequest) {
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/signatures/upload", {}, msg.body);
} else if (msg instanceof KeysBackupRequest) {
resp = await this.rawJsonRequest(Method.Put, "/_matrix/client/v3/room_keys/keys", {}, msg.body);
} else {
// TODO: ToDeviceRequest, RoomMessageRequest
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
resp = "";
}
if (msg.id) {
await this.olmMachine.markRequestAsSent(msg.id, msg.type, resp);
}
}
private async rawJsonRequest(method: Method, path: string, queryParams: QueryDict, body: string): Promise<string> {
const opts = {
// inhibit the JSON stringification and parsing within HttpApi.
json: false,
// nevertheless, we are sending, and accept, JSON.
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
// we use the full prefix
prefix: "",
};
return await this.http.authedRequest<string>(method, path, queryParams, body, opts);
}
} }

View File

@ -14,12 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend";
import { NotificationCountType, Room, RoomEvent } from "./models/room"; import { NotificationCountType, Room, RoomEvent } from "./models/room";
import { logger } from "./logger"; import { logger } from "./logger";
import * as utils from "./utils"; import * as utils from "./utils";
import { EventTimeline } from "./models/event-timeline"; import { EventTimeline } from "./models/event-timeline";
import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering } from "./client"; import { ClientEvent, IStoredClientOpts, MatrixClient } from "./client";
import { ISyncStateData, SyncState, _createAndReEmitRoom } from "./sync"; import {
ISyncStateData,
SyncState,
_createAndReEmitRoom,
SyncApiOptions,
defaultClientOpts,
defaultSyncApiOpts,
} from "./sync";
import { MatrixEvent } from "./models/event"; import { MatrixEvent } from "./models/event";
import { Crypto } from "./crypto"; import { Crypto } from "./crypto";
import { IMinimalEvent, IRoomEvent, IStateEvent, IStrippedState, ISyncResponse } from "./sync-accumulator"; import { IMinimalEvent, IRoomEvent, IStateEvent, IStrippedState, ISyncResponse } from "./sync-accumulator";
@ -102,6 +110,7 @@ class ExtensionE2EE implements Extension<ExtensionE2EERequest, ExtensionE2EEResp
Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"), Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"),
); );
} }
this.crypto.onSyncCompleted({});
} }
} }
@ -119,7 +128,7 @@ type ExtensionToDeviceResponse = {
class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, ExtensionToDeviceResponse> { class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, ExtensionToDeviceResponse> {
private nextBatch: string | null = null; private nextBatch: string | null = null;
public constructor(private readonly client: MatrixClient) {} public constructor(private readonly client: MatrixClient, private readonly cryptoCallbacks?: SyncCryptoCallbacks) {}
public name(): string { public name(): string {
return "to_device"; return "to_device";
@ -142,8 +151,12 @@ class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, Extension
public async onResponse(data: ExtensionToDeviceResponse): Promise<void> { public async onResponse(data: ExtensionToDeviceResponse): Promise<void> {
const cancelledKeyVerificationTxns: string[] = []; const cancelledKeyVerificationTxns: string[] = [];
data.events let events = data["events"] || [];
?.map(this.client.getEventMapper()) if (events.length > 0 && this.cryptoCallbacks) {
events = await this.cryptoCallbacks.preprocessToDeviceMessages(events);
}
events
.map(this.client.getEventMapper())
.map((toDeviceEvent) => { .map((toDeviceEvent) => {
// map is a cheap inline forEach // map is a cheap inline forEach
// We want to flag m.key.verification.start events as cancelled // We want to flag m.key.verification.start events as cancelled
@ -341,6 +354,8 @@ class ExtensionReceipts implements Extension<ExtensionReceiptsRequest, Extension
* sliding sync API, see sliding-sync.ts or the class SlidingSync. * sliding sync API, see sliding-sync.ts or the class SlidingSync.
*/ */
export class SlidingSyncSdk { export class SlidingSyncSdk {
private readonly opts: IStoredClientOpts;
private readonly syncOpts: SyncApiOptions;
private syncState: SyncState | null = null; private syncState: SyncState | null = null;
private syncStateData?: ISyncStateData; private syncStateData?: ISyncStateData;
private lastPos: string | null = null; private lastPos: string | null = null;
@ -350,19 +365,11 @@ export class SlidingSyncSdk {
public constructor( public constructor(
private readonly slidingSync: SlidingSync, private readonly slidingSync: SlidingSync,
private readonly client: MatrixClient, private readonly client: MatrixClient,
private readonly opts: Partial<IStoredClientOpts> = {}, opts?: IStoredClientOpts,
syncOpts?: SyncApiOptions,
) { ) {
this.opts.initialSyncLimit = this.opts.initialSyncLimit ?? 8; this.opts = defaultClientOpts(opts);
this.opts.resolveInvitesToProfiles = this.opts.resolveInvitesToProfiles || false; this.syncOpts = defaultSyncApiOpts(syncOpts);
this.opts.pollTimeout = this.opts.pollTimeout || 30 * 1000;
this.opts.pendingEventOrdering = this.opts.pendingEventOrdering || PendingEventOrdering.Chronological;
this.opts.experimentalThreadSupport = this.opts.experimentalThreadSupport === true;
if (!opts.canResetEntireTimeline) {
opts.canResetEntireTimeline = (_roomId: string): boolean => {
return false;
};
}
if (client.getNotifTimelineSet()) { if (client.getNotifTimelineSet()) {
client.reEmitter.reEmit(client.getNotifTimelineSet()!, [RoomEvent.Timeline, RoomEvent.TimelineReset]); client.reEmitter.reEmit(client.getNotifTimelineSet()!, [RoomEvent.Timeline, RoomEvent.TimelineReset]);
@ -371,13 +378,13 @@ export class SlidingSyncSdk {
this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle.bind(this)); this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle.bind(this));
this.slidingSync.on(SlidingSyncEvent.RoomData, this.onRoomData.bind(this)); this.slidingSync.on(SlidingSyncEvent.RoomData, this.onRoomData.bind(this));
const extensions: Extension<any, any>[] = [ const extensions: Extension<any, any>[] = [
new ExtensionToDevice(this.client), new ExtensionToDevice(this.client, this.syncOpts.cryptoCallbacks),
new ExtensionAccountData(this.client), new ExtensionAccountData(this.client),
new ExtensionTyping(this.client), new ExtensionTyping(this.client),
new ExtensionReceipts(this.client), new ExtensionReceipts(this.client),
]; ];
if (this.opts.crypto) { if (this.syncOpts.crypto) {
extensions.push(new ExtensionE2EE(this.opts.crypto)); extensions.push(new ExtensionE2EE(this.syncOpts.crypto));
} }
extensions.forEach((ext) => { extensions.forEach((ext) => {
this.slidingSync.registerExtension(ext); this.slidingSync.registerExtension(ext);
@ -697,7 +704,7 @@ export class SlidingSyncSdk {
if (limited) { if (limited) {
room.resetLiveTimeline( room.resetLiveTimeline(
roomData.prev_batch, roomData.prev_batch,
null, // TODO this.opts.canResetEntireTimeline(room.roomId) ? null : syncEventData.oldSyncToken, null, // TODO this.syncOpts.canResetEntireTimeline(room.roomId) ? null : syncEventData.oldSyncToken,
); );
// We have to assume any gap in any timeline is // We have to assume any gap in any timeline is
@ -729,8 +736,8 @@ export class SlidingSyncSdk {
const processRoomEvent = async (e: MatrixEvent): Promise<void> => { const processRoomEvent = async (e: MatrixEvent): Promise<void> => {
client.emit(ClientEvent.Event, e); client.emit(ClientEvent.Event, e);
if (e.isState() && e.getType() == EventType.RoomEncryption && this.opts.crypto) { if (e.isState() && e.getType() == EventType.RoomEncryption && this.syncOpts.crypto) {
await this.opts.crypto.onCryptoEvent(room, e); await this.syncOpts.crypto.onCryptoEvent(room, e);
} }
}; };

View File

@ -397,6 +397,10 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
* @param sub - The subscription information. * @param sub - The subscription information.
*/ */
public addCustomSubscription(name: string, sub: MSC3575RoomSubscription): void { public addCustomSubscription(name: string, sub: MSC3575RoomSubscription): void {
if (this.customSubscriptions.has(name)) {
logger.warn(`addCustomSubscription: ${name} already exists as a custom subscription, ignoring.`);
return;
}
this.customSubscriptions.set(name, sub); this.customSubscriptions.set(name, sub);
} }
@ -408,6 +412,11 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
* will be used. * will be used.
*/ */
public useCustomSubscription(roomId: string, name: string): void { public useCustomSubscription(roomId: string, name: string): void {
// We already know about this custom subscription, as it is immutable,
// we don't need to unconfirm the subscription.
if (this.roomIdToCustomSubscription.get(roomId) === name) {
return;
}
this.roomIdToCustomSubscription.set(roomId, name); this.roomIdToCustomSubscription.set(roomId, name);
// unconfirm this subscription so a resend() will send it up afresh. // unconfirm this subscription so a resend() will send it up afresh.
this.confirmedRoomSubscriptions.delete(roomId); this.confirmedRoomSubscriptions.delete(roomId);

View File

@ -25,6 +25,7 @@ limitations under the License.
import { Optional } from "matrix-events-sdk"; import { Optional } from "matrix-events-sdk";
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend";
import { User, UserEvent } from "./models/user"; import { User, UserEvent } from "./models/user";
import { NotificationCountType, Room, RoomEvent } from "./models/room"; import { NotificationCountType, Room, RoomEvent } from "./models/room";
import * as utils from "./utils"; import * as utils from "./utils";
@ -34,7 +35,7 @@ import { EventTimeline } from "./models/event-timeline";
import { PushProcessor } from "./pushprocessor"; import { PushProcessor } from "./pushprocessor";
import { logger } from "./logger"; import { logger } from "./logger";
import { InvalidStoreError, InvalidStoreState } from "./errors"; import { InvalidStoreError, InvalidStoreState } from "./errors";
import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering } from "./client"; import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering, ResetTimelineCallback } from "./client";
import { import {
IEphemeral, IEphemeral,
IInvitedRoom, IInvitedRoom,
@ -47,6 +48,7 @@ import {
IStrippedState, IStrippedState,
ISyncResponse, ISyncResponse,
ITimeline, ITimeline,
IToDeviceEvent,
} from "./sync-accumulator"; } from "./sync-accumulator";
import { MatrixEvent } from "./models/event"; import { MatrixEvent } from "./models/event";
import { MatrixError, Method } from "./http-api"; import { MatrixError, Method } from "./http-api";
@ -59,6 +61,7 @@ import { BeaconEvent } from "./models/beacon";
import { IEventsResponse } from "./@types/requests"; import { IEventsResponse } from "./@types/requests";
import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync"; import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync";
import { Feature, ServerSupport } from "./feature"; import { Feature, ServerSupport } from "./feature";
import { Crypto } from "./crypto";
const DEBUG = true; const DEBUG = true;
@ -110,6 +113,31 @@ function debuglog(...params: any[]): void {
logger.log(...params); logger.log(...params);
} }
/**
* Options passed into the constructor of SyncApi by MatrixClient
*/
export interface SyncApiOptions {
/**
* Crypto manager
*
* @deprecated in favour of cryptoCallbacks
*/
crypto?: Crypto;
/**
* If crypto is enabled on our client, callbacks into the crypto module
*/
cryptoCallbacks?: SyncCryptoCallbacks;
/**
* A function which is called
* with a room ID and returns a boolean. It should return 'true' if the SDK can
* SAFELY remove events from this room. It may not be safe to remove events if
* there are other references to the timelines for this room.
*/
canResetEntireTimeline?: ResetTimelineCallback;
}
interface ISyncOptions { interface ISyncOptions {
filter?: string; filter?: string;
hasSyncedBefore?: boolean; hasSyncedBefore?: boolean;
@ -163,7 +191,29 @@ type WrappedRoom<T> = T & {
isBrandNewRoom: boolean; isBrandNewRoom: boolean;
}; };
/** add default settings to an IStoredClientOpts */
export function defaultClientOpts(opts?: IStoredClientOpts): IStoredClientOpts {
return {
initialSyncLimit: 8,
resolveInvitesToProfiles: false,
pollTimeout: 30 * 1000,
pendingEventOrdering: PendingEventOrdering.Chronological,
experimentalThreadSupport: false,
...opts,
};
}
export function defaultSyncApiOpts(syncOpts?: SyncApiOptions): SyncApiOptions {
return {
canResetEntireTimeline: (_roomId): boolean => false,
...syncOpts,
};
}
export class SyncApi { export class SyncApi {
private readonly opts: IStoredClientOpts;
private readonly syncOpts: SyncApiOptions;
private _peekRoom: Optional<Room> = null; private _peekRoom: Optional<Room> = null;
private currentSyncRequest?: Promise<ISyncResponse>; private currentSyncRequest?: Promise<ISyncResponse>;
private abortController?: AbortController; private abortController?: AbortController;
@ -180,21 +230,13 @@ export class SyncApi {
/** /**
* Construct an entity which is able to sync with a homeserver. * Construct an entity which is able to sync with a homeserver.
* @param client - The matrix client instance to use. * @param client - The matrix client instance to use.
* @param opts - Config options * @param opts - client config options
* @param syncOpts - sync-specific options passed by the client
* @internal * @internal
*/ */
public constructor(private readonly client: MatrixClient, private readonly opts: Partial<IStoredClientOpts> = {}) { public constructor(private readonly client: MatrixClient, opts?: IStoredClientOpts, syncOpts?: SyncApiOptions) {
this.opts.initialSyncLimit = this.opts.initialSyncLimit ?? 8; this.opts = defaultClientOpts(opts);
this.opts.resolveInvitesToProfiles = this.opts.resolveInvitesToProfiles || false; this.syncOpts = defaultSyncApiOpts(syncOpts);
this.opts.pollTimeout = this.opts.pollTimeout || 30 * 1000;
this.opts.pendingEventOrdering = this.opts.pendingEventOrdering || PendingEventOrdering.Chronological;
this.opts.experimentalThreadSupport = this.opts.experimentalThreadSupport === true;
if (!opts.canResetEntireTimeline) {
opts.canResetEntireTimeline = (roomId: string): boolean => {
return false;
};
}
if (client.getNotifTimelineSet()) { if (client.getNotifTimelineSet()) {
client.reEmitter.reEmit(client.getNotifTimelineSet()!, [RoomEvent.Timeline, RoomEvent.TimelineReset]); client.reEmitter.reEmit(client.getNotifTimelineSet()!, [RoomEvent.Timeline, RoomEvent.TimelineReset]);
@ -632,7 +674,7 @@ export class SyncApi {
return; return;
} }
if (this.opts.lazyLoadMembers) { if (this.opts.lazyLoadMembers) {
this.opts.crypto?.enableLazyLoading(); this.syncOpts.crypto?.enableLazyLoading();
} }
try { try {
debuglog("Storing client options..."); debuglog("Storing client options...");
@ -866,10 +908,10 @@ export class SyncApi {
catchingUp: this.catchingUp, catchingUp: this.catchingUp,
}; };
if (this.opts.crypto) { if (this.syncOpts.crypto) {
// tell the crypto module we're about to process a sync // tell the crypto module we're about to process a sync
// response // response
await this.opts.crypto.onSyncWillProcess(syncEventData); await this.syncOpts.crypto.onSyncWillProcess(syncEventData);
} }
try { try {
@ -894,8 +936,8 @@ export class SyncApi {
// tell the crypto module to do its processing. It may block (to do a // tell the crypto module to do its processing. It may block (to do a
// /keys/changes request). // /keys/changes request).
if (this.opts.crypto) { if (this.syncOpts.cryptoCallbacks) {
await this.opts.crypto.onSyncCompleted(syncEventData); await this.syncOpts.cryptoCallbacks.onSyncCompleted(syncEventData);
} }
// keep emitting SYNCING -> SYNCING for clients who want to do bulk updates // keep emitting SYNCING -> SYNCING for clients who want to do bulk updates
@ -907,8 +949,8 @@ export class SyncApi {
// stored sync data which means we don't have to worry that we may have missed // stored sync data which means we don't have to worry that we may have missed
// device changes. We can also skip the delay since we're not calling this very // device changes. We can also skip the delay since we're not calling this very
// frequently (and we don't really want to delay the sync for it). // frequently (and we don't really want to delay the sync for it).
if (this.opts.crypto) { if (this.syncOpts.crypto) {
await this.opts.crypto.saveDeviceList(0); await this.syncOpts.crypto.saveDeviceList(0);
} }
// tell databases that everything is now in a consistent state and can be saved. // tell databases that everything is now in a consistent state and can be saved.
@ -1129,19 +1171,15 @@ export class SyncApi {
} }
// handle to-device events // handle to-device events
if (Array.isArray(data.to_device?.events) && data.to_device!.events.length > 0) { if (data.to_device && Array.isArray(data.to_device.events) && data.to_device.events.length > 0) {
const cancelledKeyVerificationTxns: string[] = []; let toDeviceMessages: IToDeviceEvent[] = data.to_device.events;
data.to_device!.events.filter((eventJSON) => {
if (
eventJSON.type === EventType.RoomMessageEncrypted &&
!["m.olm.v1.curve25519-aes-sha2"].includes(eventJSON.content?.algorithm)
) {
logger.log("Ignoring invalid encrypted to-device event from " + eventJSON.sender);
return false;
}
return true; if (this.syncOpts.cryptoCallbacks) {
}) toDeviceMessages = await this.syncOpts.cryptoCallbacks.preprocessToDeviceMessages(toDeviceMessages);
}
const cancelledKeyVerificationTxns: string[] = [];
toDeviceMessages
.map(client.getEventMapper({ toDevice: true })) .map(client.getEventMapper({ toDevice: true }))
.map((toDeviceEvent) => { .map((toDeviceEvent) => {
// map is a cheap inline forEach // map is a cheap inline forEach
@ -1356,7 +1394,7 @@ export class SyncApi {
if (limited) { if (limited) {
room.resetLiveTimeline( room.resetLiveTimeline(
joinObj.timeline.prev_batch, joinObj.timeline.prev_batch,
this.opts.canResetEntireTimeline!(room.roomId) ? null : syncEventData.oldSyncToken ?? null, this.syncOpts.canResetEntireTimeline!(room.roomId) ? null : syncEventData.oldSyncToken ?? null,
); );
// We have to assume any gap in any timeline is // We have to assume any gap in any timeline is
@ -1370,10 +1408,10 @@ export class SyncApi {
// avoids a race condition if the application tries to send a message after the // avoids a race condition if the application tries to send a message after the
// state event is processed, but before crypto is enabled, which then causes the // state event is processed, but before crypto is enabled, which then causes the
// crypto layer to complain. // crypto layer to complain.
if (this.opts.crypto) { if (this.syncOpts.crypto) {
for (const e of stateEvents.concat(events)) { for (const e of stateEvents.concat(events)) {
if (e.isState() && e.getType() === EventType.RoomEncryption && e.getStateKey() === "") { if (e.isState() && e.getType() === EventType.RoomEncryption && e.getStateKey() === "") {
await this.opts.crypto.onCryptoEvent(room, e); await this.syncOpts.crypto.onCryptoEvent(room, e);
} }
} }
} }
@ -1462,8 +1500,8 @@ export class SyncApi {
// Handle device list updates // Handle device list updates
if (data.device_lists) { if (data.device_lists) {
if (this.opts.crypto) { if (this.syncOpts.crypto) {
await this.opts.crypto.handleDeviceListChanges(syncEventData, data.device_lists); await this.syncOpts.crypto.handleDeviceListChanges(syncEventData, data.device_lists);
} else { } else {
// FIXME if we *don't* have a crypto module, we still need to // FIXME if we *don't* have a crypto module, we still need to
// invalidate the device lists. But that would require a // invalidate the device lists. But that would require a
@ -1472,12 +1510,12 @@ export class SyncApi {
} }
// Handle one_time_keys_count // Handle one_time_keys_count
if (this.opts.crypto && data.device_one_time_keys_count) { if (this.syncOpts.crypto && data.device_one_time_keys_count) {
const currentCount = data.device_one_time_keys_count.signed_curve25519 || 0; const currentCount = data.device_one_time_keys_count.signed_curve25519 || 0;
this.opts.crypto.updateOneTimeKeyCount(currentCount); this.syncOpts.crypto.updateOneTimeKeyCount(currentCount);
} }
if ( if (
this.opts.crypto && this.syncOpts.crypto &&
(data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"]) (data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"])
) { ) {
// The presence of device_unused_fallback_key_types indicates that the // The presence of device_unused_fallback_key_types indicates that the
@ -1485,7 +1523,7 @@ export class SyncApi {
// signed_curve25519 fallback key we need a new one. // signed_curve25519 fallback key we need a new one.
const unusedFallbackKeys = const unusedFallbackKeys =
data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"]; data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"];
this.opts.crypto.setNeedsNewFallback( this.syncOpts.crypto.setNeedsNewFallback(
Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"), Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"),
); );
} }

View File

@ -2475,18 +2475,20 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
} }
private stopAllMedia(): void { private stopAllMedia(): void {
logger.debug( logger.debug(`Call ${this.callId} stopping all media`);
!this.groupCallId
? `Call ${this.callId} stopping all media`
: `Call ${this.callId} stopping all media except local feeds`,
);
for (const feed of this.feeds) { for (const feed of this.feeds) {
if (feed.isLocal() && feed.purpose === SDPStreamMetadataPurpose.Usermedia && !this.groupCallId) { // Slightly awkward as local feed need to go via the correct method on
// the mediahandler so they get removed from mediahandler (remote tracks
// don't)
// NB. We clone local streams when passing them to individual calls in a group
// call, so we can (and should) stop the clones once we no longer need them:
// the other clones will continue fine.
if (feed.isLocal() && feed.purpose === SDPStreamMetadataPurpose.Usermedia) {
this.client.getMediaHandler().stopUserMediaStream(feed.stream); this.client.getMediaHandler().stopUserMediaStream(feed.stream);
} else if (feed.isLocal() && feed.purpose === SDPStreamMetadataPurpose.Screenshare && !this.groupCallId) { } else if (feed.isLocal() && feed.purpose === SDPStreamMetadataPurpose.Screenshare) {
this.client.getMediaHandler().stopScreensharingStream(feed.stream); this.client.getMediaHandler().stopScreensharingStream(feed.stream);
} else if (!feed.isLocal() || !this.groupCallId) { } else if (!feed.isLocal()) {
logger.debug("Stopping remote stream", feed.stream.id); logger.debug("Stopping remote stream", feed.stream.id);
for (const track of feed.stream.getTracks()) { for (const track of feed.stream.getTracks()) {
track.stop(); track.stop();

View File

@ -55,7 +55,7 @@ export enum GroupCallEvent {
export type GroupCallEventHandlerMap = { export type GroupCallEventHandlerMap = {
[GroupCallEvent.GroupCallStateChanged]: (newState: GroupCallState, oldState: GroupCallState) => void; [GroupCallEvent.GroupCallStateChanged]: (newState: GroupCallState, oldState: GroupCallState) => void;
[GroupCallEvent.ActiveSpeakerChanged]: (activeSpeaker: CallFeed | undefined) => void; [GroupCallEvent.ActiveSpeakerChanged]: (activeSpeaker: CallFeed | undefined) => void;
[GroupCallEvent.CallsChanged]: (calls: Map<RoomMember, Map<string, MatrixCall>>) => void; [GroupCallEvent.CallsChanged]: (calls: Map<string, Map<string, MatrixCall>>) => void;
[GroupCallEvent.UserMediaFeedsChanged]: (feeds: CallFeed[]) => void; [GroupCallEvent.UserMediaFeedsChanged]: (feeds: CallFeed[]) => void;
[GroupCallEvent.ScreenshareFeedsChanged]: (feeds: CallFeed[]) => void; [GroupCallEvent.ScreenshareFeedsChanged]: (feeds: CallFeed[]) => void;
[GroupCallEvent.LocalScreenshareStateChanged]: ( [GroupCallEvent.LocalScreenshareStateChanged]: (
@ -197,11 +197,11 @@ export class GroupCall extends TypedEventEmitter<
public readonly screenshareFeeds: CallFeed[] = []; public readonly screenshareFeeds: CallFeed[] = [];
public groupCallId: string; public groupCallId: string;
private readonly calls = new Map<RoomMember, Map<string, MatrixCall>>(); // RoomMember -> device ID -> MatrixCall private readonly calls = new Map<string, Map<string, MatrixCall>>(); // user_id -> device_id -> MatrixCall
private callHandlers = new Map<string, Map<string, ICallHandlers>>(); // User ID -> device ID -> handlers private callHandlers = new Map<string, Map<string, ICallHandlers>>(); // user_id -> device_id -> ICallHandlers
private activeSpeakerLoopInterval?: ReturnType<typeof setTimeout>; private activeSpeakerLoopInterval?: ReturnType<typeof setTimeout>;
private retryCallLoopInterval?: ReturnType<typeof setTimeout>; private retryCallLoopInterval?: ReturnType<typeof setTimeout>;
private retryCallCounts: Map<RoomMember, Map<string, number>> = new Map(); private retryCallCounts: Map<string, Map<string, number>> = new Map(); // user_id -> device_id -> count
private reEmitter: ReEmitter; private reEmitter: ReEmitter;
private transmitTimer: ReturnType<typeof setTimeout> | null = null; private transmitTimer: ReturnType<typeof setTimeout> | null = null;
private participantsExpirationTimer: ReturnType<typeof setTimeout> | null = null; private participantsExpirationTimer: ReturnType<typeof setTimeout> | null = null;
@ -728,18 +728,18 @@ export class GroupCall extends TypedEventEmitter<
return; return;
} }
const opponent = newCall.getOpponentMember(); const opponentUserId = newCall.getOpponentMember()?.userId;
if (opponent === undefined) { if (opponentUserId === undefined) {
logger.warn("Incoming call with no member. Ignoring."); logger.warn("Incoming call with no member. Ignoring.");
return; return;
} }
const deviceMap = this.calls.get(opponent) ?? new Map<string, MatrixCall>(); const deviceMap = this.calls.get(opponentUserId) ?? new Map<string, MatrixCall>();
const prevCall = deviceMap.get(newCall.getOpponentDeviceId()!); const prevCall = deviceMap.get(newCall.getOpponentDeviceId()!);
if (prevCall?.callId === newCall.callId) return; if (prevCall?.callId === newCall.callId) return;
logger.log(`GroupCall: incoming call from ${opponent.userId} with ID ${newCall.callId}`); logger.log(`GroupCall: incoming call from ${opponentUserId} with ID ${newCall.callId}`);
if (prevCall) this.disposeCall(prevCall, CallErrorCode.Replaced); if (prevCall) this.disposeCall(prevCall, CallErrorCode.Replaced);
@ -747,7 +747,7 @@ export class GroupCall extends TypedEventEmitter<
newCall.answerWithCallFeeds(this.getLocalFeeds().map((feed) => feed.clone())); newCall.answerWithCallFeeds(this.getLocalFeeds().map((feed) => feed.clone()));
deviceMap.set(newCall.getOpponentDeviceId()!, newCall); deviceMap.set(newCall.getOpponentDeviceId()!, newCall);
this.calls.set(opponent, deviceMap); this.calls.set(opponentUserId, deviceMap);
this.emit(GroupCallEvent.CallsChanged, this.calls); this.emit(GroupCallEvent.CallsChanged, this.calls);
}; };
@ -775,38 +775,38 @@ export class GroupCall extends TypedEventEmitter<
private placeOutgoingCalls(): void { private placeOutgoingCalls(): void {
let callsChanged = false; let callsChanged = false;
for (const [member, participantMap] of this.participants) { for (const [{ userId }, participantMap] of this.participants) {
const callMap = this.calls.get(member) ?? new Map<string, MatrixCall>(); const callMap = this.calls.get(userId) ?? new Map<string, MatrixCall>();
for (const [deviceId, participant] of participantMap) { for (const [deviceId, participant] of participantMap) {
const prevCall = callMap.get(deviceId); const prevCall = callMap.get(deviceId);
if ( if (
prevCall?.getOpponentSessionId() !== participant.sessionId && prevCall?.getOpponentSessionId() !== participant.sessionId &&
this.wantsOutgoingCall(member.userId, deviceId) this.wantsOutgoingCall(userId, deviceId)
) { ) {
callsChanged = true; callsChanged = true;
if (prevCall !== undefined) { if (prevCall !== undefined) {
logger.debug(`Replacing call ${prevCall.callId} to ${member.userId} ${deviceId}`); logger.debug(`Replacing call ${prevCall.callId} to ${userId} ${deviceId}`);
this.disposeCall(prevCall, CallErrorCode.NewSession); this.disposeCall(prevCall, CallErrorCode.NewSession);
} }
const newCall = createNewMatrixCall(this.client, this.room.roomId, { const newCall = createNewMatrixCall(this.client, this.room.roomId, {
invitee: member.userId, invitee: userId,
opponentDeviceId: deviceId, opponentDeviceId: deviceId,
opponentSessionId: participant.sessionId, opponentSessionId: participant.sessionId,
groupCallId: this.groupCallId, groupCallId: this.groupCallId,
}); });
if (newCall === null) { if (newCall === null) {
logger.error(`Failed to create call with ${member.userId} ${deviceId}`); logger.error(`Failed to create call with ${userId} ${deviceId}`);
callMap.delete(deviceId); callMap.delete(deviceId);
} else { } else {
this.initCall(newCall); this.initCall(newCall);
callMap.set(deviceId, newCall); callMap.set(deviceId, newCall);
logger.debug(`Placing call to ${member.userId} ${deviceId} (session ${participant.sessionId})`); logger.debug(`Placing call to ${userId} ${deviceId} (session ${participant.sessionId})`);
newCall newCall
.placeCallWithCallFeeds( .placeCallWithCallFeeds(
@ -819,7 +819,7 @@ export class GroupCall extends TypedEventEmitter<
} }
}) })
.catch((e) => { .catch((e) => {
logger.warn(`Failed to place call to ${member.userId}`, e); logger.warn(`Failed to place call to ${userId}`, e);
if (e instanceof CallError && e.code === GroupCallErrorCode.UnknownDevice) { if (e instanceof CallError && e.code === GroupCallErrorCode.UnknownDevice) {
this.emit(GroupCallEvent.Error, e); this.emit(GroupCallEvent.Error, e);
@ -828,7 +828,7 @@ export class GroupCall extends TypedEventEmitter<
GroupCallEvent.Error, GroupCallEvent.Error,
new GroupCallError( new GroupCallError(
GroupCallErrorCode.PlaceCallFailed, GroupCallErrorCode.PlaceCallFailed,
`Failed to place call to ${member.userId}`, `Failed to place call to ${userId}`,
), ),
); );
} }
@ -841,9 +841,9 @@ export class GroupCall extends TypedEventEmitter<
} }
if (callMap.size > 0) { if (callMap.size > 0) {
this.calls.set(member, callMap); this.calls.set(userId, callMap);
} else { } else {
this.calls.delete(member); this.calls.delete(userId);
} }
} }
@ -865,9 +865,9 @@ export class GroupCall extends TypedEventEmitter<
private onRetryCallLoop = (): void => { private onRetryCallLoop = (): void => {
let needsRetry = false; let needsRetry = false;
for (const [member, participantMap] of this.participants) { for (const [{ userId }, participantMap] of this.participants) {
const callMap = this.calls.get(member); const callMap = this.calls.get(userId);
let retriesMap = this.retryCallCounts.get(member); let retriesMap = this.retryCallCounts.get(userId);
for (const [deviceId, participant] of participantMap) { for (const [deviceId, participant] of participantMap) {
const call = callMap?.get(deviceId); const call = callMap?.get(deviceId);
@ -875,12 +875,12 @@ export class GroupCall extends TypedEventEmitter<
if ( if (
call?.getOpponentSessionId() !== participant.sessionId && call?.getOpponentSessionId() !== participant.sessionId &&
this.wantsOutgoingCall(member.userId, deviceId) && this.wantsOutgoingCall(userId, deviceId) &&
retries < 3 retries < 3
) { ) {
if (retriesMap === undefined) { if (retriesMap === undefined) {
retriesMap = new Map(); retriesMap = new Map();
this.retryCallCounts.set(member, retriesMap); this.retryCallCounts.set(userId, retriesMap);
} }
retriesMap.set(deviceId, retries + 1); retriesMap.set(deviceId, retries + 1);
needsRetry = true; needsRetry = true;
@ -1020,36 +1020,36 @@ export class GroupCall extends TypedEventEmitter<
call.setLocalVideoMuted(videoMuted); call.setLocalVideoMuted(videoMuted);
} }
if (state === CallState.Connected) { const opponentUserId = call.getOpponentMember()?.userId;
const opponent = call.getOpponentMember()!; if (state === CallState.Connected && opponentUserId) {
const retriesMap = this.retryCallCounts.get(opponent); const retriesMap = this.retryCallCounts.get(opponentUserId);
retriesMap?.delete(call.getOpponentDeviceId()!); retriesMap?.delete(call.getOpponentDeviceId()!);
if (retriesMap?.size === 0) this.retryCallCounts.delete(opponent); if (retriesMap?.size === 0) this.retryCallCounts.delete(opponentUserId);
} }
}; };
private onCallHangup = (call: MatrixCall): void => { private onCallHangup = (call: MatrixCall): void => {
if (call.hangupReason === CallErrorCode.Replaced) return; if (call.hangupReason === CallErrorCode.Replaced) return;
const opponent = call.getOpponentMember() ?? this.room.getMember(call.invitee!)!; const opponentUserId = call.getOpponentMember()?.userId ?? this.room.getMember(call.invitee!)!.userId;
const deviceMap = this.calls.get(opponent); const deviceMap = this.calls.get(opponentUserId);
// Sanity check that this call is in fact in the map // Sanity check that this call is in fact in the map
if (deviceMap?.get(call.getOpponentDeviceId()!) === call) { if (deviceMap?.get(call.getOpponentDeviceId()!) === call) {
this.disposeCall(call, call.hangupReason as CallErrorCode); this.disposeCall(call, call.hangupReason as CallErrorCode);
deviceMap.delete(call.getOpponentDeviceId()!); deviceMap.delete(call.getOpponentDeviceId()!);
if (deviceMap.size === 0) this.calls.delete(opponent); if (deviceMap.size === 0) this.calls.delete(opponentUserId);
this.emit(GroupCallEvent.CallsChanged, this.calls); this.emit(GroupCallEvent.CallsChanged, this.calls);
} }
}; };
private onCallReplaced = (prevCall: MatrixCall, newCall: MatrixCall): void => { private onCallReplaced = (prevCall: MatrixCall, newCall: MatrixCall): void => {
const opponent = prevCall.getOpponentMember()!; const opponentUserId = prevCall.getOpponentMember()!.userId;
let deviceMap = this.calls.get(opponent); let deviceMap = this.calls.get(opponentUserId);
if (deviceMap === undefined) { if (deviceMap === undefined) {
deviceMap = new Map(); deviceMap = new Map();
this.calls.set(opponent, deviceMap); this.calls.set(opponentUserId, deviceMap);
} }
this.disposeCall(prevCall, CallErrorCode.Replaced); this.disposeCall(prevCall, CallErrorCode.Replaced);

861
yarn.lock

File diff suppressed because it is too large Load Diff