1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-12-02 17:02:31 +03:00

Merge remote-tracking branch 'upstream/develop' into fix/12652/screen-share

This commit is contained in:
Šimon Brandner
2021-07-07 10:43:23 +02:00
104 changed files with 20060 additions and 16166 deletions

3
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,3 @@
<!-- Please read https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.rst before submitting your pull request -->
<!-- Include a Sign-Off as described in https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.rst#sign-off -->

View File

@@ -1,3 +1,81 @@
Changes in [12.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v12.0.1) (2021-07-05)
==================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v12.0.1-rc.1...v12.0.1)
* No changes from rc.1
Changes in [12.0.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v12.0.1-rc.1) (2021-06-29)
============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v12.0.0...v12.0.1-rc.1)
* Fix broken /messages filtering due to internal field changes in
FilterComponent
[\#1759](https://github.com/matrix-org/matrix-js-sdk/pull/1759)
* Convert crypto index to TS
[\#1749](https://github.com/matrix-org/matrix-js-sdk/pull/1749)
* Fix typescript return types for membership update events
[\#1739](https://github.com/matrix-org/matrix-js-sdk/pull/1739)
* Fix types of MatrixEvent sender & target
[\#1753](https://github.com/matrix-org/matrix-js-sdk/pull/1753)
* Add keysharing on invites to File Tree Spaces
[\#1744](https://github.com/matrix-org/matrix-js-sdk/pull/1744)
* Convert Room and RoomState to Typescript
[\#1746](https://github.com/matrix-org/matrix-js-sdk/pull/1746)
* Improve type of IContent msgtype
[\#1752](https://github.com/matrix-org/matrix-js-sdk/pull/1752)
* Add PR template
[\#1747](https://github.com/matrix-org/matrix-js-sdk/pull/1747)
* Add functions to assist in immutability of Event objects
[\#1738](https://github.com/matrix-org/matrix-js-sdk/pull/1738)
* Convert Event Context to TS
[\#1742](https://github.com/matrix-org/matrix-js-sdk/pull/1742)
* Bump lodash from 4.17.20 to 4.17.21
[\#1743](https://github.com/matrix-org/matrix-js-sdk/pull/1743)
* Add invite retries to file trees
[\#1740](https://github.com/matrix-org/matrix-js-sdk/pull/1740)
* Convert IndexedDBStore to TS
[\#1741](https://github.com/matrix-org/matrix-js-sdk/pull/1741)
* Convert additional files to typescript
[\#1736](https://github.com/matrix-org/matrix-js-sdk/pull/1736)
Changes in [12.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v12.0.0) (2021-06-21)
==================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v12.0.0-rc.1...v12.0.0)
* No changes since rc.1
Changes in [12.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v12.0.0-rc.1) (2021-06-15)
============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v11.2.0...v12.0.0-rc.1)
* Rework how disambiguation is handled
[\#1730](https://github.com/matrix-org/matrix-js-sdk/pull/1730)
* Fix baseToString for n=0 edge case to match inverse stringToBase
[\#1735](https://github.com/matrix-org/matrix-js-sdk/pull/1735)
* Move various types from the react-sdk to the js-sdk
[\#1734](https://github.com/matrix-org/matrix-js-sdk/pull/1734)
* Unstable implementation of MSC3089: File Trees
[\#1732](https://github.com/matrix-org/matrix-js-sdk/pull/1732)
* Add MSC3230 event type to enum
[\#1729](https://github.com/matrix-org/matrix-js-sdk/pull/1729)
* Add separate reason code for transferred calls
[\#1731](https://github.com/matrix-org/matrix-js-sdk/pull/1731)
* Use sendonly for call hold
[\#1728](https://github.com/matrix-org/matrix-js-sdk/pull/1728)
* Stop breeding sync listeners
[\#1727](https://github.com/matrix-org/matrix-js-sdk/pull/1727)
* Fix semicolons in TS files
[\#1724](https://github.com/matrix-org/matrix-js-sdk/pull/1724)
* [BREAKING] Convert MatrixClient to TypeScript
[\#1718](https://github.com/matrix-org/matrix-js-sdk/pull/1718)
* Factor out backup management to a separate module
[\#1697](https://github.com/matrix-org/matrix-js-sdk/pull/1697)
* Ignore power_levels events with unknown state_key on room-state
initialization
[\#1723](https://github.com/matrix-org/matrix-js-sdk/pull/1723)
* Revert 1579 (Fix extra negotiate message in Firefox)
[\#1725](https://github.com/matrix-org/matrix-js-sdk/pull/1725)
Changes in [11.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v11.2.0) (2021-06-07)
==================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v11.2.0-rc.1...v11.2.0)

View File

@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "11.2.0",
"version": "12.0.1",
"description": "Matrix Client-Server SDK for Javascript",
"scripts": {
"prepublishOnly": "yarn build",
@@ -55,6 +55,7 @@
"bs58": "^4.0.1",
"content-type": "^1.0.4",
"loglevel": "^1.7.1",
"p-retry": "^4.5.0",
"qs": "^6.9.6",
"request": "^2.88.2",
"unhomoglyph": "^1.0.6"
@@ -73,6 +74,7 @@
"@babel/preset-typescript": "^7.12.7",
"@babel/register": "^7.12.10",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz",
"@types/bs58": "^4.0.1",
"@types/jest": "^26.0.20",
"@types/node": "12",
"@types/request": "^2.48.5",

27
spec/MockBlob.ts Normal file
View File

@@ -0,0 +1,27 @@
/*
Copyright 2021 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.
*/
export class MockBlob {
private contents: number[] = [];
public constructor(private parts: ArrayLike<number>[]) {
parts.forEach(p => Array.from(p).forEach(e => this.contents.push(e)));
}
public get size(): number {
return this.contents.length;
}
}

View File

@@ -165,7 +165,7 @@ describe("DeviceList management:", function() {
aliceTestClient.httpBackend.flush('/keys/query', 1).then(
() => aliceTestClient.httpBackend.flush('/send/', 1),
),
aliceTestClient.client.crypto._deviceList.saveIfDirty(),
aliceTestClient.client.crypto.deviceList.saveIfDirty(),
]);
}).then(() => {
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
@@ -202,7 +202,7 @@ describe("DeviceList management:", function() {
return aliceTestClient.httpBackend.flush('/keys/query', 1);
}).then((flushed) => {
expect(flushed).toEqual(0);
return aliceTestClient.client.crypto._deviceList.saveIfDirty();
return aliceTestClient.client.crypto.deviceList.saveIfDirty();
}).then(() => {
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
@@ -235,7 +235,7 @@ describe("DeviceList management:", function() {
// wait for the client to stop processing the response
return aliceTestClient.client.downloadKeys(['@bob:xyz']);
}).then(() => {
return aliceTestClient.client.crypto._deviceList.saveIfDirty();
return aliceTestClient.client.crypto.deviceList.saveIfDirty();
}).then(() => {
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
@@ -256,7 +256,7 @@ describe("DeviceList management:", function() {
// wait for the client to stop processing the response
return aliceTestClient.client.downloadKeys(['@chris:abc']);
}).then(() => {
return aliceTestClient.client.crypto._deviceList.saveIfDirty();
return aliceTestClient.client.crypto.deviceList.saveIfDirty();
}).then(() => {
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
@@ -286,7 +286,7 @@ describe("DeviceList management:", function() {
},
);
await aliceTestClient.httpBackend.flush('/keys/query', 1);
await aliceTestClient.client.crypto._deviceList.saveIfDirty();
await aliceTestClient.client.crypto.deviceList.saveIfDirty();
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
@@ -322,7 +322,7 @@ describe("DeviceList management:", function() {
);
await aliceTestClient.flushSync();
await aliceTestClient.client.crypto._deviceList.saveIfDirty();
await aliceTestClient.client.crypto.deviceList.saveIfDirty();
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
@@ -358,7 +358,7 @@ describe("DeviceList management:", function() {
);
await aliceTestClient.flushSync();
await aliceTestClient.client.crypto._deviceList.saveIfDirty();
await aliceTestClient.client.crypto.deviceList.saveIfDirty();
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
@@ -379,7 +379,7 @@ describe("DeviceList management:", function() {
anotherTestClient.httpBackend.when('GET', '/sync').respond(
200, getSyncResponse([]));
await anotherTestClient.flushSync();
await anotherTestClient.client.crypto._deviceList.saveIfDirty();
await anotherTestClient.client.crypto.deviceList.saveIfDirty();
anotherTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];

View File

@@ -159,7 +159,7 @@ function aliDownloadsKeys() {
// check that the localStorage is updated as we expect (not sure this is
// an integration test, but meh)
return Promise.all([p1, p2]).then(() => {
return aliTestClient.client.crypto._deviceList.saveIfDirty();
return aliTestClient.client.crypto.deviceList.saveIfDirty();
}).then(() => {
aliTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
const devices = data.devices[bobUserId];

View File

@@ -336,7 +336,7 @@ describe("MatrixClient", function() {
var b = JSON.parse(JSON.stringify(o));
delete(b.signatures);
delete(b.unsigned);
return client.crypto._olmDevice.sign(anotherjson.stringify(b));
return client.crypto.olmDevice.sign(anotherjson.stringify(b));
};
logger.log("Ed25519: " + ed25519key);

View File

@@ -1012,8 +1012,8 @@ describe("megolm", function() {
},
event: true,
});
event._senderCurve25519Key = testSenderKey;
return testClient.client.crypto._onRoomKeyEvent(event);
event.senderCurve25519Key = testSenderKey;
return testClient.client.crypto.onRoomKeyEvent(event);
}).then(() => {
const event = testUtils.mkEvent({
event: true,

View File

@@ -51,7 +51,7 @@ export function mock(constr, name) {
result.toString = function() {
return "mock" + (name ? " of " + name : "");
};
for (const key in constr.prototype) { // eslint-disable-line guard-for-in
for (const key of Object.getOwnPropertyNames(constr.prototype)) { // eslint-disable-line guard-for-in
try {
if (constr.prototype[key] instanceof Function) {
result[key] = jest.fn();

View File

@@ -0,0 +1,78 @@
/*
Copyright 2021 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 { NamespacedValue, UnstableValue } from "../../src/NamespacedValue";
describe("NamespacedValue", () => {
it("should prefer stable over unstable", () => {
const ns = new NamespacedValue("stable", "unstable");
expect(ns.name).toBe(ns.stable);
expect(ns.altName).toBe(ns.unstable);
});
it("should return unstable if there is no stable", () => {
const ns = new NamespacedValue(null, "unstable");
expect(ns.name).toBe(ns.unstable);
expect(ns.altName).toBeFalsy();
});
it("should have a falsey unstable if needed", () => {
const ns = new NamespacedValue("stable", null);
expect(ns.name).toBe(ns.stable);
expect(ns.altName).toBeFalsy();
});
it("should match against either stable or unstable", () => {
const ns = new NamespacedValue("stable", "unstable");
expect(ns.matches("no")).toBe(false);
expect(ns.matches(ns.stable)).toBe(true);
expect(ns.matches(ns.unstable)).toBe(true);
});
it("should not permit falsey values for both parts", () => {
try {
new UnstableValue(null, null);
// noinspection ExceptionCaughtLocallyJS
throw new Error("Failed to fail");
} catch (e) {
expect(e.message).toBe("One of stable or unstable values must be supplied");
}
});
});
describe("UnstableValue", () => {
it("should prefer unstable over stable", () => {
const ns = new UnstableValue("stable", "unstable");
expect(ns.name).toBe(ns.unstable);
expect(ns.altName).toBe(ns.stable);
});
it("should return unstable if there is no stable", () => {
const ns = new UnstableValue(null, "unstable");
expect(ns.name).toBe(ns.unstable);
expect(ns.altName).toBeFalsy();
});
it("should not permit falsey unstable values", () => {
try {
new UnstableValue("stable", null);
// noinspection ExceptionCaughtLocallyJS
throw new Error("Failed to fail");
} catch (e) {
expect(e.message).toBe("Unstable value must be supplied");
}
});
});

View File

@@ -65,7 +65,7 @@ describe("Crypto", function() {
'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI';
device.keys["ed25519:FLIBBLE"] =
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
client.crypto._deviceList.getDeviceByIdentityKey = () => device;
client.crypto.deviceList.getDeviceByIdentityKey = () => device;
encryptionInfo = client.getEventEncryptionInfo(event);
expect(encryptionInfo.encrypted).toBeTruthy();
@@ -213,7 +213,7 @@ describe("Crypto", function() {
async function keyshareEventForEvent(event, index) {
const eventContent = event.getWireContent();
const key = await aliceClient.crypto._olmDevice
const key = await aliceClient.crypto.olmDevice
.getInboundGroupSessionKey(
roomId, eventContent.sender_key, eventContent.session_id,
index,
@@ -234,7 +234,7 @@ describe("Crypto", function() {
},
});
// make onRoomKeyEvent think this was an encrypted event
ksEvent._senderCurve25519Key = "akey";
ksEvent.senderCurve25519Key = "akey";
return ksEvent;
}
@@ -274,9 +274,9 @@ describe("Crypto", function() {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
event._clearEvent = {};
event._senderCurve25519Key = null;
event._claimedEd25519Key = null;
event.clearEvent = {};
event.senderCurve25519Key = null;
event.claimedEd25519Key = null;
try {
await bobClient.crypto.decryptEvent(event);
} catch (e) {
@@ -285,7 +285,7 @@ describe("Crypto", function() {
}
}));
const bobDecryptor = bobClient.crypto._getRoomDecryptor(
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
@@ -377,7 +377,7 @@ describe("Crypto", function() {
// key requests get queued until the sync has finished, but we don't
// let the client set up enough for that to happen, so gut-wrench a bit
// to force it to send now.
aliceClient.crypto._outgoingRoomKeyRequestManager.sendQueuedRequests();
aliceClient.crypto.outgoingRoomKeyRequestManager.sendQueuedRequests();
jest.runAllTimers();
await Promise.resolve();
expect(aliceClient.sendToDevice).toBeCalledTimes(1);

View File

@@ -257,7 +257,7 @@ describe("MegolmDecryption", function() {
});
it("re-uses sessions for sequential messages", async function() {
mockCrypto._backupManager = {
mockCrypto.backupManager = {
backupGroupSession: () => {},
};
const mockStorage = new MockStorageApi();
@@ -365,9 +365,9 @@ describe("MegolmDecryption", function() {
bobClient1.initCrypto(),
bobClient2.initCrypto(),
]);
const aliceDevice = aliceClient.crypto._olmDevice;
const bobDevice1 = bobClient1.crypto._olmDevice;
const bobDevice2 = bobClient2.crypto._olmDevice;
const aliceDevice = aliceClient.crypto.olmDevice;
const bobDevice1 = bobClient1.crypto.olmDevice;
const bobDevice2 = bobClient2.crypto.olmDevice;
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
@@ -404,11 +404,11 @@ describe("MegolmDecryption", function() {
},
};
aliceClient.crypto._deviceList.storeDevicesForUser(
aliceClient.crypto.deviceList.storeDevicesForUser(
"@bob:example.com", BOB_DEVICES,
);
aliceClient.crypto._deviceList.downloadKeys = async function(userIds) {
return this._getDevicesFromStore(userIds);
aliceClient.crypto.deviceList.downloadKeys = async function(userIds) {
return this.getDevicesFromStore(userIds);
};
let run = false;
@@ -468,8 +468,8 @@ describe("MegolmDecryption", function() {
aliceClient.initCrypto(),
bobClient.initCrypto(),
]);
const aliceDevice = aliceClient.crypto._olmDevice;
const bobDevice = bobClient.crypto._olmDevice;
const aliceDevice = aliceClient.crypto.olmDevice;
const bobDevice = bobClient.crypto.olmDevice;
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
@@ -508,11 +508,11 @@ describe("MegolmDecryption", function() {
},
};
aliceClient.crypto._deviceList.storeDevicesForUser(
aliceClient.crypto.deviceList.storeDevicesForUser(
"@bob:example.com", BOB_DEVICES,
);
aliceClient.crypto._deviceList.downloadKeys = async function(userIds) {
return this._getDevicesFromStore(userIds);
aliceClient.crypto.deviceList.downloadKeys = async function(userIds) {
return this.getDevicesFromStore(userIds);
};
aliceClient.claimOneTimeKeys = async () => {
@@ -561,11 +561,11 @@ describe("MegolmDecryption", function() {
aliceClient.initCrypto(),
bobClient.initCrypto(),
]);
const bobDevice = bobClient.crypto._olmDevice;
const bobDevice = bobClient.crypto.olmDevice;
const roomId = "!someroom";
aliceClient.crypto._onToDeviceEvent(new MatrixEvent({
aliceClient.crypto.onToDeviceEvent(new MatrixEvent({
type: "org.matrix.room_key.withheld",
sender: "@bob:example.com",
content: {
@@ -605,13 +605,13 @@ describe("MegolmDecryption", function() {
bobClient.initCrypto(),
]);
aliceClient.crypto.downloadKeys = async () => {};
const bobDevice = bobClient.crypto._olmDevice;
const bobDevice = bobClient.crypto.olmDevice;
const roomId = "!someroom";
const now = Date.now();
aliceClient.crypto._onToDeviceEvent(new MatrixEvent({
aliceClient.crypto.onToDeviceEvent(new MatrixEvent({
type: "org.matrix.room_key.withheld",
sender: "@bob:example.com",
content: {
@@ -655,7 +655,7 @@ describe("MegolmDecryption", function() {
aliceClient.initCrypto(),
bobClient.initCrypto(),
]);
const bobDevice = bobClient.crypto._olmDevice;
const bobDevice = bobClient.crypto.olmDevice;
aliceClient.crypto.downloadKeys = async () => {};
const roomId = "!someroom";
@@ -663,7 +663,7 @@ describe("MegolmDecryption", function() {
const now = Date.now();
// pretend we got an event that we can't decrypt
aliceClient.crypto._onToDeviceEvent(new MatrixEvent({
aliceClient.crypto.onToDeviceEvent(new MatrixEvent({
type: "m.room.encrypted",
sender: "@bob:example.com",
content: {

View File

@@ -139,7 +139,7 @@ describe("MegolmBackup", function() {
let megolmDecryption;
beforeEach(async function() {
mockCrypto = testUtils.mock(Crypto, 'Crypto');
mockCrypto._backupManager = testUtils.mock(BackupManager, "BackupManager");
mockCrypto.backupManager = testUtils.mock(BackupManager, "BackupManager");
mockCrypto.backupKey = new Olm.PkEncryption();
mockCrypto.backupKey.set_recipient_key(
"hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
@@ -217,14 +217,14 @@ describe("MegolmBackup", function() {
};
mockCrypto.cancelRoomKeyRequest = function() {};
mockCrypto._backupManager = {
mockCrypto.backupManager = {
backupGroupSession: jest.fn(),
};
return event.attemptDecryption(mockCrypto).then(() => {
return megolmDecryption.onRoomKeyEvent(event);
}).then(() => {
expect(mockCrypto._backupManager.backupGroupSession).toHaveBeenCalled();
expect(mockCrypto.backupManager.backupGroupSession).toHaveBeenCalled();
});
});
@@ -296,7 +296,7 @@ describe("MegolmBackup", function() {
resolve();
return Promise.resolve({});
};
client.crypto._backupManager.backupGroupSession(
client.crypto.backupManager.backupGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
);
@@ -478,7 +478,7 @@ describe("MegolmBackup", function() {
);
}
};
client.crypto._backupManager.backupGroupSession(
client.crypto.backupManager.backupGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
);

View File

@@ -64,8 +64,8 @@ describe("Cross Signing", function() {
);
alice.uploadDeviceSigningKeys = jest.fn(async (auth, keys) => {
await olmlib.verifySignature(
alice.crypto._olmDevice, keys.master_key, "@alice:example.com",
"Osborne2", alice.crypto._olmDevice.deviceEd25519Key,
alice.crypto.olmDevice, keys.master_key, "@alice:example.com",
"Osborne2", alice.crypto.olmDevice.deviceEd25519Key,
);
});
alice.uploadKeySignatures = async () => {};
@@ -138,7 +138,7 @@ describe("Cross Signing", function() {
// set Alice's cross-signing key
await resetCrossSigningKeys(alice);
// Alice downloads Bob's device key
alice.crypto._deviceList.storeCrossSigningForUser("@bob:example.com", {
alice.crypto.deviceList.storeCrossSigningForUser("@bob:example.com", {
keys: {
master: {
user_id: "@bob:example.com",
@@ -203,12 +203,12 @@ describe("Cross Signing", function() {
alice.uploadKeySignatures = jest.fn(async (content) => {
try {
await olmlib.verifySignature(
alice.crypto._olmDevice,
alice.crypto.olmDevice,
content["@alice:example.com"][
"nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"
],
"@alice:example.com",
"Osborne2", alice.crypto._olmDevice.deviceEd25519Key,
"Osborne2", alice.crypto.olmDevice.deviceEd25519Key,
);
olmlib.pkVerify(
content["@alice:example.com"]["Osborne2"],
@@ -222,7 +222,7 @@ describe("Cross Signing", function() {
});
});
const deviceInfo = alice.crypto._deviceList._devices["@alice:example.com"]
const deviceInfo = alice.crypto.deviceList.devices["@alice:example.com"]
.Osborne2;
const aliceDevice = {
user_id: "@alice:example.com",
@@ -230,7 +230,7 @@ describe("Cross Signing", function() {
};
aliceDevice.keys = deviceInfo.keys;
aliceDevice.algorithms = deviceInfo.algorithms;
await alice.crypto._signObject(aliceDevice);
await alice.crypto.signObject(aliceDevice);
olmlib.pkSign(aliceDevice, selfSigningKey, "@alice:example.com");
// feed sync result that includes master key, ssk, device key
@@ -358,7 +358,7 @@ describe("Cross Signing", function() {
["ed25519:" + bobMasterPubkey]: sskSig,
},
};
alice.crypto._deviceList.storeCrossSigningForUser("@bob:example.com", {
alice.crypto.deviceList.storeCrossSigningForUser("@bob:example.com", {
keys: {
master: {
user_id: "@bob:example.com",
@@ -387,7 +387,7 @@ describe("Cross Signing", function() {
["ed25519:" + bobPubkey]: sig,
},
};
alice.crypto._deviceList.storeDevicesForUser("@bob:example.com", {
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
Dynabook: bobDevice,
});
// Bob's device key should be TOFU
@@ -421,8 +421,8 @@ describe("Cross Signing", function() {
null,
aliceKeys,
);
alice.crypto._deviceList.startTrackingDeviceList("@bob:example.com");
alice.crypto._deviceList.stopTrackingAllDeviceLists = () => {};
alice.crypto.deviceList.startTrackingDeviceList("@bob:example.com");
alice.crypto.deviceList.stopTrackingAllDeviceLists = () => {};
alice.uploadDeviceSigningKeys = async () => {};
alice.uploadKeySignatures = async () => {};
@@ -437,14 +437,14 @@ describe("Cross Signing", function() {
]);
const keyChangePromise = new Promise((resolve, reject) => {
alice.crypto._deviceList.once("userCrossSigningUpdated", (userId) => {
alice.crypto.deviceList.once("userCrossSigningUpdated", (userId) => {
if (userId === "@bob:example.com") {
resolve();
}
});
});
const deviceInfo = alice.crypto._deviceList._devices["@alice:example.com"]
const deviceInfo = alice.crypto.deviceList.devices["@alice:example.com"]
.Osborne2;
const aliceDevice = {
user_id: "@alice:example.com",
@@ -452,7 +452,7 @@ describe("Cross Signing", function() {
};
aliceDevice.keys = deviceInfo.keys;
aliceDevice.algorithms = deviceInfo.algorithms;
await alice.crypto._signObject(aliceDevice);
await alice.crypto.signObject(aliceDevice);
const bobOlmAccount = new global.Olm.Account();
bobOlmAccount.create();
@@ -606,7 +606,7 @@ describe("Cross Signing", function() {
["ed25519:" + bobMasterPubkey]: sskSig,
},
};
alice.crypto._deviceList.storeCrossSigningForUser("@bob:example.com", {
alice.crypto.deviceList.storeCrossSigningForUser("@bob:example.com", {
keys: {
master: {
user_id: "@bob:example.com",
@@ -629,7 +629,7 @@ describe("Cross Signing", function() {
"ed25519:Dynabook": "someOtherPubkey",
},
};
alice.crypto._deviceList.storeDevicesForUser("@bob:example.com", {
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
Dynabook: bobDevice,
});
// Bob's device key should be untrusted
@@ -673,7 +673,7 @@ describe("Cross Signing", function() {
["ed25519:" + bobMasterPubkey]: sskSig,
},
};
alice.crypto._deviceList.storeCrossSigningForUser("@bob:example.com", {
alice.crypto.deviceList.storeCrossSigningForUser("@bob:example.com", {
keys: {
master: {
user_id: "@bob:example.com",
@@ -701,7 +701,7 @@ describe("Cross Signing", function() {
bobDevice.signatures = {};
bobDevice.signatures["@bob:example.com"] = {};
bobDevice.signatures["@bob:example.com"]["ed25519:" + bobPubkey] = sig;
alice.crypto._deviceList.storeDevicesForUser("@bob:example.com", {
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
Dynabook: bobDevice,
});
// Alice verifies Bob's SSK
@@ -733,7 +733,7 @@ describe("Cross Signing", function() {
["ed25519:" + bobMasterPubkey2]: sskSig2,
},
};
alice.crypto._deviceList.storeCrossSigningForUser("@bob:example.com", {
alice.crypto.deviceList.storeCrossSigningForUser("@bob:example.com", {
keys: {
master: {
user_id: "@bob:example.com",
@@ -770,7 +770,7 @@ describe("Cross Signing", function() {
// Alice gets new signature for device
const sig2 = bobSigning2.sign(bobDeviceString);
bobDevice.signatures["@bob:example.com"]["ed25519:" + bobPubkey2] = sig2;
alice.crypto._deviceList.storeDevicesForUser("@bob:example.com", {
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
Dynabook: bobDevice,
});
@@ -805,20 +805,20 @@ describe("Cross Signing", function() {
bob.uploadKeySignatures = async () => {};
// set Bob's cross-signing key
await resetCrossSigningKeys(bob);
alice.crypto._deviceList.storeDevicesForUser("@bob:example.com", {
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
Dynabook: {
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
keys: {
"curve25519:Dynabook": bob.crypto._olmDevice.deviceCurve25519Key,
"ed25519:Dynabook": bob.crypto._olmDevice.deviceEd25519Key,
"curve25519:Dynabook": bob.crypto.olmDevice.deviceCurve25519Key,
"ed25519:Dynabook": bob.crypto.olmDevice.deviceEd25519Key,
},
verified: 1,
known: true,
},
});
alice.crypto._deviceList.storeCrossSigningForUser(
alice.crypto.deviceList.storeCrossSigningForUser(
"@bob:example.com",
bob.crypto._crossSigningInfo.toStorage(),
bob.crypto.crossSigningInfo.toStorage(),
);
alice.uploadDeviceSigningKeys = async () => {};
@@ -838,7 +838,7 @@ describe("Cross Signing", function() {
expect(bobTrust.isTofu()).toBeTruthy();
// "forget" that Bob is trusted
delete alice.crypto._deviceList._crossSigningInfo["@bob:example.com"]
delete alice.crypto.deviceList.crossSigningInfo["@bob:example.com"]
.keys.master.signatures["@alice:example.com"];
const bobTrust2 = alice.checkUserTrust("@bob:example.com");
@@ -848,7 +848,7 @@ describe("Cross Signing", function() {
upgradePromise = new Promise((resolve) => {
upgradeResolveFunc = resolve;
});
alice.crypto._deviceList.emit("userCrossSigningUpdated", "@bob:example.com");
alice.crypto.deviceList.emit("userCrossSigningUpdated", "@bob:example.com");
await new Promise((resolve) => {
alice.crypto.on("userTrustStatusChanged", resolve);
});

View File

@@ -8,26 +8,26 @@ export async function resetCrossSigningKeys(client, {
} = {}) {
const crypto = client.crypto;
const oldKeys = Object.assign({}, crypto._crossSigningInfo.keys);
const oldKeys = Object.assign({}, crypto.crossSigningInfo.keys);
try {
await crypto._crossSigningInfo.resetKeys(level);
await crypto._signObject(crypto._crossSigningInfo.keys.master);
await crypto.crossSigningInfo.resetKeys(level);
await crypto.signObject(crypto.crossSigningInfo.keys.master);
// write a copy locally so we know these are trusted keys
await crypto._cryptoStore.doTxn(
await crypto.cryptoStore.doTxn(
'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT],
(txn) => {
crypto._cryptoStore.storeCrossSigningKeys(
txn, crypto._crossSigningInfo.keys);
crypto.cryptoStore.storeCrossSigningKeys(
txn, crypto.crossSigningInfo.keys);
},
);
} catch (e) {
// If anything failed here, revert the keys so we know to try again from the start
// next time.
crypto._crossSigningInfo.keys = oldKeys;
crypto.crossSigningInfo.keys = oldKeys;
throw e;
}
crypto._baseApis.emit("crossSigning.keysChanged", {});
await crypto._afterCrossSigningLocalKeyChange();
crypto.baseApis.emit("crossSigning.keysChanged", {});
await crypto.afterCrossSigningLocalKeyChange();
}
export async function createSecretStorageKey() {

View File

@@ -21,25 +21,23 @@ import { MemoryCryptoStore } from '../../../src/crypto/store/memory-crypto-store
import 'fake-indexeddb/auto';
import 'jest-localstorage-mock';
import {
ROOM_KEY_REQUEST_STATES,
} from '../../../src/crypto/OutgoingRoomKeyRequestManager';
import { RoomKeyRequestState } from '../../../src/crypto/OutgoingRoomKeyRequestManager';
const requests = [
{
requestId: "A",
requestBody: { session_id: "A", room_id: "A" },
state: ROOM_KEY_REQUEST_STATES.SENT,
state: RoomKeyRequestState.Sent,
},
{
requestId: "B",
requestBody: { session_id: "B", room_id: "B" },
state: ROOM_KEY_REQUEST_STATES.SENT,
state: RoomKeyRequestState.Sent,
},
{
requestId: "C",
requestBody: { session_id: "C", room_id: "C" },
state: ROOM_KEY_REQUEST_STATES.UNSENT,
state: RoomKeyRequestState.Unsent,
},
];
@@ -68,9 +66,9 @@ describe.each([
it("getAllOutgoingRoomKeyRequestsByState retrieves all entries in a given state",
async () => {
const r = await
store.getAllOutgoingRoomKeyRequestsByState(ROOM_KEY_REQUEST_STATES.SENT);
store.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent);
expect(r).toHaveLength(2);
requests.filter((e) => e.state == ROOM_KEY_REQUEST_STATES.SENT).forEach((e) => {
requests.filter((e) => e.state === RoomKeyRequestState.Sent).forEach((e) => {
expect(r).toContainEqual(e);
});
});
@@ -78,10 +76,10 @@ describe.each([
test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state",
async () => {
const r =
await store.getOutgoingRoomKeyRequestByState([ROOM_KEY_REQUEST_STATES.SENT]);
await store.getOutgoingRoomKeyRequestByState([RoomKeyRequestState.Sent]);
expect(r).not.toBeNull();
expect(r).not.toBeUndefined();
expect(r.state).toEqual(ROOM_KEY_REQUEST_STATES.SENT);
expect(r.state).toEqual(RoomKeyRequestState.Sent);
expect(requests).toContainEqual(r);
});
});

View File

@@ -99,11 +99,11 @@ describe("Secrets", function() {
},
},
);
alice.crypto._crossSigningInfo.setKeys({
alice.crypto.crossSigningInfo.setKeys({
master: signingkeyInfo,
});
const secretStorage = alice.crypto._secretStorage;
const secretStorage = alice.crypto.secretStorage;
alice.setAccountData = async function(eventType, contents, callback) {
alice.store.storeAccountDataEvents([
@@ -120,7 +120,7 @@ describe("Secrets", function() {
const keyAccountData = {
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
};
await alice.crypto._crossSigningInfo.signObject(keyAccountData, 'master');
await alice.crypto.crossSigningInfo.signObject(keyAccountData, 'master');
alice.store.storeAccountDataEvents([
new MatrixEvent({
@@ -234,11 +234,11 @@ describe("Secrets", function() {
},
);
const vaxDevice = vax.client.crypto._olmDevice;
const osborne2Device = osborne2.client.crypto._olmDevice;
const secretStorage = osborne2.client.crypto._secretStorage;
const vaxDevice = vax.client.crypto.olmDevice;
const osborne2Device = osborne2.client.crypto.olmDevice;
const secretStorage = osborne2.client.crypto.secretStorage;
osborne2.client.crypto._deviceList.storeDevicesForUser("@alice:example.com", {
osborne2.client.crypto.deviceList.storeDevicesForUser("@alice:example.com", {
"VAX": {
user_id: "@alice:example.com",
device_id: "VAX",
@@ -249,7 +249,7 @@ describe("Secrets", function() {
},
},
});
vax.client.crypto._deviceList.storeDevicesForUser("@alice:example.com", {
vax.client.crypto.deviceList.storeDevicesForUser("@alice:example.com", {
"Osborne2": {
user_id: "@alice:example.com",
device_id: "Osborne2",
@@ -265,7 +265,7 @@ describe("Secrets", function() {
const otks = (await osborne2Device.getOneTimeKeys()).curve25519;
await osborne2Device.markKeysAsPublished();
await vax.client.crypto._olmDevice.createOutboundSession(
await vax.client.crypto.olmDevice.createOutboundSession(
osborne2Device.deviceCurve25519Key,
Object.values(otks)[0],
);
@@ -334,8 +334,8 @@ describe("Secrets", function() {
createSecretStorageKey,
});
const crossSigning = bob.crypto._crossSigningInfo;
const secretStorage = bob.crypto._secretStorage;
const crossSigning = bob.crypto.crossSigningInfo;
const secretStorage = bob.crypto.secretStorage;
expect(crossSigning.getId()).toBeTruthy();
expect(await crossSigning.isStoredInSecretStorage(secretStorage))
@@ -376,10 +376,10 @@ describe("Secrets", function() {
]);
this.emit("accountData", event);
};
bob.crypto._backupManager.checkKeyBackup = async () => {};
bob.crypto.backupManager.checkKeyBackup = async () => {};
const crossSigning = bob.crypto._crossSigningInfo;
const secretStorage = bob.crypto._secretStorage;
const crossSigning = bob.crypto.crossSigningInfo;
const secretStorage = bob.crypto.secretStorage;
// Set up cross-signing keys from scratch with specific storage key
await bob.bootstrapCrossSigning({
@@ -394,7 +394,7 @@ describe("Secrets", function() {
});
// Clear local cross-signing keys and read from secret storage
bob.crypto._deviceList.storeCrossSigningForUser(
bob.crypto.deviceList.storeCrossSigningForUser(
"@bob:example.com",
crossSigning.toStorage(),
);
@@ -479,7 +479,7 @@ describe("Secrets", function() {
},
}),
]);
alice.crypto._deviceList.storeCrossSigningForUser("@alice:example.com", {
alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", {
keys: {
master: {
user_id: "@alice:example.com",
@@ -619,7 +619,7 @@ describe("Secrets", function() {
},
}),
]);
alice.crypto._deviceList.storeCrossSigningForUser("@alice:example.com", {
alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", {
keys: {
master: {
user_id: "@alice:example.com",

View File

@@ -49,7 +49,7 @@ describe("verification request integration tests with crypto layer", function()
verificationMethods: [verificationMethods.SAS],
},
);
alice.client.crypto._deviceList.getRawStoredDevicesForUser = function() {
alice.client.crypto.deviceList.getRawStoredDevicesForUser = function() {
return {
Dynabook: {
keys: {

View File

@@ -87,8 +87,8 @@ describe("SAS verification", function() {
},
);
const aliceDevice = alice.client.crypto._olmDevice;
const bobDevice = bob.client.crypto._olmDevice;
const aliceDevice = alice.client.crypto.olmDevice;
const bobDevice = bob.client.crypto.olmDevice;
ALICE_DEVICES = {
Osborne2: {
@@ -114,14 +114,14 @@ describe("SAS verification", function() {
},
};
alice.client.crypto._deviceList.storeDevicesForUser(
alice.client.crypto.deviceList.storeDevicesForUser(
"@bob:example.com", BOB_DEVICES,
);
alice.client.downloadKeys = () => {
return Promise.resolve();
};
bob.client.crypto._deviceList.storeDevicesForUser(
bob.client.crypto.deviceList.storeDevicesForUser(
"@alice:example.com", ALICE_DEVICES,
);
bob.client.downloadKeys = () => {
@@ -296,9 +296,9 @@ describe("SAS verification", function() {
await resetCrossSigningKeys(bob.client);
bob.client.crypto._deviceList.storeCrossSigningForUser(
bob.client.crypto.deviceList.storeCrossSigningForUser(
"@alice:example.com", {
keys: alice.client.crypto._crossSigningInfo.keys,
keys: alice.client.crypto.crossSigningInfo.keys,
},
);

View File

@@ -48,18 +48,18 @@ describe("self-verifications", () => {
storeCrossSigningKeyCache: jest.fn(),
};
const _crossSigningInfo = new CrossSigningInfo(
const crossSigningInfo = new CrossSigningInfo(
userId,
{},
cacheCallbacks,
);
_crossSigningInfo.keys = {
crossSigningInfo.keys = {
master: { keys: { X: testKeyPub } },
self_signing: { keys: { X: testKeyPub } },
user_signing: { keys: { X: testKeyPub } },
};
const _secretStorage = {
const secretStorage = {
request: jest.fn().mockReturnValue({
promise: Promise.resolve(encodeBase64(testKey)),
}),
@@ -70,12 +70,12 @@ describe("self-verifications", () => {
const client = {
crypto: {
_crossSigningInfo,
_secretStorage,
crossSigningInfo,
secretStorage,
storeSessionBackupPrivateKey,
getSessionBackupPrivateKey: () => null,
},
requestSecret: _secretStorage.request.bind(_secretStorage),
requestSecret: secretStorage.request.bind(secretStorage),
getUserId: () => userId,
getKeyBackupVersion: () => Promise.resolve({}),
restoreKeyBackupWithCache,
@@ -99,7 +99,7 @@ describe("self-verifications", () => {
/* We should request, and store, 3 cross signing keys and the key backup key */
expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls.length).toBe(3);
expect(_secretStorage.request.mock.calls.length).toBe(4);
expect(secretStorage.request.mock.calls.length).toBe(4);
expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[0][1])
.toEqual(testKey);

View File

@@ -3,8 +3,8 @@ import { EventTimeline } from "../../src/models/event-timeline";
import { RoomState } from "../../src/models/room-state";
function mockRoomStates(timeline) {
timeline._startState = utils.mock(RoomState, "startState");
timeline._endState = utils.mock(RoomState, "endState");
timeline.startState = utils.mock(RoomState, "startState");
timeline.endState = utils.mock(RoomState, "endState");
}
describe("EventTimeline", function() {
@@ -48,10 +48,10 @@ describe("EventTimeline", function() {
}),
];
timeline.initialiseState(events);
expect(timeline._startState.setStateEvents).toHaveBeenCalledWith(
expect(timeline.startState.setStateEvents).toHaveBeenCalledWith(
events,
);
expect(timeline._endState.setStateEvents).toHaveBeenCalledWith(
expect(timeline.endState.setStateEvents).toHaveBeenCalledWith(
events,
);
});

View File

@@ -1,6 +1,18 @@
import { logger } from "../../src/logger";
import { MatrixClient } from "../../src/client";
import { Filter } from "../../src/filter";
import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace";
import {
EventType,
RoomCreateTypeField,
RoomType,
UNSTABLE_MSC3088_ENABLED,
UNSTABLE_MSC3088_PURPOSE,
UNSTABLE_MSC3089_TREE_SUBTYPE,
} from "../../src/@types/event";
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
import { MatrixEvent } from "../../src/models/event";
import { Preset } from "../../src/@types/partials";
jest.useFakeTimers();
@@ -171,6 +183,160 @@ describe("MatrixClient", function() {
});
});
it("should create (unstable) file trees", async () => {
const userId = "@test:example.org";
const roomId = "!room:example.org";
const roomName = "Test Tree";
const mockRoom = {};
const fn = jest.fn().mockImplementation((opts) => {
expect(opts).toMatchObject({
name: roomName,
preset: Preset.PrivateChat,
power_level_content_override: {
...DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
users: {
[userId]: 100,
},
},
creation_content: {
[RoomCreateTypeField]: RoomType.Space,
},
initial_state: [
{
// We use `unstable` to ensure that the code is actually using the right identifier
type: UNSTABLE_MSC3088_PURPOSE.unstable,
state_key: UNSTABLE_MSC3089_TREE_SUBTYPE.unstable,
content: {
[UNSTABLE_MSC3088_ENABLED.unstable]: true,
},
},
{
type: EventType.RoomEncryption,
state_key: "",
content: {
algorithm: MEGOLM_ALGORITHM,
},
},
],
});
return { room_id: roomId };
});
client.getUserId = () => userId;
client.createRoom = fn;
client.getRoom = (getRoomId) => {
expect(getRoomId).toEqual(roomId);
return mockRoom;
};
const tree = await client.unstableCreateFileTree(roomName);
expect(tree).toBeDefined();
expect(tree.roomId).toEqual(roomId);
expect(tree.room).toBe(mockRoom);
expect(fn.mock.calls.length).toBe(1);
});
it("should get (unstable) file trees with valid state", async () => {
const roomId = "!room:example.org";
const mockRoom = {
currentState: {
getStateEvents: (eventType, stateKey) => {
if (eventType === EventType.RoomCreate) {
expect(stateKey).toEqual("");
return new MatrixEvent({
content: {
[RoomCreateTypeField]: RoomType.Space,
},
});
} else if (eventType === UNSTABLE_MSC3088_PURPOSE.unstable) {
// We use `unstable` to ensure that the code is actually using the right identifier
expect(stateKey).toEqual(UNSTABLE_MSC3089_TREE_SUBTYPE.unstable);
return new MatrixEvent({
content: {
[UNSTABLE_MSC3088_ENABLED.unstable]: true,
},
});
} else {
throw new Error("Unexpected event type or state key");
}
},
},
};
client.getRoom = (getRoomId) => {
expect(getRoomId).toEqual(roomId);
return mockRoom;
};
const tree = client.unstableGetFileTreeSpace(roomId);
expect(tree).toBeDefined();
expect(tree.roomId).toEqual(roomId);
expect(tree.room).toBe(mockRoom);
});
it("should not get (unstable) file trees with invalid create contents", async () => {
const roomId = "!room:example.org";
const mockRoom = {
currentState: {
getStateEvents: (eventType, stateKey) => {
if (eventType === EventType.RoomCreate) {
expect(stateKey).toEqual("");
return new MatrixEvent({
content: {
[RoomCreateTypeField]: "org.example.not_space",
},
});
} else if (eventType === UNSTABLE_MSC3088_PURPOSE.unstable) {
// We use `unstable` to ensure that the code is actually using the right identifier
expect(stateKey).toEqual(UNSTABLE_MSC3089_TREE_SUBTYPE.unstable);
return new MatrixEvent({
content: {
[UNSTABLE_MSC3088_ENABLED.unstable]: true,
},
});
} else {
throw new Error("Unexpected event type or state key");
}
},
},
};
client.getRoom = (getRoomId) => {
expect(getRoomId).toEqual(roomId);
return mockRoom;
};
const tree = client.unstableGetFileTreeSpace(roomId);
expect(tree).toBeFalsy();
});
it("should not get (unstable) file trees with invalid purpose/subtype contents", async () => {
const roomId = "!room:example.org";
const mockRoom = {
currentState: {
getStateEvents: (eventType, stateKey) => {
if (eventType === EventType.RoomCreate) {
expect(stateKey).toEqual("");
return new MatrixEvent({
content: {
[RoomCreateTypeField]: RoomType.Space,
},
});
} else if (eventType === UNSTABLE_MSC3088_PURPOSE.unstable) {
expect(stateKey).toEqual(UNSTABLE_MSC3089_TREE_SUBTYPE.unstable);
return new MatrixEvent({
content: {
[UNSTABLE_MSC3088_ENABLED.unstable]: false,
},
});
} else {
throw new Error("Unexpected event type or state key");
}
},
},
};
client.getRoom = (getRoomId) => {
expect(getRoomId).toEqual(roomId);
return mockRoom;
};
const tree = client.unstableGetFileTreeSpace(roomId);
expect(tree).toBeFalsy();
});
it("should not POST /filter if a matching filter already exists", async function() {
httpLookups = [];
httpLookups.push(PUSH_RULES_RESPONSE);

View File

@@ -0,0 +1,155 @@
/*
Copyright 2021 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 { MatrixClient } from "../../../src";
import { Room } from "../../../src/models/room";
import { MatrixEvent } from "../../../src/models/event";
import { UNSTABLE_MSC3089_BRANCH } from "../../../src/@types/event";
import { EventTimelineSet } from "../../../src/models/event-timeline-set";
import { EventTimeline } from "../../../src/models/event-timeline";
import { MSC3089Branch } from "../../../src/models/MSC3089Branch";
describe("MSC3089Branch", () => {
let client: MatrixClient;
// @ts-ignore - TS doesn't know that this is a type
let indexEvent: MatrixEvent;
let branch: MSC3089Branch;
const branchRoomId = "!room:example.org";
const fileEventId = "$file";
const staticTimelineSets = {} as EventTimelineSet;
const staticRoom = {
getUnfilteredTimelineSet: () => staticTimelineSets,
} as any as Room; // partial
beforeEach(() => {
// TODO: Use utility functions to create test rooms and clients
client = <MatrixClient>{
getRoom: (roomId: string) => {
if (roomId === branchRoomId) {
return staticRoom;
} else {
throw new Error("Unexpected fetch for unknown room");
}
},
};
indexEvent = {
getRoomId: () => branchRoomId,
getStateKey: () => fileEventId,
};
branch = new MSC3089Branch(client, indexEvent);
});
it('should know the file event ID', () => {
expect(branch.id).toEqual(fileEventId);
});
it('should know if the file is active or not', () => {
indexEvent.getContent = () => ({});
expect(branch.isActive).toBe(false);
indexEvent.getContent = () => ({ active: false });
expect(branch.isActive).toBe(false);
indexEvent.getContent = () => ({ active: true });
expect(branch.isActive).toBe(true);
indexEvent.getContent = () => ({ active: "true" }); // invalid boolean, inactive
expect(branch.isActive).toBe(false);
});
it('should be able to delete the file', async () => {
const stateFn = jest.fn()
.mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => {
expect(roomId).toEqual(branchRoomId);
expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value
expect(content).toMatchObject({});
expect(content['active']).toBeUndefined();
expect(stateKey).toEqual(fileEventId);
return Promise.resolve(); // return value not used
});
client.sendStateEvent = stateFn;
const redactFn = jest.fn().mockImplementation((roomId: string, eventId: string) => {
expect(roomId).toEqual(branchRoomId);
expect(eventId).toEqual(fileEventId);
return Promise.resolve(); // return value not used
});
client.redactEvent = redactFn;
await branch.delete();
expect(stateFn).toHaveBeenCalledTimes(1);
expect(redactFn).toHaveBeenCalledTimes(1);
});
it('should know its name', async () => {
const name = "My File.txt";
indexEvent.getContent = () => ({ active: true, name: name });
const res = branch.getName();
expect(res).toEqual(name);
});
it('should be able to change its name', async () => {
const name = "My File.txt";
indexEvent.getContent = () => ({ active: true, retained: true });
const stateFn = jest.fn()
.mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => {
expect(roomId).toEqual(branchRoomId);
expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value
expect(content).toMatchObject({
retained: true, // canary for copying state
active: true,
name: name,
});
expect(stateKey).toEqual(fileEventId);
return Promise.resolve(); // return value not used
});
client.sendStateEvent = stateFn;
await branch.setName(name);
expect(stateFn).toHaveBeenCalledTimes(1);
});
it('should be able to return event information', async () => {
const mxcLatter = "example.org/file";
const fileContent = { isFile: "not quite", url: "mxc://" + mxcLatter };
const eventsArr = [
{ getId: () => "$not-file", getContent: () => ({}) },
{ getId: () => fileEventId, getContent: () => ({ file: fileContent }) },
];
client.getEventTimeline = () => Promise.resolve({
getEvents: () => eventsArr,
}) as any as Promise<EventTimeline>; // partial
client.mxcUrlToHttp = (mxc: string) => {
expect(mxc).toEqual("mxc://" + mxcLatter);
return `https://example.org/_matrix/media/v1/download/${mxcLatter}`;
};
client.decryptEventIfNeeded = () => Promise.resolve();
const res = await branch.getFileInfo();
expect(res).toBeDefined();
expect(res).toMatchObject({
info: fileContent,
// Escape regex from MDN guides: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
httpUrl: expect.stringMatching(`.+${mxcLatter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`),
});
});
});

View File

@@ -0,0 +1,963 @@
/*
Copyright 2021 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 { MatrixClient } from "../../../src";
import { Room } from "../../../src/models/room";
import { MatrixEvent } from "../../../src/models/event";
import { EventType, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../../../src/@types/event";
import {
DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
MSC3089TreeSpace,
TreePermissions,
} from "../../../src/models/MSC3089TreeSpace";
import { DEFAULT_ALPHABET } from "../../../src/utils";
import { MockBlob } from "../../MockBlob";
import { MatrixError } from "../../../src/http-api";
describe("MSC3089TreeSpace", () => {
let client: MatrixClient;
let room: Room;
let tree: MSC3089TreeSpace;
const roomId = "!tree:localhost";
const targetUser = "@target:example.org";
let powerLevels;
beforeEach(() => {
// TODO: Use utility functions to create test rooms and clients
client = <MatrixClient>{
getRoom: (fetchRoomId: string) => {
if (fetchRoomId === roomId) {
return room;
} else {
throw new Error("Unexpected fetch for unknown room");
}
},
};
room = <Room>{
currentState: {
getStateEvents: (evType: EventType, stateKey: string) => {
if (evType === EventType.RoomPowerLevels && stateKey === "") {
return powerLevels;
} else {
throw new Error("Accessed unexpected state event type or key");
}
},
},
};
tree = new MSC3089TreeSpace(client, roomId);
makePowerLevels(DEFAULT_TREE_POWER_LEVELS_TEMPLATE);
});
function makePowerLevels(content: any) {
powerLevels = new MatrixEvent({
type: EventType.RoomPowerLevels,
state_key: "",
sender: "@creator:localhost",
event_id: "$powerlevels",
room_id: roomId,
content: content,
});
}
it('should populate the room reference', () => {
expect(tree.room).toBe(room);
});
it('should proxy the ID member to room ID', () => {
expect(tree.id).toEqual(tree.roomId);
expect(tree.id).toEqual(roomId);
});
it('should support setting the name of the space', async () => {
const newName = "NEW NAME";
const fn = jest.fn()
.mockImplementation((stateRoomId: string, eventType: EventType, content: any, stateKey: string) => {
expect(stateRoomId).toEqual(roomId);
expect(eventType).toEqual(EventType.RoomName);
expect(stateKey).toEqual("");
expect(content).toMatchObject({ name: newName });
return Promise.resolve();
});
client.sendStateEvent = fn;
await tree.setName(newName);
expect(fn).toHaveBeenCalledTimes(1);
});
it('should support inviting users to the space', async () => {
const target = targetUser;
const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => {
expect(inviteRoomId).toEqual(roomId);
expect(userId).toEqual(target);
return Promise.resolve();
});
client.invite = fn;
await tree.invite(target, false, false);
expect(fn).toHaveBeenCalledTimes(1);
});
it('should retry invites to the space', async () => {
const target = targetUser;
const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => {
expect(inviteRoomId).toEqual(roomId);
expect(userId).toEqual(target);
if (fn.mock.calls.length === 1) return Promise.reject(new Error("Sample Failure"));
return Promise.resolve();
});
client.invite = fn;
await tree.invite(target, false, false);
expect(fn).toHaveBeenCalledTimes(2);
});
it('should not retry invite permission errors', async () => {
const target = targetUser;
const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => {
expect(inviteRoomId).toEqual(roomId);
expect(userId).toEqual(target);
return Promise.reject(new MatrixError({ errcode: "M_FORBIDDEN", error: "Sample Failure" }));
});
client.invite = fn;
try {
await tree.invite(target, false, false);
// noinspection ExceptionCaughtLocallyJS
throw new Error("Failed to fail");
} catch (e) {
expect(e.errcode).toEqual("M_FORBIDDEN");
}
expect(fn).toHaveBeenCalledTimes(1);
});
it('should invite to subspaces', async () => {
const target = targetUser;
const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => {
expect(inviteRoomId).toEqual(roomId);
expect(userId).toEqual(target);
return Promise.resolve();
});
client.invite = fn;
tree.getDirectories = () => [
// Bare minimum overrides. We proxy to our mock function manually so we can
// count the calls, not to ensure accuracy. The invite function behaving correctly
// is covered by another test.
{ invite: (userId) => fn(tree.roomId, userId) } as MSC3089TreeSpace,
{ invite: (userId) => fn(tree.roomId, userId) } as MSC3089TreeSpace,
{ invite: (userId) => fn(tree.roomId, userId) } as MSC3089TreeSpace,
];
await tree.invite(target, true, false);
expect(fn).toHaveBeenCalledTimes(4);
});
it('should share keys with invitees', async () => {
const target = targetUser;
const sendKeysFn = jest.fn().mockImplementation((inviteRoomId: string, userIds: string[]) => {
expect(inviteRoomId).toEqual(roomId);
expect(userIds).toMatchObject([target]);
return Promise.resolve();
});
client.invite = () => Promise.resolve(); // we're not testing this here - see other tests
client.sendSharedHistoryKeys = sendKeysFn;
// Mock the history check as best as possible
const historyVis = "shared";
const historyFn = jest.fn().mockImplementation((eventType: string, stateKey?: string) => {
// We're not expecting a super rigid test: the function that calls this internally isn't
// really being tested here.
expect(eventType).toEqual(EventType.RoomHistoryVisibility);
expect(stateKey).toEqual("");
return { getContent: () => ({ history_visibility: historyVis }) }; // eslint-disable-line camelcase
});
room.currentState.getStateEvents = historyFn;
// Note: inverse test is implicit from other tests, which disable the call stack of this
// test in order to pass.
await tree.invite(target, false, true);
expect(sendKeysFn).toHaveBeenCalledTimes(1);
expect(historyFn).toHaveBeenCalledTimes(1);
});
it('should not share keys with invitees if inappropriate history visibility', async () => {
const target = targetUser;
const sendKeysFn = jest.fn().mockImplementation((inviteRoomId: string, userIds: string[]) => {
expect(inviteRoomId).toEqual(roomId);
expect(userIds).toMatchObject([target]);
return Promise.resolve();
});
client.invite = () => Promise.resolve(); // we're not testing this here - see other tests
client.sendSharedHistoryKeys = sendKeysFn;
const historyVis = "joined"; // NOTE: Changed.
const historyFn = jest.fn().mockImplementation((eventType: string, stateKey?: string) => {
expect(eventType).toEqual(EventType.RoomHistoryVisibility);
expect(stateKey).toEqual("");
return { getContent: () => ({ history_visibility: historyVis }) }; // eslint-disable-line camelcase
});
room.currentState.getStateEvents = historyFn;
await tree.invite(target, false, true);
expect(sendKeysFn).toHaveBeenCalledTimes(0);
expect(historyFn).toHaveBeenCalledTimes(1);
});
async function evaluatePowerLevels(pls: any, role: TreePermissions, expectedPl: number) {
makePowerLevels(pls);
const fn = jest.fn()
.mockImplementation((stateRoomId: string, eventType: EventType, content: any, stateKey: string) => {
expect(stateRoomId).toEqual(roomId);
expect(eventType).toEqual(EventType.RoomPowerLevels);
expect(stateKey).toEqual("");
expect(content).toMatchObject({
...pls,
users: {
[targetUser]: expectedPl,
},
});
return Promise.resolve();
});
client.sendStateEvent = fn;
await tree.setPermissions(targetUser, role);
expect(fn.mock.calls.length).toBe(1);
}
it('should support setting Viewer permissions', () => {
return evaluatePowerLevels({
...DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
users_default: 1024,
}, TreePermissions.Viewer, 1024);
});
it('should support setting Editor permissions', () => {
return evaluatePowerLevels({
...DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
events_default: 1024,
}, TreePermissions.Editor, 1024);
});
it('should support setting Owner permissions', () => {
return evaluatePowerLevels({
...DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
events: {
[EventType.RoomPowerLevels]: 1024,
},
}, TreePermissions.Owner, 1024);
});
it('should support demoting permissions', () => {
return evaluatePowerLevels({
...DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
users_default: 1024,
users: {
[targetUser]: 2222,
},
}, TreePermissions.Viewer, 1024);
});
it('should support promoting permissions', () => {
return evaluatePowerLevels({
...DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
events_default: 1024,
users: {
[targetUser]: 5,
},
}, TreePermissions.Editor, 1024);
});
it('should support defaults: Viewer', () => {
return evaluatePowerLevels({}, TreePermissions.Viewer, 0);
});
it('should support defaults: Editor', () => {
return evaluatePowerLevels({}, TreePermissions.Editor, 50);
});
it('should support defaults: Owner', () => {
return evaluatePowerLevels({}, TreePermissions.Owner, 100);
});
it('should create subdirectories', async () => {
const subspaceName = "subdirectory";
const subspaceId = "!subspace:localhost";
const domain = "domain.example.com";
client.getRoom = (roomId: string) => {
if (roomId === tree.roomId) {
return tree.room;
} else if (roomId === subspaceId) {
return {} as Room; // we don't need anything important off of this
} else {
throw new Error("Unexpected getRoom call");
}
};
client.getDomain = () => domain;
const createFn = jest.fn().mockImplementation(async (name: string) => {
expect(name).toEqual(subspaceName);
return new MSC3089TreeSpace(client, subspaceId);
});
const sendStateFn = jest.fn()
.mockImplementation(async (roomId: string, eventType: EventType, content: any, stateKey: string) => {
expect([tree.roomId, subspaceId]).toContain(roomId);
if (roomId === subspaceId) {
expect(eventType).toEqual(EventType.SpaceParent);
expect(stateKey).toEqual(tree.roomId);
} else {
expect(eventType).toEqual(EventType.SpaceChild);
expect(stateKey).toEqual(subspaceId);
}
expect(content).toMatchObject({ via: [domain] });
// return value not used
});
client.unstableCreateFileTree = createFn;
client.sendStateEvent = sendStateFn;
const directory = await tree.createDirectory(subspaceName);
expect(directory).toBeDefined();
expect(directory).not.toBeNull();
expect(directory).not.toBe(tree);
expect(directory.roomId).toEqual(subspaceId);
expect(createFn).toHaveBeenCalledTimes(1);
expect(sendStateFn).toHaveBeenCalledTimes(2);
const content = expect.objectContaining({ via: [domain] });
expect(sendStateFn).toHaveBeenCalledWith(subspaceId, EventType.SpaceParent, content, tree.roomId);
expect(sendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, content, subspaceId);
});
it('should find subdirectories', () => {
const firstChildRoom = "!one:example.org";
const secondChildRoom = "!two:example.org";
const thirdChildRoom = "!three:example.org"; // to ensure it doesn't end up in the subdirectories
room.currentState = {
getStateEvents: (eventType: EventType, stateKey?: string) => {
expect(eventType).toEqual(EventType.SpaceChild);
expect(stateKey).toBeUndefined();
return [
// Partial implementations of Room
{ getStateKey: () => firstChildRoom },
{ getStateKey: () => secondChildRoom },
{ getStateKey: () => thirdChildRoom },
];
},
};
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
const getFn = jest.fn().mockImplementation((roomId: string) => {
if (roomId === thirdChildRoom) {
throw new Error("Mock not-a-space room case called (expected)");
}
expect([firstChildRoom, secondChildRoom]).toContain(roomId);
return new MSC3089TreeSpace(client, roomId);
});
client.unstableGetFileTreeSpace = getFn;
const subdirectories = tree.getDirectories();
expect(subdirectories).toBeDefined();
expect(subdirectories.length).toBe(2);
expect(subdirectories[0].roomId).toBe(firstChildRoom);
expect(subdirectories[1].roomId).toBe(secondChildRoom);
expect(getFn).toHaveBeenCalledTimes(3);
expect(getFn).toHaveBeenCalledWith(firstChildRoom);
expect(getFn).toHaveBeenCalledWith(secondChildRoom);
expect(getFn).toHaveBeenCalledWith(thirdChildRoom); // check to make sure it tried
});
it('should find specific directories', () => {
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
// Only mocking used API
const firstSubdirectory = { roomId: "!first:example.org" } as any as MSC3089TreeSpace;
const searchedSubdirectory = { roomId: "!find_me:example.org" } as any as MSC3089TreeSpace;
const thirdSubdirectory = { roomId: "!third:example.org" } as any as MSC3089TreeSpace;
tree.getDirectories = () => [firstSubdirectory, searchedSubdirectory, thirdSubdirectory];
let result = tree.getDirectory(searchedSubdirectory.roomId);
expect(result).toBe(searchedSubdirectory);
result = tree.getDirectory("not a subdirectory");
expect(result).toBeFalsy();
});
it('should be able to delete itself', async () => {
const delete1 = jest.fn().mockImplementation(() => Promise.resolve());
const subdir1 = { delete: delete1 } as any as MSC3089TreeSpace; // mock tested bits
const delete2 = jest.fn().mockImplementation(() => Promise.resolve());
const subdir2 = { delete: delete2 } as any as MSC3089TreeSpace; // mock tested bits
const joinMemberId = "@join:example.org";
const knockMemberId = "@knock:example.org";
const inviteMemberId = "@invite:example.org";
const leaveMemberId = "@leave:example.org";
const banMemberId = "@ban:example.org";
const selfUserId = "@self:example.org";
tree.getDirectories = () => [subdir1, subdir2];
room.currentState = {
getStateEvents: (eventType: EventType, stateKey?: string) => {
expect(eventType).toEqual(EventType.RoomMember);
expect(stateKey).toBeUndefined();
return [
// Partial implementations
{ getContent: () => ({ membership: "join" }), getStateKey: () => joinMemberId },
{ getContent: () => ({ membership: "knock" }), getStateKey: () => knockMemberId },
{ getContent: () => ({ membership: "invite" }), getStateKey: () => inviteMemberId },
{ getContent: () => ({ membership: "leave" }), getStateKey: () => leaveMemberId },
{ getContent: () => ({ membership: "ban" }), getStateKey: () => banMemberId },
// ensure we don't kick ourselves
{ getContent: () => ({ membership: "join" }), getStateKey: () => selfUserId },
];
},
};
// These two functions are tested by input expectations, so no expectations in the function bodies
const kickFn = jest.fn().mockImplementation((userId) => Promise.resolve());
const leaveFn = jest.fn().mockImplementation(() => Promise.resolve());
client.kick = kickFn;
client.leave = leaveFn;
client.getUserId = () => selfUserId;
await tree.delete();
expect(delete1).toHaveBeenCalledTimes(1);
expect(delete2).toHaveBeenCalledTimes(1);
expect(kickFn).toHaveBeenCalledTimes(3);
expect(kickFn).toHaveBeenCalledWith(tree.roomId, joinMemberId, expect.any(String));
expect(kickFn).toHaveBeenCalledWith(tree.roomId, knockMemberId, expect.any(String));
expect(kickFn).toHaveBeenCalledWith(tree.roomId, inviteMemberId, expect.any(String));
expect(leaveFn).toHaveBeenCalledTimes(1);
});
describe('get and set order', () => {
// Danger: these are partial implementations for testing purposes only
// @ts-ignore - "MatrixEvent is a value but used as a type", which is true but not important
let childState: { [roomId: string]: MatrixEvent[] } = {};
// @ts-ignore - "MatrixEvent is a value but used as a type", which is true but not important
let parentState: MatrixEvent[] = [];
let parentRoom: Room;
let childTrees: MSC3089TreeSpace[];
let rooms: { [roomId: string]: Room };
let clientSendStateFn: jest.MockedFunction<typeof client.sendStateEvent>;
const staticDomain = "static.example.org";
function addSubspace(roomId: string, createTs?: number, order?: string) {
const content = {
via: [staticDomain],
};
if (order) content['order'] = order;
parentState.push({
getType: () => EventType.SpaceChild,
getStateKey: () => roomId,
getContent: () => content,
});
childState[roomId] = [
{
getType: () => EventType.SpaceParent,
getStateKey: () => tree.roomId,
getContent: () => ({
via: [staticDomain],
}),
},
];
if (createTs) {
childState[roomId].push({
getType: () => EventType.RoomCreate,
getStateKey: () => "",
getContent: () => ({}),
getTs: () => createTs,
});
}
rooms[roomId] = makeMockChildRoom(roomId);
childTrees.push(new MSC3089TreeSpace(client, roomId));
}
function expectOrder(childRoomId: string, order: number) {
const child = childTrees.find(c => c.roomId === childRoomId);
expect(child).toBeDefined();
expect(child.getOrder()).toEqual(order);
}
function makeMockChildRoom(roomId: string): Room {
return {
currentState: {
getStateEvents: (eventType: EventType, stateKey?: string) => {
expect([EventType.SpaceParent, EventType.RoomCreate]).toContain(eventType);
if (eventType === EventType.RoomCreate) {
expect(stateKey).toEqual("");
return childState[roomId].find(e => e.getType() === EventType.RoomCreate);
} else {
expect(stateKey).toBeUndefined();
return childState[roomId].filter(e => e.getType() === eventType);
}
},
},
} as Room; // partial
}
beforeEach(() => {
childState = {};
parentState = [];
parentRoom = {
...tree.room,
roomId: tree.roomId,
currentState: {
getStateEvents: (eventType: EventType, stateKey?: string) => {
expect([
EventType.SpaceChild,
EventType.RoomCreate,
EventType.SpaceParent,
]).toContain(eventType);
if (eventType === EventType.RoomCreate) {
expect(stateKey).toEqual("");
return parentState.filter(e => e.getType() === EventType.RoomCreate)[0];
} else {
if (stateKey !== undefined) {
expect(Object.keys(rooms)).toContain(stateKey);
expect(stateKey).not.toEqual(tree.roomId);
return parentState.find(e => e.getType() === eventType && e.getStateKey() === stateKey);
} // else fine
return parentState.filter(e => e.getType() === eventType);
}
},
},
} as Room;
childTrees = [];
rooms = {};
rooms[tree.roomId] = parentRoom;
(<any>tree).room = parentRoom; // override readonly
client.getRoom = (r) => rooms[r];
clientSendStateFn = jest.fn()
.mockImplementation((roomId: string, eventType: EventType, content: any, stateKey: string) => {
expect(roomId).toEqual(tree.roomId);
expect(eventType).toEqual(EventType.SpaceChild);
expect(content).toMatchObject(expect.objectContaining({
via: expect.any(Array),
order: expect.any(String),
}));
expect(Object.keys(rooms)).toContain(stateKey);
expect(stateKey).not.toEqual(tree.roomId);
const stateEvent = parentState.find(e => e.getType() === eventType && e.getStateKey() === stateKey);
expect(stateEvent).toBeDefined();
stateEvent.getContent = () => content;
return Promise.resolve(); // return value not used
});
client.sendStateEvent = clientSendStateFn;
});
it('should know when something is top level', () => {
const a = "!a:example.org";
addSubspace(a);
expect(tree.isTopLevel).toBe(true);
expect(childTrees[0].isTopLevel).toBe(false); // a bit of a hack to get at this, but it's fine
});
it('should return -1 for top level spaces', () => {
// The tree is what we've defined as top level, so it should work
expect(tree.getOrder()).toEqual(-1);
});
it('should throw when setting an order at the top level space', async () => {
try {
// The tree is what we've defined as top level, so it should work
await tree.setOrder(2);
// noinspection ExceptionCaughtLocallyJS
throw new Error("Failed to fail");
} catch (e) {
expect(e.message).toEqual("Cannot set order of top level spaces currently");
}
});
it('should return a stable order for unordered children', () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(c, 3);
addSubspace(b, 2);
addSubspace(a, 1);
expectOrder(a, 0);
expectOrder(b, 1);
expectOrder(c, 2);
});
it('should return a stable order for ordered children', () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(a, 1, "Z");
addSubspace(b, 2, "Y");
addSubspace(c, 3, "X");
expectOrder(c, 0);
expectOrder(b, 1);
expectOrder(a, 2);
});
it('should return a stable order for partially ordered children', () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
const d = "!d:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(a, 1);
addSubspace(b, 2);
addSubspace(c, 3, "Y");
addSubspace(d, 4, "X");
expectOrder(d, 0);
expectOrder(c, 1);
expectOrder(b, 3); // note order diff due to room ID comparison expectation
expectOrder(a, 2);
});
it('should return a stable order if the create event timestamps are the same', () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(c, 3);
addSubspace(b, 3); // same as C
addSubspace(a, 3); // same as C
expectOrder(a, 0);
expectOrder(b, 1);
expectOrder(c, 2);
});
it('should return a stable order if there are no known create events', () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(c);
addSubspace(b);
addSubspace(a);
expectOrder(a, 0);
expectOrder(b, 1);
expectOrder(c, 2);
});
// XXX: These tests rely on `getOrder()` re-calculating and not caching values.
it('should allow reordering within unordered children', async () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(c, 3);
addSubspace(b, 2);
addSubspace(a, 1);
// Order of this state is validated by other tests.
const treeA = childTrees.find(c => c.roomId === a);
expect(treeA).toBeDefined();
await treeA.setOrder(1);
expect(clientSendStateFn).toHaveBeenCalledTimes(3);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
// Because of how the reordering works (maintain stable ordering before moving), we end up calling this
// function twice for the same room.
order: DEFAULT_ALPHABET[0],
}), a);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: DEFAULT_ALPHABET[1],
}), b);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: DEFAULT_ALPHABET[2],
}), a);
expectOrder(a, 1);
expectOrder(b, 0);
expectOrder(c, 2);
});
it('should allow reordering within ordered children', async () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(c, 3, "Z");
addSubspace(b, 2, "X");
addSubspace(a, 1, "V");
// Order of this state is validated by other tests.
const treeA = childTrees.find(c => c.roomId === a);
expect(treeA).toBeDefined();
await treeA.setOrder(1);
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: 'Y',
}), a);
expectOrder(a, 1);
expectOrder(b, 0);
expectOrder(c, 2);
});
it('should allow reordering within partially ordered children', async () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
const d = "!d:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(a, 1);
addSubspace(b, 2);
addSubspace(c, 3, "Y");
addSubspace(d, 4, "W");
// Order of this state is validated by other tests.
const treeA = childTrees.find(c => c.roomId === a);
expect(treeA).toBeDefined();
await treeA.setOrder(2);
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: 'Z',
}), a);
expectOrder(a, 2);
expectOrder(b, 3);
expectOrder(c, 1);
expectOrder(d, 0);
});
it('should support moving upwards', async () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
const d = "!d:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(d, 4, "Z");
addSubspace(c, 3, "X");
addSubspace(b, 2, "V");
addSubspace(a, 1, "T");
// Order of this state is validated by other tests.
const treeB = childTrees.find(c => c.roomId === b);
expect(treeB).toBeDefined();
await treeB.setOrder(2);
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: 'Y',
}), b);
expectOrder(a, 0);
expectOrder(b, 2);
expectOrder(c, 1);
expectOrder(d, 3);
});
it('should support moving downwards', async () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
const d = "!d:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(d, 4, "Z");
addSubspace(c, 3, "X");
addSubspace(b, 2, "V");
addSubspace(a, 1, "T");
// Order of this state is validated by other tests.
const treeC = childTrees.find(ch => ch.roomId === c);
expect(treeC).toBeDefined();
await treeC.setOrder(1);
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: 'U',
}), c);
expectOrder(a, 0);
expectOrder(b, 2);
expectOrder(c, 1);
expectOrder(d, 3);
});
it('should support moving over the partial ordering boundary', async () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
const d = "!d:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(d, 4);
addSubspace(c, 3);
addSubspace(b, 2, "V");
addSubspace(a, 1, "T");
// Order of this state is validated by other tests.
const treeB = childTrees.find(ch => ch.roomId === b);
expect(treeB).toBeDefined();
await treeB.setOrder(2);
expect(clientSendStateFn).toHaveBeenCalledTimes(2);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: 'W',
}), c);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: 'X',
}), b);
expectOrder(a, 0);
expectOrder(b, 2);
expectOrder(c, 1);
expectOrder(d, 3);
});
});
it('should upload files', async () => {
const mxc = "mxc://example.org/file";
const fileInfo = {
mimetype: "text/plain",
// other fields as required by encryption, but ignored here
};
const fileEventId = "$file";
const fileName = "My File.txt";
const fileContents = "This is a test file";
// Mock out Blob for the test environment
(<any>global).Blob = MockBlob;
const uploadFn = jest.fn().mockImplementation((contents: Blob, opts: any) => {
expect(contents).toBeInstanceOf(Blob);
expect(contents.size).toEqual(fileContents.length);
expect(opts).toMatchObject({
includeFilename: false,
onlyContentUri: true, // because the tests rely on this - we shouldn't really be testing for this.
});
return Promise.resolve(mxc);
});
client.uploadContent = uploadFn;
const sendMsgFn = jest.fn().mockImplementation((roomId: string, contents: any) => {
expect(roomId).toEqual(tree.roomId);
expect(contents).toMatchObject({
msgtype: MsgType.File,
body: fileName,
url: mxc,
file: fileInfo,
[UNSTABLE_MSC3089_LEAF.unstable]: {}, // test to ensure we're definitely using unstable
});
return Promise.resolve({ event_id: fileEventId }); // eslint-disable-line camelcase
});
client.sendMessage = sendMsgFn;
const sendStateFn = jest.fn()
.mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => {
expect(roomId).toEqual(tree.roomId);
expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable
expect(stateKey).toEqual(fileEventId);
expect(content).toMatchObject({
active: true,
name: fileName,
});
return Promise.resolve(); // return value not used.
});
client.sendStateEvent = sendStateFn;
const buf = Uint8Array.from(Array.from(fileContents).map((_, i) => fileContents.charCodeAt(i)));
// We clone the file info just to make sure it doesn't get mutated for the test.
await tree.createFile(fileName, buf, Object.assign({}, fileInfo));
expect(uploadFn).toHaveBeenCalledTimes(1);
expect(sendMsgFn).toHaveBeenCalledTimes(1);
expect(sendStateFn).toHaveBeenCalledTimes(1);
});
it('should support getting files', () => {
const fileEventId = "$file";
const fileEvent = { forTest: true }; // MatrixEvent mock
room.currentState = {
getStateEvents: (eventType: string, stateKey?: string) => {
expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable
expect(stateKey).toEqual(fileEventId);
return fileEvent;
},
};
const file = tree.getFile(fileEventId);
expect(file).toBeDefined();
expect(file.indexEvent).toBe(fileEvent);
});
it('should return falsy for unknown files', () => {
const fileEventId = "$file";
room.currentState = {
getStateEvents: (eventType: string, stateKey?: string) => {
expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable
expect(stateKey).toEqual(fileEventId);
return null;
},
};
const file = tree.getFile(fileEventId);
expect(file).toBeFalsy();
});
it('should list files', () => {
const firstFile = { getContent: () => ({ active: true }) };
const secondFile = { getContent: () => ({ active: false }) }; // deliberately inactive
room.currentState = {
getStateEvents: (eventType: string, stateKey?: string) => {
expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable
expect(stateKey).toBeUndefined();
return [firstFile, secondFile];
},
};
const files = tree.listFiles();
expect(files).toBeDefined();
expect(files.length).toEqual(1);
expect(files[0].indexEvent).toBe(firstFile);
});
});

View File

@@ -0,0 +1,60 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent } from "../../../src/models/event";
describe('MatrixEvent', () => {
it('should create copies of itself', () => {
const a = new MatrixEvent({
type: "com.example.test",
content: {
isTest: true,
num: 42,
},
});
const clone = a.toSnapshot();
expect(clone).toBeDefined();
expect(clone).not.toBe(a);
expect(clone.event).not.toBe(a.event);
expect(clone.event).toMatchObject(a.event);
// The other properties we're not super interested in, honestly.
});
it('should compare itself to other events using json', () => {
const a = new MatrixEvent({
type: "com.example.test",
content: {
isTest: true,
num: 42,
},
});
const b = new MatrixEvent({
type: "com.example.test______B",
content: {
isTest: true,
num: 42,
},
});
expect(a.isEquivalentTo(b)).toBe(false);
expect(a.isEquivalentTo(a)).toBe(true);
expect(b.isEquivalentTo(a)).toBe(false);
expect(b.isEquivalentTo(b)).toBe(true);
expect(a.toSnapshot().isEquivalentTo(a)).toBe(true);
expect(a.toSnapshot().isEquivalentTo(b)).toBe(false);
});
});

View File

@@ -124,6 +124,34 @@ describe("RoomMember", function() {
expect(member.powerLevel).toEqual(0);
expect(emitCount).toEqual(1);
});
it("should not honor string power levels.",
function() {
const event = utils.mkEvent({
type: "m.room.power_levels",
room: roomId,
user: userA,
content: {
users_default: 20,
users: {
"@alice:bar": "5",
},
},
event: true,
});
let emitCount = 0;
member.on("RoomMember.powerLevel", function(emitEvent, emitMember) {
emitCount += 1;
expect(emitMember.userId).toEqual('@alice:bar');
expect(emitMember.powerLevel).toEqual(20);
expect(emitEvent).toEqual(event);
});
member.setPowerLevelEvent(event);
expect(member.powerLevel).toEqual(20);
expect(emitCount).toEqual(1);
});
});
describe("setTypingEvent", function() {

View File

@@ -1,6 +1,5 @@
import * as utils from "../test-utils";
import { RoomState } from "../../src/models/room-state";
import { RoomMember } from "../../src/models/room-member";
describe("RoomState", function() {
const roomId = "!foo:bar";
@@ -193,12 +192,7 @@ describe("RoomState", function() {
expect(emitCount).toEqual(2);
});
it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels",
function() {
// mock up the room members
state.members[userA] = utils.mock(RoomMember);
state.members[userB] = utils.mock(RoomMember);
it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", function() {
const powerLevelEvent = utils.mkEvent({
type: "m.room.power_levels", room: roomId, user: userA, event: true,
content: {
@@ -208,18 +202,16 @@ describe("RoomState", function() {
},
});
// spy on the room members
jest.spyOn(state.members[userA], "setPowerLevelEvent");
jest.spyOn(state.members[userB], "setPowerLevelEvent");
state.setStateEvents([powerLevelEvent]);
expect(state.members[userA].setPowerLevelEvent).toHaveBeenCalledWith(
powerLevelEvent,
);
expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith(
powerLevelEvent,
);
expect(state.members[userA].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent);
expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent);
});
it("should call setPowerLevelEvent on a new RoomMember if power levels exist",
function() {
it("should call setPowerLevelEvent on a new RoomMember if power levels exist", function() {
const memberEvent = utils.mkMembership({
mship: "join", user: userC, room: roomId, event: true,
});
@@ -243,13 +235,12 @@ describe("RoomState", function() {
});
it("should call setMembershipEvent on the right RoomMember", function() {
// mock up the room members
state.members[userA] = utils.mock(RoomMember);
state.members[userB] = utils.mock(RoomMember);
const memberEvent = utils.mkMembership({
user: userB, mship: "leave", room: roomId, event: true,
});
// spy on the room members
jest.spyOn(state.members[userA], "setMembershipEvent");
jest.spyOn(state.members[userB], "setMembershipEvent");
state.setStateEvents([memberEvent]);
expect(state.members[userA].setMembershipEvent).not.toHaveBeenCalled();
@@ -374,17 +365,13 @@ describe("RoomState", function() {
user_ids: [userA],
},
});
// mock up the room members
state.members[userA] = utils.mock(RoomMember);
state.members[userB] = utils.mock(RoomMember);
// spy on the room members
jest.spyOn(state.members[userA], "setTypingEvent");
jest.spyOn(state.members[userB], "setTypingEvent");
state.setTypingEvent(typingEvent);
expect(state.members[userA].setTypingEvent).toHaveBeenCalledWith(
typingEvent,
);
expect(state.members[userB].setTypingEvent).toHaveBeenCalledWith(
typingEvent,
);
expect(state.members[userA].setTypingEvent).toHaveBeenCalledWith(typingEvent);
expect(state.members[userB].setTypingEvent).toHaveBeenCalledWith(typingEvent);
});
});

View File

@@ -1,8 +1,8 @@
import * as utils from "../test-utils";
import { EventStatus, MatrixEvent } from "../../src/models/event";
import { EventStatus, MatrixEvent } from "../../src";
import { EventTimeline } from "../../src/models/event-timeline";
import { RoomState } from "../../src/models/room-state";
import { Room } from "../../src/models/room";
import { RoomState } from "../../src";
import { Room } from "../../src";
import { TestClient } from "../TestClient";
describe("Room", function() {
@@ -16,9 +16,9 @@ describe("Room", function() {
beforeEach(function() {
room = new Room(roomId);
// mock RoomStates
room.oldState = room.getLiveTimeline()._startState =
room.oldState = room.getLiveTimeline().startState =
utils.mock(RoomState, "oldState");
room.currentState = room.getLiveTimeline()._endState =
room.currentState = room.getLiveTimeline().endState =
utils.mock(RoomState, "currentState");
});
@@ -86,9 +86,11 @@ describe("Room", function() {
];
it("should call RoomState.setTypingEvent on m.typing events", function() {
room.currentState = utils.mock(RoomState);
const typing = utils.mkEvent({
room: roomId, type: "m.typing", event: true, content: {
room: roomId,
type: "m.typing",
event: true,
content: {
user_ids: [userA],
},
});
@@ -140,8 +142,8 @@ describe("Room", function() {
expect(callCount).toEqual(2);
});
it("should call setStateEvents on the right RoomState with the right " +
"forwardLooking value for new events", function() {
it("should call setStateEvents on the right RoomState with the right forwardLooking value for new events",
function() {
const events = [
utils.mkMembership({
room: roomId, mship: "invite", user: userB, skey: userA, event: true,
@@ -652,7 +654,8 @@ describe("Room", function() {
const roomName = "flibble";
const event = addMember(userA, "invite");
event.event.invite_room_state = [
event.event.unsigned = {};
event.event.unsigned.invite_room_state = [
{
type: "m.room.name",
state_key: "",
@@ -671,7 +674,8 @@ describe("Room", function() {
const roomName = "flibble";
setRoomName(roomName);
const roomNameToIgnore = "ignoreme";
event.event.invite_room_state = [
event.event.unsigned = {};
event.event.unsigned.invite_room_state = [
{
type: "m.room.name",
state_key: "",

View File

@@ -1,262 +0,0 @@
import * as utils from "../../src/utils";
describe("utils", function() {
describe("encodeParams", function() {
it("should url encode and concat with &s", function() {
const params = {
foo: "bar",
baz: "beer@",
};
expect(utils.encodeParams(params)).toEqual(
"foo=bar&baz=beer%40",
);
});
});
describe("encodeUri", function() {
it("should replace based on object keys and url encode", function() {
const path = "foo/bar/%something/%here";
const vals = {
"%something": "baz",
"%here": "beer@",
};
expect(utils.encodeUri(path, vals)).toEqual(
"foo/bar/baz/beer%40",
);
});
});
describe("removeElement", function() {
it("should remove only 1 element if there is a match", function() {
const matchFn = function() {
return true;
};
const arr = [55, 66, 77];
utils.removeElement(arr, matchFn);
expect(arr).toEqual([66, 77]);
});
it("should be able to remove in reverse order", function() {
const matchFn = function() {
return true;
};
const arr = [55, 66, 77];
utils.removeElement(arr, matchFn, true);
expect(arr).toEqual([55, 66]);
});
it("should remove nothing if the function never returns true", function() {
const matchFn = function() {
return false;
};
const arr = [55, 66, 77];
utils.removeElement(arr, matchFn);
expect(arr).toEqual(arr);
});
});
describe("isFunction", function() {
it("should return true for functions", function() {
expect(utils.isFunction([])).toBe(false);
expect(utils.isFunction([5, 3, 7])).toBe(false);
expect(utils.isFunction()).toBe(false);
expect(utils.isFunction(null)).toBe(false);
expect(utils.isFunction({})).toBe(false);
expect(utils.isFunction("foo")).toBe(false);
expect(utils.isFunction(555)).toBe(false);
expect(utils.isFunction(function() {})).toBe(true);
const s = { foo: function() {} };
expect(utils.isFunction(s.foo)).toBe(true);
});
});
describe("checkObjectHasKeys", function() {
it("should throw for missing keys", function() {
expect(function() {
utils.checkObjectHasKeys({}, ["foo"]);
}).toThrow();
expect(function() {
utils.checkObjectHasKeys({
foo: "bar",
}, ["foo"]);
}).not.toThrow();
});
});
describe("checkObjectHasNoAdditionalKeys", function() {
it("should throw for extra keys", function() {
expect(function() {
utils.checkObjectHasNoAdditionalKeys({
foo: "bar",
baz: 4,
}, ["foo"]);
}).toThrow();
expect(function() {
utils.checkObjectHasNoAdditionalKeys({
foo: "bar",
}, ["foo"]);
}).not.toThrow();
});
});
describe("deepCompare", function() {
const assert = {
isTrue: function(x) {
expect(x).toBe(true);
},
isFalse: function(x) {
expect(x).toBe(false);
},
};
it("should handle primitives", function() {
assert.isTrue(utils.deepCompare(null, null));
assert.isFalse(utils.deepCompare(null, undefined));
assert.isTrue(utils.deepCompare("hi", "hi"));
assert.isTrue(utils.deepCompare(5, 5));
assert.isFalse(utils.deepCompare(5, 10));
});
it("should handle regexps", function() {
assert.isTrue(utils.deepCompare(/abc/, /abc/));
assert.isFalse(utils.deepCompare(/abc/, /123/));
const r = /abc/;
assert.isTrue(utils.deepCompare(r, r));
});
it("should handle dates", function() {
assert.isTrue(utils.deepCompare(new Date("2011-03-31"),
new Date("2011-03-31")));
assert.isFalse(utils.deepCompare(new Date("2011-03-31"),
new Date("1970-01-01")));
});
it("should handle arrays", function() {
assert.isTrue(utils.deepCompare([], []));
assert.isTrue(utils.deepCompare([1, 2], [1, 2]));
assert.isFalse(utils.deepCompare([1, 2], [2, 1]));
assert.isFalse(utils.deepCompare([1, 2], [1, 2, 3]));
});
it("should handle simple objects", function() {
assert.isTrue(utils.deepCompare({}, {}));
assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 2 }));
assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { b: 2, a: 1 }));
assert.isFalse(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 3 }));
assert.isTrue(utils.deepCompare({ 1: { name: "mhc", age: 28 },
2: { name: "arb", age: 26 } },
{ 1: { name: "mhc", age: 28 },
2: { name: "arb", age: 26 } }));
assert.isFalse(utils.deepCompare({ 1: { name: "mhc", age: 28 },
2: { name: "arb", age: 26 } },
{ 1: { name: "mhc", age: 28 },
2: { name: "arb", age: 27 } }));
assert.isFalse(utils.deepCompare({}, null));
assert.isFalse(utils.deepCompare({}, undefined));
});
it("should handle functions", function() {
// no two different function is equal really, they capture their
// context variables so even if they have same toString(), they
// won't have same functionality
const func = function(x) {
return true;
};
const func2 = function(x) {
return true;
};
assert.isTrue(utils.deepCompare(func, func));
assert.isFalse(utils.deepCompare(func, func2));
assert.isTrue(utils.deepCompare({ a: { b: func } }, { a: { b: func } }));
assert.isFalse(utils.deepCompare({ a: { b: func } }, { a: { b: func2 } }));
});
});
describe("extend", function() {
const SOURCE = { "prop2": 1, "string2": "x", "newprop": "new" };
it("should extend", function() {
const target = {
"prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo",
};
const merged = {
"prop1": 5, "prop2": 1, "string1": "baz", "string2": "x",
"newprop": "new",
};
const sourceOrig = JSON.stringify(SOURCE);
utils.extend(target, SOURCE);
expect(JSON.stringify(target)).toEqual(JSON.stringify(merged));
// check the originial wasn't modified
expect(JSON.stringify(SOURCE)).toEqual(sourceOrig);
});
it("should ignore null", function() {
const target = {
"prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo",
};
const merged = {
"prop1": 5, "prop2": 1, "string1": "baz", "string2": "x",
"newprop": "new",
};
const sourceOrig = JSON.stringify(SOURCE);
utils.extend(target, null, SOURCE);
expect(JSON.stringify(target)).toEqual(JSON.stringify(merged));
// check the originial wasn't modified
expect(JSON.stringify(SOURCE)).toEqual(sourceOrig);
});
it("should handle properties created with defineProperties", function() {
const source = Object.defineProperties({}, {
"enumerableProp": {
get: function() {
return true;
},
enumerable: true,
},
"nonenumerableProp": {
get: function() {
return true;
},
},
});
const target = {};
utils.extend(target, source);
expect(target.enumerableProp).toBe(true);
expect(target.nonenumerableProp).toBe(undefined);
});
});
describe("chunkPromises", function() {
it("should execute promises in chunks", async function() {
let promiseCount = 0;
function fn1() {
return new Promise(async function(resolve, reject) {
await utils.sleep(1);
expect(promiseCount).toEqual(0);
++promiseCount;
resolve();
});
}
function fn2() {
return new Promise(function(resolve, reject) {
expect(promiseCount).toEqual(1);
++promiseCount;
resolve();
});
}
await utils.chunkPromises([fn1, fn2], 1);
expect(promiseCount).toEqual(2);
});
});
});

496
spec/unit/utils.spec.ts Normal file
View File

@@ -0,0 +1,496 @@
import * as utils from "../../src/utils";
import {
alphabetPad,
averageBetweenStrings,
baseToString,
deepSortedObjectEntries,
DEFAULT_ALPHABET,
lexicographicCompare,
nextString,
prevString,
simpleRetryOperation,
stringToBase,
} from "../../src/utils";
import { logger } from "../../src/logger";
// TODO: Fix types throughout
describe("utils", function() {
describe("encodeParams", function() {
it("should url encode and concat with &s", function() {
const params = {
foo: "bar",
baz: "beer@",
};
expect(utils.encodeParams(params)).toEqual(
"foo=bar&baz=beer%40",
);
});
});
describe("encodeUri", function() {
it("should replace based on object keys and url encode", function() {
const path = "foo/bar/%something/%here";
const vals = {
"%something": "baz",
"%here": "beer@",
};
expect(utils.encodeUri(path, vals)).toEqual(
"foo/bar/baz/beer%40",
);
});
});
describe("removeElement", function() {
it("should remove only 1 element if there is a match", function() {
const matchFn = function() {
return true;
};
const arr = [55, 66, 77];
utils.removeElement(arr, matchFn);
expect(arr).toEqual([66, 77]);
});
it("should be able to remove in reverse order", function() {
const matchFn = function() {
return true;
};
const arr = [55, 66, 77];
utils.removeElement(arr, matchFn, true);
expect(arr).toEqual([55, 66]);
});
it("should remove nothing if the function never returns true", function() {
const matchFn = function() {
return false;
};
const arr = [55, 66, 77];
utils.removeElement(arr, matchFn);
expect(arr).toEqual(arr);
});
});
describe("isFunction", function() {
it("should return true for functions", function() {
expect(utils.isFunction([])).toBe(false);
expect(utils.isFunction([5, 3, 7])).toBe(false);
expect(utils.isFunction(undefined)).toBe(false);
expect(utils.isFunction(null)).toBe(false);
expect(utils.isFunction({})).toBe(false);
expect(utils.isFunction("foo")).toBe(false);
expect(utils.isFunction(555)).toBe(false);
expect(utils.isFunction(function() {})).toBe(true);
const s = { foo: function() {} };
expect(utils.isFunction(s.foo)).toBe(true);
});
});
describe("checkObjectHasKeys", function() {
it("should throw for missing keys", function() {
expect(function() {
utils.checkObjectHasKeys({}, ["foo"]);
}).toThrow();
expect(function() {
utils.checkObjectHasKeys({
foo: "bar",
}, ["foo"]);
}).not.toThrow();
});
});
describe("checkObjectHasNoAdditionalKeys", function() {
it("should throw for extra keys", function() {
expect(function() {
utils.checkObjectHasNoAdditionalKeys({ foo: "bar", baz: 4 }, ["foo"]);
}).toThrow();
expect(function() {
utils.checkObjectHasNoAdditionalKeys({ foo: "bar" }, ["foo"]);
}).not.toThrow();
});
});
describe("deepCompare", function() {
const assert = {
isTrue: function(x) {
expect(x).toBe(true);
},
isFalse: function(x) {
expect(x).toBe(false);
},
};
it("should handle primitives", function() {
assert.isTrue(utils.deepCompare(null, null));
assert.isFalse(utils.deepCompare(null, undefined));
assert.isTrue(utils.deepCompare("hi", "hi"));
assert.isTrue(utils.deepCompare(5, 5));
assert.isFalse(utils.deepCompare(5, 10));
});
it("should handle regexps", function() {
assert.isTrue(utils.deepCompare(/abc/, /abc/));
assert.isFalse(utils.deepCompare(/abc/, /123/));
const r = /abc/;
assert.isTrue(utils.deepCompare(r, r));
});
it("should handle dates", function() {
assert.isTrue(utils.deepCompare(new Date("2011-03-31"), new Date("2011-03-31")));
assert.isFalse(utils.deepCompare(new Date("2011-03-31"), new Date("1970-01-01")));
});
it("should handle arrays", function() {
assert.isTrue(utils.deepCompare([], []));
assert.isTrue(utils.deepCompare([1, 2], [1, 2]));
assert.isFalse(utils.deepCompare([1, 2], [2, 1]));
assert.isFalse(utils.deepCompare([1, 2], [1, 2, 3]));
});
it("should handle simple objects", function() {
assert.isTrue(utils.deepCompare({}, {}));
assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 2 }));
assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { b: 2, a: 1 }));
assert.isFalse(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 3 }));
assert.isTrue(utils.deepCompare({
1: { name: "mhc", age: 28 },
2: { name: "arb", age: 26 },
}, {
1: { name: "mhc", age: 28 },
2: { name: "arb", age: 26 },
}));
assert.isFalse(utils.deepCompare({
1: { name: "mhc", age: 28 },
2: { name: "arb", age: 26 },
}, {
1: { name: "mhc", age: 28 },
2: { name: "arb", age: 27 },
}));
assert.isFalse(utils.deepCompare({}, null));
assert.isFalse(utils.deepCompare({}, undefined));
});
it("should handle functions", function() {
// no two different function is equal really, they capture their
// context variables so even if they have same toString(), they
// won't have same functionality
const func = function(x) {
return true;
};
const func2 = function(x) {
return true;
};
assert.isTrue(utils.deepCompare(func, func));
assert.isFalse(utils.deepCompare(func, func2));
assert.isTrue(utils.deepCompare({ a: { b: func } }, { a: { b: func } }));
assert.isFalse(utils.deepCompare({ a: { b: func } }, { a: { b: func2 } }));
});
});
describe("extend", function() {
const SOURCE = { "prop2": 1, "string2": "x", "newprop": "new" };
it("should extend", function() {
const target = {
"prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo",
};
const merged = {
"prop1": 5, "prop2": 1, "string1": "baz", "string2": "x",
"newprop": "new",
};
const sourceOrig = JSON.stringify(SOURCE);
utils.extend(target, SOURCE);
expect(JSON.stringify(target)).toEqual(JSON.stringify(merged));
// check the originial wasn't modified
expect(JSON.stringify(SOURCE)).toEqual(sourceOrig);
});
it("should ignore null", function() {
const target = {
"prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo",
};
const merged = {
"prop1": 5, "prop2": 1, "string1": "baz", "string2": "x",
"newprop": "new",
};
const sourceOrig = JSON.stringify(SOURCE);
utils.extend(target, null, SOURCE);
expect(JSON.stringify(target)).toEqual(JSON.stringify(merged));
// check the originial wasn't modified
expect(JSON.stringify(SOURCE)).toEqual(sourceOrig);
});
it("should handle properties created with defineProperties", function() {
const source = Object.defineProperties({}, {
"enumerableProp": {
get: function() {
return true;
},
enumerable: true,
},
"nonenumerableProp": {
get: function() {
return true;
},
},
});
// TODO: Fix type
const target: any = {};
utils.extend(target, source);
expect(target.enumerableProp).toBe(true);
expect(target.nonenumerableProp).toBe(undefined);
});
});
describe("chunkPromises", function() {
it("should execute promises in chunks", async function() {
let promiseCount = 0;
async function fn1() {
await utils.sleep(1);
expect(promiseCount).toEqual(0);
++promiseCount;
}
async function fn2() {
expect(promiseCount).toEqual(1);
++promiseCount;
}
await utils.chunkPromises([fn1, fn2], 1);
expect(promiseCount).toEqual(2);
});
});
describe('simpleRetryOperation', () => {
it('should retry', async () => {
let count = 0;
const val = {};
const fn = (attempt) => {
count++;
// If this expectation fails then it can appear as a Jest Timeout due to
// the retry running beyond the test limit.
expect(attempt).toEqual(count);
if (count > 1) {
return Promise.resolve(val);
} else {
return Promise.reject(new Error("Iterative failure"));
}
};
const ret = await simpleRetryOperation(fn);
expect(ret).toBe(val);
expect(count).toEqual(2);
});
// We don't test much else of the function because then we're just testing that the
// underlying library behaves, which should be tested on its own. Our API surface is
// all that concerns us.
});
describe('DEFAULT_ALPHABET', () => {
it('should be usefully printable ASCII in order', () => {
expect(DEFAULT_ALPHABET).toEqual(
" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
);
});
});
describe('alphabetPad', () => {
it('should pad to the alphabet length', () => {
const len = 12;
expect(alphabetPad("a", len)).toEqual("a" + ("".padEnd(len - 1, DEFAULT_ALPHABET[0])));
expect(alphabetPad("a", len, "123")).toEqual("a" + ("".padEnd(len - 1, '1')));
});
});
describe('baseToString', () => {
it('should calculate the appropriate string from numbers', () => {
// Verify the whole alphabet
for (let i = BigInt(1); i <= DEFAULT_ALPHABET.length; i++) {
logger.log({ i }); // for debugging
expect(baseToString(i)).toEqual(DEFAULT_ALPHABET[Number(i) - 1]);
}
// Just quickly double check that repeated characters aren't treated as padding, particularly
// at the beginning of the alphabet where they are most vulnerable to this behaviour.
expect(baseToString(BigInt(1))).toEqual(DEFAULT_ALPHABET[0].repeat(1));
expect(baseToString(BigInt(96))).toEqual(DEFAULT_ALPHABET[0].repeat(2));
expect(baseToString(BigInt(9121))).toEqual(DEFAULT_ALPHABET[0].repeat(3));
expect(baseToString(BigInt(866496))).toEqual(DEFAULT_ALPHABET[0].repeat(4));
expect(baseToString(BigInt(82317121))).toEqual(DEFAULT_ALPHABET[0].repeat(5));
expect(baseToString(BigInt(7820126496))).toEqual(DEFAULT_ALPHABET[0].repeat(6));
expect(baseToString(BigInt(10))).toEqual(DEFAULT_ALPHABET[9]);
expect(baseToString(BigInt(10), "abcdefghijklmnopqrstuvwxyz")).toEqual('j');
expect(baseToString(BigInt(6337))).toEqual("ab");
expect(baseToString(BigInt(80), "abcdefghijklmnopqrstuvwxyz")).toEqual('cb');
});
});
describe('stringToBase', () => {
it('should calculate the appropriate number for a string', () => {
expect(stringToBase(DEFAULT_ALPHABET[0].repeat(1))).toEqual(BigInt(1));
expect(stringToBase(DEFAULT_ALPHABET[0].repeat(2))).toEqual(BigInt(96));
expect(stringToBase(DEFAULT_ALPHABET[0].repeat(3))).toEqual(BigInt(9121));
expect(stringToBase(DEFAULT_ALPHABET[0].repeat(4))).toEqual(BigInt(866496));
expect(stringToBase(DEFAULT_ALPHABET[0].repeat(5))).toEqual(BigInt(82317121));
expect(stringToBase(DEFAULT_ALPHABET[0].repeat(6))).toEqual(BigInt(7820126496));
expect(stringToBase("a", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(1));
expect(stringToBase("a")).toEqual(BigInt(66));
expect(stringToBase("c", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(3));
expect(stringToBase("ab")).toEqual(BigInt(6337));
expect(stringToBase("cb", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(80));
});
});
describe('averageBetweenStrings', () => {
it('should average appropriately', () => {
expect(averageBetweenStrings(" ", "!!")).toEqual(" P");
expect(averageBetweenStrings(" ", "!")).toEqual(" ");
expect(averageBetweenStrings('A', 'B')).toEqual('A ');
expect(averageBetweenStrings('AA', 'BB')).toEqual('Aq');
expect(averageBetweenStrings('A', 'z')).toEqual(']');
expect(averageBetweenStrings('a', 'z', "abcdefghijklmnopqrstuvwxyz")).toEqual('m');
expect(averageBetweenStrings('AA', 'zz')).toEqual('^.');
expect(averageBetweenStrings('aa', 'zz', "abcdefghijklmnopqrstuvwxyz")).toEqual('mz');
expect(averageBetweenStrings('cat', 'doggo')).toEqual("d9>Cw");
expect(averageBetweenStrings('cat', 'doggo', "abcdefghijklmnopqrstuvwxyz")).toEqual("cumqh");
});
});
describe('nextString', () => {
it('should find the next string appropriately', () => {
expect(nextString('A')).toEqual('B');
expect(nextString('b', 'abcdefghijklmnopqrstuvwxyz')).toEqual('c');
expect(nextString('cat')).toEqual('cau');
expect(nextString('cat', 'abcdefghijklmnopqrstuvwxyz')).toEqual('cau');
});
});
describe('prevString', () => {
it('should find the next string appropriately', () => {
expect(prevString('B')).toEqual('A');
expect(prevString('c', 'abcdefghijklmnopqrstuvwxyz')).toEqual('b');
expect(prevString('cau')).toEqual('cat');
expect(prevString('cau', 'abcdefghijklmnopqrstuvwxyz')).toEqual('cat');
});
});
// Let's just ensure the ordering is sensible for lexicographic ordering
describe('string averaging unified', () => {
it('should be truly previous and next', () => {
let midpoint = "cat";
// We run this test 100 times to ensure we end up with a sane sequence.
for (let i = 0; i < 100; i++) {
const next = nextString(midpoint);
const prev = prevString(midpoint);
logger.log({ i, midpoint, next, prev }); // for test debugging
expect(lexicographicCompare(midpoint, next) < 0).toBe(true);
expect(lexicographicCompare(midpoint, prev) > 0).toBe(true);
expect(averageBetweenStrings(prev, next)).toBe(midpoint);
midpoint = next;
}
});
it('should roll over', () => {
const lastAlpha = DEFAULT_ALPHABET[DEFAULT_ALPHABET.length - 1];
const firstAlpha = DEFAULT_ALPHABET[0];
const highRoll = firstAlpha + firstAlpha;
const lowRoll = lastAlpha;
expect(nextString(lowRoll)).toEqual(highRoll);
expect(prevString(highRoll)).toEqual(lowRoll);
});
it('should be reversible on small strings', () => {
// Large scale reversibility is tested for max space order value
const input = "cats";
expect(prevString(nextString(input))).toEqual(input);
});
// We want to explicitly make sure that Space order values are supported and roll appropriately
it('should properly handle rolling over at 50 characters', () => {
// Note: we also test reversibility of large strings here.
const maxSpaceValue = DEFAULT_ALPHABET[DEFAULT_ALPHABET.length - 1].repeat(50);
const fiftyFirstChar = DEFAULT_ALPHABET[0].repeat(51);
expect(nextString(maxSpaceValue)).toBe(fiftyFirstChar);
expect(prevString(fiftyFirstChar)).toBe(maxSpaceValue);
// We're testing that the rollover happened, which means that the next string come before
// the maximum space order value lexicographically.
expect(lexicographicCompare(maxSpaceValue, fiftyFirstChar) > 0).toBe(true);
});
});
describe('lexicographicCompare', () => {
it('should work', () => {
// Simple tests
expect(lexicographicCompare('a', 'b') < 0).toBe(true);
expect(lexicographicCompare('ab', 'b') < 0).toBe(true);
expect(lexicographicCompare('cat', 'dog') < 0).toBe(true);
// Simple tests (reversed)
expect(lexicographicCompare('b', 'a') > 0).toBe(true);
expect(lexicographicCompare('b', 'ab') > 0).toBe(true);
expect(lexicographicCompare('dog', 'cat') > 0).toBe(true);
// Simple equality tests
expect(lexicographicCompare('a', 'a') === 0).toBe(true);
expect(lexicographicCompare('A', 'A') === 0).toBe(true);
// ASCII rule testing
expect(lexicographicCompare('A', 'a') < 0).toBe(true);
expect(lexicographicCompare('a', 'A') > 0).toBe(true);
});
});
describe('deepSortedObjectEntries', () => {
it('should auto-return non-objects', () => {
expect(deepSortedObjectEntries(42)).toEqual(42);
expect(deepSortedObjectEntries("not object")).toEqual("not object");
expect(deepSortedObjectEntries(true)).toEqual(true);
expect(deepSortedObjectEntries([42])).toEqual([42]);
expect(deepSortedObjectEntries(null)).toEqual(null);
expect(deepSortedObjectEntries(undefined)).toEqual(undefined);
});
it('should sort objects appropriately', () => {
const input = {
a: 42,
b: {
d: {},
a: "test",
b: "alpha",
},
[72]: "test",
};
const output = [
["72", "test"],
["a", 42],
["b", [
["a", "test"],
["b", "alpha"],
["d", []],
]],
];
expect(deepSortedObjectEntries(input)).toMatchObject(output);
});
});
});

View File

@@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export const SERVICE_TYPES = Object.freeze({
IS: 'SERVICE_TYPE_IS', // An Identity Service
IM: 'SERVICE_TYPE_IM', // An Integration Manager
});
declare module "another-json" {
export function stringify(o: object): string;
}

View File

@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { UnstableValue } from "../NamespacedValue";
export enum EventType {
// Room state events
RoomCanonicalAlias = "m.room.canonical_alias",
@@ -71,6 +73,7 @@ export enum EventType {
// Room account_data events
FullyRead = "m.fully_read",
Tag = "m.tag",
SpaceOrder = "org.matrix.msc3230.space_order", // MSC3230
// User account_data events
PushRules = "m.push_rules",
@@ -84,6 +87,11 @@ export enum EventType {
Dummy = "m.dummy",
}
export enum RelationType {
Annotation = "m.annotation",
Replace = "m.replace",
}
export enum MsgType {
Text = "m.text",
Emote = "m.emote",
@@ -100,3 +108,53 @@ export const RoomCreateTypeField = "type";
export enum RoomType {
Space = "m.space",
}
/**
* Identifier for an [MSC3088](https://github.com/matrix-org/matrix-doc/pull/3088)
* room purpose. Note that this reference is UNSTABLE and subject to breaking changes,
* including its eventual removal.
*/
export const UNSTABLE_MSC3088_PURPOSE = new UnstableValue("m.room.purpose", "org.matrix.msc3088.purpose");
/**
* Enabled flag for an [MSC3088](https://github.com/matrix-org/matrix-doc/pull/3088)
* room purpose. Note that this reference is UNSTABLE and subject to breaking changes,
* including its eventual removal.
*/
export const UNSTABLE_MSC3088_ENABLED = new UnstableValue("m.enabled", "org.matrix.msc3088.enabled");
/**
* Subtype for an [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room.
* Note that this reference is UNSTABLE and subject to breaking changes, including its
* eventual removal.
*/
export const UNSTABLE_MSC3089_TREE_SUBTYPE = new UnstableValue("m.data_tree", "org.matrix.msc3089.data_tree");
/**
* Leaf type for an event in a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room.
* Note that this reference is UNSTABLE and subject to breaking changes, including its
* eventual removal.
*/
export const UNSTABLE_MSC3089_LEAF = new UnstableValue("m.leaf", "org.matrix.msc3089.leaf");
/**
* Branch (Leaf Reference) type for the index approach in a
* [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room. Note that this reference is
* UNSTABLE and subject to breaking changes, including its eventual removal.
*/
export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix.msc3089.branch");
export interface IEncryptedFile {
url: string;
mimetype?: string;
key: {
alg: string;
key_ops: string[]; // eslint-disable-line camelcase
kty: string;
k: string;
ext: boolean;
};
iv: string;
hashes: {[alg: string]: string};
v: string;
}

View File

@@ -15,7 +15,7 @@ limitations under the License.
*/
// this is needed to tell TS about global.Olm
import * as Olm from "@matrix-org/olm"; // eslint-disable-line @typescript-eslint/no-unused-vars
import "@matrix-org/olm";
export {};
@@ -34,6 +34,10 @@ declare global {
getDesktopCapturerSources(options: GetSourcesOptions): Promise<Array<DesktopCapturerSource>>;
}
interface Crypto {
webkitSubtle?: Window["crypto"]["subtle"];
}
interface MediaDevices {
// This is experimental and types don't know about it yet
// https://github.com/microsoft/TypeScript/issues/33232
@@ -84,4 +88,17 @@ declare global {
// on webkit: we should check if we still need to do this
webkitGetUserMedia: DummyInterfaceWeShouldntBeUsingThis;
}
export interface ISettledFulfilled<T> {
status: "fulfilled";
value: T;
}
export interface ISettledRejected {
status: "rejected";
reason: any;
}
interface PromiseConstructor {
allSettled<T>(promises: Promise<T>[]): Promise<Array<ISettledFulfilled<T> | ISettledRejected>>;
}
}

View File

@@ -26,3 +26,16 @@ export interface IImageInfo {
w?: number;
h?: number;
}
export enum Visibility {
Public = "public",
Private = "private",
}
export enum Preset {
PrivateChat = "private_chat",
TrustedPrivateChat = "trusted_private_chat",
PublicChat = "public_chat",
}
export type ResizeMethod = "crop" | "scale";

View File

@@ -15,6 +15,10 @@ limitations under the License.
*/
import { Callback } from "../client";
import { Preset, Visibility } from "./partials";
// allow camelcase as these are things go onto the wire
/* eslint-disable camelcase */
export interface IJoinRoomOpts {
/**
@@ -40,12 +44,12 @@ export interface IRedactOpts {
}
export interface ISendEventResponse {
event_id: string; // eslint-disable-line camelcase
event_id: string;
}
export interface IPresenceOpts {
presence: "online" | "offline" | "unavailable";
status_msg?: string; // eslint-disable-line camelcase
status_msg?: string;
}
export interface IPaginateOpts {
@@ -68,14 +72,32 @@ export interface IEventSearchOpts {
term: string;
}
export interface IInvite3PID {
id_server: string;
id_access_token?: string; // this gets injected by the js-sdk
medium: string;
address: string;
}
export interface ICreateRoomStateEvent {
type: string;
state_key?: string; // defaults to an empty string
content: object;
}
export interface ICreateRoomOpts {
room_alias_name?: string; // eslint-disable-line camelcase
visibility?: "public" | "private";
room_alias_name?: string;
visibility?: Visibility;
name?: string;
topic?: string;
preset?: string;
// TODO: Types (next line)
invite_3pid?: any[]; // eslint-disable-line camelcase
preset?: Preset;
power_level_content_override?: object;
creation_content?: object;
initial_state?: ICreateRoomStateEvent[];
invite?: string[];
invite_3pid?: IInvite3PID[];
is_direct?: boolean;
room_version?: string;
}
export interface IRoomDirectoryOptions {
@@ -84,7 +106,7 @@ export interface IRoomDirectoryOptions {
since?: string;
// TODO: Proper types
filter?: any & {generic_search_term: string}; // eslint-disable-line camelcase
filter?: any & {generic_search_term: string};
}
export interface IUploadOpts {
@@ -96,3 +118,5 @@ export interface IUploadOpts {
callback?: Callback;
progressHandler?: (state: {loaded: number, total: number}) => void;
}
/* eslint-enable camelcase */

93
src/NamespacedValue.ts Normal file
View File

@@ -0,0 +1,93 @@
/*
Copyright 2021 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.
*/
/**
* Represents a simple Matrix namespaced value. This will assume that if a stable prefix
* is provided that the stable prefix should be used when representing the identifier.
*/
export class NamespacedValue<S extends string, U extends string> {
// Stable is optional, but one of the two parameters is required, hence the weird-looking types.
// Goal is to to have developers explicitly say there is no stable value (if applicable).
public constructor(public readonly stable: S | null | undefined, public readonly unstable?: U) {
if (!this.unstable && !this.stable) {
throw new Error("One of stable or unstable values must be supplied");
}
}
public get name(): U | S {
if (this.stable) {
return this.stable;
}
return this.unstable;
}
public get altName(): U | S | null {
if (!this.stable) {
return null;
}
return this.unstable;
}
public matches(val: string): boolean {
return this.name === val || this.altName === val;
}
// this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class
// so we can instantiate `NamespacedValue<string, _, _>` as a default type for that namespace.
public findIn<T>(obj: any): T {
let val: T;
if (this.name) {
val = obj?.[this.name];
}
if (!val && this.altName) {
val = obj?.[this.altName];
}
return val;
}
public includedIn(arr: any[]): boolean {
let included = false;
if (this.name) {
included = arr.includes(this.name);
}
if (!included && this.altName) {
included = arr.includes(this.altName);
}
return included;
}
}
/**
* Represents a namespaced value which prioritizes the unstable value over the stable
* value.
*/
export class UnstableValue<S extends string, U extends string> extends NamespacedValue<S, U> {
// Note: Constructor difference is that `unstable` is *required*.
public constructor(stable: S, unstable: U) {
super(stable, unstable);
if (!this.unstable) {
throw new Error("Unstable value must be supplied");
}
}
public get name(): U {
return this.unstable;
}
public get altName(): S {
return this.stable;
}
}

View File

@@ -21,7 +21,7 @@ limitations under the License.
import { EventEmitter } from "events";
import { SyncApi } from "./sync";
import { EventStatus, MatrixEvent } from "./models/event";
import { EventStatus, IDecryptOptions, MatrixEvent } from "./models/event";
import { StubStore } from "./store/stub";
import { createNewMatrixCall, MatrixCall } from "./webrtc/call";
import { Filter } from "./filter";
@@ -32,7 +32,6 @@ import { Group } from "./models/group";
import { EventTimeline } from "./models/event-timeline";
import { PushAction, PushProcessor } from "./pushprocessor";
import { AutoDiscovery } from "./autodiscovery";
import { MatrixError } from "./http-api";
import * as olmlib from "./crypto/olmlib";
import { decodeBase64, encodeBase64 } from "./crypto/olmlib";
import { ReEmitter } from './ReEmitter';
@@ -40,6 +39,7 @@ import { RoomList } from './crypto/RoomList';
import { logger } from './logger';
import { SERVICE_TYPES } from './service-types';
import {
MatrixError,
MatrixHttpApi,
PREFIX_IDENTITY_V2,
PREFIX_MEDIA_R0,
@@ -47,7 +47,8 @@ import {
PREFIX_UNSTABLE,
retryNetworkOperation,
} from "./http-api";
import { Crypto, DeviceInfo, fixBackupKey, isCryptoAvailable } from './crypto';
import { Crypto, fixBackupKey, IBootstrapCrossSigningOpts, IMegolmSessionData, isCryptoAvailable } from './crypto';
import { DeviceInfo, IDevice } from "./crypto/deviceinfo";
import { decodeRecoveryKey } from './crypto/recoverykey';
import { keyFromAuthData } from './crypto/key_passphrase';
import { User } from "./models/user";
@@ -58,13 +59,12 @@ import {
IKeyBackupPrepareOpts,
IKeyBackupRestoreOpts,
IKeyBackupRestoreResult,
IKeyBackupTrustInfo,
IKeyBackupVersion,
IKeyBackupInfo,
} from "./crypto/keybackup";
import { IIdentityServerProvider } from "./@types/IIdentityServerProvider";
import type Request from "request";
import { MatrixScheduler } from "./scheduler";
import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from "./matrix";
import { ICryptoCallbacks, ISecretStorageKeyInfo, NotificationCountType } from "./matrix";
import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store";
import { LocalStorageCryptoStore } from "./crypto/store/localStorage-crypto-store";
import { IndexedDBCryptoStore } from "./crypto/store/indexeddb-crypto-store";
@@ -85,7 +85,7 @@ import {
IRecoveryKey,
ISecretStorageKey,
} from "./crypto/api";
import { CrossSigningInfo, UserTrustLevel } from "./crypto/CrossSigning";
import { CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from "./crypto/CrossSigning";
import { Room } from "./models/room";
import {
ICreateRoomOpts,
@@ -94,19 +94,29 @@ import {
IJoinRoomOpts,
IPaginateOpts,
IPresenceOpts,
IRedactOpts, IRoomDirectoryOptions,
IRedactOpts,
IRoomDirectoryOptions,
ISearchOpts,
ISendEventResponse,
IUploadOpts,
} from "./@types/requests";
import { EventType } from "./@types/event";
import { IImageInfo } from "./@types/partials";
import {
EventType,
RoomCreateTypeField,
RoomType,
UNSTABLE_MSC3088_ENABLED,
UNSTABLE_MSC3088_PURPOSE,
UNSTABLE_MSC3089_TREE_SUBTYPE,
} from "./@types/event";
import { IImageInfo, Preset } from "./@types/partials";
import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper";
import url from "url";
import { randomString } from "./randomstring";
import { ReadStream } from "fs";
import { WebStorageSessionStore } from "./store/session/webstorage";
import { BackupManager } from "./crypto/backup";
import { BackupManager, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup";
import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace";
import { ISignatures } from "./@types/signed";
export type Store = StubStore | MemoryStore | LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend;
export type SessionStore = WebStorageSessionStore;
@@ -132,6 +142,12 @@ interface IExportedDevice {
deviceId: string;
}
export interface IKeysUploadResponse {
one_time_key_counts: { // eslint-disable-line camelcase
[algorithm: string]: number;
};
}
export interface ICreateClientOpts {
baseUrl: string;
@@ -282,6 +298,11 @@ export interface IMatrixClientCreateOpts extends ICreateClientOpts {
usingExternalCrypto?: boolean;
}
export enum PendingEventOrdering {
Chronological = "chronological",
Detached = "detached",
}
export interface IStartClientOpts {
/**
* The event <code>limit=</code> to apply to initial sync. Default: 8.
@@ -304,7 +325,7 @@ export interface IStartClientOpts {
* pending messages will appear in a separate list, accessbile via {@link module:models/room#getPendingEvents}.
* Default: "chronological".
*/
pendingEventOrdering?: "chronological" | "detached";
pendingEventOrdering?: PendingEventOrdering;
/**
* The number of milliseconds to wait on /sync. Default: 30000 (30 seconds).
@@ -340,6 +361,59 @@ export interface IStoredClientOpts extends IStartClientOpts {
canResetEntireTimeline: ResetTimelineCallback;
}
export enum RoomVersionStability {
Stable = "stable",
Unstable = "unstable",
}
export interface IRoomVersionsCapability {
default: string;
available: Record<string, RoomVersionStability>;
}
export interface IChangePasswordCapability {
enabled: boolean;
}
interface ICapabilities {
[key: string]: any;
"m.change_password"?: IChangePasswordCapability;
"m.room_versions"?: IRoomVersionsCapability;
}
/* eslint-disable camelcase */
export interface ICrossSigningKey {
keys: { [algorithm: string]: string };
signatures?: ISignatures;
usage: string[];
user_id: string;
}
enum CrossSigningKeyType {
MasterKey = "master_key",
SelfSigningKey = "self_signing_key",
UserSigningKey = "user_signing_key",
}
export type CrossSigningKeys = Record<CrossSigningKeyType, ICrossSigningKey>;
export interface ISignedKey {
keys: Record<string, string>;
signatures: ISignatures;
user_id: string;
algorithms: string[];
device_id: string;
}
/* eslint-enable camelcase */
export type KeySignatures = Record<string, Record<string, ICrossSigningKey | ISignedKey>>;
interface IUploadKeySignaturesResponse {
failures: Record<string, Record<string, {
errcode: string;
error: string;
}>>;
}
/**
* Represents a Matrix Client. Only directly construct this if you want to use
* custom modules. Normally, {@link createClient} should be used
@@ -385,7 +459,7 @@ export class MatrixClient extends EventEmitter {
protected fallbackICEServerAllowed = false;
protected roomList: RoomList;
protected syncApi: SyncApi;
protected pushRules: any; // TODO: Types
public pushRules: any; // TODO: Types
protected syncLeftRoomsPromise: Promise<Room[]>;
protected syncedLeftRooms = false;
protected clientOpts: IStoredClientOpts;
@@ -400,7 +474,7 @@ export class MatrixClient extends EventEmitter {
protected serverVersionsPromise: Promise<any>;
protected cachedCapabilities: {
capabilities: Record<string, any>;
capabilities: ICapabilities;
expiration: number;
};
protected clientWellKnown: any;
@@ -521,7 +595,7 @@ export class MatrixClient extends EventEmitter {
const room = this.getRoom(event.getRoomId());
if (!room) return;
const currentCount = room.getUnreadNotificationCount("highlight");
const currentCount = room.getUnreadNotificationCount(NotificationCountType.Highlight);
// Ensure the unread counts are kept up to date if the event is encrypted
// We also want to make sure that the notification count goes up if we already
@@ -537,12 +611,12 @@ export class MatrixClient extends EventEmitter {
let newCount = currentCount;
if (newHighlight && !oldHighlight) newCount++;
if (!newHighlight && oldHighlight) newCount--;
room.setUnreadNotificationCount("highlight", newCount);
room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount);
// Fix 'Mentions Only' rooms from not having the right badge count
const totalCount = room.getUnreadNotificationCount('total');
const totalCount = room.getUnreadNotificationCount(NotificationCountType.Total);
if (totalCount < newCount) {
room.setUnreadNotificationCount('total', newCount);
room.setUnreadNotificationCount(NotificationCountType.Total, newCount);
}
}
}
@@ -807,7 +881,7 @@ export class MatrixClient extends EventEmitter {
return;
}
// XXX: Private member access.
return await this.crypto._dehydrationManager.setKeyAndQueueDehydration(
return await this.crypto.dehydrationManager.setKeyAndQueueDehydration(
key, keyInfo, deviceDisplayName,
);
}
@@ -830,11 +904,11 @@ export class MatrixClient extends EventEmitter {
logger.warn('not dehydrating device if crypto is not enabled');
return;
}
await this.crypto._dehydrationManager.setKey(
await this.crypto.dehydrationManager.setKey(
key, keyInfo, deviceDisplayName,
);
// XXX: Private member access.
return await this.crypto._dehydrationManager.dehydrateDevice();
return await this.crypto.dehydrationManager.dehydrateDevice();
}
public async exportDevice(): Promise<IExportedDevice> {
@@ -846,7 +920,7 @@ export class MatrixClient extends EventEmitter {
userId: this.credentials.userId,
deviceId: this.deviceId,
// XXX: Private member access.
olmDevice: await this.crypto._olmDevice.export(),
olmDevice: await this.crypto.olmDevice.export(),
};
}
@@ -1050,7 +1124,7 @@ export class MatrixClient extends EventEmitter {
* @return {Promise} Resolves to the capabilities of the homeserver
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public getCapabilities(fresh = false): Promise<Record<string, any>> {
public getCapabilities(fresh = false): Promise<ICapabilities> {
const now = new Date().getTime();
if (this.cachedCapabilities && !fresh) {
@@ -1068,7 +1142,7 @@ export class MatrixClient extends EventEmitter {
return null; // otherwise consume the error
}).then((r) => {
if (!r) r = {};
const capabilities = r["capabilities"] || {};
const capabilities: ICapabilities = r["capabilities"] || {};
// If the capabilities missed the cache, cache it for a shorter amount
// of time to try and refresh them later.
@@ -1077,7 +1151,7 @@ export class MatrixClient extends EventEmitter {
: 60000 + (Math.random() * 5000);
this.cachedCapabilities = {
capabilities: capabilities,
capabilities,
expiration: now + cacheMs,
};
@@ -1210,12 +1284,12 @@ export class MatrixClient extends EventEmitter {
* Upload the device keys to the homeserver.
* @return {Promise<void>} A promise that will resolve when the keys are uploaded.
*/
public uploadKeys(): Promise<void> {
public async uploadKeys(): Promise<void> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.uploadDeviceKeys();
await this.crypto.uploadDeviceKeys();
}
/**
@@ -1230,7 +1304,7 @@ export class MatrixClient extends EventEmitter {
public downloadKeys(
userIds: string[],
forceDownload?: boolean,
): Promise<Record<string, Record<string, DeviceInfo>>> {
): Promise<Record<string, Record<string, IDevice>>> {
if (!this.crypto) {
return Promise.reject(new Error("End-to-end encryption disabled"));
}
@@ -1536,9 +1610,9 @@ export class MatrixClient extends EventEmitter {
* @param {string} userId The ID of the user whose devices is to be checked.
* @param {string} deviceId The ID of the device to check
*
* @returns {IDeviceTrustLevel}
* @returns {DeviceTrustLevel}
*/
public checkDeviceTrust(userId: string, deviceId: string): IDeviceTrustLevel {
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
@@ -1602,7 +1676,7 @@ export class MatrixClient extends EventEmitter {
* return true.
* @return {boolean} True if cross-signing is ready to be used on this device
*/
public isCrossSigningReady(): boolean {
public isCrossSigningReady(): Promise<boolean> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
@@ -1629,10 +1703,7 @@ export class MatrixClient extends EventEmitter {
* auth data as an object. Can be called multiple times, first with an empty
* authDict, to obtain the flows.
*/
public bootstrapCrossSigning(opts: {
authUploadDeviceSigningKeys: (makeRequest: (authData: any) => void) => Promise<void>,
setupNewCrossSigning?: boolean,
}) {
public bootstrapCrossSigning(opts: IBootstrapCrossSigningOpts) {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
@@ -1727,7 +1798,7 @@ export class MatrixClient extends EventEmitter {
*
* @return {boolean} True if secret storage is ready to be used on this device
*/
public isSecretStorageReady(): boolean {
public isSecretStorageReady(): Promise<boolean> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
@@ -1819,7 +1890,7 @@ export class MatrixClient extends EventEmitter {
*
* @return {string} the contents of the secret
*/
public getSecret(name: string): string {
public getSecret(name: string): Promise<string> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
@@ -1856,7 +1927,7 @@ export class MatrixClient extends EventEmitter {
*
* @return {string} the contents of the secret
*/
public requestSecret(name: string, devices: string[]): string {
public requestSecret(name: string, devices: string[]): any { // TODO types
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
@@ -1870,7 +1941,7 @@ export class MatrixClient extends EventEmitter {
*
* @return {string} The default key ID or null if no default key ID is set
*/
public getDefaultSecretStorageKeyId(): string {
public getDefaultSecretStorageKeyId(): Promise<string> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
@@ -1916,7 +1987,7 @@ export class MatrixClient extends EventEmitter {
*
* @return {Promise<module:crypto/deviceinfo?>}
*/
public getEventSenderDeviceInfo(event: MatrixEvent): Promise<DeviceInfo> {
public async getEventSenderDeviceInfo(event: MatrixEvent): Promise<DeviceInfo> {
if (!this.crypto) {
return null;
}
@@ -2030,7 +2101,7 @@ export class MatrixClient extends EventEmitter {
* @return {Promise} a promise which resolves when the keys
* have been imported
*/
public importRoomKeys(keys: any[], opts: IImportRoomKeysOpts): Promise<void> {
public importRoomKeys(keys: IMegolmSessionData[], opts: IImportRoomKeysOpts): Promise<void> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
@@ -2046,15 +2117,15 @@ export class MatrixClient extends EventEmitter {
* trust information (as returned by isKeyBackupTrusted)
* in trustInfo.
*/
public checkKeyBackup(): IKeyBackupVersion {
return this.crypto._backupManager.checkKeyBackup();
public checkKeyBackup(): Promise<IKeyBackupCheck> {
return this.crypto.backupManager.checkKeyBackup();
}
/**
* Get information about the current key backup.
* @returns {Promise} Information object from API or null
*/
public getKeyBackupVersion(): Promise<IKeyBackupVersion> {
public getKeyBackupVersion(): Promise<IKeyBackupInfo> {
return this.http.authedRequest(
undefined, "GET", "/room_keys/version", undefined, undefined,
{ prefix: PREFIX_UNSTABLE },
@@ -2088,8 +2159,8 @@ export class MatrixClient extends EventEmitter {
* ]
* }
*/
public isKeyBackupTrusted(info: IKeyBackupVersion): IKeyBackupTrustInfo {
return this.crypto._backupManager.isKeyBackupTrusted(info);
public isKeyBackupTrusted(info: IKeyBackupInfo): Promise<TrustInfo> {
return this.crypto.backupManager.isKeyBackupTrusted(info);
}
/**
@@ -2101,7 +2172,7 @@ export class MatrixClient extends EventEmitter {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto._backupManager.getKeyBackupEnabled();
return this.crypto.backupManager.getKeyBackupEnabled();
}
/**
@@ -2111,12 +2182,12 @@ export class MatrixClient extends EventEmitter {
* @param {object} info Backup information object as returned by getKeyBackupVersion
* @returns {Promise<void>} Resolves when complete.
*/
public enableKeyBackup(info: IKeyBackupVersion): Promise<void> {
public enableKeyBackup(info: IKeyBackupInfo): Promise<void> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto._backupManager.enableKeyBackup(info);
return this.crypto.backupManager.enableKeyBackup(info);
}
/**
@@ -2127,7 +2198,7 @@ export class MatrixClient extends EventEmitter {
throw new Error("End-to-end encryption disabled");
}
this.crypto._backupManager.disableKeyBackup();
this.crypto.backupManager.disableKeyBackup();
}
/**
@@ -2148,14 +2219,14 @@ export class MatrixClient extends EventEmitter {
public async prepareKeyBackupVersion(
password: string,
opts: IKeyBackupPrepareOpts = { secureSecretStorage: false },
): Promise<IKeyBackupVersion> {
): Promise<Pick<IPreparedKeyBackupVersion, "algorithm" | "auth_data" | "recovery_key">> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
// eslint-disable-next-line camelcase
const { algorithm, auth_data, recovery_key, privateKey } =
await this.crypto._backupManager.prepareKeyBackupVersion(password);
await this.crypto.backupManager.prepareKeyBackupVersion(password);
if (opts.secureSecretStorage) {
await this.storeSecret("m.megolm_backup.v1", encodeBase64(privateKey));
@@ -2166,7 +2237,7 @@ export class MatrixClient extends EventEmitter {
algorithm,
auth_data,
recovery_key,
} as any; // TODO: Types
};
}
/**
@@ -2187,12 +2258,12 @@ export class MatrixClient extends EventEmitter {
* @returns {Promise<object>} Object with 'version' param indicating the version created
*/
// TODO: Fix types
public async createKeyBackupVersion(info: IKeyBackupVersion): Promise<IKeyBackupVersion> {
public async createKeyBackupVersion(info: IKeyBackupInfo): Promise<IKeyBackupInfo> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
await this.crypto._backupManager.createKeyBackupVersion(info);
await this.crypto.backupManager.createKeyBackupVersion(info);
const data = {
algorithm: info.algorithm,
@@ -2203,19 +2274,19 @@ export class MatrixClient extends EventEmitter {
// older devices with cross-signing. This can probably go away very soon in
// favour of just signing with the cross-singing master key.
// XXX: Private member access
await this.crypto._signObject(data.auth_data);
await this.crypto.signObject(data.auth_data);
if (
this.cryptoCallbacks.getCrossSigningKey &&
// XXX: Private member access
this.crypto._crossSigningInfo.getId()
this.crypto.crossSigningInfo.getId()
) {
// now also sign the auth data with the cross-signing master key
// we check for the callback explicitly here because we still want to be able
// to create an un-cross-signed key backup if there is a cross-signing key but
// no callback supplied.
// XXX: Private member access
await this.crypto._crossSigningInfo.signObject(data.auth_data, "master");
await this.crypto.crossSigningInfo.signObject(data.auth_data, "master");
}
const res = await this.http.authedRequest(
@@ -2242,8 +2313,8 @@ export class MatrixClient extends EventEmitter {
// If we're currently backing up to this backup... stop.
// (We start using it automatically in createKeyBackupVersion
// so this is symmetrical).
if (this.crypto._backupManager.version) {
this.crypto._backupManager.disableKeyBackup();
if (this.crypto.backupManager.version) {
this.crypto.backupManager.disableKeyBackup();
}
const path = utils.encodeUri("/room_keys/version/$version", {
@@ -2281,7 +2352,7 @@ export class MatrixClient extends EventEmitter {
* Back up session keys to the homeserver.
* @param {string} roomId ID of the room that the keys are for Optional.
* @param {string} sessionId ID of the session that the keys are for Optional.
* @param {integer} version backup version Optional.
* @param {number} version backup version Optional.
* @param {object} data Object keys to send
* @return {Promise} a promise that will resolve when the keys
* are uploaded
@@ -2308,7 +2379,7 @@ export class MatrixClient extends EventEmitter {
throw new Error("End-to-end encryption disabled");
}
await this.crypto._backupManager.scheduleAllGroupSessionsForBackup();
await this.crypto.backupManager.scheduleAllGroupSessionsForBackup();
}
/**
@@ -2321,7 +2392,7 @@ export class MatrixClient extends EventEmitter {
throw new Error("End-to-end encryption disabled");
}
return this.crypto._backupManager.flagAllGroupSessionsForBackup();
return this.crypto.backupManager.flagAllGroupSessionsForBackup();
}
public isValidRecoveryKey(recoveryKey: string): boolean {
@@ -2343,7 +2414,7 @@ export class MatrixClient extends EventEmitter {
* @param {object} backupInfo Backup metadata from `checkKeyBackup`
* @return {Promise<Uint8Array>} key backup key
*/
public keyBackupKeyFromPassword(password: string, backupInfo: IKeyBackupVersion): Promise<Uint8Array> {
public keyBackupKeyFromPassword(password: string, backupInfo: IKeyBackupInfo): Promise<Uint8Array> {
return keyFromAuthData(backupInfo.auth_data, password);
}
@@ -2378,7 +2449,7 @@ export class MatrixClient extends EventEmitter {
password: string,
targetRoomId: string,
targetSessionId: string,
backupInfo: IKeyBackupVersion,
backupInfo: IKeyBackupInfo,
opts: IKeyBackupRestoreOpts,
): Promise<IKeyBackupRestoreResult> {
const privKey = await keyFromAuthData(backupInfo.auth_data, password);
@@ -2402,7 +2473,7 @@ export class MatrixClient extends EventEmitter {
*/
// TODO: Types
public async restoreKeyBackupWithSecretStorage(
backupInfo: IKeyBackupVersion,
backupInfo: IKeyBackupInfo,
targetRoomId?: string,
targetSessionId?: string,
opts?: IKeyBackupRestoreOpts,
@@ -2442,36 +2513,32 @@ export class MatrixClient extends EventEmitter {
recoveryKey: string,
targetRoomId: string,
targetSessionId: string,
backupInfo: IKeyBackupVersion,
backupInfo: IKeyBackupInfo,
opts: IKeyBackupRestoreOpts,
): Promise<IKeyBackupRestoreResult> {
const privKey = decodeRecoveryKey(recoveryKey);
return this.restoreKeyBackup(
privKey, targetRoomId, targetSessionId, backupInfo, opts,
);
return this.restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts);
}
// TODO: Types
public async restoreKeyBackupWithCache(
targetRoomId: string,
targetSessionId: string,
backupInfo: IKeyBackupVersion,
opts: IKeyBackupRestoreOpts,
backupInfo: IKeyBackupInfo,
opts?: IKeyBackupRestoreOpts,
): Promise<IKeyBackupRestoreResult> {
const privKey = await this.crypto.getSessionBackupPrivateKey();
if (!privKey) {
throw new Error("Couldn't get key");
}
return this.restoreKeyBackup(
privKey, targetRoomId, targetSessionId, backupInfo, opts,
);
return this.restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts);
}
private async restoreKeyBackup(
privKey: Uint8Array,
privKey: ArrayLike<number>,
targetRoomId: string,
targetSessionId: string,
backupInfo: IKeyBackupVersion,
backupInfo: IKeyBackupInfo,
opts?: IKeyBackupRestoreOpts,
): Promise<IKeyBackupRestoreResult> {
const cacheCompleteCallback = opts?.cacheCompleteCallback;
@@ -2604,7 +2671,7 @@ export class MatrixClient extends EventEmitter {
}
// XXX: Private member access
const alg = this.crypto._getRoomDecryptor(roomId, roomEncryption.algorithm);
const alg = this.crypto.getRoomDecryptor(roomId, roomEncryption.algorithm);
if (alg.sendSharedHistoryInboundSessions) {
await alg.sendSharedHistoryInboundSessions(devicesByUser);
} else {
@@ -2722,7 +2789,7 @@ export class MatrixClient extends EventEmitter {
* @return {Promise} Resolves: TODO
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public setAccountData(eventType: string, content: any, callback?: Callback): Promise<void> {
public setAccountData(eventType: EventType | string, content: any, callback?: Callback): Promise<void> {
const path = utils.encodeUri("/user/$userId/account_data/$type", {
$userId: this.credentials.userId,
$type: eventType,
@@ -2741,7 +2808,7 @@ export class MatrixClient extends EventEmitter {
* @param {string} eventType The event type
* @return {?object} The contents of the given account data event
*/
public getAccountData(eventType: string): any {
public getAccountData(eventType: string): MatrixEvent {
return this.store.getAccountData(eventType);
}
@@ -3034,7 +3101,7 @@ export class MatrixClient extends EventEmitter {
if (event && event.getType() === "m.room.power_levels") {
// take a copy of the content to ensure we don't corrupt
// existing client state with a failed power level change
content = utils.deepCopy(event.getContent());
content = utils.deepCopy(event.getContent()) as typeof content;
}
content.users[userId] = powerLevel;
const path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", {
@@ -3536,7 +3603,7 @@ export class MatrixClient extends EventEmitter {
const room = this.getRoom(event.getRoomId());
if (room) {
room._addLocalEchoReceipt(this.credentials.userId, event, receiptType);
room.addLocalEchoReceipt(this.credentials.userId, event, receiptType);
}
return promise;
}
@@ -3606,7 +3673,7 @@ export class MatrixClient extends EventEmitter {
throw new Error(`Cannot set read receipt to a pending event (${rrEventId})`);
}
if (room) {
room._addLocalEchoReceipt(this.credentials.userId, rrEvent, "m.read");
room.addLocalEchoReceipt(this.credentials.userId, rrEvent, "m.read");
}
}
@@ -3771,10 +3838,10 @@ export class MatrixClient extends EventEmitter {
* @param {string} userId
* @param {module:client.callback} callback Optional.
* @param {string} reason Optional.
* @return {Promise} Resolves: TODO
* @return {Promise} Resolves: {} an empty object.
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public invite(roomId: string, userId: string, callback?: Callback, reason?: string): Promise<void> {
public invite(roomId: string, userId: string, callback?: Callback, reason?: string): Promise<{}> {
return this.membershipChange(roomId, userId, "invite", reason, callback);
}
@@ -3783,10 +3850,10 @@ export class MatrixClient extends EventEmitter {
* @param {string} roomId The room to invite the user to.
* @param {string} email The email address to invite.
* @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: TODO
* @return {Promise} Resolves: {} an empty object.
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public inviteByEmail(roomId: string, email: string, callback?: Callback): Promise<void> {
public inviteByEmail(roomId: string, email: string, callback?: Callback): Promise<{}> {
return this.inviteByThreePid(roomId, "email", email, callback);
}
@@ -3796,10 +3863,10 @@ export class MatrixClient extends EventEmitter {
* @param {string} medium The medium to invite the user e.g. "email".
* @param {string} address The address for the specified medium.
* @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: TODO
* @return {Promise} Resolves: {} an empty object.
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public async inviteByThreePid(roomId: string, medium: string, address: string, callback?: Callback): Promise<void> {
public async inviteByThreePid(roomId: string, medium: string, address: string, callback?: Callback): Promise<{}> {
const path = utils.encodeUri(
"/rooms/$roomId/invite",
{ $roomId: roomId },
@@ -3835,10 +3902,10 @@ export class MatrixClient extends EventEmitter {
/**
* @param {string} roomId
* @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: TODO
* @return {Promise} Resolves: {} an empty object.
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public leave(roomId: string, callback?: Callback): Promise<void> {
public leave(roomId: string, callback?: Callback): Promise<{}> {
return this.membershipChange(roomId, undefined, "leave", undefined, callback);
}
@@ -3906,10 +3973,10 @@ export class MatrixClient extends EventEmitter {
* @param {boolean} deleteRoom True to delete the room from the store on success.
* Default: true.
* @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: TODO
* @return {Promise} Resolves: {} an empty object.
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public forget(roomId: string, deleteRoom?: boolean, callback?: Callback): Promise<void> {
public forget(roomId: string, deleteRoom?: boolean, callback?: Callback): Promise<{}> {
if (deleteRoom === undefined) {
deleteRoom = true;
}
@@ -3954,10 +4021,10 @@ export class MatrixClient extends EventEmitter {
* @param {string} userId
* @param {string} reason Optional.
* @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: TODO
* @return {Promise} Resolves: {} an empty object.
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public kick(roomId: string, userId: string, reason?: string, callback?: Callback): Promise<void> {
public kick(roomId: string, userId: string, reason?: string, callback?: Callback): Promise<{}> {
return this.setMembershipState(roomId, userId, "leave", reason, callback);
}
@@ -4001,7 +4068,7 @@ export class MatrixClient extends EventEmitter {
membership: string,
reason?: string,
callback?: Callback,
): Promise<void> {
): Promise<{}> {
if (utils.isFunction(reason)) {
callback = reason as any as Callback; // legacy
reason = undefined;
@@ -4222,7 +4289,7 @@ export class MatrixClient extends EventEmitter {
// reduce the required number of events appropriately
limit = limit - numAdded;
const prom = new Promise((resolve, reject) => {
const prom = new Promise<Room>((resolve, reject) => {
// wait for a time before doing this request
// (which may be 0 in order not to special case the code paths)
sleep(timeToWaitMs).then(() => {
@@ -4288,7 +4355,7 @@ export class MatrixClient extends EventEmitter {
* {@link module:models/event-timeline~EventTimeline} including the given
* event
*/
public getEventTimeline(timelineSet: EventTimelineSet, eventId: string): EventTimeline {
public getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<EventTimeline> {
// don't allow any timeline support unless it's been enabled.
if (!this.timelineSupport) {
throw new Error("timeline support is disabled. Set the 'timelineSupport'" +
@@ -4397,7 +4464,7 @@ export class MatrixClient extends EventEmitter {
// XXX: it's horrific that /messages' filter parameter doesn't match
// /sync's one - see https://matrix.org/jira/browse/SPEC-451
filter = filter || {};
Object.assign(filter, timelineFilter.getRoomTimelineFilterComponent());
Object.assign(filter, timelineFilter.getRoomTimelineFilterComponent()?.toJSON());
}
if (filter) {
params.filter = JSON.stringify(filter);
@@ -4440,7 +4507,7 @@ export class MatrixClient extends EventEmitter {
return Promise.resolve(false);
}
const pendingRequest = eventTimeline._paginationRequests[dir];
const pendingRequest = eventTimeline.paginationRequests[dir];
if (pendingRequest) {
// already a request in progress - return the existing promise
@@ -4489,9 +4556,9 @@ export class MatrixClient extends EventEmitter {
}
return res.next_token ? true : false;
}).finally(() => {
eventTimeline._paginationRequests[dir] = null;
eventTimeline.paginationRequests[dir] = null;
});
eventTimeline._paginationRequests[dir] = promise;
eventTimeline.paginationRequests[dir] = promise;
} else {
const room = this.getRoom(eventTimeline.getRoomId());
if (!room) {
@@ -4523,9 +4590,9 @@ export class MatrixClient extends EventEmitter {
}
return res.end != res.start;
}).finally(() => {
eventTimeline._paginationRequests[dir] = null;
eventTimeline.paginationRequests[dir] = null;
});
eventTimeline._paginationRequests[dir] = promise;
eventTimeline.paginationRequests[dir] = promise;
}
return promise;
@@ -5577,15 +5644,21 @@ export class MatrixClient extends EventEmitter {
/**
* Query the server to see if it is forcing encryption to be enabled for
* a given room preset, based on the /versions response.
* @param {string} presetName The name of the preset to check.
* @param {Preset} presetName The name of the preset to check.
* @returns {Promise<boolean>} true if the server is forcing encryption
* for the preset.
*/
public async doesServerForceEncryptionForPreset(presetName: string): Promise<boolean> {
public async doesServerForceEncryptionForPreset(presetName: Preset): Promise<boolean> {
const response = await this.getVersions();
if (!response) return false;
const unstableFeatures = response["unstable_features"];
return unstableFeatures && !!unstableFeatures[`io.element.e2ee_forced.${presetName}`];
// The preset name in the versions response will be without the _chat suffix.
const versionsPresetName = presetName.includes("_chat")
? presetName.substring(0, presetName.indexOf("_chat"))
: presetName;
return unstableFeatures && !!unstableFeatures[`io.element.e2ee_forced.${versionsPresetName}`];
}
/**
@@ -5673,7 +5746,7 @@ export class MatrixClient extends EventEmitter {
*/
public getCrossSigningCacheCallbacks(): any { // TODO: Types
// XXX: Private member access
return this.crypto?._crossSigningInfo.getCacheCallbacks();
return this.crypto?.crossSigningInfo.getCacheCallbacks();
}
/**
@@ -5693,13 +5766,13 @@ export class MatrixClient extends EventEmitter {
* @param {boolean} options.isRetry True if this is a retry (enables more logging)
* @param {boolean} options.emit Emits "event.decrypted" if set to true
*/
public decryptEventIfNeeded(event: MatrixEvent, options?: { emit: boolean, isRetry: boolean }): Promise<void> {
public decryptEventIfNeeded(event: MatrixEvent, options?: IDecryptOptions): Promise<void> {
if (event.shouldAttemptDecryption()) {
event.attemptDecryption(this.crypto, options);
}
if (event.isBeingDecrypted()) {
return event._decryptionPromise;
return event.getDecryptionPromise();
} else {
return Promise.resolve();
}
@@ -7052,11 +7125,11 @@ export class MatrixClient extends EventEmitter {
* @return {Promise} Resolves: result object. Rejects: with
* an error response ({@link module:http-api.MatrixError}).
*/
public uploadKeysRequest(content: any, opts?: any, callback?: Callback): Promise<any> { // TODO: Types
public uploadKeysRequest(content: any, opts?: any, callback?: Callback): Promise<IKeysUploadResponse> {
return this.http.authedRequest(callback, "POST", "/keys/upload", undefined, content);
}
public uploadKeySignatures(content: any): Promise<any> { // TODO: Types
public uploadKeySignatures(content: KeySignatures): Promise<IUploadKeySignaturesResponse> {
return this.http.authedRequest(
undefined, "POST", '/keys/signatures/upload', undefined,
content, {
@@ -7155,7 +7228,7 @@ export class MatrixClient extends EventEmitter {
return this.http.authedRequest(undefined, "GET", path, qps, undefined);
}
public uploadDeviceSigningKeys(auth: any, keys: any): Promise<any> { // TODO: Lots of types
public uploadDeviceSigningKeys(auth: any, keys: CrossSigningKeys): Promise<{}> { // TODO: types
const data = Object.assign({}, keys);
if (auth) Object.assign(data, { auth });
return this.http.authedRequest(
@@ -7567,7 +7640,11 @@ export class MatrixClient extends EventEmitter {
* supplied.
* @return {Promise} Resolves to the result object
*/
public sendToDevice(eventType: string, contentMap: any, txnId?: string): Promise<any> { // TODO: Types
public sendToDevice(
eventType: string,
contentMap: { [userId: string]: { [deviceId: string]: Record<string, any> } },
txnId?: string,
): Promise<{}> {
const path = utils.encodeUri("/sendToDevice/$eventType/$txnId", {
$eventType: eventType,
$txnId: txnId ? txnId : this.makeTxnId(),
@@ -7709,6 +7786,73 @@ export class MatrixClient extends EventEmitter {
});
}
/**
* Creates a new file tree space with the given name. The client will pick
* defaults for how it expects to be able to support the remaining API offered
* by the returned class.
*
* Note that this is UNSTABLE and may have breaking changes without notice.
* @param {string} name The name of the tree space.
* @returns {Promise<MSC3089TreeSpace>} Resolves to the created space.
*/
public async unstableCreateFileTree(name: string): Promise<MSC3089TreeSpace> {
const { room_id: roomId } = await this.createRoom({
name: name,
preset: Preset.PrivateChat,
power_level_content_override: {
...DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
users: {
[this.getUserId()]: 100,
},
},
creation_content: {
[RoomCreateTypeField]: RoomType.Space,
},
initial_state: [
{
type: UNSTABLE_MSC3088_PURPOSE.name,
state_key: UNSTABLE_MSC3089_TREE_SUBTYPE.name,
content: {
[UNSTABLE_MSC3088_ENABLED.name]: true,
},
},
{
type: EventType.RoomEncryption,
state_key: "",
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
},
},
],
});
return new MSC3089TreeSpace(this, roomId);
}
/**
* Gets a reference to a tree space, if the room ID given is a tree space. If the room
* does not appear to be a tree space then null is returned.
*
* Note that this is UNSTABLE and may have breaking changes without notice.
* @param {string} roomId The room ID to get a tree space reference for.
* @returns {MSC3089TreeSpace} The tree space, or null if not a tree space.
*/
public unstableGetFileTreeSpace(roomId: string): MSC3089TreeSpace {
const room = this.getRoom(roomId);
if (!room) return null;
const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, "");
const purposeEvent = room.currentState.getStateEvents(
UNSTABLE_MSC3088_PURPOSE.name,
UNSTABLE_MSC3089_TREE_SUBTYPE.name);
if (!createEvent) throw new Error("Expected single room create event");
if (!purposeEvent?.getContent()?.[UNSTABLE_MSC3088_ENABLED.name]) return null;
if (createEvent.getContent()?.[RoomCreateTypeField] !== RoomType.Space) return null;
return new MSC3089TreeSpace(this, roomId);
}
// TODO: Remove this warning, alongside the functions
// See https://github.com/vector-im/element-web/issues/17532
// ======================================================

View File

@@ -1,6 +1,6 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 - 2021 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.
@@ -23,7 +23,7 @@ limitations under the License.
* @param {string} htmlBody the HTML representation of the message
* @returns {{msgtype: string, format: string, body: string, formatted_body: string}}
*/
export function makeHtmlMessage(body, htmlBody) {
export function makeHtmlMessage(body: string, htmlBody: string) {
return {
msgtype: "m.text",
format: "org.matrix.custom.html",
@@ -38,7 +38,7 @@ export function makeHtmlMessage(body, htmlBody) {
* @param {string} htmlBody the HTML representation of the notice
* @returns {{msgtype: string, format: string, body: string, formatted_body: string}}
*/
export function makeHtmlNotice(body, htmlBody) {
export function makeHtmlNotice(body: string, htmlBody: string) {
return {
msgtype: "m.notice",
format: "org.matrix.custom.html",
@@ -53,7 +53,7 @@ export function makeHtmlNotice(body, htmlBody) {
* @param {string} htmlBody the HTML representation of the emote
* @returns {{msgtype: string, format: string, body: string, formatted_body: string}}
*/
export function makeHtmlEmote(body, htmlBody) {
export function makeHtmlEmote(body: string, htmlBody: string) {
return {
msgtype: "m.emote",
format: "org.matrix.custom.html",
@@ -67,7 +67,7 @@ export function makeHtmlEmote(body, htmlBody) {
* @param {string} body the plaintext body of the emote
* @returns {{msgtype: string, body: string}}
*/
export function makeTextMessage(body) {
export function makeTextMessage(body: string) {
return {
msgtype: "m.text",
body: body,
@@ -79,7 +79,7 @@ export function makeTextMessage(body) {
* @param {string} body the plaintext body of the notice
* @returns {{msgtype: string, body: string}}
*/
export function makeNotice(body) {
export function makeNotice(body: string) {
return {
msgtype: "m.notice",
body: body,
@@ -91,7 +91,7 @@ export function makeNotice(body) {
* @param {string} body the plaintext body of the emote
* @returns {{msgtype: string, body: string}}
*/
export function makeEmoteMessage(body) {
export function makeEmoteMessage(body: string) {
return {
msgtype: "m.emote",
body: body,

View File

@@ -1,6 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2015 - 2021 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.
@@ -34,8 +33,14 @@ import * as utils from "./utils";
* for such URLs.
* @return {string} The complete URL to the content.
*/
export function getHttpUriForMxc(baseUrl, mxc, width, height,
resizeMethod, allowDirectLinks) {
export function getHttpUriForMxc(
baseUrl: string,
mxc: string,
width: number,
height: number,
resizeMethod: string,
allowDirectLinks = false,
): string {
if (typeof mxc !== "string" || !mxc) {
return '';
}
@@ -51,13 +56,13 @@ export function getHttpUriForMxc(baseUrl, mxc, width, height,
const params = {};
if (width) {
params.width = Math.round(width);
params["width"] = Math.round(width);
}
if (height) {
params.height = Math.round(height);
params["height"] = Math.round(height);
}
if (resizeMethod) {
params.method = resizeMethod;
params["method"] = resizeMethod;
}
if (Object.keys(params).length > 0) {
// these are thumbnailing params so they probably want the
@@ -71,7 +76,7 @@ export function getHttpUriForMxc(baseUrl, mxc, width, height,
fragment = serverAndMediaId.substr(fragmentOffset);
serverAndMediaId = serverAndMediaId.substr(0, fragmentOffset);
}
return baseUrl + prefix + serverAndMediaId +
(Object.keys(params).length === 0 ? "" :
("?" + utils.encodeParams(params))) + fragment;
const urlParams = (Object.keys(params).length === 0 ? "" : ("?" + utils.encodeParams(params)));
return baseUrl + prefix + serverAndMediaId + urlParams + fragment;
}

View File

@@ -1,6 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019 - 2021 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.
@@ -20,22 +19,44 @@ limitations under the License.
* @module crypto/CrossSigning
*/
import { decodeBase64, encodeBase64, pkSign, pkVerify } from './olmlib';
import { EventEmitter } from 'events';
import { decodeBase64, encodeBase64, pkSign, pkVerify } from './olmlib';
import { logger } from '../logger';
import { IndexedDBCryptoStore } from '../crypto/store/indexeddb-crypto-store';
import { decryptAES, encryptAES } from './aes';
import { PkSigning } from "@matrix-org/olm";
import { DeviceInfo } from "./deviceinfo";
import { SecretStorage } from "./SecretStorage";
import { CryptoStore, ICrossSigningKey, ISignedKey, MatrixClient } from "../client";
import { OlmDevice } from "./OlmDevice";
import { ICryptoCallbacks } from "../matrix";
import { ISignatures } from "../@types/signed";
const KEY_REQUEST_TIMEOUT_MS = 1000 * 60;
function publicKeyFromKeyInfo(keyInfo) {
function publicKeyFromKeyInfo(keyInfo: ICrossSigningKey): string {
// `keys` is an object with { [`ed25519:${pubKey}`]: pubKey }
// We assume only a single key, and we want the bare form without type
// prefix, so we select the values.
return Object.values(keyInfo.keys)[0];
}
export interface ICacheCallbacks {
getCrossSigningKeyCache?(type: string, expectedPublicKey?: string): Promise<Uint8Array>;
storeCrossSigningKeyCache?(type: string, key: Uint8Array): Promise<void>;
}
export class CrossSigningInfo extends EventEmitter {
public keys: Record<string, ICrossSigningKey> = {};
public firstUse = true;
// This tracks whether we've ever verified this user with any identity.
// When you verify a user, any devices online at the time that receive
// the verifying signature via the homeserver will latch this to true
// and can use it in the future to detect cases where the user has
// become unverified later for any reason.
private crossSigningVerifiedBefore = false;
/**
* Information about a user's cross-signing keys
*
@@ -46,27 +67,15 @@ export class CrossSigningInfo extends EventEmitter {
* Requires getCrossSigningKey and saveCrossSigningKeys
* @param {object} cacheCallbacks Callbacks used to interact with the cache
*/
constructor(userId, callbacks, cacheCallbacks) {
constructor(
public readonly userId: string,
private callbacks: ICryptoCallbacks = {},
private cacheCallbacks: ICacheCallbacks = {},
) {
super();
// you can't change the userId
Object.defineProperty(this, 'userId', {
enumerable: true,
value: userId,
});
this._callbacks = callbacks || {};
this._cacheCallbacks = cacheCallbacks || {};
this.keys = {};
this.firstUse = true;
// This tracks whether we've ever verified this user with any identity.
// When you verify a user, any devices online at the time that receive
// the verifying signature via the homeserver will latch this to true
// and can use it in the future to detect cases where the user has
// become unverifed later for any reason.
this.crossSigningVerifiedBefore = false;
}
static fromStorage(obj, userId) {
public static fromStorage(obj: object, userId: string): CrossSigningInfo {
const res = new CrossSigningInfo(userId);
for (const prop in obj) {
if (obj.hasOwnProperty(prop)) {
@@ -76,7 +85,7 @@ export class CrossSigningInfo extends EventEmitter {
return res;
}
toStorage() {
public toStorage(): object {
return {
keys: this.keys,
firstUse: this.firstUse,
@@ -92,10 +101,10 @@ export class CrossSigningInfo extends EventEmitter {
* the stored public key for the given key type.
* @returns {Array} An array with [ public key, Olm.PkSigning ]
*/
async getCrossSigningKey(type, expectedPubkey) {
public async getCrossSigningKey(type: string, expectedPubkey?: string): Promise<[string, PkSigning]> {
const shouldCache = ["master", "self_signing", "user_signing"].indexOf(type) >= 0;
if (!this._callbacks.getCrossSigningKey) {
if (!this.callbacks.getCrossSigningKey) {
throw new Error("No getCrossSigningKey callback supplied");
}
@@ -103,7 +112,7 @@ export class CrossSigningInfo extends EventEmitter {
expectedPubkey = this.getId(type);
}
function validateKey(key) {
function validateKey(key: Uint8Array): [string, PkSigning] {
if (!key) return;
const signing = new global.Olm.PkSigning();
const gotPubkey = signing.init_with_seed(key);
@@ -114,9 +123,8 @@ export class CrossSigningInfo extends EventEmitter {
}
let privkey;
if (this._cacheCallbacks.getCrossSigningKeyCache && shouldCache) {
privkey = await this._cacheCallbacks
.getCrossSigningKeyCache(type, expectedPubkey);
if (this.cacheCallbacks.getCrossSigningKeyCache && shouldCache) {
privkey = await this.cacheCallbacks.getCrossSigningKeyCache(type, expectedPubkey);
}
const cacheresult = validateKey(privkey);
@@ -124,11 +132,11 @@ export class CrossSigningInfo extends EventEmitter {
return cacheresult;
}
privkey = await this._callbacks.getCrossSigningKey(type, expectedPubkey);
privkey = await this.callbacks.getCrossSigningKey(type, expectedPubkey);
const result = validateKey(privkey);
if (result) {
if (this._cacheCallbacks.storeCrossSigningKeyCache && shouldCache) {
await this._cacheCallbacks.storeCrossSigningKeyCache(type, privkey);
if (this.cacheCallbacks.storeCrossSigningKeyCache && shouldCache) {
await this.cacheCallbacks.storeCrossSigningKeyCache(type, privkey);
}
return result;
}
@@ -156,10 +164,9 @@ export class CrossSigningInfo extends EventEmitter {
* with, or null if it is not present or not encrypted with a trusted
* key
*/
async isStoredInSecretStorage(secretStorage) {
public async isStoredInSecretStorage(secretStorage: SecretStorage): Promise<Record<string, object>> {
// check what SSSS keys have encrypted the master key (if any)
const stored =
await secretStorage.isStored("m.cross_signing.master", false) || {};
const stored = await secretStorage.isStored("m.cross_signing.master", false) || {};
// then check which of those SSSS keys have also encrypted the SSK and USK
function intersect(s) {
for (const k of Object.keys(stored)) {
@@ -169,9 +176,7 @@ export class CrossSigningInfo extends EventEmitter {
}
}
for (const type of ["self_signing", "user_signing"]) {
intersect(
await secretStorage.isStored(`m.cross_signing.${type}`, false) || {},
);
intersect(await secretStorage.isStored(`m.cross_signing.${type}`, false) || {});
}
return Object.keys(stored).length ? stored : null;
}
@@ -184,7 +189,10 @@ export class CrossSigningInfo extends EventEmitter {
* @param {Map} keys The keys to store
* @param {SecretStorage} secretStorage The secret store using account data
*/
static async storeInSecretStorage(keys, secretStorage) {
public static async storeInSecretStorage(
keys: Map<string, Uint8Array>,
secretStorage: SecretStorage,
): Promise<void> {
for (const [type, privateKey] of keys) {
const encodedKey = encodeBase64(privateKey);
await secretStorage.store(`m.cross_signing.${type}`, encodedKey);
@@ -200,7 +208,7 @@ export class CrossSigningInfo extends EventEmitter {
* @param {SecretStorage} secretStorage The secret store using account data
* @return {Uint8Array} The private key
*/
static async getFromSecretStorage(type, secretStorage) {
public static async getFromSecretStorage(type: string, secretStorage: SecretStorage): Promise<Uint8Array> {
const encodedKey = await secretStorage.get(`m.cross_signing.${type}`);
if (!encodedKey) {
return null;
@@ -215,8 +223,8 @@ export class CrossSigningInfo extends EventEmitter {
* "self_signing", or "user_signing". Optional, will check all by default.
* @returns {boolean} True if all keys are stored in the local cache.
*/
async isStoredInKeyCache(type) {
const cacheCallbacks = this._cacheCallbacks;
public async isStoredInKeyCache(type?: string): Promise<boolean> {
const cacheCallbacks = this.cacheCallbacks;
if (!cacheCallbacks) return false;
const types = type ? [type] : ["master", "self_signing", "user_signing"];
for (const t of types) {
@@ -232,9 +240,9 @@ export class CrossSigningInfo extends EventEmitter {
*
* @returns {Map} A map from key type (string) to private key (Uint8Array)
*/
async getCrossSigningKeysFromCache() {
public async getCrossSigningKeysFromCache(): Promise<Map<string, Uint8Array>> {
const keys = new Map();
const cacheCallbacks = this._cacheCallbacks;
const cacheCallbacks = this.cacheCallbacks;
if (!cacheCallbacks) return keys;
for (const type of ["master", "self_signing", "user_signing"]) {
const privKey = await cacheCallbacks.getCrossSigningKeyCache(type);
@@ -255,8 +263,7 @@ export class CrossSigningInfo extends EventEmitter {
*
* @return {string} the ID
*/
getId(type) {
type = type || "master";
public getId(type = "master"): string {
if (!this.keys[type]) return null;
const keyInfo = this.keys[type];
return publicKeyFromKeyInfo(keyInfo);
@@ -269,8 +276,8 @@ export class CrossSigningInfo extends EventEmitter {
*
* @param {CrossSigningLevel} level The key types to reset
*/
async resetKeys(level) {
if (!this._callbacks.saveCrossSigningKeys) {
public async resetKeys(level?: CrossSigningLevel): Promise<void> {
if (!this.callbacks.saveCrossSigningKeys) {
throw new Error("No saveCrossSigningKeys callback supplied");
}
@@ -285,12 +292,12 @@ export class CrossSigningInfo extends EventEmitter {
CrossSigningLevel.USER_SIGNING |
CrossSigningLevel.SELF_SIGNING
);
} else if (level === 0) {
} else if (level === 0 as CrossSigningLevel) {
return;
}
const privateKeys = {};
const keys = {};
const privateKeys: Record<string, Uint8Array> = {};
const keys: Record<string, any> = {}; // TODO types
let masterSigning;
let masterPub;
@@ -347,7 +354,7 @@ export class CrossSigningInfo extends EventEmitter {
}
Object.assign(this.keys, keys);
this._callbacks.saveCrossSigningKeys(privateKeys);
this.callbacks.saveCrossSigningKeys(privateKeys);
} finally {
if (masterSigning) {
masterSigning.free();
@@ -358,12 +365,12 @@ export class CrossSigningInfo extends EventEmitter {
/**
* unsets the keys, used when another session has reset the keys, to disable cross-signing
*/
clearKeys() {
public clearKeys(): void {
this.keys = {};
}
setKeys(keys) {
const signingKeys = {};
public setKeys(keys: Record<string, ICrossSigningKey>): void {
const signingKeys: Record<string, ICrossSigningKey> = {};
if (keys.master) {
if (keys.master.user_id !== this.userId) {
const error = "Mismatched user ID " + keys.master.user_id +
@@ -434,7 +441,7 @@ export class CrossSigningInfo extends EventEmitter {
}
}
updateCrossSigningVerifiedBefore(isCrossSigningVerified) {
public updateCrossSigningVerifiedBefore(isCrossSigningVerified: boolean): void {
// It is critical that this value latches forward from false to true but
// never back to false to avoid a downgrade attack.
if (!this.crossSigningVerifiedBefore && isCrossSigningVerified) {
@@ -442,7 +449,7 @@ export class CrossSigningInfo extends EventEmitter {
}
}
async signObject(data, type) {
public async signObject<T extends object>(data: T, type: string): Promise<T & { signatures: ISignatures }> {
if (!this.keys[type]) {
throw new Error(
"Attempted to sign with " + type + " key but no such key present",
@@ -451,13 +458,13 @@ export class CrossSigningInfo extends EventEmitter {
const [pubkey, signing] = await this.getCrossSigningKey(type);
try {
pkSign(data, signing, this.userId, pubkey);
return data;
return data as T & { signatures: ISignatures };
} finally {
signing.free();
}
}
async signUser(key) {
public async signUser(key: CrossSigningInfo): Promise<ICrossSigningKey> {
if (!this.keys.user_signing) {
logger.info("No user signing key: not signing user");
return;
@@ -465,7 +472,7 @@ export class CrossSigningInfo extends EventEmitter {
return this.signObject(key.keys.master, "user_signing");
}
async signDevice(userId, device) {
public async signDevice(userId: string, device: DeviceInfo): Promise<ISignedKey> {
if (userId !== this.userId) {
throw new Error(
`Trying to sign ${userId}'s device; can only sign our own device`,
@@ -475,7 +482,7 @@ export class CrossSigningInfo extends EventEmitter {
logger.info("No self signing key: not signing device");
return;
}
return this.signObject(
return this.signObject<Omit<ISignedKey, "signatures">>(
{
algorithms: device.algorithms,
keys: device.keys,
@@ -492,7 +499,7 @@ export class CrossSigningInfo extends EventEmitter {
*
* @returns {UserTrustLevel}
*/
checkUserTrust(userCrossSigning) {
public checkUserTrust(userCrossSigning: CrossSigningInfo): UserTrustLevel {
// if we're checking our own key, then it's trusted if the master key
// and self-signing key match
if (this.userId === userCrossSigning.userId
@@ -530,12 +537,17 @@ export class CrossSigningInfo extends EventEmitter {
*
* @param {CrossSigningInfo} userCrossSigning Cross signing info for user
* @param {module:crypto/deviceinfo} device The device to check
* @param {bool} localTrust Whether the device is trusted locally
* @param {bool} trustCrossSignedDevices Whether we trust cross signed devices
* @param {boolean} localTrust Whether the device is trusted locally
* @param {boolean} trustCrossSignedDevices Whether we trust cross signed devices
*
* @returns {DeviceTrustLevel}
*/
checkDeviceTrust(userCrossSigning, device, localTrust, trustCrossSignedDevices) {
public checkDeviceTrust(
userCrossSigning: CrossSigningInfo,
device: DeviceInfo,
localTrust: boolean,
trustCrossSignedDevices: boolean,
): DeviceTrustLevel {
const userTrust = this.checkUserTrust(userCrossSigning);
const userSSK = userCrossSigning.keys.self_signing;
@@ -552,29 +564,23 @@ export class CrossSigningInfo extends EventEmitter {
// if we can verify the user's SSK from their master key...
pkVerify(userSSK, userCrossSigning.getId(), userCrossSigning.userId);
// ...and this device's key from their SSK...
pkVerify(
deviceObj, publicKeyFromKeyInfo(userSSK), userCrossSigning.userId,
);
pkVerify(deviceObj, publicKeyFromKeyInfo(userSSK), userCrossSigning.userId);
// ...then we trust this device as much as far as we trust the user
return DeviceTrustLevel.fromUserTrustLevel(
userTrust, localTrust, trustCrossSignedDevices,
);
return DeviceTrustLevel.fromUserTrustLevel(userTrust, localTrust, trustCrossSignedDevices);
} catch (e) {
return new DeviceTrustLevel(
false, false, localTrust, trustCrossSignedDevices,
);
return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices);
}
}
/**
* @returns {object} Cache callbacks
*/
getCacheCallbacks() {
return this._cacheCallbacks;
public getCacheCallbacks(): ICacheCallbacks {
return this.cacheCallbacks;
}
}
function deviceToObject(device, userId) {
function deviceToObject(device: DeviceInfo, userId: string) {
return {
algorithms: device.algorithms,
keys: device.keys,
@@ -584,49 +590,49 @@ function deviceToObject(device, userId) {
};
}
export const CrossSigningLevel = {
MASTER: 4,
USER_SIGNING: 2,
SELF_SIGNING: 1,
};
export enum CrossSigningLevel {
MASTER = 4,
USER_SIGNING = 2,
SELF_SIGNING = 1,
}
/**
* Represents the ways in which we trust a user
*/
export class UserTrustLevel {
constructor(crossSigningVerified, crossSigningVerifiedBefore, tofu) {
this._crossSigningVerified = crossSigningVerified;
this._crossSigningVerifiedBefore = crossSigningVerifiedBefore;
this._tofu = tofu;
}
constructor(
private readonly crossSigningVerified: boolean,
private readonly crossSigningVerifiedBefore: boolean,
private readonly tofu: boolean,
) {}
/**
* @returns {bool} true if this user is verified via any means
* @returns {boolean} true if this user is verified via any means
*/
isVerified() {
public isVerified(): boolean {
return this.isCrossSigningVerified();
}
/**
* @returns {bool} true if this user is verified via cross signing
* @returns {boolean} true if this user is verified via cross signing
*/
isCrossSigningVerified() {
return this._crossSigningVerified;
public isCrossSigningVerified(): boolean {
return this.crossSigningVerified;
}
/**
* @returns {bool} true if we ever verified this user before (at least for
* @returns {boolean} true if we ever verified this user before (at least for
* the history of verifications observed by this device).
*/
wasCrossSigningVerified() {
return this._crossSigningVerifiedBefore;
public wasCrossSigningVerified(): boolean {
return this.crossSigningVerifiedBefore;
}
/**
* @returns {bool} true if this user's key is trusted on first use
* @returns {boolean} true if this user's key is trusted on first use
*/
isTofu() {
return this._tofu;
public isTofu(): boolean {
return this.tofu;
}
}
@@ -634,58 +640,62 @@ export class UserTrustLevel {
* Represents the ways in which we trust a device
*/
export class DeviceTrustLevel {
constructor(crossSigningVerified, tofu, localVerified, trustCrossSignedDevices) {
this._crossSigningVerified = crossSigningVerified;
this._tofu = tofu;
this._localVerified = localVerified;
this._trustCrossSignedDevices = trustCrossSignedDevices;
}
constructor(
public readonly crossSigningVerified: boolean,
public readonly tofu: boolean,
private readonly localVerified: boolean,
private readonly trustCrossSignedDevices: boolean,
) {}
static fromUserTrustLevel(userTrustLevel, localVerified, trustCrossSignedDevices) {
public static fromUserTrustLevel(
userTrustLevel: UserTrustLevel,
localVerified: boolean,
trustCrossSignedDevices: boolean,
): DeviceTrustLevel {
return new DeviceTrustLevel(
userTrustLevel._crossSigningVerified,
userTrustLevel._tofu,
userTrustLevel.isCrossSigningVerified(),
userTrustLevel.isTofu(),
localVerified,
trustCrossSignedDevices,
);
}
/**
* @returns {bool} true if this device is verified via any means
* @returns {boolean} true if this device is verified via any means
*/
isVerified() {
public isVerified(): boolean {
return Boolean(this.isLocallyVerified() || (
this._trustCrossSignedDevices && this.isCrossSigningVerified()
this.trustCrossSignedDevices && this.isCrossSigningVerified()
));
}
/**
* @returns {bool} true if this device is verified via cross signing
* @returns {boolean} true if this device is verified via cross signing
*/
isCrossSigningVerified() {
return this._crossSigningVerified;
public isCrossSigningVerified(): boolean {
return this.crossSigningVerified;
}
/**
* @returns {bool} true if this device is verified locally
* @returns {boolean} true if this device is verified locally
*/
isLocallyVerified() {
return this._localVerified;
public isLocallyVerified(): boolean {
return this.localVerified;
}
/**
* @returns {bool} true if this device is trusted from a user's key
* @returns {boolean} true if this device is trusted from a user's key
* that is trusted on first use
*/
isTofu() {
return this._tofu;
public isTofu(): boolean {
return this.tofu;
}
}
export function createCryptoStoreCacheCallbacks(store, olmdevice) {
export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: OlmDevice): ICacheCallbacks {
return {
getCrossSigningKeyCache: async function(type, _expectedPublicKey) {
const key = await new Promise((resolve) => {
getCrossSigningKeyCache: async function(type: string, _expectedPublicKey: string): Promise<Uint8Array> {
const key = await new Promise<any>((resolve) => {
return store.doTxn(
'readonly',
[IndexedDBCryptoStore.STORE_ACCOUNT],
@@ -696,26 +706,26 @@ export function createCryptoStoreCacheCallbacks(store, olmdevice) {
});
if (key && key.ciphertext) {
const pickleKey = Buffer.from(olmdevice._pickleKey);
const pickleKey = Buffer.from(olmDevice._pickleKey);
const decrypted = await decryptAES(key, pickleKey, type);
return decodeBase64(decrypted);
} else {
return key;
}
},
storeCrossSigningKeyCache: async function(type, key) {
storeCrossSigningKeyCache: async function(type: string, key: Uint8Array): Promise<void> {
if (!(key instanceof Uint8Array)) {
throw new Error(
`storeCrossSigningKeyCache expects Uint8Array, got ${key}`,
);
}
const pickleKey = Buffer.from(olmdevice._pickleKey);
key = await encryptAES(encodeBase64(key), pickleKey, type);
const pickleKey = Buffer.from(olmDevice._pickleKey);
const encryptedKey = await encryptAES(encodeBase64(key), pickleKey, type);
return store.doTxn(
'readwrite',
[IndexedDBCryptoStore.STORE_ACCOUNT],
(txn) => {
store.storeSecretStorePrivateKey(txn, type, key);
store.storeSecretStorePrivateKey(txn, type, encryptedKey);
},
);
},
@@ -729,7 +739,7 @@ export function createCryptoStoreCacheCallbacks(store, olmdevice) {
* @param {string} userId The user ID being verified
* @param {string} deviceId The device ID being verified
*/
export async function requestKeysDuringVerification(baseApis, userId, deviceId) {
export async function requestKeysDuringVerification(baseApis: MatrixClient, userId: string, deviceId: string) {
// If this is a self-verification, ask the other party for keys
if (baseApis.getUserId() !== userId) {
return;
@@ -739,7 +749,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId)
// it. We return here in order to test.
return new Promise((resolve, reject) => {
const client = baseApis;
const original = client.crypto._crossSigningInfo;
const original = client.crypto.crossSigningInfo;
// We already have all of the infrastructure we need to validate and
// cache cross-signing keys, so instead of replicating that, here we set
@@ -748,8 +758,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId)
const crossSigning = new CrossSigningInfo(
original.userId,
{ getCrossSigningKey: async (type) => {
logger.debug("Cross-signing: requesting secret",
type, deviceId);
logger.debug("Cross-signing: requesting secret", type, deviceId);
const { promise } = client.requestSecret(
`m.cross_signing.${type}`, [deviceId],
);
@@ -757,7 +766,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId)
const decoded = decodeBase64(result);
return Uint8Array.from(decoded);
} },
original._cacheCallbacks,
original.getCacheCallbacks(),
);
crossSigning.keys = original.keys;
@@ -774,7 +783,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId)
});
// also request and cache the key backup key
const backupKeyPromise = new Promise(async resolve => {
const backupKeyPromise = (async () => {
const cachedKey = await client.crypto.getSessionBackupPrivateKey();
if (!cachedKey) {
logger.info("No cached backup key found. Requesting...");
@@ -791,14 +800,11 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId)
logger.info("Backup key stored. Starting backup restore...");
const backupInfo = await client.getKeyBackupVersion();
// no need to await for this - just let it go in the bg
client.restoreKeyBackupWithCache(
undefined, undefined, backupInfo,
).then(() => {
client.restoreKeyBackupWithCache(undefined, undefined, backupInfo).then(() => {
logger.info("Backup restored.");
});
}
resolve();
});
})();
// We call getCrossSigningKey() for its side-effects
return Promise.race([

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,24 @@
import { logger } from "../logger";
import { MatrixEvent } from "../models/event";
import { EventEmitter } from "events";
import { createCryptoStoreCacheCallbacks } from "./CrossSigning";
import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning";
import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store';
import { PREFIX_UNSTABLE } from "../http-api";
import { Crypto, IBootstrapCrossSigningOpts } from "./index";
import {
PREFIX_UNSTABLE,
} from "../http-api";
CrossSigningKeys,
ICrossSigningKey,
ICryptoCallbacks,
ISecretStorageKeyInfo,
ISignedKey,
KeySignatures,
} from "../matrix";
import { IKeyBackupInfo } from "./keybackup";
interface ICrossSigningKeys {
authUpload: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"];
keys: Record<string, ICrossSigningKey>;
}
/**
* Builds an EncryptionSetupOperation by calling any of the add.. methods.
@@ -17,18 +30,23 @@ import {
* more than once.
*/
export class EncryptionSetupBuilder {
public readonly accountDataClientAdapter: AccountDataClientAdapter;
public readonly crossSigningCallbacks: CrossSigningCallbacks;
public readonly ssssCryptoCallbacks: SSSSCryptoCallbacks;
private crossSigningKeys: ICrossSigningKeys = null;
private keySignatures: KeySignatures = null;
private keyBackupInfo: IKeyBackupInfo = null;
private sessionBackupPrivateKey: Uint8Array;
/**
* @param {Object.<String, MatrixEvent>} accountData pre-existing account data, will only be read, not written.
* @param {CryptoCallbacks} delegateCryptoCallbacks crypto callbacks to delegate to if the key isn't in cache yet
*/
constructor(accountData, delegateCryptoCallbacks) {
constructor(accountData: Record<string, MatrixEvent>, delegateCryptoCallbacks: ICryptoCallbacks) {
this.accountDataClientAdapter = new AccountDataClientAdapter(accountData);
this.crossSigningCallbacks = new CrossSigningCallbacks();
this.ssssCryptoCallbacks = new SSSSCryptoCallbacks(delegateCryptoCallbacks);
this._crossSigningKeys = null;
this._keySignatures = null;
this._keyBackupInfo = null;
}
/**
@@ -42,8 +60,8 @@ export class EncryptionSetupBuilder {
* an empty authDict, to obtain the flows.
* @param {Object} keys the new keys
*/
addCrossSigningKeys(authUpload, keys) {
this._crossSigningKeys = { authUpload, keys };
public addCrossSigningKeys(authUpload: ICrossSigningKeys["authUpload"], keys: ICrossSigningKeys["keys"]): void {
this.crossSigningKeys = { authUpload, keys };
}
/**
@@ -54,8 +72,8 @@ export class EncryptionSetupBuilder {
*
* @param {Object} keyBackupInfo as received from/sent to the server
*/
addSessionBackup(keyBackupInfo) {
this._keyBackupInfo = keyBackupInfo;
public addSessionBackup(keyBackupInfo: IKeyBackupInfo): void {
this.keyBackupInfo = keyBackupInfo;
}
/**
@@ -65,8 +83,8 @@ export class EncryptionSetupBuilder {
*
* @param {Uint8Array} privateKey
*/
addSessionBackupPrivateKeyToCache(privateKey) {
this._sessionBackupPrivateKey = privateKey;
public addSessionBackupPrivateKeyToCache(privateKey: Uint8Array): void {
this.sessionBackupPrivateKey = privateKey;
}
/**
@@ -75,14 +93,14 @@ export class EncryptionSetupBuilder {
*
* @param {String} userId
* @param {String} deviceId
* @param {String} signature
* @param {Object} signature
*/
addKeySignature(userId, deviceId, signature) {
if (!this._keySignatures) {
this._keySignatures = {};
public addKeySignature(userId: string, deviceId: string, signature: ISignedKey): void {
if (!this.keySignatures) {
this.keySignatures = {};
}
const userSignatures = this._keySignatures[userId] || {};
this._keySignatures[userId] = userSignatures;
const userSignatures = this.keySignatures[userId] || {};
this.keySignatures[userId] = userSignatures;
userSignatures[deviceId] = signature;
}
@@ -91,7 +109,7 @@ export class EncryptionSetupBuilder {
* @param {Object} content
* @return {Promise}
*/
setAccountData(type, content) {
public setAccountData(type: string, content: object): Promise<void> {
return this.accountDataClientAdapter.setAccountData(type, content);
}
@@ -99,13 +117,13 @@ export class EncryptionSetupBuilder {
* builds the operation containing all the parts that have been added to the builder
* @return {EncryptionSetupOperation}
*/
buildOperation() {
const accountData = this.accountDataClientAdapter._values;
public buildOperation(): EncryptionSetupOperation {
const accountData = this.accountDataClientAdapter.values;
return new EncryptionSetupOperation(
accountData,
this._crossSigningKeys,
this._keyBackupInfo,
this._keySignatures,
this.crossSigningKeys,
this.keyBackupInfo,
this.keySignatures,
);
}
@@ -118,28 +136,27 @@ export class EncryptionSetupBuilder {
* @param {Crypto} crypto
* @return {Promise}
*/
async persist(crypto) {
public async persist(crypto: Crypto): Promise<void> {
// store private keys in cache
if (this._crossSigningKeys) {
const cacheCallbacks = createCryptoStoreCacheCallbacks(
crypto._cryptoStore, crypto._olmDevice);
if (this.crossSigningKeys) {
const cacheCallbacks = createCryptoStoreCacheCallbacks(crypto.cryptoStore, crypto.olmDevice);
for (const type of ["master", "self_signing", "user_signing"]) {
logger.log(`Cache ${type} cross-signing private key locally`);
const privateKey = this.crossSigningCallbacks.privateKeys.get(type);
await cacheCallbacks.storeCrossSigningKeyCache(type, privateKey);
}
// store own cross-sign pubkeys as trusted
await crypto._cryptoStore.doTxn(
await crypto.cryptoStore.doTxn(
'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT],
(txn) => {
crypto._cryptoStore.storeCrossSigningKeys(
txn, this._crossSigningKeys.keys);
crypto.cryptoStore.storeCrossSigningKeys(
txn, this.crossSigningKeys.keys);
},
);
}
// store session backup key in cache
if (this._sessionBackupPrivateKey) {
await crypto.storeSessionBackupPrivateKey(this._sessionBackupPrivateKey);
if (this.sessionBackupPrivateKey) {
await crypto.storeSessionBackupPrivateKey(this.sessionBackupPrivateKey);
}
}
}
@@ -157,58 +174,58 @@ export class EncryptionSetupOperation {
* @param {Object} keyBackupInfo
* @param {Object} keySignatures
*/
constructor(accountData, crossSigningKeys, keyBackupInfo, keySignatures) {
this._accountData = accountData;
this._crossSigningKeys = crossSigningKeys;
this._keyBackupInfo = keyBackupInfo;
this._keySignatures = keySignatures;
}
constructor(
private readonly accountData: Map<string, object>,
private readonly crossSigningKeys: ICrossSigningKeys,
private readonly keyBackupInfo: IKeyBackupInfo,
private readonly keySignatures: KeySignatures,
) {}
/**
* Runs the (remaining part of, in the future) operation by sending requests to the server.
* @param {Crypto} crypto
* @param {Crypto} crypto
*/
async apply(crypto) {
const baseApis = crypto._baseApis;
public async apply(crypto: Crypto): Promise<void> {
const baseApis = crypto.baseApis;
// upload cross-signing keys
if (this._crossSigningKeys) {
const keys = {};
for (const [name, key] of Object.entries(this._crossSigningKeys.keys)) {
if (this.crossSigningKeys) {
const keys: Partial<CrossSigningKeys> = {};
for (const [name, key] of Object.entries(this.crossSigningKeys.keys)) {
keys[name + "_key"] = key;
}
// We must only call `uploadDeviceSigningKeys` from inside this auth
// helper to ensure we properly handle auth errors.
await this._crossSigningKeys.authUpload(authDict => {
return baseApis.uploadDeviceSigningKeys(authDict, keys);
await this.crossSigningKeys.authUpload(authDict => {
return baseApis.uploadDeviceSigningKeys(authDict, keys as CrossSigningKeys);
});
// pass the new keys to the main instance of our own CrossSigningInfo.
crypto._crossSigningInfo.setKeys(this._crossSigningKeys.keys);
crypto.crossSigningInfo.setKeys(this.crossSigningKeys.keys);
}
// set account data
if (this._accountData) {
for (const [type, content] of this._accountData) {
if (this.accountData) {
for (const [type, content] of this.accountData) {
await baseApis.setAccountData(type, content);
}
}
// upload first cross-signing signatures with the new key
// (e.g. signing our own device)
if (this._keySignatures) {
await baseApis.uploadKeySignatures(this._keySignatures);
if (this.keySignatures) {
await baseApis.uploadKeySignatures(this.keySignatures);
}
// need to create/update key backup info
if (this._keyBackupInfo) {
if (this._keyBackupInfo.version) {
if (this.keyBackupInfo) {
if (this.keyBackupInfo.version) {
// session backup signature
// The backup is trusted because the user provided the private key.
// Sign the backup with the cross signing key so the key backup can
// be trusted via cross-signing.
await baseApis.http.authedRequest(
undefined, "PUT", "/room_keys/version/" + this._keyBackupInfo.version,
undefined, "PUT", "/room_keys/version/" + this.keyBackupInfo.version,
undefined, {
algorithm: this._keyBackupInfo.algorithm,
auth_data: this._keyBackupInfo.auth_data,
algorithm: this.keyBackupInfo.algorithm,
auth_data: this.keyBackupInfo.auth_data,
},
{ prefix: PREFIX_UNSTABLE },
);
@@ -216,7 +233,7 @@ export class EncryptionSetupOperation {
// add new key backup
await baseApis.http.authedRequest(
undefined, "POST", "/room_keys/version",
undefined, this._keyBackupInfo,
undefined, this.keyBackupInfo,
{ prefix: PREFIX_UNSTABLE },
);
}
@@ -229,20 +246,20 @@ export class EncryptionSetupOperation {
* implementing the methods related to account data in MatrixClient
*/
class AccountDataClientAdapter extends EventEmitter {
public readonly values = new Map<string, object>();
/**
* @param {Object.<String, MatrixEvent>} accountData existing account data
* @param {Object.<String, MatrixEvent>} existingValues existing account data
*/
constructor(accountData) {
constructor(private readonly existingValues: Record<string, MatrixEvent>) {
super();
this._existingValues = accountData;
this._values = new Map();
}
/**
* @param {String} type
* @return {Promise<Object>} the content of the account data
*/
getAccountDataFromServer(type) {
public getAccountDataFromServer(type: string): Promise<object> {
return Promise.resolve(this.getAccountData(type));
}
@@ -250,12 +267,12 @@ class AccountDataClientAdapter extends EventEmitter {
* @param {String} type
* @return {Object} the content of the account data
*/
getAccountData(type) {
const modifiedValue = this._values.get(type);
public getAccountData(type: string): object {
const modifiedValue = this.values.get(type);
if (modifiedValue) {
return modifiedValue;
}
const existingValue = this._existingValues[type];
const existingValue = this.existingValues[type];
if (existingValue) {
return existingValue.getContent();
}
@@ -267,9 +284,9 @@ class AccountDataClientAdapter extends EventEmitter {
* @param {Object} content
* @return {Promise}
*/
setAccountData(type, content) {
const lastEvent = this._values.get(type);
this._values.set(type, content);
public setAccountData(type: string, content: object): Promise<void> {
const lastEvent = this.values.get(type);
this.values.set(type, content);
// ensure accountData is emitted on the next tick,
// as SecretStorage listens for it while calling this method
// and it seems to rely on this.
@@ -285,27 +302,25 @@ class AccountDataClientAdapter extends EventEmitter {
* by both cache callbacks (see createCryptoStoreCacheCallbacks) as non-cache callbacks.
* See CrossSigningInfo constructor
*/
class CrossSigningCallbacks {
constructor() {
this.privateKeys = new Map();
}
class CrossSigningCallbacks implements ICryptoCallbacks, ICacheCallbacks {
public readonly privateKeys = new Map<string, Uint8Array>();
// cache callbacks
getCrossSigningKeyCache(type, expectedPublicKey) {
public getCrossSigningKeyCache(type: string, expectedPublicKey: string): Promise<Uint8Array> {
return this.getCrossSigningKey(type, expectedPublicKey);
}
storeCrossSigningKeyCache(type, key) {
public storeCrossSigningKeyCache(type: string, key: Uint8Array): Promise<void> {
this.privateKeys.set(type, key);
return Promise.resolve();
}
// non-cache callbacks
getCrossSigningKey(type, _expectedPubkey) {
public getCrossSigningKey(type: string, expectedPubkey: string): Promise<Uint8Array> {
return Promise.resolve(this.privateKeys.get(type));
}
saveCrossSigningKeys(privateKeys) {
public saveCrossSigningKeys(privateKeys: Record<string, Uint8Array>) {
for (const [type, privateKey] of Object.entries(privateKeys)) {
this.privateKeys.set(type, privateKey);
}
@@ -317,39 +332,36 @@ class CrossSigningCallbacks {
* the SecretStorage crypto callbacks
*/
class SSSSCryptoCallbacks {
constructor(delegateCryptoCallbacks) {
this._privateKeys = new Map();
this._delegateCryptoCallbacks = delegateCryptoCallbacks;
}
private readonly privateKeys = new Map<string, Uint8Array>();
async getSecretStorageKey({ keys }, name) {
constructor(private readonly delegateCryptoCallbacks: ICryptoCallbacks) {}
public async getSecretStorageKey(
{ keys }: { keys: Record<string, object> },
name: string,
): Promise<[string, Uint8Array]> {
for (const keyId of Object.keys(keys)) {
const privateKey = this._privateKeys.get(keyId);
const privateKey = this.privateKeys.get(keyId);
if (privateKey) {
return [keyId, privateKey];
}
}
// if we don't have the key cached yet, ask
// for it to the general crypto callbacks and cache it
if (this._delegateCryptoCallbacks) {
const result = await this._delegateCryptoCallbacks.
if (this.delegateCryptoCallbacks) {
const result = await this.delegateCryptoCallbacks.
getSecretStorageKey({ keys }, name);
if (result) {
const [keyId, privateKey] = result;
this._privateKeys.set(keyId, privateKey);
this.privateKeys.set(keyId, privateKey);
}
return result;
}
}
addPrivateKey(keyId, keyInfo, privKey) {
this._privateKeys.set(keyId, privKey);
public addPrivateKey(keyId: string, keyInfo: ISecretStorageKeyInfo, privKey: Uint8Array): void {
this.privateKeys.set(keyId, privKey);
// Also pass along to application to cache if it wishes
if (
this._delegateCryptoCallbacks &&
this._delegateCryptoCallbacks.cacheSecretStorageKey
) {
this._delegateCryptoCallbacks.cacheSecretStorageKey(keyId, keyInfo, privKey);
}
this.delegateCryptoCallbacks?.cacheSecretStorageKey?.(keyId, keyInfo, privKey);
}
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2017 - 2021 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.
@@ -15,6 +15,10 @@ limitations under the License.
*/
import { logger } from '../logger';
import { CryptoStore, MatrixClient } from "../client";
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "./index";
import { OutgoingRoomKeyRequest } from './store/base';
import { EventType } from "../@types/event";
/**
* Internal module. Management of outgoing room key requests.
@@ -57,61 +61,58 @@ const SEND_KEY_REQUESTS_DELAY_MS = 500;
*
* @enum {number}
*/
export const ROOM_KEY_REQUEST_STATES = {
export enum RoomKeyRequestState {
/** request not yet sent */
UNSENT: 0,
Unsent,
/** request sent, awaiting reply */
SENT: 1,
Sent,
/** reply received, cancellation not yet sent */
CANCELLATION_PENDING: 2,
CancellationPending,
/**
* Cancellation not yet sent and will transition to UNSENT instead of
* being deleted once the cancellation has been sent.
*/
CANCELLATION_PENDING_AND_WILL_RESEND: 3,
};
CancellationPendingAndWillResend,
}
export class OutgoingRoomKeyRequestManager {
constructor(baseApis, deviceId, cryptoStore) {
this._baseApis = baseApis;
this._deviceId = deviceId;
this._cryptoStore = cryptoStore;
// handle for the delayed call to sendOutgoingRoomKeyRequests. Non-null
// if the callback has been set, or if it is still running.
private sendOutgoingRoomKeyRequestsTimer: NodeJS.Timeout = null;
// handle for the delayed call to _sendOutgoingRoomKeyRequests. Non-null
// if the callback has been set, or if it is still running.
this._sendOutgoingRoomKeyRequestsTimer = null;
// sanity check to ensure that we don't end up with two concurrent runs
// of sendOutgoingRoomKeyRequests
private sendOutgoingRoomKeyRequestsRunning = false;
// sanity check to ensure that we don't end up with two concurrent runs
// of _sendOutgoingRoomKeyRequests
this._sendOutgoingRoomKeyRequestsRunning = false;
private clientRunning = false;
this._clientRunning = false;
}
constructor(
private readonly baseApis: MatrixClient,
private readonly deviceId: string,
private readonly cryptoStore: CryptoStore,
) {}
/**
* Called when the client is started. Sets background processes running.
*/
start() {
this._clientRunning = true;
public start(): void {
this.clientRunning = true;
}
/**
* Called when the client is stopped. Stops any running background processes.
*/
stop() {
public stop(): void {
logger.log('stopping OutgoingRoomKeyRequestManager');
// stop the timer on the next run
this._clientRunning = false;
this.clientRunning = false;
}
/**
* Send any requests that have been queued
*/
sendQueuedRequests() {
this._startTimer();
public sendQueuedRequests(): void {
this.startTimer();
}
/**
@@ -131,95 +132,99 @@ export class OutgoingRoomKeyRequestManager {
* pending list (or we have established that a similar request already
* exists)
*/
async queueRoomKeyRequest(requestBody, recipients, resend=false) {
const req = await this._cryptoStore.getOutgoingRoomKeyRequest(
public async queueRoomKeyRequest(
requestBody: IRoomKeyRequestBody,
recipients: IRoomKeyRequestRecipient[],
resend = false,
): Promise<void> {
const req = await this.cryptoStore.getOutgoingRoomKeyRequest(
requestBody,
);
if (!req) {
await this._cryptoStore.getOrAddOutgoingRoomKeyRequest({
await this.cryptoStore.getOrAddOutgoingRoomKeyRequest({
requestBody: requestBody,
recipients: recipients,
requestId: this._baseApis.makeTxnId(),
state: ROOM_KEY_REQUEST_STATES.UNSENT,
requestId: this.baseApis.makeTxnId(),
state: RoomKeyRequestState.Unsent,
});
} else {
switch (req.state) {
case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND:
case ROOM_KEY_REQUEST_STATES.UNSENT:
// nothing to do here, since we're going to send a request anyways
return;
case RoomKeyRequestState.CancellationPendingAndWillResend:
case RoomKeyRequestState.Unsent:
// nothing to do here, since we're going to send a request anyways
return;
case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING: {
// existing request is about to be cancelled. If we want to
// resend, then change the state so that it resends after
// cancelling. Otherwise, just cancel the cancellation.
const state = resend ?
ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND :
ROOM_KEY_REQUEST_STATES.SENT;
await this._cryptoStore.updateOutgoingRoomKeyRequest(
req.requestId, ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, {
state,
cancellationTxnId: this._baseApis.makeTxnId(),
},
);
break;
}
case ROOM_KEY_REQUEST_STATES.SENT: {
// a request has already been sent. If we don't want to
// resend, then do nothing. If we do want to, then cancel the
// existing request and send a new one.
if (resend) {
const state =
ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND;
const updatedReq =
await this._cryptoStore.updateOutgoingRoomKeyRequest(
req.requestId, ROOM_KEY_REQUEST_STATES.SENT, {
state,
cancellationTxnId: this._baseApis.makeTxnId(),
// need to use a new transaction ID so that
// the request gets sent
requestTxnId: this._baseApis.makeTxnId(),
},
);
if (!updatedReq) {
// updateOutgoingRoomKeyRequest couldn't find the request
// in state ROOM_KEY_REQUEST_STATES.SENT, so we must have
// raced with another tab to mark the request cancelled.
// Try again, to make sure the request is resent.
return await this.queueRoomKeyRequest(
requestBody, recipients, resend,
);
}
// We don't want to wait for the timer, so we send it
// immediately. (We might actually end up racing with the timer,
// but that's ok: even if we make the request twice, we'll do it
// with the same transaction_id, so only one message will get
// sent).
//
// (We also don't want to wait for the response from the server
// here, as it will slow down processing of received keys if we
// do.)
try {
await this._sendOutgoingRoomKeyRequestCancellation(
updatedReq,
true,
);
} catch (e) {
logger.error(
"Error sending room key request cancellation;"
+ " will retry later.", e,
);
}
// The request has transitioned from
// CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We
// still need to resend the request which is now UNSENT, so
// start the timer if it isn't already started.
case RoomKeyRequestState.CancellationPending: {
// existing request is about to be cancelled. If we want to
// resend, then change the state so that it resends after
// cancelling. Otherwise, just cancel the cancellation.
const state = resend ?
RoomKeyRequestState.CancellationPendingAndWillResend :
RoomKeyRequestState.Sent;
await this.cryptoStore.updateOutgoingRoomKeyRequest(
req.requestId, RoomKeyRequestState.CancellationPending, {
state,
cancellationTxnId: this.baseApis.makeTxnId(),
},
);
break;
}
break;
}
default:
throw new Error('unhandled state: ' + req.state);
case RoomKeyRequestState.Sent: {
// a request has already been sent. If we don't want to
// resend, then do nothing. If we do want to, then cancel the
// existing request and send a new one.
if (resend) {
const state =
RoomKeyRequestState.CancellationPendingAndWillResend;
const updatedReq =
await this.cryptoStore.updateOutgoingRoomKeyRequest(
req.requestId, RoomKeyRequestState.Sent, {
state,
cancellationTxnId: this.baseApis.makeTxnId(),
// need to use a new transaction ID so that
// the request gets sent
requestTxnId: this.baseApis.makeTxnId(),
},
);
if (!updatedReq) {
// updateOutgoingRoomKeyRequest couldn't find the request
// in state ROOM_KEY_REQUEST_STATES.SENT, so we must have
// raced with another tab to mark the request cancelled.
// Try again, to make sure the request is resent.
return await this.queueRoomKeyRequest(
requestBody, recipients, resend,
);
}
// We don't want to wait for the timer, so we send it
// immediately. (We might actually end up racing with the timer,
// but that's ok: even if we make the request twice, we'll do it
// with the same transaction_id, so only one message will get
// sent).
//
// (We also don't want to wait for the response from the server
// here, as it will slow down processing of received keys if we
// do.)
try {
await this.sendOutgoingRoomKeyRequestCancellation(
updatedReq,
true,
);
} catch (e) {
logger.error(
"Error sending room key request cancellation;"
+ " will retry later.", e,
);
}
// The request has transitioned from
// CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We
// still need to resend the request which is now UNSENT, so
// start the timer if it isn't already started.
}
break;
}
default:
throw new Error('unhandled state: ' + req.state);
}
}
}
@@ -232,8 +237,8 @@ export class OutgoingRoomKeyRequestManager {
* @returns {Promise} resolves when the request has been updated in our
* pending list.
*/
cancelRoomKeyRequest(requestBody) {
return this._cryptoStore.getOutgoingRoomKeyRequest(
public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<void> {
return this.cryptoStore.getOutgoingRoomKeyRequest(
requestBody,
).then((req) => {
if (!req) {
@@ -241,12 +246,12 @@ export class OutgoingRoomKeyRequestManager {
return;
}
switch (req.state) {
case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING:
case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND:
case RoomKeyRequestState.CancellationPending:
case RoomKeyRequestState.CancellationPendingAndWillResend:
// nothing to do here
return;
case ROOM_KEY_REQUEST_STATES.UNSENT:
case RoomKeyRequestState.Unsent:
// just delete it
// FIXME: ghahah we may have attempted to send it, and
@@ -258,16 +263,16 @@ export class OutgoingRoomKeyRequestManager {
'deleting unnecessary room key request for ' +
stringifyRequestBody(requestBody),
);
return this._cryptoStore.deleteOutgoingRoomKeyRequest(
req.requestId, ROOM_KEY_REQUEST_STATES.UNSENT,
return this.cryptoStore.deleteOutgoingRoomKeyRequest(
req.requestId, RoomKeyRequestState.Unsent,
);
case ROOM_KEY_REQUEST_STATES.SENT: {
case RoomKeyRequestState.Sent: {
// send a cancellation.
return this._cryptoStore.updateOutgoingRoomKeyRequest(
req.requestId, ROOM_KEY_REQUEST_STATES.SENT, {
state: ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING,
cancellationTxnId: this._baseApis.makeTxnId(),
return this.cryptoStore.updateOutgoingRoomKeyRequest(
req.requestId, RoomKeyRequestState.Sent, {
state: RoomKeyRequestState.CancellationPending,
cancellationTxnId: this.baseApis.makeTxnId(),
},
).then((updatedReq) => {
if (!updatedReq) {
@@ -294,14 +299,14 @@ export class OutgoingRoomKeyRequestManager {
// (We also don't want to wait for the response from the server
// here, as it will slow down processing of received keys if we
// do.)
this._sendOutgoingRoomKeyRequestCancellation(
this.sendOutgoingRoomKeyRequestCancellation(
updatedReq,
).catch((e) => {
logger.error(
"Error sending room key request cancellation;"
+ " will retry later.", e,
);
this._startTimer();
this.startTimer();
});
});
}
@@ -320,10 +325,8 @@ export class OutgoingRoomKeyRequestManager {
* @return {Promise} resolves to a list of all the
* {@link module:crypto/store/base~OutgoingRoomKeyRequest}
*/
getOutgoingSentRoomKeyRequest(userId, deviceId) {
return this._cryptoStore.getOutgoingRoomKeyRequestsByTarget(
userId, deviceId, [ROOM_KEY_REQUEST_STATES.SENT],
);
public getOutgoingSentRoomKeyRequest(userId: string, deviceId: string): OutgoingRoomKeyRequest[] {
return this.cryptoStore.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, [RoomKeyRequestState.Sent]);
}
/**
@@ -333,29 +336,27 @@ export class OutgoingRoomKeyRequestManager {
* For example, after initialization or self-verification.
* @return {Promise} An array of `queueRoomKeyRequest` outputs.
*/
async cancelAndResendAllOutgoingRequests() {
const outgoings = await this._cryptoStore.getAllOutgoingRoomKeyRequestsByState(
ROOM_KEY_REQUEST_STATES.SENT,
);
public async cancelAndResendAllOutgoingRequests(): Promise<void[]> {
const outgoings = await this.cryptoStore.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent);
return Promise.all(outgoings.map(({ requestBody, recipients }) =>
this.queueRoomKeyRequest(requestBody, recipients, true)));
}
// start the background timer to send queued requests, if the timer isn't
// already running
_startTimer() {
if (this._sendOutgoingRoomKeyRequestsTimer) {
private startTimer(): void {
if (this.sendOutgoingRoomKeyRequestsTimer) {
return;
}
const startSendingOutgoingRoomKeyRequests = () => {
if (this._sendOutgoingRoomKeyRequestsRunning) {
if (this.sendOutgoingRoomKeyRequestsRunning) {
throw new Error("RoomKeyRequestSend already in progress!");
}
this._sendOutgoingRoomKeyRequestsRunning = true;
this.sendOutgoingRoomKeyRequestsRunning = true;
this._sendOutgoingRoomKeyRequests().finally(() => {
this._sendOutgoingRoomKeyRequestsRunning = false;
this.sendOutgoingRoomKeyRequests().finally(() => {
this.sendOutgoingRoomKeyRequestsRunning = false;
}).catch((e) => {
// this should only happen if there is an indexeddb error,
// in which case we're a bit stuffed anyway.
@@ -365,7 +366,7 @@ export class OutgoingRoomKeyRequestManager {
});
};
this._sendOutgoingRoomKeyRequestsTimer = global.setTimeout(
this.sendOutgoingRoomKeyRequestsTimer = global.setTimeout(
startSendingOutgoingRoomKeyRequests,
SEND_KEY_REQUESTS_DELAY_MS,
);
@@ -374,47 +375,47 @@ export class OutgoingRoomKeyRequestManager {
// look for and send any queued requests. Runs itself recursively until
// there are no more requests, or there is an error (in which case, the
// timer will be restarted before the promise resolves).
_sendOutgoingRoomKeyRequests() {
if (!this._clientRunning) {
this._sendOutgoingRoomKeyRequestsTimer = null;
private sendOutgoingRoomKeyRequests(): Promise<void> {
if (!this.clientRunning) {
this.sendOutgoingRoomKeyRequestsTimer = null;
return Promise.resolve();
}
return this._cryptoStore.getOutgoingRoomKeyRequestByState([
ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING,
ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND,
ROOM_KEY_REQUEST_STATES.UNSENT,
]).then((req) => {
return this.cryptoStore.getOutgoingRoomKeyRequestByState([
RoomKeyRequestState.CancellationPending,
RoomKeyRequestState.CancellationPendingAndWillResend,
RoomKeyRequestState.Unsent,
]).then((req: OutgoingRoomKeyRequest) => {
if (!req) {
this._sendOutgoingRoomKeyRequestsTimer = null;
this.sendOutgoingRoomKeyRequestsTimer = null;
return;
}
let prom;
switch (req.state) {
case ROOM_KEY_REQUEST_STATES.UNSENT:
prom = this._sendOutgoingRoomKeyRequest(req);
case RoomKeyRequestState.Unsent:
prom = this.sendOutgoingRoomKeyRequest(req);
break;
case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING:
prom = this._sendOutgoingRoomKeyRequestCancellation(req);
case RoomKeyRequestState.CancellationPending:
prom = this.sendOutgoingRoomKeyRequestCancellation(req);
break;
case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND:
prom = this._sendOutgoingRoomKeyRequestCancellation(req, true);
case RoomKeyRequestState.CancellationPendingAndWillResend:
prom = this.sendOutgoingRoomKeyRequestCancellation(req, true);
break;
}
return prom.then(() => {
// go around the loop again
return this._sendOutgoingRoomKeyRequests();
return this.sendOutgoingRoomKeyRequests();
}).catch((e) => {
logger.error("Error sending room key request; will retry later.", e);
this._sendOutgoingRoomKeyRequestsTimer = null;
this.sendOutgoingRoomKeyRequestsTimer = null;
});
});
}
// given a RoomKeyRequest, send it and update the request record
_sendOutgoingRoomKeyRequest(req) {
private sendOutgoingRoomKeyRequest(req: OutgoingRoomKeyRequest): Promise<void> {
logger.log(
`Requesting keys for ${stringifyRequestBody(req.requestBody)}` +
` from ${stringifyRecipientList(req.recipients)}` +
@@ -423,24 +424,24 @@ export class OutgoingRoomKeyRequestManager {
const requestMessage = {
action: "request",
requesting_device_id: this._deviceId,
requesting_device_id: this.deviceId,
request_id: req.requestId,
body: req.requestBody,
};
return this._sendMessageToDevices(
return this.sendMessageToDevices(
requestMessage, req.recipients, req.requestTxnId || req.requestId,
).then(() => {
return this._cryptoStore.updateOutgoingRoomKeyRequest(
req.requestId, ROOM_KEY_REQUEST_STATES.UNSENT,
{ state: ROOM_KEY_REQUEST_STATES.SENT },
return this.cryptoStore.updateOutgoingRoomKeyRequest(
req.requestId, RoomKeyRequestState.Unsent,
{ state: RoomKeyRequestState.Sent },
);
});
}
// Given a RoomKeyRequest, cancel it and delete the request record unless
// andResend is set, in which case transition to UNSENT.
_sendOutgoingRoomKeyRequestCancellation(req, andResend) {
private sendOutgoingRoomKeyRequestCancellation(req: OutgoingRoomKeyRequest, andResend = false): Promise<void> {
logger.log(
`Sending cancellation for key request for ` +
`${stringifyRequestBody(req.requestBody)} to ` +
@@ -450,30 +451,30 @@ export class OutgoingRoomKeyRequestManager {
const requestMessage = {
action: "request_cancellation",
requesting_device_id: this._deviceId,
requesting_device_id: this.deviceId,
request_id: req.requestId,
};
return this._sendMessageToDevices(
return this.sendMessageToDevices(
requestMessage, req.recipients, req.cancellationTxnId,
).then(() => {
if (andResend) {
// We want to resend, so transition to UNSENT
return this._cryptoStore.updateOutgoingRoomKeyRequest(
return this.cryptoStore.updateOutgoingRoomKeyRequest(
req.requestId,
ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND,
{ state: ROOM_KEY_REQUEST_STATES.UNSENT },
RoomKeyRequestState.CancellationPendingAndWillResend,
{ state: RoomKeyRequestState.Unsent },
);
}
return this._cryptoStore.deleteOutgoingRoomKeyRequest(
req.requestId, ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING,
return this.cryptoStore.deleteOutgoingRoomKeyRequest(
req.requestId, RoomKeyRequestState.CancellationPending,
);
});
}
// send a RoomKeyRequest to a list of recipients
_sendMessageToDevices(message, recipients, txnId) {
const contentMap = {};
private sendMessageToDevices(message, recipients, txnId: string): Promise<{}> {
const contentMap: Record<string, Record<string, Record<string, any>>> = {};
for (const recip of recipients) {
if (!contentMap[recip.userId]) {
contentMap[recip.userId] = {};
@@ -481,9 +482,7 @@ export class OutgoingRoomKeyRequestManager {
contentMap[recip.userId][recip.deviceId] = message;
}
return this._baseApis.sendToDevice(
'm.room_key_request', contentMap, txnId,
);
return this.baseApis.sendToDevice(EventType.RoomKeyRequest, contentMap, txnId);
}
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2018, 2019 New Vector Ltd
Copyright 2018 - 2021 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.
@@ -21,44 +21,51 @@ limitations under the License.
*/
import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store';
import { CryptoStore } from "../client";
/* eslint-disable camelcase */
interface IRoomEncryption {
algorithm: string;
rotation_period_ms: number;
rotation_period_msgs: number;
}
/* eslint-enable camelcase */
/**
* @alias module:crypto/RoomList
*/
export class RoomList {
constructor(cryptoStore) {
this._cryptoStore = cryptoStore;
// Object of roomId -> room e2e info object (body of the m.room.encryption event)
private roomEncryption: Record<string, IRoomEncryption> = {};
// Object of roomId -> room e2e info object (body of the m.room.encryption event)
this._roomEncryption = {};
}
constructor(private readonly cryptoStore: CryptoStore) {}
async init() {
await this._cryptoStore.doTxn(
public async init(): Promise<void> {
await this.cryptoStore.doTxn(
'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => {
this._cryptoStore.getEndToEndRooms(txn, (result) => {
this._roomEncryption = result;
this.cryptoStore.getEndToEndRooms(txn, (result) => {
this.roomEncryption = result;
});
},
);
}
getRoomEncryption(roomId) {
return this._roomEncryption[roomId] || null;
public getRoomEncryption(roomId: string): IRoomEncryption {
return this.roomEncryption[roomId] || null;
}
isRoomEncrypted(roomId) {
public isRoomEncrypted(roomId: string): boolean {
return Boolean(this.getRoomEncryption(roomId));
}
async setRoomEncryption(roomId, roomInfo) {
public async setRoomEncryption(roomId: string, roomInfo: IRoomEncryption): Promise<void> {
// important that this happens before calling into the store
// as it prevents the Crypto::setRoomEncryption from calling
// this twice for consecutive m.room.encryption events
this._roomEncryption[roomId] = roomInfo;
await this._cryptoStore.doTxn(
this.roomEncryption[roomId] = roomInfo;
await this.cryptoStore.doTxn(
'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => {
this._cryptoStore.storeEndToEndRoom(roomId, roomInfo, txn);
this.cryptoStore.storeEndToEndRoom(roomId, roomInfo, txn);
},
);
}

View File

@@ -480,11 +480,11 @@ export class SecretStorage extends EventEmitter {
};
const encryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: this._baseApis.crypto._olmDevice.deviceCurve25519Key,
sender_key: this._baseApis.crypto.olmDevice.deviceCurve25519Key,
ciphertext: {},
};
await olmlib.ensureOlmSessionsForDevices(
this._baseApis.crypto._olmDevice,
this._baseApis.crypto.olmDevice,
this._baseApis,
{
[sender]: [
@@ -496,7 +496,7 @@ export class SecretStorage extends EventEmitter {
encryptedContent.ciphertext,
this._baseApis.getUserId(),
this._baseApis.deviceId,
this._baseApis.crypto._olmDevice,
this._baseApis.crypto.olmDevice,
sender,
this._baseApis.getStoredDevice(sender, deviceId),
payload,
@@ -527,7 +527,7 @@ export class SecretStorage extends EventEmitter {
if (requestControl) {
// make sure that the device that sent it is one of the devices that
// we requested from
const deviceInfo = this._baseApis.crypto._deviceList.getDeviceByIdentityKey(
const deviceInfo = this._baseApis.crypto.deviceList.getDeviceByIdentityKey(
olmlib.OLM_ALGORITHM,
event.getSenderKey(),
);

View File

@@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { BinaryLike } from "crypto";
import { getCrypto } from '../utils';
import { decodeBase64, encodeBase64 } from './olmlib';
@@ -21,7 +23,13 @@ const subtleCrypto = (typeof window !== "undefined" && window.crypto) ?
(window.crypto.subtle || window.crypto.webkitSubtle) : null;
// salt for HKDF, with 8 bytes of zeros
const zerosalt = new Uint8Array(8);
const zeroSalt = new Uint8Array(8);
export interface IEncryptedPayload {
iv: string;
ciphertext: string;
mac: string;
}
/**
* encrypt a string in Node.js
@@ -31,7 +39,7 @@ const zerosalt = new Uint8Array(8);
* @param {string} name the name of the secret
* @param {string} ivStr the initialization vector to use
*/
async function encryptNode(data, key, name, ivStr) {
async function encryptNode(data: string, key: Uint8Array, name: string, ivStr?: string): Promise<IEncryptedPayload> {
const crypto = getCrypto();
if (!crypto) {
throw new Error("No usable crypto implementation");
@@ -52,15 +60,17 @@ async function encryptNode(data, key, name, ivStr) {
const [aesKey, hmacKey] = deriveKeysNode(key, name);
const cipher = crypto.createCipheriv("aes-256-ctr", aesKey, iv);
const ciphertext = cipher.update(data, "utf-8", "base64")
+ cipher.final("base64");
const ciphertext = Buffer.concat([
cipher.update(data, "utf8"),
cipher.final(),
]);
const hmac = crypto.createHmac("sha256", hmacKey)
.update(ciphertext, "base64").digest("base64");
.update(ciphertext).digest("base64");
return {
iv: encodeBase64(iv),
ciphertext: ciphertext,
ciphertext: ciphertext.toString("base64"),
mac: hmac,
};
}
@@ -75,7 +85,7 @@ async function encryptNode(data, key, name, ivStr) {
* @param {Uint8Array} key the encryption key to use
* @param {string} name the name of the secret
*/
async function decryptNode(data, key, name) {
async function decryptNode(data: IEncryptedPayload, key: Uint8Array, name: string): Promise<string> {
const crypto = getCrypto();
if (!crypto) {
throw new Error("No usable crypto implementation");
@@ -84,7 +94,8 @@ async function decryptNode(data, key, name) {
const [aesKey, hmacKey] = deriveKeysNode(key, name);
const hmac = crypto.createHmac("sha256", hmacKey)
.update(data.ciphertext, "base64").digest("base64").replace(/=+$/g, '');
.update(Buffer.from(data.ciphertext, "base64"))
.digest("base64").replace(/=+$/g, '');
if (hmac !== data.mac.replace(/=+$/g, '')) {
throw new Error(`Error decrypting secret ${name}: bad MAC`);
@@ -93,21 +104,20 @@ async function decryptNode(data, key, name) {
const decipher = crypto.createDecipheriv(
"aes-256-ctr", aesKey, decodeBase64(data.iv),
);
return decipher.update(data.ciphertext, "base64", "utf-8")
+ decipher.final("utf-8");
return decipher.update(data.ciphertext, "base64", "utf8")
+ decipher.final("utf8");
}
function deriveKeysNode(key, name) {
function deriveKeysNode(key: BinaryLike, name: string): [Buffer, Buffer] {
const crypto = getCrypto();
const prk = crypto.createHmac("sha256", zerosalt)
.update(key).digest();
const prk = crypto.createHmac("sha256", zeroSalt).update(key).digest();
const b = Buffer.alloc(1, 1);
const aesKey = crypto.createHmac("sha256", prk)
.update(name, "utf-8").update(b).digest();
.update(name, "utf8").update(b).digest();
b[0] = 2;
const hmacKey = crypto.createHmac("sha256", prk)
.update(aesKey).update(name, "utf-8").update(b).digest();
.update(aesKey).update(name, "utf8").update(b).digest();
return [aesKey, hmacKey];
}
@@ -120,7 +130,7 @@ function deriveKeysNode(key, name) {
* @param {string} name the name of the secret
* @param {string} ivStr the initialization vector to use
*/
async function encryptBrowser(data, key, name, ivStr) {
async function encryptBrowser(data: string, key: Uint8Array, name: string, ivStr?: string): Promise<IEncryptedPayload> {
let iv;
if (ivStr) {
iv = decodeBase64(ivStr);
@@ -170,7 +180,7 @@ async function encryptBrowser(data, key, name, ivStr) {
* @param {Uint8Array} key the encryption key to use
* @param {string} name the name of the secret
*/
async function decryptBrowser(data, key, name) {
async function decryptBrowser(data: IEncryptedPayload, key: Uint8Array, name: string): Promise<string> {
const [aesKey, hmacKey] = await deriveKeysBrowser(key, name);
const ciphertext = decodeBase64(data.ciphertext);
@@ -197,7 +207,7 @@ async function decryptBrowser(data, key, name) {
return new TextDecoder().decode(new Uint8Array(plaintext));
}
async function deriveKeysBrowser(key, name) {
async function deriveKeysBrowser(key: Uint8Array, name: string): Promise<[CryptoKey, CryptoKey]> {
const hkdfkey = await subtleCrypto.importKey(
'raw',
key,
@@ -208,7 +218,9 @@ async function deriveKeysBrowser(key, name) {
const keybits = await subtleCrypto.deriveBits(
{
name: "HKDF",
salt: zerosalt,
salt: zeroSalt,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/879
info: (new TextEncoder().encode(name)),
hash: "SHA-256",
},
@@ -241,11 +253,11 @@ async function deriveKeysBrowser(key, name) {
return await Promise.all([aesProm, hmacProm]);
}
export function encryptAES(...args) {
return subtleCrypto ? encryptBrowser(...args) : encryptNode(...args);
export function encryptAES(data: string, key: Uint8Array, name: string, ivStr?: string): Promise<IEncryptedPayload> {
return subtleCrypto ? encryptBrowser(data, key, name, ivStr) : encryptNode(data, key, name, ivStr);
}
export function decryptAES(...args) {
return subtleCrypto ? decryptBrowser(...args) : decryptNode(...args);
export function decryptAES(data: IEncryptedPayload, key: Uint8Array, name: string): Promise<string> {
return subtleCrypto ? decryptBrowser(data, key, name) : decryptNode(data, key, name);
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2016 - 2021 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.
@@ -20,13 +20,22 @@ limitations under the License.
* @module
*/
import { MatrixClient } from "../../client";
import { Room } from "../../models/room";
import { OlmDevice } from "../OlmDevice";
import { MatrixEvent, RoomMember } from "../..";
import { Crypto, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "..";
import { DeviceInfo } from "../deviceinfo";
/**
* map of registered encryption algorithm classes. A map from string to {@link
* module:crypto/algorithms/base.EncryptionAlgorithm|EncryptionAlgorithm} class
*
* @type {Object.<string, function(new: module:crypto/algorithms/base.EncryptionAlgorithm)>}
*/
export const ENCRYPTION_CLASSES = {};
export const ENCRYPTION_CLASSES: Record<string, new (params: IParams) => EncryptionAlgorithm> = {};
type DecryptionClassParams = Omit<IParams, "deviceId" | "config">;
/**
* map of registered encryption algorithm classes. Map from string to {@link
@@ -34,7 +43,17 @@ export const ENCRYPTION_CLASSES = {};
*
* @type {Object.<string, function(new: module:crypto/algorithms/base.DecryptionAlgorithm)>}
*/
export const DECRYPTION_CLASSES = {};
export const DECRYPTION_CLASSES: Record<string, new (params: DecryptionClassParams) => DecryptionAlgorithm> = {};
interface IParams {
userId: string;
deviceId: string;
crypto: Crypto;
olmDevice: OlmDevice;
baseApis: MatrixClient;
roomId: string;
config: object;
}
/**
* base type for encryption implementations
@@ -50,14 +69,21 @@ export const DECRYPTION_CLASSES = {};
* @param {string} params.roomId The ID of the room we will be sending to
* @param {object} params.config The body of the m.room.encryption event
*/
export class EncryptionAlgorithm {
constructor(params) {
this._userId = params.userId;
this._deviceId = params.deviceId;
this._crypto = params.crypto;
this._olmDevice = params.olmDevice;
this._baseApis = params.baseApis;
this._roomId = params.roomId;
export abstract class EncryptionAlgorithm {
protected readonly userId: string;
protected readonly deviceId: string;
protected readonly crypto: Crypto;
protected readonly olmDevice: OlmDevice;
protected readonly baseApis: MatrixClient;
protected readonly roomId: string;
constructor(params: IParams) {
this.userId = params.userId;
this.deviceId = params.deviceId;
this.crypto = params.crypto;
this.olmDevice = params.olmDevice;
this.baseApis = params.baseApis;
this.roomId = params.roomId;
}
/**
@@ -66,21 +92,22 @@ export class EncryptionAlgorithm {
*
* @param {module:models/room} room the room the event is in
*/
prepareToEncrypt(room) {
}
public prepareToEncrypt(room: Room): void {}
/**
* Encrypt a message event
*
* @method module:crypto/algorithms/base.EncryptionAlgorithm.encryptMessage
* @public
* @abstract
*
* @param {module:models/room} room
* @param {string} eventType
* @param {object} plaintext event content
* @param {object} content event content
*
* @return {Promise} Promise which resolves to the new event body
*/
public abstract encryptMessage(room: Room, eventType: string, content: object): Promise<object>;
/**
* Called when the membership of a member of the room changes.
@@ -89,9 +116,18 @@ export class EncryptionAlgorithm {
* @param {module:models/room-member} member user whose membership changed
* @param {string=} oldMembership previous membership
* @public
* @abstract
*/
onRoomMembership(event, member, oldMembership) {
}
public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {}
public reshareKeyWithDevice?(
senderKey: string,
sessionId: string,
userId: string,
device: DeviceInfo,
): Promise<void>;
public forceDiscardSession?(): void;
}
/**
@@ -106,13 +142,19 @@ export class EncryptionAlgorithm {
* @param {string=} params.roomId The ID of the room we will be receiving
* from. Null for to-device events.
*/
export class DecryptionAlgorithm {
constructor(params) {
this._userId = params.userId;
this._crypto = params.crypto;
this._olmDevice = params.olmDevice;
this._baseApis = params.baseApis;
this._roomId = params.roomId;
export abstract class DecryptionAlgorithm {
protected readonly userId: string;
protected readonly crypto: Crypto;
protected readonly olmDevice: OlmDevice;
protected readonly baseApis: MatrixClient;
protected readonly roomId: string;
constructor(params: DecryptionClassParams) {
this.userId = params.userId;
this.crypto = params.crypto;
this.olmDevice = params.olmDevice;
this.baseApis = params.baseApis;
this.roomId = params.roomId;
}
/**
@@ -127,6 +169,7 @@ export class DecryptionAlgorithm {
* resolves once we have finished decrypting. Rejects with an
* `algorithms.DecryptionError` if there is a problem decrypting the event.
*/
public abstract decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult>;
/**
* Handle a key event
@@ -135,7 +178,7 @@ export class DecryptionAlgorithm {
*
* @param {module:models/event.MatrixEvent} params event key event
*/
onRoomKeyEvent(params) {
public onRoomKeyEvent(params: MatrixEvent): void {
// ignore by default
}
@@ -143,8 +186,9 @@ export class DecryptionAlgorithm {
* Import a room key
*
* @param {module:crypto/OlmDevice.MegolmSessionData} session
* @param {object} opts object
*/
importRoomKey(session) {
public async importRoomKey(session: IMegolmSessionData, opts: object): Promise<void> {
// ignore by default
}
@@ -155,7 +199,7 @@ export class DecryptionAlgorithm {
* @return {Promise<boolean>} true if we have the keys and could (theoretically) share
* them; else false.
*/
hasKeysForKeyRequest(keyRequest) {
public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise<boolean> {
return Promise.resolve(false);
}
@@ -164,7 +208,7 @@ export class DecryptionAlgorithm {
*
* @param {module:crypto~IncomingRoomKeyRequest} keyRequest
*/
shareKeysWithDevice(keyRequest) {
public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void {
throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm");
}
@@ -174,9 +218,13 @@ export class DecryptionAlgorithm {
*
* @param {string} senderKey the sender's key
*/
async retryDecryptionFromSender(senderKey) {
public async retryDecryptionFromSender(senderKey: string): Promise<boolean> {
// ignore by default
return false;
}
public onRoomKeyWithheldEvent?(event: MatrixEvent): Promise<void>;
public sendSharedHistoryInboundSessions?(devicesByUser: Record<string, DeviceInfo[]>): Promise<void>;
}
/**
@@ -191,22 +239,21 @@ export class DecryptionAlgorithm {
* @extends Error
*/
export class DecryptionError extends Error {
constructor(code, msg, details) {
public readonly detailedString: string;
constructor(public readonly code: string, msg: string, details?: Record<string, string>) {
super(msg);
this.code = code;
this.name = 'DecryptionError';
this.detailedString = _detailedStringForDecryptionError(this, details);
this.detailedString = detailedStringForDecryptionError(this, details);
}
}
function _detailedStringForDecryptionError(err, details) {
function detailedStringForDecryptionError(err: DecryptionError, details?: Record<string, string>): string {
let result = err.name + '[msg: ' + err.message;
if (details) {
result += ', ' +
Object.keys(details).map(
(k) => k + ': ' + details[k],
).join(', ');
result += ', ' + Object.keys(details).map((k) => k + ': ' + details[k]).join(', ');
}
result += ']';
@@ -224,7 +271,7 @@ function _detailedStringForDecryptionError(err, details) {
* @extends Error
*/
export class UnknownDeviceError extends Error {
constructor(msg, devices) {
constructor(msg: string, public readonly devices: Record<string, Record<string, object>>) {
super(msg);
this.name = "UnknownDeviceError";
this.devices = devices;
@@ -244,7 +291,11 @@ export class UnknownDeviceError extends Error {
* module:crypto/algorithms/base.DecryptionAlgorithm|DecryptionAlgorithm}
* implementation
*/
export function registerAlgorithm(algorithm, encryptor, decryptor) {
export function registerAlgorithm(
algorithm: string,
encryptor: new (params: IParams) => EncryptionAlgorithm,
decryptor: new (params: Omit<IParams, "deviceId">) => DecryptionAlgorithm,
): void {
ENCRYPTION_CLASSES[algorithm] = encryptor;
DECRYPTION_CLASSES[algorithm] = decryptor;
}

View File

@@ -1,6 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2016 - 2021 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.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,361 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Defines m.olm encryption/decryption
*
* @module crypto/algorithms/olm
*/
import { logger } from '../../logger';
import * as utils from "../../utils";
import { polyfillSuper } from "../../utils";
import * as olmlib from "../olmlib";
import { DeviceInfo } from "../deviceinfo";
import {
DecryptionAlgorithm,
DecryptionError,
EncryptionAlgorithm,
registerAlgorithm,
} from "./base";
const DeviceVerification = DeviceInfo.DeviceVerification;
/**
* Olm encryption implementation
*
* @constructor
* @extends {module:crypto/algorithms/EncryptionAlgorithm}
*
* @param {object} params parameters, as per
* {@link module:crypto/algorithms/EncryptionAlgorithm}
*/
function OlmEncryption(params) {
polyfillSuper(this, EncryptionAlgorithm, params);
this._sessionPrepared = false;
this._prepPromise = null;
}
utils.inherits(OlmEncryption, EncryptionAlgorithm);
/**
* @private
* @param {string[]} roomMembers list of currently-joined users in the room
* @return {Promise} Promise which resolves when setup is complete
*/
OlmEncryption.prototype._ensureSession = function(roomMembers) {
if (this._prepPromise) {
// prep already in progress
return this._prepPromise;
}
if (this._sessionPrepared) {
// prep already done
return Promise.resolve();
}
const self = this;
this._prepPromise = self._crypto.downloadKeys(roomMembers).then(function(res) {
return self._crypto.ensureOlmSessionsForUsers(roomMembers);
}).then(function() {
self._sessionPrepared = true;
}).finally(function() {
self._prepPromise = null;
});
return this._prepPromise;
};
/**
* @inheritdoc
*
* @param {module:models/room} room
* @param {string} eventType
* @param {object} content plaintext event content
*
* @return {Promise} Promise which resolves to the new event body
*/
OlmEncryption.prototype.encryptMessage = async function(room, eventType, content) {
// pick the list of recipients based on the membership list.
//
// TODO: there is a race condition here! What if a new user turns up
// just as you are sending a secret message?
const members = await room.getEncryptionTargetMembers();
const users = members.map(function(u) {
return u.userId;
});
const self = this;
await this._ensureSession(users);
const payloadFields = {
room_id: room.roomId,
type: eventType,
content: content,
};
const encryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: self._olmDevice.deviceCurve25519Key,
ciphertext: {},
};
const promises = [];
for (let i = 0; i < users.length; ++i) {
const userId = users[i];
const devices = self._crypto.getStoredDevicesForUser(userId);
for (let j = 0; j < devices.length; ++j) {
const deviceInfo = devices[j];
const key = deviceInfo.getIdentityKey();
if (key == self._olmDevice.deviceCurve25519Key) {
// don't bother sending to ourself
continue;
}
if (deviceInfo.verified == DeviceVerification.BLOCKED) {
// don't bother setting up sessions with blocked users
continue;
}
promises.push(
olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
self._userId, self._deviceId, self._olmDevice,
userId, deviceInfo, payloadFields,
),
);
}
}
return await Promise.all(promises).then(() => encryptedContent);
};
/**
* Olm decryption implementation
*
* @constructor
* @extends {module:crypto/algorithms/DecryptionAlgorithm}
* @param {object} params parameters, as per
* {@link module:crypto/algorithms/DecryptionAlgorithm}
*/
function OlmDecryption(params) {
polyfillSuper(this, DecryptionAlgorithm, params);
}
utils.inherits(OlmDecryption, DecryptionAlgorithm);
/**
* @inheritdoc
*
* @param {MatrixEvent} event
*
* returns a promise which resolves to a
* {@link module:crypto~EventDecryptionResult} once we have finished
* decrypting. Rejects with an `algorithms.DecryptionError` if there is a
* problem decrypting the event.
*/
OlmDecryption.prototype.decryptEvent = async function(event) {
const content = event.getWireContent();
const deviceKey = content.sender_key;
const ciphertext = content.ciphertext;
if (!ciphertext) {
throw new DecryptionError(
"OLM_MISSING_CIPHERTEXT",
"Missing ciphertext",
);
}
if (!(this._olmDevice.deviceCurve25519Key in ciphertext)) {
throw new DecryptionError(
"OLM_NOT_INCLUDED_IN_RECIPIENTS",
"Not included in recipients",
);
}
const message = ciphertext[this._olmDevice.deviceCurve25519Key];
let payloadString;
try {
payloadString = await this._decryptMessage(deviceKey, message);
} catch (e) {
throw new DecryptionError(
"OLM_BAD_ENCRYPTED_MESSAGE",
"Bad Encrypted Message", {
sender: deviceKey,
err: e,
},
);
}
const payload = JSON.parse(payloadString);
// check that we were the intended recipient, to avoid unknown-key attack
// https://github.com/vector-im/vector-web/issues/2483
if (payload.recipient != this._userId) {
throw new DecryptionError(
"OLM_BAD_RECIPIENT",
"Message was intented for " + payload.recipient,
);
}
if (payload.recipient_keys.ed25519 != this._olmDevice.deviceEd25519Key) {
throw new DecryptionError(
"OLM_BAD_RECIPIENT_KEY",
"Message not intended for this device", {
intended: payload.recipient_keys.ed25519,
our_key: this._olmDevice.deviceEd25519Key,
},
);
}
// check that the original sender matches what the homeserver told us, to
// avoid people masquerading as others.
// (this check is also provided via the sender's embedded ed25519 key,
// which is checked elsewhere).
if (payload.sender != event.getSender()) {
throw new DecryptionError(
"OLM_FORWARDED_MESSAGE",
"Message forwarded from " + payload.sender, {
reported_sender: event.getSender(),
},
);
}
// Olm events intended for a room have a room_id.
if (payload.room_id !== event.getRoomId()) {
throw new DecryptionError(
"OLM_BAD_ROOM",
"Message intended for room " + payload.room_id, {
reported_room: event.room_id,
},
);
}
const claimedKeys = payload.keys || {};
return {
clearEvent: payload,
senderCurve25519Key: deviceKey,
claimedEd25519Key: claimedKeys.ed25519 || null,
};
};
/**
* Attempt to decrypt an Olm message
*
* @param {string} theirDeviceIdentityKey Curve25519 identity key of the sender
* @param {object} message message object, with 'type' and 'body' fields
*
* @return {string} payload, if decrypted successfully.
*/
OlmDecryption.prototype._decryptMessage = async function(
theirDeviceIdentityKey, message,
) {
// This is a wrapper that serialises decryptions of prekey messages, because
// otherwise we race between deciding we have no active sessions for the message
// and creating a new one, which we can only do once because it removes the OTK.
if (message.type !== 0) {
// not a prekey message: we can safely just try & decrypt it
return this._reallyDecryptMessage(theirDeviceIdentityKey, message);
} else {
const myPromise = this._olmDevice._olmPrekeyPromise.then(() => {
return this._reallyDecryptMessage(theirDeviceIdentityKey, message);
});
// we want the error, but don't propagate it to the next decryption
this._olmDevice._olmPrekeyPromise = myPromise.catch(() => {});
return await myPromise;
}
};
OlmDecryption.prototype._reallyDecryptMessage = async function(
theirDeviceIdentityKey, message,
) {
const sessionIds = await this._olmDevice.getSessionIdsForDevice(
theirDeviceIdentityKey,
);
// try each session in turn.
const decryptionErrors = {};
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i];
try {
const payload = await this._olmDevice.decryptMessage(
theirDeviceIdentityKey, sessionId, message.type, message.body,
);
logger.log(
"Decrypted Olm message from " + theirDeviceIdentityKey +
" with session " + sessionId,
);
return payload;
} catch (e) {
const foundSession = await this._olmDevice.matchesSession(
theirDeviceIdentityKey, sessionId, message.type, message.body,
);
if (foundSession) {
// decryption failed, but it was a prekey message matching this
// session, so it should have worked.
throw new Error(
"Error decrypting prekey message with existing session id " +
sessionId + ": " + e.message,
);
}
// otherwise it's probably a message for another session; carry on, but
// keep a record of the error
decryptionErrors[sessionId] = e.message;
}
}
if (message.type !== 0) {
// not a prekey message, so it should have matched an existing session, but it
// didn't work.
if (sessionIds.length === 0) {
throw new Error("No existing sessions");
}
throw new Error(
"Error decrypting non-prekey message with existing sessions: " +
JSON.stringify(decryptionErrors),
);
}
// prekey message which doesn't match any existing sessions: make a new
// session.
let res;
try {
res = await this._olmDevice.createInboundSession(
theirDeviceIdentityKey, message.type, message.body,
);
} catch (e) {
decryptionErrors["(new)"] = e.message;
throw new Error(
"Error decrypting prekey message: " +
JSON.stringify(decryptionErrors),
);
}
logger.log(
"created new inbound Olm session ID " +
res.session_id + " with " + theirDeviceIdentityKey,
);
return res.payload;
};
registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption);

View File

@@ -0,0 +1,355 @@
/*
Copyright 2016 - 2021 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.
*/
/**
* Defines m.olm encryption/decryption
*
* @module crypto/algorithms/olm
*/
import { logger } from '../../logger';
import * as olmlib from "../olmlib";
import { DeviceInfo } from "../deviceinfo";
import {
DecryptionAlgorithm,
DecryptionError,
EncryptionAlgorithm,
registerAlgorithm,
} from "./base";
import { Room } from '../../models/room';
import { MatrixEvent } from "../..";
import { IEventDecryptionResult } from "../index";
const DeviceVerification = DeviceInfo.DeviceVerification;
interface IMessage {
type: number | string;
body: string;
}
/**
* Olm encryption implementation
*
* @constructor
* @extends {module:crypto/algorithms/EncryptionAlgorithm}
*
* @param {object} params parameters, as per
* {@link module:crypto/algorithms/EncryptionAlgorithm}
*/
class OlmEncryption extends EncryptionAlgorithm {
private sessionPrepared = false;
private prepPromise: Promise<void> = null;
/**
* @private
* @param {string[]} roomMembers list of currently-joined users in the room
* @return {Promise} Promise which resolves when setup is complete
*/
private ensureSession(roomMembers: string[]): Promise<void> {
if (this.prepPromise) {
// prep already in progress
return this.prepPromise;
}
if (this.sessionPrepared) {
// prep already done
return Promise.resolve();
}
this.prepPromise = this.crypto.downloadKeys(roomMembers).then((res) => {
return this.crypto.ensureOlmSessionsForUsers(roomMembers);
}).then(() => {
this.sessionPrepared = true;
}).finally(() => {
this.prepPromise = null;
});
return this.prepPromise;
}
/**
* @inheritdoc
*
* @param {module:models/room} room
* @param {string} eventType
* @param {object} content plaintext event content
*
* @return {Promise} Promise which resolves to the new event body
*/
public async encryptMessage(room: Room, eventType: string, content: object): Promise<object> {
// pick the list of recipients based on the membership list.
//
// TODO: there is a race condition here! What if a new user turns up
// just as you are sending a secret message?
const members = await room.getEncryptionTargetMembers();
const users = members.map(function(u) {
return u.userId;
});
await this.ensureSession(users);
const payloadFields = {
room_id: room.roomId,
type: eventType,
content: content,
};
const encryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: this.olmDevice.deviceCurve25519Key,
ciphertext: {},
};
const promises = [];
for (let i = 0; i < users.length; ++i) {
const userId = users[i];
const devices = this.crypto.getStoredDevicesForUser(userId);
for (let j = 0; j < devices.length; ++j) {
const deviceInfo = devices[j];
const key = deviceInfo.getIdentityKey();
if (key == this.olmDevice.deviceCurve25519Key) {
// don't bother sending to ourself
continue;
}
if (deviceInfo.verified == DeviceVerification.BLOCKED) {
// don't bother setting up sessions with blocked users
continue;
}
promises.push(
olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
this.userId, this.deviceId, this.olmDevice,
userId, deviceInfo, payloadFields,
),
);
}
}
return await Promise.all(promises).then(() => encryptedContent);
}
}
/**
* Olm decryption implementation
*
* @constructor
* @extends {module:crypto/algorithms/DecryptionAlgorithm}
* @param {object} params parameters, as per
* {@link module:crypto/algorithms/DecryptionAlgorithm}
*/
class OlmDecryption extends DecryptionAlgorithm {
/**
* @inheritdoc
*
* @param {MatrixEvent} event
*
* returns a promise which resolves to a
* {@link module:crypto~EventDecryptionResult} once we have finished
* decrypting. Rejects with an `algorithms.DecryptionError` if there is a
* problem decrypting the event.
*/
public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
const content = event.getWireContent();
const deviceKey = content.sender_key;
const ciphertext = content.ciphertext;
if (!ciphertext) {
throw new DecryptionError(
"OLM_MISSING_CIPHERTEXT",
"Missing ciphertext",
);
}
if (!(this.olmDevice.deviceCurve25519Key in ciphertext)) {
throw new DecryptionError(
"OLM_NOT_INCLUDED_IN_RECIPIENTS",
"Not included in recipients",
);
}
const message = ciphertext[this.olmDevice.deviceCurve25519Key];
let payloadString;
try {
payloadString = await this.decryptMessage(deviceKey, message);
} catch (e) {
throw new DecryptionError(
"OLM_BAD_ENCRYPTED_MESSAGE",
"Bad Encrypted Message", {
sender: deviceKey,
err: e,
},
);
}
const payload = JSON.parse(payloadString);
// check that we were the intended recipient, to avoid unknown-key attack
// https://github.com/vector-im/vector-web/issues/2483
if (payload.recipient != this.userId) {
throw new DecryptionError(
"OLM_BAD_RECIPIENT",
"Message was intented for " + payload.recipient,
);
}
if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) {
throw new DecryptionError(
"OLM_BAD_RECIPIENT_KEY",
"Message not intended for this device", {
intended: payload.recipient_keys.ed25519,
our_key: this.olmDevice.deviceEd25519Key,
},
);
}
// check that the original sender matches what the homeserver told us, to
// avoid people masquerading as others.
// (this check is also provided via the sender's embedded ed25519 key,
// which is checked elsewhere).
if (payload.sender != event.getSender()) {
throw new DecryptionError(
"OLM_FORWARDED_MESSAGE",
"Message forwarded from " + payload.sender, {
reported_sender: event.getSender(),
},
);
}
// Olm events intended for a room have a room_id.
if (payload.room_id !== event.getRoomId()) {
throw new DecryptionError(
"OLM_BAD_ROOM",
"Message intended for room " + payload.room_id, {
reported_room: event.getRoomId(),
},
);
}
const claimedKeys = payload.keys || {};
return {
clearEvent: payload,
senderCurve25519Key: deviceKey,
claimedEd25519Key: claimedKeys.ed25519 || null,
};
}
/**
* Attempt to decrypt an Olm message
*
* @param {string} theirDeviceIdentityKey Curve25519 identity key of the sender
* @param {object} message message object, with 'type' and 'body' fields
*
* @return {string} payload, if decrypted successfully.
*/
private async decryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise<string> {
// This is a wrapper that serialises decryptions of prekey messages, because
// otherwise we race between deciding we have no active sessions for the message
// and creating a new one, which we can only do once because it removes the OTK.
if (message.type !== 0) {
// not a prekey message: we can safely just try & decrypt it
return this.reallyDecryptMessage(theirDeviceIdentityKey, message);
} else {
const myPromise = this.olmDevice._olmPrekeyPromise.then(() => {
return this.reallyDecryptMessage(theirDeviceIdentityKey, message);
});
// we want the error, but don't propagate it to the next decryption
this.olmDevice._olmPrekeyPromise = myPromise.catch(() => {});
return await myPromise;
}
}
private async reallyDecryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise<string> {
const sessionIds = await this.olmDevice.getSessionIdsForDevice(theirDeviceIdentityKey);
// try each session in turn.
const decryptionErrors = {};
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i];
try {
const payload = await this.olmDevice.decryptMessage(
theirDeviceIdentityKey, sessionId, message.type, message.body,
);
logger.log(
"Decrypted Olm message from " + theirDeviceIdentityKey +
" with session " + sessionId,
);
return payload;
} catch (e) {
const foundSession = await this.olmDevice.matchesSession(
theirDeviceIdentityKey, sessionId, message.type, message.body,
);
if (foundSession) {
// decryption failed, but it was a prekey message matching this
// session, so it should have worked.
throw new Error(
"Error decrypting prekey message with existing session id " +
sessionId + ": " + e.message,
);
}
// otherwise it's probably a message for another session; carry on, but
// keep a record of the error
decryptionErrors[sessionId] = e.message;
}
}
if (message.type !== 0) {
// not a prekey message, so it should have matched an existing session, but it
// didn't work.
if (sessionIds.length === 0) {
throw new Error("No existing sessions");
}
throw new Error(
"Error decrypting non-prekey message with existing sessions: " +
JSON.stringify(decryptionErrors),
);
}
// prekey message which doesn't match any existing sessions: make a new
// session.
let res;
try {
res = await this.olmDevice.createInboundSession(
theirDeviceIdentityKey, message.type, message.body,
);
} catch (e) {
decryptionErrors["(new)"] = e.message;
throw new Error(
"Error decrypting prekey message: " +
JSON.stringify(decryptionErrors),
);
}
logger.log(
"created new inbound Olm session ID " +
res.session_id + " with " + theirDeviceIdentityKey,
);
return res.payload;
}
}
registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption);

View File

@@ -15,7 +15,7 @@ limitations under the License.
*/
import { DeviceInfo } from "./deviceinfo";
import { IKeyBackupVersion } from "./keybackup";
import { IKeyBackupInfo } from "./keybackup";
import { ISecretStorageKeyInfo } from "../matrix";
// TODO: Merge this with crypto.js once converted
@@ -60,7 +60,7 @@ export interface IEncryptedEventInfo {
export interface IRecoveryKey {
keyInfo: {
pubkey: Uint8Array;
pubkey: string;
passphrase?: {
algorithm: string;
iterations: number;
@@ -85,7 +85,7 @@ export interface ICreateSecretStorageOpts {
* The current key backup object. If passed,
* the passphrase and recovery key from this backup will be used.
*/
keyBackupInfo?: IKeyBackupVersion;
keyBackupInfo?: IKeyBackupInfo;
/**
* If true, a new key backup version will be

View File

@@ -29,33 +29,42 @@ import { keyFromPassphrase } from './key_passphrase';
import { sleep } from "../utils";
import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store';
import { encodeRecoveryKey } from './recoverykey';
import { IKeyBackupInfo } from "./keybackup";
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
type AuthData = Record<string, any>;
type BackupInfo = {
algorithm: string,
auth_data: AuthData, // eslint-disable-line camelcase
[properties: string]: any,
};
type AuthData = IKeyBackupInfo["auth_data"];
type SigInfo = {
deviceId: string,
valid?: boolean | null, // true: valid, false: invalid, null: cannot attempt validation
device?: DeviceInfo | null,
crossSigningId?: boolean,
deviceTrust?: DeviceTrustLevel,
deviceId: string;
valid?: boolean | null; // true: valid, false: invalid, null: cannot attempt validation
device?: DeviceInfo | null;
crossSigningId?: boolean;
deviceTrust?: DeviceTrustLevel;
};
type TrustInfo = {
usable: boolean, // is the backup trusted, true iff there is a sig that is valid & from a trusted device
sigs: SigInfo[],
export type TrustInfo = {
usable: boolean; // is the backup trusted, true iff there is a sig that is valid & from a trusted device
sigs: SigInfo[];
};
export interface IKeyBackupCheck {
backupInfo: IKeyBackupInfo;
trustInfo: TrustInfo;
}
/* eslint-disable camelcase */
export interface IPreparedKeyBackupVersion {
algorithm: string;
auth_data: AuthData;
recovery_key: string;
privateKey: Uint8Array;
}
/* eslint-enable camelcase */
/** A function used to get the secret key for a backup.
*/
type GetKey = () => Promise<Uint8Array>;
type GetKey = () => Promise<ArrayLike<number>>;
interface BackupAlgorithmClass {
algorithmName: string;
@@ -72,7 +81,7 @@ interface BackupAlgorithm {
encryptSession(data: Record<string, any>): Promise<any>;
decryptSessions(ciphertexts: Record<string, any>): Promise<Record<string, any>[]>;
authData: AuthData;
keyMatches(key: Uint8Array): Promise<boolean>;
keyMatches(key: ArrayLike<number>): Promise<boolean>;
free(): void;
}
@@ -81,7 +90,7 @@ interface BackupAlgorithm {
*/
export class BackupManager {
private algorithm: BackupAlgorithm | undefined;
private backupInfo: BackupInfo | undefined; // The info dict from /room_keys/version
public backupInfo: IKeyBackupInfo | undefined; // The info dict from /room_keys/version
public checkedForBackup: boolean; // Have we checked the server for a backup we can use?
private sendingBackups: boolean; // Are we currently sending backups?
constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) {
@@ -93,7 +102,7 @@ export class BackupManager {
return this.backupInfo && this.backupInfo.version;
}
public static async makeAlgorithm(info: BackupInfo, getKey: GetKey): Promise<BackupAlgorithm> {
public static async makeAlgorithm(info: IKeyBackupInfo, getKey: GetKey): Promise<BackupAlgorithm> {
const Algorithm = algorithmsByName[info.algorithm];
if (!Algorithm) {
throw new Error("Unknown backup algorithm");
@@ -101,7 +110,7 @@ export class BackupManager {
return await Algorithm.init(info.auth_data, getKey);
}
public async enableKeyBackup(info: BackupInfo): Promise<void> {
public async enableKeyBackup(info: IKeyBackupInfo): Promise<void> {
this.backupInfo = info;
if (this.algorithm) {
this.algorithm.free();
@@ -140,7 +149,8 @@ export class BackupManager {
public async prepareKeyBackupVersion(
key?: string | Uint8Array | null,
algorithm?: string | undefined,
): Promise<BackupInfo> {
// eslint-disable-next-line camelcase
): Promise<IPreparedKeyBackupVersion> {
const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm;
if (!Algorithm) {
throw new Error("Unknown backup algorithm");
@@ -156,7 +166,7 @@ export class BackupManager {
};
}
public async createKeyBackupVersion(info: BackupInfo): Promise<void> {
public async createKeyBackupVersion(info: IKeyBackupInfo): Promise<void> {
this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
}
@@ -166,14 +176,14 @@ export class BackupManager {
* one of the user's verified devices, start backing up
* to it.
*/
public async checkAndStart(): Promise<{backupInfo: BackupInfo, trustInfo: TrustInfo}> {
public async checkAndStart(): Promise<IKeyBackupCheck> {
logger.log("Checking key backup status...");
if (this.baseApis.isGuest()) {
logger.log("Skipping key backup check since user is guest");
this.checkedForBackup = true;
return null;
}
let backupInfo: BackupInfo;
let backupInfo: IKeyBackupInfo;
try {
backupInfo = await this.baseApis.getKeyBackupVersion();
} catch (e) {
@@ -232,7 +242,7 @@ export class BackupManager {
* trust information (as returned by isKeyBackupTrusted)
* in trustInfo.
*/
public async checkKeyBackup(): Promise<{backupInfo: BackupInfo, trustInfo: TrustInfo}> {
public async checkKeyBackup(): Promise<IKeyBackupCheck> {
this.checkedForBackup = false;
return this.checkAndStart();
}
@@ -250,7 +260,7 @@ export class BackupManager {
* ]
* }
*/
public async isKeyBackupTrusted(backupInfo: BackupInfo): Promise<TrustInfo> {
public async isKeyBackupTrusted(backupInfo: IKeyBackupInfo): Promise<TrustInfo> {
const ret = {
usable: false,
trusted_locally: false,
@@ -268,7 +278,7 @@ export class BackupManager {
return ret;
}
const trustedPubkey = this.baseApis.crypto._sessionStore.getLocalTrustedBackupPubKey();
const trustedPubkey = this.baseApis.crypto.sessionStore.getLocalTrustedBackupPubKey();
if (backupInfo.auth_data.public_key === trustedPubkey) {
logger.info("Backup public key " + trustedPubkey + " is trusted locally");
@@ -288,12 +298,12 @@ export class BackupManager {
const sigInfo: SigInfo = { deviceId: keyIdParts[1] };
// first check to see if it's from our cross-signing key
const crossSigningId = this.baseApis.crypto._crossSigningInfo.getId();
const crossSigningId = this.baseApis.crypto.crossSigningInfo.getId();
if (crossSigningId === sigInfo.deviceId) {
sigInfo.crossSigningId = true;
try {
await verifySignature(
this.baseApis.crypto._olmDevice,
this.baseApis.crypto.olmDevice,
backupInfo.auth_data,
this.baseApis.getUserId(),
sigInfo.deviceId,
@@ -313,7 +323,7 @@ export class BackupManager {
// Now look for a sig from a device
// At some point this can probably go away and we'll just support
// it being signed by the cross-signing master key
const device = this.baseApis.crypto._deviceList.getStoredDevice(
const device = this.baseApis.crypto.deviceList.getStoredDevice(
this.baseApis.getUserId(), sigInfo.deviceId,
);
if (device) {
@@ -323,7 +333,7 @@ export class BackupManager {
);
try {
await verifySignature(
this.baseApis.crypto._olmDevice,
this.baseApis.crypto.olmDevice,
backupInfo.auth_data,
this.baseApis.getUserId(),
device.deviceId,
@@ -423,12 +433,12 @@ export class BackupManager {
* @returns {integer} Number of sessions backed up
*/
private async backupPendingKeys(limit: number): Promise<number> {
const sessions = await this.baseApis.crypto._cryptoStore.getSessionsNeedingBackup(limit);
const sessions = await this.baseApis.crypto.cryptoStore.getSessionsNeedingBackup(limit);
if (!sessions.length) {
return 0;
}
let remaining = await this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup();
let remaining = await this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup();
this.baseApis.crypto.emit("crypto.keyBackupSessionsRemaining", remaining);
const data = {};
@@ -438,7 +448,7 @@ export class BackupManager {
data[roomId] = { sessions: {} };
}
const sessionData = await this.baseApis.crypto._olmDevice.exportInboundGroupSession(
const sessionData = await this.baseApis.crypto.olmDevice.exportInboundGroupSession(
session.senderKey, session.sessionId, session.sessionData,
);
sessionData.algorithm = MEGOLM_ALGORITHM;
@@ -446,13 +456,13 @@ export class BackupManager {
const forwardedCount =
(sessionData.forwarding_curve25519_key_chain || []).length;
const userId = this.baseApis.crypto._deviceList.getUserByIdentityKey(
const userId = this.baseApis.crypto.deviceList.getUserByIdentityKey(
MEGOLM_ALGORITHM, session.senderKey,
);
const device = this.baseApis.crypto._deviceList.getDeviceByIdentityKey(
const device = this.baseApis.crypto.deviceList.getDeviceByIdentityKey(
MEGOLM_ALGORITHM, session.senderKey,
);
const verified = this.baseApis.crypto._checkDeviceInfoTrust(userId, device).isVerified();
const verified = this.baseApis.crypto.checkDeviceInfoTrust(userId, device).isVerified();
data[roomId]['sessions'][session.sessionId] = {
first_message_index: sessionData.first_known_index,
@@ -467,8 +477,8 @@ export class BackupManager {
{ rooms: data },
);
await this.baseApis.crypto._cryptoStore.unmarkSessionsNeedingBackup(sessions);
remaining = await this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup();
await this.baseApis.crypto.cryptoStore.unmarkSessionsNeedingBackup(sessions);
remaining = await this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup();
this.baseApis.crypto.emit("crypto.keyBackupSessionsRemaining", remaining);
return sessions.length;
@@ -477,7 +487,7 @@ export class BackupManager {
public async backupGroupSession(
senderKey: string, sessionId: string,
): Promise<void> {
await this.baseApis.crypto._cryptoStore.markSessionsNeedingBackup([{
await this.baseApis.crypto.cryptoStore.markSessionsNeedingBackup([{
senderKey: senderKey,
sessionId: sessionId,
}]);
@@ -509,22 +519,22 @@ export class BackupManager {
* (which will be equal to the number of sessions in the store).
*/
public async flagAllGroupSessionsForBackup(): Promise<number> {
await this.baseApis.crypto._cryptoStore.doTxn(
await this.baseApis.crypto.cryptoStore.doTxn(
'readwrite',
[
IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS,
IndexedDBCryptoStore.STORE_BACKUP,
],
(txn) => {
this.baseApis.crypto._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => {
this.baseApis.crypto.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => {
if (session !== null) {
this.baseApis.crypto._cryptoStore.markSessionsNeedingBackup([session], txn);
this.baseApis.crypto.cryptoStore.markSessionsNeedingBackup([session], txn);
}
});
},
);
const remaining = await this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup();
const remaining = await this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup();
this.baseApis.emit("crypto.keyBackupSessionsRemaining", remaining);
return remaining;
}
@@ -534,7 +544,7 @@ export class BackupManager {
* @returns {Promise<int>} Resolves to the number of sessions requiring backup
*/
public countSessionsNeedingBackup(): Promise<number> {
return this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup();
return this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup();
}
}
@@ -564,7 +574,7 @@ export class Curve25519 implements BackupAlgorithm {
): Promise<[Uint8Array, AuthData]> {
const decryption = new global.Olm.PkDecryption();
try {
const authData: AuthData = {};
const authData: Partial<AuthData> = {};
if (!key) {
authData.public_key = decryption.generate_key();
} else if (key instanceof Uint8Array) {
@@ -580,7 +590,7 @@ export class Curve25519 implements BackupAlgorithm {
return [
decryption.get_private_key(),
authData,
authData as AuthData,
];
} finally {
decryption.free();

View File

@@ -44,7 +44,7 @@ interface DeviceKeys {
signatures?: Signatures;
}
interface OneTimeKey {
export interface OneTimeKey {
key: string;
fallback?: boolean;
signatures?: Signatures;
@@ -64,16 +64,16 @@ export class DehydrationManager {
this.getDehydrationKeyFromCache();
}
async getDehydrationKeyFromCache(): Promise<void> {
return await this.crypto._cryptoStore.doTxn(
return await this.crypto.cryptoStore.doTxn(
'readonly',
[IndexedDBCryptoStore.STORE_ACCOUNT],
(txn) => {
this.crypto._cryptoStore.getSecretStorePrivateKey(
this.crypto.cryptoStore.getSecretStorePrivateKey(
txn,
async (result) => {
if (result) {
const { key, keyInfo, deviceDisplayName, time } = result;
const pickleKey = Buffer.from(this.crypto._olmDevice._pickleKey);
const pickleKey = Buffer.from(this.crypto.olmDevice._pickleKey);
const decrypted = await decryptAES(key, pickleKey, DEHYDRATION_ALGORITHM);
this.key = decodeBase64(decrypted);
this.keyInfo = keyInfo;
@@ -114,11 +114,11 @@ export class DehydrationManager {
this.timeoutId = undefined;
}
// clear storage
await this.crypto._cryptoStore.doTxn(
await this.crypto.cryptoStore.doTxn(
'readwrite',
[IndexedDBCryptoStore.STORE_ACCOUNT],
(txn) => {
this.crypto._cryptoStore.storeSecretStorePrivateKey(
this.crypto.cryptoStore.storeSecretStorePrivateKey(
txn, "dehydration", null,
);
},
@@ -158,15 +158,15 @@ export class DehydrationManager {
this.timeoutId = undefined;
}
try {
const pickleKey = Buffer.from(this.crypto._olmDevice._pickleKey);
const pickleKey = Buffer.from(this.crypto.olmDevice._pickleKey);
// update the crypto store with the timestamp
const key = await encryptAES(encodeBase64(this.key), pickleKey, DEHYDRATION_ALGORITHM);
await this.crypto._cryptoStore.doTxn(
await this.crypto.cryptoStore.doTxn(
'readwrite',
[IndexedDBCryptoStore.STORE_ACCOUNT],
(txn) => {
this.crypto._cryptoStore.storeSecretStorePrivateKey(
this.crypto.cryptoStore.storeSecretStorePrivateKey(
txn, "dehydration",
{
keyInfo: this.keyInfo,
@@ -205,7 +205,7 @@ export class DehydrationManager {
}
logger.log("Uploading account to server");
const dehydrateResult = await this.crypto._baseApis.http.authedRequest(
const dehydrateResult = await this.crypto.baseApis.http.authedRequest(
undefined,
"PUT",
"/dehydrated_device",
@@ -223,9 +223,9 @@ export class DehydrationManager {
const deviceId = dehydrateResult.device_id;
logger.log("Preparing device keys", deviceId);
const deviceKeys: DeviceKeys = {
algorithms: this.crypto._supportedAlgorithms,
algorithms: this.crypto.supportedAlgorithms,
device_id: deviceId,
user_id: this.crypto._userId,
user_id: this.crypto.userId,
keys: {
[`ed25519:${deviceId}`]: e2eKeys.ed25519,
[`curve25519:${deviceId}`]: e2eKeys.curve25519,
@@ -233,12 +233,12 @@ export class DehydrationManager {
};
const deviceSignature = account.sign(anotherjson.stringify(deviceKeys));
deviceKeys.signatures = {
[this.crypto._userId]: {
[this.crypto.userId]: {
[`ed25519:${deviceId}`]: deviceSignature,
},
};
if (this.crypto._crossSigningInfo.getId("self_signing")) {
await this.crypto._crossSigningInfo.signObject(deviceKeys, "self_signing");
if (this.crypto.crossSigningInfo.getId("self_signing")) {
await this.crypto.crossSigningInfo.signObject(deviceKeys, "self_signing");
}
logger.log("Preparing one-time keys");
@@ -247,7 +247,7 @@ export class DehydrationManager {
const k: OneTimeKey = { key };
const signature = account.sign(anotherjson.stringify(k));
k.signatures = {
[this.crypto._userId]: {
[this.crypto.userId]: {
[`ed25519:${deviceId}`]: signature,
},
};
@@ -260,7 +260,7 @@ export class DehydrationManager {
const k: OneTimeKey = { key, fallback: true };
const signature = account.sign(anotherjson.stringify(k));
k.signatures = {
[this.crypto._userId]: {
[this.crypto.userId]: {
[`ed25519:${deviceId}`]: signature,
},
};
@@ -268,7 +268,7 @@ export class DehydrationManager {
}
logger.log("Uploading keys to server");
await this.crypto._baseApis.http.authedRequest(
await this.crypto.baseApis.http.authedRequest(
undefined,
"POST",
"/keys/upload/" + encodeURI(deviceId),
@@ -292,7 +292,7 @@ export class DehydrationManager {
}
}
private stop() {
public stop() {
if (this.timeoutId) {
global.clearTimeout(this.timeoutId);
this.timeoutId = undefined;

View File

@@ -1,168 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 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.
*/
/**
* @module crypto/deviceinfo
*/
/**
* Information about a user's device
*
* @constructor
* @alias module:crypto/deviceinfo
*
* @property {string} deviceId the ID of this device
*
* @property {string[]} algorithms list of algorithms supported by this device
*
* @property {Object.<string,string>} keys a map from
* &lt;key type&gt;:&lt;id&gt; -> &lt;base64-encoded key&gt;>
*
* @property {module:crypto/deviceinfo.DeviceVerification} verified
* whether the device has been verified/blocked by the user
*
* @property {boolean} known
* whether the user knows of this device's existence (useful when warning
* the user that a user has added new devices)
*
* @property {Object} unsigned additional data from the homeserver
*
* @param {string} deviceId id of the device
*/
export function DeviceInfo(deviceId) {
// you can't change the deviceId
Object.defineProperty(this, 'deviceId', {
enumerable: true,
value: deviceId,
});
this.algorithms = [];
this.keys = {};
this.verified = DeviceVerification.UNVERIFIED;
this.known = false;
this.unsigned = {};
this.signatures = {};
}
/**
* rehydrate a DeviceInfo from the session store
*
* @param {object} obj raw object from session store
* @param {string} deviceId id of the device
*
* @return {module:crypto~DeviceInfo} new DeviceInfo
*/
DeviceInfo.fromStorage = function(obj, deviceId) {
const res = new DeviceInfo(deviceId);
for (const prop in obj) {
if (obj.hasOwnProperty(prop)) {
res[prop] = obj[prop];
}
}
return res;
};
/**
* Prepare a DeviceInfo for JSON serialisation in the session store
*
* @return {object} deviceinfo with non-serialised members removed
*/
DeviceInfo.prototype.toStorage = function() {
return {
algorithms: this.algorithms,
keys: this.keys,
verified: this.verified,
known: this.known,
unsigned: this.unsigned,
signatures: this.signatures,
};
};
/**
* Get the fingerprint for this device (ie, the Ed25519 key)
*
* @return {string} base64-encoded fingerprint of this device
*/
DeviceInfo.prototype.getFingerprint = function() {
return this.keys["ed25519:" + this.deviceId];
};
/**
* Get the identity key for this device (ie, the Curve25519 key)
*
* @return {string} base64-encoded identity key of this device
*/
DeviceInfo.prototype.getIdentityKey = function() {
return this.keys["curve25519:" + this.deviceId];
};
/**
* Get the configured display name for this device, if any
*
* @return {string?} displayname
*/
DeviceInfo.prototype.getDisplayName = function() {
return this.unsigned.device_display_name || null;
};
/**
* Returns true if this device is blocked
*
* @return {Boolean} true if blocked
*/
DeviceInfo.prototype.isBlocked = function() {
return this.verified == DeviceVerification.BLOCKED;
};
/**
* Returns true if this device is verified
*
* @return {Boolean} true if verified
*/
DeviceInfo.prototype.isVerified = function() {
return this.verified == DeviceVerification.VERIFIED;
};
/**
* Returns true if this device is unverified
*
* @return {Boolean} true if unverified
*/
DeviceInfo.prototype.isUnverified = function() {
return this.verified == DeviceVerification.UNVERIFIED;
};
/**
* Returns true if the user knows about this device's existence
*
* @return {Boolean} true if known
*/
DeviceInfo.prototype.isKnown = function() {
return this.known == true;
};
/**
* @enum
*/
DeviceInfo.DeviceVerification = {
VERIFIED: 1,
UNVERIFIED: 0,
BLOCKED: -1,
};
const DeviceVerification = DeviceInfo.DeviceVerification;

177
src/crypto/deviceinfo.ts Normal file
View File

@@ -0,0 +1,177 @@
/*
Copyright 2016 - 2021 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 { ISignatures } from "../@types/signed";
/**
* @module crypto/deviceinfo
*/
export interface IDevice {
keys: Record<string, string>;
algorithms: string[];
verified: DeviceVerification;
known: boolean;
unsigned?: Record<string, any>;
signatures?: ISignatures;
}
enum DeviceVerification {
Blocked = -1,
Unverified = 0,
Verified = 1,
}
/**
* Information about a user's device
*
* @constructor
* @alias module:crypto/deviceinfo
*
* @property {string} deviceId the ID of this device
*
* @property {string[]} algorithms list of algorithms supported by this device
*
* @property {Object.<string,string>} keys a map from
* &lt;key type&gt;:&lt;id&gt; -> &lt;base64-encoded key&gt;>
*
* @property {module:crypto/deviceinfo.DeviceVerification} verified
* whether the device has been verified/blocked by the user
*
* @property {boolean} known
* whether the user knows of this device's existence (useful when warning
* the user that a user has added new devices)
*
* @property {Object} unsigned additional data from the homeserver
*
* @param {string} deviceId id of the device
*/
export class DeviceInfo {
/**
* rehydrate a DeviceInfo from the session store
*
* @param {object} obj raw object from session store
* @param {string} deviceId id of the device
*
* @return {module:crypto~DeviceInfo} new DeviceInfo
*/
public static fromStorage(obj: IDevice, deviceId: string): DeviceInfo {
const res = new DeviceInfo(deviceId);
for (const prop in obj) {
if (obj.hasOwnProperty(prop)) {
res[prop] = obj[prop];
}
}
return res;
}
/**
* @enum
*/
public static DeviceVerification = {
VERIFIED: DeviceVerification.Verified,
UNVERIFIED: DeviceVerification.Unverified,
BLOCKED: DeviceVerification.Blocked,
};
public algorithms: string[];
public keys: Record<string, string> = {};
public verified = DeviceVerification.Unverified;
public known = false;
public unsigned: Record<string, any> = {};
public signatures: ISignatures = {};
constructor(public readonly deviceId: string) {}
/**
* Prepare a DeviceInfo for JSON serialisation in the session store
*
* @return {object} deviceinfo with non-serialised members removed
*/
public toStorage(): IDevice {
return {
algorithms: this.algorithms,
keys: this.keys,
verified: this.verified,
known: this.known,
unsigned: this.unsigned,
signatures: this.signatures,
};
}
/**
* Get the fingerprint for this device (ie, the Ed25519 key)
*
* @return {string} base64-encoded fingerprint of this device
*/
public getFingerprint(): string {
return this.keys["ed25519:" + this.deviceId];
}
/**
* Get the identity key for this device (ie, the Curve25519 key)
*
* @return {string} base64-encoded identity key of this device
*/
public getIdentityKey(): string {
return this.keys["curve25519:" + this.deviceId];
}
/**
* Get the configured display name for this device, if any
*
* @return {string?} displayname
*/
public getDisplayName(): string | null {
return this.unsigned.device_display_name || null;
}
/**
* Returns true if this device is blocked
*
* @return {Boolean} true if blocked
*/
public isBlocked(): boolean {
return this.verified == DeviceVerification.Blocked;
}
/**
* Returns true if this device is verified
*
* @return {Boolean} true if verified
*/
public isVerified(): boolean {
return this.verified == DeviceVerification.Verified;
}
/**
* Returns true if this device is unverified
*
* @return {Boolean} true if unverified
*/
public isUnverified(): boolean {
return this.verified == DeviceVerification.Unverified;
}
/**
* Returns true if the user knows about this device's existence
*
* @return {Boolean} true if known
*/
public isKnown(): boolean {
return this.known === true;
}
}

File diff suppressed because it is too large Load Diff

3719
src/crypto/index.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 - 2021 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.
@@ -21,7 +20,21 @@ const DEFAULT_ITERATIONS = 500000;
const DEFAULT_BITSIZE = 256;
export async function keyFromAuthData(authData, password) {
/* eslint-disable camelcase */
interface IAuthData {
private_key_salt: string;
private_key_iterations: number;
private_key_bits?: number;
}
/* eslint-enable camelcase */
interface IKey {
key: Uint8Array;
salt: string;
iterations: number;
}
export async function keyFromAuthData(authData: IAuthData, password: string): Promise<Uint8Array> {
if (!global.Olm) {
throw new Error("Olm is not available");
}
@@ -40,7 +53,7 @@ export async function keyFromAuthData(authData, password) {
);
}
export async function keyFromPassphrase(password) {
export async function keyFromPassphrase(password: string): Promise<IKey> {
if (!global.Olm) {
throw new Error("Olm is not available");
}
@@ -52,7 +65,12 @@ export async function keyFromPassphrase(password) {
return { key, salt, iterations: DEFAULT_ITERATIONS };
}
export async function deriveKey(password, salt, iterations, numBits = DEFAULT_BITSIZE) {
export async function deriveKey(
password: string,
salt: string,
iterations: number,
numBits = DEFAULT_BITSIZE,
): Promise<Uint8Array> {
const subtleCrypto = global.crypto.subtle;
const TextEncoder = global.TextEncoder;
if (!subtleCrypto || !TextEncoder) {

View File

@@ -15,7 +15,6 @@ limitations under the License.
*/
import { ISignatures } from "../@types/signed";
import { DeviceInfo } from "./deviceinfo";
export interface IKeyBackupSession {
first_message_index: number; // eslint-disable-line camelcase
@@ -32,28 +31,21 @@ export interface IKeyBackupRoomSessions {
[sessionId: string]: IKeyBackupSession;
}
export interface IKeyBackupVersion {
/* eslint-disable camelcase */
export interface IKeyBackupInfo {
algorithm: string;
auth_data: { // eslint-disable-line camelcase
public_key: string; // eslint-disable-line camelcase
auth_data: {
public_key: string;
signatures: ISignatures;
private_key_salt: string;
private_key_iterations: number;
private_key_bits?: number;
};
count: number;
etag: string;
version: string; // number contained within
}
// TODO: Verify types
export interface IKeyBackupTrustInfo {
/**
* is the backup trusted, true if there is a sig that is valid & from a trusted device
*/
usable: boolean[];
sigs: {
valid: boolean[];
device: DeviceInfo[];
}[];
count?: number;
etag?: string;
version?: string; // number contained within
}
/* eslint-enable camelcase */
export interface IKeyBackupPrepareOpts {
secureSecretStorage: boolean;

View File

@@ -1,7 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -22,24 +20,42 @@ limitations under the License.
* Utilities common to olm encryption algorithms
*/
import anotherjson from "another-json";
import type { PkSigning } from "@matrix-org/olm";
import { Logger } from "loglevel";
import OlmDevice from "./OlmDevice";
import { DeviceInfo } from "./deviceinfo";
import { logger } from '../logger';
import * as utils from "../utils";
import anotherjson from "another-json";
import { OneTimeKey } from "./dehydration";
import { MatrixClient } from "../client";
enum Algorithm {
Olm = "m.olm.v1.curve25519-aes-sha2",
Megolm = "m.megolm.v1.aes-sha2",
MegolmBackup = "m.megolm_backup.v1.curve25519-aes-sha2",
}
/**
* matrix algorithm tag for olm
*/
export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2";
export const OLM_ALGORITHM = Algorithm.Olm;
/**
* matrix algorithm tag for megolm
*/
export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2";
export const MEGOLM_ALGORITHM = Algorithm.Megolm;
/**
* matrix algorithm tag for megolm backups
*/
export const MEGOLM_BACKUP_ALGORITHM = "m.megolm_backup.v1.curve25519-aes-sha2";
export const MEGOLM_BACKUP_ALGORITHM = Algorithm.MegolmBackup;
export interface IOlmSessionResult {
device: DeviceInfo;
sessionId?: string;
}
/**
* Encrypt an event payload for an Olm device
@@ -58,9 +74,13 @@ export const MEGOLM_BACKUP_ALGORITHM = "m.megolm_backup.v1.curve25519-aes-sha2";
* has been encrypted into `resultsObject`
*/
export async function encryptMessageForDevice(
resultsObject,
ourUserId, ourDeviceId, olmDevice, recipientUserId, recipientDevice,
payloadFields,
resultsObject: Record<string, string>,
ourUserId: string,
ourDeviceId: string,
olmDevice: OlmDevice,
recipientUserId: string,
recipientDevice: DeviceInfo,
payloadFields: Record<string, any>,
) {
const deviceKey = recipientDevice.getIdentityKey();
const sessionId = await olmDevice.getSessionIdForDevice(deviceKey);
@@ -77,6 +97,7 @@ export async function encryptMessageForDevice(
const payload = {
sender: ourUserId,
// TODO this appears to no longer be used whatsoever
sender_device: ourDeviceId,
// Include the Ed25519 key so that the recipient knows what
@@ -129,7 +150,9 @@ export async function encryptMessageForDevice(
* a map from userId to deviceId to {@link module:crypto~OlmSessionResult}
*/
export async function getExistingOlmSessions(
olmDevice, baseApis, devicesByUser,
olmDevice: OlmDevice,
baseApis: MatrixClient,
devicesByUser: Record<string, DeviceInfo[]>,
) {
const devicesWithoutSession = {};
const sessions = {};
@@ -189,23 +212,30 @@ export async function getExistingOlmSessions(
* {@link module:crypto~OlmSessionResult}
*/
export async function ensureOlmSessionsForDevices(
olmDevice, baseApis, devicesByUser, force, otkTimeout, failedServers, log,
) {
olmDevice: OlmDevice,
baseApis: MatrixClient,
devicesByUser: Record<string, DeviceInfo[]>,
force = false,
otkTimeout?: number,
failedServers?: string[],
log: Logger = logger,
): Promise<Record<string, Record<string, IOlmSessionResult>>> {
if (typeof force === "number") {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - backwards compatibility
log = failedServers;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - backwards compatibility
failedServers = otkTimeout;
otkTimeout = force;
force = false;
}
if (!log) {
log = logger;
}
const devicesWithoutSession = [
// [userId, deviceId], ...
];
const result = {};
const resolveSession = {};
const resolveSession: Record<string, (sessionId?: string) => void> = {};
// Mark all sessions this task intends to update as in progress. It is
// important to do this for all devices this task cares about in a single
@@ -227,9 +257,9 @@ export async function ensureOlmSessionsForDevices(
// conditions. If we find that we already have a session, then
// we'll resolve
olmDevice._sessionsInProgress[key] = new Promise(resolve => {
resolveSession[key] = (...args) => {
resolveSession[key] = (v: any) => {
delete olmDevice._sessionsInProgress[key];
resolve(...args);
resolve(v);
};
});
}
@@ -375,7 +405,12 @@ export async function ensureOlmSessionsForDevices(
return result;
}
async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo) {
async function _verifyKeyAndStartSession(
olmDevice: OlmDevice,
oneTimeKey: OneTimeKey,
userId: string,
deviceInfo: DeviceInfo,
): Promise<string> {
const deviceId = deviceInfo.deviceId;
try {
await verifySignature(
@@ -407,6 +442,11 @@ async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceIn
return sid;
}
export interface IObject {
unsigned?: object;
signatures?: object;
}
/**
* Verify the signature on an object
*
@@ -424,7 +464,11 @@ async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceIn
* or rejects with an Error if it is bad.
*/
export async function verifySignature(
olmDevice, obj, signingUserId, signingDeviceId, signingKey,
olmDevice: OlmDevice,
obj: OneTimeKey | IObject,
signingUserId: string,
signingDeviceId: string,
signingKey: string,
) {
const signKeyId = "ed25519:" + signingDeviceId;
const signatures = obj.signatures || {};
@@ -434,10 +478,11 @@ export async function verifySignature(
throw Error("No signature");
}
// prepare the canonical json: remove unsigned and signatures, and stringify with
// anotherjson
// prepare the canonical json: remove unsigned and signatures, and stringify with anotherjson
const mangledObj = Object.assign({}, obj);
delete mangledObj.unsigned;
if ("unsigned" in mangledObj) {
delete mangledObj.unsigned;
}
delete mangledObj.signatures;
const json = anotherjson.stringify(mangledObj);
@@ -453,14 +498,14 @@ export async function verifySignature(
* @param {Olm.PkSigning|Uint8Array} key the signing object or the private key
* seed
* @param {string} userId The user ID who owns the signing key
* @param {string} pubkey The public key (ignored if key is a seed)
* @param {string} pubKey The public key (ignored if key is a seed)
* @returns {string} the signature for the object
*/
export function pkSign(obj, key, userId, pubkey) {
export function pkSign(obj: IObject, key: PkSigning, userId: string, pubKey: string): string {
let createdKey = false;
if (key instanceof Uint8Array) {
const keyObj = new global.Olm.PkSigning();
pubkey = keyObj.init_with_seed(key);
pubKey = keyObj.init_with_seed(key);
key = keyObj;
createdKey = true;
}
@@ -472,7 +517,7 @@ export function pkSign(obj, key, userId, pubkey) {
const mysigs = sigs[userId] || {};
sigs[userId] = mysigs;
return mysigs['ed25519:' + pubkey] = key.sign(anotherjson.stringify(obj));
return mysigs['ed25519:' + pubKey] = key.sign(anotherjson.stringify(obj));
} finally {
obj.signatures = sigs;
if (unsigned) obj.unsigned = unsigned;
@@ -485,11 +530,11 @@ export function pkSign(obj, key, userId, pubkey) {
/**
* Verify a signed JSON object
* @param {Object} obj Object to verify
* @param {string} pubkey The public key to use to verify
* @param {string} pubKey The public key to use to verify
* @param {string} userId The user ID who signed the object
*/
export function pkVerify(obj, pubkey, userId) {
const keyId = "ed25519:" + pubkey;
export function pkVerify(obj: IObject, pubKey: string, userId: string) {
const keyId = "ed25519:" + pubKey;
if (!(obj.signatures && obj.signatures[userId] && obj.signatures[userId][keyId])) {
throw new Error("No signature");
}
@@ -500,7 +545,7 @@ export function pkVerify(obj, pubkey, userId) {
const unsigned = obj.unsigned;
if (obj.unsigned) delete obj.unsigned;
try {
util.ed25519_verify(pubkey, anotherjson.stringify(obj), signature);
util.ed25519_verify(pubKey, anotherjson.stringify(obj), signature);
} finally {
obj.signatures = sigs;
if (unsigned) obj.unsigned = unsigned;
@@ -513,7 +558,7 @@ export function pkVerify(obj, pubkey, userId) {
* @param {Uint8Array} uint8Array The data to encode.
* @return {string} The base64.
*/
export function encodeBase64(uint8Array) {
export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string {
return Buffer.from(uint8Array).toString("base64");
}
@@ -522,7 +567,7 @@ export function encodeBase64(uint8Array) {
* @param {Uint8Array} uint8Array The data to encode.
* @return {string} The unpadded base64.
*/
export function encodeUnpaddedBase64(uint8Array) {
export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
return encodeBase64(uint8Array).replace(/=+$/g, '');
}
@@ -531,6 +576,6 @@ export function encodeUnpaddedBase64(uint8Array) {
* @param {string} base64 The base64 to decode.
* @return {Uint8Array} The decoded data.
*/
export function decodeBase64(base64) {
export function decodeBase64(base64: string): Uint8Array {
return Buffer.from(base64, "base64");
}

View File

@@ -20,7 +20,7 @@ import bs58 from 'bs58';
// (which are also base58 encoded, but bitcoin's involve a lot more hashing)
const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01];
export function encodeRecoveryKey(key) {
export function encodeRecoveryKey(key: ArrayLike<number>): string {
const buf = new Buffer(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1);
buf.set(OLM_RECOVERY_KEY_PREFIX, 0);
buf.set(key, OLM_RECOVERY_KEY_PREFIX.length);
@@ -35,8 +35,8 @@ export function encodeRecoveryKey(key) {
return base58key.match(/.{1,4}/g).join(" ");
}
export function decodeRecoveryKey(recoverykey) {
const result = bs58.decode(recoverykey.replace(/ /g, ''));
export function decodeRecoveryKey(recoveryKey: string): Uint8Array {
const result = bs58.decode(recoveryKey.replace(/ /g, ''));
let parity = 0;
for (const b of result) {

View File

@@ -10,6 +10,9 @@
* @interface CryptoStore
*/
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager";
/**
* Represents an outgoing room key request
*
@@ -32,3 +35,11 @@
* @property {Number} state current state of this request (states are defined
* in {@link module:crypto/OutgoingRoomKeyRequestManager~ROOM_KEY_REQUEST_STATES})
*/
export interface OutgoingRoomKeyRequest {
requestId: string;
requestTxnId?: string;
cancellationTxnId?: string;
recipients: IRoomKeyRequestRecipient[];
requestBody: IRoomKeyRequestBody;
state: RoomKeyRequestState;
}

View File

@@ -292,7 +292,7 @@ export class VerificationBase extends EventEmitter {
await verifier(keyId, device, keyInfo);
verifiedDevices.push(deviceId);
} else {
const crossSigningInfo = this._baseApis.crypto._deviceList
const crossSigningInfo = this._baseApis.crypto.deviceList
.getStoredCrossSigningForUser(userId);
if (crossSigningInfo && crossSigningInfo.getId() === deviceId) {
await verifier(keyId, DeviceInfo.fromStorage({

View File

@@ -1,145 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 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.
*/
/**
* @module filter-component
*/
/**
* Checks if a value matches a given field value, which may be a * terminated
* wildcard pattern.
* @param {String} actual_value The value to be compared
* @param {String} filter_value The filter pattern to be compared
* @return {bool} true if the actual_value matches the filter_value
*/
function _matches_wildcard(actual_value, filter_value) {
if (filter_value.endsWith("*")) {
const type_prefix = filter_value.slice(0, -1);
return actual_value.substr(0, type_prefix.length) === type_prefix;
} else {
return actual_value === filter_value;
}
}
/**
* FilterComponent is a section of a Filter definition which defines the
* types, rooms, senders filters etc to be applied to a particular type of resource.
* This is all ported over from synapse's Filter object.
*
* N.B. that synapse refers to these as 'Filters', and what js-sdk refers to as
* 'Filters' are referred to as 'FilterCollections'.
*
* @constructor
* @param {Object} filter_json the definition of this filter JSON, e.g. { 'contains_url': true }
*/
export function FilterComponent(filter_json) {
this.filter_json = filter_json;
this.types = filter_json.types || null;
this.not_types = filter_json.not_types || [];
this.rooms = filter_json.rooms || null;
this.not_rooms = filter_json.not_rooms || [];
this.senders = filter_json.senders || null;
this.not_senders = filter_json.not_senders || [];
this.contains_url = filter_json.contains_url || null;
}
/**
* Checks with the filter component matches the given event
* @param {MatrixEvent} event event to be checked against the filter
* @return {bool} true if the event matches the filter
*/
FilterComponent.prototype.check = function(event) {
return this._checkFields(
event.getRoomId(),
event.getSender(),
event.getType(),
event.getContent() ? event.getContent().url !== undefined : false,
);
};
/**
* Checks whether the filter component matches the given event fields.
* @param {String} room_id the room_id for the event being checked
* @param {String} sender the sender of the event being checked
* @param {String} event_type the type of the event being checked
* @param {String} contains_url whether the event contains a content.url field
* @return {bool} true if the event fields match the filter
*/
FilterComponent.prototype._checkFields =
function(room_id, sender, event_type, contains_url) {
const literal_keys = {
"rooms": function(v) {
return room_id === v;
},
"senders": function(v) {
return sender === v;
},
"types": function(v) {
return _matches_wildcard(event_type, v);
},
};
const self = this;
for (let n=0; n < Object.keys(literal_keys).length; n++) {
const name = Object.keys(literal_keys)[n];
const match_func = literal_keys[name];
const not_name = "not_" + name;
const disallowed_values = self[not_name];
if (disallowed_values.filter(match_func).length > 0) {
return false;
}
const allowed_values = self[name];
if (allowed_values && allowed_values.length > 0) {
const anyMatch = allowed_values.some(match_func);
if (!anyMatch) {
return false;
}
}
}
const contains_url_filter = this.filter_json.contains_url;
if (contains_url_filter !== undefined) {
if (contains_url_filter !== contains_url) {
return false;
}
}
return true;
};
/**
* Filters a list of events down to those which match this filter component
* @param {MatrixEvent[]} events Events to be checked againt the filter component
* @return {MatrixEvent[]} events which matched the filter component
*/
FilterComponent.prototype.filter = function(events) {
return events.filter(this.check, this);
};
/**
* Returns the limit field for a given filter component, providing a default of
* 10 if none is otherwise specified. Cargo-culted from Synapse.
* @return {Number} the limit for this filter component.
*/
FilterComponent.prototype.limit = function() {
return this.filter_json.limit !== undefined ? this.filter_json.limit : 10;
};

156
src/filter-component.ts Normal file
View File

@@ -0,0 +1,156 @@
/*
Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent } from "./models/event";
/**
* @module filter-component
*/
/**
* Checks if a value matches a given field value, which may be a * terminated
* wildcard pattern.
* @param {String} actualValue The value to be compared
* @param {String} filterValue The filter pattern to be compared
* @return {boolean} true if the actualValue matches the filterValue
*/
function matchesWildcard(actualValue: string, filterValue: string): boolean {
if (filterValue.endsWith("*")) {
const typePrefix = filterValue.slice(0, -1);
return actualValue.substr(0, typePrefix.length) === typePrefix;
} else {
return actualValue === filterValue;
}
}
/* eslint-disable camelcase */
export interface IFilterComponent {
types?: string[];
not_types?: string[];
rooms?: string[];
not_rooms?: string[];
senders?: string[];
not_senders?: string[];
contains_url?: boolean;
limit?: number;
}
/* eslint-enable camelcase */
/**
* FilterComponent is a section of a Filter definition which defines the
* types, rooms, senders filters etc to be applied to a particular type of resource.
* This is all ported over from synapse's Filter object.
*
* N.B. that synapse refers to these as 'Filters', and what js-sdk refers to as
* 'Filters' are referred to as 'FilterCollections'.
*
* @constructor
* @param {Object} filterJson the definition of this filter JSON, e.g. { 'contains_url': true }
*/
export class FilterComponent {
constructor(private filterJson: IFilterComponent) {}
/**
* Checks with the filter component matches the given event
* @param {MatrixEvent} event event to be checked against the filter
* @return {boolean} true if the event matches the filter
*/
public check(event: MatrixEvent): boolean {
return this.checkFields(
event.getRoomId(),
event.getSender(),
event.getType(),
event.getContent() ? event.getContent().url !== undefined : false,
);
}
/**
* Converts the filter component into the form expected over the wire
*/
public toJSON(): object {
return {
types: this.filterJson.types || null,
not_types: this.filterJson.not_types || [],
rooms: this.filterJson.rooms || null,
not_rooms: this.filterJson.not_rooms || [],
senders: this.filterJson.senders || null,
not_senders: this.filterJson.not_senders || [],
contains_url: this.filterJson.contains_url || null,
};
}
/**
* Checks whether the filter component matches the given event fields.
* @param {String} roomId the roomId for the event being checked
* @param {String} sender the sender of the event being checked
* @param {String} eventType the type of the event being checked
* @param {boolean} containsUrl whether the event contains a content.url field
* @return {boolean} true if the event fields match the filter
*/
private checkFields(roomId: string, sender: string, eventType: string, containsUrl: boolean): boolean {
const literalKeys = {
"rooms": function(v: string): boolean {
return roomId === v;
},
"senders": function(v: string): boolean {
return sender === v;
},
"types": function(v: string): boolean {
return matchesWildcard(eventType, v);
},
};
for (let n = 0; n < Object.keys(literalKeys).length; n++) {
const name = Object.keys(literalKeys)[n];
const matchFunc = literalKeys[name];
const notName = "not_" + name;
const disallowedValues: string[] = this.filterJson[notName];
if (disallowedValues?.some(matchFunc)) {
return false;
}
const allowedValues: string[] = this.filterJson[name];
if (allowedValues && !allowedValues.some(matchFunc)) {
return false;
}
}
const containsUrlFilter = this.filterJson.contains_url;
if (containsUrlFilter !== undefined && containsUrlFilter !== containsUrl) {
return false;
}
return true;
}
/**
* Filters a list of events down to those which match this filter component
* @param {MatrixEvent[]} events Events to be checked against the filter component
* @return {MatrixEvent[]} events which matched the filter component
*/
filter(events: MatrixEvent[]): MatrixEvent[] {
return events.filter(this.check, this);
}
/**
* Returns the limit field for a given filter component, providing a default of
* 10 if none is otherwise specified. Cargo-culted from Synapse.
* @return {Number} the limit for this filter component.
*/
limit(): number {
return this.filterJson.limit !== undefined ? this.filterJson.limit : 10;
}
}

View File

@@ -1,199 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 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.
*/
/**
* @module filter
*/
import { FilterComponent } from "./filter-component";
/**
* @param {Object} obj
* @param {string} keyNesting
* @param {*} val
*/
function setProp(obj, keyNesting, val) {
const nestedKeys = keyNesting.split(".");
let currentObj = obj;
for (let i = 0; i < (nestedKeys.length - 1); i++) {
if (!currentObj[nestedKeys[i]]) {
currentObj[nestedKeys[i]] = {};
}
currentObj = currentObj[nestedKeys[i]];
}
currentObj[nestedKeys[nestedKeys.length - 1]] = val;
}
/**
* Construct a new Filter.
* @constructor
* @param {string} userId The user ID for this filter.
* @param {string=} filterId The filter ID if known.
* @prop {string} userId The user ID of the filter
* @prop {?string} filterId The filter ID
*/
export function Filter(userId, filterId) {
this.userId = userId;
this.filterId = filterId;
this.definition = {};
}
Filter.LAZY_LOADING_MESSAGES_FILTER = {
lazy_load_members: true,
};
/**
* Get the ID of this filter on your homeserver (if known)
* @return {?Number} The filter ID
*/
Filter.prototype.getFilterId = function() {
return this.filterId;
};
/**
* Get the JSON body of the filter.
* @return {Object} The filter definition
*/
Filter.prototype.getDefinition = function() {
return this.definition;
};
/**
* Set the JSON body of the filter
* @param {Object} definition The filter definition
*/
Filter.prototype.setDefinition = function(definition) {
this.definition = definition;
// This is all ported from synapse's FilterCollection()
// definitions look something like:
// {
// "room": {
// "rooms": ["!abcde:example.com"],
// "not_rooms": ["!123456:example.com"],
// "state": {
// "types": ["m.room.*"],
// "not_rooms": ["!726s6s6q:example.com"],
// "lazy_load_members": true,
// },
// "timeline": {
// "limit": 10,
// "types": ["m.room.message"],
// "not_rooms": ["!726s6s6q:example.com"],
// "not_senders": ["@spam:example.com"]
// "contains_url": true
// },
// "ephemeral": {
// "types": ["m.receipt", "m.typing"],
// "not_rooms": ["!726s6s6q:example.com"],
// "not_senders": ["@spam:example.com"]
// }
// },
// "presence": {
// "types": ["m.presence"],
// "not_senders": ["@alice:example.com"]
// },
// "event_format": "client",
// "event_fields": ["type", "content", "sender"]
// }
const room_filter_json = definition.room;
// consider the top level rooms/not_rooms filter
const room_filter_fields = {};
if (room_filter_json) {
if (room_filter_json.rooms) {
room_filter_fields.rooms = room_filter_json.rooms;
}
if (room_filter_json.rooms) {
room_filter_fields.not_rooms = room_filter_json.not_rooms;
}
this._include_leave = room_filter_json.include_leave || false;
}
this._room_filter = new FilterComponent(room_filter_fields);
this._room_timeline_filter = new FilterComponent(
room_filter_json ? (room_filter_json.timeline || {}) : {},
);
// don't bother porting this from synapse yet:
// this._room_state_filter =
// new FilterComponent(room_filter_json.state || {});
// this._room_ephemeral_filter =
// new FilterComponent(room_filter_json.ephemeral || {});
// this._room_account_data_filter =
// new FilterComponent(room_filter_json.account_data || {});
// this._presence_filter =
// new FilterComponent(definition.presence || {});
// this._account_data_filter =
// new FilterComponent(definition.account_data || {});
};
/**
* Get the room.timeline filter component of the filter
* @return {FilterComponent} room timeline filter component
*/
Filter.prototype.getRoomTimelineFilterComponent = function() {
return this._room_timeline_filter;
};
/**
* Filter the list of events based on whether they are allowed in a timeline
* based on this filter
* @param {MatrixEvent[]} events the list of events being filtered
* @return {MatrixEvent[]} the list of events which match the filter
*/
Filter.prototype.filterRoomTimeline = function(events) {
return this._room_timeline_filter.filter(this._room_filter.filter(events));
};
/**
* Set the max number of events to return for each room's timeline.
* @param {Number} limit The max number of events to return for each room.
*/
Filter.prototype.setTimelineLimit = function(limit) {
setProp(this.definition, "room.timeline.limit", limit);
};
Filter.prototype.setLazyLoadMembers = function(enabled) {
setProp(this.definition, "room.state.lazy_load_members", !!enabled);
};
/**
* Control whether left rooms should be included in responses.
* @param {boolean} includeLeave True to make rooms the user has left appear
* in responses.
*/
Filter.prototype.setIncludeLeaveRooms = function(includeLeave) {
setProp(this.definition, "room.include_leave", includeLeave);
};
/**
* Create a filter from existing data.
* @static
* @param {string} userId
* @param {string} filterId
* @param {Object} jsonObj
* @return {Filter}
*/
Filter.fromJson = function(userId, filterId, jsonObj) {
const filter = new Filter(userId, filterId);
filter.setDefinition(jsonObj);
return filter;
};

224
src/filter.ts Normal file
View File

@@ -0,0 +1,224 @@
/*
Copyright 2015 - 2021 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.
*/
/**
* @module filter
*/
import { FilterComponent, IFilterComponent } from "./filter-component";
import { MatrixEvent } from "./models/event";
/**
* @param {Object} obj
* @param {string} keyNesting
* @param {*} val
*/
function setProp(obj: object, keyNesting: string, val: any) {
const nestedKeys = keyNesting.split(".");
let currentObj = obj;
for (let i = 0; i < (nestedKeys.length - 1); i++) {
if (!currentObj[nestedKeys[i]]) {
currentObj[nestedKeys[i]] = {};
}
currentObj = currentObj[nestedKeys[i]];
}
currentObj[nestedKeys[nestedKeys.length - 1]] = val;
}
/* eslint-disable camelcase */
interface IFilterDefinition {
event_fields?: string[];
event_format?: "client" | "federation";
presence?: IFilterComponent;
account_data?: IFilterComponent;
room?: IRoomFilter;
}
interface IRoomEventFilter extends IFilterComponent {
lazy_load_members?: boolean;
include_redundant_members?: boolean;
}
interface IStateFilter extends IRoomEventFilter {}
interface IRoomFilter {
not_rooms?: string[];
rooms?: string[];
ephemeral?: IRoomEventFilter;
include_leave?: boolean;
state?: IStateFilter;
timeline?: IRoomEventFilter;
account_data?: IRoomEventFilter;
}
/* eslint-enable camelcase */
/**
* Construct a new Filter.
* @constructor
* @param {string} userId The user ID for this filter.
* @param {string=} filterId The filter ID if known.
* @prop {string} userId The user ID of the filter
* @prop {?string} filterId The filter ID
*/
export class Filter {
static LAZY_LOADING_MESSAGES_FILTER = {
lazy_load_members: true,
};
/**
* Create a filter from existing data.
* @static
* @param {string} userId
* @param {string} filterId
* @param {Object} jsonObj
* @return {Filter}
*/
static fromJson(userId: string, filterId: string, jsonObj: IFilterDefinition): Filter {
const filter = new Filter(userId, filterId);
filter.setDefinition(jsonObj);
return filter;
}
private definition: IFilterDefinition = {};
private roomFilter: FilterComponent;
private roomTimelineFilter: FilterComponent;
constructor(public readonly userId: string, public filterId?: string) {}
/**
* Get the ID of this filter on your homeserver (if known)
* @return {?string} The filter ID
*/
getFilterId(): string | null {
return this.filterId;
}
/**
* Get the JSON body of the filter.
* @return {Object} The filter definition
*/
getDefinition(): IFilterDefinition {
return this.definition;
}
/**
* Set the JSON body of the filter
* @param {Object} definition The filter definition
*/
setDefinition(definition: IFilterDefinition) {
this.definition = definition;
// This is all ported from synapse's FilterCollection()
// definitions look something like:
// {
// "room": {
// "rooms": ["!abcde:example.com"],
// "not_rooms": ["!123456:example.com"],
// "state": {
// "types": ["m.room.*"],
// "not_rooms": ["!726s6s6q:example.com"],
// "lazy_load_members": true,
// },
// "timeline": {
// "limit": 10,
// "types": ["m.room.message"],
// "not_rooms": ["!726s6s6q:example.com"],
// "not_senders": ["@spam:example.com"]
// "contains_url": true
// },
// "ephemeral": {
// "types": ["m.receipt", "m.typing"],
// "not_rooms": ["!726s6s6q:example.com"],
// "not_senders": ["@spam:example.com"]
// }
// },
// "presence": {
// "types": ["m.presence"],
// "not_senders": ["@alice:example.com"]
// },
// "event_format": "client",
// "event_fields": ["type", "content", "sender"]
// }
const roomFilterJson = definition.room;
// consider the top level rooms/not_rooms filter
const roomFilterFields: IRoomFilter = {};
if (roomFilterJson) {
if (roomFilterJson.rooms) {
roomFilterFields.rooms = roomFilterJson.rooms;
}
if (roomFilterJson.rooms) {
roomFilterFields.not_rooms = roomFilterJson.not_rooms;
}
}
this.roomFilter = new FilterComponent(roomFilterFields);
this.roomTimelineFilter = new FilterComponent(roomFilterJson?.timeline || {});
// don't bother porting this from synapse yet:
// this._room_state_filter =
// new FilterComponent(roomFilterJson.state || {});
// this._room_ephemeral_filter =
// new FilterComponent(roomFilterJson.ephemeral || {});
// this._room_account_data_filter =
// new FilterComponent(roomFilterJson.account_data || {});
// this._presence_filter =
// new FilterComponent(definition.presence || {});
// this._account_data_filter =
// new FilterComponent(definition.account_data || {});
}
/**
* Get the room.timeline filter component of the filter
* @return {FilterComponent} room timeline filter component
*/
getRoomTimelineFilterComponent(): FilterComponent {
return this.roomTimelineFilter;
}
/**
* Filter the list of events based on whether they are allowed in a timeline
* based on this filter
* @param {MatrixEvent[]} events the list of events being filtered
* @return {MatrixEvent[]} the list of events which match the filter
*/
filterRoomTimeline(events: MatrixEvent[]): MatrixEvent[] {
return this.roomTimelineFilter.filter(this.roomFilter.filter(events));
}
/**
* Set the max number of events to return for each room's timeline.
* @param {Number} limit The max number of events to return for each room.
*/
setTimelineLimit(limit: number) {
setProp(this.definition, "room.timeline.limit", limit);
}
setLazyLoadMembers(enabled: boolean) {
setProp(this.definition, "room.state.lazy_load_members", !!enabled);
}
/**
* Control whether left rooms should be included in responses.
* @param {boolean} includeLeave True to make rooms the user has left appear
* in responses.
*/
setIncludeLeaveRooms(includeLeave: boolean) {
setProp(this.definition, "room.include_leave", includeLeave);
}
}

View File

@@ -19,6 +19,7 @@ import { MemoryStore } from "./store/memory";
import { MatrixScheduler } from "./scheduler";
import { MatrixClient } from "./client";
import { ICreateClientOpts } from "./client";
import { DeviceTrustLevel } from "./crypto/CrossSigning";
export * from "./client";
export * from "./http-api";
@@ -99,7 +100,7 @@ export function setCryptoStoreFactory(fac) {
}
export interface ICryptoCallbacks {
getCrossSigningKey?: (keyType: string, pubKey: Uint8Array) => Promise<Uint8Array>;
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array>;
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
shouldUpgradeDeviceVerifications?: (
users: Record<string, any>
@@ -112,7 +113,7 @@ export interface ICryptoCallbacks {
) => void;
onSecretRequested?: (
userId: string, deviceId: string,
requestId: string, secretName: string, deviceTrust: IDeviceTrustLevel
requestId: string, secretName: string, deviceTrust: DeviceTrustLevel
) => Promise<string>;
getDehydrationKey?: (
keyInfo: ISecretStorageKeyInfo,
@@ -132,14 +133,6 @@ export interface ISecretStorageKeyInfo {
mac?: string;
}
// TODO: Move this to `CrossSigning` once converted
export interface IDeviceTrustLevel {
isVerified(): boolean;
isCrossSigningVerified(): boolean;
isLocallyVerified(): boolean;
isTofu(): boolean;
}
/**
* Construct a Matrix Client. Similar to {@link module:client.MatrixClient}
* except that the 'request', 'store' and 'scheduler' dependencies are satisfied.

102
src/models/MSC3089Branch.ts Normal file
View File

@@ -0,0 +1,102 @@
/*
Copyright 2021 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 { MatrixClient } from "../client";
import { IEncryptedFile, UNSTABLE_MSC3089_BRANCH } from "../@types/event";
import { MatrixEvent } from "./event";
/**
* Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) branch - a reference
* to a file (leaf) in the tree. Note that this is UNSTABLE and subject to breaking changes
* without notice.
*/
export class MSC3089Branch {
public constructor(private client: MatrixClient, public readonly indexEvent: MatrixEvent) {
// Nothing to do
}
/**
* The file ID.
*/
public get id(): string {
return this.indexEvent.getStateKey();
}
/**
* Whether this branch is active/valid.
*/
public get isActive(): boolean {
return this.indexEvent.getContent()["active"] === true;
}
private get roomId(): string {
return this.indexEvent.getRoomId();
}
/**
* Deletes the file from the tree.
* @returns {Promise<void>} Resolves when complete.
*/
public async delete(): Promise<void> {
await this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, {}, this.id);
await this.client.redactEvent(this.roomId, this.id);
// TODO: Delete edit history as well
}
/**
* Gets the name for this file.
* @returns {string} The name, or "Unnamed File" if unknown.
*/
public getName(): string {
return this.indexEvent.getContent()['name'] || "Unnamed File";
}
/**
* Sets the name for this file.
* @param {string} name The new name for this file.
* @returns {Promise<void>} Resolves when complete.
*/
public setName(name: string): Promise<void> {
return this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, {
...this.indexEvent.getContent(),
name: name,
}, this.id);
}
/**
* Gets information about the file needed to download it.
* @returns {Promise<{info: IEncryptedFile, httpUrl: string}>} Information about the file.
*/
public async getFileInfo(): Promise<{ info: IEncryptedFile, httpUrl: string }> {
const room = this.client.getRoom(this.roomId);
if (!room) throw new Error("Unknown room");
const timeline = await this.client.getEventTimeline(room.getUnfilteredTimelineSet(), this.id);
if (!timeline) throw new Error("Failed to get timeline for room event");
const event = timeline.getEvents().find(e => e.getId() === this.id);
if (!event) throw new Error("Failed to find event");
// Sometimes the event context doesn't decrypt for us, so do that.
await this.client.decryptEventIfNeeded(event, { emit: false, isRetry: false });
const file = event.getContent()['file'];
const httpUrl = this.client.mxcUrlToHttp(file['url']);
return { info: file, httpUrl: httpUrl };
}
}

View File

@@ -0,0 +1,476 @@
/*
Copyright 2021 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 { MatrixClient } from "../client";
import { EventType, IEncryptedFile, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../@types/event";
import { Room } from "./room";
import { logger } from "../logger";
import { MatrixEvent } from "./event";
import {
averageBetweenStrings,
DEFAULT_ALPHABET,
lexicographicCompare,
nextString,
prevString,
simpleRetryOperation,
} from "../utils";
import { MSC3089Branch } from "./MSC3089Branch";
import promiseRetry from "p-retry";
import { isRoomSharedHistory } from "../crypto/algorithms/megolm";
/**
* The recommended defaults for a tree space's power levels. Note that this
* is UNSTABLE and subject to breaking changes without notice.
*/
export const DEFAULT_TREE_POWER_LEVELS_TEMPLATE = {
// Owner
invite: 100,
kick: 100,
ban: 100,
// Editor
redact: 50,
state_default: 50,
events_default: 50,
// Viewer
users_default: 0,
// Mixed
events: {
[EventType.RoomPowerLevels]: 100,
[EventType.RoomHistoryVisibility]: 100,
[EventType.RoomTombstone]: 100,
[EventType.RoomEncryption]: 100,
[EventType.RoomName]: 50,
[EventType.RoomMessage]: 50,
[EventType.RoomMessageEncrypted]: 50,
[EventType.Sticker]: 50,
},
users: {}, // defined by calling code
};
/**
* Ease-of-use representation for power levels represented as simple roles.
* Note that this is UNSTABLE and subject to breaking changes without notice.
*/
export enum TreePermissions {
Viewer = "viewer", // Default
Editor = "editor", // "Moderator" or ~PL50
Owner = "owner", // "Admin" or PL100
}
/**
* Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089)
* file tree Space. Note that this is UNSTABLE and subject to breaking changes
* without notice.
*/
export class MSC3089TreeSpace {
public readonly room: Room;
public constructor(private client: MatrixClient, public readonly roomId: string) {
this.room = this.client.getRoom(this.roomId);
if (!this.room) throw new Error("Unknown room");
}
/**
* Syntactic sugar for room ID of the Space.
*/
public get id(): string {
return this.roomId;
}
/**
* Whether or not this is a top level space.
*/
public get isTopLevel(): boolean {
// XXX: This is absolutely not how you find out if the space is top level
// but is safe for a managed usecase like we offer in the SDK.
const parentEvents = this.room.currentState.getStateEvents(EventType.SpaceParent);
if (!parentEvents?.length) return true;
return parentEvents.every(e => !e.getContent()?.['via']);
}
/**
* Sets the name of the tree space.
* @param {string} name The new name for the space.
* @returns {Promise<void>} Resolves when complete.
*/
public setName(name: string): Promise<void> {
return this.client.sendStateEvent(this.roomId, EventType.RoomName, { name }, "");
}
/**
* Invites a user to the tree space. They will be given the default Viewer
* permission level unless specified elsewhere.
* @param {string} userId The user ID to invite.
* @param {boolean} andSubspaces True (default) to invite the user to all
* directories/subspaces too, recursively.
* @param {boolean} shareHistoryKeys True (default) to share encryption keys
* with the invited user. This will allow them to decrypt the events (files)
* in the tree. Keys will not be shared if the room is lacking appropriate
* history visibility (by default, history visibility is "shared" in trees,
* which is an appropriate visibility for these purposes).
* @returns {Promise<void>} Resolves when complete.
*/
public async invite(userId: string, andSubspaces = true, shareHistoryKeys = true): Promise<void> {
const promises: Promise<void>[] = [this.retryInvite(userId)];
if (andSubspaces) {
promises.push(...this.getDirectories().map(d => d.invite(userId, andSubspaces, shareHistoryKeys)));
}
return Promise.all(promises).then(() => {
// Note: key sharing is default on because for file trees it is relatively important that the invite
// target can actually decrypt the files. The implied use case is that by inviting a user to the tree
// it means the sender would like the receiver to view/download the files contained within, much like
// sharing a folder in other circles.
if (shareHistoryKeys && isRoomSharedHistory(this.room)) {
// noinspection JSIgnoredPromiseFromCall - we aren't concerned as much if this fails.
this.client.sendSharedHistoryKeys(this.roomId, [userId]);
}
});
}
private retryInvite(userId: string): Promise<void> {
return simpleRetryOperation(async () => {
await this.client.invite(this.roomId, userId).catch(e => {
// We don't want to retry permission errors forever...
if (e?.errcode === "M_FORBIDDEN") {
throw new promiseRetry.AbortError(e);
}
throw e;
});
});
}
/**
* Sets the permissions of a user to the given role. Note that if setting a user
* to Owner then they will NOT be able to be demoted. If the user does not have
* permission to change the power level of the target, an error will be thrown.
* @param {string} userId The user ID to change the role of.
* @param {TreePermissions} role The role to assign.
* @returns {Promise<void>} Resolves when complete.
*/
public async setPermissions(userId: string, role: TreePermissions): Promise<void> {
const currentPls = this.room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
if (Array.isArray(currentPls)) throw new Error("Unexpected return type for power levels");
const pls = currentPls.getContent() || {};
const viewLevel = pls['users_default'] || 0;
const editLevel = pls['events_default'] || 50;
const adminLevel = pls['events']?.[EventType.RoomPowerLevels] || 100;
const users = pls['users'] || {};
switch (role) {
case TreePermissions.Viewer:
users[userId] = viewLevel;
break;
case TreePermissions.Editor:
users[userId] = editLevel;
break;
case TreePermissions.Owner:
users[userId] = adminLevel;
break;
default:
throw new Error("Invalid role: " + role);
}
pls['users'] = users;
return this.client.sendStateEvent(this.roomId, EventType.RoomPowerLevels, pls, "");
}
/**
* Creates a directory under this tree space, represented as another tree space.
* @param {string} name The name for the directory.
* @returns {Promise<MSC3089TreeSpace>} Resolves to the created directory.
*/
public async createDirectory(name: string): Promise<MSC3089TreeSpace> {
const directory = await this.client.unstableCreateFileTree(name);
await this.client.sendStateEvent(this.roomId, EventType.SpaceChild, {
via: [this.client.getDomain()],
}, directory.roomId);
await this.client.sendStateEvent(directory.roomId, EventType.SpaceParent, {
via: [this.client.getDomain()],
}, this.roomId);
return directory;
}
/**
* Gets a list of all known immediate subdirectories to this tree space.
* @returns {MSC3089TreeSpace[]} The tree spaces (directories). May be empty, but not null.
*/
public getDirectories(): MSC3089TreeSpace[] {
const trees: MSC3089TreeSpace[] = [];
const children = this.room.currentState.getStateEvents(EventType.SpaceChild);
for (const child of children) {
try {
const tree = this.client.unstableGetFileTreeSpace(child.getStateKey());
if (tree) trees.push(tree);
} catch (e) {
logger.warn("Unable to create tree space instance for listing. Are we joined?", e);
}
}
return trees;
}
/**
* Gets a subdirectory of a given ID under this tree space. Note that this will not recurse
* into children and instead only look one level deep.
* @param {string} roomId The room ID (directory ID) to find.
* @returns {MSC3089TreeSpace} The directory, or falsy if not found.
*/
public getDirectory(roomId: string): MSC3089TreeSpace {
return this.getDirectories().find(r => r.roomId === roomId);
}
/**
* Deletes the tree, kicking all members and deleting **all subdirectories**.
* @returns {Promise<void>} Resolves when complete.
*/
public async delete(): Promise<void> {
const subdirectories = this.getDirectories();
for (const dir of subdirectories) {
await dir.delete();
}
const kickMemberships = ["invite", "knock", "join"];
const members = this.room.currentState.getStateEvents(EventType.RoomMember);
for (const member of members) {
const isNotUs = member.getStateKey() !== this.client.getUserId();
if (isNotUs && kickMemberships.includes(member.getContent()['membership'])) {
await this.client.kick(this.roomId, member.getStateKey(), "Room deleted");
}
}
await this.client.leave(this.roomId);
}
private getOrderedChildren(children: MatrixEvent[]): { roomId: string, order: string }[] {
const ordered: { roomId: string, order: string }[] = children
.map(c => ({ roomId: c.getStateKey(), order: c.getContent()['order'] }));
ordered.sort((a, b) => {
if (a.order && !b.order) {
return -1;
} else if (!a.order && b.order) {
return 1;
} else if (!a.order && !b.order) {
const roomA = this.client.getRoom(a.roomId);
const roomB = this.client.getRoom(b.roomId);
if (!roomA || !roomB) { // just don't bother trying to do more partial sorting
return lexicographicCompare(a.roomId, b.roomId);
}
const createTsA = roomA.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0;
const createTsB = roomB.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0;
if (createTsA === createTsB) {
return lexicographicCompare(a.roomId, b.roomId);
}
return createTsA - createTsB;
} else { // both not-null orders
return lexicographicCompare(a.order, b.order);
}
});
return ordered;
}
private getParentRoom(): Room {
const parents = this.room.currentState.getStateEvents(EventType.SpaceParent);
const parent = parents[0]; // XXX: Wild assumption
if (!parent) throw new Error("Expected to have a parent in a non-top level space");
// XXX: We are assuming the parent is a valid tree space.
// We probably don't need to validate the parent room state for this usecase though.
const parentRoom = this.client.getRoom(parent.getStateKey());
if (!parentRoom) throw new Error("Unable to locate room for parent");
return parentRoom;
}
/**
* Gets the current order index for this directory. Note that if this is the top level space
* then -1 will be returned.
* @returns {number} The order index of this space.
*/
public getOrder(): number {
if (this.isTopLevel) return -1;
const parentRoom = this.getParentRoom();
const children = parentRoom.currentState.getStateEvents(EventType.SpaceChild);
const ordered = this.getOrderedChildren(children);
return ordered.findIndex(c => c.roomId === this.roomId);
}
/**
* Sets the order index for this directory within its parent. Note that if this is a top level
* space then an error will be thrown. -1 can be used to move the child to the start, and numbers
* larger than the number of children can be used to move the child to the end.
* @param {number} index The new order index for this space.
* @returns {Promise<void>} Resolves when complete.
* @throws Throws if this is a top level space.
*/
public async setOrder(index: number): Promise<void> {
if (this.isTopLevel) throw new Error("Cannot set order of top level spaces currently");
const parentRoom = this.getParentRoom();
const children = parentRoom.currentState.getStateEvents(EventType.SpaceChild);
const ordered = this.getOrderedChildren(children);
index = Math.max(Math.min(index, ordered.length - 1), 0);
const currentIndex = this.getOrder();
const movingUp = currentIndex < index;
if (movingUp && index === (ordered.length - 1)) {
index--;
} else if (!movingUp && index === 0) {
index++;
}
const prev = ordered[movingUp ? index : (index - 1)];
const next = ordered[movingUp ? (index + 1) : index];
let newOrder = DEFAULT_ALPHABET[0];
let ensureBeforeIsSane = false;
if (!prev) {
// Move to front
if (next?.order) {
newOrder = prevString(next.order);
}
} else if (index === (ordered.length - 1)) {
// Move to back
if (next?.order) {
newOrder = nextString(next.order);
}
} else {
// Move somewhere in the middle
const startOrder = prev?.order;
const endOrder = next?.order;
if (startOrder && endOrder) {
if (startOrder === endOrder) {
// Error case: just move +1 to break out of awful math
newOrder = nextString(startOrder);
} else {
newOrder = averageBetweenStrings(startOrder, endOrder);
}
} else {
if (startOrder) {
// We're at the end (endOrder is null, so no explicit order)
newOrder = nextString(startOrder);
} else if (endOrder) {
// We're at the start (startOrder is null, so nothing before us)
newOrder = prevString(endOrder);
} else {
// Both points are unknown. We're likely in a range where all the children
// don't have particular order values, so we may need to update them too.
// The other possibility is there's only us as a child, but we should have
// shown up in the other states.
ensureBeforeIsSane = true;
}
}
}
if (ensureBeforeIsSane) {
// We were asked by the order algorithm to prepare the moving space for a landing
// in the undefined order part of the order array, which means we need to update the
// spaces that come before it with a stable order value.
let lastOrder: string;
for (let i = 0; i <= index; i++) {
const target = ordered[i];
if (i === 0) {
lastOrder = target.order;
}
if (!target.order) {
// XXX: We should be creating gaps to avoid conflicts
lastOrder = lastOrder ? nextString(lastOrder) : DEFAULT_ALPHABET[0];
const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, target.roomId);
const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] };
await this.client.sendStateEvent(parentRoom.roomId, EventType.SpaceChild, {
...content,
order: lastOrder,
}, target.roomId);
} else {
lastOrder = target.order;
}
}
newOrder = nextString(lastOrder);
}
// TODO: Deal with order conflicts by reordering
// Now we can finally update our own order state
const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, this.roomId);
const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] };
await this.client.sendStateEvent(parentRoom.roomId, EventType.SpaceChild, {
...content,
// TODO: Safely constrain to 50 character limit required by spaces.
order: newOrder,
}, this.roomId);
}
/**
* Creates (uploads) a new file to this tree. The file must have already been encrypted for the room.
* @param {string} name The name of the file.
* @param {ArrayBuffer} encryptedContents The encrypted contents.
* @param {Partial<IEncryptedFile>} info The encrypted file information.
* @returns {Promise<void>} Resolves when uploaded.
*/
public async createFile(
name: string,
encryptedContents: ArrayBuffer, info: Partial<IEncryptedFile>,
): Promise<void> {
const mxc = await this.client.uploadContent(new Blob([encryptedContents]), {
includeFilename: false,
onlyContentUri: true,
});
info.url = mxc;
const res = await this.client.sendMessage(this.roomId, {
msgtype: MsgType.File,
body: name,
url: mxc,
file: info,
[UNSTABLE_MSC3089_LEAF.name]: {},
});
await this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, {
active: true,
name: name,
}, res['event_id']);
}
/**
* Retrieves a file from the tree.
* @param {string} fileEventId The event ID of the file.
* @returns {MSC3089Branch} The file, or falsy if not found.
*/
public getFile(fileEventId: string): MSC3089Branch {
const branch = this.room.currentState.getStateEvents(UNSTABLE_MSC3089_BRANCH.name, fileEventId);
return branch ? new MSC3089Branch(this.client, branch) : null;
}
/**
* Gets an array of all known files for the tree.
* @returns {MSC3089Branch[]} The known files. May be empty, but not null.
*/
public listFiles(): MSC3089Branch[] {
const branches = this.room.currentState.getStateEvents(UNSTABLE_MSC3089_BRANCH.name) ?? [];
return branches.map(e => new MSC3089Branch(this.client, e)).filter(b => b.isActive);
}
}

View File

@@ -1,115 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 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.
*/
/**
* @module models/event-context
*/
/**
* Construct a new EventContext
*
* An eventcontext is used for circumstances such as search results, when we
* have a particular event of interest, and a bunch of events before and after
* it.
*
* It also stores pagination tokens for going backwards and forwards in the
* timeline.
*
* @param {MatrixEvent} ourEvent the event at the centre of this context
*
* @constructor
*/
export function EventContext(ourEvent) {
this._timeline = [ourEvent];
this._ourEventIndex = 0;
this._paginateTokens = { b: null, f: null };
// this is used by MatrixClient to keep track of active requests
this._paginateRequests = { b: null, f: null };
}
/**
* Get the main event of interest
*
* This is a convenience function for getTimeline()[getOurEventIndex()].
*
* @return {MatrixEvent} The event at the centre of this context.
*/
EventContext.prototype.getEvent = function() {
return this._timeline[this._ourEventIndex];
};
/**
* Get the list of events in this context
*
* @return {Array} An array of MatrixEvents
*/
EventContext.prototype.getTimeline = function() {
return this._timeline;
};
/**
* Get the index in the timeline of our event
*
* @return {Number}
*/
EventContext.prototype.getOurEventIndex = function() {
return this._ourEventIndex;
};
/**
* Get a pagination token.
*
* @param {boolean} backwards true to get the pagination token for going
* backwards in time
* @return {string}
*/
EventContext.prototype.getPaginateToken = function(backwards) {
return this._paginateTokens[backwards ? 'b' : 'f'];
};
/**
* Set a pagination token.
*
* Generally this will be used only by the matrix js sdk.
*
* @param {string} token pagination token
* @param {boolean} backwards true to set the pagination token for going
* backwards in time
*/
EventContext.prototype.setPaginateToken = function(token, backwards) {
this._paginateTokens[backwards ? 'b' : 'f'] = token;
};
/**
* Add more events to the timeline
*
* @param {Array} events new events, in timeline order
* @param {boolean} atStart true to insert new events at the start
*/
EventContext.prototype.addEvents = function(events, atStart) {
// TODO: should we share logic with Room.addEventsToTimeline?
// Should Room even use EventContext?
if (atStart) {
this._timeline = events.concat(this._timeline);
this._ourEventIndex += events.length;
} else {
this._timeline = this._timeline.concat(events);
}
};

119
src/models/event-context.ts Normal file
View File

@@ -0,0 +1,119 @@
/*
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent } from "./event";
import { Direction } from "./event-timeline";
/**
* @module models/event-context
*/
export class EventContext {
private timeline: MatrixEvent[];
private ourEventIndex = 0;
private paginateTokens: Record<Direction, string | null> = {
[Direction.Backward]: null,
[Direction.Forward]: null,
};
/**
* Construct a new EventContext
*
* An eventcontext is used for circumstances such as search results, when we
* have a particular event of interest, and a bunch of events before and after
* it.
*
* It also stores pagination tokens for going backwards and forwards in the
* timeline.
*
* @param {MatrixEvent} ourEvent the event at the centre of this context
*
* @constructor
*/
constructor(ourEvent: MatrixEvent) {
this.timeline = [ourEvent];
}
/**
* Get the main event of interest
*
* This is a convenience function for getTimeline()[getOurEventIndex()].
*
* @return {MatrixEvent} The event at the centre of this context.
*/
public getEvent(): MatrixEvent {
return this.timeline[this.ourEventIndex];
}
/**
* Get the list of events in this context
*
* @return {Array} An array of MatrixEvents
*/
public getTimeline(): MatrixEvent[] {
return this.timeline;
}
/**
* Get the index in the timeline of our event
*
* @return {Number}
*/
public getOurEventIndex(): number {
return this.ourEventIndex;
}
/**
* Get a pagination token.
*
* @param {boolean} backwards true to get the pagination token for going
* backwards in time
* @return {string}
*/
public getPaginateToken(backwards = false): string {
return this.paginateTokens[backwards ? Direction.Backward : Direction.Forward];
}
/**
* Set a pagination token.
*
* Generally this will be used only by the matrix js sdk.
*
* @param {string} token pagination token
* @param {boolean} backwards true to set the pagination token for going
* backwards in time
*/
public setPaginateToken(token: string, backwards = false): void {
this.paginateTokens[backwards ? Direction.Backward : Direction.Forward] = token;
}
/**
* Add more events to the timeline
*
* @param {Array} events new events, in timeline order
* @param {boolean} atStart true to insert new events at the start
*/
public addEvents(events: MatrixEvent[], atStart = false): void {
// TODO: should we share logic with Room.addEventsToTimeline?
// Should Room even use EventContext?
if (atStart) {
this.timeline = events.concat(this.timeline);
this.ourEventIndex += events.length;
} else {
this.timeline = this.timeline.concat(events);
}
}
}

View File

@@ -1,848 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 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.
*/
/**
* @module models/event-timeline-set
*/
import { EventEmitter } from "events";
import { EventTimeline } from "./event-timeline";
import { EventStatus } from "./event";
import * as utils from "../utils";
import { logger } from '../logger';
import { Relations } from './relations';
// var DEBUG = false;
const DEBUG = true;
let debuglog;
if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console
debuglog = logger.log.bind(logger);
} else {
debuglog = function() {};
}
/**
* Construct a set of EventTimeline objects, typically on behalf of a given
* room. A room may have multiple EventTimelineSets for different levels
* of filtering. The global notification list is also an EventTimelineSet, but
* lacks a room.
*
* <p>This is an ordered sequence of timelines, which may or may not
* be continuous. Each timeline lists a series of events, as well as tracking
* the room state at the start and the end of the timeline (if appropriate).
* It also tracks forward and backward pagination tokens, as well as containing
* links to the next timeline in the sequence.
*
* <p>There is one special timeline - the 'live' timeline, which represents the
* timeline to which events are being added in real-time as they are received
* from the /sync API. Note that you should not retain references to this
* timeline - even if it is the current timeline right now, it may not remain
* so if the server gives us a timeline gap in /sync.
*
* <p>In order that we can find events from their ids later, we also maintain a
* map from event_id to timeline and index.
*
* @constructor
* @param {?Room} room
* Room for this timelineSet. May be null for non-room cases, such as the
* notification timeline.
* @param {Object} opts Options inherited from Room.
*
* @param {boolean} [opts.timelineSupport = false]
* Set to true to enable improved timeline support.
* @param {Object} [opts.filter = null]
* The filter object, if any, for this timelineSet.
* @param {boolean} [opts.unstableClientRelationAggregation = false]
* Optional. Set to true to enable client-side aggregation of event relations
* via `getRelationsForEvent`.
* This feature is currently unstable and the API may change without notice.
*/
export function EventTimelineSet(room, opts) {
this.room = room;
this._timelineSupport = Boolean(opts.timelineSupport);
this._liveTimeline = new EventTimeline(this);
this._unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation;
// just a list - *not* ordered.
this._timelines = [this._liveTimeline];
this._eventIdToTimeline = {};
this._filter = opts.filter || null;
if (this._unstableClientRelationAggregation) {
// A tree of objects to access a set of relations for an event, as in:
// this._relations[relatesToEventId][relationType][relationEventType]
this._relations = {};
}
}
utils.inherits(EventTimelineSet, EventEmitter);
/**
* Get all the timelines in this set
* @return {module:models/event-timeline~EventTimeline[]} the timelines in this set
*/
EventTimelineSet.prototype.getTimelines = function() {
return this._timelines;
};
/**
* Get the filter object this timeline set is filtered on, if any
* @return {?Filter} the optional filter for this timelineSet
*/
EventTimelineSet.prototype.getFilter = function() {
return this._filter;
};
/**
* Set the filter object this timeline set is filtered on
* (passed to the server when paginating via /messages).
* @param {Filter} filter the filter for this timelineSet
*/
EventTimelineSet.prototype.setFilter = function(filter) {
this._filter = filter;
};
/**
* Get the list of pending sent events for this timelineSet's room, filtered
* by the timelineSet's filter if appropriate.
*
* @return {module:models/event.MatrixEvent[]} A list of the sent events
* waiting for remote echo.
*
* @throws If <code>opts.pendingEventOrdering</code> was not 'detached'
*/
EventTimelineSet.prototype.getPendingEvents = function() {
if (!this.room) {
return [];
}
if (this._filter) {
return this._filter.filterRoomTimeline(this.room.getPendingEvents());
} else {
return this.room.getPendingEvents();
}
};
/**
* Get the live timeline for this room.
*
* @return {module:models/event-timeline~EventTimeline} live timeline
*/
EventTimelineSet.prototype.getLiveTimeline = function() {
return this._liveTimeline;
};
/**
* Return the timeline (if any) this event is in.
* @param {String} eventId the eventId being sought
* @return {module:models/event-timeline~EventTimeline} timeline
*/
EventTimelineSet.prototype.eventIdToTimeline = function(eventId) {
return this._eventIdToTimeline[eventId];
};
/**
* Track a new event as if it were in the same timeline as an old event,
* replacing it.
* @param {String} oldEventId event ID of the original event
* @param {String} newEventId event ID of the replacement event
*/
EventTimelineSet.prototype.replaceEventId = function(oldEventId, newEventId) {
const existingTimeline = this._eventIdToTimeline[oldEventId];
if (existingTimeline) {
delete this._eventIdToTimeline[oldEventId];
this._eventIdToTimeline[newEventId] = existingTimeline;
}
};
/**
* Reset the live timeline, and start a new one.
*
* <p>This is used when /sync returns a 'limited' timeline.
*
* @param {string=} backPaginationToken token for back-paginating the new timeline
* @param {string=} forwardPaginationToken token for forward-paginating the old live timeline,
* if absent or null, all timelines are reset.
*
* @fires module:client~MatrixClient#event:"Room.timelineReset"
*/
EventTimelineSet.prototype.resetLiveTimeline = function(
backPaginationToken, forwardPaginationToken,
) {
// Each EventTimeline has RoomState objects tracking the state at the start
// and end of that timeline. The copies at the end of the live timeline are
// special because they will have listeners attached to monitor changes to
// the current room state, so we move this RoomState from the end of the
// current live timeline to the end of the new one and, if necessary,
// replace it with a newly created one. We also make a copy for the start
// of the new timeline.
// if timeline support is disabled, forget about the old timelines
const resetAllTimelines = !this._timelineSupport || !forwardPaginationToken;
const oldTimeline = this._liveTimeline;
const newTimeline = resetAllTimelines ?
oldTimeline.forkLive(EventTimeline.FORWARDS) :
oldTimeline.fork(EventTimeline.FORWARDS);
if (resetAllTimelines) {
this._timelines = [newTimeline];
this._eventIdToTimeline = {};
} else {
this._timelines.push(newTimeline);
}
if (forwardPaginationToken) {
// Now set the forward pagination token on the old live timeline
// so it can be forward-paginated.
oldTimeline.setPaginationToken(
forwardPaginationToken, EventTimeline.FORWARDS,
);
}
// make sure we set the pagination token before firing timelineReset,
// otherwise clients which start back-paginating will fail, and then get
// stuck without realising that they *can* back-paginate.
newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS);
// Now we can swap the live timeline to the new one.
this._liveTimeline = newTimeline;
this.emit("Room.timelineReset", this.room, this, resetAllTimelines);
};
/**
* Get the timeline which contains the given event, if any
*
* @param {string} eventId event ID to look for
* @return {?module:models/event-timeline~EventTimeline} timeline containing
* the given event, or null if unknown
*/
EventTimelineSet.prototype.getTimelineForEvent = function(eventId) {
const res = this._eventIdToTimeline[eventId];
return (res === undefined) ? null : res;
};
/**
* Get an event which is stored in our timelines
*
* @param {string} eventId event ID to look for
* @return {?module:models/event~MatrixEvent} the given event, or undefined if unknown
*/
EventTimelineSet.prototype.findEventById = function(eventId) {
const tl = this.getTimelineForEvent(eventId);
if (!tl) {
return undefined;
}
return tl.getEvents().find(function(ev) {
return ev.getId() == eventId;
});
};
/**
* Add a new timeline to this timeline list
*
* @return {module:models/event-timeline~EventTimeline} newly-created timeline
*/
EventTimelineSet.prototype.addTimeline = function() {
if (!this._timelineSupport) {
throw new Error("timeline support is disabled. Set the 'timelineSupport'" +
" parameter to true when creating MatrixClient to enable" +
" it.");
}
const timeline = new EventTimeline(this);
this._timelines.push(timeline);
return timeline;
};
/**
* Add events to a timeline
*
* <p>Will fire "Room.timeline" for each event added.
*
* @param {MatrixEvent[]} events A list of events to add.
*
* @param {boolean} toStartOfTimeline True to add these events to the start
* (oldest) instead of the end (newest) of the timeline. If true, the oldest
* event will be the <b>last</b> element of 'events'.
*
* @param {module:models/event-timeline~EventTimeline} timeline timeline to
* add events to.
*
* @param {string=} paginationToken token for the next batch of events
*
* @fires module:client~MatrixClient#event:"Room.timeline"
*
*/
EventTimelineSet.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
timeline, paginationToken) {
if (!timeline) {
throw new Error(
"'timeline' not specified for EventTimelineSet.addEventsToTimeline",
);
}
if (!toStartOfTimeline && timeline == this._liveTimeline) {
throw new Error(
"EventTimelineSet.addEventsToTimeline cannot be used for adding events to " +
"the live timeline - use Room.addLiveEvents instead",
);
}
if (this._filter) {
events = this._filter.filterRoomTimeline(events);
if (!events.length) {
return;
}
}
const direction = toStartOfTimeline ? EventTimeline.BACKWARDS :
EventTimeline.FORWARDS;
const inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS :
EventTimeline.BACKWARDS;
// Adding events to timelines can be quite complicated. The following
// illustrates some of the corner-cases.
//
// Let's say we start by knowing about four timelines. timeline3 and
// timeline4 are neighbours:
//
// timeline1 timeline2 timeline3 timeline4
// [M] [P] [S] <------> [T]
//
// Now we paginate timeline1, and get the following events from the server:
// [M, N, P, R, S, T, U].
//
// 1. First, we ignore event M, since we already know about it.
//
// 2. Next, we append N to timeline 1.
//
// 3. Next, we don't add event P, since we already know about it,
// but we do link together the timelines. We now have:
//
// timeline1 timeline2 timeline3 timeline4
// [M, N] <---> [P] [S] <------> [T]
//
// 4. Now we add event R to timeline2:
//
// timeline1 timeline2 timeline3 timeline4
// [M, N] <---> [P, R] [S] <------> [T]
//
// Note that we have switched the timeline we are working on from
// timeline1 to timeline2.
//
// 5. We ignore event S, but again join the timelines:
//
// timeline1 timeline2 timeline3 timeline4
// [M, N] <---> [P, R] <---> [S] <------> [T]
//
// 6. We ignore event T, and the timelines are already joined, so there
// is nothing to do.
//
// 7. Finally, we add event U to timeline4:
//
// timeline1 timeline2 timeline3 timeline4
// [M, N] <---> [P, R] <---> [S] <------> [T, U]
//
// The important thing to note in the above is what happened when we
// already knew about a given event:
//
// - if it was appropriate, we joined up the timelines (steps 3, 5).
// - in any case, we started adding further events to the timeline which
// contained the event we knew about (steps 3, 5, 6).
//
//
// So much for adding events to the timeline. But what do we want to do
// with the pagination token?
//
// In the case above, we will be given a pagination token which tells us how to
// get events beyond 'U' - in this case, it makes sense to store this
// against timeline4. But what if timeline4 already had 'U' and beyond? in
// that case, our best bet is to throw away the pagination token we were
// given and stick with whatever token timeline4 had previously. In short,
// we want to only store the pagination token if the last event we receive
// is one we didn't previously know about.
//
// We make an exception for this if it turns out that we already knew about
// *all* of the events, and we weren't able to join up any timelines. When
// that happens, it means our existing pagination token is faulty, since it
// is only telling us what we already know. Rather than repeatedly
// paginating with the same token, we might as well use the new pagination
// token in the hope that we eventually work our way out of the mess.
let didUpdate = false;
let lastEventWasNew = false;
for (let i = 0; i < events.length; i++) {
const event = events[i];
const eventId = event.getId();
const existingTimeline = this._eventIdToTimeline[eventId];
if (!existingTimeline) {
// we don't know about this event yet. Just add it to the timeline.
this.addEventToTimeline(event, timeline, toStartOfTimeline);
lastEventWasNew = true;
didUpdate = true;
continue;
}
lastEventWasNew = false;
if (existingTimeline == timeline) {
debuglog("Event " + eventId + " already in timeline " + timeline);
continue;
}
const neighbour = timeline.getNeighbouringTimeline(direction);
if (neighbour) {
// this timeline already has a neighbour in the relevant direction;
// let's assume the timelines are already correctly linked up, and
// skip over to it.
//
// there's probably some edge-case here where we end up with an
// event which is in a timeline a way down the chain, and there is
// a break in the chain somewhere. But I can't really imagine how
// that would happen, so I'm going to ignore it for now.
//
if (existingTimeline == neighbour) {
debuglog("Event " + eventId + " in neighbouring timeline - " +
"switching to " + existingTimeline);
} else {
debuglog("Event " + eventId + " already in a different " +
"timeline " + existingTimeline);
}
timeline = existingTimeline;
continue;
}
// time to join the timelines.
logger.info("Already have timeline for " + eventId +
" - joining timeline " + timeline + " to " +
existingTimeline);
// Variables to keep the line length limited below.
const existingIsLive = existingTimeline === this._liveTimeline;
const timelineIsLive = timeline === this._liveTimeline;
const backwardsIsLive = direction === EventTimeline.BACKWARDS && existingIsLive;
const forwardsIsLive = direction === EventTimeline.FORWARDS && timelineIsLive;
if (backwardsIsLive || forwardsIsLive) {
// The live timeline should never be spliced into a non-live position.
// We use independent logging to better discover the problem at a glance.
if (backwardsIsLive) {
logger.warn(
"Refusing to set a preceding existingTimeLine on our " +
"timeline as the existingTimeLine is live (" + existingTimeline + ")",
);
}
if (forwardsIsLive) {
logger.warn(
"Refusing to set our preceding timeline on a existingTimeLine " +
"as our timeline is live (" + timeline + ")",
);
}
continue; // abort splicing - try next event
}
timeline.setNeighbouringTimeline(existingTimeline, direction);
existingTimeline.setNeighbouringTimeline(timeline, inverseDirection);
timeline = existingTimeline;
didUpdate = true;
}
// see above - if the last event was new to us, or if we didn't find any
// new information, we update the pagination token for whatever
// timeline we ended up on.
if (lastEventWasNew || !didUpdate) {
if (direction === EventTimeline.FORWARDS && timeline === this._liveTimeline) {
logger.warn({ lastEventWasNew, didUpdate }); // for debugging
logger.warn(
`Refusing to set forwards pagination token of live timeline ` +
`${timeline} to ${paginationToken}`,
);
return;
}
timeline.setPaginationToken(paginationToken, direction);
}
};
/**
* Add an event to the end of this live timeline.
*
* @param {MatrixEvent} event Event to be added
* @param {string?} duplicateStrategy 'ignore' or 'replace'
* @param {boolean} fromCache whether the sync response came from cache
*/
EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy, fromCache) {
if (this._filter) {
const events = this._filter.filterRoomTimeline([event]);
if (!events.length) {
return;
}
}
const timeline = this._eventIdToTimeline[event.getId()];
if (timeline) {
if (duplicateStrategy === "replace") {
debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " +
event.getId());
const tlEvents = timeline.getEvents();
for (let j = 0; j < tlEvents.length; j++) {
if (tlEvents[j].getId() === event.getId()) {
// still need to set the right metadata on this event
EventTimeline.setEventMetadata(
event,
timeline.getState(EventTimeline.FORWARDS),
false,
);
if (!tlEvents[j].encryptedType) {
tlEvents[j] = event;
}
// XXX: we need to fire an event when this happens.
break;
}
}
} else {
debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " +
event.getId());
}
return;
}
this.addEventToTimeline(event, this._liveTimeline, false, fromCache);
};
/**
* Add event to the given timeline, and emit Room.timeline. Assumes
* we have already checked we don't know about this event.
*
* Will fire "Room.timeline" for each event added.
*
* @param {MatrixEvent} event
* @param {EventTimeline} timeline
* @param {boolean} toStartOfTimeline
* @param {boolean} fromCache whether the sync response came from cache
*
* @fires module:client~MatrixClient#event:"Room.timeline"
*/
EventTimelineSet.prototype.addEventToTimeline = function(event, timeline,
toStartOfTimeline, fromCache) {
const eventId = event.getId();
timeline.addEvent(event, toStartOfTimeline);
this._eventIdToTimeline[eventId] = timeline;
this.setRelationsTarget(event);
this.aggregateRelations(event);
const data = {
timeline: timeline,
liveEvent: !toStartOfTimeline && timeline == this._liveTimeline && !fromCache,
};
this.emit("Room.timeline", event, this.room,
Boolean(toStartOfTimeline), false, data);
};
/**
* Replaces event with ID oldEventId with one with newEventId, if oldEventId is
* recognised. Otherwise, add to the live timeline. Used to handle remote echos.
*
* @param {MatrixEvent} localEvent the new event to be added to the timeline
* @param {String} oldEventId the ID of the original event
* @param {boolean} newEventId the ID of the replacement event
*
* @fires module:client~MatrixClient#event:"Room.timeline"
*/
EventTimelineSet.prototype.handleRemoteEcho = function(localEvent, oldEventId,
newEventId) {
// XXX: why don't we infer newEventId from localEvent?
const existingTimeline = this._eventIdToTimeline[oldEventId];
if (existingTimeline) {
delete this._eventIdToTimeline[oldEventId];
this._eventIdToTimeline[newEventId] = existingTimeline;
} else {
if (this._filter) {
if (this._filter.filterRoomTimeline([localEvent]).length) {
this.addEventToTimeline(localEvent, this._liveTimeline, false);
}
} else {
this.addEventToTimeline(localEvent, this._liveTimeline, false);
}
}
};
/**
* Removes a single event from this room.
*
* @param {String} eventId The id of the event to remove
*
* @return {?MatrixEvent} the removed event, or null if the event was not found
* in this room.
*/
EventTimelineSet.prototype.removeEvent = function(eventId) {
const timeline = this._eventIdToTimeline[eventId];
if (!timeline) {
return null;
}
const removed = timeline.removeEvent(eventId);
if (removed) {
delete this._eventIdToTimeline[eventId];
const data = {
timeline: timeline,
};
this.emit("Room.timeline", removed, this.room, undefined, true, data);
}
return removed;
};
/**
* Determine where two events appear in the timeline relative to one another
*
* @param {string} eventId1 The id of the first event
* @param {string} eventId2 The id of the second event
* @return {?number} a number less than zero if eventId1 precedes eventId2, and
* greater than zero if eventId1 succeeds eventId2. zero if they are the
* same event; null if we can't tell (either because we don't know about one
* of the events, or because they are in separate timelines which don't join
* up).
*/
EventTimelineSet.prototype.compareEventOrdering = function(eventId1, eventId2) {
if (eventId1 == eventId2) {
// optimise this case
return 0;
}
const timeline1 = this._eventIdToTimeline[eventId1];
const timeline2 = this._eventIdToTimeline[eventId2];
if (timeline1 === undefined) {
return null;
}
if (timeline2 === undefined) {
return null;
}
if (timeline1 === timeline2) {
// both events are in the same timeline - figure out their
// relative indices
let idx1;
let idx2;
const events = timeline1.getEvents();
for (let idx = 0; idx < events.length &&
(idx1 === undefined || idx2 === undefined); idx++) {
const evId = events[idx].getId();
if (evId == eventId1) {
idx1 = idx;
}
if (evId == eventId2) {
idx2 = idx;
}
}
return idx1 - idx2;
}
// the events are in different timelines. Iterate through the
// linkedlist to see which comes first.
// first work forwards from timeline1
let tl = timeline1;
while (tl) {
if (tl === timeline2) {
// timeline1 is before timeline2
return -1;
}
tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS);
}
// now try backwards from timeline1
tl = timeline1;
while (tl) {
if (tl === timeline2) {
// timeline2 is before timeline1
return 1;
}
tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS);
}
// the timelines are not contiguous.
return null;
};
/**
* Get a collection of relations to a given event in this timeline set.
*
* @param {String} eventId
* The ID of the event that you'd like to access relation events for.
* For example, with annotations, this would be the ID of the event being annotated.
* @param {String} relationType
* The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc.
* @param {String} eventType
* The relation event's type, such as "m.reaction", etc.
* @throws If <code>eventId</code>, <code>relationType</code> or <code>eventType</code>
* are not valid.
*
* @returns {?Relations}
* A container for relation events or undefined if there are no relation events for
* the relationType.
*/
EventTimelineSet.prototype.getRelationsForEvent = function(
eventId, relationType, eventType,
) {
if (!this._unstableClientRelationAggregation) {
throw new Error("Client-side relation aggregation is disabled");
}
if (!eventId || !relationType || !eventType) {
throw new Error("Invalid arguments for `getRelationsForEvent`");
}
// debuglog("Getting relations for: ", eventId, relationType, eventType);
const relationsForEvent = this._relations[eventId] || {};
const relationsWithRelType = relationsForEvent[relationType] || {};
return relationsWithRelType[eventType];
};
/**
* Set an event as the target event if any Relations exist for it already
*
* @param {MatrixEvent} event
* The event to check as relation target.
*/
EventTimelineSet.prototype.setRelationsTarget = function(event) {
if (!this._unstableClientRelationAggregation) {
return;
}
const relationsForEvent = this._relations[event.getId()];
if (!relationsForEvent) {
return;
}
for (const relationsWithRelType of Object.values(relationsForEvent)) {
for (const relationsWithEventType of Object.values(relationsWithRelType)) {
relationsWithEventType.setTargetEvent(event);
}
}
};
/**
* Add relation events to the relevant relation collection.
*
* @param {MatrixEvent} event
* The new relation event to be aggregated.
*/
EventTimelineSet.prototype.aggregateRelations = function(event) {
if (!this._unstableClientRelationAggregation) {
return;
}
if (event.isRedacted() || event.status === EventStatus.CANCELLED) {
return;
}
// If the event is currently encrypted, wait until it has been decrypted.
if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) {
event.once("Event.decrypted", () => {
this.aggregateRelations(event);
});
return;
}
const relation = event.getRelation();
if (!relation) {
return;
}
const relatesToEventId = relation.event_id;
const relationType = relation.rel_type;
const eventType = event.getType();
// debuglog("Aggregating relation: ", event.getId(), eventType, relation);
let relationsForEvent = this._relations[relatesToEventId];
if (!relationsForEvent) {
relationsForEvent = this._relations[relatesToEventId] = {};
}
let relationsWithRelType = relationsForEvent[relationType];
if (!relationsWithRelType) {
relationsWithRelType = relationsForEvent[relationType] = {};
}
let relationsWithEventType = relationsWithRelType[eventType];
let relatesToEvent;
if (!relationsWithEventType) {
relationsWithEventType = relationsWithRelType[eventType] = new Relations(
relationType,
eventType,
this.room,
);
relatesToEvent = this.findEventById(relatesToEventId) || this.room.getPendingEvent(relatesToEventId);
if (relatesToEvent) {
relationsWithEventType.setTargetEvent(relatesToEvent);
}
}
relationsWithEventType.addEvent(event);
};
/**
* Fires whenever the timeline in a room is updated.
* @event module:client~MatrixClient#"Room.timeline"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {?Room} room The room, if any, whose timeline was updated.
* @param {boolean} toStartOfTimeline True if this event was added to the start
* @param {boolean} removed True if this event has just been removed from the timeline
* (beginning; oldest) of the timeline e.g. due to pagination.
*
* @param {object} data more data about the event
*
* @param {module:models/event-timeline.EventTimeline} data.timeline the timeline the
* event was added to/removed from
*
* @param {boolean} data.liveEvent true if the event was a real-time event
* added to the end of the live timeline
*
* @example
* matrixClient.on("Room.timeline",
* function(event, room, toStartOfTimeline, removed, data) {
* if (!toStartOfTimeline && data.liveEvent) {
* var messageToAppend = room.timeline.[room.timeline.length - 1];
* }
* });
*/
/**
* Fires whenever the live timeline in a room is reset.
*
* When we get a 'limited' sync (for example, after a network outage), we reset
* the live timeline to be empty before adding the recent events to the new
* timeline. This event is fired after the timeline is reset, and before the
* new events are added.
*
* @event module:client~MatrixClient#"Room.timelineReset"
* @param {Room} room The room whose live timeline was reset, if any
* @param {EventTimelineSet} timelineSet timelineSet room whose live timeline was reset
* @param {boolean} resetAllTimelines True if all timelines were reset.
*/

View File

@@ -0,0 +1,874 @@
/*
Copyright 2016 - 2021 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.
*/
/**
* @module models/event-timeline-set
*/
import { EventEmitter } from "events";
import { EventTimeline } from "./event-timeline";
import { EventStatus, MatrixEvent } from "./event";
import { logger } from '../logger';
import { Relations } from './relations';
import { Room } from "./room";
import { Filter } from "../filter";
import { EventType, RelationType } from "../@types/event";
// var DEBUG = false;
const DEBUG = true;
let debuglog;
if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console
debuglog = logger.log.bind(logger);
} else {
debuglog = function() {};
}
interface IOpts {
timelineSupport?: boolean;
filter?: Filter;
unstableClientRelationAggregation?: boolean;
}
export class EventTimelineSet extends EventEmitter {
private readonly timelineSupport: boolean;
private unstableClientRelationAggregation: boolean;
private liveTimeline: EventTimeline;
private timelines: EventTimeline[];
private _eventIdToTimeline: Record<string, EventTimeline>;
private filter?: Filter;
private relations: Record<string, Record<string, Record<RelationType, Relations>>>;
/**
* Construct a set of EventTimeline objects, typically on behalf of a given
* room. A room may have multiple EventTimelineSets for different levels
* of filtering. The global notification list is also an EventTimelineSet, but
* lacks a room.
*
* <p>This is an ordered sequence of timelines, which may or may not
* be continuous. Each timeline lists a series of events, as well as tracking
* the room state at the start and the end of the timeline (if appropriate).
* It also tracks forward and backward pagination tokens, as well as containing
* links to the next timeline in the sequence.
*
* <p>There is one special timeline - the 'live' timeline, which represents the
* timeline to which events are being added in real-time as they are received
* from the /sync API. Note that you should not retain references to this
* timeline - even if it is the current timeline right now, it may not remain
* so if the server gives us a timeline gap in /sync.
*
* <p>In order that we can find events from their ids later, we also maintain a
* map from event_id to timeline and index.
*
* @constructor
* @param {?Room} room
* Room for this timelineSet. May be null for non-room cases, such as the
* notification timeline.
* @param {Object} opts Options inherited from Room.
*
* @param {boolean} [opts.timelineSupport = false]
* Set to true to enable improved timeline support.
* @param {Object} [opts.filter = null]
* The filter object, if any, for this timelineSet.
* @param {boolean} [opts.unstableClientRelationAggregation = false]
* Optional. Set to true to enable client-side aggregation of event relations
* via `getRelationsForEvent`.
* This feature is currently unstable and the API may change without notice.
*/
constructor(public readonly room: Room, opts: IOpts) {
super();
this.timelineSupport = Boolean(opts.timelineSupport);
this.liveTimeline = new EventTimeline(this);
this.unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation;
// just a list - *not* ordered.
this.timelines = [this.liveTimeline];
this._eventIdToTimeline = {};
this.filter = opts.filter;
if (this.unstableClientRelationAggregation) {
// A tree of objects to access a set of relations for an event, as in:
// this.relations[relatesToEventId][relationType][relationEventType]
this.relations = {};
}
}
/**
* Get all the timelines in this set
* @return {module:models/event-timeline~EventTimeline[]} the timelines in this set
*/
public getTimelines(): EventTimeline[] {
return this.timelines;
}
/**
* Get the filter object this timeline set is filtered on, if any
* @return {?Filter} the optional filter for this timelineSet
*/
public getFilter(): Filter | undefined {
return this.filter;
}
/**
* Set the filter object this timeline set is filtered on
* (passed to the server when paginating via /messages).
* @param {Filter} filter the filter for this timelineSet
*/
public setFilter(filter?: Filter): void {
this.filter = filter;
}
/**
* Get the list of pending sent events for this timelineSet's room, filtered
* by the timelineSet's filter if appropriate.
*
* @return {module:models/event.MatrixEvent[]} A list of the sent events
* waiting for remote echo.
*
* @throws If <code>opts.pendingEventOrdering</code> was not 'detached'
*/
public getPendingEvents(): MatrixEvent[] {
if (!this.room) {
return [];
}
if (this.filter) {
return this.filter.filterRoomTimeline(this.room.getPendingEvents());
} else {
return this.room.getPendingEvents();
}
}
/**
* Get the live timeline for this room.
*
* @return {module:models/event-timeline~EventTimeline} live timeline
*/
public getLiveTimeline(): EventTimeline {
return this.liveTimeline;
}
/**
* Return the timeline (if any) this event is in.
* @param {String} eventId the eventId being sought
* @return {module:models/event-timeline~EventTimeline} timeline
*/
public eventIdToTimeline(eventId: string): EventTimeline {
return this._eventIdToTimeline[eventId];
}
/**
* Track a new event as if it were in the same timeline as an old event,
* replacing it.
* @param {String} oldEventId event ID of the original event
* @param {String} newEventId event ID of the replacement event
*/
public replaceEventId(oldEventId: string, newEventId: string): void {
const existingTimeline = this._eventIdToTimeline[oldEventId];
if (existingTimeline) {
delete this._eventIdToTimeline[oldEventId];
this._eventIdToTimeline[newEventId] = existingTimeline;
}
}
/**
* Reset the live timeline, and start a new one.
*
* <p>This is used when /sync returns a 'limited' timeline.
*
* @param {string=} backPaginationToken token for back-paginating the new timeline
* @param {string=} forwardPaginationToken token for forward-paginating the old live timeline,
* if absent or null, all timelines are reset.
*
* @fires module:client~MatrixClient#event:"Room.timelineReset"
*/
public resetLiveTimeline(backPaginationToken: string, forwardPaginationToken?: string): void {
// Each EventTimeline has RoomState objects tracking the state at the start
// and end of that timeline. The copies at the end of the live timeline are
// special because they will have listeners attached to monitor changes to
// the current room state, so we move this RoomState from the end of the
// current live timeline to the end of the new one and, if necessary,
// replace it with a newly created one. We also make a copy for the start
// of the new timeline.
// if timeline support is disabled, forget about the old timelines
const resetAllTimelines = !this.timelineSupport || !forwardPaginationToken;
const oldTimeline = this.liveTimeline;
const newTimeline = resetAllTimelines ?
oldTimeline.forkLive(EventTimeline.FORWARDS) :
oldTimeline.fork(EventTimeline.FORWARDS);
if (resetAllTimelines) {
this.timelines = [newTimeline];
this._eventIdToTimeline = {};
} else {
this.timelines.push(newTimeline);
}
if (forwardPaginationToken) {
// Now set the forward pagination token on the old live timeline
// so it can be forward-paginated.
oldTimeline.setPaginationToken(
forwardPaginationToken, EventTimeline.FORWARDS,
);
}
// make sure we set the pagination token before firing timelineReset,
// otherwise clients which start back-paginating will fail, and then get
// stuck without realising that they *can* back-paginate.
newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS);
// Now we can swap the live timeline to the new one.
this.liveTimeline = newTimeline;
this.emit("Room.timelineReset", this.room, this, resetAllTimelines);
}
/**
* Get the timeline which contains the given event, if any
*
* @param {string} eventId event ID to look for
* @return {?module:models/event-timeline~EventTimeline} timeline containing
* the given event, or null if unknown
*/
public getTimelineForEvent(eventId: string): EventTimeline | null {
const res = this._eventIdToTimeline[eventId];
return (res === undefined) ? null : res;
}
/**
* Get an event which is stored in our timelines
*
* @param {string} eventId event ID to look for
* @return {?module:models/event~MatrixEvent} the given event, or undefined if unknown
*/
public findEventById(eventId: string): MatrixEvent | undefined {
const tl = this.getTimelineForEvent(eventId);
if (!tl) {
return undefined;
}
return tl.getEvents().find(function(ev) {
return ev.getId() == eventId;
});
}
/**
* Add a new timeline to this timeline list
*
* @return {module:models/event-timeline~EventTimeline} newly-created timeline
*/
public addTimeline(): EventTimeline {
if (!this.timelineSupport) {
throw new Error("timeline support is disabled. Set the 'timelineSupport'" +
" parameter to true when creating MatrixClient to enable" +
" it.");
}
const timeline = new EventTimeline(this);
this.timelines.push(timeline);
return timeline;
}
/**
* Add events to a timeline
*
* <p>Will fire "Room.timeline" for each event added.
*
* @param {MatrixEvent[]} events A list of events to add.
*
* @param {boolean} toStartOfTimeline True to add these events to the start
* (oldest) instead of the end (newest) of the timeline. If true, the oldest
* event will be the <b>last</b> element of 'events'.
*
* @param {module:models/event-timeline~EventTimeline} timeline timeline to
* add events to.
*
* @param {string=} paginationToken token for the next batch of events
*
* @fires module:client~MatrixClient#event:"Room.timeline"
*
*/
public addEventsToTimeline(
events: MatrixEvent[],
toStartOfTimeline: boolean,
timeline: EventTimeline,
paginationToken: string,
): void {
if (!timeline) {
throw new Error(
"'timeline' not specified for EventTimelineSet.addEventsToTimeline",
);
}
if (!toStartOfTimeline && timeline == this.liveTimeline) {
throw new Error(
"EventTimelineSet.addEventsToTimeline cannot be used for adding events to " +
"the live timeline - use Room.addLiveEvents instead",
);
}
if (this.filter) {
events = this.filter.filterRoomTimeline(events);
if (!events.length) {
return;
}
}
const direction = toStartOfTimeline ? EventTimeline.BACKWARDS :
EventTimeline.FORWARDS;
const inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS :
EventTimeline.BACKWARDS;
// Adding events to timelines can be quite complicated. The following
// illustrates some of the corner-cases.
//
// Let's say we start by knowing about four timelines. timeline3 and
// timeline4 are neighbours:
//
// timeline1 timeline2 timeline3 timeline4
// [M] [P] [S] <------> [T]
//
// Now we paginate timeline1, and get the following events from the server:
// [M, N, P, R, S, T, U].
//
// 1. First, we ignore event M, since we already know about it.
//
// 2. Next, we append N to timeline 1.
//
// 3. Next, we don't add event P, since we already know about it,
// but we do link together the timelines. We now have:
//
// timeline1 timeline2 timeline3 timeline4
// [M, N] <---> [P] [S] <------> [T]
//
// 4. Now we add event R to timeline2:
//
// timeline1 timeline2 timeline3 timeline4
// [M, N] <---> [P, R] [S] <------> [T]
//
// Note that we have switched the timeline we are working on from
// timeline1 to timeline2.
//
// 5. We ignore event S, but again join the timelines:
//
// timeline1 timeline2 timeline3 timeline4
// [M, N] <---> [P, R] <---> [S] <------> [T]
//
// 6. We ignore event T, and the timelines are already joined, so there
// is nothing to do.
//
// 7. Finally, we add event U to timeline4:
//
// timeline1 timeline2 timeline3 timeline4
// [M, N] <---> [P, R] <---> [S] <------> [T, U]
//
// The important thing to note in the above is what happened when we
// already knew about a given event:
//
// - if it was appropriate, we joined up the timelines (steps 3, 5).
// - in any case, we started adding further events to the timeline which
// contained the event we knew about (steps 3, 5, 6).
//
//
// So much for adding events to the timeline. But what do we want to do
// with the pagination token?
//
// In the case above, we will be given a pagination token which tells us how to
// get events beyond 'U' - in this case, it makes sense to store this
// against timeline4. But what if timeline4 already had 'U' and beyond? in
// that case, our best bet is to throw away the pagination token we were
// given and stick with whatever token timeline4 had previously. In short,
// we want to only store the pagination token if the last event we receive
// is one we didn't previously know about.
//
// We make an exception for this if it turns out that we already knew about
// *all* of the events, and we weren't able to join up any timelines. When
// that happens, it means our existing pagination token is faulty, since it
// is only telling us what we already know. Rather than repeatedly
// paginating with the same token, we might as well use the new pagination
// token in the hope that we eventually work our way out of the mess.
let didUpdate = false;
let lastEventWasNew = false;
for (let i = 0; i < events.length; i++) {
const event = events[i];
const eventId = event.getId();
const existingTimeline = this._eventIdToTimeline[eventId];
if (!existingTimeline) {
// we don't know about this event yet. Just add it to the timeline.
this.addEventToTimeline(event, timeline, toStartOfTimeline);
lastEventWasNew = true;
didUpdate = true;
continue;
}
lastEventWasNew = false;
if (existingTimeline == timeline) {
debuglog("Event " + eventId + " already in timeline " + timeline);
continue;
}
const neighbour = timeline.getNeighbouringTimeline(direction);
if (neighbour) {
// this timeline already has a neighbour in the relevant direction;
// let's assume the timelines are already correctly linked up, and
// skip over to it.
//
// there's probably some edge-case here where we end up with an
// event which is in a timeline a way down the chain, and there is
// a break in the chain somewhere. But I can't really imagine how
// that would happen, so I'm going to ignore it for now.
//
if (existingTimeline == neighbour) {
debuglog("Event " + eventId + " in neighbouring timeline - " +
"switching to " + existingTimeline);
} else {
debuglog("Event " + eventId + " already in a different " +
"timeline " + existingTimeline);
}
timeline = existingTimeline;
continue;
}
// time to join the timelines.
logger.info("Already have timeline for " + eventId +
" - joining timeline " + timeline + " to " +
existingTimeline);
// Variables to keep the line length limited below.
const existingIsLive = existingTimeline === this.liveTimeline;
const timelineIsLive = timeline === this.liveTimeline;
const backwardsIsLive = direction === EventTimeline.BACKWARDS && existingIsLive;
const forwardsIsLive = direction === EventTimeline.FORWARDS && timelineIsLive;
if (backwardsIsLive || forwardsIsLive) {
// The live timeline should never be spliced into a non-live position.
// We use independent logging to better discover the problem at a glance.
if (backwardsIsLive) {
logger.warn(
"Refusing to set a preceding existingTimeLine on our " +
"timeline as the existingTimeLine is live (" + existingTimeline + ")",
);
}
if (forwardsIsLive) {
logger.warn(
"Refusing to set our preceding timeline on a existingTimeLine " +
"as our timeline is live (" + timeline + ")",
);
}
continue; // abort splicing - try next event
}
timeline.setNeighbouringTimeline(existingTimeline, direction);
existingTimeline.setNeighbouringTimeline(timeline, inverseDirection);
timeline = existingTimeline;
didUpdate = true;
}
// see above - if the last event was new to us, or if we didn't find any
// new information, we update the pagination token for whatever
// timeline we ended up on.
if (lastEventWasNew || !didUpdate) {
if (direction === EventTimeline.FORWARDS && timeline === this.liveTimeline) {
logger.warn({ lastEventWasNew, didUpdate }); // for debugging
logger.warn(
`Refusing to set forwards pagination token of live timeline ` +
`${timeline} to ${paginationToken}`,
);
return;
}
timeline.setPaginationToken(paginationToken, direction);
}
}
/**
* Add an event to the end of this live timeline.
*
* @param {MatrixEvent} event Event to be added
* @param {string?} duplicateStrategy 'ignore' or 'replace'
* @param {boolean} fromCache whether the sync response came from cache
*/
public addLiveEvent(event: MatrixEvent, duplicateStrategy?: "ignore" | "replace", fromCache = false): void {
if (this.filter) {
const events = this.filter.filterRoomTimeline([event]);
if (!events.length) {
return;
}
}
const timeline = this._eventIdToTimeline[event.getId()];
if (timeline) {
if (duplicateStrategy === "replace") {
debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " +
event.getId());
const tlEvents = timeline.getEvents();
for (let j = 0; j < tlEvents.length; j++) {
if (tlEvents[j].getId() === event.getId()) {
// still need to set the right metadata on this event
EventTimeline.setEventMetadata(
event,
timeline.getState(EventTimeline.FORWARDS),
false,
);
tlEvents[j] = event;
// XXX: we need to fire an event when this happens.
break;
}
}
} else {
debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " +
event.getId());
}
return;
}
this.addEventToTimeline(event, this.liveTimeline, false, fromCache);
}
/**
* Add event to the given timeline, and emit Room.timeline. Assumes
* we have already checked we don't know about this event.
*
* Will fire "Room.timeline" for each event added.
*
* @param {MatrixEvent} event
* @param {EventTimeline} timeline
* @param {boolean} toStartOfTimeline
* @param {boolean} fromCache whether the sync response came from cache
*
* @fires module:client~MatrixClient#event:"Room.timeline"
*/
public addEventToTimeline(
event: MatrixEvent,
timeline: EventTimeline,
toStartOfTimeline: boolean,
fromCache = false,
) {
const eventId = event.getId();
timeline.addEvent(event, toStartOfTimeline);
this._eventIdToTimeline[eventId] = timeline;
this.setRelationsTarget(event);
this.aggregateRelations(event);
const data = {
timeline: timeline,
liveEvent: !toStartOfTimeline && timeline == this.liveTimeline && !fromCache,
};
this.emit("Room.timeline", event, this.room,
Boolean(toStartOfTimeline), false, data);
}
/**
* Replaces event with ID oldEventId with one with newEventId, if oldEventId is
* recognised. Otherwise, add to the live timeline. Used to handle remote echos.
*
* @param {MatrixEvent} localEvent the new event to be added to the timeline
* @param {String} oldEventId the ID of the original event
* @param {boolean} newEventId the ID of the replacement event
*
* @fires module:client~MatrixClient#event:"Room.timeline"
*/
public handleRemoteEcho(
localEvent: MatrixEvent,
oldEventId: string,
newEventId: string,
): void {
// XXX: why don't we infer newEventId from localEvent?
const existingTimeline = this._eventIdToTimeline[oldEventId];
if (existingTimeline) {
delete this._eventIdToTimeline[oldEventId];
this._eventIdToTimeline[newEventId] = existingTimeline;
} else {
if (this.filter) {
if (this.filter.filterRoomTimeline([localEvent]).length) {
this.addEventToTimeline(localEvent, this.liveTimeline, false);
}
} else {
this.addEventToTimeline(localEvent, this.liveTimeline, false);
}
}
}
/**
* Removes a single event from this room.
*
* @param {String} eventId The id of the event to remove
*
* @return {?MatrixEvent} the removed event, or null if the event was not found
* in this room.
*/
public removeEvent(eventId: string): MatrixEvent | null {
const timeline = this._eventIdToTimeline[eventId];
if (!timeline) {
return null;
}
const removed = timeline.removeEvent(eventId);
if (removed) {
delete this._eventIdToTimeline[eventId];
const data = {
timeline: timeline,
};
this.emit("Room.timeline", removed, this.room, undefined, true, data);
}
return removed;
}
/**
* Determine where two events appear in the timeline relative to one another
*
* @param {string} eventId1 The id of the first event
* @param {string} eventId2 The id of the second event
* @return {?number} a number less than zero if eventId1 precedes eventId2, and
* greater than zero if eventId1 succeeds eventId2. zero if they are the
* same event; null if we can't tell (either because we don't know about one
* of the events, or because they are in separate timelines which don't join
* up).
*/
public compareEventOrdering(eventId1: string, eventId2: string): number | null {
if (eventId1 == eventId2) {
// optimise this case
return 0;
}
const timeline1 = this._eventIdToTimeline[eventId1];
const timeline2 = this._eventIdToTimeline[eventId2];
if (timeline1 === undefined) {
return null;
}
if (timeline2 === undefined) {
return null;
}
if (timeline1 === timeline2) {
// both events are in the same timeline - figure out their
// relative indices
let idx1;
let idx2;
const events = timeline1.getEvents();
for (let idx = 0; idx < events.length &&
(idx1 === undefined || idx2 === undefined); idx++) {
const evId = events[idx].getId();
if (evId == eventId1) {
idx1 = idx;
}
if (evId == eventId2) {
idx2 = idx;
}
}
return idx1 - idx2;
}
// the events are in different timelines. Iterate through the
// linkedlist to see which comes first.
// first work forwards from timeline1
let tl = timeline1;
while (tl) {
if (tl === timeline2) {
// timeline1 is before timeline2
return -1;
}
tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS);
}
// now try backwards from timeline1
tl = timeline1;
while (tl) {
if (tl === timeline2) {
// timeline2 is before timeline1
return 1;
}
tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS);
}
// the timelines are not contiguous.
return null;
}
/**
* Get a collection of relations to a given event in this timeline set.
*
* @param {String} eventId
* The ID of the event that you'd like to access relation events for.
* For example, with annotations, this would be the ID of the event being annotated.
* @param {String} relationType
* The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc.
* @param {String} eventType
* The relation event's type, such as "m.reaction", etc.
* @throws If <code>eventId</code>, <code>relationType</code> or <code>eventType</code>
* are not valid.
*
* @returns {?Relations}
* A container for relation events or undefined if there are no relation events for
* the relationType.
*/
public getRelationsForEvent(
eventId: string,
relationType: RelationType,
eventType: EventType | string,
): Relations | undefined {
if (!this.unstableClientRelationAggregation) {
throw new Error("Client-side relation aggregation is disabled");
}
if (!eventId || !relationType || !eventType) {
throw new Error("Invalid arguments for `getRelationsForEvent`");
}
// debuglog("Getting relations for: ", eventId, relationType, eventType);
const relationsForEvent = this.relations[eventId] || {};
const relationsWithRelType = relationsForEvent[relationType] || {};
return relationsWithRelType[eventType];
}
/**
* Set an event as the target event if any Relations exist for it already
*
* @param {MatrixEvent} event
* The event to check as relation target.
*/
public setRelationsTarget(event: MatrixEvent): void {
if (!this.unstableClientRelationAggregation) {
return;
}
const relationsForEvent = this.relations[event.getId()];
if (!relationsForEvent) {
return;
}
for (const relationsWithRelType of Object.values(relationsForEvent)) {
for (const relationsWithEventType of Object.values(relationsWithRelType)) {
relationsWithEventType.setTargetEvent(event);
}
}
}
/**
* Add relation events to the relevant relation collection.
*
* @param {MatrixEvent} event
* The new relation event to be aggregated.
*/
public aggregateRelations(event: MatrixEvent): void {
if (!this.unstableClientRelationAggregation) {
return;
}
if (event.isRedacted() || event.status === EventStatus.CANCELLED) {
return;
}
// If the event is currently encrypted, wait until it has been decrypted.
if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) {
event.once("Event.decrypted", () => {
this.aggregateRelations(event);
});
return;
}
const relation = event.getRelation();
if (!relation) {
return;
}
const relatesToEventId = relation.event_id;
const relationType = relation.rel_type;
const eventType = event.getType();
// debuglog("Aggregating relation: ", event.getId(), eventType, relation);
let relationsForEvent: Record<string, Partial<Record<string, Relations>>> = this.relations[relatesToEventId];
if (!relationsForEvent) {
relationsForEvent = this.relations[relatesToEventId] = {};
}
let relationsWithRelType = relationsForEvent[relationType];
if (!relationsWithRelType) {
relationsWithRelType = relationsForEvent[relationType] = {};
}
let relationsWithEventType = relationsWithRelType[eventType];
let relatesToEvent;
if (!relationsWithEventType) {
relationsWithEventType = relationsWithRelType[eventType] = new Relations(
relationType,
eventType,
this.room,
);
relatesToEvent = this.findEventById(relatesToEventId) || this.room.getPendingEvent(relatesToEventId);
if (relatesToEvent) {
relationsWithEventType.setTargetEvent(relatesToEvent);
}
}
relationsWithEventType.addEvent(event);
}
}
/**
* Fires whenever the timeline in a room is updated.
* @event module:client~MatrixClient#"Room.timeline"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {?Room} room The room, if any, whose timeline was updated.
* @param {boolean} toStartOfTimeline True if this event was added to the start
* @param {boolean} removed True if this event has just been removed from the timeline
* (beginning; oldest) of the timeline e.g. due to pagination.
*
* @param {object} data more data about the event
*
* @param {module:models/event-timeline.EventTimeline} data.timeline the timeline the
* event was added to/removed from
*
* @param {boolean} data.liveEvent true if the event was a real-time event
* added to the end of the live timeline
*
* @example
* matrixClient.on("Room.timeline",
* function(event, room, toStartOfTimeline, removed, data) {
* if (!toStartOfTimeline && data.liveEvent) {
* var messageToAppend = room.timeline.[room.timeline.length - 1];
* }
* });
*/
/**
* Fires whenever the live timeline in a room is reset.
*
* When we get a 'limited' sync (for example, after a network outage), we reset
* the live timeline to be empty before adding the recent events to the new
* timeline. This event is fired after the timeline is reset, and before the
* new events are added.
*
* @event module:client~MatrixClient#"Room.timelineReset"
* @param {Room} room The room whose live timeline was reset, if any
* @param {EventTimelineSet} timelineSet timelineSet room whose live timeline was reset
* @param {boolean} resetAllTimelines True if all timelines were reset.
*/

View File

@@ -1,398 +0,0 @@
/*
Copyright 2016, 2017 OpenMarket Ltd
Copyright 2019 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.
*/
/**
* @module models/event-timeline
*/
import { RoomState } from "./room-state";
/**
* Construct a new EventTimeline
*
* <p>An EventTimeline represents a contiguous sequence of events in a room.
*
* <p>As well as keeping track of the events themselves, it stores the state of
* the room at the beginning and end of the timeline, and pagination tokens for
* going backwards and forwards in the timeline.
*
* <p>In order that clients can meaningfully maintain an index into a timeline,
* the EventTimeline object tracks a 'baseIndex'. This starts at zero, but is
* incremented when events are prepended to the timeline. The index of an event
* relative to baseIndex therefore remains constant.
*
* <p>Once a timeline joins up with its neighbour, they are linked together into a
* doubly-linked list.
*
* @param {EventTimelineSet} eventTimelineSet the set of timelines this is part of
* @constructor
*/
export function EventTimeline(eventTimelineSet) {
this._eventTimelineSet = eventTimelineSet;
this._roomId = eventTimelineSet.room ? eventTimelineSet.room.roomId : null;
this._events = [];
this._baseIndex = 0;
this._startState = new RoomState(this._roomId);
this._startState.paginationToken = null;
this._endState = new RoomState(this._roomId);
this._endState.paginationToken = null;
this._prevTimeline = null;
this._nextTimeline = null;
// this is used by client.js
this._paginationRequests = { 'b': null, 'f': null };
this._name = this._roomId + ":" + new Date().toISOString();
}
/**
* Symbolic constant for methods which take a 'direction' argument:
* refers to the start of the timeline, or backwards in time.
*/
EventTimeline.BACKWARDS = "b";
/**
* Symbolic constant for methods which take a 'direction' argument:
* refers to the end of the timeline, or forwards in time.
*/
EventTimeline.FORWARDS = "f";
/**
* Initialise the start and end state with the given events
*
* <p>This can only be called before any events are added.
*
* @param {MatrixEvent[]} stateEvents list of state events to initialise the
* state with.
* @throws {Error} if an attempt is made to call this after addEvent is called.
*/
EventTimeline.prototype.initialiseState = function(stateEvents) {
if (this._events.length > 0) {
throw new Error("Cannot initialise state after events are added");
}
// We previously deep copied events here and used different copies in
// the oldState and state events: this decision seems to date back
// quite a way and was apparently made to fix a bug where modifications
// made to the start state leaked through to the end state.
// This really shouldn't be possible though: the events themselves should
// not change. Duplicating the events uses a lot of extra memory,
// so we now no longer do it. To assert that they really do never change,
// freeze them! Note that we can't do this for events in general:
// although it looks like the only things preventing us are the
// 'status' flag, forwardLooking (which is only set once when adding to the
// timeline) and possibly the sender (which seems like it should never be
// reset but in practice causes a lot of the tests to break).
for (const e of stateEvents) {
Object.freeze(e);
}
this._startState.setStateEvents(stateEvents);
this._endState.setStateEvents(stateEvents);
};
/**
* Forks the (live) timeline, taking ownership of the existing directional state of this timeline.
* All attached listeners will keep receiving state updates from the new live timeline state.
* The end state of this timeline gets replaced with an independent copy of the current RoomState,
* and will need a new pagination token if it ever needs to paginate forwards.
* @param {string} direction EventTimeline.BACKWARDS to get the state at the
* start of the timeline; EventTimeline.FORWARDS to get the state at the end
* of the timeline.
*
* @return {EventTimeline} the new timeline
*/
EventTimeline.prototype.forkLive = function(direction) {
const forkState = this.getState(direction);
const timeline = new EventTimeline(this._eventTimelineSet);
timeline._startState = forkState.clone();
// Now clobber the end state of the new live timeline with that from the
// previous live timeline. It will be identical except that we'll keep
// using the same RoomMember objects for the 'live' set of members with any
// listeners still attached
timeline._endState = forkState;
// Firstly, we just stole the current timeline's end state, so it needs a new one.
// Make an immutable copy of the state so back pagination will get the correct sentinels.
this._endState = forkState.clone();
return timeline;
};
/**
* Creates an independent timeline, inheriting the directional state from this timeline.
*
* @param {string} direction EventTimeline.BACKWARDS to get the state at the
* start of the timeline; EventTimeline.FORWARDS to get the state at the end
* of the timeline.
*
* @return {EventTimeline} the new timeline
*/
EventTimeline.prototype.fork = function(direction) {
const forkState = this.getState(direction);
const timeline = new EventTimeline(this._eventTimelineSet);
timeline._startState = forkState.clone();
timeline._endState = forkState.clone();
return timeline;
};
/**
* Get the ID of the room for this timeline
* @return {string} room ID
*/
EventTimeline.prototype.getRoomId = function() {
return this._roomId;
};
/**
* Get the filter for this timeline's timelineSet (if any)
* @return {Filter} filter
*/
EventTimeline.prototype.getFilter = function() {
return this._eventTimelineSet.getFilter();
};
/**
* Get the timelineSet for this timeline
* @return {EventTimelineSet} timelineSet
*/
EventTimeline.prototype.getTimelineSet = function() {
return this._eventTimelineSet;
};
/**
* Get the base index.
*
* <p>This is an index which is incremented when events are prepended to the
* timeline. An individual event therefore stays at the same index in the array
* relative to the base index (although note that a given event's index may
* well be less than the base index, thus giving that event a negative relative
* index).
*
* @return {number}
*/
EventTimeline.prototype.getBaseIndex = function() {
return this._baseIndex;
};
/**
* Get the list of events in this context
*
* @return {MatrixEvent[]} An array of MatrixEvents
*/
EventTimeline.prototype.getEvents = function() {
return this._events;
};
/**
* Get the room state at the start/end of the timeline
*
* @param {string} direction EventTimeline.BACKWARDS to get the state at the
* start of the timeline; EventTimeline.FORWARDS to get the state at the end
* of the timeline.
*
* @return {RoomState} state at the start/end of the timeline
*/
EventTimeline.prototype.getState = function(direction) {
if (direction == EventTimeline.BACKWARDS) {
return this._startState;
} else if (direction == EventTimeline.FORWARDS) {
return this._endState;
} else {
throw new Error("Invalid direction '" + direction + "'");
}
};
/**
* Get a pagination token
*
* @param {string} direction EventTimeline.BACKWARDS to get the pagination
* token for going backwards in time; EventTimeline.FORWARDS to get the
* pagination token for going forwards in time.
*
* @return {?string} pagination token
*/
EventTimeline.prototype.getPaginationToken = function(direction) {
return this.getState(direction).paginationToken;
};
/**
* Set a pagination token
*
* @param {?string} token pagination token
*
* @param {string} direction EventTimeline.BACKWARDS to set the pagination
* token for going backwards in time; EventTimeline.FORWARDS to set the
* pagination token for going forwards in time.
*/
EventTimeline.prototype.setPaginationToken = function(token, direction) {
this.getState(direction).paginationToken = token;
};
/**
* Get the next timeline in the series
*
* @param {string} direction EventTimeline.BACKWARDS to get the previous
* timeline; EventTimeline.FORWARDS to get the next timeline.
*
* @return {?EventTimeline} previous or following timeline, if they have been
* joined up.
*/
EventTimeline.prototype.getNeighbouringTimeline = function(direction) {
if (direction == EventTimeline.BACKWARDS) {
return this._prevTimeline;
} else if (direction == EventTimeline.FORWARDS) {
return this._nextTimeline;
} else {
throw new Error("Invalid direction '" + direction + "'");
}
};
/**
* Set the next timeline in the series
*
* @param {EventTimeline} neighbour previous/following timeline
*
* @param {string} direction EventTimeline.BACKWARDS to set the previous
* timeline; EventTimeline.FORWARDS to set the next timeline.
*
* @throws {Error} if an attempt is made to set the neighbouring timeline when
* it is already set.
*/
EventTimeline.prototype.setNeighbouringTimeline = function(neighbour, direction) {
if (this.getNeighbouringTimeline(direction)) {
throw new Error("timeline already has a neighbouring timeline - " +
"cannot reset neighbour (direction: " + direction + ")");
}
if (direction == EventTimeline.BACKWARDS) {
this._prevTimeline = neighbour;
} else if (direction == EventTimeline.FORWARDS) {
this._nextTimeline = neighbour;
} else {
throw new Error("Invalid direction '" + direction + "'");
}
// make sure we don't try to paginate this timeline
this.setPaginationToken(null, direction);
};
/**
* Add a new event to the timeline, and update the state
*
* @param {MatrixEvent} event new event
* @param {boolean} atStart true to insert new event at the start
*/
EventTimeline.prototype.addEvent = function(event, atStart) {
const stateContext = atStart ? this._startState : this._endState;
// only call setEventMetadata on the unfiltered timelineSets
const timelineSet = this.getTimelineSet();
if (timelineSet.room &&
timelineSet.room.getUnfilteredTimelineSet() === timelineSet) {
EventTimeline.setEventMetadata(event, stateContext, atStart);
// modify state
if (event.isState()) {
stateContext.setStateEvents([event]);
// it is possible that the act of setting the state event means we
// can set more metadata (specifically sender/target props), so try
// it again if the prop wasn't previously set. It may also mean that
// the sender/target is updated (if the event set was a room member event)
// so we want to use the *updated* member (new avatar/name) instead.
//
// However, we do NOT want to do this on member events if we're going
// back in time, else we'll set the .sender value for BEFORE the given
// member event, whereas we want to set the .sender value for the ACTUAL
// member event itself.
if (!event.sender || (event.getType() === "m.room.member" && !atStart)) {
EventTimeline.setEventMetadata(event, stateContext, atStart);
}
}
}
let insertIndex;
if (atStart) {
insertIndex = 0;
} else {
insertIndex = this._events.length;
}
this._events.splice(insertIndex, 0, event); // insert element
if (atStart) {
this._baseIndex++;
}
};
/**
* Static helper method to set sender and target properties
*
* @param {MatrixEvent} event the event whose metadata is to be set
* @param {RoomState} stateContext the room state to be queried
* @param {bool} toStartOfTimeline if true the event's forwardLooking flag is set false
*/
EventTimeline.setEventMetadata = function(event, stateContext, toStartOfTimeline) {
// set sender and target properties
event.sender = stateContext.getSentinelMember(
event.getSender(),
);
if (event.getType() === "m.room.member") {
event.target = stateContext.getSentinelMember(
event.getStateKey(),
);
}
if (event.isState()) {
// room state has no concept of 'old' or 'current', but we want the
// room state to regress back to previous values if toStartOfTimeline
// is set, which means inspecting prev_content if it exists. This
// is done by toggling the forwardLooking flag.
if (toStartOfTimeline) {
event.forwardLooking = false;
}
}
};
/**
* Remove an event from the timeline
*
* @param {string} eventId ID of event to be removed
* @return {?MatrixEvent} removed event, or null if not found
*/
EventTimeline.prototype.removeEvent = function(eventId) {
for (let i = this._events.length - 1; i >= 0; i--) {
const ev = this._events[i];
if (ev.getId() == eventId) {
this._events.splice(i, 1);
if (i < this._baseIndex) {
this._baseIndex--;
}
return ev;
}
}
return null;
};
/**
* Return a string to identify this timeline, for debugging
*
* @return {string} name for this timeline
*/
EventTimeline.prototype.toString = function() {
return this._name;
};

View File

@@ -0,0 +1,416 @@
/*
Copyright 2016 - 2021 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.
*/
/**
* @module models/event-timeline
*/
import { RoomState } from "./room-state";
import { EventTimelineSet } from "./event-timeline-set";
import { MatrixEvent } from "./event";
import { Filter } from "../filter";
export enum Direction {
Backward = "b",
Forward = "f",
}
export class EventTimeline {
/**
* Symbolic constant for methods which take a 'direction' argument:
* refers to the start of the timeline, or backwards in time.
*/
static BACKWARDS = Direction.Backward;
/**
* Symbolic constant for methods which take a 'direction' argument:
* refers to the end of the timeline, or forwards in time.
*/
static FORWARDS = Direction.Forward;
/**
* Static helper method to set sender and target properties
*
* @param {MatrixEvent} event the event whose metadata is to be set
* @param {RoomState} stateContext the room state to be queried
* @param {boolean} toStartOfTimeline if true the event's forwardLooking flag is set false
*/
static setEventMetadata(event: MatrixEvent, stateContext: RoomState, toStartOfTimeline: boolean): void {
// set sender and target properties
event.sender = stateContext.getSentinelMember(
event.getSender(),
);
if (event.getType() === "m.room.member") {
event.target = stateContext.getSentinelMember(
event.getStateKey(),
);
}
if (event.isState()) {
// room state has no concept of 'old' or 'current', but we want the
// room state to regress back to previous values if toStartOfTimeline
// is set, which means inspecting prev_content if it exists. This
// is done by toggling the forwardLooking flag.
if (toStartOfTimeline) {
event.forwardLooking = false;
}
}
}
private readonly roomId: string | null;
private readonly name: string;
private events: MatrixEvent[] = [];
private baseIndex = 0;
private startState: RoomState;
private endState: RoomState;
private prevTimeline?: EventTimeline;
private nextTimeline?: EventTimeline;
public paginationRequests: Record<Direction, Promise<boolean>> = {
[Direction.Backward]: null,
[Direction.Forward]: null,
};
/**
* Construct a new EventTimeline
*
* <p>An EventTimeline represents a contiguous sequence of events in a room.
*
* <p>As well as keeping track of the events themselves, it stores the state of
* the room at the beginning and end of the timeline, and pagination tokens for
* going backwards and forwards in the timeline.
*
* <p>In order that clients can meaningfully maintain an index into a timeline,
* the EventTimeline object tracks a 'baseIndex'. This starts at zero, but is
* incremented when events are prepended to the timeline. The index of an event
* relative to baseIndex therefore remains constant.
*
* <p>Once a timeline joins up with its neighbour, they are linked together into a
* doubly-linked list.
*
* @param {EventTimelineSet} eventTimelineSet the set of timelines this is part of
* @constructor
*/
constructor(private readonly eventTimelineSet: EventTimelineSet) {
this.roomId = eventTimelineSet.room?.roomId ?? null;
this.startState = new RoomState(this.roomId);
this.startState.paginationToken = null;
this.endState = new RoomState(this.roomId);
this.endState.paginationToken = null;
this.prevTimeline = null;
this.nextTimeline = null;
// this is used by client.js
this.paginationRequests = { 'b': null, 'f': null };
this.name = this.roomId + ":" + new Date().toISOString();
}
/**
* Initialise the start and end state with the given events
*
* <p>This can only be called before any events are added.
*
* @param {MatrixEvent[]} stateEvents list of state events to initialise the
* state with.
* @throws {Error} if an attempt is made to call this after addEvent is called.
*/
public initialiseState(stateEvents: MatrixEvent[]): void {
if (this.events.length > 0) {
throw new Error("Cannot initialise state after events are added");
}
// We previously deep copied events here and used different copies in
// the oldState and state events: this decision seems to date back
// quite a way and was apparently made to fix a bug where modifications
// made to the start state leaked through to the end state.
// This really shouldn't be possible though: the events themselves should
// not change. Duplicating the events uses a lot of extra memory,
// so we now no longer do it. To assert that they really do never change,
// freeze them! Note that we can't do this for events in general:
// although it looks like the only things preventing us are the
// 'status' flag, forwardLooking (which is only set once when adding to the
// timeline) and possibly the sender (which seems like it should never be
// reset but in practice causes a lot of the tests to break).
for (const e of stateEvents) {
Object.freeze(e);
}
this.startState.setStateEvents(stateEvents);
this.endState.setStateEvents(stateEvents);
}
/**
* Forks the (live) timeline, taking ownership of the existing directional state of this timeline.
* All attached listeners will keep receiving state updates from the new live timeline state.
* The end state of this timeline gets replaced with an independent copy of the current RoomState,
* and will need a new pagination token if it ever needs to paginate forwards.
* @param {string} direction EventTimeline.BACKWARDS to get the state at the
* start of the timeline; EventTimeline.FORWARDS to get the state at the end
* of the timeline.
*
* @return {EventTimeline} the new timeline
*/
public forkLive(direction: Direction): EventTimeline {
const forkState = this.getState(direction);
const timeline = new EventTimeline(this.eventTimelineSet);
timeline.startState = forkState.clone();
// Now clobber the end state of the new live timeline with that from the
// previous live timeline. It will be identical except that we'll keep
// using the same RoomMember objects for the 'live' set of members with any
// listeners still attached
timeline.endState = forkState;
// Firstly, we just stole the current timeline's end state, so it needs a new one.
// Make an immutable copy of the state so back pagination will get the correct sentinels.
this.endState = forkState.clone();
return timeline;
}
/**
* Creates an independent timeline, inheriting the directional state from this timeline.
*
* @param {string} direction EventTimeline.BACKWARDS to get the state at the
* start of the timeline; EventTimeline.FORWARDS to get the state at the end
* of the timeline.
*
* @return {EventTimeline} the new timeline
*/
public fork(direction: Direction): EventTimeline {
const forkState = this.getState(direction);
const timeline = new EventTimeline(this.eventTimelineSet);
timeline.startState = forkState.clone();
timeline.endState = forkState.clone();
return timeline;
}
/**
* Get the ID of the room for this timeline
* @return {string} room ID
*/
public getRoomId(): string {
return this.roomId;
}
/**
* Get the filter for this timeline's timelineSet (if any)
* @return {Filter} filter
*/
public getFilter(): Filter {
return this.eventTimelineSet.getFilter();
}
/**
* Get the timelineSet for this timeline
* @return {EventTimelineSet} timelineSet
*/
public getTimelineSet(): EventTimelineSet {
return this.eventTimelineSet;
}
/**
* Get the base index.
*
* <p>This is an index which is incremented when events are prepended to the
* timeline. An individual event therefore stays at the same index in the array
* relative to the base index (although note that a given event's index may
* well be less than the base index, thus giving that event a negative relative
* index).
*
* @return {number}
*/
public getBaseIndex(): number {
return this.baseIndex;
}
/**
* Get the list of events in this context
*
* @return {MatrixEvent[]} An array of MatrixEvents
*/
public getEvents(): MatrixEvent[] {
return this.events;
}
/**
* Get the room state at the start/end of the timeline
*
* @param {string} direction EventTimeline.BACKWARDS to get the state at the
* start of the timeline; EventTimeline.FORWARDS to get the state at the end
* of the timeline.
*
* @return {RoomState} state at the start/end of the timeline
*/
public getState(direction: Direction): RoomState {
if (direction == EventTimeline.BACKWARDS) {
return this.startState;
} else if (direction == EventTimeline.FORWARDS) {
return this.endState;
} else {
throw new Error("Invalid direction '" + direction + "'");
}
}
/**
* Get a pagination token
*
* @param {string} direction EventTimeline.BACKWARDS to get the pagination
* token for going backwards in time; EventTimeline.FORWARDS to get the
* pagination token for going forwards in time.
*
* @return {?string} pagination token
*/
public getPaginationToken(direction: Direction): string | null {
return this.getState(direction).paginationToken;
}
/**
* Set a pagination token
*
* @param {?string} token pagination token
*
* @param {string} direction EventTimeline.BACKWARDS to set the pagination
* token for going backwards in time; EventTimeline.FORWARDS to set the
* pagination token for going forwards in time.
*/
public setPaginationToken(token: string, direction: Direction): void {
this.getState(direction).paginationToken = token;
}
/**
* Get the next timeline in the series
*
* @param {string} direction EventTimeline.BACKWARDS to get the previous
* timeline; EventTimeline.FORWARDS to get the next timeline.
*
* @return {?EventTimeline} previous or following timeline, if they have been
* joined up.
*/
public getNeighbouringTimeline(direction: Direction): EventTimeline {
if (direction == EventTimeline.BACKWARDS) {
return this.prevTimeline;
} else if (direction == EventTimeline.FORWARDS) {
return this.nextTimeline;
} else {
throw new Error("Invalid direction '" + direction + "'");
}
}
/**
* Set the next timeline in the series
*
* @param {EventTimeline} neighbour previous/following timeline
*
* @param {string} direction EventTimeline.BACKWARDS to set the previous
* timeline; EventTimeline.FORWARDS to set the next timeline.
*
* @throws {Error} if an attempt is made to set the neighbouring timeline when
* it is already set.
*/
public setNeighbouringTimeline(neighbour: EventTimeline, direction: Direction): void {
if (this.getNeighbouringTimeline(direction)) {
throw new Error("timeline already has a neighbouring timeline - " +
"cannot reset neighbour (direction: " + direction + ")");
}
if (direction == EventTimeline.BACKWARDS) {
this.prevTimeline = neighbour;
} else if (direction == EventTimeline.FORWARDS) {
this.nextTimeline = neighbour;
} else {
throw new Error("Invalid direction '" + direction + "'");
}
// make sure we don't try to paginate this timeline
this.setPaginationToken(null, direction);
}
/**
* Add a new event to the timeline, and update the state
*
* @param {MatrixEvent} event new event
* @param {boolean} atStart true to insert new event at the start
*/
public addEvent(event: MatrixEvent, atStart: boolean): void {
const stateContext = atStart ? this.startState : this.endState;
// only call setEventMetadata on the unfiltered timelineSets
const timelineSet = this.getTimelineSet();
if (timelineSet.room &&
timelineSet.room.getUnfilteredTimelineSet() === timelineSet) {
EventTimeline.setEventMetadata(event, stateContext, atStart);
// modify state
if (event.isState()) {
stateContext.setStateEvents([event]);
// it is possible that the act of setting the state event means we
// can set more metadata (specifically sender/target props), so try
// it again if the prop wasn't previously set. It may also mean that
// the sender/target is updated (if the event set was a room member event)
// so we want to use the *updated* member (new avatar/name) instead.
//
// However, we do NOT want to do this on member events if we're going
// back in time, else we'll set the .sender value for BEFORE the given
// member event, whereas we want to set the .sender value for the ACTUAL
// member event itself.
if (!event.sender || (event.getType() === "m.room.member" && !atStart)) {
EventTimeline.setEventMetadata(event, stateContext, atStart);
}
}
}
let insertIndex;
if (atStart) {
insertIndex = 0;
} else {
insertIndex = this.events.length;
}
this.events.splice(insertIndex, 0, event); // insert element
if (atStart) {
this.baseIndex++;
}
}
/**
* Remove an event from the timeline
*
* @param {string} eventId ID of event to be removed
* @return {?MatrixEvent} removed event, or null if not found
*/
public removeEvent(eventId: string): MatrixEvent | null {
for (let i = this.events.length - 1; i >= 0; i--) {
const ev = this.events[i];
if (ev.getId() == eventId) {
this.events.splice(i, 1);
if (i < this.baseIndex) {
this.baseIndex--;
}
return ev;
}
}
return null;
}
/**
* Return a string to identify this timeline, for debugging
*
* @return {string} name for this timeline
*/
public toString(): string {
return this.name;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019, 2021 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.
@@ -15,8 +15,11 @@ limitations under the License.
*/
import { EventEmitter } from 'events';
import { EventStatus } from '../models/event';
import { EventStatus, MatrixEvent } from './event';
import { Room } from './room';
import { logger } from '../logger';
import { RelationType } from "../@types/event";
/**
* A container for relation events that supports easy access to common ways of
@@ -27,8 +30,16 @@ import { logger } from '../logger';
* EventTimelineSet#getRelationsForEvent.
*/
export class Relations extends EventEmitter {
private relationEventIds = new Set<string>();
private relations = new Set<MatrixEvent>();
private annotationsByKey: Record<string, Set<MatrixEvent>> = {};
private annotationsBySender: Record<string, Set<MatrixEvent>> = {};
private sortedAnnotationsByKey: [string, Set<MatrixEvent>][] = [];
private targetEvent: MatrixEvent = null;
private creationEmitted = false;
/**
* @param {String} relationType
* @param {RelationType} relationType
* The type of relation involved, such as "m.annotation", "m.reference",
* "m.replace", etc.
* @param {String} eventType
@@ -37,18 +48,12 @@ export class Relations extends EventEmitter {
* Room for this container. May be null for non-room cases, such as the
* notification timeline.
*/
constructor(relationType, eventType, room) {
constructor(
public readonly relationType: RelationType | string,
public readonly eventType: string,
private readonly room: Room,
) {
super();
this.relationType = relationType;
this.eventType = eventType;
this._relationEventIds = new Set();
this._relations = new Set();
this._annotationsByKey = {};
this._annotationsBySender = {};
this._sortedAnnotationsByKey = [];
this._targetEvent = null;
this._room = room;
this._creationEmitted = false;
}
/**
@@ -57,8 +62,8 @@ export class Relations extends EventEmitter {
* @param {MatrixEvent} event
* The new relation event to be added.
*/
async addEvent(event) {
if (this._relationEventIds.has(event.getId())) {
public async addEvent(event: MatrixEvent) {
if (this.relationEventIds.has(event.getId())) {
return;
}
@@ -79,24 +84,24 @@ export class Relations extends EventEmitter {
// If the event is in the process of being sent, listen for cancellation
// so we can remove the event from the collection.
if (event.isSending()) {
event.on("Event.status", this._onEventStatus);
event.on("Event.status", this.onEventStatus);
}
this._relations.add(event);
this._relationEventIds.add(event.getId());
this.relations.add(event);
this.relationEventIds.add(event.getId());
if (this.relationType === "m.annotation") {
this._addAnnotationToAggregation(event);
} else if (this.relationType === "m.replace" && this._targetEvent) {
if (this.relationType === RelationType.Annotation) {
this.addAnnotationToAggregation(event);
} else if (this.relationType === RelationType.Replace && this.targetEvent) {
const lastReplacement = await this.getLastReplacement();
this._targetEvent.makeReplaced(lastReplacement);
this.targetEvent.makeReplaced(lastReplacement);
}
event.on("Event.beforeRedaction", this._onBeforeRedaction);
event.on("Event.beforeRedaction", this.onBeforeRedaction);
this.emit("Relations.add", event);
this._maybeEmitCreated();
this.maybeEmitCreated();
}
/**
@@ -105,8 +110,8 @@ export class Relations extends EventEmitter {
* @param {MatrixEvent} event
* The relation event to remove.
*/
async _removeEvent(event) {
if (!this._relations.has(event)) {
private async removeEvent(event: MatrixEvent) {
if (!this.relations.has(event)) {
return;
}
@@ -124,13 +129,13 @@ export class Relations extends EventEmitter {
return;
}
this._relations.delete(event);
this.relations.delete(event);
if (this.relationType === "m.annotation") {
this._removeAnnotationFromAggregation(event);
} else if (this.relationType === "m.replace" && this._targetEvent) {
if (this.relationType === RelationType.Annotation) {
this.removeAnnotationFromAggregation(event);
} else if (this.relationType === RelationType.Replace && this.targetEvent) {
const lastReplacement = await this.getLastReplacement();
this._targetEvent.makeReplaced(lastReplacement);
this.targetEvent.makeReplaced(lastReplacement);
}
this.emit("Relations.remove", event);
@@ -142,19 +147,19 @@ export class Relations extends EventEmitter {
* @param {MatrixEvent} event The event whose status has changed
* @param {EventStatus} status The new status
*/
_onEventStatus = (event, status) => {
private onEventStatus = (event: MatrixEvent, status: EventStatus) => {
if (!event.isSending()) {
// Sending is done, so we don't need to listen anymore
event.removeListener("Event.status", this._onEventStatus);
event.removeListener("Event.status", this.onEventStatus);
return;
}
if (status !== EventStatus.CANCELLED) {
return;
}
// Event was cancelled, remove from the collection
event.removeListener("Event.status", this._onEventStatus);
this._removeEvent(event);
}
event.removeListener("Event.status", this.onEventStatus);
this.removeEvent(event);
};
/**
* Get all relation events in this collection.
@@ -166,51 +171,51 @@ export class Relations extends EventEmitter {
* @return {Array}
* Relation events in insertion order.
*/
getRelations() {
return [...this._relations];
public getRelations() {
return [...this.relations];
}
_addAnnotationToAggregation(event) {
private addAnnotationToAggregation(event: MatrixEvent) {
const { key } = event.getRelation();
if (!key) {
return;
}
let eventsForKey = this._annotationsByKey[key];
let eventsForKey = this.annotationsByKey[key];
if (!eventsForKey) {
eventsForKey = this._annotationsByKey[key] = new Set();
this._sortedAnnotationsByKey.push([key, eventsForKey]);
eventsForKey = this.annotationsByKey[key] = new Set();
this.sortedAnnotationsByKey.push([key, eventsForKey]);
}
// Add the new event to the set for this key
eventsForKey.add(event);
// Re-sort the [key, events] pairs in descending order of event count
this._sortedAnnotationsByKey.sort((a, b) => {
this.sortedAnnotationsByKey.sort((a, b) => {
const aEvents = a[1];
const bEvents = b[1];
return bEvents.size - aEvents.size;
});
const sender = event.getSender();
let eventsFromSender = this._annotationsBySender[sender];
let eventsFromSender = this.annotationsBySender[sender];
if (!eventsFromSender) {
eventsFromSender = this._annotationsBySender[sender] = new Set();
eventsFromSender = this.annotationsBySender[sender] = new Set();
}
// Add the new event to the set for this sender
eventsFromSender.add(event);
}
_removeAnnotationFromAggregation(event) {
private removeAnnotationFromAggregation(event: MatrixEvent) {
const { key } = event.getRelation();
if (!key) {
return;
}
const eventsForKey = this._annotationsByKey[key];
const eventsForKey = this.annotationsByKey[key];
if (eventsForKey) {
eventsForKey.delete(event);
// Re-sort the [key, events] pairs in descending order of event count
this._sortedAnnotationsByKey.sort((a, b) => {
this.sortedAnnotationsByKey.sort((a, b) => {
const aEvents = a[1];
const bEvents = b[1];
return bEvents.size - aEvents.size;
@@ -218,7 +223,7 @@ export class Relations extends EventEmitter {
}
const sender = event.getSender();
const eventsFromSender = this._annotationsBySender[sender];
const eventsFromSender = this.annotationsBySender[sender];
if (eventsFromSender) {
eventsFromSender.delete(event);
}
@@ -235,25 +240,25 @@ export class Relations extends EventEmitter {
* @param {MatrixEvent} redactedEvent
* The original relation event that is about to be redacted.
*/
_onBeforeRedaction = async (redactedEvent) => {
if (!this._relations.has(redactedEvent)) {
private onBeforeRedaction = async (redactedEvent: MatrixEvent) => {
if (!this.relations.has(redactedEvent)) {
return;
}
this._relations.delete(redactedEvent);
this.relations.delete(redactedEvent);
if (this.relationType === "m.annotation") {
if (this.relationType === RelationType.Annotation) {
// Remove the redacted annotation from aggregation by key
this._removeAnnotationFromAggregation(redactedEvent);
} else if (this.relationType === "m.replace" && this._targetEvent) {
this.removeAnnotationFromAggregation(redactedEvent);
} else if (this.relationType === RelationType.Replace && this.targetEvent) {
const lastReplacement = await this.getLastReplacement();
this._targetEvent.makeReplaced(lastReplacement);
this.targetEvent.makeReplaced(lastReplacement);
}
redactedEvent.removeListener("Event.beforeRedaction", this._onBeforeRedaction);
redactedEvent.removeListener("Event.beforeRedaction", this.onBeforeRedaction);
this.emit("Relations.redaction", redactedEvent);
}
};
/**
* Get all events in this collection grouped by key and sorted by descending
@@ -265,13 +270,13 @@ export class Relations extends EventEmitter {
* An array of [key, events] pairs sorted by descending event count.
* The events are stored in a Set (which preserves insertion order).
*/
getSortedAnnotationsByKey() {
if (this.relationType !== "m.annotation") {
public getSortedAnnotationsByKey() {
if (this.relationType !== RelationType.Annotation) {
// Other relation types are not grouped currently.
return null;
}
return this._sortedAnnotationsByKey;
return this.sortedAnnotationsByKey;
}
/**
@@ -283,13 +288,13 @@ export class Relations extends EventEmitter {
* An object with each relation sender as a key and the matching Set of
* events for that sender as a value.
*/
getAnnotationsBySender() {
if (this.relationType !== "m.annotation") {
public getAnnotationsBySender() {
if (this.relationType !== RelationType.Annotation) {
// Other relation types are not grouped currently.
return null;
}
return this._annotationsBySender;
return this.annotationsBySender;
}
/**
@@ -300,12 +305,12 @@ export class Relations extends EventEmitter {
*
* @return {MatrixEvent?}
*/
async getLastReplacement() {
if (this.relationType !== "m.replace") {
public async getLastReplacement(): Promise<MatrixEvent | null> {
if (this.relationType !== RelationType.Replace) {
// Aggregating on last only makes sense for this relation type
return null;
}
if (!this._targetEvent) {
if (!this.targetEvent) {
// Don't know which replacements to accept yet.
// This method shouldn't be called before the original
// event is known anyway.
@@ -314,12 +319,11 @@ export class Relations extends EventEmitter {
// the all-knowning server tells us that the event at some point had
// this timestamp for its replacement, so any following replacement should definitely not be less
const replaceRelation =
this._targetEvent.getServerAggregatedRelation("m.replace");
const replaceRelation = this.targetEvent.getServerAggregatedRelation(RelationType.Replace);
const minTs = replaceRelation && replaceRelation.origin_server_ts;
const lastReplacement = this.getRelations().reduce((last, event) => {
if (event.getSender() !== this._targetEvent.getSender()) {
if (event.getSender() !== this.targetEvent.getSender()) {
return last;
}
if (minTs && minTs > event.getTs()) {
@@ -332,9 +336,9 @@ export class Relations extends EventEmitter {
}, null);
if (lastReplacement?.shouldAttemptDecryption()) {
await lastReplacement.attemptDecryption(this._room._client.crypto);
await lastReplacement.attemptDecryption(this.room.client.crypto);
} else if (lastReplacement?.isBeingDecrypted()) {
await lastReplacement._decryptionPromise;
await lastReplacement.getDecryptionPromise();
}
return lastReplacement;
@@ -343,38 +347,34 @@ export class Relations extends EventEmitter {
/*
* @param {MatrixEvent} targetEvent the event the relations are related to.
*/
async setTargetEvent(event) {
if (this._targetEvent) {
public async setTargetEvent(event: MatrixEvent) {
if (this.targetEvent) {
return;
}
this._targetEvent = event;
this.targetEvent = event;
if (this.relationType === "m.replace") {
if (this.relationType === RelationType.Replace) {
const replacement = await this.getLastReplacement();
// this is the initial update, so only call it if we already have something
// to not emit Event.replaced needlessly
if (replacement) {
this._targetEvent.makeReplaced(replacement);
this.targetEvent.makeReplaced(replacement);
}
}
this._maybeEmitCreated();
this.maybeEmitCreated();
}
_maybeEmitCreated() {
if (this._creationEmitted) {
private maybeEmitCreated() {
if (this.creationEmitted) {
return;
}
// Only emit we're "created" once we have a target event instance _and_
// at least one related event.
if (!this._targetEvent || !this._relations.size) {
if (!this.targetEvent || !this.relations.size) {
return;
}
this._creationEmitted = true;
this._targetEvent.emit(
"Event.relationsCreated",
this.relationType,
this.eventType,
);
this.creationEmitted = true;
this.targetEvent.emit("Event.relationsCreated", this.relationType, this.eventType);
}
}

View File

@@ -1,382 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 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.
*/
/**
* @module models/room-member
*/
import { EventEmitter } from "events";
import { getHttpUriForMxc } from "../content-repo";
import * as utils from "../utils";
/**
* Construct a new room member.
*
* @constructor
* @alias module:models/room-member
*
* @param {string} roomId The room ID of the member.
* @param {string} userId The user ID of the member.
* @prop {string} roomId The room ID for this member.
* @prop {string} userId The user ID of this member.
* @prop {boolean} typing True if the room member is currently typing.
* @prop {string} name The human-readable name for this room member. This will be
* disambiguated with a suffix of " (@user_id:matrix.org)" if another member shares the
* same displayname.
* @prop {string} rawDisplayName The ambiguous displayname of this room member.
* @prop {Number} powerLevel The power level for this room member.
* @prop {Number} powerLevelNorm The normalised power level (0-100) for this
* room member.
* @prop {User} user The User object for this room member, if one exists.
* @prop {string} membership The membership state for this room member e.g. 'join'.
* @prop {Object} events The events describing this RoomMember.
* @prop {MatrixEvent} events.member The m.room.member event for this RoomMember.
*/
export function RoomMember(roomId, userId) {
this.roomId = roomId;
this.userId = userId;
this.typing = false;
this.name = userId;
this.rawDisplayName = userId;
this.powerLevel = 0;
this.powerLevelNorm = 0;
this.user = null;
this.membership = null;
this.events = {
member: null,
};
this._isOutOfBand = false;
this._updateModifiedTime();
}
utils.inherits(RoomMember, EventEmitter);
/**
* Mark the member as coming from a channel that is not sync
*/
RoomMember.prototype.markOutOfBand = function() {
this._isOutOfBand = true;
};
/**
* @return {bool} does the member come from a channel that is not sync?
* This is used to store the member seperately
* from the sync state so it available across browser sessions.
*/
RoomMember.prototype.isOutOfBand = function() {
return this._isOutOfBand;
};
/**
* Update this room member's membership event. May fire "RoomMember.name" if
* this event updates this member's name.
* @param {MatrixEvent} event The <code>m.room.member</code> event
* @param {RoomState} roomState Optional. The room state to take into account
* when calculating (e.g. for disambiguating users with the same name).
* @fires module:client~MatrixClient#event:"RoomMember.name"
* @fires module:client~MatrixClient#event:"RoomMember.membership"
*/
RoomMember.prototype.setMembershipEvent = function(event, roomState) {
if (event.getType() !== "m.room.member") {
return;
}
this._isOutOfBand = false;
this.events.member = event;
const oldMembership = this.membership;
this.membership = event.getDirectionalContent().membership;
const oldName = this.name;
this.name = calculateDisplayName(
this.userId,
event.getDirectionalContent().displayname,
roomState);
this.rawDisplayName = event.getDirectionalContent().displayname || this.userId;
if (oldMembership !== this.membership) {
this._updateModifiedTime();
this.emit("RoomMember.membership", event, this, oldMembership);
}
if (oldName !== this.name) {
this._updateModifiedTime();
this.emit("RoomMember.name", event, this, oldName);
}
};
/**
* Update this room member's power level event. May fire
* "RoomMember.powerLevel" if this event updates this member's power levels.
* @param {MatrixEvent} powerLevelEvent The <code>m.room.power_levels</code>
* event
* @fires module:client~MatrixClient#event:"RoomMember.powerLevel"
*/
RoomMember.prototype.setPowerLevelEvent = function(powerLevelEvent) {
if (powerLevelEvent.getType() !== "m.room.power_levels") {
return;
}
const evContent = powerLevelEvent.getDirectionalContent();
let maxLevel = evContent.users_default || 0;
const users = evContent.users || {};
Object.values(users).forEach(function(lvl) {
maxLevel = Math.max(maxLevel, lvl);
});
const oldPowerLevel = this.powerLevel;
const oldPowerLevelNorm = this.powerLevelNorm;
if (users[this.userId] !== undefined) {
this.powerLevel = users[this.userId];
} else if (evContent.users_default !== undefined) {
this.powerLevel = evContent.users_default;
} else {
this.powerLevel = 0;
}
this.powerLevelNorm = 0;
if (maxLevel > 0) {
this.powerLevelNorm = (this.powerLevel * 100) / maxLevel;
}
// emit for changes in powerLevelNorm as well (since the app will need to
// redraw everyone's level if the max has changed)
if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) {
this._updateModifiedTime();
this.emit("RoomMember.powerLevel", powerLevelEvent, this);
}
};
/**
* Update this room member's typing event. May fire "RoomMember.typing" if
* this event changes this member's typing state.
* @param {MatrixEvent} event The typing event
* @fires module:client~MatrixClient#event:"RoomMember.typing"
*/
RoomMember.prototype.setTypingEvent = function(event) {
if (event.getType() !== "m.typing") {
return;
}
const oldTyping = this.typing;
this.typing = false;
const typingList = event.getContent().user_ids;
if (!Array.isArray(typingList)) {
// malformed event :/ bail early. TODO: whine?
return;
}
if (typingList.indexOf(this.userId) !== -1) {
this.typing = true;
}
if (oldTyping !== this.typing) {
this._updateModifiedTime();
this.emit("RoomMember.typing", event, this);
}
};
/**
* Update the last modified time to the current time.
*/
RoomMember.prototype._updateModifiedTime = function() {
this._modified = Date.now();
};
/**
* Get the timestamp when this RoomMember was last updated. This timestamp is
* updated when properties on this RoomMember are updated.
* It is updated <i>before</i> firing events.
* @return {number} The timestamp
*/
RoomMember.prototype.getLastModifiedTime = function() {
return this._modified;
};
RoomMember.prototype.isKicked = function() {
return this.membership === "leave" &&
this.events.member.getSender() !== this.events.member.getStateKey();
};
/**
* If this member was invited with the is_direct flag set, return
* the user that invited this member
* @return {string} user id of the inviter
*/
RoomMember.prototype.getDMInviter = function() {
// when not available because that room state hasn't been loaded in,
// we don't really know, but more likely to not be a direct chat
if (this.events.member) {
// TODO: persist the is_direct flag on the member as more member events
// come in caused by displayName changes.
// the is_direct flag is set on the invite member event.
// This is copied on the prev_content section of the join member event
// when the invite is accepted.
const memberEvent = this.events.member;
let memberContent = memberEvent.getContent();
let inviteSender = memberEvent.getSender();
if (memberContent.membership === "join") {
memberContent = memberEvent.getPrevContent();
inviteSender = memberEvent.getUnsigned().prev_sender;
}
if (memberContent.membership === "invite" && memberContent.is_direct) {
return inviteSender;
}
}
};
/**
* Get the avatar URL for a room member.
* @param {string} baseUrl The base homeserver URL See
* {@link module:client~MatrixClient#getHomeserverUrl}.
* @param {Number} width The desired width of the thumbnail.
* @param {Number} height The desired height of the thumbnail.
* @param {string} resizeMethod The thumbnail resize method to use, either
* "crop" or "scale".
* @param {Boolean} allowDefault (optional) Passing false causes this method to
* return null if the user has no avatar image. Otherwise, a default image URL
* will be returned. Default: true. (Deprecated)
* @param {Boolean} allowDirectLinks (optional) If true, the avatar URL will be
* returned even if it is a direct hyperlink rather than a matrix content URL.
* If false, any non-matrix content URLs will be ignored. Setting this option to
* true will expose URLs that, if fetched, will leak information about the user
* to anyone who they share a room with.
* @return {?string} the avatar URL or null.
*/
RoomMember.prototype.getAvatarUrl =
function(baseUrl, width, height, resizeMethod, allowDefault, allowDirectLinks) {
if (allowDefault === undefined) {
allowDefault = true;
}
const rawUrl = this.getMxcAvatarUrl();
if (!rawUrl && !allowDefault) {
return null;
}
const httpUrl = getHttpUriForMxc(
baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks,
);
if (httpUrl) {
return httpUrl;
}
return null;
};
/**
* get the mxc avatar url, either from a state event, or from a lazily loaded member
* @return {string} the mxc avatar url
*/
RoomMember.prototype.getMxcAvatarUrl = function() {
if (this.events.member) {
return this.events.member.getDirectionalContent().avatar_url;
} else if (this.user) {
return this.user.avatarUrl;
}
return null;
};
const MXID_PATTERN = /@.+:.+/;
const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/;
function calculateDisplayName(selfUserId, displayName, roomState) {
if (!displayName || displayName === selfUserId) {
return selfUserId;
}
// First check if the displayname is something we consider truthy
// after stripping it of zero width characters and padding spaces
if (!utils.removeHiddenChars(displayName)) {
return selfUserId;
}
if (!roomState) {
return displayName;
}
// Next check if the name contains something that look like a mxid
// If it does, it may be someone trying to impersonate someone else
// Show full mxid in this case
let disambiguate = MXID_PATTERN.test(displayName);
if (!disambiguate) {
// Also show mxid if the display name contains any LTR/RTL characters as these
// make it very difficult for us to find similar *looking* display names
// E.g "Mark" could be cloned by writing "kraM" but in RTL.
disambiguate = LTR_RTL_PATTERN.test(displayName);
}
if (!disambiguate) {
// Also show mxid if there are other people with the same or similar
// displayname, after hidden character removal.
const userIds = roomState.getUserIdsWithDisplayName(displayName);
disambiguate = userIds.some((u) => u !== selfUserId);
}
if (disambiguate) {
return displayName + " (" + selfUserId + ")";
}
return displayName;
}
/**
* Fires whenever any room member's name changes.
* @event module:client~MatrixClient#"RoomMember.name"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomMember} member The member whose RoomMember.name changed.
* @param {string?} oldName The previous name. Null if the member didn't have a
* name previously.
* @example
* matrixClient.on("RoomMember.name", function(event, member){
* var newName = member.name;
* });
*/
/**
* Fires whenever any room member's membership state changes.
* @event module:client~MatrixClient#"RoomMember.membership"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomMember} member The member whose RoomMember.membership changed.
* @param {string?} oldMembership The previous membership state. Null if it's a
* new member.
* @example
* matrixClient.on("RoomMember.membership", function(event, member, oldMembership){
* var newState = member.membership;
* });
*/
/**
* Fires whenever any room member's typing state changes.
* @event module:client~MatrixClient#"RoomMember.typing"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomMember} member The member whose RoomMember.typing changed.
* @example
* matrixClient.on("RoomMember.typing", function(event, member){
* var isTyping = member.typing;
* });
*/
/**
* Fires whenever any room member's power level changes.
* @event module:client~MatrixClient#"RoomMember.powerLevel"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomMember} member The member whose RoomMember.powerLevel changed.
* @example
* matrixClient.on("RoomMember.powerLevel", function(event, member){
* var newPowerLevel = member.powerLevel;
* var newNormPowerLevel = member.powerLevelNorm;
* });
*/

412
src/models/room-member.ts Normal file
View File

@@ -0,0 +1,412 @@
/*
Copyright 2015 - 2021 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.
*/
/**
* @module models/room-member
*/
import { EventEmitter } from "events";
import { getHttpUriForMxc } from "../content-repo";
import * as utils from "../utils";
import { User } from "./user";
import { MatrixEvent } from "./event";
import { RoomState } from "./room-state";
export class RoomMember extends EventEmitter {
private _isOutOfBand = false;
private _modified: number;
public _requestedProfileInfo: boolean; // used by sync.ts
// XXX these should be read-only
public typing = false;
public name: string;
public rawDisplayName: string;
public powerLevel = 0;
public powerLevelNorm = 0;
public user?: User = null;
public membership: string = null;
public disambiguate = false;
public events: {
member?: MatrixEvent;
} = {
member: null,
};
/**
* Construct a new room member.
*
* @constructor
* @alias module:models/room-member
*
* @param {string} roomId The room ID of the member.
* @param {string} userId The user ID of the member.
* @prop {string} roomId The room ID for this member.
* @prop {string} userId The user ID of this member.
* @prop {boolean} typing True if the room member is currently typing.
* @prop {string} name The human-readable name for this room member. This will be
* disambiguated with a suffix of " (@user_id:matrix.org)" if another member shares the
* same displayname.
* @prop {string} rawDisplayName The ambiguous displayname of this room member.
* @prop {Number} powerLevel The power level for this room member.
* @prop {Number} powerLevelNorm The normalised power level (0-100) for this
* room member.
* @prop {User} user The User object for this room member, if one exists.
* @prop {string} membership The membership state for this room member e.g. 'join'.
* @prop {Object} events The events describing this RoomMember.
* @prop {MatrixEvent} events.member The m.room.member event for this RoomMember.
* @prop {boolean} disambiguate True if the member's name is disambiguated.
*/
constructor(public readonly roomId: string, public readonly userId: string) {
super();
this.name = userId;
this.rawDisplayName = userId;
this.updateModifiedTime();
}
/**
* Mark the member as coming from a channel that is not sync
*/
public markOutOfBand(): void {
this._isOutOfBand = true;
}
/**
* @return {boolean} does the member come from a channel that is not sync?
* This is used to store the member seperately
* from the sync state so it available across browser sessions.
*/
public isOutOfBand(): boolean {
return this._isOutOfBand;
}
/**
* Update this room member's membership event. May fire "RoomMember.name" if
* this event updates this member's name.
* @param {MatrixEvent} event The <code>m.room.member</code> event
* @param {RoomState} roomState Optional. The room state to take into account
* when calculating (e.g. for disambiguating users with the same name).
* @fires module:client~MatrixClient#event:"RoomMember.name"
* @fires module:client~MatrixClient#event:"RoomMember.membership"
*/
public setMembershipEvent(event: MatrixEvent, roomState: RoomState): void {
const displayName = event.getDirectionalContent().displayname;
if (event.getType() !== "m.room.member") {
return;
}
this._isOutOfBand = false;
this.events.member = event;
const oldMembership = this.membership;
this.membership = event.getDirectionalContent().membership;
this.disambiguate = shouldDisambiguate(
this.userId,
displayName,
roomState,
);
const oldName = this.name;
this.name = calculateDisplayName(
this.userId,
displayName,
roomState,
this.disambiguate,
);
this.rawDisplayName = event.getDirectionalContent().displayname || this.userId;
if (oldMembership !== this.membership) {
this.updateModifiedTime();
this.emit("RoomMember.membership", event, this, oldMembership);
}
if (oldName !== this.name) {
this.updateModifiedTime();
this.emit("RoomMember.name", event, this, oldName);
}
}
/**
* Update this room member's power level event. May fire
* "RoomMember.powerLevel" if this event updates this member's power levels.
* @param {MatrixEvent} powerLevelEvent The <code>m.room.power_levels</code>
* event
* @fires module:client~MatrixClient#event:"RoomMember.powerLevel"
*/
public setPowerLevelEvent(powerLevelEvent: MatrixEvent): void {
if (powerLevelEvent.getType() !== "m.room.power_levels") {
return;
}
const evContent = powerLevelEvent.getDirectionalContent();
let maxLevel = evContent.users_default || 0;
const users = evContent.users || {};
Object.values(users).forEach(function(lvl: number) {
maxLevel = Math.max(maxLevel, lvl);
});
const oldPowerLevel = this.powerLevel;
const oldPowerLevelNorm = this.powerLevelNorm;
if (users[this.userId] !== undefined && Number.isInteger(users[this.userId])) {
this.powerLevel = users[this.userId];
} else if (evContent.users_default !== undefined) {
this.powerLevel = evContent.users_default;
} else {
this.powerLevel = 0;
}
this.powerLevelNorm = 0;
if (maxLevel > 0) {
this.powerLevelNorm = (this.powerLevel * 100) / maxLevel;
}
// emit for changes in powerLevelNorm as well (since the app will need to
// redraw everyone's level if the max has changed)
if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) {
this.updateModifiedTime();
this.emit("RoomMember.powerLevel", powerLevelEvent, this);
}
}
/**
* Update this room member's typing event. May fire "RoomMember.typing" if
* this event changes this member's typing state.
* @param {MatrixEvent} event The typing event
* @fires module:client~MatrixClient#event:"RoomMember.typing"
*/
public setTypingEvent(event: MatrixEvent): void {
if (event.getType() !== "m.typing") {
return;
}
const oldTyping = this.typing;
this.typing = false;
const typingList = event.getContent().user_ids;
if (!Array.isArray(typingList)) {
// malformed event :/ bail early. TODO: whine?
return;
}
if (typingList.indexOf(this.userId) !== -1) {
this.typing = true;
}
if (oldTyping !== this.typing) {
this.updateModifiedTime();
this.emit("RoomMember.typing", event, this);
}
}
/**
* Update the last modified time to the current time.
*/
private updateModifiedTime() {
this._modified = Date.now();
}
/**
* Get the timestamp when this RoomMember was last updated. This timestamp is
* updated when properties on this RoomMember are updated.
* It is updated <i>before</i> firing events.
* @return {number} The timestamp
*/
public getLastModifiedTime(): number {
return this._modified;
}
public isKicked(): boolean {
return this.membership === "leave" &&
this.events.member.getSender() !== this.events.member.getStateKey();
}
/**
* If this member was invited with the is_direct flag set, return
* the user that invited this member
* @return {string} user id of the inviter
*/
public getDMInviter(): string {
// when not available because that room state hasn't been loaded in,
// we don't really know, but more likely to not be a direct chat
if (this.events.member) {
// TODO: persist the is_direct flag on the member as more member events
// come in caused by displayName changes.
// the is_direct flag is set on the invite member event.
// This is copied on the prev_content section of the join member event
// when the invite is accepted.
const memberEvent = this.events.member;
let memberContent = memberEvent.getContent();
let inviteSender = memberEvent.getSender();
if (memberContent.membership === "join") {
memberContent = memberEvent.getPrevContent();
inviteSender = memberEvent.getUnsigned().prev_sender;
}
if (memberContent.membership === "invite" && memberContent.is_direct) {
return inviteSender;
}
}
}
/**
* Get the avatar URL for a room member.
* @param {string} baseUrl The base homeserver URL See
* {@link module:client~MatrixClient#getHomeserverUrl}.
* @param {Number} width The desired width of the thumbnail.
* @param {Number} height The desired height of the thumbnail.
* @param {string} resizeMethod The thumbnail resize method to use, either
* "crop" or "scale".
* @param {Boolean} allowDefault (optional) Passing false causes this method to
* return null if the user has no avatar image. Otherwise, a default image URL
* will be returned. Default: true. (Deprecated)
* @param {Boolean} allowDirectLinks (optional) If true, the avatar URL will be
* returned even if it is a direct hyperlink rather than a matrix content URL.
* If false, any non-matrix content URLs will be ignored. Setting this option to
* true will expose URLs that, if fetched, will leak information about the user
* to anyone who they share a room with.
* @return {?string} the avatar URL or null.
*/
public getAvatarUrl(
baseUrl: string,
width: number,
height: number,
resizeMethod: string,
allowDefault = true,
allowDirectLinks: boolean,
): string | null {
const rawUrl = this.getMxcAvatarUrl();
if (!rawUrl && !allowDefault) {
return null;
}
const httpUrl = getHttpUriForMxc(baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks);
if (httpUrl) {
return httpUrl;
}
return null;
}
/**
* get the mxc avatar url, either from a state event, or from a lazily loaded member
* @return {string} the mxc avatar url
*/
public getMxcAvatarUrl(): string | null {
if (this.events.member) {
return this.events.member.getDirectionalContent().avatar_url;
} else if (this.user) {
return this.user.avatarUrl;
}
return null;
}
}
const MXID_PATTERN = /@.+:.+/;
const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/;
function shouldDisambiguate(selfUserId: string, displayName: string, roomState: RoomState): boolean {
if (!displayName || displayName === selfUserId) return false;
// First check if the displayname is something we consider truthy
// after stripping it of zero width characters and padding spaces
if (!utils.removeHiddenChars(displayName)) return false;
if (!roomState) return false;
// Next check if the name contains something that look like a mxid
// If it does, it may be someone trying to impersonate someone else
// Show full mxid in this case
if (MXID_PATTERN.test(displayName)) return true;
// Also show mxid if the display name contains any LTR/RTL characters as these
// make it very difficult for us to find similar *looking* display names
// E.g "Mark" could be cloned by writing "kraM" but in RTL.
if (LTR_RTL_PATTERN.test(displayName)) return true;
// Also show mxid if there are other people with the same or similar
// displayname, after hidden character removal.
const userIds = roomState.getUserIdsWithDisplayName(displayName);
if (userIds.some((u) => u !== selfUserId)) return true;
return false;
}
function calculateDisplayName(
selfUserId: string,
displayName: string,
roomState: RoomState,
disambiguate: boolean,
): string {
if (disambiguate) return displayName + " (" + selfUserId + ")";
if (!displayName || displayName === selfUserId) return selfUserId;
// First check if the displayname is something we consider truthy
// after stripping it of zero width characters and padding spaces
if (!utils.removeHiddenChars(displayName)) return selfUserId;
return displayName;
}
/**
* Fires whenever any room member's name changes.
* @event module:client~MatrixClient#"RoomMember.name"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomMember} member The member whose RoomMember.name changed.
* @param {string?} oldName The previous name. Null if the member didn't have a
* name previously.
* @example
* matrixClient.on("RoomMember.name", function(event, member){
* var newName = member.name;
* });
*/
/**
* Fires whenever any room member's membership state changes.
* @event module:client~MatrixClient#"RoomMember.membership"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomMember} member The member whose RoomMember.membership changed.
* @param {string?} oldMembership The previous membership state. Null if it's a
* new member.
* @example
* matrixClient.on("RoomMember.membership", function(event, member, oldMembership){
* var newState = member.membership;
* });
*/
/**
* Fires whenever any room member's typing state changes.
* @event module:client~MatrixClient#"RoomMember.typing"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomMember} member The member whose RoomMember.typing changed.
* @example
* matrixClient.on("RoomMember.typing", function(event, member){
* var isTyping = member.typing;
* });
*/
/**
* Fires whenever any room member's power level changes.
* @event module:client~MatrixClient#"RoomMember.powerLevel"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomMember} member The member whose RoomMember.powerLevel changed.
* @example
* matrixClient.on("RoomMember.powerLevel", function(event, member){
* var newPowerLevel = member.powerLevel;
* var newNormPowerLevel = member.powerLevelNorm;
* });
*/

View File

@@ -1,832 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 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.
*/
/**
* @module models/room-state
*/
import { EventEmitter } from "events";
import { RoomMember } from "./room-member";
import { logger } from '../logger';
import * as utils from "../utils";
import { EventType } from "../@types/event";
// possible statuses for out-of-band member loading
const OOB_STATUS_NOTSTARTED = 1;
const OOB_STATUS_INPROGRESS = 2;
const OOB_STATUS_FINISHED = 3;
/**
* Construct room state.
*
* Room State represents the state of the room at a given point.
* It can be mutated by adding state events to it.
* There are two types of room member associated with a state event:
* normal member objects (accessed via getMember/getMembers) which mutate
* with the state to represent the current state of that room/user, eg.
* the object returned by getMember('@bob:example.com') will mutate to
* get a different display name if Bob later changes his display name
* in the room.
* There are also 'sentinel' members (accessed via getSentinelMember).
* These also represent the state of room members at the point in time
* represented by the RoomState object, but unlike objects from getMember,
* sentinel objects will always represent the room state as at the time
* getSentinelMember was called, so if Bob subsequently changes his display
* name, a room member object previously acquired with getSentinelMember
* will still have his old display name. Calling getSentinelMember again
* after the display name change will return a new RoomMember object
* with Bob's new display name.
*
* @constructor
* @param {?string} roomId Optional. The ID of the room which has this state.
* If none is specified it just tracks paginationTokens, useful for notifTimelineSet
* @param {?object} oobMemberFlags Optional. The state of loading out of bound members.
* As the timeline might get reset while they are loading, this state needs to be inherited
* and shared when the room state is cloned for the new timeline.
* This should only be passed from clone.
* @prop {Object.<string, RoomMember>} members The room member dictionary, keyed
* on the user's ID.
* @prop {Object.<string, Object.<string, MatrixEvent>>} events The state
* events dictionary, keyed on the event type and then the state_key value.
* @prop {string} paginationToken The pagination token for this state.
*/
export function RoomState(roomId, oobMemberFlags = undefined) {
this.roomId = roomId;
this.members = {
// userId: RoomMember
};
this.events = new Map(); // Map<eventType, Map<stateKey, MatrixEvent>>
this.paginationToken = null;
this._sentinels = {
// userId: RoomMember
};
this._updateModifiedTime();
// stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys)
this._displayNameToUserIds = {};
this._userIdsToDisplayNames = {};
this._tokenToInvite = {}; // 3pid invite state_key to m.room.member invite
this._joinedMemberCount = null; // cache of the number of joined members
// joined members count from summary api
// once set, we know the server supports the summary api
// and we should only trust that
// we could also only trust that before OOB members
// are loaded but doesn't seem worth the hassle atm
this._summaryJoinedMemberCount = null;
// same for invited member count
this._invitedMemberCount = null;
this._summaryInvitedMemberCount = null;
if (!oobMemberFlags) {
oobMemberFlags = {
status: OOB_STATUS_NOTSTARTED,
};
}
this._oobMemberFlags = oobMemberFlags;
}
utils.inherits(RoomState, EventEmitter);
/**
* Returns the number of joined members in this room
* This method caches the result.
* @return {integer} The number of members in this room whose membership is 'join'
*/
RoomState.prototype.getJoinedMemberCount = function() {
if (this._summaryJoinedMemberCount !== null) {
return this._summaryJoinedMemberCount;
}
if (this._joinedMemberCount === null) {
this._joinedMemberCount = this.getMembers().reduce((count, m) => {
return m.membership === 'join' ? count + 1 : count;
}, 0);
}
return this._joinedMemberCount;
};
/**
* Set the joined member count explicitly (like from summary part of the sync response)
* @param {number} count the amount of joined members
*/
RoomState.prototype.setJoinedMemberCount = function(count) {
this._summaryJoinedMemberCount = count;
};
/**
* Returns the number of invited members in this room
* @return {integer} The number of members in this room whose membership is 'invite'
*/
RoomState.prototype.getInvitedMemberCount = function() {
if (this._summaryInvitedMemberCount !== null) {
return this._summaryInvitedMemberCount;
}
if (this._invitedMemberCount === null) {
this._invitedMemberCount = this.getMembers().reduce((count, m) => {
return m.membership === 'invite' ? count + 1 : count;
}, 0);
}
return this._invitedMemberCount;
};
/**
* Set the amount of invited members in this room
* @param {number} count the amount of invited members
*/
RoomState.prototype.setInvitedMemberCount = function(count) {
this._summaryInvitedMemberCount = count;
};
/**
* Get all RoomMembers in this room.
* @return {Array<RoomMember>} A list of RoomMembers.
*/
RoomState.prototype.getMembers = function() {
return Object.values(this.members);
};
/**
* Get all RoomMembers in this room, excluding the user IDs provided.
* @param {Array<string>} excludedIds The user IDs to exclude.
* @return {Array<RoomMember>} A list of RoomMembers.
*/
RoomState.prototype.getMembersExcept = function(excludedIds) {
return Object.values(this.members)
.filter((m) => !excludedIds.includes(m.userId));
};
/**
* Get a room member by their user ID.
* @param {string} userId The room member's user ID.
* @return {RoomMember} The member or null if they do not exist.
*/
RoomState.prototype.getMember = function(userId) {
return this.members[userId] || null;
};
/**
* Get a room member whose properties will not change with this room state. You
* typically want this if you want to attach a RoomMember to a MatrixEvent which
* may no longer be represented correctly by Room.currentState or Room.oldState.
* The term 'sentinel' refers to the fact that this RoomMember is an unchanging
* guardian for state at this particular point in time.
* @param {string} userId The room member's user ID.
* @return {RoomMember} The member or null if they do not exist.
*/
RoomState.prototype.getSentinelMember = function(userId) {
if (!userId) return null;
let sentinel = this._sentinels[userId];
if (sentinel === undefined) {
sentinel = new RoomMember(this.roomId, userId);
const member = this.members[userId];
if (member) {
sentinel.setMembershipEvent(member.events.member, this);
}
this._sentinels[userId] = sentinel;
}
return sentinel;
};
/**
* Get state events from the state of the room.
* @param {string} eventType The event type of the state event.
* @param {string} stateKey Optional. The state_key of the state event. If
* this is <code>undefined</code> then all matching state events will be
* returned.
* @return {MatrixEvent[]|MatrixEvent} A list of events if state_key was
* <code>undefined</code>, else a single event (or null if no match found).
*/
RoomState.prototype.getStateEvents = function(eventType, stateKey) {
if (!this.events.has(eventType)) {
// no match
return stateKey === undefined ? [] : null;
}
if (stateKey === undefined) { // return all values
return Array.from(this.events.get(eventType).values());
}
const event = this.events.get(eventType).get(stateKey);
return event ? event : null;
};
/**
* Creates a copy of this room state so that mutations to either won't affect the other.
* @return {RoomState} the copy of the room state
*/
RoomState.prototype.clone = function() {
const copy = new RoomState(this.roomId, this._oobMemberFlags);
// Ugly hack: because setStateEvents will mark
// members as susperseding future out of bound members
// if loading is in progress (through _oobMemberFlags)
// since these are not new members, we're merely copying them
// set the status to not started
// after copying, we set back the status
const status = this._oobMemberFlags.status;
this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED;
Array.from(this.events.values()).forEach((eventsByStateKey) => {
copy.setStateEvents(Array.from(eventsByStateKey.values()));
});
// Ugly hack: see above
this._oobMemberFlags.status = status;
if (this._summaryInvitedMemberCount !== null) {
copy.setInvitedMemberCount(this.getInvitedMemberCount());
}
if (this._summaryJoinedMemberCount !== null) {
copy.setJoinedMemberCount(this.getJoinedMemberCount());
}
// copy out of band flags if needed
if (this._oobMemberFlags.status == OOB_STATUS_FINISHED) {
// copy markOutOfBand flags
this.getMembers().forEach((member) => {
if (member.isOutOfBand()) {
const copyMember = copy.getMember(member.userId);
copyMember.markOutOfBand();
}
});
}
return copy;
};
/**
* Add previously unknown state events.
* When lazy loading members while back-paginating,
* the relevant room state for the timeline chunk at the end
* of the chunk can be set with this method.
* @param {MatrixEvent[]} events state events to prepend
*/
RoomState.prototype.setUnknownStateEvents = function(events) {
const unknownStateEvents = events.filter((event) => {
return !this.events.has(event.getType()) ||
!this.events.get(event.getType()).has(event.getStateKey());
});
this.setStateEvents(unknownStateEvents);
};
/**
* Add an array of one or more state MatrixEvents, overwriting
* any existing state with the same {type, stateKey} tuple. Will fire
* "RoomState.events" for every event added. May fire "RoomState.members"
* if there are <code>m.room.member</code> events.
* @param {MatrixEvent[]} stateEvents a list of state events for this room.
* @fires module:client~MatrixClient#event:"RoomState.members"
* @fires module:client~MatrixClient#event:"RoomState.newMember"
* @fires module:client~MatrixClient#event:"RoomState.events"
*/
RoomState.prototype.setStateEvents = function(stateEvents) {
const self = this;
this._updateModifiedTime();
// update the core event dict
stateEvents.forEach(function(event) {
if (event.getRoomId() !== self.roomId) {
return;
}
if (!event.isState()) {
return;
}
const lastStateEvent = self._getStateEventMatching(event);
self._setStateEvent(event);
if (event.getType() === "m.room.member") {
_updateDisplayNameCache(
self, event.getStateKey(), event.getContent().displayname,
);
_updateThirdPartyTokenCache(self, event);
}
self.emit("RoomState.events", event, self, lastStateEvent);
});
// update higher level data structures. This needs to be done AFTER the
// core event dict as these structures may depend on other state events in
// the given array (e.g. disambiguating display names in one go to do both
// clashing names rather than progressively which only catches 1 of them).
stateEvents.forEach(function(event) {
if (event.getRoomId() !== self.roomId) {
return;
}
if (!event.isState()) {
return;
}
if (event.getType() === "m.room.member") {
const userId = event.getStateKey();
// leave events apparently elide the displayname or avatar_url,
// so let's fake one up so that we don't leak user ids
// into the timeline
if (event.getContent().membership === "leave" ||
event.getContent().membership === "ban") {
event.getContent().avatar_url =
event.getContent().avatar_url ||
event.getPrevContent().avatar_url;
event.getContent().displayname =
event.getContent().displayname ||
event.getPrevContent().displayname;
}
const member = self._getOrCreateMember(userId, event);
member.setMembershipEvent(event, self);
self._updateMember(member);
self.emit("RoomState.members", event, self, member);
} else if (event.getType() === "m.room.power_levels") {
// events with unknown state keys should be ignored
// and should not aggregate onto members power levels
if (event.getStateKey() !== "") {
return;
}
const members = Object.values(self.members);
members.forEach(function(member) {
// We only propagate `RoomState.members` event if the
// power levels has been changed
// large room suffer from large re-rendering especially when not needed
const oldLastModified = member.getLastModifiedTime();
member.setPowerLevelEvent(event);
if (oldLastModified !== member.getLastModifiedTime()) {
self.emit("RoomState.members", event, self, member);
}
});
// assume all our sentinels are now out-of-date
self._sentinels = {};
}
});
};
/**
* Looks up a member by the given userId, and if it doesn't exist,
* create it and emit the `RoomState.newMember` event.
* This method makes sure the member is added to the members dictionary
* before emitting, as this is done from setStateEvents and _setOutOfBandMember.
* @param {string} userId the id of the user to look up
* @param {MatrixEvent} event the membership event for the (new) member. Used to emit.
* @fires module:client~MatrixClient#event:"RoomState.newMember"
* @returns {RoomMember} the member, existing or newly created.
*/
RoomState.prototype._getOrCreateMember = function(userId, event) {
let member = this.members[userId];
if (!member) {
member = new RoomMember(this.roomId, userId);
// add member to members before emitting any events,
// as event handlers often lookup the member
this.members[userId] = member;
this.emit("RoomState.newMember", event, this, member);
}
return member;
};
RoomState.prototype._setStateEvent = function(event) {
if (!this.events.has(event.getType())) {
this.events.set(event.getType(), new Map());
}
this.events.get(event.getType()).set(event.getStateKey(), event);
};
RoomState.prototype._getStateEventMatching = function(event) {
if (!this.events.has(event.getType())) return null;
return this.events.get(event.getType()).get(event.getStateKey());
};
RoomState.prototype._updateMember = function(member) {
// this member may have a power level already, so set it.
const pwrLvlEvent = this.getStateEvents("m.room.power_levels", "");
if (pwrLvlEvent) {
member.setPowerLevelEvent(pwrLvlEvent);
}
// blow away the sentinel which is now outdated
delete this._sentinels[member.userId];
this.members[member.userId] = member;
this._joinedMemberCount = null;
this._invitedMemberCount = null;
};
/**
* Get the out-of-band members loading state, whether loading is needed or not.
* Note that loading might be in progress and hence isn't needed.
* @return {bool} whether or not the members of this room need to be loaded
*/
RoomState.prototype.needsOutOfBandMembers = function() {
return this._oobMemberFlags.status === OOB_STATUS_NOTSTARTED;
};
/**
* Mark this room state as waiting for out-of-band members,
* ensuring it doesn't ask for them to be requested again
* through needsOutOfBandMembers
*/
RoomState.prototype.markOutOfBandMembersStarted = function() {
if (this._oobMemberFlags.status !== OOB_STATUS_NOTSTARTED) {
return;
}
this._oobMemberFlags.status = OOB_STATUS_INPROGRESS;
};
/**
* Mark this room state as having failed to fetch out-of-band members
*/
RoomState.prototype.markOutOfBandMembersFailed = function() {
if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) {
return;
}
this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED;
};
/**
* Clears the loaded out-of-band members
*/
RoomState.prototype.clearOutOfBandMembers = function() {
let count = 0;
Object.keys(this.members).forEach((userId) => {
const member = this.members[userId];
if (member.isOutOfBand()) {
++count;
delete this.members[userId];
}
});
logger.log(`LL: RoomState removed ${count} members...`);
this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED;
};
/**
* Sets the loaded out-of-band members.
* @param {MatrixEvent[]} stateEvents array of membership state events
*/
RoomState.prototype.setOutOfBandMembers = function(stateEvents) {
logger.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`);
if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) {
return;
}
logger.log(`LL: RoomState put in OOB_STATUS_FINISHED state ...`);
this._oobMemberFlags.status = OOB_STATUS_FINISHED;
stateEvents.forEach((e) => this._setOutOfBandMember(e));
};
/**
* Sets a single out of band member, used by both setOutOfBandMembers and clone
* @param {MatrixEvent} stateEvent membership state event
*/
RoomState.prototype._setOutOfBandMember = function(stateEvent) {
if (stateEvent.getType() !== 'm.room.member') {
return;
}
const userId = stateEvent.getStateKey();
const existingMember = this.getMember(userId);
// never replace members received as part of the sync
if (existingMember && !existingMember.isOutOfBand()) {
return;
}
const member = this._getOrCreateMember(userId, stateEvent);
member.setMembershipEvent(stateEvent, this);
// needed to know which members need to be stored seperately
// as they are not part of the sync accumulator
// this is cleared by setMembershipEvent so when it's updated through /sync
member.markOutOfBand();
_updateDisplayNameCache(this, member.userId, member.name);
this._setStateEvent(stateEvent);
this._updateMember(member);
this.emit("RoomState.members", stateEvent, this, member);
};
/**
* Set the current typing event for this room.
* @param {MatrixEvent} event The typing event
*/
RoomState.prototype.setTypingEvent = function(event) {
Object.values(this.members).forEach(function(member) {
member.setTypingEvent(event);
});
};
/**
* Get the m.room.member event which has the given third party invite token.
*
* @param {string} token The token
* @return {?MatrixEvent} The m.room.member event or null
*/
RoomState.prototype.getInviteForThreePidToken = function(token) {
return this._tokenToInvite[token] || null;
};
/**
* Update the last modified time to the current time.
*/
RoomState.prototype._updateModifiedTime = function() {
this._modified = Date.now();
};
/**
* Get the timestamp when this room state was last updated. This timestamp is
* updated when this object has received new state events.
* @return {number} The timestamp
*/
RoomState.prototype.getLastModifiedTime = function() {
return this._modified;
};
/**
* Get user IDs with the specified or similar display names.
* @param {string} displayName The display name to get user IDs from.
* @return {string[]} An array of user IDs or an empty array.
*/
RoomState.prototype.getUserIdsWithDisplayName = function(displayName) {
return this._displayNameToUserIds[utils.removeHiddenChars(displayName)] || [];
};
/**
* Returns true if userId is in room, event is not redacted and either sender of
* mxEvent or has power level sufficient to redact events other than their own.
* @param {MatrixEvent} mxEvent The event to test permission for
* @param {string} userId The user ID of the user to test permission for
* @return {boolean} true if the given used ID can redact given event
*/
RoomState.prototype.maySendRedactionForEvent = function(mxEvent, userId) {
const member = this.getMember(userId);
if (!member || member.membership === 'leave') return false;
if (mxEvent.status || mxEvent.isRedacted()) return false;
// The user may have been the sender, but they can't redact their own message
// if redactions are blocked.
const canRedact = this.maySendEvent("m.room.redaction", userId);
if (mxEvent.getSender() === userId) return canRedact;
return this._hasSufficientPowerLevelFor('redact', member.powerLevel);
};
/**
* Returns true if the given power level is sufficient for action
* @param {string} action The type of power level to check
* @param {number} powerLevel The power level of the member
* @return {boolean} true if the given power level is sufficient
*/
RoomState.prototype._hasSufficientPowerLevelFor = function(action, powerLevel) {
const powerLevelsEvent = this.getStateEvents('m.room.power_levels', '');
let powerLevels = {};
if (powerLevelsEvent) {
powerLevels = powerLevelsEvent.getContent();
}
let requiredLevel = 50;
if (utils.isNumber(powerLevels[action])) {
requiredLevel = powerLevels[action];
}
return powerLevel >= requiredLevel;
};
/**
* Short-form for maySendEvent('m.room.message', userId)
* @param {string} userId The user ID of the user to test permission for
* @return {boolean} true if the given user ID should be permitted to send
* message events into the given room.
*/
RoomState.prototype.maySendMessage = function(userId) {
return this._maySendEventOfType('m.room.message', userId, false);
};
/**
* Returns true if the given user ID has permission to send a normal
* event of type `eventType` into this room.
* @param {string} eventType The type of event to test
* @param {string} userId The user ID of the user to test permission for
* @return {boolean} true if the given user ID should be permitted to send
* the given type of event into this room,
* according to the room's state.
*/
RoomState.prototype.maySendEvent = function(eventType, userId) {
return this._maySendEventOfType(eventType, userId, false);
};
/**
* Returns true if the given MatrixClient has permission to send a state
* event of type `stateEventType` into this room.
* @param {string} stateEventType The type of state events to test
* @param {MatrixClient} cli The client to test permission for
* @return {boolean} true if the given client should be permitted to send
* the given type of state event into this room,
* according to the room's state.
*/
RoomState.prototype.mayClientSendStateEvent = function(stateEventType, cli) {
if (cli.isGuest()) {
return false;
}
return this.maySendStateEvent(stateEventType, cli.credentials.userId);
};
/**
* Returns true if the given user ID has permission to send a state
* event of type `stateEventType` into this room.
* @param {string} stateEventType The type of state events to test
* @param {string} userId The user ID of the user to test permission for
* @return {boolean} true if the given user ID should be permitted to send
* the given type of state event into this room,
* according to the room's state.
*/
RoomState.prototype.maySendStateEvent = function(stateEventType, userId) {
return this._maySendEventOfType(stateEventType, userId, true);
};
/**
* Returns true if the given user ID has permission to send a normal or state
* event of type `eventType` into this room.
* @param {string} eventType The type of event to test
* @param {string} userId The user ID of the user to test permission for
* @param {boolean} state If true, tests if the user may send a state
event of this type. Otherwise tests whether
they may send a regular event.
* @return {boolean} true if the given user ID should be permitted to send
* the given type of event into this room,
* according to the room's state.
*/
RoomState.prototype._maySendEventOfType = function(eventType, userId, state) {
const power_levels_event = this.getStateEvents('m.room.power_levels', '');
let power_levels;
let events_levels = {};
let state_default = 0;
let events_default = 0;
let powerLevel = 0;
if (power_levels_event) {
power_levels = power_levels_event.getContent();
events_levels = power_levels.events || {};
if (Number.isFinite(power_levels.state_default)) {
state_default = power_levels.state_default;
} else {
state_default = 50;
}
const userPowerLevel = power_levels.users && power_levels.users[userId];
if (Number.isFinite(userPowerLevel)) {
powerLevel = userPowerLevel;
} else if (Number.isFinite(power_levels.users_default)) {
powerLevel = power_levels.users_default;
}
if (Number.isFinite(power_levels.events_default)) {
events_default = power_levels.events_default;
}
}
let required_level = state ? state_default : events_default;
if (Number.isFinite(events_levels[eventType])) {
required_level = events_levels[eventType];
}
return powerLevel >= required_level;
};
/**
* Returns true if the given user ID has permission to trigger notification
* of type `notifLevelKey`
* @param {string} notifLevelKey The level of notification to test (eg. 'room')
* @param {string} userId The user ID of the user to test permission for
* @return {boolean} true if the given user ID has permission to trigger a
* notification of this type.
*/
RoomState.prototype.mayTriggerNotifOfType = function(notifLevelKey, userId) {
const member = this.getMember(userId);
if (!member) {
return false;
}
const powerLevelsEvent = this.getStateEvents('m.room.power_levels', '');
let notifLevel = 50;
if (
powerLevelsEvent &&
powerLevelsEvent.getContent() &&
powerLevelsEvent.getContent().notifications &&
utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey])
) {
notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey];
}
return member.powerLevel >= notifLevel;
};
/**
* Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`.
* @returns {string} the join_rule applied to this room
*/
RoomState.prototype.getJoinRule = function() {
const joinRuleEvent = this.getStateEvents(EventType.RoomJoinRules, "");
const joinRuleContent = joinRuleEvent ? joinRuleEvent.getContent() : {};
return joinRuleContent["join_rule"] || "invite";
};
function _updateThirdPartyTokenCache(roomState, memberEvent) {
if (!memberEvent.getContent().third_party_invite) {
return;
}
const token = (memberEvent.getContent().third_party_invite.signed || {}).token;
if (!token) {
return;
}
const threePidInvite = roomState.getStateEvents(
"m.room.third_party_invite", token,
);
if (!threePidInvite) {
return;
}
roomState._tokenToInvite[token] = memberEvent;
}
function _updateDisplayNameCache(roomState, userId, displayName) {
const oldName = roomState._userIdsToDisplayNames[userId];
delete roomState._userIdsToDisplayNames[userId];
if (oldName) {
// Remove the old name from the cache.
// We clobber the user_id > name lookup but the name -> [user_id] lookup
// means we need to remove that user ID from that array rather than nuking
// the lot.
const strippedOldName = utils.removeHiddenChars(oldName);
const existingUserIds = roomState._displayNameToUserIds[strippedOldName];
if (existingUserIds) {
// remove this user ID from this array
const filteredUserIDs = existingUserIds.filter((id) => id !== userId);
roomState._displayNameToUserIds[strippedOldName] = filteredUserIDs;
}
}
roomState._userIdsToDisplayNames[userId] = displayName;
const strippedDisplayname = displayName && utils.removeHiddenChars(displayName);
// an empty stripped displayname (undefined/'') will be set to MXID in room-member.js
if (strippedDisplayname) {
if (!roomState._displayNameToUserIds[strippedDisplayname]) {
roomState._displayNameToUserIds[strippedDisplayname] = [];
}
roomState._displayNameToUserIds[strippedDisplayname].push(userId);
}
}
/**
* Fires whenever the event dictionary in room state is updated.
* @event module:client~MatrixClient#"RoomState.events"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomState} state The room state whose RoomState.events dictionary
* was updated.
* @param {MatrixEvent} prevEvent The event being replaced by the new state, if
* known. Note that this can differ from `getPrevContent()` on the new state event
* as this is the store's view of the last state, not the previous state provided
* by the server.
* @example
* matrixClient.on("RoomState.events", function(event, state, prevEvent){
* var newStateEvent = event;
* });
*/
/**
* Fires whenever a member in the members dictionary is updated in any way.
* @event module:client~MatrixClient#"RoomState.members"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomState} state The room state whose RoomState.members dictionary
* was updated.
* @param {RoomMember} member The room member that was updated.
* @example
* matrixClient.on("RoomState.members", function(event, state, member){
* var newMembershipState = member.membership;
* });
*/
/**
* Fires whenever a member is added to the members dictionary. The RoomMember
* will not be fully populated yet (e.g. no membership state) but will already
* be available in the members dictionary.
* @event module:client~MatrixClient#"RoomState.newMember"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomState} state The room state whose RoomState.members dictionary
* was updated with a new entry.
* @param {RoomMember} member The room member that was added.
* @example
* matrixClient.on("RoomState.newMember", function(event, state, member){
* // add event listeners on 'member'
* });
*/

825
src/models/room-state.ts Normal file
View File

@@ -0,0 +1,825 @@
/*
Copyright 2015 - 2021 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.
*/
/**
* @module models/room-state
*/
import { EventEmitter } from "events";
import { RoomMember } from "./room-member";
import { logger } from '../logger';
import * as utils from "../utils";
import { EventType } from "../@types/event";
import { MatrixEvent } from "./event";
import { MatrixClient } from "../client";
// possible statuses for out-of-band member loading
enum OobStatus {
NotStarted,
InProgress,
Finished,
}
export class RoomState extends EventEmitter {
private sentinels: Record<string, RoomMember> = {}; // userId: RoomMember
// stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys)
private displayNameToUserIds: Record<string, string[]> = {};
private userIdsToDisplayNames: Record<string, string> = {};
private tokenToInvite: Record<string, MatrixEvent> = {}; // 3pid invite state_key to m.room.member invite
private joinedMemberCount: number = null; // cache of the number of joined members
// joined members count from summary api
// once set, we know the server supports the summary api
// and we should only trust that
// we could also only trust that before OOB members
// are loaded but doesn't seem worth the hassle atm
private summaryJoinedMemberCount: number = null;
// same for invited member count
private invitedMemberCount: number = null;
private summaryInvitedMemberCount: number = null;
private modified: number;
// XXX: Should be read-only
public members: Record<string, RoomMember> = {}; // userId: RoomMember
public events = new Map<string, Map<string, MatrixEvent>>(); // Map<eventType, Map<stateKey, MatrixEvent>>
public paginationToken: string = null;
/**
* Construct room state.
*
* Room State represents the state of the room at a given point.
* It can be mutated by adding state events to it.
* There are two types of room member associated with a state event:
* normal member objects (accessed via getMember/getMembers) which mutate
* with the state to represent the current state of that room/user, eg.
* the object returned by getMember('@bob:example.com') will mutate to
* get a different display name if Bob later changes his display name
* in the room.
* There are also 'sentinel' members (accessed via getSentinelMember).
* These also represent the state of room members at the point in time
* represented by the RoomState object, but unlike objects from getMember,
* sentinel objects will always represent the room state as at the time
* getSentinelMember was called, so if Bob subsequently changes his display
* name, a room member object previously acquired with getSentinelMember
* will still have his old display name. Calling getSentinelMember again
* after the display name change will return a new RoomMember object
* with Bob's new display name.
*
* @constructor
* @param {?string} roomId Optional. The ID of the room which has this state.
* If none is specified it just tracks paginationTokens, useful for notifTimelineSet
* @param {?object} oobMemberFlags Optional. The state of loading out of bound members.
* As the timeline might get reset while they are loading, this state needs to be inherited
* and shared when the room state is cloned for the new timeline.
* This should only be passed from clone.
* @prop {Object.<string, RoomMember>} members The room member dictionary, keyed
* on the user's ID.
* @prop {Object.<string, Object.<string, MatrixEvent>>} events The state
* events dictionary, keyed on the event type and then the state_key value.
* @prop {string} paginationToken The pagination token for this state.
*/
constructor(public readonly roomId: string, private oobMemberFlags = { status: OobStatus.NotStarted }) {
super();
this.updateModifiedTime();
}
/**
* Returns the number of joined members in this room
* This method caches the result.
* @return {number} The number of members in this room whose membership is 'join'
*/
public getJoinedMemberCount(): number {
if (this.summaryJoinedMemberCount !== null) {
return this.summaryJoinedMemberCount;
}
if (this.joinedMemberCount === null) {
this.joinedMemberCount = this.getMembers().reduce((count, m) => {
return m.membership === 'join' ? count + 1 : count;
}, 0);
}
return this.joinedMemberCount;
}
/**
* Set the joined member count explicitly (like from summary part of the sync response)
* @param {number} count the amount of joined members
*/
public setJoinedMemberCount(count: number): void {
this.summaryJoinedMemberCount = count;
}
/**
* Returns the number of invited members in this room
* @return {number} The number of members in this room whose membership is 'invite'
*/
public getInvitedMemberCount(): number {
if (this.summaryInvitedMemberCount !== null) {
return this.summaryInvitedMemberCount;
}
if (this.invitedMemberCount === null) {
this.invitedMemberCount = this.getMembers().reduce((count, m) => {
return m.membership === 'invite' ? count + 1 : count;
}, 0);
}
return this.invitedMemberCount;
}
/**
* Set the amount of invited members in this room
* @param {number} count the amount of invited members
*/
public setInvitedMemberCount(count: number): void {
this.summaryInvitedMemberCount = count;
}
/**
* Get all RoomMembers in this room.
* @return {Array<RoomMember>} A list of RoomMembers.
*/
public getMembers(): RoomMember[] {
return Object.values(this.members);
}
/**
* Get all RoomMembers in this room, excluding the user IDs provided.
* @param {Array<string>} excludedIds The user IDs to exclude.
* @return {Array<RoomMember>} A list of RoomMembers.
*/
public getMembersExcept(excludedIds: string[]): RoomMember[] {
return this.getMembers().filter((m) => !excludedIds.includes(m.userId));
}
/**
* Get a room member by their user ID.
* @param {string} userId The room member's user ID.
* @return {RoomMember} The member or null if they do not exist.
*/
public getMember(userId: string): RoomMember | null {
return this.members[userId] || null;
}
/**
* Get a room member whose properties will not change with this room state. You
* typically want this if you want to attach a RoomMember to a MatrixEvent which
* may no longer be represented correctly by Room.currentState or Room.oldState.
* The term 'sentinel' refers to the fact that this RoomMember is an unchanging
* guardian for state at this particular point in time.
* @param {string} userId The room member's user ID.
* @return {RoomMember} The member or null if they do not exist.
*/
public getSentinelMember(userId: string): RoomMember | null {
if (!userId) return null;
let sentinel = this.sentinels[userId];
if (sentinel === undefined) {
sentinel = new RoomMember(this.roomId, userId);
const member = this.members[userId];
if (member) {
sentinel.setMembershipEvent(member.events.member, this);
}
this.sentinels[userId] = sentinel;
}
return sentinel;
}
/**
* Get state events from the state of the room.
* @param {string} eventType The event type of the state event.
* @param {string} stateKey Optional. The state_key of the state event. If
* this is <code>undefined</code> then all matching state events will be
* returned.
* @return {MatrixEvent[]|MatrixEvent} A list of events if state_key was
* <code>undefined</code>, else a single event (or null if no match found).
*/
public getStateEvents(eventType: string): MatrixEvent[];
public getStateEvents(eventType: string, stateKey: string): MatrixEvent;
public getStateEvents(eventType: string, stateKey?: string) {
if (!this.events.has(eventType)) {
// no match
return stateKey === undefined ? [] : null;
}
if (stateKey === undefined) { // return all values
return Array.from(this.events.get(eventType).values());
}
const event = this.events.get(eventType).get(stateKey);
return event ? event : null;
}
/**
* Creates a copy of this room state so that mutations to either won't affect the other.
* @return {RoomState} the copy of the room state
*/
public clone(): RoomState {
const copy = new RoomState(this.roomId, this.oobMemberFlags);
// Ugly hack: because setStateEvents will mark
// members as susperseding future out of bound members
// if loading is in progress (through oobMemberFlags)
// since these are not new members, we're merely copying them
// set the status to not started
// after copying, we set back the status
const status = this.oobMemberFlags.status;
this.oobMemberFlags.status = OobStatus.NotStarted;
Array.from(this.events.values()).forEach((eventsByStateKey) => {
copy.setStateEvents(Array.from(eventsByStateKey.values()));
});
// Ugly hack: see above
this.oobMemberFlags.status = status;
if (this.summaryInvitedMemberCount !== null) {
copy.setInvitedMemberCount(this.getInvitedMemberCount());
}
if (this.summaryJoinedMemberCount !== null) {
copy.setJoinedMemberCount(this.getJoinedMemberCount());
}
// copy out of band flags if needed
if (this.oobMemberFlags.status == OobStatus.Finished) {
// copy markOutOfBand flags
this.getMembers().forEach((member) => {
if (member.isOutOfBand()) {
const copyMember = copy.getMember(member.userId);
copyMember.markOutOfBand();
}
});
}
return copy;
}
/**
* Add previously unknown state events.
* When lazy loading members while back-paginating,
* the relevant room state for the timeline chunk at the end
* of the chunk can be set with this method.
* @param {MatrixEvent[]} events state events to prepend
*/
public setUnknownStateEvents(events: MatrixEvent[]): void {
const unknownStateEvents = events.filter((event) => {
return !this.events.has(event.getType()) ||
!this.events.get(event.getType()).has(event.getStateKey());
});
this.setStateEvents(unknownStateEvents);
}
/**
* Add an array of one or more state MatrixEvents, overwriting
* any existing state with the same {type, stateKey} tuple. Will fire
* "RoomState.events" for every event added. May fire "RoomState.members"
* if there are <code>m.room.member</code> events.
* @param {MatrixEvent[]} stateEvents a list of state events for this room.
* @fires module:client~MatrixClient#event:"RoomState.members"
* @fires module:client~MatrixClient#event:"RoomState.newMember"
* @fires module:client~MatrixClient#event:"RoomState.events"
*/
public setStateEvents(stateEvents: MatrixEvent[]) {
this.updateModifiedTime();
// update the core event dict
stateEvents.forEach((event) => {
if (event.getRoomId() !== this.roomId) {
return;
}
if (!event.isState()) {
return;
}
const lastStateEvent = this.getStateEventMatching(event);
this.setStateEvent(event);
if (event.getType() === EventType.RoomMember) {
this.updateDisplayNameCache(event.getStateKey(), event.getContent().displayname);
this.updateThirdPartyTokenCache(event);
}
this.emit("RoomState.events", event, this, lastStateEvent);
});
// update higher level data structures. This needs to be done AFTER the
// core event dict as these structures may depend on other state events in
// the given array (e.g. disambiguating display names in one go to do both
// clashing names rather than progressively which only catches 1 of them).
stateEvents.forEach((event) => {
if (event.getRoomId() !== this.roomId) {
return;
}
if (!event.isState()) {
return;
}
if (event.getType() === EventType.RoomMember) {
const userId = event.getStateKey();
// leave events apparently elide the displayname or avatar_url,
// so let's fake one up so that we don't leak user ids
// into the timeline
if (event.getContent().membership === "leave" ||
event.getContent().membership === "ban") {
event.getContent().avatar_url =
event.getContent().avatar_url ||
event.getPrevContent().avatar_url;
event.getContent().displayname =
event.getContent().displayname ||
event.getPrevContent().displayname;
}
const member = this.getOrCreateMember(userId, event);
member.setMembershipEvent(event, this);
this.updateMember(member);
this.emit("RoomState.members", event, this, member);
} else if (event.getType() === EventType.RoomPowerLevels) {
// events with unknown state keys should be ignored
// and should not aggregate onto members power levels
if (event.getStateKey() !== "") {
return;
}
const members = Object.values(this.members);
members.forEach((member) => {
// We only propagate `RoomState.members` event if the
// power levels has been changed
// large room suffer from large re-rendering especially when not needed
const oldLastModified = member.getLastModifiedTime();
member.setPowerLevelEvent(event);
if (oldLastModified !== member.getLastModifiedTime()) {
this.emit("RoomState.members", event, this, member);
}
});
// assume all our sentinels are now out-of-date
this.sentinels = {};
}
});
}
/**
* Looks up a member by the given userId, and if it doesn't exist,
* create it and emit the `RoomState.newMember` event.
* This method makes sure the member is added to the members dictionary
* before emitting, as this is done from setStateEvents and setOutOfBandMember.
* @param {string} userId the id of the user to look up
* @param {MatrixEvent} event the membership event for the (new) member. Used to emit.
* @fires module:client~MatrixClient#event:"RoomState.newMember"
* @returns {RoomMember} the member, existing or newly created.
*/
private getOrCreateMember(userId: string, event: MatrixEvent): RoomMember {
let member = this.members[userId];
if (!member) {
member = new RoomMember(this.roomId, userId);
// add member to members before emitting any events,
// as event handlers often lookup the member
this.members[userId] = member;
this.emit("RoomState.newMember", event, this, member);
}
return member;
}
private setStateEvent(event: MatrixEvent): void {
if (!this.events.has(event.getType())) {
this.events.set(event.getType(), new Map());
}
this.events.get(event.getType()).set(event.getStateKey(), event);
}
private getStateEventMatching(event: MatrixEvent): MatrixEvent | null {
if (!this.events.has(event.getType())) return null;
return this.events.get(event.getType()).get(event.getStateKey());
}
private updateMember(member: RoomMember): void {
// this member may have a power level already, so set it.
const pwrLvlEvent = this.getStateEvents(EventType.RoomPowerLevels, "");
if (pwrLvlEvent) {
member.setPowerLevelEvent(pwrLvlEvent);
}
// blow away the sentinel which is now outdated
delete this.sentinels[member.userId];
this.members[member.userId] = member;
this.joinedMemberCount = null;
this.invitedMemberCount = null;
}
/**
* Get the out-of-band members loading state, whether loading is needed or not.
* Note that loading might be in progress and hence isn't needed.
* @return {boolean} whether or not the members of this room need to be loaded
*/
public needsOutOfBandMembers(): boolean {
return this.oobMemberFlags.status === OobStatus.NotStarted;
}
/**
* Mark this room state as waiting for out-of-band members,
* ensuring it doesn't ask for them to be requested again
* through needsOutOfBandMembers
*/
public markOutOfBandMembersStarted(): void {
if (this.oobMemberFlags.status !== OobStatus.NotStarted) {
return;
}
this.oobMemberFlags.status = OobStatus.InProgress;
}
/**
* Mark this room state as having failed to fetch out-of-band members
*/
public markOutOfBandMembersFailed(): void {
if (this.oobMemberFlags.status !== OobStatus.InProgress) {
return;
}
this.oobMemberFlags.status = OobStatus.NotStarted;
}
/**
* Clears the loaded out-of-band members
*/
public clearOutOfBandMembers(): void {
let count = 0;
Object.keys(this.members).forEach((userId) => {
const member = this.members[userId];
if (member.isOutOfBand()) {
++count;
delete this.members[userId];
}
});
logger.log(`LL: RoomState removed ${count} members...`);
this.oobMemberFlags.status = OobStatus.NotStarted;
}
/**
* Sets the loaded out-of-band members.
* @param {MatrixEvent[]} stateEvents array of membership state events
*/
public setOutOfBandMembers(stateEvents: MatrixEvent[]): void {
logger.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`);
if (this.oobMemberFlags.status !== OobStatus.InProgress) {
return;
}
logger.log(`LL: RoomState put in finished state ...`);
this.oobMemberFlags.status = OobStatus.Finished;
stateEvents.forEach((e) => this.setOutOfBandMember(e));
}
/**
* Sets a single out of band member, used by both setOutOfBandMembers and clone
* @param {MatrixEvent} stateEvent membership state event
*/
private setOutOfBandMember(stateEvent: MatrixEvent): void {
if (stateEvent.getType() !== EventType.RoomMember) {
return;
}
const userId = stateEvent.getStateKey();
const existingMember = this.getMember(userId);
// never replace members received as part of the sync
if (existingMember && !existingMember.isOutOfBand()) {
return;
}
const member = this.getOrCreateMember(userId, stateEvent);
member.setMembershipEvent(stateEvent, this);
// needed to know which members need to be stored seperately
// as they are not part of the sync accumulator
// this is cleared by setMembershipEvent so when it's updated through /sync
member.markOutOfBand();
this.updateDisplayNameCache(member.userId, member.name);
this.setStateEvent(stateEvent);
this.updateMember(member);
this.emit("RoomState.members", stateEvent, this, member);
}
/**
* Set the current typing event for this room.
* @param {MatrixEvent} event The typing event
*/
public setTypingEvent(event: MatrixEvent): void {
Object.values(this.members).forEach(function(member) {
member.setTypingEvent(event);
});
}
/**
* Get the m.room.member event which has the given third party invite token.
*
* @param {string} token The token
* @return {?MatrixEvent} The m.room.member event or null
*/
public getInviteForThreePidToken(token: string): MatrixEvent | null {
return this.tokenToInvite[token] || null;
}
/**
* Update the last modified time to the current time.
*/
private updateModifiedTime(): void {
this.modified = Date.now();
}
/**
* Get the timestamp when this room state was last updated. This timestamp is
* updated when this object has received new state events.
* @return {number} The timestamp
*/
public getLastModifiedTime(): number {
return this.modified;
}
/**
* Get user IDs with the specified or similar display names.
* @param {string} displayName The display name to get user IDs from.
* @return {string[]} An array of user IDs or an empty array.
*/
public getUserIdsWithDisplayName(displayName: string): string[] {
return this.displayNameToUserIds[utils.removeHiddenChars(displayName)] || [];
}
/**
* Returns true if userId is in room, event is not redacted and either sender of
* mxEvent or has power level sufficient to redact events other than their own.
* @param {MatrixEvent} mxEvent The event to test permission for
* @param {string} userId The user ID of the user to test permission for
* @return {boolean} true if the given used ID can redact given event
*/
public maySendRedactionForEvent(mxEvent: MatrixEvent, userId: string): boolean {
const member = this.getMember(userId);
if (!member || member.membership === 'leave') return false;
if (mxEvent.status || mxEvent.isRedacted()) return false;
// The user may have been the sender, but they can't redact their own message
// if redactions are blocked.
const canRedact = this.maySendEvent(EventType.RoomRedaction, userId);
if (mxEvent.getSender() === userId) return canRedact;
return this.hasSufficientPowerLevelFor('redact', member.powerLevel);
}
/**
* Returns true if the given power level is sufficient for action
* @param {string} action The type of power level to check
* @param {number} powerLevel The power level of the member
* @return {boolean} true if the given power level is sufficient
*/
private hasSufficientPowerLevelFor(action: string, powerLevel: number): boolean {
const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, "");
let powerLevels = {};
if (powerLevelsEvent) {
powerLevels = powerLevelsEvent.getContent();
}
let requiredLevel = 50;
if (utils.isNumber(powerLevels[action])) {
requiredLevel = powerLevels[action];
}
return powerLevel >= requiredLevel;
}
/**
* Short-form for maySendEvent('m.room.message', userId)
* @param {string} userId The user ID of the user to test permission for
* @return {boolean} true if the given user ID should be permitted to send
* message events into the given room.
*/
public maySendMessage(userId: string): boolean {
return this.maySendEventOfType(EventType.RoomMessage, userId, false);
}
/**
* Returns true if the given user ID has permission to send a normal
* event of type `eventType` into this room.
* @param {string} eventType The type of event to test
* @param {string} userId The user ID of the user to test permission for
* @return {boolean} true if the given user ID should be permitted to send
* the given type of event into this room,
* according to the room's state.
*/
public maySendEvent(eventType: EventType | string, userId: string): boolean {
return this.maySendEventOfType(eventType, userId, false);
}
/**
* Returns true if the given MatrixClient has permission to send a state
* event of type `stateEventType` into this room.
* @param {string} stateEventType The type of state events to test
* @param {MatrixClient} cli The client to test permission for
* @return {boolean} true if the given client should be permitted to send
* the given type of state event into this room,
* according to the room's state.
*/
public mayClientSendStateEvent(stateEventType: EventType | string, cli: MatrixClient): boolean {
if (cli.isGuest()) {
return false;
}
return this.maySendStateEvent(stateEventType, cli.credentials.userId);
}
/**
* Returns true if the given user ID has permission to send a state
* event of type `stateEventType` into this room.
* @param {string} stateEventType The type of state events to test
* @param {string} userId The user ID of the user to test permission for
* @return {boolean} true if the given user ID should be permitted to send
* the given type of state event into this room,
* according to the room's state.
*/
public maySendStateEvent(stateEventType: EventType | string, userId: string): boolean {
return this.maySendEventOfType(stateEventType, userId, true);
}
/**
* Returns true if the given user ID has permission to send a normal or state
* event of type `eventType` into this room.
* @param {string} eventType The type of event to test
* @param {string} userId The user ID of the user to test permission for
* @param {boolean} state If true, tests if the user may send a state
event of this type. Otherwise tests whether
they may send a regular event.
* @return {boolean} true if the given user ID should be permitted to send
* the given type of event into this room,
* according to the room's state.
*/
private maySendEventOfType(eventType: EventType | string, userId: string, state: boolean): boolean {
const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, '');
let powerLevels;
let eventsLevels = {};
let stateDefault = 0;
let eventsDefault = 0;
let powerLevel = 0;
if (powerLevelsEvent) {
powerLevels = powerLevelsEvent.getContent();
eventsLevels = powerLevels.events || {};
if (Number.isSafeInteger(powerLevels.state_default)) {
stateDefault = powerLevels.state_default;
} else {
stateDefault = 50;
}
const userPowerLevel = powerLevels.users && powerLevels.users[userId];
if (Number.isSafeInteger(userPowerLevel)) {
powerLevel = userPowerLevel;
} else if (Number.isSafeInteger(powerLevels.users_default)) {
powerLevel = powerLevels.users_default;
}
if (Number.isSafeInteger(powerLevels.events_default)) {
eventsDefault = powerLevels.events_default;
}
}
let requiredLevel = state ? stateDefault : eventsDefault;
if (Number.isSafeInteger(eventsLevels[eventType])) {
requiredLevel = eventsLevels[eventType];
}
return powerLevel >= requiredLevel;
}
/**
* Returns true if the given user ID has permission to trigger notification
* of type `notifLevelKey`
* @param {string} notifLevelKey The level of notification to test (eg. 'room')
* @param {string} userId The user ID of the user to test permission for
* @return {boolean} true if the given user ID has permission to trigger a
* notification of this type.
*/
public mayTriggerNotifOfType(notifLevelKey: string, userId: string): boolean {
const member = this.getMember(userId);
if (!member) {
return false;
}
const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, '');
let notifLevel = 50;
if (
powerLevelsEvent &&
powerLevelsEvent.getContent() &&
powerLevelsEvent.getContent().notifications &&
utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey])
) {
notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey];
}
return member.powerLevel >= notifLevel;
}
/**
* Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`.
* @returns {string} the join_rule applied to this room
*/
public getJoinRule(): string {
const joinRuleEvent = this.getStateEvents(EventType.RoomJoinRules, "");
const joinRuleContent = joinRuleEvent ? joinRuleEvent.getContent() : {};
return joinRuleContent["join_rule"] || "invite";
}
private updateThirdPartyTokenCache(memberEvent: MatrixEvent): void {
if (!memberEvent.getContent().third_party_invite) {
return;
}
const token = (memberEvent.getContent().third_party_invite.signed || {}).token;
if (!token) {
return;
}
const threePidInvite = this.getStateEvents(EventType.RoomThirdPartyInvite, token);
if (!threePidInvite) {
return;
}
this.tokenToInvite[token] = memberEvent;
}
private updateDisplayNameCache(userId: string, displayName: string): void {
const oldName = this.userIdsToDisplayNames[userId];
delete this.userIdsToDisplayNames[userId];
if (oldName) {
// Remove the old name from the cache.
// We clobber the user_id > name lookup but the name -> [user_id] lookup
// means we need to remove that user ID from that array rather than nuking
// the lot.
const strippedOldName = utils.removeHiddenChars(oldName);
const existingUserIds = this.displayNameToUserIds[strippedOldName];
if (existingUserIds) {
// remove this user ID from this array
const filteredUserIDs = existingUserIds.filter((id) => id !== userId);
this.displayNameToUserIds[strippedOldName] = filteredUserIDs;
}
}
this.userIdsToDisplayNames[userId] = displayName;
const strippedDisplayname = displayName && utils.removeHiddenChars(displayName);
// an empty stripped displayname (undefined/'') will be set to MXID in room-member.js
if (strippedDisplayname) {
if (!this.displayNameToUserIds[strippedDisplayname]) {
this.displayNameToUserIds[strippedDisplayname] = [];
}
this.displayNameToUserIds[strippedDisplayname].push(userId);
}
}
}
/**
* Fires whenever the event dictionary in room state is updated.
* @event module:client~MatrixClient#"RoomState.events"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomState} state The room state whose RoomState.events dictionary
* was updated.
* @param {MatrixEvent} prevEvent The event being replaced by the new state, if
* known. Note that this can differ from `getPrevContent()` on the new state event
* as this is the store's view of the last state, not the previous state provided
* by the server.
* @example
* matrixClient.on("RoomState.events", function(event, state, prevEvent){
* var newStateEvent = event;
* });
*/
/**
* Fires whenever a member in the members dictionary is updated in any way.
* @event module:client~MatrixClient#"RoomState.members"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomState} state The room state whose RoomState.members dictionary
* was updated.
* @param {RoomMember} member The room member that was updated.
* @example
* matrixClient.on("RoomState.members", function(event, state, member){
* var newMembershipState = member.membership;
* });
*/
/**
* Fires whenever a member is added to the members dictionary. The RoomMember
* will not be fully populated yet (e.g. no membership state) but will already
* be available in the members dictionary.
* @event module:client~MatrixClient#"RoomState.newMember"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomState} state The room state whose RoomState.members dictionary
* was updated with a new entry.
* @param {RoomMember} member The room member that was added.
* @example
* matrixClient.on("RoomState.newMember", function(event, state, member){
* // add event listeners on 'member'
* });
*/

View File

@@ -1,6 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -19,6 +18,20 @@ limitations under the License.
* @module models/room-summary
*/
export interface IRoomSummary {
"m.heroes": string[];
"m.joined_member_count": number;
"m.invited_member_count": number;
}
interface IInfo {
title: string;
desc?: string;
numMembers?: number;
aliases?: string[];
timestamp?: number;
}
/**
* Construct a new Room Summary. A summary can be used for display on a recent
* list, without having to load the entire room list into memory.
@@ -32,8 +45,7 @@ limitations under the License.
* @param {string[]} info.aliases The list of aliases for this room.
* @param {Number} info.timestamp The timestamp for this room.
*/
export function RoomSummary(roomId, info) {
this.roomId = roomId;
this.info = info;
export class RoomSummary {
constructor(public readonly roomId: string, info?: IInfo) {}
}

File diff suppressed because it is too large Load Diff

2267
src/models/room.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,260 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 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.
*/
/**
* @module models/user
*/
import * as utils from "../utils";
import { EventEmitter } from "events";
/**
* Construct a new User. A User must have an ID and can optionally have extra
* information associated with it.
* @constructor
* @param {string} userId Required. The ID of this user.
* @prop {string} userId The ID of the user.
* @prop {Object} info The info object supplied in the constructor.
* @prop {string} displayName The 'displayname' of the user if known.
* @prop {string} avatarUrl The 'avatar_url' of the user if known.
* @prop {string} presence The presence enum if known.
* @prop {string} presenceStatusMsg The presence status message if known.
* @prop {Number} lastActiveAgo The time elapsed in ms since the user interacted
* proactively with the server, or we saw a message from the user
* @prop {Number} lastPresenceTs Timestamp (ms since the epoch) for when we last
* received presence data for this user. We can subtract
* lastActiveAgo from this to approximate an absolute value for
* when a user was last active.
* @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be
* an approximation and that the user should be seen as active 'now'
* @prop {string} _unstable_statusMessage The status message for the user, if known. This is
* different from the presenceStatusMsg in that this is not tied to
* the user's presence, and should be represented differently.
* @prop {Object} events The events describing this user.
* @prop {MatrixEvent} events.presence The m.presence event for this user.
*/
export function User(userId) {
this.userId = userId;
this.presence = "offline";
this.presenceStatusMsg = null;
this._unstable_statusMessage = "";
this.displayName = userId;
this.rawDisplayName = userId;
this.avatarUrl = null;
this.lastActiveAgo = 0;
this.lastPresenceTs = 0;
this.currentlyActive = false;
this.events = {
presence: null,
profile: null,
};
this._updateModifiedTime();
}
utils.inherits(User, EventEmitter);
/**
* Update this User with the given presence event. May fire "User.presence",
* "User.avatarUrl" and/or "User.displayName" if this event updates this user's
* properties.
* @param {MatrixEvent} event The <code>m.presence</code> event.
* @fires module:client~MatrixClient#event:"User.presence"
* @fires module:client~MatrixClient#event:"User.displayName"
* @fires module:client~MatrixClient#event:"User.avatarUrl"
*/
User.prototype.setPresenceEvent = function(event) {
if (event.getType() !== "m.presence") {
return;
}
const firstFire = this.events.presence === null;
this.events.presence = event;
const eventsToFire = [];
if (event.getContent().presence !== this.presence || firstFire) {
eventsToFire.push("User.presence");
}
if (event.getContent().avatar_url &&
event.getContent().avatar_url !== this.avatarUrl) {
eventsToFire.push("User.avatarUrl");
}
if (event.getContent().displayname &&
event.getContent().displayname !== this.displayName) {
eventsToFire.push("User.displayName");
}
if (event.getContent().currently_active !== undefined &&
event.getContent().currently_active !== this.currentlyActive) {
eventsToFire.push("User.currentlyActive");
}
this.presence = event.getContent().presence;
eventsToFire.push("User.lastPresenceTs");
if (event.getContent().status_msg) {
this.presenceStatusMsg = event.getContent().status_msg;
}
if (event.getContent().displayname) {
this.displayName = event.getContent().displayname;
}
if (event.getContent().avatar_url) {
this.avatarUrl = event.getContent().avatar_url;
}
this.lastActiveAgo = event.getContent().last_active_ago;
this.lastPresenceTs = Date.now();
this.currentlyActive = event.getContent().currently_active;
this._updateModifiedTime();
for (let i = 0; i < eventsToFire.length; i++) {
this.emit(eventsToFire[i], event, this);
}
};
/**
* Manually set this user's display name. No event is emitted in response to this
* as there is no underlying MatrixEvent to emit with.
* @param {string} name The new display name.
*/
User.prototype.setDisplayName = function(name) {
const oldName = this.displayName;
if (typeof name === "string") {
this.displayName = name;
} else {
this.displayName = undefined;
}
if (name !== oldName) {
this._updateModifiedTime();
}
};
/**
* Manually set this user's non-disambiguated display name. No event is emitted
* in response to this as there is no underlying MatrixEvent to emit with.
* @param {string} name The new display name.
*/
User.prototype.setRawDisplayName = function(name) {
if (typeof name === "string") {
this.rawDisplayName = name;
} else {
this.rawDisplayName = undefined;
}
};
/**
* Manually set this user's avatar URL. No event is emitted in response to this
* as there is no underlying MatrixEvent to emit with.
* @param {string} url The new avatar URL.
*/
User.prototype.setAvatarUrl = function(url) {
const oldUrl = this.avatarUrl;
this.avatarUrl = url;
if (url !== oldUrl) {
this._updateModifiedTime();
}
};
/**
* Update the last modified time to the current time.
*/
User.prototype._updateModifiedTime = function() {
this._modified = Date.now();
};
/**
* Get the timestamp when this User was last updated. This timestamp is
* updated when this User receives a new Presence event which has updated a
* property on this object. It is updated <i>before</i> firing events.
* @return {number} The timestamp
*/
User.prototype.getLastModifiedTime = function() {
return this._modified;
};
/**
* Get the absolute timestamp when this User was last known active on the server.
* It is *NOT* accurate if this.currentlyActive is true.
* @return {number} The timestamp
*/
User.prototype.getLastActiveTs = function() {
return this.lastPresenceTs - this.lastActiveAgo;
};
/**
* Manually set the user's status message.
* @param {MatrixEvent} event The <code>im.vector.user_status</code> event.
* @fires module:client~MatrixClient#event:"User._unstable_statusMessage"
*/
User.prototype._unstable_updateStatusMessage = function(event) {
if (!event.getContent()) this._unstable_statusMessage = "";
else this._unstable_statusMessage = event.getContent()["status"];
this._updateModifiedTime();
this.emit("User._unstable_statusMessage", this);
};
/**
* Fires whenever any user's lastPresenceTs changes,
* ie. whenever any presence event is received for a user.
* @event module:client~MatrixClient#"User.lastPresenceTs"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {User} user The user whose User.lastPresenceTs changed.
* @example
* matrixClient.on("User.lastPresenceTs", function(event, user){
* var newlastPresenceTs = user.lastPresenceTs;
* });
*/
/**
* Fires whenever any user's presence changes.
* @event module:client~MatrixClient#"User.presence"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {User} user The user whose User.presence changed.
* @example
* matrixClient.on("User.presence", function(event, user){
* var newPresence = user.presence;
* });
*/
/**
* Fires whenever any user's currentlyActive changes.
* @event module:client~MatrixClient#"User.currentlyActive"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {User} user The user whose User.currentlyActive changed.
* @example
* matrixClient.on("User.currentlyActive", function(event, user){
* var newCurrentlyActive = user.currentlyActive;
* });
*/
/**
* Fires whenever any user's display name changes.
* @event module:client~MatrixClient#"User.displayName"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {User} user The user whose User.displayName changed.
* @example
* matrixClient.on("User.displayName", function(event, user){
* var newName = user.displayName;
* });
*/
/**
* Fires whenever any user's avatar URL changes.
* @event module:client~MatrixClient#"User.avatarUrl"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {User} user The user whose User.avatarUrl changed.
* @example
* matrixClient.on("User.avatarUrl", function(event, user){
* var newUrl = user.avatarUrl;
* });
*/

274
src/models/user.ts Normal file
View File

@@ -0,0 +1,274 @@
/*
Copyright 2015 - 2021 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.
*/
/**
* @module models/user
*/
import { EventEmitter } from "events";
import { MatrixEvent } from "./event";
export class User extends EventEmitter {
// eslint-disable-next-line camelcase
private modified: number;
// XXX these should be read-only
public displayName: string;
public rawDisplayName: string;
public avatarUrl: string;
public presenceStatusMsg: string = null;
public presence = "offline";
public lastActiveAgo = 0;
public lastPresenceTs = 0;
public currentlyActive = false;
public events: {
presence?: MatrixEvent;
profile?: MatrixEvent;
} = {
presence: null,
profile: null,
};
// eslint-disable-next-line camelcase
public unstable_statusMessage = "";
/**
* Construct a new User. A User must have an ID and can optionally have extra
* information associated with it.
* @constructor
* @param {string} userId Required. The ID of this user.
* @prop {string} userId The ID of the user.
* @prop {Object} info The info object supplied in the constructor.
* @prop {string} displayName The 'displayname' of the user if known.
* @prop {string} avatarUrl The 'avatar_url' of the user if known.
* @prop {string} presence The presence enum if known.
* @prop {string} presenceStatusMsg The presence status message if known.
* @prop {Number} lastActiveAgo The time elapsed in ms since the user interacted
* proactively with the server, or we saw a message from the user
* @prop {Number} lastPresenceTs Timestamp (ms since the epoch) for when we last
* received presence data for this user. We can subtract
* lastActiveAgo from this to approximate an absolute value for
* when a user was last active.
* @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be
* an approximation and that the user should be seen as active 'now'
* @prop {string} unstable_statusMessage The status message for the user, if known. This is
* different from the presenceStatusMsg in that this is not tied to
* the user's presence, and should be represented differently.
* @prop {Object} events The events describing this user.
* @prop {MatrixEvent} events.presence The m.presence event for this user.
*/
constructor(public readonly userId: string) {
super();
this.displayName = userId;
this.rawDisplayName = userId;
this.avatarUrl = null;
this.updateModifiedTime();
}
/**
* Update this User with the given presence event. May fire "User.presence",
* "User.avatarUrl" and/or "User.displayName" if this event updates this user's
* properties.
* @param {MatrixEvent} event The <code>m.presence</code> event.
* @fires module:client~MatrixClient#event:"User.presence"
* @fires module:client~MatrixClient#event:"User.displayName"
* @fires module:client~MatrixClient#event:"User.avatarUrl"
*/
public setPresenceEvent(event: MatrixEvent): void {
if (event.getType() !== "m.presence") {
return;
}
const firstFire = this.events.presence === null;
this.events.presence = event;
const eventsToFire = [];
if (event.getContent().presence !== this.presence || firstFire) {
eventsToFire.push("User.presence");
}
if (event.getContent().avatar_url &&
event.getContent().avatar_url !== this.avatarUrl) {
eventsToFire.push("User.avatarUrl");
}
if (event.getContent().displayname &&
event.getContent().displayname !== this.displayName) {
eventsToFire.push("User.displayName");
}
if (event.getContent().currently_active !== undefined &&
event.getContent().currently_active !== this.currentlyActive) {
eventsToFire.push("User.currentlyActive");
}
this.presence = event.getContent().presence;
eventsToFire.push("User.lastPresenceTs");
if (event.getContent().status_msg) {
this.presenceStatusMsg = event.getContent().status_msg;
}
if (event.getContent().displayname) {
this.displayName = event.getContent().displayname;
}
if (event.getContent().avatar_url) {
this.avatarUrl = event.getContent().avatar_url;
}
this.lastActiveAgo = event.getContent().last_active_ago;
this.lastPresenceTs = Date.now();
this.currentlyActive = event.getContent().currently_active;
this.updateModifiedTime();
for (let i = 0; i < eventsToFire.length; i++) {
this.emit(eventsToFire[i], event, this);
}
}
/**
* Manually set this user's display name. No event is emitted in response to this
* as there is no underlying MatrixEvent to emit with.
* @param {string} name The new display name.
*/
public setDisplayName(name: string): void {
const oldName = this.displayName;
if (typeof name === "string") {
this.displayName = name;
} else {
this.displayName = undefined;
}
if (name !== oldName) {
this.updateModifiedTime();
}
}
/**
* Manually set this user's non-disambiguated display name. No event is emitted
* in response to this as there is no underlying MatrixEvent to emit with.
* @param {string} name The new display name.
*/
public setRawDisplayName(name: string): void {
if (typeof name === "string") {
this.rawDisplayName = name;
} else {
this.rawDisplayName = undefined;
}
}
/**
* Manually set this user's avatar URL. No event is emitted in response to this
* as there is no underlying MatrixEvent to emit with.
* @param {string} url The new avatar URL.
*/
public setAvatarUrl(url: string): void {
const oldUrl = this.avatarUrl;
this.avatarUrl = url;
if (url !== oldUrl) {
this.updateModifiedTime();
}
}
/**
* Update the last modified time to the current time.
*/
private updateModifiedTime(): void {
this.modified = Date.now();
}
/**
* Get the timestamp when this User was last updated. This timestamp is
* updated when this User receives a new Presence event which has updated a
* property on this object. It is updated <i>before</i> firing events.
* @return {number} The timestamp
*/
public getLastModifiedTime(): number {
return this.modified;
}
/**
* Get the absolute timestamp when this User was last known active on the server.
* It is *NOT* accurate if this.currentlyActive is true.
* @return {number} The timestamp
*/
public getLastActiveTs(): number {
return this.lastPresenceTs - this.lastActiveAgo;
}
/**
* Manually set the user's status message.
* @param {MatrixEvent} event The <code>im.vector.user_status</code> event.
* @fires module:client~MatrixClient#event:"User.unstable_statusMessage"
*/
// eslint-disable-next-line camelcase
public unstable_updateStatusMessage(event: MatrixEvent): void {
if (!event.getContent()) this.unstable_statusMessage = "";
else this.unstable_statusMessage = event.getContent()["status"];
this.updateModifiedTime();
this.emit("User.unstable_statusMessage", this);
}
}
/**
* Fires whenever any user's lastPresenceTs changes,
* ie. whenever any presence event is received for a user.
* @event module:client~MatrixClient#"User.lastPresenceTs"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {User} user The user whose User.lastPresenceTs changed.
* @example
* matrixClient.on("User.lastPresenceTs", function(event, user){
* var newlastPresenceTs = user.lastPresenceTs;
* });
*/
/**
* Fires whenever any user's presence changes.
* @event module:client~MatrixClient#"User.presence"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {User} user The user whose User.presence changed.
* @example
* matrixClient.on("User.presence", function(event, user){
* var newPresence = user.presence;
* });
*/
/**
* Fires whenever any user's currentlyActive changes.
* @event module:client~MatrixClient#"User.currentlyActive"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {User} user The user whose User.currentlyActive changed.
* @example
* matrixClient.on("User.currentlyActive", function(event, user){
* var newCurrentlyActive = user.currentlyActive;
* });
*/
/**
* Fires whenever any user's display name changes.
* @event module:client~MatrixClient#"User.displayName"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {User} user The user whose User.displayName changed.
* @example
* matrixClient.on("User.displayName", function(event, user){
* var newName = user.displayName;
* });
*/
/**
* Fires whenever any user's avatar URL changes.
* @event module:client~MatrixClient#"User.avatarUrl"
* @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {User} user The user whose User.avatarUrl changed.
* @example
* matrixClient.on("User.avatarUrl", function(event, user){
* var newUrl = user.avatarUrl;
* });
*/

20
src/service-types.ts Normal file
View File

@@ -0,0 +1,20 @@
/*
Copyright 2019 - 2021 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.
*/
export enum SERVICE_TYPES {
IS = 'SERVICE_TYPE_IS', // An Identity Service
IM = 'SERVICE_TYPE_IM', // An Integration Manager
}

234
src/store/index.ts Normal file
View File

@@ -0,0 +1,234 @@
/*
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventType } from "../@types/event";
import { Group } from "../models/group";
import { Room } from "../models/room";
import { User } from "../models/user";
import { MatrixEvent } from "../models/event";
import { Filter } from "../filter";
import { RoomSummary } from "../models/room-summary";
import { IMinimalEvent, IGroups, IRooms } from "../sync-accumulator";
export interface ISavedSync {
nextBatch: string;
roomsData: IRooms;
groupsData: IGroups;
accountData: IMinimalEvent[];
}
/**
* Construct a stub store. This does no-ops on most store methods.
* @constructor
*/
export interface IStore {
/** @return {Promise<bool>} whether or not the database was newly created in this session. */
isNewlyCreated(): Promise<boolean>;
/**
* Get the sync token.
* @return {string}
*/
getSyncToken(): string | null;
/**
* Set the sync token.
* @param {string} token
*/
setSyncToken(token: string);
/**
* No-op.
* @param {Group} group
*/
storeGroup(group: Group);
/**
* No-op.
* @param {string} groupId
* @return {null}
*/
getGroup(groupId: string): Group | null;
/**
* No-op.
* @return {Array} An empty array.
*/
getGroups(): Group[];
/**
* No-op.
* @param {Room} room
*/
storeRoom(room: Room);
/**
* No-op.
* @param {string} roomId
* @return {null}
*/
getRoom(roomId: string): Room | null;
/**
* No-op.
* @return {Array} An empty array.
*/
getRooms(): Room[];
/**
* Permanently delete a room.
* @param {string} roomId
*/
removeRoom(roomId: string);
/**
* No-op.
* @return {Array} An empty array.
*/
getRoomSummaries(): RoomSummary[];
/**
* No-op.
* @param {User} user
*/
storeUser(user: User);
/**
* No-op.
* @param {string} userId
* @return {null}
*/
getUser(userId: string): User | null;
/**
* No-op.
* @return {User[]}
*/
getUsers(): User[];
/**
* No-op.
* @param {Room} room
* @param {integer} limit
* @return {Array}
*/
scrollback(room: Room, limit: number): MatrixEvent[];
/**
* Store events for a room.
* @param {Room} room The room to store events for.
* @param {Array<MatrixEvent>} events The events to store.
* @param {string} token The token associated with these events.
* @param {boolean} toStart True if these are paginated results.
*/
storeEvents(room: Room, events: MatrixEvent[], token: string, toStart: boolean);
/**
* Store a filter.
* @param {Filter} filter
*/
storeFilter(filter: Filter);
/**
* Retrieve a filter.
* @param {string} userId
* @param {string} filterId
* @return {?Filter} A filter or null.
*/
getFilter(userId: string, filterId: string): Filter | null;
/**
* Retrieve a filter ID with the given name.
* @param {string} filterName The filter name.
* @return {?string} The filter ID or null.
*/
getFilterIdByName(filterName: string): string | null;
/**
* Set a filter name to ID mapping.
* @param {string} filterName
* @param {string} filterId
*/
setFilterIdByName(filterName: string, filterId: string);
/**
* Store user-scoped account data events
* @param {Array<MatrixEvent>} events The events to store.
*/
storeAccountDataEvents(events: MatrixEvent[]);
/**
* Get account data event by event type
* @param {string} eventType The event type being queried
*/
getAccountData(eventType: EventType | string): MatrixEvent;
/**
* setSyncData does nothing as there is no backing data store.
*
* @param {Object} syncData The sync data
* @return {Promise} An immediately resolved promise.
*/
setSyncData(syncData: object): Promise<void>;
/**
* We never want to save because we have nothing to save to.
*
* @return {boolean} If the store wants to save
*/
wantsSave(): boolean;
/**
* Save does nothing as there is no backing data store.
*/
save(force: boolean): void;
/**
* Startup does nothing.
* @return {Promise} An immediately resolved promise.
*/
startup(): Promise<void>;
/**
* @return {Promise} Resolves with a sync response to restore the
* client state to where it was at the last save, or null if there
* is no saved sync data.
*/
getSavedSync(): Promise<ISavedSync>;
/**
* @return {Promise} If there is a saved sync, the nextBatch token
* for this sync, otherwise null.
*/
getSavedSyncToken(): Promise<string | null>;
/**
* Delete all data from this store. Does nothing since this store
* doesn't store anything.
* @return {Promise} An immediately resolved promise.
*/
deleteAllData(): Promise<void>;
getOutOfBandMembers(roomId: string): Promise<MatrixEvent[] | null>;
setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise<void>;
clearOutOfBandMembers(roomId: string): Promise<void>;
getClientOptions(): Promise<object>;
storeClientOptions(options: object): Promise<void>;
}

View File

@@ -1,319 +0,0 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 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.
*/
/* eslint-disable @babel/no-invalid-this */
import { MemoryStore } from "./memory";
import * as utils from "../utils";
import { EventEmitter } from 'events';
import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend.js";
import { RemoteIndexedDBStoreBackend } from "./indexeddb-remote-backend.js";
import { User } from "../models/user";
import { MatrixEvent } from "../models/event";
import { logger } from '../logger';
/**
* This is an internal module. See {@link IndexedDBStore} for the public class.
* @module store/indexeddb
*/
// If this value is too small we'll be writing very often which will cause
// noticable stop-the-world pauses. If this value is too big we'll be writing
// so infrequently that the /sync size gets bigger on reload. Writing more
// often does not affect the length of the pause since the entire /sync
// response is persisted each time.
const WRITE_DELAY_MS = 1000 * 60 * 5; // once every 5 minutes
/**
* Construct a new Indexed Database store, which extends MemoryStore.
*
* This store functions like a MemoryStore except it periodically persists
* the contents of the store to an IndexedDB backend.
*
* All data is still kept in-memory but can be loaded from disk by calling
* <code>startup()</code>. This can make startup times quicker as a complete
* sync from the server is not required. This does not reduce memory usage as all
* the data is eagerly fetched when <code>startup()</code> is called.
* <pre>
* let opts = { indexedDB: window.indexedDB, localStorage: window.localStorage };
* let store = new IndexedDBStore(opts);
* await store.startup(); // load from indexed db
* let client = sdk.createClient({
* store: store,
* });
* client.startClient();
* client.on("sync", function(state, prevState, data) {
* if (state === "PREPARED") {
* console.log("Started up, now with go faster stripes!");
* }
* });
* </pre>
*
* @constructor
* @extends MemoryStore
* @param {Object} opts Options object.
* @param {Object} opts.indexedDB The Indexed DB interface e.g.
* <code>window.indexedDB</code>
* @param {string=} opts.dbName Optional database name. The same name must be used
* to open the same database.
* @param {string=} opts.workerScript Optional URL to a script to invoke a web
* worker with to run IndexedDB queries on the web worker. The IndexedDbStoreWorker
* class is provided for this purpose and requires the application to provide a
* trivial wrapper script around it.
* @param {Object=} opts.workerApi The webWorker API object. If omitted, the global Worker
* object will be used if it exists.
* @prop {IndexedDBStoreBackend} backend The backend instance. Call through to
* this API if you need to perform specific indexeddb actions like deleting the
* database.
*/
export function IndexedDBStore(opts) {
MemoryStore.call(this, opts);
if (!opts.indexedDB) {
throw new Error('Missing required option: indexedDB');
}
if (opts.workerScript) {
// try & find a webworker-compatible API
let workerApi = opts.workerApi;
if (!workerApi) {
// default to the global Worker object (which is where it in a browser)
workerApi = global.Worker;
}
this.backend = new RemoteIndexedDBStoreBackend(
opts.workerScript, opts.dbName, workerApi,
);
} else {
this.backend = new LocalIndexedDBStoreBackend(opts.indexedDB, opts.dbName);
}
this.startedUp = false;
this._syncTs = 0;
// Records the last-modified-time of each user at the last point we saved
// the database, such that we can derive the set if users that have been
// modified since we last saved.
this._userModifiedMap = {
// user_id : timestamp
};
}
utils.inherits(IndexedDBStore, MemoryStore);
utils.extend(IndexedDBStore.prototype, EventEmitter.prototype);
IndexedDBStore.exists = function(indexedDB, dbName) {
return LocalIndexedDBStoreBackend.exists(indexedDB, dbName);
};
/**
* @return {Promise} Resolved when loaded from indexed db.
*/
IndexedDBStore.prototype.startup = function() {
if (this.startedUp) {
logger.log(`IndexedDBStore.startup: already started`);
return Promise.resolve();
}
logger.log(`IndexedDBStore.startup: connecting to backend`);
return this.backend.connect().then(() => {
logger.log(`IndexedDBStore.startup: loading presence events`);
return this.backend.getUserPresenceEvents();
}).then((userPresenceEvents) => {
logger.log(`IndexedDBStore.startup: processing presence events`);
userPresenceEvents.forEach(([userId, rawEvent]) => {
const u = new User(userId);
if (rawEvent) {
u.setPresenceEvent(new MatrixEvent(rawEvent));
}
this._userModifiedMap[u.userId] = u.getLastModifiedTime();
this.storeUser(u);
});
});
};
/**
* @return {Promise} Resolves with a sync response to restore the
* client state to where it was at the last save, or null if there
* is no saved sync data.
*/
IndexedDBStore.prototype.getSavedSync = degradable(function() {
return this.backend.getSavedSync();
}, "getSavedSync");
/** @return {Promise<bool>} whether or not the database was newly created in this session. */
IndexedDBStore.prototype.isNewlyCreated = degradable(function() {
return this.backend.isNewlyCreated();
}, "isNewlyCreated");
/**
* @return {Promise} If there is a saved sync, the nextBatch token
* for this sync, otherwise null.
*/
IndexedDBStore.prototype.getSavedSyncToken = degradable(function() {
return this.backend.getNextBatchToken();
}, "getSavedSyncToken"),
/**
* Delete all data from this store.
* @return {Promise} Resolves if the data was deleted from the database.
*/
IndexedDBStore.prototype.deleteAllData = degradable(function() {
MemoryStore.prototype.deleteAllData.call(this);
return this.backend.clearDatabase().then(() => {
logger.log("Deleted indexeddb data.");
}, (err) => {
logger.error(`Failed to delete indexeddb data: ${err}`);
throw err;
});
});
/**
* Whether this store would like to save its data
* Note that obviously whether the store wants to save or
* not could change between calling this function and calling
* save().
*
* @return {boolean} True if calling save() will actually save
* (at the time this function is called).
*/
IndexedDBStore.prototype.wantsSave = function() {
const now = Date.now();
return now - this._syncTs > WRITE_DELAY_MS;
};
/**
* Possibly write data to the database.
*
* @param {bool} force True to force a save to happen
* @return {Promise} Promise resolves after the write completes
* (or immediately if no write is performed)
*/
IndexedDBStore.prototype.save = function(force) {
if (force || this.wantsSave()) {
return this._reallySave();
}
return Promise.resolve();
};
IndexedDBStore.prototype._reallySave = degradable(function() {
this._syncTs = Date.now(); // set now to guard against multi-writes
// work out changed users (this doesn't handle deletions but you
// can't 'delete' users as they are just presence events).
const userTuples = [];
for (const u of this.getUsers()) {
if (this._userModifiedMap[u.userId] === u.getLastModifiedTime()) continue;
if (!u.events.presence) continue;
userTuples.push([u.userId, u.events.presence.event]);
// note that we've saved this version of the user
this._userModifiedMap[u.userId] = u.getLastModifiedTime();
}
return this.backend.syncToDatabase(userTuples);
});
IndexedDBStore.prototype.setSyncData = degradable(function(syncData) {
return this.backend.setSyncData(syncData);
}, "setSyncData");
/**
* Returns the out-of-band membership events for this room that
* were previously loaded.
* @param {string} roomId
* @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet
*/
IndexedDBStore.prototype.getOutOfBandMembers = degradable(function(roomId) {
return this.backend.getOutOfBandMembers(roomId);
}, "getOutOfBandMembers");
/**
* Stores the out-of-band membership events for this room. Note that
* it still makes sense to store an empty array as the OOB status for the room is
* marked as fetched, and getOutOfBandMembers will return an empty array instead of null
* @param {string} roomId
* @param {event[]} membershipEvents the membership events to store
* @returns {Promise} when all members have been stored
*/
IndexedDBStore.prototype.setOutOfBandMembers = degradable(function(
roomId,
membershipEvents,
) {
MemoryStore.prototype.setOutOfBandMembers.call(this, roomId, membershipEvents);
return this.backend.setOutOfBandMembers(roomId, membershipEvents);
}, "setOutOfBandMembers");
IndexedDBStore.prototype.clearOutOfBandMembers = degradable(function(roomId) {
MemoryStore.prototype.clearOutOfBandMembers.call(this);
return this.backend.clearOutOfBandMembers(roomId);
}, "clearOutOfBandMembers");
IndexedDBStore.prototype.getClientOptions = degradable(function() {
return this.backend.getClientOptions();
}, "getClientOptions");
IndexedDBStore.prototype.storeClientOptions = degradable(function(options) {
MemoryStore.prototype.storeClientOptions.call(this, options);
return this.backend.storeClientOptions(options);
}, "storeClientOptions");
/**
* All member functions of `IndexedDBStore` that access the backend use this wrapper to
* watch for failures after initial store startup, including `QuotaExceededError` as
* free disk space changes, etc.
*
* When IndexedDB fails via any of these paths, we degrade this back to a `MemoryStore`
* in place so that the current operation and all future ones are in-memory only.
*
* @param {Function} func The degradable work to do.
* @param {String} fallback The method name for fallback.
* @returns {Function} A wrapped member function.
*/
function degradable(func, fallback) {
return async function(...args) {
try {
return await func.call(this, ...args);
} catch (e) {
logger.error("IndexedDBStore failure, degrading to MemoryStore", e);
this.emit("degraded", e);
try {
// We try to delete IndexedDB after degrading since this store is only a
// cache (the app will still function correctly without the data).
// It's possible that deleting repair IndexedDB for the next app load,
// potenially by making a little more space available.
logger.log("IndexedDBStore trying to delete degraded data");
await this.backend.clearDatabase();
logger.log("IndexedDBStore delete after degrading succeeeded");
} catch (e) {
logger.warn("IndexedDBStore delete after degrading failed", e);
}
// Degrade the store from being an instance of `IndexedDBStore` to instead be
// an instance of `MemoryStore` so that future API calls use the memory path
// directly and skip IndexedDB entirely. This should be safe as
// `IndexedDBStore` already extends from `MemoryStore`, so we are making the
// store become its parent type in a way. The mutator methods of
// `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are
// not overridden at all).
Object.setPrototypeOf(this, MemoryStore.prototype);
if (fallback) {
return await MemoryStore.prototype[fallback].call(this, ...args);
}
}
};
}

331
src/store/indexeddb.ts Normal file
View File

@@ -0,0 +1,331 @@
/*
Copyright 2017 - 2021 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/* eslint-disable @babel/no-invalid-this */
import { EventEmitter } from 'events';
import { MemoryStore, IOpts as IBaseOpts } from "./memory";
import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend.js";
import { RemoteIndexedDBStoreBackend } from "./indexeddb-remote-backend.js";
import { User } from "../models/user";
import { MatrixEvent } from "../models/event";
import { logger } from '../logger';
import { ISavedSync } from "./index";
/**
* This is an internal module. See {@link IndexedDBStore} for the public class.
* @module store/indexeddb
*/
// If this value is too small we'll be writing very often which will cause
// noticeable stop-the-world pauses. If this value is too big we'll be writing
// so infrequently that the /sync size gets bigger on reload. Writing more
// often does not affect the length of the pause since the entire /sync
// response is persisted each time.
const WRITE_DELAY_MS = 1000 * 60 * 5; // once every 5 minutes
interface IOpts extends IBaseOpts {
indexedDB: IDBFactory;
dbName?: string;
workerScript?: string;
workerApi?: typeof Worker;
}
export class IndexedDBStore extends MemoryStore {
static exists(indexedDB: IDBFactory, dbName: string): boolean {
return LocalIndexedDBStoreBackend.exists(indexedDB, dbName);
}
// TODO these should conform to one interface
public readonly backend: LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend;
private startedUp = false;
private syncTs = 0;
// Records the last-modified-time of each user at the last point we saved
// the database, such that we can derive the set if users that have been
// modified since we last saved.
private userModifiedMap: Record<string, number> = {}; // user_id : timestamp
private emitter = new EventEmitter();
/**
* Construct a new Indexed Database store, which extends MemoryStore.
*
* This store functions like a MemoryStore except it periodically persists
* the contents of the store to an IndexedDB backend.
*
* All data is still kept in-memory but can be loaded from disk by calling
* <code>startup()</code>. This can make startup times quicker as a complete
* sync from the server is not required. This does not reduce memory usage as all
* the data is eagerly fetched when <code>startup()</code> is called.
* <pre>
* let opts = { indexedDB: window.indexedDB, localStorage: window.localStorage };
* let store = new IndexedDBStore(opts);
* await store.startup(); // load from indexed db
* let client = sdk.createClient({
* store: store,
* });
* client.startClient();
* client.on("sync", function(state, prevState, data) {
* if (state === "PREPARED") {
* console.log("Started up, now with go faster stripes!");
* }
* });
* </pre>
*
* @constructor
* @extends MemoryStore
* @param {Object} opts Options object.
* @param {Object} opts.indexedDB The Indexed DB interface e.g.
* <code>window.indexedDB</code>
* @param {string=} opts.dbName Optional database name. The same name must be used
* to open the same database.
* @param {string=} opts.workerScript Optional URL to a script to invoke a web
* worker with to run IndexedDB queries on the web worker. The IndexedDbStoreWorker
* class is provided for this purpose and requires the application to provide a
* trivial wrapper script around it.
* @param {Object=} opts.workerApi The webWorker API object. If omitted, the global Worker
* object will be used if it exists.
* @prop {IndexedDBStoreBackend} backend The backend instance. Call through to
* this API if you need to perform specific indexeddb actions like deleting the
* database.
*/
constructor(opts: IOpts) {
super(opts);
if (!opts.indexedDB) {
throw new Error('Missing required option: indexedDB');
}
if (opts.workerScript) {
// try & find a webworker-compatible API
let workerApi = opts.workerApi;
if (!workerApi) {
// default to the global Worker object (which is where it in a browser)
workerApi = global.Worker;
}
this.backend = new RemoteIndexedDBStoreBackend(
opts.workerScript, opts.dbName, workerApi,
);
} else {
this.backend = new LocalIndexedDBStoreBackend(opts.indexedDB, opts.dbName);
}
}
public on = this.emitter.on.bind(this.emitter);
/**
* @return {Promise} Resolved when loaded from indexed db.
*/
public startup(): Promise<void> {
if (this.startedUp) {
logger.log(`IndexedDBStore.startup: already started`);
return Promise.resolve();
}
logger.log(`IndexedDBStore.startup: connecting to backend`);
return this.backend.connect().then(() => {
logger.log(`IndexedDBStore.startup: loading presence events`);
return this.backend.getUserPresenceEvents();
}).then((userPresenceEvents) => {
logger.log(`IndexedDBStore.startup: processing presence events`);
userPresenceEvents.forEach(([userId, rawEvent]) => {
const u = new User(userId);
if (rawEvent) {
u.setPresenceEvent(new MatrixEvent(rawEvent));
}
this.userModifiedMap[u.userId] = u.getLastModifiedTime();
this.storeUser(u);
});
});
}
/**
* @return {Promise} Resolves with a sync response to restore the
* client state to where it was at the last save, or null if there
* is no saved sync data.
*/
public getSavedSync = this.degradable((): Promise<ISavedSync> => {
return this.backend.getSavedSync();
}, "getSavedSync");
/** @return {Promise<boolean>} whether or not the database was newly created in this session. */
public isNewlyCreated = this.degradable((): Promise<boolean> => {
return this.backend.isNewlyCreated();
}, "isNewlyCreated");
/**
* @return {Promise} If there is a saved sync, the nextBatch token
* for this sync, otherwise null.
*/
public getSavedSyncToken = this.degradable((): Promise<string | null> => {
return this.backend.getNextBatchToken();
}, "getSavedSyncToken");
/**
* Delete all data from this store.
* @return {Promise} Resolves if the data was deleted from the database.
*/
public deleteAllData = this.degradable((): Promise<void> => {
super.deleteAllData();
return this.backend.clearDatabase().then(() => {
logger.log("Deleted indexeddb data.");
}, (err) => {
logger.error(`Failed to delete indexeddb data: ${err}`);
throw err;
});
});
/**
* Whether this store would like to save its data
* Note that obviously whether the store wants to save or
* not could change between calling this function and calling
* save().
*
* @return {boolean} True if calling save() will actually save
* (at the time this function is called).
*/
public wantsSave(): boolean {
const now = Date.now();
return now - this.syncTs > WRITE_DELAY_MS;
}
/**
* Possibly write data to the database.
*
* @param {boolean} force True to force a save to happen
* @return {Promise} Promise resolves after the write completes
* (or immediately if no write is performed)
*/
public save(force = false): Promise<void> {
if (force || this.wantsSave()) {
return this.reallySave();
}
return Promise.resolve();
}
private reallySave = this.degradable((): Promise<void> => {
this.syncTs = Date.now(); // set now to guard against multi-writes
// work out changed users (this doesn't handle deletions but you
// can't 'delete' users as they are just presence events).
const userTuples = [];
for (const u of this.getUsers()) {
if (this.userModifiedMap[u.userId] === u.getLastModifiedTime()) continue;
if (!u.events.presence) continue;
userTuples.push([u.userId, u.events.presence.event]);
// note that we've saved this version of the user
this.userModifiedMap[u.userId] = u.getLastModifiedTime();
}
return this.backend.syncToDatabase(userTuples);
});
public setSyncData = this.degradable((syncData: object): Promise<void> => {
return this.backend.setSyncData(syncData);
}, "setSyncData");
/**
* Returns the out-of-band membership events for this room that
* were previously loaded.
* @param {string} roomId
* @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet
*/
public getOutOfBandMembers = this.degradable((roomId: string): Promise<MatrixEvent[]> => {
return this.backend.getOutOfBandMembers(roomId);
}, "getOutOfBandMembers");
/**
* Stores the out-of-band membership events for this room. Note that
* it still makes sense to store an empty array as the OOB status for the room is
* marked as fetched, and getOutOfBandMembers will return an empty array instead of null
* @param {string} roomId
* @param {event[]} membershipEvents the membership events to store
* @returns {Promise} when all members have been stored
*/
public setOutOfBandMembers = this.degradable((roomId: string, membershipEvents: MatrixEvent[]): Promise<void> => {
super.setOutOfBandMembers(roomId, membershipEvents);
return this.backend.setOutOfBandMembers(roomId, membershipEvents);
}, "setOutOfBandMembers");
public clearOutOfBandMembers = this.degradable((roomId: string) => {
super.clearOutOfBandMembers(roomId);
return this.backend.clearOutOfBandMembers(roomId);
}, "clearOutOfBandMembers");
public getClientOptions = this.degradable((): Promise<object> => {
return this.backend.getClientOptions();
}, "getClientOptions");
public storeClientOptions = this.degradable((options: object): Promise<void> => {
super.storeClientOptions(options);
return this.backend.storeClientOptions(options);
}, "storeClientOptions");
/**
* All member functions of `IndexedDBStore` that access the backend use this wrapper to
* watch for failures after initial store startup, including `QuotaExceededError` as
* free disk space changes, etc.
*
* When IndexedDB fails via any of these paths, we degrade this back to a `MemoryStore`
* in place so that the current operation and all future ones are in-memory only.
*
* @param {Function} func The degradable work to do.
* @param {String} fallback The method name for fallback.
* @returns {Function} A wrapped member function.
*/
private degradable<A extends Array<any>, R = void>(
func: DegradableFn<A, R>,
fallback?: string,
): DegradableFn<A, R> {
const fallbackFn = super[fallback];
return async (...args) => {
try {
return func.call(this, ...args);
} catch (e) {
logger.error("IndexedDBStore failure, degrading to MemoryStore", e);
this.emitter.emit("degraded", e);
try {
// We try to delete IndexedDB after degrading since this store is only a
// cache (the app will still function correctly without the data).
// It's possible that deleting repair IndexedDB for the next app load,
// potentially by making a little more space available.
logger.log("IndexedDBStore trying to delete degraded data");
await this.backend.clearDatabase();
logger.log("IndexedDBStore delete after degrading succeeded");
} catch (e) {
logger.warn("IndexedDBStore delete after degrading failed", e);
}
// Degrade the store from being an instance of `IndexedDBStore` to instead be
// an instance of `MemoryStore` so that future API calls use the memory path
// directly and skip IndexedDB entirely. This should be safe as
// `IndexedDBStore` already extends from `MemoryStore`, so we are making the
// store become its parent type in a way. The mutator methods of
// `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are
// not overridden at all).
if (fallbackFn) {
return fallbackFn(...args);
}
}
};
}
}
type DegradableFn<A extends Array<any>, T> = (...args: A) => Promise<T>;

View File

@@ -1,8 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -22,9 +19,18 @@ limitations under the License.
* @module store/memory
*/
import { EventType } from "../@types/event";
import { Group } from "../models/group";
import { Room } from "../models/room";
import { User } from "../models/user";
import { MatrixEvent } from "../models/event";
import { RoomState } from "../models/room-state";
import { RoomMember } from "../models/room-member";
import { Filter } from "../filter";
import { ISavedSync, IStore } from "./index";
import { RoomSummary } from "../models/room-summary";
function isValidFilterId(filterId) {
function isValidFilterId(filterId: string): boolean {
const isValidStr = typeof filterId === "string" &&
!!filterId &&
filterId !== "undefined" && // exclude these as we've serialized undefined in localStorage before
@@ -33,6 +39,10 @@ function isValidFilterId(filterId) {
return isValidStr || typeof filterId === "number";
}
export interface IOpts {
localStorage?: Storage;
}
/**
* Construct a new in-memory data store for the Matrix Client.
* @constructor
@@ -40,96 +50,84 @@ function isValidFilterId(filterId) {
* @param {LocalStorage} opts.localStorage The local storage instance to persist
* some forms of data such as tokens. Rooms will NOT be stored.
*/
export function MemoryStore(opts) {
opts = opts || {};
this.rooms = {
// roomId: Room
};
this.groups = {
// groupId: Group
};
this.users = {
// userId: User
};
this.syncToken = null;
this.filters = {
// userId: {
// filterId: Filter
// }
};
this.accountData = {
// type : content
};
this.localStorage = opts.localStorage;
this._oobMembers = {
// roomId: [member events]
};
this._clientOptions = {};
}
export class MemoryStore implements IStore {
private rooms: Record<string, Room> = {}; // roomId: Room
private groups: Record<string, Group> = {}; // groupId: Group
private users: Record<string, User> = {}; // userId: User
private syncToken: string = null;
// userId: {
// filterId: Filter
// }
private filters: Record<string, Record<string, Filter>> = {};
private accountData: Record<string, MatrixEvent> = {}; // type : content
private readonly localStorage: Storage;
private oobMembers: Record<string, MatrixEvent[]> = {}; // roomId: [member events]
private clientOptions = {};
MemoryStore.prototype = {
constructor(opts: IOpts = {}) {
this.localStorage = opts.localStorage;
}
/**
* Retrieve the token to stream from.
* @return {string} The token or null.
*/
getSyncToken: function() {
public getSyncToken(): string | null {
return this.syncToken;
},
}
/** @return {Promise<bool>} whether or not the database was newly created in this session. */
isNewlyCreated: function() {
/** @return {Promise<boolean>} whether or not the database was newly created in this session. */
public isNewlyCreated(): Promise<boolean> {
return Promise.resolve(true);
},
}
/**
* Set the token to stream from.
* @param {string} token The token to stream from.
*/
setSyncToken: function(token) {
public setSyncToken(token: string) {
this.syncToken = token;
},
}
/**
* Store the given room.
* @param {Group} group The group to be stored
*/
storeGroup: function(group) {
public storeGroup(group: Group) {
this.groups[group.groupId] = group;
},
}
/**
* Retrieve a group by its group ID.
* @param {string} groupId The group ID.
* @return {Group} The group or null.
*/
getGroup: function(groupId) {
public getGroup(groupId: string): Group | null {
return this.groups[groupId] || null;
},
}
/**
* Retrieve all known groups.
* @return {Group[]} A list of groups, which may be empty.
*/
getGroups: function() {
public getGroups(): Group[] {
return Object.values(this.groups);
},
}
/**
* Store the given room.
* @param {Room} room The room to be stored. All properties must be stored.
*/
storeRoom: function(room) {
public storeRoom(room: Room) {
this.rooms[room.roomId] = room;
// add listeners for room member changes so we can keep the room member
// map up-to-date.
room.currentState.on("RoomState.members", this._onRoomMember.bind(this));
room.currentState.on("RoomState.members", this.onRoomMember);
// add existing members
const self = this;
room.currentState.getMembers().forEach(function(m) {
self._onRoomMember(null, room.currentState, m);
room.currentState.getMembers().forEach((m) => {
this.onRoomMember(null, room.currentState, m);
});
},
}
/**
* Called when a room member in a room being tracked by this store has been
@@ -138,7 +136,7 @@ MemoryStore.prototype = {
* @param {RoomState} state
* @param {RoomMember} member
*/
_onRoomMember: function(event, state, member) {
private onRoomMember = (event: MatrixEvent, state: RoomState, member: RoomMember) => {
if (member.membership === "invite") {
// We do NOT add invited members because people love to typo user IDs
// which would then show up in these lists (!)
@@ -158,70 +156,70 @@ MemoryStore.prototype = {
user.setAvatarUrl(member.events.member.getContent().avatar_url);
}
this.users[user.userId] = user;
},
};
/**
* Retrieve a room by its' room ID.
* @param {string} roomId The room ID.
* @return {Room} The room or null.
*/
getRoom: function(roomId) {
public getRoom(roomId: string): Room | null {
return this.rooms[roomId] || null;
},
}
/**
* Retrieve all known rooms.
* @return {Room[]} A list of rooms, which may be empty.
*/
getRooms: function() {
public getRooms(): Room[] {
return Object.values(this.rooms);
},
}
/**
* Permanently delete a room.
* @param {string} roomId
*/
removeRoom: function(roomId) {
public removeRoom(roomId: string): void {
if (this.rooms[roomId]) {
this.rooms[roomId].removeListener("RoomState.members", this._onRoomMember);
this.rooms[roomId].removeListener("RoomState.members", this.onRoomMember);
}
delete this.rooms[roomId];
},
}
/**
* Retrieve a summary of all the rooms.
* @return {RoomSummary[]} A summary of each room.
*/
getRoomSummaries: function() {
public getRoomSummaries(): RoomSummary[] {
return Object.values(this.rooms).map(function(room) {
return room.summary;
});
},
}
/**
* Store a User.
* @param {User} user The user to store.
*/
storeUser: function(user) {
public storeUser(user: User): void {
this.users[user.userId] = user;
},
}
/**
* Retrieve a User by its' user ID.
* @param {string} userId The user ID.
* @return {User} The user or null.
*/
getUser: function(userId) {
public getUser(userId: string): User | null {
return this.users[userId] || null;
},
}
/**
* Retrieve all known users.
* @return {User[]} A list of users, which may be empty.
*/
getUsers: function() {
public getUsers(): User[] {
return Object.values(this.users);
},
}
/**
* Retrieve scrollback for this room.
@@ -230,9 +228,9 @@ MemoryStore.prototype = {
* @return {Array<Object>} An array of objects which will be at most 'limit'
* length and at least 0. The objects are the raw event JSON.
*/
scrollback: function(room, limit) {
public scrollback(room: Room, limit: number): MatrixEvent[] {
return [];
},
}
/**
* Store events for a room. The events have already been added to the timeline
@@ -241,15 +239,15 @@ MemoryStore.prototype = {
* @param {string} token The token associated with these events.
* @param {boolean} toStart True if these are paginated results.
*/
storeEvents: function(room, events, token, toStart) {
public storeEvents(room: Room, events: MatrixEvent[], token: string, toStart: boolean) {
// no-op because they've already been added to the room instance.
},
}
/**
* Store a filter.
* @param {Filter} filter
*/
storeFilter: function(filter) {
public storeFilter(filter: Filter): void {
if (!filter) {
return;
}
@@ -257,7 +255,7 @@ MemoryStore.prototype = {
this.filters[filter.userId] = {};
}
this.filters[filter.userId][filter.filterId] = filter;
},
}
/**
* Retrieve a filter.
@@ -265,19 +263,19 @@ MemoryStore.prototype = {
* @param {string} filterId
* @return {?Filter} A filter or null.
*/
getFilter: function(userId, filterId) {
public getFilter(userId: string, filterId: string): Filter | null {
if (!this.filters[userId] || !this.filters[userId][filterId]) {
return null;
}
return this.filters[userId][filterId];
},
}
/**
* Retrieve a filter ID with the given name.
* @param {string} filterName The filter name.
* @return {?string} The filter ID or null.
*/
getFilterIdByName: function(filterName) {
public getFilterIdByName(filterName: string): string | null {
if (!this.localStorage) {
return null;
}
@@ -294,14 +292,14 @@ MemoryStore.prototype = {
}
} catch (e) {}
return null;
},
}
/**
* Set a filter name to ID mapping.
* @param {string} filterName
* @param {string} filterId
*/
setFilterIdByName: function(filterName, filterId) {
public setFilterIdByName(filterName: string, filterId: string) {
if (!this.localStorage) {
return;
}
@@ -313,7 +311,7 @@ MemoryStore.prototype = {
this.localStorage.removeItem(key);
}
} catch (e) {}
},
}
/**
* Store user-scoped account data events.
@@ -321,21 +319,20 @@ MemoryStore.prototype = {
* events with the same type will replace each other.
* @param {Array<MatrixEvent>} events The events to store.
*/
storeAccountDataEvents: function(events) {
const self = this;
events.forEach(function(event) {
self.accountData[event.getType()] = event;
public storeAccountDataEvents(events: MatrixEvent[]): void {
events.forEach((event) => {
this.accountData[event.getType()] = event;
});
},
}
/**
* Get account data event by event type
* @param {string} eventType The event type being queried
* @return {?MatrixEvent} the user account_data event of given type, if any
*/
getAccountData: function(eventType) {
public getAccountData(eventType: EventType | string): MatrixEvent | undefined {
return this.accountData[eventType];
},
}
/**
* setSyncData does nothing as there is no backing data store.
@@ -343,56 +340,56 @@ MemoryStore.prototype = {
* @param {Object} syncData The sync data
* @return {Promise} An immediately resolved promise.
*/
setSyncData: function(syncData) {
public setSyncData(syncData: object): Promise<void> {
return Promise.resolve();
},
}
/**
* We never want to save becase we have nothing to save to.
*
* @return {boolean} If the store wants to save
*/
wantsSave: function() {
public wantsSave(): boolean {
return false;
},
}
/**
* Save does nothing as there is no backing data store.
* @param {bool} force True to force a save (but the memory
* store still can't save anything)
*/
save: function(force) {},
public save(force: boolean): void {}
/**
* Startup does nothing as this store doesn't require starting up.
* @return {Promise} An immediately resolved promise.
*/
startup: function() {
public startup(): Promise<void> {
return Promise.resolve();
},
}
/**
* @return {Promise} Resolves with a sync response to restore the
* client state to where it was at the last save, or null if there
* is no saved sync data.
*/
getSavedSync: function() {
public getSavedSync(): Promise<ISavedSync> {
return Promise.resolve(null);
},
}
/**
* @return {Promise} If there is a saved sync, the nextBatch token
* for this sync, otherwise null.
*/
getSavedSyncToken: function() {
public getSavedSyncToken(): Promise<string | null> {
return Promise.resolve(null);
},
}
/**
* Delete all data from this store.
* @return {Promise} An immediately resolved promise.
*/
deleteAllData: function() {
public deleteAllData(): Promise<void> {
this.rooms = {
// roomId: Room
};
@@ -409,7 +406,7 @@ MemoryStore.prototype = {
// type : content
};
return Promise.resolve();
},
}
/**
* Returns the out-of-band membership events for this room that
@@ -418,9 +415,9 @@ MemoryStore.prototype = {
* @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet
*/
getOutOfBandMembers: function(roomId) {
return Promise.resolve(this._oobMembers[roomId] || null);
},
public getOutOfBandMembers(roomId: string): Promise<MatrixEvent[] | null> {
return Promise.resolve(this.oobMembers[roomId] || null);
}
/**
* Stores the out-of-band membership events for this room. Note that
@@ -430,22 +427,22 @@ MemoryStore.prototype = {
* @param {event[]} membershipEvents the membership events to store
* @returns {Promise} when all members have been stored
*/
setOutOfBandMembers: function(roomId, membershipEvents) {
this._oobMembers[roomId] = membershipEvents;
public setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise<void> {
this.oobMembers[roomId] = membershipEvents;
return Promise.resolve();
},
}
clearOutOfBandMembers: function() {
this._oobMembers = {};
public clearOutOfBandMembers(roomId: string): Promise<void> {
this.oobMembers = {};
return Promise.resolve();
},
}
getClientOptions: function() {
return Promise.resolve(this._clientOptions);
},
public getClientOptions(): Promise<object> {
return Promise.resolve(this.clientOptions);
}
storeClientOptions: function(options) {
this._clientOptions = Object.assign({}, options);
public storeClientOptions(options: object): Promise<void> {
this.clientOptions = Object.assign({}, options);
return Promise.resolve();
},
};
}
}

View File

@@ -1,8 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -22,124 +19,127 @@ limitations under the License.
* @module store/stub
*/
import { EventType } from "../@types/event";
import { Group } from "../models/group";
import { Room } from "../models/room";
import { User } from "../models/user";
import { MatrixEvent } from "../models/event";
import { Filter } from "../filter";
import { ISavedSync, IStore } from "./index";
import { RoomSummary } from "../models/room-summary";
/**
* Construct a stub store. This does no-ops on most store methods.
* @constructor
*/
export function StubStore() {
this.fromToken = null;
}
StubStore.prototype = {
export class StubStore implements IStore {
private fromToken: string = null;
/** @return {Promise<bool>} whether or not the database was newly created in this session. */
isNewlyCreated: function() {
public isNewlyCreated(): Promise<boolean> {
return Promise.resolve(true);
},
}
/**
* Get the sync token.
* @return {string}
*/
getSyncToken: function() {
public getSyncToken(): string | null {
return this.fromToken;
},
}
/**
* Set the sync token.
* @param {string} token
*/
setSyncToken: function(token) {
public setSyncToken(token: string) {
this.fromToken = token;
},
}
/**
* No-op.
* @param {Group} group
*/
storeGroup: function(group) {
},
public storeGroup(group: Group) {}
/**
* No-op.
* @param {string} groupId
* @return {null}
*/
getGroup: function(groupId) {
public getGroup(groupId: string): Group | null {
return null;
},
}
/**
* No-op.
* @return {Array} An empty array.
*/
getGroups: function() {
public getGroups(): Group[] {
return [];
},
}
/**
* No-op.
* @param {Room} room
*/
storeRoom: function(room) {
},
public storeRoom(room: Room) {}
/**
* No-op.
* @param {string} roomId
* @return {null}
*/
getRoom: function(roomId) {
public getRoom(roomId: string): Room | null {
return null;
},
}
/**
* No-op.
* @return {Array} An empty array.
*/
getRooms: function() {
public getRooms(): Room[] {
return [];
},
}
/**
* Permanently delete a room.
* @param {string} roomId
*/
removeRoom: function(roomId) {
public removeRoom(roomId: string) {
return;
},
}
/**
* No-op.
* @return {Array} An empty array.
*/
getRoomSummaries: function() {
public getRoomSummaries(): RoomSummary[] {
return [];
},
}
/**
* No-op.
* @param {User} user
*/
storeUser: function(user) {
},
public storeUser(user: User) {}
/**
* No-op.
* @param {string} userId
* @return {null}
*/
getUser: function(userId) {
public getUser(userId: string): User | null {
return null;
},
}
/**
* No-op.
* @return {User[]}
*/
getUsers: function() {
public getUsers(): User[] {
return [];
},
}
/**
* No-op.
@@ -147,9 +147,9 @@ StubStore.prototype = {
* @param {integer} limit
* @return {Array}
*/
scrollback: function(room, limit) {
public scrollback(room: Room, limit: number): MatrixEvent[] {
return [];
},
}
/**
* Store events for a room.
@@ -158,15 +158,13 @@ StubStore.prototype = {
* @param {string} token The token associated with these events.
* @param {boolean} toStart True if these are paginated results.
*/
storeEvents: function(room, events, token, toStart) {
},
public storeEvents(room: Room, events: MatrixEvent[], token: string, toStart: boolean) {}
/**
* Store a filter.
* @param {Filter} filter
*/
storeFilter: function(filter) {
},
public storeFilter(filter: Filter) {}
/**
* Retrieve a filter.
@@ -174,43 +172,39 @@ StubStore.prototype = {
* @param {string} filterId
* @return {?Filter} A filter or null.
*/
getFilter: function(userId, filterId) {
public getFilter(userId: string, filterId: string): Filter | null {
return null;
},
}
/**
* Retrieve a filter ID with the given name.
* @param {string} filterName The filter name.
* @return {?string} The filter ID or null.
*/
getFilterIdByName: function(filterName) {
public getFilterIdByName(filterName: string): string | null {
return null;
},
}
/**
* Set a filter name to ID mapping.
* @param {string} filterName
* @param {string} filterId
*/
setFilterIdByName: function(filterName, filterId) {
},
public setFilterIdByName(filterName: string, filterId: string) {}
/**
* Store user-scoped account data events
* @param {Array<MatrixEvent>} events The events to store.
*/
storeAccountDataEvents: function(events) {
},
public storeAccountDataEvents(events: MatrixEvent[]) {}
/**
* Get account data event by event type
* @param {string} eventType The event type being queried
*/
getAccountData: function(eventType) {
},
public getAccountData(eventType: EventType | string): MatrixEvent | undefined {
return undefined;
}
/**
* setSyncData does nothing as there is no backing data store.
@@ -218,75 +212,75 @@ StubStore.prototype = {
* @param {Object} syncData The sync data
* @return {Promise} An immediately resolved promise.
*/
setSyncData: function(syncData) {
public setSyncData(syncData: object): Promise<void> {
return Promise.resolve();
},
}
/**
* We never want to save becase we have nothing to save to.
* We never want to save because we have nothing to save to.
*
* @return {boolean} If the store wants to save
*/
wantsSave: function() {
public wantsSave(): boolean {
return false;
},
}
/**
* Save does nothing as there is no backing data store.
*/
save: function() {},
public save() {}
/**
* Startup does nothing.
* @return {Promise} An immediately resolved promise.
*/
startup: function() {
public startup(): Promise<void> {
return Promise.resolve();
},
}
/**
* @return {Promise} Resolves with a sync response to restore the
* client state to where it was at the last save, or null if there
* is no saved sync data.
*/
getSavedSync: function() {
public getSavedSync(): Promise<ISavedSync> {
return Promise.resolve(null);
},
}
/**
* @return {Promise} If there is a saved sync, the nextBatch token
* for this sync, otherwise null.
*/
getSavedSyncToken: function() {
public getSavedSyncToken(): Promise<string | null> {
return Promise.resolve(null);
},
}
/**
* Delete all data from this store. Does nothing since this store
* doesn't store anything.
* @return {Promise} An immediately resolved promise.
*/
deleteAllData: function() {
public deleteAllData(): Promise<void> {
return Promise.resolve();
},
}
getOutOfBandMembers: function() {
public getOutOfBandMembers(): Promise<MatrixEvent[]> {
return Promise.resolve(null);
},
}
setOutOfBandMembers: function() {
public setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise<void> {
return Promise.resolve();
},
}
clearOutOfBandMembers: function() {
public clearOutOfBandMembers(): Promise<void> {
return Promise.resolve();
},
}
getClientOptions: function() {
return Promise.resolve();
},
public getClientOptions(): Promise<object> {
return Promise.resolve({});
}
storeClientOptions: function() {
public storeClientOptions(options: object): Promise<void> {
return Promise.resolve();
},
};
}
}

View File

@@ -1,7 +1,5 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2017 - 2021 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.
@@ -23,6 +21,153 @@ limitations under the License.
import { logger } from './logger';
import { deepCopy } from "./utils";
import { IContent, IUnsigned } from "./models/event";
import { IRoomSummary } from "./models/room-summary";
import { EventType } from "./@types/event";
interface IOpts {
maxTimelineEntries?: number;
}
export interface IMinimalEvent {
content: IContent;
type: EventType | string;
}
export interface IEphemeral {
events: IMinimalEvent[];
}
/* eslint-disable camelcase */
interface IUnreadNotificationCounts {
highlight_count: number;
notification_count: number;
}
export interface IRoomEvent extends IMinimalEvent {
event_id: string;
sender: string;
origin_server_ts: number;
unsigned?: IUnsigned;
/** @deprecated - legacy field */
age?: number;
}
export interface IStateEvent extends IRoomEvent {
prev_content?: IContent;
state_key: string;
}
interface IState {
events: IStateEvent[];
}
export interface ITimeline {
events: Array<IRoomEvent | IStateEvent>;
limited: boolean;
prev_batch: string;
}
export interface IJoinedRoom {
summary: IRoomSummary;
state: IState;
timeline: ITimeline;
ephemeral: IEphemeral;
account_data: IAccountData;
unread_notifications: IUnreadNotificationCounts;
}
export interface IStrippedState {
content: IContent;
state_key: string;
type: EventType | string;
sender: string;
}
export interface IInviteState {
events: IStrippedState[];
}
export interface IInvitedRoom {
invite_state: IInviteState;
}
export interface ILeftRoom {
state: IState;
timeline: ITimeline;
account_data: IAccountData;
}
export interface IRooms {
[Category.Join]: Record<string, IJoinedRoom>;
[Category.Invite]: Record<string, IInvitedRoom>;
[Category.Leave]: Record<string, ILeftRoom>;
}
interface IPresence {
events: IMinimalEvent[];
}
interface IAccountData {
events: IMinimalEvent[];
}
interface IToDeviceEvent {
content: IContent;
sender: string;
type: string;
}
interface IToDevice {
events: IToDeviceEvent[];
}
interface IDeviceLists {
changed: string[];
left: string[];
}
export interface IGroups {
[Category.Join]: object;
[Category.Invite]: object;
[Category.Leave]: object;
}
export interface ISyncResponse {
next_batch: string;
rooms: IRooms;
presence?: IPresence;
account_data: IAccountData;
to_device?: IToDevice;
device_lists?: IDeviceLists;
device_one_time_keys_count?: Record<string, number>;
groups: IGroups; // unspecced
}
/* eslint-enable camelcase */
export enum Category {
Invite = "invite",
Leave = "leave",
Join = "join",
}
interface IRoom {
_currentState: { [eventType: string]: { [stateKey: string]: IStateEvent } };
_timeline: {
event: IRoomEvent | IStateEvent;
token: string | null;
}[];
_summary: Partial<IRoomSummary>;
_accountData: { [eventType: string]: IMinimalEvent };
_unreadNotifications: Partial<IUnreadNotificationCounts>;
_readReceipts: {
[userId: string]: {
data: IMinimalEvent;
eventId: string;
};
};
}
/**
* The purpose of this class is to accumulate /sync responses such that a
@@ -35,6 +180,22 @@ import { deepCopy } from "./utils";
* rather than asking the server to do an initial sync on startup.
*/
export class SyncAccumulator {
private accountData: Record<string, IMinimalEvent> = {}; // $event_type: Object
private inviteRooms: Record<string, IInvitedRoom> = {}; // $roomId: { ... sync 'invite' json data ... }
private joinRooms: { [roomId: string]: IRoom } = {};
// the /sync token which corresponds to the last time rooms were
// accumulated. We remember this so that any caller can obtain a
// coherent /sync response and know at what point they should be
// streaming from without losing events.
private nextBatch: string = null;
// { ('invite'|'join'|'leave'): $groupId: { ... sync 'group' data } }
private groups: Record<Category, object> = {
invite: {},
join: {},
leave: {},
};
/**
* @param {Object} opts
* @param {Number=} opts.maxTimelineEntries The ideal maximum number of
@@ -44,57 +205,18 @@ export class SyncAccumulator {
* never be more. This cannot be 0 or else it makes it impossible to scroll
* back in a room. Default: 50.
*/
constructor(opts) {
opts = opts || {};
opts.maxTimelineEntries = opts.maxTimelineEntries || 50;
this.opts = opts;
this.accountData = {
//$event_type: Object
};
this.inviteRooms = {
//$roomId: { ... sync 'invite' json data ... }
};
this.joinRooms = {
//$roomId: {
// _currentState: { $event_type: { $state_key: json } },
// _timeline: [
// { event: $event, token: null|token },
// { event: $event, token: null|token },
// { event: $event, token: null|token },
// ...
// ],
// _summary: {
// m.heroes: [ $user_id ],
// m.joined_member_count: $count,
// m.invited_member_count: $count
// },
// _accountData: { $event_type: json },
// _unreadNotifications: { ... unread_notifications JSON ... },
// _readReceipts: { $user_id: { data: $json, eventId: $event_id }}
//}
};
// the /sync token which corresponds to the last time rooms were
// accumulated. We remember this so that any caller can obtain a
// coherent /sync response and know at what point they should be
// streaming from without losing events.
this.nextBatch = null;
// { ('invite'|'join'|'leave'): $groupId: { ... sync 'group' data } }
this.groups = {
invite: {},
join: {},
leave: {},
};
constructor(private readonly opts: IOpts = {}) {
this.opts.maxTimelineEntries = this.opts.maxTimelineEntries || 50;
}
accumulate(syncResponse, fromDatabase) {
this._accumulateRooms(syncResponse, fromDatabase);
this._accumulateGroups(syncResponse);
this._accumulateAccountData(syncResponse);
public accumulate(syncResponse: ISyncResponse, fromDatabase = false): void {
this.accumulateRooms(syncResponse, fromDatabase);
this.accumulateGroups(syncResponse);
this.accumulateAccountData(syncResponse);
this.nextBatch = syncResponse.next_batch;
}
_accumulateAccountData(syncResponse) {
private accumulateAccountData(syncResponse: ISyncResponse): void {
if (!syncResponse.account_data || !syncResponse.account_data.events) {
return;
}
@@ -109,34 +231,31 @@ export class SyncAccumulator {
* @param {Object} syncResponse the complete /sync JSON
* @param {boolean} fromDatabase True if the sync response is one saved to the database
*/
_accumulateRooms(syncResponse, fromDatabase) {
private accumulateRooms(syncResponse: ISyncResponse, fromDatabase = false): void {
if (!syncResponse.rooms) {
return;
}
if (syncResponse.rooms.invite) {
Object.keys(syncResponse.rooms.invite).forEach((roomId) => {
this._accumulateRoom(
roomId, "invite", syncResponse.rooms.invite[roomId], fromDatabase,
);
this.accumulateRoom(roomId, Category.Invite, syncResponse.rooms.invite[roomId], fromDatabase);
});
}
if (syncResponse.rooms.join) {
Object.keys(syncResponse.rooms.join).forEach((roomId) => {
this._accumulateRoom(
roomId, "join", syncResponse.rooms.join[roomId], fromDatabase,
);
this.accumulateRoom(roomId, Category.Join, syncResponse.rooms.join[roomId], fromDatabase);
});
}
if (syncResponse.rooms.leave) {
Object.keys(syncResponse.rooms.leave).forEach((roomId) => {
this._accumulateRoom(
roomId, "leave", syncResponse.rooms.leave[roomId], fromDatabase,
);
this.accumulateRoom(roomId, Category.Leave, syncResponse.rooms.leave[roomId], fromDatabase);
});
}
}
_accumulateRoom(roomId, category, data, fromDatabase) {
private accumulateRoom(roomId: string, category: Category.Invite, data: IInvitedRoom, fromDatabase: boolean): void;
private accumulateRoom(roomId: string, category: Category.Join, data: IJoinedRoom, fromDatabase: boolean): void;
private accumulateRoom(roomId: string, category: Category.Leave, data: ILeftRoom, fromDatabase: boolean): void;
private accumulateRoom(roomId: string, category: Category, data: any, fromDatabase = false): void {
// Valid /sync state transitions
// +--------+ <======+ 1: Accept an invite
// +== | INVITE | | (5) 2: Leave a room
@@ -149,10 +268,11 @@ export class SyncAccumulator {
//
// * equivalent to "no state"
switch (category) {
case "invite": // (5)
this._accumulateInviteState(roomId, data);
case Category.Invite: // (5)
this.accumulateInviteState(roomId, data as IInvitedRoom);
break;
case "join":
case Category.Join:
if (this.inviteRooms[roomId]) { // (1)
// was previously invite, now join. We expect /sync to give
// the entire state and timeline on 'join', so delete previous
@@ -160,21 +280,23 @@ export class SyncAccumulator {
delete this.inviteRooms[roomId];
}
// (3)
this._accumulateJoinState(roomId, data, fromDatabase);
this.accumulateJoinState(roomId, data as IJoinedRoom, fromDatabase);
break;
case "leave":
case Category.Leave:
if (this.inviteRooms[roomId]) { // (4)
delete this.inviteRooms[roomId];
} else { // (2)
delete this.joinRooms[roomId];
}
break;
default:
logger.error("Unknown cateogory: ", category);
}
}
_accumulateInviteState(roomId, data) {
private accumulateInviteState(roomId: string, data: IInvitedRoom): void {
if (!data.invite_state || !data.invite_state.events) { // no new data
return;
}
@@ -204,7 +326,7 @@ export class SyncAccumulator {
}
// Accumulate timeline and state events in a room.
_accumulateJoinState(roomId, data, fromDatabase) {
private accumulateJoinState(roomId: string, data: IJoinedRoom, fromDatabase = false): void {
// We expect this function to be called a lot (every /sync) so we want
// this to be fast. /sync stores events in an array but we often want
// to clobber based on type/state_key. Rather than convert arrays to
@@ -338,7 +460,7 @@ export class SyncAccumulator {
setState(currentData._currentState, e);
// append the event to the timeline. The back-pagination token
// corresponds to the first event in the timeline
let transformedEvent;
let transformedEvent: IRoomEvent & { _localTs?: number };
if (!fromDatabase) {
transformedEvent = Object.assign({}, e);
if (transformedEvent.unsigned !== undefined) {
@@ -379,35 +501,29 @@ export class SyncAccumulator {
* Accumulate incremental /sync group data.
* @param {Object} syncResponse the complete /sync JSON
*/
_accumulateGroups(syncResponse) {
private accumulateGroups(syncResponse: ISyncResponse): void {
if (!syncResponse.groups) {
return;
}
if (syncResponse.groups.invite) {
Object.keys(syncResponse.groups.invite).forEach((groupId) => {
this._accumulateGroup(
groupId, "invite", syncResponse.groups.invite[groupId],
);
this.accumulateGroup(groupId, Category.Invite, syncResponse.groups.invite[groupId]);
});
}
if (syncResponse.groups.join) {
Object.keys(syncResponse.groups.join).forEach((groupId) => {
this._accumulateGroup(
groupId, "join", syncResponse.groups.join[groupId],
);
this.accumulateGroup(groupId, Category.Join, syncResponse.groups.join[groupId]);
});
}
if (syncResponse.groups.leave) {
Object.keys(syncResponse.groups.leave).forEach((groupId) => {
this._accumulateGroup(
groupId, "leave", syncResponse.groups.leave[groupId],
);
this.accumulateGroup(groupId, Category.Leave, syncResponse.groups.leave[groupId]);
});
}
}
_accumulateGroup(groupId, category, data) {
for (const cat of ['invite', 'join', 'leave']) {
private accumulateGroup(groupId: string, category: Category, data: object): void {
for (const cat of [Category.Invite, Category.Leave, Category.Join]) {
delete this.groups[cat][groupId];
}
this.groups[category][groupId] = data;
@@ -428,7 +544,7 @@ export class SyncAccumulator {
* /sync response from the 'rooms' key onwards. The "accountData" key is
* a list of raw events which represent global account data.
*/
getJSON(forDatabase) {
public getJSON(forDatabase = false): object {
const data = {
join: {},
invite: {},
@@ -501,14 +617,14 @@ export class SyncAccumulator {
roomJson.timeline.prev_batch = msgData.token;
}
let transformedEvent;
if (!forDatabase && msgData.event._localTs) {
let transformedEvent: (IRoomEvent | IStateEvent) & { _localTs?: number };
if (!forDatabase && msgData.event["_localTs"]) {
// This means we have to copy each event so we can fix it up to
// set a correct 'age' parameter whilst keeping the local timestamp
// on our stored event. If this turns out to be a bottleneck, it could
// be optimised either by doing this in the main process after the data
// has been structured-cloned to go between the worker & main process,
// or special-casing data from saved syncs to read the local timstamp
// or special-casing data from saved syncs to read the local timestamp
// directly rather than turning it into age to then immediately be
// transformed back again into a local timestamp.
transformedEvent = Object.assign({}, msgData.event);
@@ -517,7 +633,7 @@ export class SyncAccumulator {
}
delete transformedEvent._localTs;
transformedEvent.unsigned = transformedEvent.unsigned || {};
transformedEvent.unsigned.age = Date.now() - msgData.event._localTs;
transformedEvent.unsigned.age = Date.now() - msgData.event["_localTs"];
} else {
transformedEvent = msgData.event;
}
@@ -575,17 +691,17 @@ export class SyncAccumulator {
};
}
getNextBatchToken() {
public getNextBatchToken(): string {
return this.nextBatch;
}
}
function setState(eventMap, event) {
if (event.state_key === null || event.state_key === undefined || !event.type) {
function setState(eventMap: Record<string, Record<string, IStateEvent>>, event: IRoomEvent | IStateEvent): void {
if ((event as IStateEvent).state_key === null || (event as IStateEvent).state_key === undefined || !event.type) {
return;
}
if (!eventMap[event.type]) {
eventMap[event.type] = Object.create(null);
}
eventMap[event.type][event.state_key] = event;
eventMap[event.type][(event as IStateEvent).state_key] = event as IStateEvent;
}

File diff suppressed because it is too large Load Diff

1745
src/sync.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,521 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 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.
*/
/** @module timeline-window */
import { EventTimeline } from './models/event-timeline';
import { logger } from './logger';
/**
* @private
*/
const DEBUG = false;
/**
* @private
*/
const debuglog = DEBUG ? logger.log.bind(logger) : function() {};
/**
* the number of times we ask the server for more events before giving up
*
* @private
*/
const DEFAULT_PAGINATE_LOOP_LIMIT = 5;
/**
* Construct a TimelineWindow.
*
* <p>This abstracts the separate timelines in a Matrix {@link
* module:models/room|Room} into a single iterable thing. It keeps track of
* the start and endpoints of the window, which can be advanced with the help
* of pagination requests.
*
* <p>Before the window is useful, it must be initialised by calling {@link
* module:timeline-window~TimelineWindow#load|load}.
*
* <p>Note that the window will not automatically extend itself when new events
* are received from /sync; you should arrange to call {@link
* module:timeline-window~TimelineWindow#paginate|paginate} on {@link
* module:client~MatrixClient.event:"Room.timeline"|Room.timeline} events.
*
* @param {MatrixClient} client MatrixClient to be used for context/pagination
* requests.
*
* @param {EventTimelineSet} timelineSet The timelineSet to track
*
* @param {Object} [opts] Configuration options for this window
*
* @param {number} [opts.windowLimit = 1000] maximum number of events to keep
* in the window. If more events are retrieved via pagination requests,
* excess events will be dropped from the other end of the window.
*
* @constructor
*/
export function TimelineWindow(client, timelineSet, opts) {
opts = opts || {};
this._client = client;
this._timelineSet = timelineSet;
// these will be TimelineIndex objects; they delineate the 'start' and
// 'end' of the window.
//
// _start.index is inclusive; _end.index is exclusive.
this._start = null;
this._end = null;
this._eventCount = 0;
this._windowLimit = opts.windowLimit || 1000;
}
/**
* Initialise the window to point at a given event, or the live timeline
*
* @param {string} [initialEventId] If given, the window will contain the
* given event
* @param {number} [initialWindowSize = 20] Size of the initial window
*
* @return {Promise}
*/
TimelineWindow.prototype.load = function(initialEventId, initialWindowSize) {
const self = this;
initialWindowSize = initialWindowSize || 20;
// given an EventTimeline, find the event we were looking for, and initialise our
// fields so that the event in question is in the middle of the window.
const initFields = function(timeline) {
let eventIndex;
const events = timeline.getEvents();
if (!initialEventId) {
// we were looking for the live timeline: initialise to the end
eventIndex = events.length;
} else {
for (let i = 0; i < events.length; i++) {
if (events[i].getId() == initialEventId) {
eventIndex = i;
break;
}
}
if (eventIndex === undefined) {
throw new Error("getEventTimeline result didn't include requested event");
}
}
const endIndex = Math.min(events.length,
eventIndex + Math.ceil(initialWindowSize / 2));
const startIndex = Math.max(0, endIndex - initialWindowSize);
self._start = new TimelineIndex(timeline, startIndex - timeline.getBaseIndex());
self._end = new TimelineIndex(timeline, endIndex - timeline.getBaseIndex());
self._eventCount = endIndex - startIndex;
};
// We avoid delaying the resolution of the promise by a reactor tick if
// we already have the data we need, which is important to keep room-switching
// feeling snappy.
//
if (initialEventId) {
const timeline = this._timelineSet.getTimelineForEvent(initialEventId);
if (timeline) {
// hot-path optimization to save a reactor tick by replicating the sync check getTimelineForEvent does.
initFields(timeline);
return Promise.resolve(timeline);
}
const prom = this._client.getEventTimeline(this._timelineSet, initialEventId);
return prom.then(initFields);
} else {
const tl = this._timelineSet.getLiveTimeline();
initFields(tl);
return Promise.resolve();
}
};
/**
* Get the TimelineIndex of the window in the given direction.
*
* @param {string} direction EventTimeline.BACKWARDS to get the TimelineIndex
* at the start of the window; EventTimeline.FORWARDS to get the TimelineIndex at
* the end.
*
* @return {TimelineIndex} The requested timeline index if one exists, null
* otherwise.
*/
TimelineWindow.prototype.getTimelineIndex = function(direction) {
if (direction == EventTimeline.BACKWARDS) {
return this._start;
} else if (direction == EventTimeline.FORWARDS) {
return this._end;
} else {
throw new Error("Invalid direction '" + direction + "'");
}
};
/**
* Try to extend the window using events that are already in the underlying
* TimelineIndex.
*
* @param {string} direction EventTimeline.BACKWARDS to try extending it
* backwards; EventTimeline.FORWARDS to try extending it forwards.
* @param {number} size number of events to try to extend by.
*
* @return {boolean} true if the window was extended, false otherwise.
*/
TimelineWindow.prototype.extend = function(direction, size) {
const tl = this.getTimelineIndex(direction);
if (!tl) {
debuglog("TimelineWindow: no timeline yet");
return false;
}
const count = (direction == EventTimeline.BACKWARDS) ?
tl.retreat(size) : tl.advance(size);
if (count) {
this._eventCount += count;
debuglog("TimelineWindow: increased cap by " + count +
" (now " + this._eventCount + ")");
// remove some events from the other end, if necessary
const excess = this._eventCount - this._windowLimit;
if (excess > 0) {
this.unpaginate(excess, direction != EventTimeline.BACKWARDS);
}
return true;
}
return false;
};
/**
* Check if this window can be extended
*
* <p>This returns true if we either have more events, or if we have a
* pagination token which means we can paginate in that direction. It does not
* necessarily mean that there are more events available in that direction at
* this time.
*
* @param {string} direction EventTimeline.BACKWARDS to check if we can
* paginate backwards; EventTimeline.FORWARDS to check if we can go forwards
*
* @return {boolean} true if we can paginate in the given direction
*/
TimelineWindow.prototype.canPaginate = function(direction) {
const tl = this.getTimelineIndex(direction);
if (!tl) {
debuglog("TimelineWindow: no timeline yet");
return false;
}
if (direction == EventTimeline.BACKWARDS) {
if (tl.index > tl.minIndex()) {
return true;
}
} else {
if (tl.index < tl.maxIndex()) {
return true;
}
}
return Boolean(tl.timeline.getNeighbouringTimeline(direction) ||
tl.timeline.getPaginationToken(direction));
};
/**
* Attempt to extend the window
*
* @param {string} direction EventTimeline.BACKWARDS to extend the window
* backwards (towards older events); EventTimeline.FORWARDS to go forwards.
*
* @param {number} size number of events to try to extend by. If fewer than this
* number are immediately available, then we return immediately rather than
* making an API call.
*
* @param {boolean} [makeRequest = true] whether we should make API calls to
* fetch further events if we don't have any at all. (This has no effect if
* the room already knows about additional events in the relevant direction,
* even if there are fewer than 'size' of them, as we will just return those
* we already know about.)
*
* @param {number} [requestLimit = 5] limit for the number of API requests we
* should make.
*
* @return {Promise} Resolves to a boolean which is true if more events
* were successfully retrieved.
*/
TimelineWindow.prototype.paginate = function(direction, size, makeRequest,
requestLimit) {
// Either wind back the message cap (if there are enough events in the
// timeline to do so), or fire off a pagination request.
if (makeRequest === undefined) {
makeRequest = true;
}
if (requestLimit === undefined) {
requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT;
}
const tl = this.getTimelineIndex(direction);
if (!tl) {
debuglog("TimelineWindow: no timeline yet");
return Promise.resolve(false);
}
if (tl.pendingPaginate) {
return tl.pendingPaginate;
}
// try moving the cap
if (this.extend(direction, size)) {
return Promise.resolve(true);
}
if (!makeRequest || requestLimit === 0) {
// todo: should we return something different to indicate that there
// might be more events out there, but we haven't found them yet?
return Promise.resolve(false);
}
// try making a pagination request
const token = tl.timeline.getPaginationToken(direction);
if (!token) {
debuglog("TimelineWindow: no token");
return Promise.resolve(false);
}
debuglog("TimelineWindow: starting request");
const self = this;
const prom = this._client.paginateEventTimeline(tl.timeline, {
backwards: direction == EventTimeline.BACKWARDS,
limit: size,
}).finally(function() {
tl.pendingPaginate = null;
}).then(function(r) {
debuglog("TimelineWindow: request completed with result " + r);
if (!r) {
// end of timeline
return false;
}
// recurse to advance the index into the results.
//
// If we don't get any new events, we want to make sure we keep asking
// the server for events for as long as we have a valid pagination
// token. In particular, we want to know if we've actually hit the
// start of the timeline, or if we just happened to know about all of
// the events thanks to https://matrix.org/jira/browse/SYN-645.
//
// On the other hand, we necessarily want to wait forever for the
// server to make its mind up about whether there are other events,
// because it gives a bad user experience
// (https://github.com/vector-im/vector-web/issues/1204).
return self.paginate(direction, size, true, requestLimit - 1);
});
tl.pendingPaginate = prom;
return prom;
};
/**
* Remove `delta` events from the start or end of the timeline.
*
* @param {number} delta number of events to remove from the timeline
* @param {boolean} startOfTimeline if events should be removed from the start
* of the timeline.
*/
TimelineWindow.prototype.unpaginate = function(delta, startOfTimeline) {
const tl = startOfTimeline ? this._start : this._end;
// sanity-check the delta
if (delta > this._eventCount || delta < 0) {
throw new Error("Attemting to unpaginate " + delta + " events, but " +
"only have " + this._eventCount + " in the timeline");
}
while (delta > 0) {
const count = startOfTimeline ? tl.advance(delta) : tl.retreat(delta);
if (count <= 0) {
// sadness. This shouldn't be possible.
throw new Error(
"Unable to unpaginate any further, but still have " +
this._eventCount + " events");
}
delta -= count;
this._eventCount -= count;
debuglog("TimelineWindow.unpaginate: dropped " + count +
" (now " + this._eventCount + ")");
}
};
/**
* Get a list of the events currently in the window
*
* @return {MatrixEvent[]} the events in the window
*/
TimelineWindow.prototype.getEvents = function() {
if (!this._start) {
// not yet loaded
return [];
}
const result = [];
// iterate through each timeline between this._start and this._end
// (inclusive).
let timeline = this._start.timeline;
while (true) {
const events = timeline.getEvents();
// For the first timeline in the chain, we want to start at
// this._start.index. For the last timeline in the chain, we want to
// stop before this._end.index. Otherwise, we want to copy all of the
// events in the timeline.
//
// (Note that both this._start.index and this._end.index are relative
// to their respective timelines' BaseIndex).
//
let startIndex = 0;
let endIndex = events.length;
if (timeline === this._start.timeline) {
startIndex = this._start.index + timeline.getBaseIndex();
}
if (timeline === this._end.timeline) {
endIndex = this._end.index + timeline.getBaseIndex();
}
for (let i = startIndex; i < endIndex; i++) {
result.push(events[i]);
}
// if we're not done, iterate to the next timeline.
if (timeline === this._end.timeline) {
break;
} else {
timeline = timeline.getNeighbouringTimeline(EventTimeline.FORWARDS);
}
}
return result;
};
/**
* a thing which contains a timeline reference, and an index into it.
*
* @constructor
* @param {EventTimeline} timeline
* @param {number} index
* @private
*/
export function TimelineIndex(timeline, index) {
this.timeline = timeline;
// the indexes are relative to BaseIndex, so could well be negative.
this.index = index;
}
/**
* @return {number} the minimum possible value for the index in the current
* timeline
*/
TimelineIndex.prototype.minIndex = function() {
return this.timeline.getBaseIndex() * -1;
};
/**
* @return {number} the maximum possible value for the index in the current
* timeline (exclusive - ie, it actually returns one more than the index
* of the last element).
*/
TimelineIndex.prototype.maxIndex = function() {
return this.timeline.getEvents().length - this.timeline.getBaseIndex();
};
/**
* Try move the index forward, or into the neighbouring timeline
*
* @param {number} delta number of events to advance by
* @return {number} number of events successfully advanced by
*/
TimelineIndex.prototype.advance = function(delta) {
if (!delta) {
return 0;
}
// first try moving the index in the current timeline. See if there is room
// to do so.
let cappedDelta;
if (delta < 0) {
// we want to wind the index backwards.
//
// (this.minIndex() - this.index) is a negative number whose magnitude
// is the amount of room we have to wind back the index in the current
// timeline. We cap delta to this quantity.
cappedDelta = Math.max(delta, this.minIndex() - this.index);
if (cappedDelta < 0) {
this.index += cappedDelta;
return cappedDelta;
}
} else {
// we want to wind the index forwards.
//
// (this.maxIndex() - this.index) is a (positive) number whose magnitude
// is the amount of room we have to wind forward the index in the current
// timeline. We cap delta to this quantity.
cappedDelta = Math.min(delta, this.maxIndex() - this.index);
if (cappedDelta > 0) {
this.index += cappedDelta;
return cappedDelta;
}
}
// the index is already at the start/end of the current timeline.
//
// next see if there is a neighbouring timeline to switch to.
const neighbour = this.timeline.getNeighbouringTimeline(
delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS);
if (neighbour) {
this.timeline = neighbour;
if (delta < 0) {
this.index = this.maxIndex();
} else {
this.index = this.minIndex();
}
debuglog("paginate: switched to new neighbour");
// recurse, using the next timeline
return this.advance(delta);
}
return 0;
};
/**
* Try move the index backwards, or into the neighbouring timeline
*
* @param {number} delta number of events to retreat by
* @return {number} number of events successfully retreated by
*/
TimelineIndex.prototype.retreat = function(delta) {
return this.advance(delta * -1) * -1;
};

522
src/timeline-window.ts Normal file
View File

@@ -0,0 +1,522 @@
/*
Copyright 2016 - 2021 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.
*/
/** @module timeline-window */
import { Direction, EventTimeline } from './models/event-timeline';
import { logger } from './logger';
import { MatrixClient } from "./client";
import { EventTimelineSet } from "./models/event-timeline-set";
import { MatrixEvent } from "./models/event";
/**
* @private
*/
const DEBUG = false;
/**
* @private
*/
const debuglog = DEBUG ? logger.log.bind(logger) : function() {};
/**
* the number of times we ask the server for more events before giving up
*
* @private
*/
const DEFAULT_PAGINATE_LOOP_LIMIT = 5;
interface IOpts {
windowLimit?: number;
}
export class TimelineWindow {
private readonly windowLimit: number;
// these will be TimelineIndex objects; they delineate the 'start' and
// 'end' of the window.
//
// start.index is inclusive; end.index is exclusive.
private start?: TimelineIndex = null;
private end?: TimelineIndex = null;
private eventCount = 0;
/**
* Construct a TimelineWindow.
*
* <p>This abstracts the separate timelines in a Matrix {@link
* module:models/room|Room} into a single iterable thing. It keeps track of
* the start and endpoints of the window, which can be advanced with the help
* of pagination requests.
*
* <p>Before the window is useful, it must be initialised by calling {@link
* module:timeline-window~TimelineWindow#load|load}.
*
* <p>Note that the window will not automatically extend itself when new events
* are received from /sync; you should arrange to call {@link
* module:timeline-window~TimelineWindow#paginate|paginate} on {@link
* module:client~MatrixClient.event:"Room.timeline"|Room.timeline} events.
*
* @param {MatrixClient} client MatrixClient to be used for context/pagination
* requests.
*
* @param {EventTimelineSet} timelineSet The timelineSet to track
*
* @param {Object} [opts] Configuration options for this window
*
* @param {number} [opts.windowLimit = 1000] maximum number of events to keep
* in the window. If more events are retrieved via pagination requests,
* excess events will be dropped from the other end of the window.
*
* @constructor
*/
constructor(
private readonly client: MatrixClient,
private readonly timelineSet: EventTimelineSet,
opts: IOpts = {},
) {
this.windowLimit = opts.windowLimit || 1000;
}
/**
* Initialise the window to point at a given event, or the live timeline
*
* @param {string} [initialEventId] If given, the window will contain the
* given event
* @param {number} [initialWindowSize = 20] Size of the initial window
*
* @return {Promise}
*/
public load(initialEventId: string, initialWindowSize = 20): Promise<any> {
// given an EventTimeline, find the event we were looking for, and initialise our
// fields so that the event in question is in the middle of the window.
const initFields = (timeline: EventTimeline) => {
let eventIndex;
const events = timeline.getEvents();
if (!initialEventId) {
// we were looking for the live timeline: initialise to the end
eventIndex = events.length;
} else {
for (let i = 0; i < events.length; i++) {
if (events[i].getId() == initialEventId) {
eventIndex = i;
break;
}
}
if (eventIndex === undefined) {
throw new Error("getEventTimeline result didn't include requested event");
}
}
const endIndex = Math.min(events.length,
eventIndex + Math.ceil(initialWindowSize / 2));
const startIndex = Math.max(0, endIndex - initialWindowSize);
this.start = new TimelineIndex(timeline, startIndex - timeline.getBaseIndex());
this.end = new TimelineIndex(timeline, endIndex - timeline.getBaseIndex());
this.eventCount = endIndex - startIndex;
};
// We avoid delaying the resolution of the promise by a reactor tick if
// we already have the data we need, which is important to keep room-switching
// feeling snappy.
//
if (initialEventId) {
const timeline = this.timelineSet.getTimelineForEvent(initialEventId);
if (timeline) {
// hot-path optimization to save a reactor tick by replicating the sync check getTimelineForEvent does.
initFields(timeline);
return Promise.resolve(timeline);
}
const prom = this.client.getEventTimeline(this.timelineSet, initialEventId);
return prom.then(initFields);
} else {
const tl = this.timelineSet.getLiveTimeline();
initFields(tl);
return Promise.resolve();
}
}
/**
* Get the TimelineIndex of the window in the given direction.
*
* @param {string} direction EventTimeline.BACKWARDS to get the TimelineIndex
* at the start of the window; EventTimeline.FORWARDS to get the TimelineIndex at
* the end.
*
* @return {TimelineIndex} The requested timeline index if one exists, null
* otherwise.
*/
public getTimelineIndex(direction: Direction): TimelineIndex {
if (direction == EventTimeline.BACKWARDS) {
return this.start;
} else if (direction == EventTimeline.FORWARDS) {
return this.end;
} else {
throw new Error("Invalid direction '" + direction + "'");
}
}
/**
* Try to extend the window using events that are already in the underlying
* TimelineIndex.
*
* @param {string} direction EventTimeline.BACKWARDS to try extending it
* backwards; EventTimeline.FORWARDS to try extending it forwards.
* @param {number} size number of events to try to extend by.
*
* @return {boolean} true if the window was extended, false otherwise.
*/
public extend(direction: Direction, size: number): boolean {
const tl = this.getTimelineIndex(direction);
if (!tl) {
debuglog("TimelineWindow: no timeline yet");
return false;
}
const count = (direction == EventTimeline.BACKWARDS) ?
tl.retreat(size) : tl.advance(size);
if (count) {
this.eventCount += count;
debuglog("TimelineWindow: increased cap by " + count +
" (now " + this.eventCount + ")");
// remove some events from the other end, if necessary
const excess = this.eventCount - this.windowLimit;
if (excess > 0) {
this.unpaginate(excess, direction != EventTimeline.BACKWARDS);
}
return true;
}
return false;
}
/**
* Check if this window can be extended
*
* <p>This returns true if we either have more events, or if we have a
* pagination token which means we can paginate in that direction. It does not
* necessarily mean that there are more events available in that direction at
* this time.
*
* @param {string} direction EventTimeline.BACKWARDS to check if we can
* paginate backwards; EventTimeline.FORWARDS to check if we can go forwards
*
* @return {boolean} true if we can paginate in the given direction
*/
public canPaginate(direction: Direction): boolean {
const tl = this.getTimelineIndex(direction);
if (!tl) {
debuglog("TimelineWindow: no timeline yet");
return false;
}
if (direction == EventTimeline.BACKWARDS) {
if (tl.index > tl.minIndex()) {
return true;
}
} else {
if (tl.index < tl.maxIndex()) {
return true;
}
}
return Boolean(tl.timeline.getNeighbouringTimeline(direction) ||
tl.timeline.getPaginationToken(direction));
}
/**
* Attempt to extend the window
*
* @param {string} direction EventTimeline.BACKWARDS to extend the window
* backwards (towards older events); EventTimeline.FORWARDS to go forwards.
*
* @param {number} size number of events to try to extend by. If fewer than this
* number are immediately available, then we return immediately rather than
* making an API call.
*
* @param {boolean} [makeRequest = true] whether we should make API calls to
* fetch further events if we don't have any at all. (This has no effect if
* the room already knows about additional events in the relevant direction,
* even if there are fewer than 'size' of them, as we will just return those
* we already know about.)
*
* @param {number} [requestLimit = 5] limit for the number of API requests we
* should make.
*
* @return {Promise} Resolves to a boolean which is true if more events
* were successfully retrieved.
*/
public paginate(
direction: Direction,
size: number,
makeRequest = true,
requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT,
): Promise<boolean> {
// Either wind back the message cap (if there are enough events in the
// timeline to do so), or fire off a pagination request.
const tl = this.getTimelineIndex(direction);
if (!tl) {
debuglog("TimelineWindow: no timeline yet");
return Promise.resolve(false);
}
if (tl.pendingPaginate) {
return tl.pendingPaginate;
}
// try moving the cap
if (this.extend(direction, size)) {
return Promise.resolve(true);
}
if (!makeRequest || requestLimit === 0) {
// todo: should we return something different to indicate that there
// might be more events out there, but we haven't found them yet?
return Promise.resolve(false);
}
// try making a pagination request
const token = tl.timeline.getPaginationToken(direction);
if (!token) {
debuglog("TimelineWindow: no token");
return Promise.resolve(false);
}
debuglog("TimelineWindow: starting request");
const prom = this.client.paginateEventTimeline(tl.timeline, {
backwards: direction == EventTimeline.BACKWARDS,
limit: size,
}).finally(function() {
tl.pendingPaginate = null;
}).then((r) => {
debuglog("TimelineWindow: request completed with result " + r);
if (!r) {
// end of timeline
return false;
}
// recurse to advance the index into the results.
//
// If we don't get any new events, we want to make sure we keep asking
// the server for events for as long as we have a valid pagination
// token. In particular, we want to know if we've actually hit the
// start of the timeline, or if we just happened to know about all of
// the events thanks to https://matrix.org/jira/browse/SYN-645.
//
// On the other hand, we necessarily want to wait forever for the
// server to make its mind up about whether there are other events,
// because it gives a bad user experience
// (https://github.com/vector-im/vector-web/issues/1204).
return this.paginate(direction, size, true, requestLimit - 1);
});
tl.pendingPaginate = prom;
return prom;
}
/**
* Remove `delta` events from the start or end of the timeline.
*
* @param {number} delta number of events to remove from the timeline
* @param {boolean} startOfTimeline if events should be removed from the start
* of the timeline.
*/
public unpaginate(delta: number, startOfTimeline: boolean): void {
const tl = startOfTimeline ? this.start : this.end;
// sanity-check the delta
if (delta > this.eventCount || delta < 0) {
throw new Error("Attemting to unpaginate " + delta + " events, but " +
"only have " + this.eventCount + " in the timeline");
}
while (delta > 0) {
const count = startOfTimeline ? tl.advance(delta) : tl.retreat(delta);
if (count <= 0) {
// sadness. This shouldn't be possible.
throw new Error(
"Unable to unpaginate any further, but still have " +
this.eventCount + " events");
}
delta -= count;
this.eventCount -= count;
debuglog("TimelineWindow.unpaginate: dropped " + count +
" (now " + this.eventCount + ")");
}
}
/**
* Get a list of the events currently in the window
*
* @return {MatrixEvent[]} the events in the window
*/
public getEvents(): MatrixEvent[] {
if (!this.start) {
// not yet loaded
return [];
}
const result = [];
// iterate through each timeline between this.start and this.end
// (inclusive).
let timeline = this.start.timeline;
// eslint-disable-next-line no-constant-condition
while (true) {
const events = timeline.getEvents();
// For the first timeline in the chain, we want to start at
// this.start.index. For the last timeline in the chain, we want to
// stop before this.end.index. Otherwise, we want to copy all of the
// events in the timeline.
//
// (Note that both this.start.index and this.end.index are relative
// to their respective timelines' BaseIndex).
//
let startIndex = 0;
let endIndex = events.length;
if (timeline === this.start.timeline) {
startIndex = this.start.index + timeline.getBaseIndex();
}
if (timeline === this.end.timeline) {
endIndex = this.end.index + timeline.getBaseIndex();
}
for (let i = startIndex; i < endIndex; i++) {
result.push(events[i]);
}
// if we're not done, iterate to the next timeline.
if (timeline === this.end.timeline) {
break;
} else {
timeline = timeline.getNeighbouringTimeline(EventTimeline.FORWARDS);
}
}
return result;
}
}
/**
* a thing which contains a timeline reference, and an index into it.
*
* @constructor
* @param {EventTimeline} timeline
* @param {number} index
* @private
*/
export class TimelineIndex {
public pendingPaginate?: Promise<boolean>;
// index: the indexes are relative to BaseIndex, so could well be negative.
constructor(public timeline: EventTimeline, public index: number) {}
/**
* @return {number} the minimum possible value for the index in the current
* timeline
*/
public minIndex(): number {
return this.timeline.getBaseIndex() * -1;
}
/**
* @return {number} the maximum possible value for the index in the current
* timeline (exclusive - ie, it actually returns one more than the index
* of the last element).
*/
public maxIndex(): number {
return this.timeline.getEvents().length - this.timeline.getBaseIndex();
}
/**
* Try move the index forward, or into the neighbouring timeline
*
* @param {number} delta number of events to advance by
* @return {number} number of events successfully advanced by
*/
public advance(delta: number): number {
if (!delta) {
return 0;
}
// first try moving the index in the current timeline. See if there is room
// to do so.
let cappedDelta;
if (delta < 0) {
// we want to wind the index backwards.
//
// (this.minIndex() - this.index) is a negative number whose magnitude
// is the amount of room we have to wind back the index in the current
// timeline. We cap delta to this quantity.
cappedDelta = Math.max(delta, this.minIndex() - this.index);
if (cappedDelta < 0) {
this.index += cappedDelta;
return cappedDelta;
}
} else {
// we want to wind the index forwards.
//
// (this.maxIndex() - this.index) is a (positive) number whose magnitude
// is the amount of room we have to wind forward the index in the current
// timeline. We cap delta to this quantity.
cappedDelta = Math.min(delta, this.maxIndex() - this.index);
if (cappedDelta > 0) {
this.index += cappedDelta;
return cappedDelta;
}
}
// the index is already at the start/end of the current timeline.
//
// next see if there is a neighbouring timeline to switch to.
const neighbour = this.timeline.getNeighbouringTimeline(
delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS);
if (neighbour) {
this.timeline = neighbour;
if (delta < 0) {
this.index = this.maxIndex();
} else {
this.index = this.minIndex();
}
debuglog("paginate: switched to new neighbour");
// recurse, using the next timeline
return this.advance(delta);
}
return 0;
}
/**
* Try move the index backwards, or into the neighbouring timeline
*
* @param {number} delta number of events to retreat by
* @return {number} number of events successfully retreated by
*/
public retreat(delta: number): number {
return this.advance(delta * -1) * -1;
}
}

Some files were not shown because too many files have changed in this diff Show More