Compare commits

...

34 Commits

Author SHA1 Message Date
RiotRobot f576a9f2e9 v19.3.0-rc.1 2022-08-09 17:03:34 +01:00
RiotRobot fed121b0aa Prepare changelog for v19.3.0-rc.1 2022-08-09 17:03:34 +01:00
3nprob 3e37c74264 Document where new linting rules go (#2573) 2022-08-09 09:59:17 +02:00
Faye Duxovni 3762c20aad Revert "Always block sending keys to unverified devices of verified users (#2562)" (#2571)
This will be rolled out again later with more accompanying UI adjustments, including clearer error messages and possibly the option to disable it per-room.
2022-08-08 12:27:41 -04:00
Matthew Hodgson c96f1ba22b Merge pull request #2569 from matrix-org/matthew/avoid-sync-overlaps
Don't load the sync accumulator if there's already a sync persist in flight
2022-08-08 10:18:39 +01:00
Matthew Hodgson 43c81358b2 don't load the sync accumulator if there's already a sync persist in flight
this should hopefully reduce chances of
https://github.com/vector-im/element-web/issues/21541 a bit more
as we were incorrectly loading the sync accumulator even
if a sync persist was already in flight, thus wasting RAM
and increasing the chance of the renderer process OOMing
2022-08-07 01:13:06 +01:00
Šimon Brandner 6316a6ae44 Add support for stable prefixes for MSC2285 (#2524)
Co-authored-by: Travis Ralston <travisr@matrix.org>
2022-08-05 17:33:49 +02:00
David Baker 575b416856 Simplify encryptAndSendToDevices (#2566)
It went to quite a lot of effort to gather a bunch of information to
return, but the only thing using it already had all that info anyway.
2022-08-05 15:58:33 +01:00
David Baker 7b7f8c1592 Increase timeout to try & avoid flakiness in queueToDevice test (#2565)
https://github.com/matrix-org/matrix-js-sdk/issues/2561
2022-08-05 15:35:29 +01:00
David Baker 3907d1c28f Fix output after test finished (#2564)
* Fix output after test finished

As per comment

* Add more flushes
2022-08-04 17:28:42 +01:00
Robin c629d2f60e Emit an event when the client receives TURN servers (#2529)
* Emit an event when the client receives TURN servers

* Add tests

* Fix lints
2022-08-04 11:44:10 -04:00
Faye Duxovni 43b453804b Always block sending keys to unverified devices of verified users (#2562) 2022-08-04 11:11:12 -04:00
Šimon Brandner d867affc40 Remove stream-replacement (#2551) 2022-08-03 21:45:37 +02:00
Robin c36bfc821c Add support for sending user-defined encrypted to-device messages (#2528)
* Add support for sending user-defined encrypted to-device messages

This is a port of the same change from the robertlong/group-call branch.

* Fix tests

* Expose the method in MatrixClient

* Fix a code smell

* Fix types

* Test the MatrixClient method

* Fix some types in Crypto test suite

* Test the Crypto method

* Fix tests

* Upgrade matrix-mock-request

* Move useRealTimers to afterEach
2022-08-03 16:16:48 +00:00
David Baker 7e784da00a Retry to-device messages (#2549)
* Retry to-device messages

This adds a queueToDevice API alongside sendToDevice which is a
much higher-level API that adds the messages to a queue, stored in
persistent storage, and retries them periodically. Also converts
sending of megolm keys to use the new API.

Other uses of sendToDevice are nopt converted in this PR, but could
be later.

Requires https://github.com/matrix-org/matrix-mock-request/pull/17

* Bump matrix-mock-request

* Add more waits to make indexeddb tests pass

* Switch some test expectations to queueToDevice

* Stop straight away if the client has been stopped

Hopefully will fix tests being flakey and logging after tests have
finished.

* Add return types & fix constant usage

* Fix return type

Co-authored-by: Germain <germains@element.io>

* Fix return type

Co-authored-by: Germain <germains@element.io>

* Fix return type

Co-authored-by: Germain <germains@element.io>

* Stop the client in all test cases

Co-authored-by: Germain <germains@element.io>
2022-08-03 13:32:58 +01:00
Germain b79f469008 Use EventType enum values instead of hardcoded strings (#2557) 2022-08-03 08:54:11 +00:00
RiotRobot cf33569a21 Resetting package fields for development 2022-08-02 17:01:12 +01:00
RiotRobot fb0a0c66c8 Merge branch 'master' into develop 2022-08-02 17:01:11 +01:00
kegsay f0991348e2 Merge pull request #2555 from matrix-org/kegan/sync-v3
Sliding sync: add missing filters from latest MSC
2022-08-02 14:54:37 +01:00
Kegan Dougal fa6708c27e Gracefully handle missing room_ids 2022-08-01 16:34:11 +01:00
Kegan Dougal 4427201326 Sliding sync: add missing filters from latest MSC 2022-08-01 16:30:33 +01:00
Kerry 4a4241806e test typescriptification - autodiscovery / crypto specs (#2550)
* spec/unit/autodiscovery.spec.js -> spec/unit/autodiscovery.spec.ts

* fix ts in autodiscovery.spec

* renamed:    spec/unit/crypto.spec.js -> spec/unit/crypto.spec.ts

* fix ts in crypto.spec

* fix some strict errors
2022-07-29 09:11:01 +00:00
David Baker 3824f65d15 Prevent double mute status changed events (#2502) (#2522)
Audio & video mute status were set in separate calls but share a
mute status changed event, so you'd always get two mute status
changed events emitted. We could suppress events where the mute
status didn't change, but this would still get two events saying
the same thing when they both changed. Instead, merge setAudioMuted
& setVideoMuted into a single call that sets either or both.

Port of https://github.com/matrix-org/matrix-js-sdk/pull/2502 from
group call branch
2022-07-28 16:13:00 +01:00
Michael Telatynski 3c17e4a6d6 Use consolidated Renovate config (#2548)
* Delete renovate.json

* Create renovate.json
2022-07-28 08:13:02 +02:00
Kerry 75513d08de test typescriptification - misc (#2547)
* renamed:    spec/unit/login.spec.js -> spec/unit/login.spec.ts

* type test client

* renamed:    spec/unit/interactive-auth.spec.js -> spec/unit/interactive-auth.spec.ts

* fix ts issues in interactive-auth.spec

* renamed:    spec/unit/filter.spec.js -> spec/unit/filter.spec.ts

* fix ts in filter.spec

* renamed:    spec/unit/event.spec.js -> spec/unit/event.spec.ts

* ts in event.spec

* renamed:    spec/unit/pushprocessor.spec.js -> spec/unit/pushprocessor.spec.ts

* fix ts in pushprocessor.spec

* fix ts in realtime-callbacks.spec

* renamed:    spec/unit/content-repo.spec.js -> spec/unit/content-repo.spec.ts

* fix signature for getHttpUriForMxc

* pr fixes
2022-07-28 08:09:21 +02:00
Šimon Brandner 7cb3b40493 Use stable prefixes for MSC3827 (#2537) 2022-07-27 20:10:39 +02:00
Kerry ab89804c55 test typescriptification: unit/crypto/algorithm specs (#2538)
* typescriptify megolm.spec

* add copyright

* renamed:    spec/unit/crypto/algorithms/olm.spec.js -> spec/unit/crypto/algorithms/olm.spec.ts

* fix ts issues in olm.spec

* remove comment

* more types in megolm and olm specs
2022-07-27 17:43:17 +02:00
renovate[bot] ab6cf93c2b Lock file maintenance (#2546)
* Lock file maintenance

* Empty commit to retry CI

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Robin Townsend <robin@robin.town>
2022-07-27 15:17:00 +00:00
Kerry 4c80762e22 test typescriptification - timeline-window, scheduler, etc (#2539)
* spec/unit/user.spec.js -> spec/unit/user.spec.ts

* fix ts in user.spec

* renamed:    spec/unit/timeline-window.spec.js -> spec/unit/timeline-window.spec.ts

* overdo it fixing types in timeline-window.spec

* renamed spec/unit/sync-accumulator.spec.js spec/unit/sync-accumulator.spec.ts

* fix ts in sync-accumalator.spec

* spec/unit/scheduler.spec.js -> spec/unit/scheduler.spec.ts

* fix ts in scheduler.spec

* missed types in timeline-window spec
2022-07-27 15:10:20 +00:00
Michael Telatynski 1f7e80c68d Require confirmation when doing proper release when intending to make an RC (#2540) 2022-07-27 09:12:57 +01:00
renovate[bot] e91b879a69 Update typescript-eslint monorepo to v5.31.0 (#2544)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-07-26 17:45:10 +00:00
renovate[bot] 14885ba7a2 Update dependency @types/jest to v28.1.6 (#2543)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-07-26 17:37:13 +00:00
renovate[bot] 0dda187d96 Update all (#2541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-07-26 17:30:10 +00:00
renovate[bot] 680d8cac4d Update babel monorepo to v7.18.9 (#2542)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-07-26 18:16:53 +01:00
53 changed files with 2929 additions and 1673 deletions
+2
View File
@@ -9,6 +9,8 @@ module.exports = {
browser: true,
node: true,
},
// NOTE: These rules are frozen and new rules should not be added here.
// New changes belong in https://github.com/matrix-org/eslint-plugin-matrix-org/
rules: {
"no-var": ["warn"],
"prefer-rest-params": ["warn"],
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"github>matrix-org/renovate-config-element-web"
]
}
+16
View File
@@ -1,3 +1,19 @@
Changes in [19.3.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.3.0-rc.1) (2022-08-09)
============================================================================================================
## ✨ Features
* Emit an event when the client receives TURN servers ([\#2529](https://github.com/matrix-org/matrix-js-sdk/pull/2529)).
* Add support for stable prefixes for MSC2285 ([\#2524](https://github.com/matrix-org/matrix-js-sdk/pull/2524)).
* Always block sending keys to unverified devices of verified users ([\#2562](https://github.com/matrix-org/matrix-js-sdk/pull/2562)). Contributed by @duxovni.
* Remove stream-replacement ([\#2551](https://github.com/matrix-org/matrix-js-sdk/pull/2551)).
* Add support for sending user-defined encrypted to-device messages ([\#2528](https://github.com/matrix-org/matrix-js-sdk/pull/2528)).
* Retry to-device messages ([\#2549](https://github.com/matrix-org/matrix-js-sdk/pull/2549)). Fixes vector-im/element-web#12851.
* Sliding sync: add missing filters from latest MSC ([\#2555](https://github.com/matrix-org/matrix-js-sdk/pull/2555)).
* Use stable prefixes for MSC3827 ([\#2537](https://github.com/matrix-org/matrix-js-sdk/pull/2537)).
## 🐛 Bug Fixes
* Don't load the sync accumulator if there's already a sync persist in flight ([\#2569](https://github.com/matrix-org/matrix-js-sdk/pull/2569)).
Changes in [19.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.2.0) (2022-08-02)
==================================================================================================
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "19.2.0",
"version": "19.3.0-rc.1",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=12.9.0"
@@ -92,7 +92,7 @@
"better-docs": "^2.4.0-beta.9",
"browserify": "^17.0.0",
"docdash": "^1.2.0",
"eslint": "8.19.0",
"eslint": "8.20.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-matrix-org": "^0.5.0",
@@ -102,7 +102,7 @@
"jest-localstorage-mock": "^2.4.6",
"jest-sonar-reporter": "^2.0.0",
"jsdoc": "^3.6.6",
"matrix-mock-request": "^2.1.0",
"matrix-mock-request": "^2.1.2",
"rimraf": "^3.0.2",
"terser": "^5.5.1",
"tsify": "^5.0.2",
+2
View File
@@ -121,6 +121,8 @@ echo $release | grep -q '-' && prerelease=1
if [ $prerelease -eq 1 ]; then
echo Making a PRE-RELEASE
else
read -p "Making a FINAL RELEASE, press enter to continue " REPLY
fi
# We might already be on the release branch, in which case, yay
-16
View File
@@ -1,16 +0,0 @@
{
"extends": [
"config:base",
":dependencyDashboardApproval"
],
"labels": ["T-Task", "Dependencies"],
"lockFileMaintenance": { "enabled": true },
"groupName": "all",
"packageRules": [{
"matchFiles": ["package.json"],
"rangeStrategy": "update-lockfile"
}],
"platformAutomerge": true,
"automerge": true,
"automergeType": "pr"
}
@@ -1,6 +1,6 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -17,19 +17,20 @@ limitations under the License.
import MockHttpBackend from "matrix-mock-request";
import * as sdk from "../../src";
import { request } from "../../src/matrix";
import { AutoDiscovery } from "../../src/autodiscovery";
describe("AutoDiscovery", function() {
let httpBackend = null;
beforeEach(function() {
httpBackend = new MockHttpBackend();
sdk.request(httpBackend.requestFn);
});
const getHttpBackend = (): MockHttpBackend => {
const httpBackend = new MockHttpBackend();
request(httpBackend.requestFn);
return httpBackend;
};
it("should throw an error when no domain is specified", function() {
getHttpBackend();
return Promise.all([
// @ts-ignore testing no args
AutoDiscovery.findClientConfig(/* no args */).then(() => {
throw new Error("Expected a failure, not success with no args");
}, () => {
@@ -42,13 +43,13 @@ describe("AutoDiscovery", function() {
return true;
}),
AutoDiscovery.findClientConfig(null).then(() => {
AutoDiscovery.findClientConfig(null as any).then(() => {
throw new Error("Expected a failure, not success with null");
}, () => {
return true;
}),
AutoDiscovery.findClientConfig(true).then(() => {
AutoDiscovery.findClientConfig(true as any).then(() => {
throw new Error("Expected a failure, not success with a non-string");
}, () => {
return true;
@@ -57,6 +58,7 @@ describe("AutoDiscovery", function() {
});
it("should return PROMPT when .well-known 404s", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(404, {});
return Promise.all([
httpBackend.flushAllExpected(),
@@ -80,6 +82,7 @@ describe("AutoDiscovery", function() {
});
it("should return FAIL_PROMPT when .well-known returns a 500 error", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(500, {});
return Promise.all([
httpBackend.flushAllExpected(),
@@ -103,6 +106,7 @@ describe("AutoDiscovery", function() {
});
it("should return FAIL_PROMPT when .well-known returns a 400 error", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(400, {});
return Promise.all([
httpBackend.flushAllExpected(),
@@ -126,6 +130,7 @@ describe("AutoDiscovery", function() {
});
it("should return FAIL_PROMPT when .well-known returns an empty body", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, "");
return Promise.all([
httpBackend.flushAllExpected(),
@@ -149,6 +154,7 @@ describe("AutoDiscovery", function() {
});
it("should return FAIL_PROMPT when .well-known returns not-JSON", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, "abc");
return Promise.all([
httpBackend.flushAllExpected(),
@@ -173,6 +179,7 @@ describe("AutoDiscovery", function() {
it("should return FAIL_PROMPT when .well-known does not have a base_url for " +
"m.homeserver (empty string)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
base_url: "",
@@ -201,6 +208,7 @@ describe("AutoDiscovery", function() {
it("should return FAIL_PROMPT when .well-known does not have a base_url for " +
"m.homeserver (no property)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {},
});
@@ -227,6 +235,7 @@ describe("AutoDiscovery", function() {
it("should return FAIL_ERROR when .well-known has an invalid base_url for " +
"m.homeserver (disallowed scheme)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
base_url: "mxc://example.org",
@@ -255,6 +264,7 @@ describe("AutoDiscovery", function() {
it("should return FAIL_ERROR when .well-known has an invalid base_url for " +
"m.homeserver (verification failure: 404)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").respond(404, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -284,6 +294,7 @@ describe("AutoDiscovery", function() {
it("should return FAIL_ERROR when .well-known has an invalid base_url for " +
"m.homeserver (verification failure: 500)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").respond(500, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -313,6 +324,7 @@ describe("AutoDiscovery", function() {
it("should return FAIL_ERROR when .well-known has an invalid base_url for " +
"m.homeserver (verification failure: 200 but wrong content)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
not_matrix_versions: ["r0.0.1"],
});
@@ -344,8 +356,9 @@ describe("AutoDiscovery", function() {
it("should return SUCCESS when .well-known has a verifiably accurate base_url for " +
"m.homeserver", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri).toEqual("https://example.org/_matrix/client/versions");
expect(req.path).toEqual("https://example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
});
@@ -376,8 +389,9 @@ describe("AutoDiscovery", function() {
});
it("should return SUCCESS with the right homeserver URL", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
@@ -411,8 +425,9 @@ describe("AutoDiscovery", function() {
it("should return SUCCESS / FAIL_PROMPT when the identity server configuration " +
"is wrong (missing base_url)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
@@ -451,8 +466,9 @@ describe("AutoDiscovery", function() {
it("should return SUCCESS / FAIL_PROMPT when the identity server configuration " +
"is wrong (empty base_url)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
@@ -491,8 +507,9 @@ describe("AutoDiscovery", function() {
it("should return SUCCESS / FAIL_PROMPT when the identity server configuration " +
"is wrong (validation error: 404)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
@@ -532,8 +549,9 @@ describe("AutoDiscovery", function() {
it("should return SUCCESS / FAIL_PROMPT when the identity server configuration " +
"is wrong (validation error: 500)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
@@ -573,14 +591,15 @@ describe("AutoDiscovery", function() {
it("should return SUCCESS when the identity server configuration is " +
"verifiably accurate", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
});
httpBackend.when("GET", "/_matrix/identity/api/v1").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://identity.example.org/_matrix/identity/api/v1");
}).respond(200, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
@@ -615,14 +634,15 @@ describe("AutoDiscovery", function() {
it("should return SUCCESS and preserve non-standard keys from the " +
".well-known response", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
});
httpBackend.when("GET", "/_matrix/identity/api/v1").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://identity.example.org/_matrix/identity/api/v1");
}).respond(200, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
@@ -19,41 +19,41 @@ describe("ContentRepo", function() {
});
it("should return a download URL if no width/height/resize are specified",
function() {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual(
baseUrl + "/_matrix/media/r0/download/server.name/resourceid",
);
});
function() {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual(
baseUrl + "/_matrix/media/r0/download/server.name/resourceid",
);
});
it("should return the empty string for null input", function() {
expect(getHttpUriForMxc(null)).toEqual("");
expect(getHttpUriForMxc(null as any, '')).toEqual("");
});
it("should return a thumbnail URL if a width/height/resize is specified",
function() {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri, 32, 64, "crop")).toEqual(
baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" +
function() {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri, 32, 64, "crop")).toEqual(
baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" +
"?width=32&height=64&method=crop",
);
});
);
});
it("should put fragments from mxc:// URIs after any query parameters",
function() {
const mxcUri = "mxc://server.name/resourceid#automade";
expect(getHttpUriForMxc(baseUrl, mxcUri, 32)).toEqual(
baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" +
function() {
const mxcUri = "mxc://server.name/resourceid#automade";
expect(getHttpUriForMxc(baseUrl, mxcUri, 32)).toEqual(
baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" +
"?width=32#automade",
);
});
);
});
it("should put fragments from mxc:// URIs at the end of the HTTP URI",
function() {
const mxcUri = "mxc://server.name/resourceid#automade";
expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual(
baseUrl + "/_matrix/media/r0/download/server.name/resourceid#automade",
);
});
function() {
const mxcUri = "mxc://server.name/resourceid#automade";
expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual(
baseUrl + "/_matrix/media/r0/download/server.name/resourceid#automade",
);
});
});
});
@@ -2,6 +2,7 @@ import '../olm-loader';
// eslint-disable-next-line no-restricted-imports
import { EventEmitter } from "events";
import { MatrixClient } from "../../src/client";
import { Crypto } from "../../src/crypto";
import { MemoryCryptoStore } from "../../src/crypto/store/memory-crypto-store";
import { MockStorageApi } from "../MockStorageApi";
@@ -25,7 +26,7 @@ function awaitEvent(emitter, event) {
});
}
async function keyshareEventForEvent(client, event, index) {
async function keyshareEventForEvent(client, event, index): Promise<MatrixEvent> {
const roomId = event.getRoomId();
const eventContent = event.getWireContent();
const key = await client.crypto.olmDevice.getInboundGroupSessionKey(
@@ -50,6 +51,7 @@ async function keyshareEventForEvent(client, event, index) {
},
});
// make onRoomKeyEvent think this was an encrypted event
// @ts-ignore private property
ksEvent.senderCurve25519Key = "akey";
return ksEvent;
}
@@ -63,6 +65,10 @@ describe("Crypto", function() {
return Olm.init();
});
afterEach(() => {
jest.useRealTimers();
});
it("Crypto exposes the correct olm library version", function() {
expect(Crypto.getOlmVersion()[0]).toEqual(3);
});
@@ -79,7 +85,7 @@ describe("Crypto", function() {
getId: () => "$event_id",
getSenderKey: () => null,
getWireContent: () => {return {};},
};
} as unknown as MatrixEvent;
let encryptionInfo = client.getEventEncryptionInfo(event);
expect(encryptionInfo.encrypted).toBeFalsy();
@@ -154,12 +160,15 @@ describe("Crypto", function() {
beforeEach(async function() {
const mockStorage = new MockStorageApi();
const clientStore = new MemoryStore({ localStorage: mockStorage });
const cryptoStore = new MemoryCryptoStore(mockStorage);
const cryptoStore = new MemoryCryptoStore();
cryptoStore.storeEndToEndDeviceData({
devices: {
'@bob:home.server': {
'BOBDEVICE': {
algorithms: [],
verified: 1,
known: false,
keys: {
'curve25519:BOBDEVICE': 'this is a key',
},
@@ -167,7 +176,7 @@ describe("Crypto", function() {
},
},
trackingStatus: {},
});
}, {});
mockBaseApis = {
sendToDevice: jest.fn(),
@@ -185,6 +194,7 @@ describe("Crypto", function() {
clientStore,
cryptoStore,
mockRoomList,
[],
);
crypto.registerEventHandlers(fakeEmitter);
await crypto.init();
@@ -195,7 +205,7 @@ describe("Crypto", function() {
});
it("restarts wedged Olm sessions", async function() {
const prom = new Promise((resolve) => {
const prom = new Promise<void>((resolve) => {
mockBaseApis.claimOneTimeKeys = function() {
resolve();
return otkResponse;
@@ -220,8 +230,8 @@ describe("Crypto", function() {
});
describe('Key requests', function() {
let aliceClient;
let bobClient;
let aliceClient: MatrixClient;
let bobClient: MatrixClient;
beforeEach(async function() {
aliceClient = (new TestClient(
@@ -276,8 +286,12 @@ 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);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto.decryptEvent(event);
@@ -291,7 +305,7 @@ describe("Crypto", function() {
roomId, olmlib.MEGOLM_ALGORITHM,
);
let eventPromise = Promise.all(events.map((ev) => {
const decryptEventsPromise = Promise.all(events.map((ev) => {
return awaitEvent(ev, "Event.decrypted");
}));
@@ -300,11 +314,11 @@ describe("Crypto", function() {
// can
let ksEvent = await keyshareEventForEvent(aliceClient, events[1], 1);
await bobDecryptor.onRoomKeyEvent(ksEvent);
await eventPromise;
await decryptEventsPromise;
expect(events[0].getContent().msgtype).toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
const cryptoStore = bobClient.cryptoStore;
const cryptoStore = bobClient.crypto.cryptoStore;
const eventContent = events[0].getWireContent();
const senderKey = eventContent.sender_key;
const sessionId = eventContent.session_id;
@@ -320,10 +334,10 @@ describe("Crypto", function() {
// keyshare the session key starting at the first message, so
// that it can now be decrypted
eventPromise = awaitEvent(events[0], "Event.decrypted");
const decryptEventPromise = awaitEvent(events[0], "Event.decrypted");
ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
await bobDecryptor.onRoomKeyEvent(ksEvent);
await eventPromise;
await decryptEventPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
await sleep(1);
// the room key request should be gone since we've now decrypted everything
@@ -354,8 +368,12 @@ 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);
// remove keys from the event
// @ts-ignore private property
event.clearEvent = undefined;
// @ts-ignore private property
event.senderCurve25519Key = null;
// @ts-ignore private property
event.claimedEd25519Key = null;
try {
await bobClient.crypto.decryptEvent(event);
@@ -370,9 +388,9 @@ describe("Crypto", function() {
const ksEvent = await keyshareEventForEvent(aliceClient, event, 1);
ksEvent.getContent().sender_key = undefined; // test
bobClient.crypto.addInboundGroupSession = jest.fn();
bobClient.crypto.olmDevice.addInboundGroupSession = jest.fn();
await bobDecryptor.onRoomKeyEvent(ksEvent);
expect(bobClient.crypto.addInboundGroupSession).not.toHaveBeenCalled();
expect(bobClient.crypto.olmDevice.addInboundGroupSession).not.toHaveBeenCalled();
});
it("creates a new keyshare request if we request a keyshare", async function() {
@@ -388,7 +406,7 @@ describe("Crypto", function() {
},
});
await aliceClient.cancelAndResendEventRoomKeyRequest(event);
const cryptoStore = aliceClient.cryptoStore;
const cryptoStore = aliceClient.crypto.cryptoStore;
const roomKeyRequestBody = {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: "!someroom",
@@ -412,7 +430,8 @@ describe("Crypto", function() {
},
});
// replace Alice's sendToDevice function with a mock
aliceClient.sendToDevice = jest.fn().mockResolvedValue(undefined);
const aliceSendToDevice = jest.fn().mockResolvedValue(undefined);
aliceClient.sendToDevice = aliceSendToDevice;
aliceClient.startClient();
// make a room key request, and record the transaction ID for the
@@ -421,11 +440,12 @@ 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.
// @ts-ignore
aliceClient.crypto.outgoingRoomKeyRequestManager.sendQueuedRequests();
jest.runAllTimers();
await Promise.resolve();
expect(aliceClient.sendToDevice).toBeCalledTimes(1);
const txnId = aliceClient.sendToDevice.mock.calls[0][2];
expect(aliceSendToDevice).toBeCalledTimes(1);
const txnId = aliceSendToDevice.mock.calls[0][2];
// give the room key request manager time to update the state
// of the request
@@ -438,8 +458,8 @@ describe("Crypto", function() {
// cancelAndResend will call sendToDevice twice:
// the first call to sendToDevice will be the cancellation
// the second call to sendToDevice will be the key request
expect(aliceClient.sendToDevice).toBeCalledTimes(3);
expect(aliceClient.sendToDevice.mock.calls[2][2]).not.toBe(txnId);
expect(aliceSendToDevice).toBeCalledTimes(3);
expect(aliceSendToDevice.mock.calls[2][2]).not.toBe(txnId);
});
});
@@ -451,7 +471,7 @@ describe("Crypto", function() {
await client.initCrypto();
client.crypto.getSecretStorageKey = async () => null;
client.crypto.isCrossSigningReady = async () => false;
client.crypto.baseApis.uploadDeviceSigningKeys = () => null;
client.crypto.baseApis.uploadDeviceSigningKeys = jest.fn().mockResolvedValue(null);
client.crypto.baseApis.setAccountData = () => null;
client.crypto.baseApis.uploadKeySignatures = () => null;
client.crypto.baseApis.http.authedRequest = () => null;
@@ -467,4 +487,105 @@ describe("Crypto", function() {
client.stopClient();
});
});
describe("encryptAndSendToDevices", () => {
let client: TestClient;
let ensureOlmSessionsForDevices: jest.SpiedFunction<typeof olmlib.ensureOlmSessionsForDevices>;
let encryptMessageForDevice: jest.SpiedFunction<typeof olmlib.encryptMessageForDevice>;
const payload = { hello: "world" };
let encryptedPayload: object;
beforeEach(async () => {
ensureOlmSessionsForDevices = jest.spyOn(olmlib, "ensureOlmSessionsForDevices");
ensureOlmSessionsForDevices.mockResolvedValue({});
encryptMessageForDevice = jest.spyOn(olmlib, "encryptMessageForDevice");
encryptMessageForDevice.mockImplementation(async (...[result,,,,,, payload]) => {
result.plaintext = JSON.stringify(payload);
});
client = new TestClient("@alice:example.org", "aliceweb");
await client.client.initCrypto();
encryptedPayload = {
algorithm: "m.olm.v1.curve25519-aes-sha2",
sender_key: client.client.crypto.olmDevice.deviceCurve25519Key,
ciphertext: { plaintext: JSON.stringify(payload) },
};
});
afterEach(async () => {
ensureOlmSessionsForDevices.mockRestore();
encryptMessageForDevice.mockRestore();
await client.stop();
});
it("encrypts and sends to devices", async () => {
client.httpBackend
.when("PUT", "/sendToDevice/m.room.encrypted", {
messages: {
"@bob:example.org": {
bobweb: encryptedPayload,
bobmobile: encryptedPayload,
},
"@carol:example.org": {
caroldesktop: encryptedPayload,
},
},
})
.respond(200, {});
await Promise.all([
client.client.encryptAndSendToDevices(
[
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") },
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobmobile") },
{ userId: "@carol:example.org", deviceInfo: new DeviceInfo("caroldesktop") },
],
payload,
),
client.httpBackend.flushAllExpected(),
]);
});
it("sends nothing to devices that couldn't be encrypted to", async () => {
encryptMessageForDevice.mockImplementation(async (...[result,,,, userId, device, payload]) => {
// Refuse to encrypt to Carol's desktop device
if (userId === "@carol:example.org" && device.deviceId === "caroldesktop") return;
result.plaintext = JSON.stringify(payload);
});
client.httpBackend
.when("PUT", "/sendToDevice/m.room.encrypted", {
// Carol is nowhere to be seen
messages: { "@bob:example.org": { bobweb: encryptedPayload } },
})
.respond(200, {});
await Promise.all([
client.client.encryptAndSendToDevices(
[
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") },
{ userId: "@carol:example.org", deviceInfo: new DeviceInfo("caroldesktop") },
],
payload,
),
client.httpBackend.flushAllExpected(),
]);
});
it("no-ops if no devices can be encrypted to", async () => {
// Refuse to encrypt to anybody
encryptMessageForDevice.mockResolvedValue(undefined);
// Get the room keys version request out of the way
client.httpBackend.when("GET", "/room_keys/version").respond(404, {});
await client.httpBackend.flush("/room_keys/version", 1);
await client.client.encryptAndSendToDevices(
[{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") }],
payload,
);
client.httpBackend.verifyNoOutstandingRequests();
});
});
});
@@ -1,15 +1,36 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { mocked, MockedObject } from 'jest-mock';
import '../../../olm-loader';
import * as algorithms from "../../../../src/crypto/algorithms";
import { MemoryCryptoStore } from "../../../../src/crypto/store/memory-crypto-store";
import { MockStorageApi } from "../../../MockStorageApi";
import * as testUtils from "../../../test-utils/test-utils";
import { OlmDevice } from "../../../../src/crypto/OlmDevice";
import { Crypto } from "../../../../src/crypto";
import { Crypto, IncomingRoomKeyRequest } from "../../../../src/crypto";
import { logger } from "../../../../src/logger";
import { MatrixEvent } from "../../../../src/models/event";
import { TestClient } from "../../../TestClient";
import { Room } from "../../../../src/models/room";
import * as olmlib from "../../../../src/crypto/olmlib";
import { TypedEventEmitter } from '../../../../src/models/typed-event-emitter';
import { ClientEvent, MatrixClient, RoomMember } from '../../../../src';
import { DeviceInfo, IDevice } from '../../../../src/crypto/deviceinfo';
import { DeviceTrustLevel } from '../../../../src/crypto/CrossSigning';
const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2'];
const MegolmEncryption = algorithms.ENCRYPTION_CLASSES['m.megolm.v1.aes-sha2'];
@@ -28,17 +49,20 @@ describe("MegolmDecryption", function() {
return Olm.init();
});
let megolmDecryption;
let mockOlmLib;
let mockCrypto;
let mockBaseApis;
let megolmDecryption: algorithms.DecryptionAlgorithm;
let mockOlmLib: MockedObject<typeof olmlib>;
let mockCrypto: MockedObject<Crypto>;
let mockBaseApis: MockedObject<MatrixClient>;
beforeEach(async function() {
mockCrypto = testUtils.mock(Crypto, 'Crypto');
mockBaseApis = {};
mockCrypto = testUtils.mock(Crypto, 'Crypto') as MockedObject<Crypto>;
mockBaseApis = {
claimOneTimeKeys: jest.fn(),
sendToDevice: jest.fn(),
queueToDevice: jest.fn(),
} as unknown as MockedObject<MatrixClient>;
const mockStorage = new MockStorageApi();
const cryptoStore = new MemoryCryptoStore(mockStorage);
const cryptoStore = new MemoryCryptoStore();
const olmDevice = new OlmDevice(cryptoStore);
@@ -51,11 +75,15 @@ describe("MegolmDecryption", function() {
});
// we stub out the olm encryption bits
mockOlmLib = {};
mockOlmLib.ensureOlmSessionsForDevices = jest.fn();
mockOlmLib.encryptMessageForDevice =
jest.fn().mockResolvedValue(undefined);
mockOlmLib = {
encryptMessageForDevice: jest.fn().mockResolvedValue(undefined),
ensureOlmSessionsForDevices: jest.fn(),
} as unknown as MockedObject<typeof olmlib>;
// @ts-ignore illegal assignment that makes these tests work :/
megolmDecryption.olmlib = mockOlmLib;
jest.clearAllMocks();
});
describe('receives some keys:', function() {
@@ -87,7 +115,7 @@ describe("MegolmDecryption", function() {
decryptEvent: function() {
return Promise.resolve(decryptedData);
},
};
} as unknown as Crypto;
await event.attemptDecryption(mockCrypto).then(() => {
megolmDecryption.onRoomKeyEvent(event);
@@ -115,10 +143,13 @@ describe("MegolmDecryption", function() {
});
it('can respond to a key request event', function() {
const keyRequest = {
const keyRequest: IncomingRoomKeyRequest = {
requestId: '123',
share: jest.fn(),
userId: '@alice:foo',
deviceId: 'alidevice',
requestBody: {
algorithm: '',
room_id: ROOM_ID,
sender_key: "SENDER_CURVE25519",
session_id: groupSession.session_id(),
@@ -131,23 +162,25 @@ describe("MegolmDecryption", function() {
expect(hasKeys).toBe(true);
// set up some pre-conditions for the share call
const deviceInfo = {};
const deviceInfo = {} as DeviceInfo;
mockCrypto.getStoredDevice.mockReturnValue(deviceInfo);
mockOlmLib.ensureOlmSessionsForDevices.mockResolvedValue({
'@alice:foo': { 'alidevice': {
sessionId: 'alisession',
device: new DeviceInfo('alidevice'),
} },
});
const awaitEncryptForDevice = new Promise((res, rej) => {
const awaitEncryptForDevice = new Promise<void>((res, rej) => {
mockOlmLib.encryptMessageForDevice.mockImplementation(() => {
res();
return Promise.resolve();
});
});
mockBaseApis.sendToDevice = jest.fn();
mockBaseApis.sendToDevice.mockReset();
mockBaseApis.queueToDevice.mockReset();
// do the share
megolmDecryption.shareKeysWithDevice(keyRequest);
@@ -265,17 +298,18 @@ describe("MegolmDecryption", function() {
let olmDevice;
beforeEach(async () => {
// @ts-ignore assigning to readonly prop
mockCrypto.backupManager = {
backupGroupSession: () => {},
};
const mockStorage = new MockStorageApi();
const cryptoStore = new MemoryCryptoStore(mockStorage);
const cryptoStore = new MemoryCryptoStore();
olmDevice = new OlmDevice(cryptoStore);
olmDevice.verifySignature = jest.fn();
await olmDevice.init();
mockBaseApis.claimOneTimeKeys = jest.fn().mockReturnValue(Promise.resolve({
mockBaseApis.claimOneTimeKeys.mockResolvedValue({
failures: {},
one_time_keys: {
'@alice:home.server': {
aliceDevice: {
@@ -290,8 +324,9 @@ describe("MegolmDecryption", function() {
},
},
},
}));
mockBaseApis.sendToDevice = jest.fn().mockResolvedValue(undefined);
});
mockBaseApis.sendToDevice.mockResolvedValue(undefined);
mockBaseApis.queueToDevice.mockResolvedValue(undefined);
aliceDeviceInfo = {
deviceId: 'aliceDevice',
@@ -311,18 +346,30 @@ describe("MegolmDecryption", function() {
mockCrypto.checkDeviceTrust.mockReturnValue({
isVerified: () => false,
});
} as DeviceTrustLevel);
megolmEncryption = new MegolmEncryption({
userId: '@user:id',
deviceId: '12345',
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: mockBaseApis,
roomId: ROOM_ID,
config: {
algorithm: 'm.megolm.v1.aes-sha2',
rotation_period_ms: rotationPeriodMs,
},
});
// Splice the real method onto the mock object as megolm uses this method
// on the crypto class in order to encrypt / start sessions
// @ts-ignore Mock
mockCrypto.encryptAndSendToDevices = Crypto.prototype.encryptAndSendToDevices;
// @ts-ignore Mock
mockCrypto.olmDevice = olmDevice;
// @ts-ignore Mock
mockCrypto.baseApis = mockBaseApis;
mockRoom = {
getEncryptionTargetMembers: jest.fn().mockReturnValue(
[{ userId: "@alice:home.server" }],
@@ -369,7 +416,7 @@ describe("MegolmDecryption", function() {
expect(mockCrypto.downloadKeys).toHaveBeenCalledWith(
['@alice:home.server'], false,
);
expect(mockBaseApis.sendToDevice).toHaveBeenCalled();
expect(mockBaseApis.queueToDevice).toHaveBeenCalled();
expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalledWith(
[['@alice:home.server', 'aliceDevice']], 'signed_curve25519', 2000,
);
@@ -412,7 +459,7 @@ describe("MegolmDecryption", function() {
'YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWI',
);
mockBaseApis.sendToDevice.mockClear();
mockBaseApis.queueToDevice.mockClear();
await megolmEncryption.reshareKeyWithDevice(
olmDevice.deviceCurve25519Key,
ct1.session_id,
@@ -420,7 +467,7 @@ describe("MegolmDecryption", function() {
aliceDeviceInfo,
);
expect(mockBaseApis.sendToDevice).not.toHaveBeenCalled();
expect(mockBaseApis.queueToDevice).not.toHaveBeenCalled();
});
});
});
@@ -449,33 +496,33 @@ describe("MegolmDecryption", function() {
};
const roomId = "!someroom";
const room = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobMember = new RoomMember(roomId, "@bob:example.com");
room.getEncryptionTargetMembers = async function() {
return [{ userId: "@bob:example.com" }];
return [bobMember];
};
room.setBlacklistUnverifiedDevices(true);
aliceClient.store.storeRoom(room);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
const BOB_DEVICES = {
const BOB_DEVICES: Record<string, IDevice> = {
bobdevice1: {
user_id: "@bob:example.com",
device_id: "bobdevice1",
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
keys: {
"ed25519:Dynabook": bobDevice1.deviceEd25519Key,
"curve25519:Dynabook": bobDevice1.deviceCurve25519Key,
},
verified: 0,
known: false,
},
bobdevice2: {
user_id: "@bob:example.com",
device_id: "bobdevice2",
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
keys: {
"ed25519:Dynabook": bobDevice2.deviceEd25519Key,
"curve25519:Dynabook": bobDevice2.deviceCurve25519Key,
},
verified: -1,
known: false,
},
};
@@ -486,32 +533,7 @@ describe("MegolmDecryption", function() {
return this.getDevicesFromStore(userIds);
};
let run = false;
aliceClient.sendToDevice = async (msgtype, contentMap) => {
run = true;
expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/);
delete contentMap["@bob:example.com"].bobdevice1.session_id;
delete contentMap["@bob:example.com"].bobdevice2.session_id;
expect(contentMap).toStrictEqual({
'@bob:example.com': {
bobdevice1: {
algorithm: "m.megolm.v1.aes-sha2",
room_id: roomId,
code: 'm.unverified',
reason:
'The sender has disabled encrypting to unverified devices.',
sender_key: aliceDevice.deviceCurve25519Key,
},
bobdevice2: {
algorithm: "m.megolm.v1.aes-sha2",
room_id: roomId,
code: 'm.blacklisted',
reason: 'The sender has blocked you.',
sender_key: aliceDevice.deviceCurve25519Key,
},
},
});
};
aliceClient.sendToDevice = jest.fn().mockResolvedValue({});
const event = new MatrixEvent({
type: "m.room.message",
@@ -525,7 +547,30 @@ describe("MegolmDecryption", function() {
});
await aliceClient.crypto.encryptEvent(event, room);
expect(run).toBe(true);
expect(aliceClient.sendToDevice).toHaveBeenCalled();
const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0];
expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/);
delete contentMap["@bob:example.com"].bobdevice1.session_id;
delete contentMap["@bob:example.com"].bobdevice2.session_id;
expect(contentMap).toStrictEqual({
'@bob:example.com': {
bobdevice1: {
algorithm: "m.megolm.v1.aes-sha2",
room_id: roomId,
code: 'm.unverified',
reason:
'The sender has disabled encrypting to unverified devices.',
sender_key: aliceDevice.deviceCurve25519Key,
},
bobdevice2: {
algorithm: "m.megolm.v1.aes-sha2",
room_id: roomId,
code: 'm.blacklisted',
reason: 'The sender has blocked you.',
sender_key: aliceDevice.deviceCurve25519Key,
},
},
});
aliceClient.stopClient();
bobClient1.stopClient();
@@ -557,18 +602,16 @@ describe("MegolmDecryption", function() {
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
aliceRoom.getEncryptionTargetMembers = async () => {
return [
{
userId: "@alice:example.com",
membership: "join",
},
{
userId: "@bob:example.com",
membership: "join",
},
];
};
aliceRoom.getEncryptionTargetMembers = jest.fn().mockResolvedValue([
{
userId: "@alice:example.com",
membership: "join",
},
{
userId: "@bob:example.com",
membership: "join",
},
]);
const BOB_DEVICES = {
bobdevice: {
user_id: "@bob:example.com",
@@ -590,30 +633,14 @@ describe("MegolmDecryption", function() {
return this.getDevicesFromStore(userIds);
};
aliceClient.claimOneTimeKeys = async () => {
aliceClient.claimOneTimeKeys = jest.fn().mockResolvedValue({
// Bob has no one-time keys
return {
one_time_keys: {},
};
};
const sendPromise = new Promise((resolve, reject) => {
aliceClient.sendToDevice = async (msgtype, contentMap) => {
expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/);
expect(contentMap).toStrictEqual({
'@bob:example.com': {
bobdevice: {
algorithm: "m.megolm.v1.aes-sha2",
code: 'm.no_olm',
reason: 'Unable to establish a secure channel.',
sender_key: aliceDevice.deviceCurve25519Key,
},
},
});
resolve();
};
one_time_keys: {},
failures: {},
});
aliceClient.sendToDevice = jest.fn().mockResolvedValue({});
const event = new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
@@ -622,7 +649,21 @@ describe("MegolmDecryption", function() {
content: {},
});
await aliceClient.crypto.encryptEvent(event, aliceRoom);
await sendPromise;
expect(aliceClient.sendToDevice).toHaveBeenCalled();
const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0];
expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/);
expect(contentMap).toStrictEqual({
'@bob:example.com': {
bobdevice: {
algorithm: "m.megolm.v1.aes-sha2",
code: 'm.no_olm',
reason: 'Unable to establish a secure channel.',
sender_key: aliceDevice.deviceCurve25519Key,
},
},
});
aliceClient.stopClient();
bobClient.stopClient();
});
@@ -640,9 +681,12 @@ describe("MegolmDecryption", function() {
]);
const bobDevice = bobClient.crypto.olmDevice;
const aliceEventEmitter = new TypedEventEmitter<ClientEvent.ToDeviceEvent, any>();
aliceClient.crypto.registerEventHandlers(aliceEventEmitter);
const roomId = "!someroom";
aliceClient.crypto.onToDeviceEvent(new MatrixEvent({
aliceEventEmitter.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({
type: "m.room_key.withheld",
sender: "@bob:example.com",
content: {
@@ -669,7 +713,7 @@ describe("MegolmDecryption", function() {
},
}))).rejects.toThrow("The sender has blocked you.");
aliceClient.crypto.onToDeviceEvent(new MatrixEvent({
aliceEventEmitter.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({
type: "m.room_key.withheld",
sender: "@bob:example.com",
content: {
@@ -710,14 +754,18 @@ describe("MegolmDecryption", function() {
aliceClient.initCrypto(),
bobClient.initCrypto(),
]);
aliceClient.crypto.downloadKeys = async () => {};
const aliceEventEmitter = new TypedEventEmitter<ClientEvent.ToDeviceEvent, any>();
aliceClient.crypto.registerEventHandlers(aliceEventEmitter);
aliceClient.crypto.downloadKeys = jest.fn();
const bobDevice = bobClient.crypto.olmDevice;
const roomId = "!someroom";
const now = Date.now();
aliceClient.crypto.onToDeviceEvent(new MatrixEvent({
aliceEventEmitter.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({
type: "m.room_key.withheld",
sender: "@bob:example.com",
content: {
@@ -749,7 +797,7 @@ describe("MegolmDecryption", function() {
origin_server_ts: now,
}))).rejects.toThrow("The sender was unable to establish a secure channel.");
aliceClient.crypto.onToDeviceEvent(new MatrixEvent({
aliceEventEmitter.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({
type: "m.room_key.withheld",
sender: "@bob:example.com",
content: {
@@ -795,15 +843,18 @@ describe("MegolmDecryption", function() {
aliceClient.initCrypto(),
bobClient.initCrypto(),
]);
const aliceEventEmitter = new TypedEventEmitter<ClientEvent.ToDeviceEvent, any>();
aliceClient.crypto.registerEventHandlers(aliceEventEmitter);
const bobDevice = bobClient.crypto.olmDevice;
aliceClient.crypto.downloadKeys = async () => {};
aliceClient.crypto.downloadKeys = jest.fn();
const roomId = "!someroom";
const now = Date.now();
// pretend we got an event that we can't decrypt
aliceClient.crypto.onToDeviceEvent(new MatrixEvent({
aliceEventEmitter.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({
type: "m.room.encrypted",
sender: "@bob:example.com",
content: {
@@ -1,6 +1,6 @@
/*
Copyright 2018,2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -15,17 +15,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MockedObject } from 'jest-mock';
import '../../../olm-loader';
import { MemoryCryptoStore } from "../../../../src/crypto/store/memory-crypto-store";
import { MockStorageApi } from "../../../MockStorageApi";
import { logger } from "../../../../src/logger";
import { OlmDevice } from "../../../../src/crypto/OlmDevice";
import * as olmlib from "../../../../src/crypto/olmlib";
import { DeviceInfo } from "../../../../src/crypto/deviceinfo";
import { MatrixClient } from '../../../../src';
function makeOlmDevice() {
const mockStorage = new MockStorageApi();
const cryptoStore = new MemoryCryptoStore(mockStorage);
const cryptoStore = new MemoryCryptoStore();
const olmDevice = new OlmDevice(cryptoStore);
return olmDevice;
}
@@ -51,8 +52,8 @@ describe("OlmDevice", function() {
return global.Olm.init();
});
let aliceOlmDevice;
let bobOlmDevice;
let aliceOlmDevice: OlmDevice;
let bobOlmDevice: OlmDevice;
beforeEach(async function() {
aliceOlmDevice = makeOlmDevice();
@@ -69,7 +70,7 @@ describe("OlmDevice", function() {
bobOlmDevice.deviceCurve25519Key,
sid,
"The olm or proteus is an aquatic salamander in the family Proteidae",
);
) as any; // OlmDevice.encryptMessage has incorrect return type
const result = await bobOlmDevice.createInboundSession(
aliceOlmDevice.deviceCurve25519Key,
@@ -96,7 +97,7 @@ describe("OlmDevice", function() {
bobOlmDevice.deviceCurve25519Key,
sessionId,
MESSAGE,
);
) as any; // OlmDevice.encryptMessage has incorrect return type
const bobRecreatedOlmDevice = makeOlmDevice();
bobRecreatedOlmDevice.init({ fromExportedDevice: exported });
@@ -120,7 +121,7 @@ describe("OlmDevice", function() {
bobOlmDevice.deviceCurve25519Key,
sessionId,
MESSAGE_2,
);
) as any; // OlmDevice.encryptMessage has incorrect return type
const bobRecreatedAgainOlmDevice = makeOlmDevice();
bobRecreatedAgainOlmDevice.init({ fromExportedDevice: exportedAgain });
@@ -148,7 +149,7 @@ describe("OlmDevice", function() {
setTimeout(reject, 500);
});
},
};
} as unknown as MockedObject<MatrixClient>;
const devicesByUser = {
"@bob:example.com": [
DeviceInfo.fromStorage({
@@ -205,7 +206,7 @@ describe("OlmDevice", function() {
setTimeout(reject, 500);
});
},
};
} as unknown as MockedObject<MatrixClient>;
const deviceBobA = DeviceInfo.fromStorage({
keys: {
@@ -1,26 +1,36 @@
import { mocked } from 'jest-mock';
import * as utils from "../test-utils/test-utils";
import { EventTimeline } from "../../src/models/event-timeline";
import { RoomState } from "../../src/models/room-state";
import { MatrixClient } from "../../src/matrix";
import { Room } from "../../src/models/room";
import { RoomMember } from "../../src/models/room-member";
import { EventTimelineSet } from "../../src/models/event-timeline-set";
function mockRoomStates(timeline) {
timeline.startState = utils.mock(RoomState, "startState");
timeline.endState = utils.mock(RoomState, "endState");
}
jest.mock("../../src/models/room-state");
describe("EventTimeline", function() {
const roomId = "!foo:bar";
const userA = "@alice:bar";
const userB = "@bertha:bar";
let timeline;
let timeline: EventTimeline;
const mockClient = {} as unknown as MatrixClient;
const getTimeline = (): EventTimeline => {
const room = new Room(roomId, mockClient, userA);
const timelineSet = new EventTimelineSet(room);
jest.spyOn(timelineSet.room, 'getUnfilteredTimelineSet').mockReturnValue(timelineSet);
return new EventTimeline(timelineSet);
};
beforeEach(function() {
// XXX: this is a horrid hack; should use sinon or something instead to mock
const timelineSet = { room: { roomId: roomId } };
timelineSet.room.getUnfilteredTimelineSet = function() {
return timelineSet;
};
// reset any RoomState mocks
jest.resetAllMocks();
timeline = new EventTimeline(timelineSet);
timeline = getTimeline();
});
describe("construction", function() {
@@ -31,10 +41,6 @@ describe("EventTimeline", function() {
});
describe("initialiseState", function() {
beforeEach(function() {
mockRoomStates(timeline);
});
it("should copy state events to start and end state", function() {
const events = [
utils.mkMembership({
@@ -48,11 +54,15 @@ describe("EventTimeline", function() {
}),
];
timeline.initialiseState(events);
expect(timeline.startState.setStateEvents).toHaveBeenCalledWith(
// @ts-ignore private prop
const timelineStartState = timeline.startState;
expect(mocked(timelineStartState).setStateEvents).toHaveBeenCalledWith(
events,
{ timelineWasEmpty: undefined },
);
expect(timeline.endState.setStateEvents).toHaveBeenCalledWith(
// @ts-ignore private prop
const timelineEndState = timeline.endState;
expect(mocked(timelineEndState).setStateEvents).toHaveBeenCalledWith(
events,
{ timelineWasEmpty: undefined },
);
@@ -103,8 +113,8 @@ describe("EventTimeline", function() {
});
it("setNeighbouringTimeline should set neighbour", function() {
const prev = { a: "a" };
const next = { b: "b" };
const prev = getTimeline();
const next = getTimeline();
timeline.setNeighbouringTimeline(prev, EventTimeline.BACKWARDS);
timeline.setNeighbouringTimeline(next, EventTimeline.FORWARDS);
expect(timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)).toBe(prev);
@@ -112,8 +122,8 @@ describe("EventTimeline", function() {
});
it("setNeighbouringTimeline should throw if called twice", function() {
const prev = { a: "a" };
const next = { b: "b" };
const prev = getTimeline();
const next = getTimeline();
expect(function() {
timeline.setNeighbouringTimeline(prev, EventTimeline.BACKWARDS);
}).not.toThrow();
@@ -135,10 +145,6 @@ describe("EventTimeline", function() {
});
describe("addEvent", function() {
beforeEach(function() {
mockRoomStates(timeline);
});
const events = [
utils.mkMessage({
room: roomId, user: userA, msg: "hungry hungry hungry",
@@ -171,24 +177,22 @@ describe("EventTimeline", function() {
});
it("should set event.sender for new and old events", function() {
const sentinel = {
userId: userA,
membership: "join",
name: "Alice",
};
const oldSentinel = {
userId: userA,
membership: "join",
name: "Old Alice",
};
timeline.getState(EventTimeline.FORWARDS).getSentinelMember
const sentinel = new RoomMember(roomId, userA);
sentinel.name = "Alice";
sentinel.membership = "join";
const oldSentinel = new RoomMember(roomId, userA);
sentinel.name = "Old Alice";
sentinel.membership = "join";
mocked(timeline.getState(EventTimeline.FORWARDS)).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return sentinel;
}
return null;
});
timeline.getState(EventTimeline.BACKWARDS).getSentinelMember
mocked(timeline.getState(EventTimeline.BACKWARDS)).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return oldSentinel;
@@ -212,43 +216,41 @@ describe("EventTimeline", function() {
});
it("should set event.target for new and old m.room.member events",
function() {
const sentinel = {
userId: userA,
membership: "join",
name: "Alice",
};
const oldSentinel = {
userId: userA,
membership: "join",
name: "Old Alice",
};
timeline.getState(EventTimeline.FORWARDS).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return sentinel;
}
return null;
});
timeline.getState(EventTimeline.BACKWARDS).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return oldSentinel;
}
return null;
});
function() {
const sentinel = new RoomMember(roomId, userA);
sentinel.name = "Alice";
sentinel.membership = "join";
const newEv = utils.mkMembership({
room: roomId, mship: "invite", user: userB, skey: userA, event: true,
const oldSentinel = new RoomMember(roomId, userA);
sentinel.name = "Old Alice";
sentinel.membership = "join";
mocked(timeline.getState(EventTimeline.FORWARDS)).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return sentinel;
}
return null;
});
mocked(timeline.getState(EventTimeline.BACKWARDS)).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return oldSentinel;
}
return null;
});
const newEv = utils.mkMembership({
room: roomId, mship: "invite", user: userB, skey: userA, event: true,
});
const oldEv = utils.mkMembership({
room: roomId, mship: "ban", user: userB, skey: userA, event: true,
});
timeline.addEvent(newEv, { toStartOfTimeline: false });
expect(newEv.target).toEqual(sentinel);
timeline.addEvent(oldEv, { toStartOfTimeline: true });
expect(oldEv.target).toEqual(oldSentinel);
});
const oldEv = utils.mkMembership({
room: roomId, mship: "ban", user: userB, skey: userA, event: true,
});
timeline.addEvent(newEv, { toStartOfTimeline: false });
expect(newEv.target).toEqual(sentinel);
timeline.addEvent(oldEv, { toStartOfTimeline: true });
expect(oldEv.target).toEqual(oldSentinel);
});
it("should call setStateEvents on the right RoomState with the right " +
"forwardLooking value for new events", function() {
@@ -310,7 +312,11 @@ describe("EventTimeline", function() {
it("Make sure legacy overload passing options directly as parameters still works", () => {
expect(() => timeline.addEvent(events[0], { toStartOfTimeline: true })).not.toThrow();
expect(() => timeline.addEvent(events[0], { stateContext: new RoomState() })).not.toThrow();
// @ts-ignore stateContext is not a valid param
expect(() => timeline.addEvent(events[0], { stateContext: new RoomState(roomId) })).not.toThrow();
expect(() => timeline.addEvent(events[0],
{ toStartOfTimeline: false, roomState: new RoomState(roomId) },
)).not.toThrow();
});
});
@@ -364,14 +370,14 @@ describe("EventTimeline", function() {
// - removing the last event got baseIndex into such a state that
// further addEvent(ev, false) calls made the index increase.
it("should not make baseIndex assplode when removing the last event",
function() {
timeline.addEvent(events[0], { toStartOfTimeline: true });
timeline.removeEvent(events[0].getId());
const initialIndex = timeline.getBaseIndex();
timeline.addEvent(events[1], { toStartOfTimeline: false });
timeline.addEvent(events[2], { toStartOfTimeline: false });
expect(timeline.getBaseIndex()).toEqual(initialIndex);
expect(timeline.getEvents().length).toEqual(2);
});
function() {
timeline.addEvent(events[0], { toStartOfTimeline: true });
timeline.removeEvent(events[0].getId());
const initialIndex = timeline.getBaseIndex();
timeline.addEvent(events[1], { toStartOfTimeline: false });
timeline.addEvent(events[2], { toStartOfTimeline: false });
expect(timeline.getBaseIndex()).toEqual(initialIndex);
expect(timeline.getEvents().length).toEqual(2);
});
});
});
@@ -1,6 +1,6 @@
/*
Copyright 2017 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -15,16 +15,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "../../src/logger";
import { MatrixEvent } from "../../src/models/event";
describe("MatrixEvent", () => {
describe(".attemptDecryption", () => {
let encryptedEvent;
const eventId = 'test_encrypted_event';
beforeEach(() => {
encryptedEvent = new MatrixEvent({
id: 'test_encrypted_event',
event_id: eventId,
type: 'm.room.encrypted',
content: {
ciphertext: 'secrets',
@@ -32,45 +32,34 @@ describe("MatrixEvent", () => {
});
});
it('should retry decryption if a retry is queued', () => {
let callCount = 0;
let prom2;
let prom2Fulfilled = false;
it('should retry decryption if a retry is queued', async () => {
const eventAttemptDecryptionSpy = jest.spyOn(encryptedEvent, 'attemptDecryption');
const crypto = {
decryptEvent: function() {
++callCount;
logger.log(`decrypt: ${callCount}`);
if (callCount == 1) {
decryptEvent: jest.fn()
.mockImplementationOnce(() => {
// schedule a second decryption attempt while
// the first one is still running.
prom2 = encryptedEvent.attemptDecryption(crypto);
prom2.then(() => prom2Fulfilled = true);
encryptedEvent.attemptDecryption(crypto);
const error = new Error("nope");
error.name = 'DecryptionError';
return Promise.reject(error);
} else {
expect(prom2Fulfilled).toBe(
false, 'second attemptDecryption resolved too soon');
})
.mockImplementationOnce(() => {
return Promise.resolve({
clearEvent: {
type: 'm.room.message',
},
});
}
},
}),
};
return encryptedEvent.attemptDecryption(crypto).then(() => {
expect(callCount).toEqual(2);
expect(encryptedEvent.getType()).toEqual('m.room.message');
await encryptedEvent.attemptDecryption(crypto);
// make sure the second attemptDecryption resolves
return prom2;
});
expect(eventAttemptDecryptionSpy).toHaveBeenCalledTimes(2);
expect(crypto.decryptEvent).toHaveBeenCalledTimes(2);
expect(encryptedEvent.getType()).toEqual('m.room.message');
});
});
});
@@ -1,9 +1,9 @@
import { Filter } from "../../src/filter";
import { Filter, IFilterDefinition } from "../../src/filter";
describe("Filter", function() {
const filterId = "f1lt3ring15g00d4ursoul";
const userId = "@sir_arthur_david:humming.tiger";
let filter;
let filter: Filter;
beforeEach(function() {
filter = new Filter(userId);
@@ -37,7 +37,7 @@ describe("Filter", function() {
describe("setDefinition/getDefinition", function() {
it("should set and get the filter body", function() {
const definition = {
event_format: "client",
event_format: "client" as IFilterDefinition['event_format'],
};
filter.setDefinition(definition);
expect(filter.getDefinition()).toEqual(definition);
@@ -15,8 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "../../src/client";
import { logger } from "../../src/logger";
import { InteractiveAuth } from "../../src/interactive-auth";
import { InteractiveAuth, AuthType } from "../../src/interactive-auth";
import { MatrixError } from "../../src/http-api";
import { sleep } from "../../src/utils";
import { randomString } from "../../src/randomstring";
@@ -29,38 +30,40 @@ class FakeClient {
}
}
const getFakeClient = (): MatrixClient => new FakeClient() as unknown as MatrixClient;
describe("InteractiveAuth", function() {
it("should start an auth stage and complete it", function() {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
matrixClient: getFakeClient(),
doRequest: doRequest,
stateUpdated: stateUpdated,
requestEmailToken: jest.fn(),
authData: {
session: "sessionId",
flows: [
{ stages: ["logintype"] },
{ stages: [AuthType.Password] },
],
params: {
"logintype": { param: "aa" },
[AuthType.Password]: { param: "aa" },
},
},
});
expect(ia.getSessionId()).toEqual("sessionId");
expect(ia.getStageParams("logintype")).toEqual({
expect(ia.getStageParams(AuthType.Password)).toEqual({
param: "aa",
});
// first we expect a call here
stateUpdated.mockImplementation(function(stage) {
logger.log('aaaa');
expect(stage).toEqual("logintype");
expect(stage).toEqual(AuthType.Password);
ia.submitAuthDict({
type: "logintype",
foo: "bar",
type: AuthType.Password,
});
});
@@ -70,8 +73,7 @@ describe("InteractiveAuth", function() {
logger.log('cccc');
expect(authData).toEqual({
session: "sessionId",
type: "logintype",
foo: "bar",
type: AuthType.Password,
});
return Promise.resolve(requestRes);
});
@@ -86,15 +88,17 @@ describe("InteractiveAuth", function() {
it("should make a request if no authdata is provided", function() {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
stateUpdated: stateUpdated,
doRequest: doRequest,
matrixClient: getFakeClient(),
stateUpdated,
doRequest,
requestEmailToken,
});
expect(ia.getSessionId()).toBe(undefined);
expect(ia.getStageParams("logintype")).toBe(undefined);
expect(ia.getStageParams(AuthType.Password)).toBe(undefined);
// first we expect a call to doRequest
doRequest.mockImplementation(function(authData) {
@@ -103,10 +107,10 @@ describe("InteractiveAuth", function() {
const err = new MatrixError({
session: "sessionId",
flows: [
{ stages: ["logintype"] },
{ stages: [AuthType.Password] },
],
params: {
"logintype": { param: "aa" },
[AuthType.Password]: { param: "aa" },
},
});
err.httpStatus = 401;
@@ -116,9 +120,9 @@ describe("InteractiveAuth", function() {
// .. which should be followed by a call to stateUpdated
const requestRes = { "a": "b" };
stateUpdated.mockImplementation(function(stage) {
expect(stage).toEqual("logintype");
expect(stage).toEqual(AuthType.Password);
expect(ia.getSessionId()).toEqual("sessionId");
expect(ia.getStageParams("logintype")).toEqual({
expect(ia.getStageParams(AuthType.Password)).toEqual({
param: "aa",
});
@@ -127,15 +131,13 @@ describe("InteractiveAuth", function() {
logger.log("request2", authData);
expect(authData).toEqual({
session: "sessionId",
type: "logintype",
foo: "bar",
type: AuthType.Password,
});
return Promise.resolve(requestRes);
});
ia.submitAuthDict({
type: "logintype",
foo: "bar",
type: AuthType.Password,
});
});
@@ -149,11 +151,13 @@ describe("InteractiveAuth", function() {
it("should start an auth stage and reject if no auth flow", function() {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
doRequest: doRequest,
stateUpdated: stateUpdated,
matrixClient: getFakeClient(),
doRequest,
stateUpdated,
requestEmailToken,
});
doRequest.mockImplementation(function(authData) {
@@ -163,7 +167,7 @@ describe("InteractiveAuth", function() {
session: "sessionId",
flows: [],
params: {
"logintype": { param: "aa" },
[AuthType.Password]: { param: "aa" },
},
});
err.httpStatus = 401;
@@ -183,7 +187,7 @@ describe("InteractiveAuth", function() {
requestEmailToken.mockImplementation(async () => ({ sid: "" }));
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
matrixClient: getFakeClient(),
doRequest, stateUpdated, requestEmailToken,
});
@@ -210,7 +214,7 @@ describe("InteractiveAuth", function() {
requestEmailToken.mockImplementation(async () => ({ sid: "" }));
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
matrixClient: getFakeClient(),
doRequest, stateUpdated, requestEmailToken,
});
@@ -239,7 +243,7 @@ describe("InteractiveAuth", function() {
});
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
matrixClient: getFakeClient(),
doRequest, stateUpdated, requestEmailToken,
});
@@ -253,7 +257,7 @@ describe("InteractiveAuth", function() {
requestEmailToken.mockImplementation(() => sleep(500, { sid: "" }));
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
matrixClient: getFakeClient(),
doRequest, stateUpdated, requestEmailToken,
});
@@ -269,7 +273,7 @@ describe("InteractiveAuth", function() {
requestEmailToken.mockImplementation(() => sleep(500, { sid }));
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
matrixClient: getFakeClient(),
doRequest, stateUpdated, requestEmailToken,
});
@@ -1,7 +1,7 @@
import { TestClient } from '../TestClient';
describe('Login request', function() {
let client;
let client: TestClient;
beforeEach(function() {
client = new TestClient();
+131 -16
View File
@@ -14,8 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { mocked } from "jest-mock";
import { logger } from "../../src/logger";
import { MatrixClient } from "../../src/client";
import { MatrixClient, ClientEvent } from "../../src/client";
import { Filter } from "../../src/filter";
import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace";
import {
@@ -27,6 +29,7 @@ import {
UNSTABLE_MSC3089_TREE_SUBTYPE,
} from "../../src/@types/event";
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
import { Crypto } from "../../src/crypto";
import { EventStatus, MatrixEvent } from "../../src/models/event";
import { Preset } from "../../src/@types/partials";
import { ReceiptType } from "../../src/@types/read_receipts";
@@ -34,10 +37,16 @@ import * as testUtils from "../test-utils/test-utils";
import { makeBeaconInfoContent } from "../../src/content-helpers";
import { M_BEACON_INFO } from "../../src/@types/beacon";
import { ContentHelpers, Room } from "../../src";
import { supportsMatrixCall } from "../../src/webrtc/call";
import { makeBeaconEvent } from "../test-utils/beacon";
jest.useFakeTimers();
jest.mock("../../src/webrtc/call", () => ({
...jest.requireActual("../../src/webrtc/call"),
supportsMatrixCall: jest.fn(() => false),
}));
describe("MatrixClient", function() {
const userId = "@alice:bar";
const identityServerUrl = "https://identity.server";
@@ -159,6 +168,24 @@ describe("MatrixClient", function() {
return new Promise(() => {});
}
function makeClient() {
client = new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: identityServerUrl,
accessToken: "my.access.token",
request: function() {} as any, // NOP
store: store,
scheduler: scheduler,
userId: userId,
});
// FIXME: We shouldn't be yanking http like this.
client.http = [
"authedRequest", "getContentUri", "request", "uploadContent",
].reduce((r, k) => { r[k] = jest.fn(); return r; }, {});
client.http.authedRequest.mockImplementation(httpReq);
client.http.request.mockImplementation(httpReq);
}
beforeEach(function() {
scheduler = [
"getQueueForEvent", "queueEvent", "removeEventFromQueue",
@@ -176,21 +203,7 @@ describe("MatrixClient", function() {
store.getClientOptions = jest.fn().mockReturnValue(Promise.resolve(null));
store.storeClientOptions = jest.fn().mockReturnValue(Promise.resolve(null));
store.isNewlyCreated = jest.fn().mockReturnValue(Promise.resolve(true));
client = new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: identityServerUrl,
accessToken: "my.access.token",
request: function() {} as any, // NOP
store: store,
scheduler: scheduler,
userId: userId,
});
// FIXME: We shouldn't be yanking http like this.
client.http = [
"authedRequest", "getContentUri", "request", "uploadContent",
].reduce((r, k) => { r[k] = jest.fn(); return r; }, {});
client.http.authedRequest.mockImplementation(httpReq);
client.http.request.mockImplementation(httpReq);
makeClient();
// set reasonable working defaults
acceptKeepalives = true;
@@ -1297,4 +1310,106 @@ describe("MatrixClient", function() {
expect(result!.aliases).toEqual(response.aliases);
});
});
describe("pollingTurnServers", () => {
afterEach(() => {
mocked(supportsMatrixCall).mockReset();
});
it("is false if the client isn't started", () => {
expect(client.clientRunning).toBe(false);
expect(client.pollingTurnServers).toBe(false);
});
it("is false if VoIP is not supported", async () => {
mocked(supportsMatrixCall).mockReturnValue(false);
makeClient(); // create the client a second time so it picks up the supportsMatrixCall mock
await client.startClient();
expect(client.pollingTurnServers).toBe(false);
});
it("is true if VoIP is supported", async () => {
mocked(supportsMatrixCall).mockReturnValue(true);
makeClient(); // create the client a second time so it picks up the supportsMatrixCall mock
await client.startClient();
expect(client.pollingTurnServers).toBe(true);
});
});
describe("checkTurnServers", () => {
beforeAll(() => {
mocked(supportsMatrixCall).mockReturnValue(true);
});
beforeEach(() => {
makeClient(); // create the client a second time so it picks up the supportsMatrixCall mock
});
afterAll(() => {
mocked(supportsMatrixCall).mockReset();
});
it("emits an event when new TURN creds are found", async () => {
const turnServer = {
uris: [
"turn:turn.example.com:3478?transport=udp",
"turn:10.20.30.40:3478?transport=tcp",
"turns:10.20.30.40:443?transport=tcp",
],
username: "1443779631:@user:example.com",
password: "JlKfBy1QwLrO20385QyAtEyIv0=",
};
jest.spyOn(client, "turnServer").mockResolvedValue(turnServer);
const events: any[][] = [];
const onTurnServers = (...args) => events.push(args);
client.on(ClientEvent.TurnServers, onTurnServers);
expect(await client.checkTurnServers()).toBe(true);
client.off(ClientEvent.TurnServers, onTurnServers);
expect(events).toEqual([[[{
urls: turnServer.uris,
username: turnServer.username,
credential: turnServer.password,
}]]]);
});
it("emits an event when an error occurs", async () => {
const error = new Error(":(");
jest.spyOn(client, "turnServer").mockRejectedValue(error);
const events: any[][] = [];
const onTurnServersError = (...args) => events.push(args);
client.on(ClientEvent.TurnServersError, onTurnServersError);
expect(await client.checkTurnServers()).toBe(false);
client.off(ClientEvent.TurnServersError, onTurnServersError);
expect(events).toEqual([[error, false]]); // non-fatal
});
it("considers 403 errors fatal", async () => {
const error = { httpStatus: 403 };
jest.spyOn(client, "turnServer").mockRejectedValue(error);
const events: any[][] = [];
const onTurnServersError = (...args) => events.push(args);
client.on(ClientEvent.TurnServersError, onTurnServersError);
expect(await client.checkTurnServers()).toBe(false);
client.off(ClientEvent.TurnServersError, onTurnServersError);
expect(events).toEqual([[error, true]]); // fatal
});
});
describe("encryptAndSendToDevices", () => {
it("throws an error if crypto is unavailable", () => {
client.crypto = undefined;
expect(() => client.encryptAndSendToDevices([], {})).toThrow();
});
it("is an alias for the crypto method", async () => {
client.crypto = testUtils.mock(Crypto, "Crypto");
const deviceInfos = [];
const payload = {};
await client.encryptAndSendToDevices(deviceInfos, payload);
expect(client.crypto.encryptAndSendToDevices).toHaveBeenLastCalledWith(deviceInfos, payload);
});
});
});
@@ -1,15 +1,15 @@
import * as utils from "../test-utils/test-utils";
import { PushProcessor } from "../../src/pushprocessor";
import { EventType } from "../../src";
import { EventType, MatrixClient, MatrixEvent } from "../../src";
describe('NotificationService', function() {
const testUserId = "@ali:matrix.org";
const testDisplayName = "Alice M";
const testRoomId = "!fl1bb13:localhost";
let testEvent;
let testEvent: MatrixEvent;
let pushProcessor;
let pushProcessor: PushProcessor;
// These would be better if individual rules were configured in the tests themselves.
const matrixClient = {
@@ -196,7 +196,7 @@ describe('NotificationService', function() {
],
},
},
};
} as unknown as MatrixClient;
beforeEach(function() {
testEvent = utils.mkEvent({
+349
View File
@@ -0,0 +1,349 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import MockHttpBackend from 'matrix-mock-request';
import { indexedDB as fakeIndexedDB } from 'fake-indexeddb';
import { IHttpOpts, IndexedDBStore, MatrixEvent, MemoryStore, Room } from "../../src";
import { MatrixClient } from "../../src/client";
import { ToDeviceBatch } from '../../src/models/ToDeviceMessage';
import { logger } from '../../src/logger';
import { IStore } from '../../src/store';
const FAKE_USER = "@alice:example.org";
const FAKE_DEVICE_ID = "AAAAAAAA";
const FAKE_PAYLOAD = {
"foo": 42,
};
const EXPECTED_BODY = {
messages: {
[FAKE_USER]: {
[FAKE_DEVICE_ID]: FAKE_PAYLOAD,
},
},
};
const FAKE_MSG = {
userId: FAKE_USER,
deviceId: FAKE_DEVICE_ID,
payload: FAKE_PAYLOAD,
};
enum StoreType {
Memory = 'Memory',
IndexedDB = 'IndexedDB',
}
// Jest now uses @sinonjs/fake-timers which exposes tickAsync() and a number of
// other async methods which break the event loop, letting scheduled promise
// callbacks run. Unfortunately, Jest doesn't expose these, so we have to do
// it manually (this is what sinon does under the hood). We do both in a loop
// until the thing we expect happens: hopefully this is the least flakey way
// and avoids assuming anything about the app's behaviour.
const realSetTimeout = setTimeout;
function flushPromises() {
return new Promise(r => {
realSetTimeout(r, 1);
});
}
async function flushAndRunTimersUntil(cond: () => boolean) {
while (!cond()) {
await flushPromises();
if (cond()) break;
jest.advanceTimersToNextTimer();
}
}
describe.each([
[StoreType.Memory], [StoreType.IndexedDB],
])("queueToDevice (%s store)", function(storeType) {
let httpBackend: MockHttpBackend;
let client: MatrixClient;
beforeEach(async function() {
httpBackend = new MockHttpBackend();
let store: IStore;
if (storeType === StoreType.IndexedDB) {
const idbStore = new IndexedDBStore({ indexedDB: fakeIndexedDB });
await idbStore.startup();
store = idbStore;
} else {
store = new MemoryStore();
}
client = new MatrixClient({
baseUrl: "https://my.home.server",
accessToken: "my.access.token",
request: httpBackend.requestFn as IHttpOpts["request"],
store,
});
});
afterEach(function() {
jest.useRealTimers();
client.stopClient();
});
it("sends a to-device message", async function() {
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).check((request) => {
expect(request.data).toEqual(EXPECTED_BODY);
}).respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [
FAKE_MSG,
],
});
await httpBackend.flushAllExpected();
// let the code handle the response to the request so we don't get
// log output after the test has finished (apparently stopping the
// client in aftereach is not sufficient.)
await flushPromises();
});
it("retries on error", async function() {
jest.useFakeTimers();
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(500);
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).check((request) => {
expect(request.data).toEqual(EXPECTED_BODY);
}).respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [
FAKE_MSG,
],
});
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(null, 1)).toEqual(1);
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(null, 1)).toEqual(1);
// flush, as per comment in first test
await flushPromises();
});
it("stops retrying on 4xx errors", async function() {
jest.useFakeTimers();
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(400);
await client.queueToDevice({
eventType: "org.example.foo",
batch: [
FAKE_MSG,
],
});
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(null, 1)).toEqual(1);
// Asserting that another request is never made is obviously
// a bit tricky - we just flush the queue what should hopefully
// be plenty of times and assert that nothing comes through.
let tries = 0;
await flushAndRunTimersUntil(() => ++tries === 10);
expect(httpBackend.requests.length).toEqual(0);
});
it("honours ratelimiting", async function() {
jest.useFakeTimers();
// pick something obscure enough it's unlikley to clash with a
// retry delay the algorithm uses anyway
const retryDelay = 279 * 1000;
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(429, {
errcode: "M_LIMIT_EXCEEDED",
retry_after_ms: retryDelay,
});
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [
FAKE_MSG,
],
});
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(null, 1)).toEqual(1);
await flushPromises();
logger.info("Advancing clock to just before expected retry time...");
jest.advanceTimersByTime(retryDelay - 1000);
await flushPromises();
expect(httpBackend.requests.length).toEqual(0);
logger.info("Advancing clock past expected retry time...");
jest.advanceTimersByTime(2000);
await flushPromises();
expect(httpBackend.flushSync(null, 1)).toEqual(1);
});
it("retries on retryImmediately()", async function() {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["r0.0.1"],
});
await Promise.all([client.startClient(), httpBackend.flush(null, 1, 20)]);
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(500);
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [
FAKE_MSG,
],
});
expect(await httpBackend.flush(null, 1, 1)).toEqual(1);
await flushPromises();
client.retryImmediately();
// longer timeout here to try & avoid flakiness
expect(await httpBackend.flush(null, 1, 3000)).toEqual(1);
});
it("retries on when client is started", async function() {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["r0.0.1"],
});
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(500);
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [
FAKE_MSG,
],
});
expect(await httpBackend.flush(null, 1, 1)).toEqual(1);
await flushPromises();
client.stopClient();
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
expect(await httpBackend.flush(null, 1, 20)).toEqual(1);
});
it("retries when a message is retried", async function() {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["r0.0.1"],
});
await Promise.all([client.startClient(), httpBackend.flush(null, 1, 20)]);
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(500);
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [
FAKE_MSG,
],
});
expect(await httpBackend.flush(null, 1, 1)).toEqual(1);
await flushPromises();
const dummyEvent = new MatrixEvent({
event_id: "!fake:example.org",
});
const mockRoom = {
updatePendingEvent: jest.fn(),
} as unknown as Room;
client.resendEvent(dummyEvent, mockRoom);
expect(await httpBackend.flush(null, 1, 20)).toEqual(1);
});
it("splits many messages into multiple HTTP requests", async function() {
const batch: ToDeviceBatch = {
eventType: "org.example.foo",
batch: [],
};
for (let i = 0; i <= 20; ++i) {
batch.batch.push({
userId: `@user${i}:example.org`,
deviceId: FAKE_DEVICE_ID,
payload: FAKE_PAYLOAD,
});
}
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).check((request) => {
expect(Object.keys(request.data.messages).length).toEqual(20);
}).respond(200, {});
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).check((request) => {
expect(Object.keys(request.data.messages).length).toEqual(1);
}).respond(200, {});
await client.queueToDevice(batch);
await httpBackend.flushAllExpected();
// flush, as per comment in first test
await flushPromises();
});
});
@@ -21,7 +21,7 @@ describe("realtime-callbacks", function() {
it("should default to a zero timeout", function() {
const callback = jest.fn();
callbacks.setTimeout(callback);
callbacks.setTimeout(callback, 0);
expect(callback).not.toHaveBeenCalled();
tick(0);
@@ -38,11 +38,11 @@ describe("realtime-callbacks", function() {
it("should set 'this' to the global object", function() {
let passed = false;
const callback = function() {
expect(this).toBe(global); // eslint-disable-line @babel/no-invalid-this
expect(this.console).toBeTruthy(); // eslint-disable-line @babel/no-invalid-this
expect(this).toBe(global); // eslint-disable-line @typescript-eslint/no-invalid-this
expect(this.console).toBeTruthy(); // eslint-disable-line @typescript-eslint/no-invalid-this
passed = true;
};
callbacks.setTimeout(callback);
callbacks.setTimeout(callback, 0);
tick(0);
expect(passed).toBe(true);
});
@@ -92,7 +92,7 @@ describe("realtime-callbacks", function() {
expect(callback2).not.toHaveBeenCalled();
});
callbacks.setTimeout(callback1);
callbacks.setTimeout(callback1, 0);
callbacks.setTimeout(callback2, -100);
expect(callback1).not.toHaveBeenCalled();
@@ -109,14 +109,14 @@ describe("realtime-callbacks", function() {
expect(callback2).not.toHaveBeenCalled();
});
callbacks.setTimeout(callback1);
callbacks.setTimeout(callback1, 1);
expect(callback1).not.toHaveBeenCalled();
expect(callback2).not.toHaveBeenCalled();
tick(0);
tick(1);
expect(callback1).toHaveBeenCalled();
// the fake timer won't actually run callbacks registered during
// one tick until the next tick.
tick(1);
tick(2);
expect(callback2).toHaveBeenCalled();
});
@@ -139,9 +139,9 @@ describe("realtime-callbacks", function() {
describe("cancelTimeout", function() {
it("should cancel a pending timeout", function() {
const callback = jest.fn();
const k = callbacks.setTimeout(callback);
const k = callbacks.setTimeout(callback, 10);
callbacks.clearTimeout(k);
tick(0);
tick(11);
expect(callback).not.toHaveBeenCalled();
});
+89 -9
View File
@@ -2435,16 +2435,96 @@ describe("Room", function() {
expect(room.getEventReadUpTo(userA)).toEqual("eventId");
});
it("prefers older receipt", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
return (receiptType === ReceiptType.Read
? { eventId: "eventId1" }
: { eventId: "eventId2" }
) as IWrappedReceipt;
};
room.getUnfilteredTimelineSet = () => ({ compareEventOrdering: (event1, event2) => 1 } as EventTimelineSet);
describe("prefers newer receipt", () => {
it("should compare correctly using timelines", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1" } as IWrappedReceipt;
}
if (receiptType === ReceiptType.UnstableReadPrivate) {
return { eventId: "eventId2" } as IWrappedReceipt;
}
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId3" } as IWrappedReceipt;
}
};
expect(room.getEventReadUpTo(userA)).toEqual("eventId1");
for (let i = 1; i <= 3; i++) {
room.getUnfilteredTimelineSet = () => ({ compareEventOrdering: (event1, event2) => {
return (event1 === `eventId${i}`) ? 1 : -1;
} } as EventTimelineSet);
expect(room.getEventReadUpTo(userA)).toEqual(`eventId${i}`);
}
});
it("should compare correctly by timestamp", () => {
for (let i = 1; i <= 3; i++) {
room.getUnfilteredTimelineSet = () => ({
compareEventOrdering: (_1, _2) => null,
} as EventTimelineSet);
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1", data: { ts: i === 1 ? 1 : 0 } } as IWrappedReceipt;
}
if (receiptType === ReceiptType.UnstableReadPrivate) {
return { eventId: "eventId2", data: { ts: i === 2 ? 1 : 0 } } as IWrappedReceipt;
}
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId3", data: { ts: i === 3 ? 1 : 0 } } as IWrappedReceipt;
}
};
expect(room.getEventReadUpTo(userA)).toEqual(`eventId${i}`);
}
});
describe("fallback precedence", () => {
beforeAll(() => {
room.getUnfilteredTimelineSet = () => ({
compareEventOrdering: (_1, _2) => null,
} as EventTimelineSet);
});
it("should give precedence to m.read.private", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1" } as IWrappedReceipt;
}
if (receiptType === ReceiptType.UnstableReadPrivate) {
return { eventId: "eventId2" } as IWrappedReceipt;
}
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId3" } as IWrappedReceipt;
}
};
expect(room.getEventReadUpTo(userA)).toEqual(`eventId1`);
});
it("should give precedence to org.matrix.msc2285.read.private", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
if (receiptType === ReceiptType.UnstableReadPrivate) {
return { eventId: "eventId2" } as IWrappedReceipt;
}
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2" } as IWrappedReceipt;
}
};
expect(room.getEventReadUpTo(userA)).toEqual(`eventId2`);
});
it("should give precedence to m.read", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId3" } as IWrappedReceipt;
}
};
expect(room.getEventReadUpTo(userA)).toEqual(`eventId3`);
});
});
});
});
});
@@ -45,8 +45,8 @@ describe("MatrixScheduler", function() {
queueFn = function() {
return "one_big_queue";
};
const deferA = defer();
const deferB = defer();
const deferA = defer<Record<string, boolean>>();
const deferB = defer<Record<string, boolean>>();
let yieldedA = false;
scheduler.setProcessFunction(function(event) {
if (yieldedA) {
@@ -70,84 +70,84 @@ describe("MatrixScheduler", function() {
});
it("should invoke the retryFn on failure and wait the amount of time specified",
async function() {
const waitTimeMs = 1500;
const retryDefer = defer();
retryFn = function() {
retryDefer.resolve();
return waitTimeMs;
};
queueFn = function() {
return "yep";
};
async function() {
const waitTimeMs = 1500;
const retryDefer = defer();
retryFn = function() {
retryDefer.resolve();
return waitTimeMs;
};
queueFn = function() {
return "yep";
};
let procCount = 0;
scheduler.setProcessFunction(function(ev) {
procCount += 1;
if (procCount === 1) {
expect(ev).toEqual(eventA);
return deferred.promise;
} else if (procCount === 2) {
let procCount = 0;
scheduler.setProcessFunction(function(ev) {
procCount += 1;
if (procCount === 1) {
expect(ev).toEqual(eventA);
return deferred.promise;
} else if (procCount === 2) {
// don't care about this deferred
return new Promise();
}
expect(procCount).toBeLessThan(3);
return new Promise(() => {});
}
expect(procCount).toBeLessThan(3);
});
scheduler.queueEvent(eventA);
// as queueing doesn't start processing synchronously anymore (see commit bbdb5ac)
// wait just long enough before it does
await Promise.resolve();
expect(procCount).toEqual(1);
deferred.reject({});
await retryDefer.promise;
expect(procCount).toEqual(1);
jest.advanceTimersByTime(waitTimeMs);
await Promise.resolve();
expect(procCount).toEqual(2);
});
scheduler.queueEvent(eventA);
// as queueing doesn't start processing synchronously anymore (see commit bbdb5ac)
// wait just long enough before it does
await Promise.resolve();
expect(procCount).toEqual(1);
deferred.reject({});
await retryDefer.promise;
expect(procCount).toEqual(1);
jest.advanceTimersByTime(waitTimeMs);
await Promise.resolve();
expect(procCount).toEqual(2);
});
it("should give up if the retryFn on failure returns -1 and try the next event",
async function() {
async function() {
// Queue A & B.
// Reject A and return -1 on retry.
// Expect B to be tried next and the promise for A to be rejected.
retryFn = function() {
return -1;
};
queueFn = function() {
return "yep";
};
retryFn = function() {
return -1;
};
queueFn = function() {
return "yep";
};
const deferA = defer();
const deferB = defer();
let procCount = 0;
scheduler.setProcessFunction(function(ev) {
procCount += 1;
if (procCount === 1) {
expect(ev).toEqual(eventA);
return deferA.promise;
} else if (procCount === 2) {
expect(ev).toEqual(eventB);
return deferB.promise;
}
expect(procCount).toBeLessThan(3);
});
const deferA = defer();
const deferB = defer();
let procCount = 0;
scheduler.setProcessFunction(function(ev) {
procCount += 1;
if (procCount === 1) {
expect(ev).toEqual(eventA);
return deferA.promise;
} else if (procCount === 2) {
expect(ev).toEqual(eventB);
return deferB.promise;
}
expect(procCount).toBeLessThan(3);
});
const globalA = scheduler.queueEvent(eventA);
scheduler.queueEvent(eventB);
// as queueing doesn't start processing synchronously anymore (see commit bbdb5ac)
// wait just long enough before it does
await Promise.resolve();
expect(procCount).toEqual(1);
deferA.reject({});
try {
await globalA;
} catch (err) {
const globalA = scheduler.queueEvent(eventA);
scheduler.queueEvent(eventB);
// as queueing doesn't start processing synchronously anymore (see commit bbdb5ac)
// wait just long enough before it does
await Promise.resolve();
expect(procCount).toEqual(2);
}
});
expect(procCount).toEqual(1);
deferA.reject({});
try {
await globalA;
} catch (err) {
await Promise.resolve();
expect(procCount).toEqual(2);
}
});
it("should treat each queue separately", function(done) {
// Queue messages A B C D.
@@ -175,7 +175,7 @@ describe("MatrixScheduler", function() {
const expectOrder = [
eventA.getId(), eventB.getId(), eventD.getId(),
];
const deferA = defer();
const deferA = defer<void>();
scheduler.setProcessFunction(function(event) {
const id = expectOrder.shift();
expect(id).toEqual(event.getId());
@@ -191,7 +191,7 @@ describe("MatrixScheduler", function() {
// wait a bit then resolve A and we should get D (not C) next.
setTimeout(function() {
deferA.resolve({});
deferA.resolve();
}, 1000);
jest.advanceTimersByTime(1000);
});
@@ -336,28 +336,29 @@ describe("MatrixScheduler", function() {
errcode: "M_LIMIT_EXCEEDED", retry_after_ms: 5000,
}),
);
expect(res >= 500).toBe(true, "Didn't wait long enough.");
expect(res >= 500).toBe(true);
});
it("should give up after 5 attempts", function() {
const res = MatrixScheduler.RETRY_BACKOFF_RATELIMIT(
eventA, 5, {},
eventA, 5, new MatrixError({}),
);
expect(res).toBe(-1, "Didn't give up.");
expect(res).toBe(-1);
});
it("should do exponential backoff", function() {
const error = new MatrixError({});
expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT(
eventA, 1, {},
eventA, 1, error,
)).toEqual(2000);
expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT(
eventA, 2, {},
eventA, 2, error,
)).toEqual(4000);
expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT(
eventA, 3, {},
eventA, 3, error,
)).toEqual(8000);
expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT(
eventA, 4, {},
eventA, 4, error,
)).toEqual(16000);
});
});
@@ -1,6 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -302,6 +302,9 @@ describe("SyncAccumulator", function() {
[ReceiptType.ReadPrivate]: {
"@dan:localhost": { ts: 4 },
},
[ReceiptType.UnstableReadPrivate]: {
"@matthew:localhost": { ts: 5 },
},
"some.other.receipt.type": {
"@should_be_ignored:localhost": { key: "val" },
},
@@ -347,6 +350,9 @@ describe("SyncAccumulator", function() {
[ReceiptType.ReadPrivate]: {
"@dan:localhost": { ts: 4 },
},
[ReceiptType.UnstableReadPrivate]: {
"@matthew:localhost": { ts: 5 },
},
},
"$event2:localhost": {
[ReceiptType.Read]: {
@@ -384,6 +390,10 @@ describe("SyncAccumulator", function() {
};
}
afterEach(() => {
jest.spyOn(global.Date, 'now').mockRestore();
});
it("should copy summary properties", function() {
sa.accumulate(createSyncResponseWithSummary({
"m.heroes": ["@alice:bar"],
@@ -413,25 +423,19 @@ describe("SyncAccumulator", function() {
const delta = 1000;
const startingTs = 1000;
const oldDateNow = Date.now;
try {
Date.now = jest.fn();
Date.now.mockReturnValue(startingTs);
jest.spyOn(global.Date, 'now').mockReturnValue(startingTs);
sa.accumulate(RES_WITH_AGE);
sa.accumulate(RES_WITH_AGE);
Date.now.mockReturnValue(startingTs + delta);
jest.spyOn(global.Date, 'now').mockReturnValue(startingTs + delta);
const output = sa.getJSON();
expect(output.roomsData.join["!foo:bar"].timeline.events[0].unsigned.age).toEqual(
RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0].unsigned.age + delta,
);
expect(Object.keys(output.roomsData.join["!foo:bar"].timeline.events[0])).toEqual(
Object.keys(RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0]),
);
} finally {
Date.now = oldDateNow;
}
const output = sa.getJSON();
expect(output.roomsData.join["!foo:bar"].timeline.events[0].unsigned.age).toEqual(
RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0].unsigned.age + delta,
);
expect(Object.keys(output.roomsData.join["!foo:bar"].timeline.events[0])).toEqual(
Object.keys(RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0]),
);
});
it("should mangle age without adding extra keys", () => {
-456
View File
@@ -1,456 +0,0 @@
import { EventTimeline } from "../../src/models/event-timeline";
import { TimelineIndex, TimelineWindow } from "../../src/timeline-window";
import * as utils from "../test-utils/test-utils";
const ROOM_ID = "roomId";
const USER_ID = "userId";
/*
* create a timeline with a bunch (default 3) events.
* baseIndex is 1 by default.
*/
function createTimeline(numEvents, baseIndex) {
if (numEvents === undefined) {
numEvents = 3;
}
if (baseIndex === undefined) {
baseIndex = 1;
}
// XXX: this is a horrid hack
const timelineSet = { room: { roomId: ROOM_ID } };
timelineSet.room.getUnfilteredTimelineSet = function() {
return timelineSet;
};
const timeline = new EventTimeline(timelineSet);
// add the events after the baseIndex first
addEventsToTimeline(timeline, numEvents - baseIndex, false);
// then add those before the baseIndex
addEventsToTimeline(timeline, baseIndex, true);
expect(timeline.getBaseIndex()).toEqual(baseIndex);
return timeline;
}
function addEventsToTimeline(timeline, numEvents, toStartOfTimeline) {
for (let i = 0; i < numEvents; i++) {
timeline.addEvent(
utils.mkMessage({
room: ROOM_ID, user: USER_ID,
event: true,
}),
{ toStartOfTimeline },
);
}
}
/*
* create a pair of linked timelines
*/
function createLinkedTimelines() {
const tl1 = createTimeline();
const tl2 = createTimeline();
tl1.setNeighbouringTimeline(tl2, EventTimeline.FORWARDS);
tl2.setNeighbouringTimeline(tl1, EventTimeline.BACKWARDS);
return [tl1, tl2];
}
describe("TimelineIndex", function() {
describe("minIndex", function() {
it("should return the min index relative to BaseIndex", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
expect(timelineIndex.minIndex()).toEqual(-1);
});
});
describe("maxIndex", function() {
it("should return the max index relative to BaseIndex", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
expect(timelineIndex.maxIndex()).toEqual(2);
});
});
describe("advance", function() {
it("should advance up to the end of the timeline", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
const result = timelineIndex.advance(3);
expect(result).toEqual(2);
expect(timelineIndex.index).toEqual(2);
});
it("should retreat back to the start of the timeline", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
const result = timelineIndex.advance(-2);
expect(result).toEqual(-1);
expect(timelineIndex.index).toEqual(-1);
});
it("should advance into the next timeline", function() {
const timelines = createLinkedTimelines();
const tl1 = timelines[0];
const tl2 = timelines[1];
// initialise the index pointing at the end of the first timeline
const timelineIndex = new TimelineIndex(tl1, 2);
const result = timelineIndex.advance(1);
expect(result).toEqual(1);
expect(timelineIndex.timeline).toBe(tl2);
// we expect the index to be the zero (ie, the same as the
// BaseIndex), because the BaseIndex points at the second event,
// and we've advanced past the first.
expect(timelineIndex.index).toEqual(0);
});
it("should retreat into the previous timeline", function() {
const timelines = createLinkedTimelines();
const tl1 = timelines[0];
const tl2 = timelines[1];
// initialise the index pointing at the start of the second
// timeline
const timelineIndex = new TimelineIndex(tl2, -1);
const result = timelineIndex.advance(-1);
expect(result).toEqual(-1);
expect(timelineIndex.timeline).toBe(tl1);
expect(timelineIndex.index).toEqual(1);
});
});
describe("retreat", function() {
it("should retreat up to the start of the timeline", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
const result = timelineIndex.retreat(2);
expect(result).toEqual(1);
expect(timelineIndex.index).toEqual(-1);
});
});
});
describe("TimelineWindow", function() {
/**
* create a dummy eventTimelineSet and client, and a TimelineWindow
* attached to them.
*/
let timelineSet;
let client;
function createWindow(timeline, opts) {
timelineSet = { getTimelineForEvent: () => null };
client = {};
client.getEventTimeline = function(timelineSet0, eventId0) {
expect(timelineSet0).toBe(timelineSet);
return Promise.resolve(timeline);
};
return new TimelineWindow(client, timelineSet, opts);
}
describe("load", function() {
it("should initialise from the live timeline", function() {
const liveTimeline = createTimeline();
const room = {};
room.getLiveTimeline = function() {
return liveTimeline;
};
const timelineWindow = new TimelineWindow(undefined, room);
return timelineWindow.load(undefined, 2).then(function() {
const expectedEvents = liveTimeline.getEvents().slice(1);
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
});
});
it("should initialise from a specific event", function() {
const timeline = createTimeline();
const eventId = timeline.getEvents()[1].getId();
const timelineSet = { getTimelineForEvent: () => null };
const client = {};
client.getEventTimeline = function(timelineSet0, eventId0) {
expect(timelineSet0).toBe(timelineSet);
expect(eventId0).toEqual(eventId);
return Promise.resolve(timeline);
};
const timelineWindow = new TimelineWindow(client, timelineSet);
return timelineWindow.load(eventId, 3).then(function() {
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
});
});
it("canPaginate should return false until load has returned", function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok1", EventTimeline.BACKWARDS);
timeline.setPaginationToken("toktok2", EventTimeline.FORWARDS);
const eventId = timeline.getEvents()[1].getId();
const timelineSet = { getTimelineForEvent: () => null };
const client = {};
const timelineWindow = new TimelineWindow(client, timelineSet);
client.getEventTimeline = function(timelineSet0, eventId0) {
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
return Promise.resolve(timeline);
};
return timelineWindow.load(eventId, 3).then(function() {
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
});
});
});
describe("pagination", function() {
it("should be able to advance across the initial timeline", function() {
const timeline = createTimeline();
const eventId = timeline.getEvents()[1].getId();
const timelineWindow = createWindow(timeline);
return timelineWindow.load(eventId, 1).then(function() {
const expectedEvents = [timeline.getEvents()[1]];
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
return timelineWindow.paginate(EventTimeline.FORWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
const expectedEvents = timeline.getEvents().slice(1);
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
return timelineWindow.paginate(EventTimeline.FORWARDS, 2);
}).then(function(success) {
expect(success).toBe(false);
return timelineWindow.paginate(EventTimeline.BACKWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
return timelineWindow.paginate(EventTimeline.BACKWARDS, 2);
}).then(function(success) {
expect(success).toBe(false);
});
});
it("should advance into next timeline", function() {
const tls = createLinkedTimelines();
const eventId = tls[0].getEvents()[1].getId();
const timelineWindow = createWindow(tls[0], { windowLimit: 5 });
return timelineWindow.load(eventId, 3).then(function() {
const expectedEvents = tls[0].getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
return timelineWindow.paginate(EventTimeline.FORWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
const expectedEvents = tls[0].getEvents()
.concat(tls[1].getEvents().slice(0, 2));
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
return timelineWindow.paginate(EventTimeline.FORWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
// the windowLimit should have made us drop an event from
// tls[0]
const expectedEvents = tls[0].getEvents().slice(1)
.concat(tls[1].getEvents());
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
return timelineWindow.paginate(EventTimeline.FORWARDS, 2);
}).then(function(success) {
expect(success).toBe(false);
});
});
it("should retreat into previous timeline", function() {
const tls = createLinkedTimelines();
const eventId = tls[1].getEvents()[1].getId();
const timelineWindow = createWindow(tls[1], { windowLimit: 5 });
return timelineWindow.load(eventId, 3).then(function() {
const expectedEvents = tls[1].getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
return timelineWindow.paginate(EventTimeline.BACKWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
const expectedEvents = tls[0].getEvents().slice(1, 3)
.concat(tls[1].getEvents());
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
return timelineWindow.paginate(EventTimeline.BACKWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
// the windowLimit should have made us drop an event from
// tls[1]
const expectedEvents = tls[0].getEvents()
.concat(tls[1].getEvents().slice(0, 2));
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
return timelineWindow.paginate(EventTimeline.BACKWARDS, 2);
}).then(function(success) {
expect(success).toBe(false);
});
});
it("should make forward pagination requests", function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok", EventTimeline.FORWARDS);
const timelineWindow = createWindow(timeline, { windowLimit: 5 });
const eventId = timeline.getEvents()[1].getId();
client.paginateEventTimeline = function(timeline0, opts) {
expect(timeline0).toBe(timeline);
expect(opts.backwards).toBe(false);
expect(opts.limit).toEqual(2);
addEventsToTimeline(timeline, 3, false);
return Promise.resolve(true);
};
return timelineWindow.load(eventId, 3).then(function() {
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
return timelineWindow.paginate(EventTimeline.FORWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
const expectedEvents = timeline.getEvents().slice(0, 5);
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
});
});
it("should make backward pagination requests", function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok", EventTimeline.BACKWARDS);
const timelineWindow = createWindow(timeline, { windowLimit: 5 });
const eventId = timeline.getEvents()[1].getId();
client.paginateEventTimeline = function(timeline0, opts) {
expect(timeline0).toBe(timeline);
expect(opts.backwards).toBe(true);
expect(opts.limit).toEqual(2);
addEventsToTimeline(timeline, 3, true);
return Promise.resolve(true);
};
return timelineWindow.load(eventId, 3).then(function() {
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
return timelineWindow.paginate(EventTimeline.BACKWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
const expectedEvents = timeline.getEvents().slice(1, 6);
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
});
});
it("should limit the number of unsuccessful pagination requests", function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok", EventTimeline.FORWARDS);
const timelineWindow = createWindow(timeline, { windowLimit: 5 });
const eventId = timeline.getEvents()[1].getId();
let paginateCount = 0;
client.paginateEventTimeline = function(timeline0, opts) {
expect(timeline0).toBe(timeline);
expect(opts.backwards).toBe(false);
expect(opts.limit).toEqual(2);
paginateCount += 1;
return Promise.resolve(true);
};
return timelineWindow.load(eventId, 3).then(function() {
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
return timelineWindow.paginate(EventTimeline.FORWARDS, 2, true, 3);
}).then(function(success) {
expect(success).toBe(false);
expect(paginateCount).toEqual(3);
const expectedEvents = timeline.getEvents().slice(0, 3);
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
});
});
});
});
+439
View File
@@ -0,0 +1,439 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MockedObject } from 'jest-mock';
import { MatrixClient } from "../../src/client";
import { EventTimelineSet } from "../../src/models/event-timeline-set";
import { Room } from "../../src/models/room";
import { EventTimeline } from "../../src/models/event-timeline";
import { TimelineIndex, TimelineWindow } from "../../src/timeline-window";
import { mkMessage } from "../test-utils/test-utils";
const ROOM_ID = "roomId";
const USER_ID = "userId";
const mockClient = {
getEventTimeline: jest.fn(),
paginateEventTimeline: jest.fn(),
} as unknown as MockedObject<MatrixClient>;
/*
* create a timeline with a bunch (default 3) events.
* baseIndex is 1 by default.
*/
function createTimeline(numEvents = 3, baseIndex = 1): EventTimeline {
const room = new Room(ROOM_ID, mockClient, USER_ID);
const timelineSet = new EventTimelineSet(room);
jest.spyOn(timelineSet.room, 'getUnfilteredTimelineSet').mockReturnValue(timelineSet);
const timeline = new EventTimeline(timelineSet);
// add the events after the baseIndex first
addEventsToTimeline(timeline, numEvents - baseIndex, false);
// then add those before the baseIndex
addEventsToTimeline(timeline, baseIndex, true);
expect(timeline.getBaseIndex()).toEqual(baseIndex);
return timeline;
}
function addEventsToTimeline(timeline: EventTimeline, numEvents: number, toStartOfTimeline: boolean) {
for (let i = 0; i < numEvents; i++) {
timeline.addEvent(
mkMessage({
room: ROOM_ID, user: USER_ID,
event: true,
}),
{ toStartOfTimeline },
);
}
}
/*
* create a pair of linked timelines
*/
function createLinkedTimelines(): [EventTimeline, EventTimeline] {
const tl1 = createTimeline();
const tl2 = createTimeline();
tl1.setNeighbouringTimeline(tl2, EventTimeline.FORWARDS);
tl2.setNeighbouringTimeline(tl1, EventTimeline.BACKWARDS);
return [tl1, tl2];
}
describe("TimelineIndex", function() {
beforeEach(() => {
jest.clearAllMocks();
mockClient.getEventTimeline.mockResolvedValue(undefined);
});
describe("minIndex", function() {
it("should return the min index relative to BaseIndex", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
expect(timelineIndex.minIndex()).toEqual(-1);
});
});
describe("maxIndex", function() {
it("should return the max index relative to BaseIndex", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
expect(timelineIndex.maxIndex()).toEqual(2);
});
});
describe("advance", function() {
it("should advance up to the end of the timeline", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
const result = timelineIndex.advance(3);
expect(result).toEqual(2);
expect(timelineIndex.index).toEqual(2);
});
it("should retreat back to the start of the timeline", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
const result = timelineIndex.advance(-2);
expect(result).toEqual(-1);
expect(timelineIndex.index).toEqual(-1);
});
it("should advance into the next timeline", function() {
const timelines = createLinkedTimelines();
const tl1 = timelines[0];
const tl2 = timelines[1];
// initialise the index pointing at the end of the first timeline
const timelineIndex = new TimelineIndex(tl1, 2);
const result = timelineIndex.advance(1);
expect(result).toEqual(1);
expect(timelineIndex.timeline).toBe(tl2);
// we expect the index to be the zero (ie, the same as the
// BaseIndex), because the BaseIndex points at the second event,
// and we've advanced past the first.
expect(timelineIndex.index).toEqual(0);
});
it("should retreat into the previous timeline", function() {
const timelines = createLinkedTimelines();
const tl1 = timelines[0];
const tl2 = timelines[1];
// initialise the index pointing at the start of the second
// timeline
const timelineIndex = new TimelineIndex(tl2, -1);
const result = timelineIndex.advance(-1);
expect(result).toEqual(-1);
expect(timelineIndex.timeline).toBe(tl1);
expect(timelineIndex.index).toEqual(1);
});
});
describe("retreat", function() {
it("should retreat up to the start of the timeline", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
const result = timelineIndex.retreat(2);
expect(result).toEqual(1);
expect(timelineIndex.index).toEqual(-1);
});
});
});
describe("TimelineWindow", function() {
/**
* create a dummy eventTimelineSet and client, and a TimelineWindow
* attached to them.
*/
function createWindow(timeline: EventTimeline, opts?: {
windowLimit?: number;
}): [TimelineWindow, EventTimelineSet] {
const timelineSet = { getTimelineForEvent: () => null } as unknown as EventTimelineSet;
mockClient.getEventTimeline.mockResolvedValue(timeline);
return [new TimelineWindow(mockClient, timelineSet, opts), timelineSet];
}
beforeEach(() => {
jest.clearAllMocks();
mockClient.getEventTimeline.mockResolvedValue(undefined);
mockClient.paginateEventTimeline.mockReturnValue(undefined);
});
describe("load", function() {
it("should initialise from the live timeline", async function() {
const liveTimeline = createTimeline();
const room = new Room(ROOM_ID, mockClient, USER_ID);
const timelineSet = new EventTimelineSet(room);
jest.spyOn(timelineSet, 'getLiveTimeline').mockReturnValue(liveTimeline);
const timelineWindow = new TimelineWindow(mockClient, timelineSet);
await timelineWindow.load(undefined, 2);
expect(timelineSet.getLiveTimeline).toHaveBeenCalled();
const expectedEvents = liveTimeline.getEvents().slice(1);
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
});
it("should initialise from a specific event", async function() {
const timeline = createTimeline();
const eventId = timeline.getEvents()[1].getId();
const timelineSet = { getTimelineForEvent: () => null } as unknown as EventTimelineSet;
mockClient.getEventTimeline.mockResolvedValue(timeline);
const timelineWindow = new TimelineWindow(mockClient, timelineSet);
await timelineWindow.load(eventId, 3);
expect(mockClient.getEventTimeline).toHaveBeenCalledWith(timelineSet, eventId);
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
});
it("canPaginate should return false until load has returned", async function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok1", EventTimeline.BACKWARDS);
timeline.setPaginationToken("toktok2", EventTimeline.FORWARDS);
const eventId = timeline.getEvents()[1].getId();
const timelineSet = { getTimelineForEvent: () => null } as unknown as EventTimelineSet;
mockClient.getEventTimeline.mockResolvedValue(timeline);
const timelineWindow = new TimelineWindow(mockClient, timelineSet);
const timelineWindowLoadPromise = timelineWindow.load(eventId, 3);
// cannot paginate before load is complete
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(false);
// wait for load
await timelineWindowLoadPromise;
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
// can paginate now
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
});
});
describe("pagination", function() {
it("should be able to advance across the initial timeline", async function() {
const timeline = createTimeline();
const eventId = timeline.getEvents()[1].getId();
const [timelineWindow] = createWindow(timeline);
await timelineWindow.load(eventId, 1);
const expectedEvents = [timeline.getEvents()[1]];
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(true);
const expectedEventsAfterPagination = timeline.getEvents().slice(1);
expect(timelineWindow.getEvents()).toEqual(expectedEventsAfterPagination);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
// cant paginate forward anymore
expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(false);
// paginate back again
expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(true);
const expectedEvents3 = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents3);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(false);
});
it("should advance into next timeline", async function() {
const tls = createLinkedTimelines();
const eventId = tls[0].getEvents()[1].getId();
const [timelineWindow] = createWindow(tls[0], { windowLimit: 5 });
await timelineWindow.load(eventId, 3);
const expectedEvents = tls[0].getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(true);
const expectedEvents2 = tls[0].getEvents()
.concat(tls[1].getEvents().slice(0, 2));
expect(timelineWindow.getEvents()).toEqual(expectedEvents2);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(true);
// the windowLimit should have made us drop an event from
// tls[0]
const expectedEvents3 = tls[0].getEvents().slice(1)
.concat(tls[1].getEvents());
expect(timelineWindow.getEvents()).toEqual(expectedEvents3);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(false);
});
it("should retreat into previous timeline", async function() {
const tls = createLinkedTimelines();
const eventId = tls[1].getEvents()[1].getId();
const [timelineWindow] = createWindow(tls[1], { windowLimit: 5 });
await timelineWindow.load(eventId, 3);
const expectedEvents = tls[1].getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(true);
const expectedEvents2 = tls[0].getEvents().slice(1, 3)
.concat(tls[1].getEvents());
expect(timelineWindow.getEvents()).toEqual(expectedEvents2);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(true);
// the windowLimit should have made us drop an event from
// tls[1]
const expectedEvents3 = tls[0].getEvents()
.concat(tls[1].getEvents().slice(0, 2));
expect(timelineWindow.getEvents()).toEqual(expectedEvents3);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(false);
});
it("should make forward pagination requests", async function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok", EventTimeline.FORWARDS);
const [timelineWindow] = createWindow(timeline, { windowLimit: 5 });
const eventId = timeline.getEvents()[1].getId();
mockClient.paginateEventTimeline.mockImplementation(async (_t, _opts) => {
addEventsToTimeline(timeline, 3, false);
return true;
});
await timelineWindow.load(eventId, 3);
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(true);
expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(true);
expect(mockClient.paginateEventTimeline).toHaveBeenCalledWith(timeline, { backwards: false, limit: 2 });
const expectedEvents2 = timeline.getEvents().slice(0, 5);
expect(timelineWindow.getEvents()).toEqual(expectedEvents2);
});
it("should make backward pagination requests", async function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok", EventTimeline.BACKWARDS);
const [timelineWindow] = createWindow(timeline, { windowLimit: 5 });
const eventId = timeline.getEvents()[1].getId();
mockClient.paginateEventTimeline.mockImplementation(async (_t, _opts) => {
addEventsToTimeline(timeline, 3, true);
return true;
});
await timelineWindow.load(eventId, 3);
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(false);
expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(true);
expect(mockClient.paginateEventTimeline).toHaveBeenCalledWith(timeline, { backwards: true, limit: 2 });
const expectedEvents2 = timeline.getEvents().slice(1, 6);
expect(timelineWindow.getEvents()).toEqual(expectedEvents2);
});
it("should limit the number of unsuccessful pagination requests", async function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok", EventTimeline.FORWARDS);
const [timelineWindow] = createWindow(timeline, { windowLimit: 5 });
const eventId = timeline.getEvents()[1].getId();
mockClient.paginateEventTimeline.mockImplementation(async (_t, _opts) => {
return true;
});
await timelineWindow.load(eventId, 3);
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2, true, 3)).toBe(false);
expect(mockClient.paginateEventTimeline).toHaveBeenCalledWith(timeline, { backwards: false, limit: 2 });
expect(mockClient.paginateEventTimeline).toHaveBeenCalledTimes(3);
const expectedEvents2 = timeline.getEvents().slice(0, 3);
expect(timelineWindow.getEvents()).toEqual(expectedEvents2);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
});
});
});
@@ -1,16 +1,32 @@
import { User } from "../../src/models/user";
import * as utils from "../test-utils/test-utils";
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { User, UserEvent } from "../../src/models/user";
import { mkEvent } from "../test-utils/test-utils";
describe("User", function() {
const userId = "@alice:bar";
let user;
let user: User;
beforeEach(function() {
user = new User(userId);
});
describe("setPresenceEvent", function() {
const event = utils.mkEvent({
const event = mkEvent({
type: "m.presence", content: {
presence: "online",
user_id: userId,
@@ -22,7 +38,7 @@ describe("User", function() {
it("should emit 'User.displayName' if the display name changes", function() {
let emitCount = 0;
user.on("User.displayName", function(ev, usr) {
user.on(UserEvent.DisplayName, function(ev, usr) {
emitCount += 1;
});
user.setPresenceEvent(event);
@@ -33,7 +49,7 @@ describe("User", function() {
it("should emit 'User.avatarUrl' if the avatar URL changes", function() {
let emitCount = 0;
user.on("User.avatarUrl", function(ev, usr) {
user.on(UserEvent.AvatarUrl, function(ev, usr) {
emitCount += 1;
});
user.setPresenceEvent(event);
@@ -44,7 +60,7 @@ describe("User", function() {
it("should emit 'User.presence' if the presence changes", function() {
let emitCount = 0;
user.on("User.presence", function(ev, usr) {
user.on(UserEvent.Presence, function(ev, usr) {
emitCount += 1;
});
user.setPresenceEvent(event);
+51
View File
@@ -15,6 +15,7 @@ import {
import { logger } from "../../src/logger";
import { mkMessage } from "../test-utils/test-utils";
import { makeBeaconEvent } from "../test-utils/beacon";
import { ReceiptType } from "../../src/@types/read_receipts";
// TODO: Fix types throughout
@@ -523,4 +524,54 @@ describe("utils", function() {
).toEqual([beaconEvent2, beaconEvent1, beaconEvent3]);
});
});
describe('getPrivateReadReceiptField', () => {
it('should return m.read.private if server supports stable', async () => {
expect(await utils.getPrivateReadReceiptField({
doesServerSupportUnstableFeature: jest.fn().mockImplementation((feature) => {
return feature === "org.matrix.msc2285.stable";
}),
} as any)).toBe(ReceiptType.ReadPrivate);
});
it('should return m.read.private if server supports stable and unstable', async () => {
expect(await utils.getPrivateReadReceiptField({
doesServerSupportUnstableFeature: jest.fn().mockImplementation((feature) => {
return ["org.matrix.msc2285.stable", "org.matrix.msc2285"].includes(feature);
}),
} as any)).toBe(ReceiptType.ReadPrivate);
});
it('should return org.matrix.msc2285.read.private if server supports unstable', async () => {
expect(await utils.getPrivateReadReceiptField({
doesServerSupportUnstableFeature: jest.fn().mockImplementation((feature) => {
return feature === "org.matrix.msc2285";
}),
} as any)).toBe(ReceiptType.UnstableReadPrivate);
});
it('should return none if server does not support either', async () => {
expect(await utils.getPrivateReadReceiptField({
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
} as any)).toBeFalsy();
});
});
describe('isSupportedReceiptType', () => {
it('should support m.read', () => {
expect(utils.isSupportedReceiptType(ReceiptType.Read)).toBeTruthy();
});
it('should support m.read.private', () => {
expect(utils.isSupportedReceiptType(ReceiptType.ReadPrivate)).toBeTruthy();
});
it('should support org.matrix.msc2285.read.private', () => {
expect(utils.isSupportedReceiptType(ReceiptType.UnstableReadPrivate)).toBeTruthy();
});
it('should not support other receipt types', () => {
expect(utils.isSupportedReceiptType("this is a receipt type")).toBeFalsy();
});
});
});
+59 -65
View File
@@ -391,71 +391,6 @@ describe('Call', function() {
}).track.id).toBe("video_track");
});
describe("should handle stream replacement", () => {
it("with both purpose and id", async () => {
await startVoiceCall(client, call);
call.updateRemoteSDPStreamMetadata({
"remote_stream1": {
purpose: SDPStreamMetadataPurpose.Usermedia,
},
});
call.pushRemoteFeed(new MockMediaStream("remote_stream1", []));
const feed = call.getFeeds().find((feed) => feed.stream.id === "remote_stream1");
call.updateRemoteSDPStreamMetadata({
"remote_stream2": {
purpose: SDPStreamMetadataPurpose.Usermedia,
},
});
call.pushRemoteFeed(new MockMediaStream("remote_stream2", []));
expect(feed?.stream?.id).toBe("remote_stream2");
});
it("with just purpose", async () => {
await startVoiceCall(client, call);
call.updateRemoteSDPStreamMetadata({
"remote_stream1": {
purpose: SDPStreamMetadataPurpose.Usermedia,
},
});
call.pushRemoteFeed(new MockMediaStream("remote_stream1", []));
const feed = call.getFeeds().find((feed) => feed.stream.id === "remote_stream1");
call.updateRemoteSDPStreamMetadata({
"remote_stream2": {
purpose: SDPStreamMetadataPurpose.Usermedia,
},
});
call.pushRemoteFeed(new MockMediaStream("remote_stream2", []));
expect(feed?.stream?.id).toBe("remote_stream2");
});
it("should not replace purpose is different", async () => {
await startVoiceCall(client, call);
call.updateRemoteSDPStreamMetadata({
"remote_stream1": {
purpose: SDPStreamMetadataPurpose.Usermedia,
},
});
call.pushRemoteFeed(new MockMediaStream("remote_stream1", []));
const feed = call.getFeeds().find((feed) => feed.stream.id === "remote_stream1");
call.updateRemoteSDPStreamMetadata({
"remote_stream2": {
purpose: SDPStreamMetadataPurpose.Screenshare,
},
});
call.pushRemoteFeed(new MockMediaStream("remote_stream2", []));
expect(feed?.stream?.id).toBe("remote_stream1");
});
});
it("should handle SDPStreamMetadata changes", async () => {
await startVoiceCall(client, call);
@@ -758,4 +693,63 @@ describe('Call', function() {
expect(supportsMatrixCall()).toBe(false);
});
});
describe("ignoring streams with ids for which we already have a feed", () => {
const STREAM_ID = "stream_id";
const FEEDS_CHANGED_CALLBACK = jest.fn();
beforeEach(async () => {
await startVoiceCall(client, call);
call.on(CallEvent.FeedsChanged, FEEDS_CHANGED_CALLBACK);
jest.spyOn(call, "pushLocalFeed");
});
afterEach(() => {
FEEDS_CHANGED_CALLBACK.mockReset();
});
it("should ignore stream passed to pushRemoteFeed()", async () => {
await call.onAnswerReceived({
getContent: () => {
return {
version: 1,
call_id: call.callId,
party_id: 'party_id',
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {
[STREAM_ID]: {
purpose: SDPStreamMetadataPurpose.Usermedia,
},
},
};
},
});
call.pushRemoteFeed(new MockMediaStream(STREAM_ID));
call.pushRemoteFeed(new MockMediaStream(STREAM_ID));
expect(call.getRemoteFeeds().length).toBe(1);
expect(FEEDS_CHANGED_CALLBACK).toHaveBeenCalledTimes(1);
});
it("should ignore stream passed to pushRemoteFeedWithoutMetadata()", async () => {
call.pushRemoteFeedWithoutMetadata(new MockMediaStream(STREAM_ID));
call.pushRemoteFeedWithoutMetadata(new MockMediaStream(STREAM_ID));
expect(call.getRemoteFeeds().length).toBe(1);
expect(FEEDS_CHANGED_CALLBACK).toHaveBeenCalledTimes(1);
});
it("should ignore stream passed to pushNewLocalFeed()", async () => {
call.pushNewLocalFeed(new MockMediaStream(STREAM_ID), SDPStreamMetadataPurpose.Screenshare);
call.pushNewLocalFeed(new MockMediaStream(STREAM_ID), SDPStreamMetadataPurpose.Screenshare);
// We already have one local feed from placeVoiceCall()
expect(call.getLocalFeeds().length).toBe(2);
expect(FEEDS_CHANGED_CALLBACK).toHaveBeenCalledTimes(1);
expect(call.pushLocalFeed).toHaveBeenCalled();
});
});
});
+53 -25
View File
@@ -15,13 +15,11 @@ limitations under the License.
*/
import { SDPStreamMetadataPurpose } from "../../../src/webrtc/callEventTypes";
import { CallFeed, CallFeedEvent } from "../../../src/webrtc/callFeed";
import { MockMediaStream, MockMediaStreamTrack } from "../../test-utils/webrtc";
import { CallFeed } from "../../../src/webrtc/callFeed";
import { TestClient } from "../../TestClient";
import { MockMediaStream, MockMediaStreamTrack } from "../../test-utils/webrtc";
describe("CallFeed", () => {
const roomId = "room_id";
let client;
beforeEach(() => {
@@ -32,30 +30,60 @@ describe("CallFeed", () => {
client.stop();
});
it("should handle stream replacement", () => {
const feedNewStreamCallback = jest.fn();
const feed = new CallFeed({
client,
roomId,
userId: "user1",
// @ts-ignore Mock
stream: new MockMediaStream("stream1"),
id: "id",
purpose: SDPStreamMetadataPurpose.Usermedia,
audioMuted: false,
videoMuted: false,
describe("muting", () => {
let feed: CallFeed;
beforeEach(() => {
feed = new CallFeed({
client,
roomId: "room1",
userId: "user1",
// @ts-ignore Mock
stream: new MockMediaStream("stream1"),
purpose: SDPStreamMetadataPurpose.Usermedia,
audioMuted: false,
videoMuted: false,
});
});
feed.on(CallFeedEvent.NewStream, feedNewStreamCallback);
const replacementStream = new MockMediaStream("stream2");
// @ts-ignore Mock
feed.setNewStream(replacementStream);
expect(feedNewStreamCallback).toHaveBeenCalledWith(replacementStream);
expect(feed.stream).toBe(replacementStream);
describe("muting by default", () => {
it("should mute audio by default", () => {
expect(feed.isAudioMuted()).toBeTruthy();
});
feedNewStreamCallback.mockReset();
it("should mute video by default", () => {
expect(feed.isVideoMuted()).toBeTruthy();
});
});
replacementStream.addTrack(new MockMediaStreamTrack("track_id", "audio"));
expect(feedNewStreamCallback).toHaveBeenCalledWith(replacementStream);
describe("muting after adding a track", () => {
it("should un-mute audio", () => {
// @ts-ignore Mock
feed.stream.addTrack(new MockMediaStreamTrack("track", "audio", true));
expect(feed.isAudioMuted()).toBeFalsy();
});
it("should un-mute video", () => {
// @ts-ignore Mock
feed.stream.addTrack(new MockMediaStreamTrack("track", "video", true));
expect(feed.isVideoMuted()).toBeFalsy();
});
});
describe("muting after calling setAudioVideoMuted()", () => {
it("should mute audio by default ", () => {
// @ts-ignore Mock
feed.stream.addTrack(new MockMediaStreamTrack("track", "audio", true));
feed.setAudioVideoMuted(true, false);
expect(feed.isAudioMuted()).toBeTruthy();
});
it("should mute video by default", () => {
// @ts-ignore Mock
feed.stream.addTrack(new MockMediaStreamTrack("track", "video", true));
feed.setAudioVideoMuted(false, true);
expect(feed.isVideoMuted()).toBeTruthy();
});
});
});
});
+5 -1
View File
@@ -17,5 +17,9 @@ limitations under the License.
export enum ReceiptType {
Read = "m.read",
FullyRead = "m.fully_read",
ReadPrivate = "org.matrix.msc2285.read.private"
ReadPrivate = "m.read.private",
/**
* @deprecated Please use the ReadPrivate type when possible. This value may be removed at any time without notice.
*/
UnstableReadPrivate = "org.matrix.msc2285.read.private",
}
+1 -1
View File
@@ -113,7 +113,7 @@ export interface IRoomDirectoryOptions {
since?: string;
filter?: {
generic_search_term?: string;
"org.matrix.msc3827.room_types"?: Array<RoomType | null>;
room_types?: Array<RoomType | null>;
};
include_all_networks?: boolean;
third_party_instance_id?: string;
+125
View File
@@ -0,0 +1,125 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "./logger";
import { MatrixClient } from "./matrix";
import { IndexedToDeviceBatch, ToDeviceBatch, ToDeviceBatchWithTxnId, ToDevicePayload } from "./models/ToDeviceMessage";
import { MatrixScheduler } from "./scheduler";
const MAX_BATCH_SIZE = 20;
/**
* Maintains a queue of outgoing to-device messages, sending them
* as soon as the homeserver is reachable.
*/
export class ToDeviceMessageQueue {
private sending = false;
private running = true;
private retryTimeout: number = null;
private retryAttempts = 0;
constructor(private client: MatrixClient) {
}
public start(): void {
this.running = true;
this.sendQueue();
}
public stop(): void {
this.running = false;
if (this.retryTimeout !== null) clearTimeout(this.retryTimeout);
this.retryTimeout = null;
}
public async queueBatch(batch: ToDeviceBatch): Promise<void> {
const batches: ToDeviceBatchWithTxnId[] = [];
for (let i = 0; i < batch.batch.length; i += MAX_BATCH_SIZE) {
batches.push({
eventType: batch.eventType,
batch: batch.batch.slice(i, i + MAX_BATCH_SIZE),
txnId: this.client.makeTxnId(),
});
}
await this.client.store.saveToDeviceBatches(batches);
this.sendQueue();
}
public sendQueue = async (): Promise<void> => {
if (this.retryTimeout !== null) clearTimeout(this.retryTimeout);
this.retryTimeout = null;
if (this.sending || !this.running) return;
logger.debug("Attempting to send queued to-device messages");
this.sending = true;
let headBatch;
try {
while (this.running) {
headBatch = await this.client.store.getOldestToDeviceBatch();
if (headBatch === null) break;
await this.sendBatch(headBatch);
await this.client.store.removeToDeviceBatch(headBatch.id);
this.retryAttempts = 0;
}
// Make sure we're still running after the async tasks: if not, stop.
if (!this.running) return;
logger.debug("All queued to-device messages sent");
} catch (e) {
++this.retryAttempts;
// eslint-disable-next-line @typescript-eslint/naming-convention
// eslint-disable-next-line new-cap
const retryDelay = MatrixScheduler.RETRY_BACKOFF_RATELIMIT(null, this.retryAttempts, e);
if (retryDelay === -1) {
// the scheduler function doesn't differentiate between fatal errors and just getting
// bored and giving up for now
if (Math.floor(e.httpStatus / 100) === 4) {
logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
await this.client.store.removeToDeviceBatch(headBatch.id);
} else {
logger.info("Automatic retry limit reached for to-device messages.");
}
return;
}
logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
this.retryTimeout = setTimeout(this.sendQueue, retryDelay);
} finally {
this.sending = false;
}
};
/**
* Attempts to send a batch of to-device messages.
*/
private async sendBatch(batch: IndexedToDeviceBatch): Promise<void> {
const contentMap: Record<string, Record<string, ToDevicePayload>> = {};
for (const item of batch.batch) {
if (!contentMap[item.userId]) {
contentMap[item.userId] = {};
}
contentMap[item.userId][item.deviceId] = item.payload;
}
logger.info(`Sending batch of ${batch.batch.length} to-device messages with ID ${batch.id}`);
await this.client.sendToDevice(batch.eventType, contentMap, batch.txnId);
}
}
+92 -17
View File
@@ -1,5 +1,5 @@
/*
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Copyright 2015-2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -42,7 +42,8 @@ import { IActionsObject, PushProcessor } from "./pushprocessor";
import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery";
import * as olmlib from "./crypto/olmlib";
import { decodeBase64, encodeBase64 } from "./crypto/olmlib";
import { IExportedDevice as IOlmDevice } from "./crypto/OlmDevice";
import { IExportedDevice as IExportedOlmDevice } from "./crypto/OlmDevice";
import { IOlmDevice } from "./crypto/algorithms/megolm";
import { TypedReEmitter } from './ReEmitter';
import { IRoomEncryption, RoomList } from './crypto/RoomList';
import { logger } from './logger';
@@ -194,6 +195,8 @@ import { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, SlidingSync } fr
import { SlidingSyncSdk } from "./sliding-sync-sdk";
import { Thread, THREAD_RELATION_TYPE } from "./models/thread";
import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue";
import { ToDeviceBatch } from "./models/ToDeviceMessage";
export type Store = IStore;
@@ -206,7 +209,7 @@ const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value
const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes
interface IExportedDevice {
olmDevice: IOlmDevice;
olmDevice: IExportedOlmDevice;
userId: string;
deviceId: string;
}
@@ -502,7 +505,7 @@ interface ITurnServerResponse {
ttl: number;
}
interface ITurnServer {
export interface ITurnServer {
urls: string[];
username: string;
credential: string;
@@ -787,6 +790,8 @@ export enum ClientEvent {
DeleteRoom = "deleteRoom",
SyncUnexpectedError = "sync.unexpectedError",
ClientWellKnown = "WellKnown.client",
TurnServers = "turnServers",
TurnServersError = "turnServers.error",
}
type RoomEvents = RoomEvent.Name
@@ -857,6 +862,8 @@ export type ClientEventHandlerMap = {
[ClientEvent.DeleteRoom]: (roomId: string) => void;
[ClientEvent.SyncUnexpectedError]: (error: Error) => void;
[ClientEvent.ClientWellKnown]: (data: IClientWellKnown) => void;
[ClientEvent.TurnServers]: (servers: ITurnServer[]) => void;
[ClientEvent.TurnServersError]: (error: Error, fatal: boolean) => void;
} & RoomEventHandlerMap
& RoomStateEventHandlerMap
& CryptoEventHandlerMap
@@ -933,12 +940,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
protected clientWellKnownPromise: Promise<IClientWellKnown>;
protected turnServers: ITurnServer[] = [];
protected turnServersExpiry = 0;
protected checkTurnServersIntervalID: ReturnType<typeof setInterval>;
protected exportedOlmDeviceToImport: IOlmDevice;
protected checkTurnServersIntervalID: ReturnType<typeof setInterval> | null = null;
protected exportedOlmDeviceToImport: IExportedOlmDevice;
protected txnCtr = 0;
protected mediaHandler = new MediaHandler(this);
protected pendingEventEncryption = new Map<string, Promise<void>>();
private toDeviceMessageQueue: ToDeviceMessageQueue;
constructor(opts: IMatrixClientCreateOpts) {
super();
@@ -1033,6 +1042,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// we don't want to start sending unencrypted events to them.
this.roomList = new RoomList(this.cryptoStore);
this.toDeviceMessageQueue = new ToDeviceMessageQueue(this);
// The SDK doesn't really provide a clean way for events to recalculate the push
// actions for themselves, so we have to kinda help them out when they are encrypted.
// We do this so that push rules are correctly executed on events in their decrypted
@@ -1077,11 +1088,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// Figure out if we've read something or if it's just informational
const content = event.getContent();
const isSelf = Object.keys(content).filter(eid => {
const read = content[eid][ReceiptType.Read];
if (read && Object.keys(read).includes(this.getUserId())) return true;
for (const [key, value] of Object.entries(content[eid])) {
if (!utils.isSupportedReceiptType(key)) continue;
if (!value) continue;
const readPrivate = content[eid][ReceiptType.ReadPrivate];
if (readPrivate && Object.keys(readPrivate).includes(this.getUserId())) return true;
if (Object.keys(value).includes(this.getUserId())) return true;
}
return false;
}).length > 0;
@@ -1196,6 +1208,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}, 1000 * this.clientOpts.clientWellKnownPollPeriod);
this.fetchClientWellKnown();
}
this.toDeviceMessageQueue.start();
}
/**
@@ -1220,9 +1234,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.callEventHandler = null;
global.clearInterval(this.checkTurnServersIntervalID);
this.checkTurnServersIntervalID = null;
if (this.clientWellKnownIntervalID !== undefined) {
global.clearInterval(this.clientWellKnownIntervalID);
}
this.toDeviceMessageQueue.stop();
}
/**
@@ -1561,9 +1579,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* Retry a backed off syncing request immediately. This should only be used when
* the user <b>explicitly</b> attempts to retry their lost connection.
* Will also retry any outbound to-device messages currently in the queue to be sent
* (retries of regular outgoing events are handled separately, per-event).
* @return {boolean} True if this resulted in a request being retried.
*/
public retryImmediately(): boolean {
// don't await for this promise: we just want to kick it off
this.toDeviceMessageQueue.sendQueue();
return this.syncApi.retryImmediately();
}
@@ -2544,6 +2566,30 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.roomList.isRoomEncrypted(roomId);
}
/**
* Encrypts and sends a given object via Olm to-device messages to a given
* set of devices.
*
* @param {object[]} userDeviceInfoArr
* mapping from userId to deviceInfo
*
* @param {object} payload fields to include in the encrypted payload
* *
* @return {Promise<{contentMap, deviceInfoByDeviceId}>} Promise which
* resolves once the message has been encrypted and sent to the given
* userDeviceMap, and returns the { contentMap, deviceInfoByDeviceId }
* of the successfully sent messages.
*/
public encryptAndSendToDevices(
userDeviceInfoArr: IOlmDevice<DeviceInfo>[],
payload: object,
): Promise<void> {
if (!this.crypto) {
throw new Error("End-to-End encryption disabled");
}
return this.crypto.encryptAndSendToDevices(userDeviceInfoArr, payload);
}
/**
* Forces the current outbound group session to be discarded such
* that another one will be created next time an event is sent.
@@ -3500,7 +3546,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* Resend an event.
* Resend an event. Will also retry any to-device messages waiting to be sent.
* @param {MatrixEvent} event The event to resend.
* @param {Room} room Optional. The room the event is in. Will update the
* timeline entry if provided.
@@ -3508,6 +3554,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public resendEvent(event: MatrixEvent, room: Room): Promise<ISendEventResponse> {
// also kick the to-device queue to retry
this.toDeviceMessageQueue.sendQueue();
this.updatePendingEventStatus(room, event, EventStatus.SENDING);
return this.encryptAndSendEvent(room, event);
}
@@ -4612,7 +4661,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
room?.addLocalEchoReceipt(this.credentials.userId, rpEvent, ReceiptType.ReadPrivate);
}
return this.setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId, rpEventId);
return await this.setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId, rpEventId);
}
/**
@@ -6300,6 +6349,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.turnServersExpiry;
}
public get pollingTurnServers(): boolean {
return this.checkTurnServersIntervalID !== null;
}
// XXX: Intended private, used in code.
public async checkTurnServers(): Promise<boolean> {
if (!this.canSupportVoip) {
@@ -6327,17 +6380,21 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// The TTL is in seconds but we work in ms
this.turnServersExpiry = Date.now() + (res.ttl * 1000);
credentialsGood = true;
this.emit(ClientEvent.TurnServers, this.turnServers);
}
} catch (err) {
logger.error("Failed to get TURN URIs", err);
// If we get a 403, there's no point in looping forever.
if (err.httpStatus === 403) {
// We got a 403, so there's no point in looping forever.
logger.info("TURN access unavailable for this account: stopping credentials checks");
if (this.checkTurnServersIntervalID !== null) global.clearInterval(this.checkTurnServersIntervalID);
this.checkTurnServersIntervalID = null;
this.emit(ClientEvent.TurnServersError, err, true); // fatal
} else {
// otherwise, if we failed for whatever reason, try again the next time we're called.
this.emit(ClientEvent.TurnServersError, err, false); // non-fatal
}
}
// otherwise, if we failed for whatever reason, try again the next time we're called.
}
return credentialsGood;
@@ -7444,7 +7501,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* don't want other users to see the read receipts. This is experimental. Optional.
* @return {Promise} Resolves: the empty object, {}.
*/
public setRoomReadMarkersHttpRequest(
public async setRoomReadMarkersHttpRequest(
roomId: string,
rmEventId: string,
rrEventId: string,
@@ -7457,9 +7514,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const content = {
[ReceiptType.FullyRead]: rmEventId,
[ReceiptType.Read]: rrEventId,
[ReceiptType.ReadPrivate]: rpEventId,
};
const privateField = await utils.getPrivateReadReceiptField(this);
if (privateField) {
content[privateField] = rpEventId;
}
return this.http.authedRequest(undefined, Method.Post, path, undefined, content);
}
@@ -8694,7 +8755,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* Send an event to a specific list of devices
* Send an event to a specific list of devices.
* This is a low-level API that simply wraps the HTTP API
* call to send to-device messages. We recommend using
* queueToDevice() which is a higher level API.
*
* @param {string} eventType type of event to send
* @param {Object.<string, Object<string, Object>>} contentMap
@@ -8726,6 +8790,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.http.authedRequest(undefined, Method.Put, path, undefined, body);
}
/**
* Sends events directly to specific devices using Matrix's to-device
* messaging system. The batch will be split up into appropriately sized
* batches for sending and stored in the store so they can be retried
* later if they fail to send. Retries will happen automatically.
* @param batch The to-device messages to send
*/
public queueToDevice(batch: ToDeviceBatch): Promise<void> {
return this.toDeviceMessageQueue.queueBatch(batch);
}
/**
* Get the third party protocols that can be reached using
* this HS
+3 -3
View File
@@ -36,9 +36,9 @@ import * as utils from "./utils";
export function getHttpUriForMxc(
baseUrl: string,
mxc: string,
width: number,
height: number,
resizeMethod: string,
width?: number,
height?: number,
resizeMethod?: string,
allowDirectLinks = false,
): string {
if (typeof mxc !== "string" || !mxc) {
+16 -87
View File
@@ -606,96 +606,25 @@ class MegolmEncryption extends EncryptionAlgorithm {
private encryptAndSendKeysToDevices(
session: OutboundSessionInfo,
chainIndex: number,
userDeviceMap: IOlmDevice[],
devices: IOlmDevice[],
payload: IPayload,
): Promise<void> {
const contentMap: Record<string, Record<string, IEncryptedContent>> = {};
// Map from userId to a map of deviceId to deviceInfo
const deviceInfoByUserIdAndDeviceId = new Map<string, Map<string, DeviceInfo>>();
const promises: Promise<unknown>[] = [];
for (let i = 0; i < userDeviceMap.length; i++) {
const encryptedContent: IEncryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: this.olmDevice.deviceCurve25519Key,
ciphertext: {},
};
const val = userDeviceMap[i];
const userId = val.userId;
const deviceInfo = val.deviceInfo;
const deviceId = deviceInfo.deviceId;
// Assign to temp value to make type-checking happy
let userIdDeviceInfo = deviceInfoByUserIdAndDeviceId.get(userId);
if (userIdDeviceInfo === undefined) {
userIdDeviceInfo = new Map<string, DeviceInfo>();
deviceInfoByUserIdAndDeviceId.set(userId, userIdDeviceInfo);
return this.crypto.encryptAndSendToDevices(
devices,
payload,
).then(() => {
// store that we successfully uploaded the keys of the current slice
for (const device of devices) {
session.markSharedWithDevice(
device.userId,
device.deviceInfo.deviceId,
device.deviceInfo.getIdentityKey(),
chainIndex,
);
}
// We hold by reference, this updates deviceInfoByUserIdAndDeviceId[userId]
userIdDeviceInfo.set(deviceId, deviceInfo);
if (!contentMap[userId]) {
contentMap[userId] = {};
}
contentMap[userId][deviceId] = encryptedContent;
promises.push(
olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
this.userId,
this.deviceId,
this.olmDevice,
userId,
deviceInfo,
payload,
),
);
}
return Promise.all(promises).then(() => {
// prune out any devices that encryptMessageForDevice could not encrypt for,
// in which case it will have just not added anything to the ciphertext object.
// There's no point sending messages to devices if we couldn't encrypt to them,
// since that's effectively a blank message.
for (const userId of Object.keys(contentMap)) {
for (const deviceId of Object.keys(contentMap[userId])) {
if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) {
logger.log(
"No ciphertext for device " +
userId + ":" + deviceId + ": pruning",
);
delete contentMap[userId][deviceId];
}
}
// No devices left for that user? Strip that too.
if (Object.keys(contentMap[userId]).length === 0) {
logger.log("Pruned all devices for user " + userId);
delete contentMap[userId];
}
}
// Is there anything left?
if (Object.keys(contentMap).length === 0) {
logger.log("No users left to send to: aborting");
return;
}
return this.baseApis.sendToDevice("m.room.encrypted", contentMap).then(() => {
// store that we successfully uploaded the keys of the current slice
for (const userId of Object.keys(contentMap)) {
for (const deviceId of Object.keys(contentMap[userId])) {
session.markSharedWithDevice(
userId,
deviceId,
deviceInfoByUserIdAndDeviceId.get(userId).get(deviceId).getIdentityKey(),
chainIndex,
);
}
}
});
}).catch((error) => {
logger.error("failed to encryptAndSendToDevices", error);
throw error;
});
}
+86
View File
@@ -23,9 +23,11 @@ limitations under the License.
import anotherjson from "another-json";
import { EventType } from "../@types/event";
import { TypedReEmitter } from '../ReEmitter';
import { logger } from '../logger';
import { IExportedDevice, OlmDevice } from "./OlmDevice";
import { IOlmDevice } from "./algorithms/megolm";
import * as olmlib from "./olmlib";
import { DeviceInfoMap, DeviceList } from "./DeviceList";
import { DeviceInfo, IDevice } from "./deviceinfo";
@@ -68,6 +70,7 @@ import { IStore } from "../store";
import { Room, RoomEvent } from "../models/room";
import { RoomMember, RoomMemberEvent } from "../models/room-member";
import { EventStatus, IClearEvent, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event";
import { ToDeviceBatch } from "../models/ToDeviceMessage";
import {
ClientEvent,
ICrossSigningKey,
@@ -201,6 +204,14 @@ export interface IRequestsMap {
setRequestByChannel(channel: IVerificationChannel, request: VerificationRequest): void;
}
/* eslint-disable camelcase */
export interface IEncryptedContent {
algorithm: string;
sender_key: string;
ciphertext: Record<string, string>;
}
/* eslint-enable camelcase */
export enum CryptoEvent {
DeviceVerificationChanged = "deviceVerificationChanged",
UserTrustStatusChanged = "userTrustStatusChanged",
@@ -3100,6 +3111,81 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
});
}
/**
* Encrypts and sends a given object via Olm to-device messages to a given
* set of devices.
* @param {object[]} userDeviceInfoArr the devices to send to
* @param {object} payload fields to include in the encrypted payload
* @return {Promise<{contentMap, deviceInfoByDeviceId}>} Promise which
* resolves once the message has been encrypted and sent to the given
* userDeviceMap, and returns the { contentMap, deviceInfoByDeviceId }
* of the successfully sent messages.
*/
public async encryptAndSendToDevices(
userDeviceInfoArr: IOlmDevice<DeviceInfo>[],
payload: object,
): Promise<void> {
const toDeviceBatch: ToDeviceBatch = {
eventType: EventType.RoomMessageEncrypted,
batch: [],
};
try {
await Promise.all(userDeviceInfoArr.map(async ({ userId, deviceInfo }) => {
const deviceId = deviceInfo.deviceId;
const encryptedContent: IEncryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: this.olmDevice.deviceCurve25519Key,
ciphertext: {},
};
toDeviceBatch.batch.push({
userId,
deviceId,
payload: encryptedContent,
});
await olmlib.ensureOlmSessionsForDevices(
this.olmDevice,
this.baseApis,
{ [userId]: [deviceInfo] },
);
await olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
this.userId,
this.deviceId,
this.olmDevice,
userId,
deviceInfo,
payload,
);
}));
// prune out any devices that encryptMessageForDevice could not encrypt for,
// in which case it will have just not added anything to the ciphertext object.
// There's no point sending messages to devices if we couldn't encrypt to them,
// since that's effectively a blank message.
toDeviceBatch.batch = toDeviceBatch.batch.filter(msg => {
if (Object.keys(msg.payload.ciphertext).length > 0) {
return true;
} else {
logger.log(`No ciphertext for device ${msg.userId}:${msg.deviceId}: pruning`);
return false;
}
});
try {
await this.baseApis.queueToDevice(toDeviceBatch);
} catch (e) {
logger.error("sendToDevice failed", e);
throw e;
}
} catch (e) {
logger.error("encryptAndSendToDevices promises failed", e);
throw e;
}
}
private onMembership = (event: MatrixEvent, member: RoomMember, oldMembership?: string) => {
try {
this.onRoomMembership(event, member, oldMembership);
+38
View File
@@ -0,0 +1,38 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export type ToDevicePayload = Record<string, any>;
export interface ToDeviceMessage {
userId: string;
deviceId: string;
payload: ToDevicePayload;
}
export interface ToDeviceBatch {
eventType: string;
batch: ToDeviceMessage[];
}
// Only used internally
export interface ToDeviceBatchWithTxnId extends ToDeviceBatch {
txnId: string;
}
// Only used internally
export interface IndexedToDeviceBatch extends ToDeviceBatchWithTxnId {
id: number;
}
+58 -19
View File
@@ -72,7 +72,7 @@ function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: Rece
},
},
},
type: "m.receipt",
type: EventType.Receipt,
room_id: event.getRoomId(),
});
}
@@ -2423,9 +2423,9 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
*/
public addEphemeralEvents(events: MatrixEvent[]): void {
for (const event of events) {
if (event.getType() === 'm.typing') {
if (event.getType() === EventType.Typing) {
this.currentState.setTypingEvent(event);
} else if (event.getType() === 'm.receipt') {
} else if (event.getType() === EventType.Receipt) {
this.addReceipt(event);
} // else ignore - life is too short for us to care about these events
}
@@ -2514,7 +2514,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
*/
public getUsersReadUpTo(event: MatrixEvent): string[] {
return this.getReceiptsForEvent(event).filter(function(receipt) {
return [ReceiptType.Read, ReceiptType.ReadPrivate].includes(receipt.type);
return utils.isSupportedReceiptType(receipt.type);
}).map(function(receipt) {
return receipt.userId;
});
@@ -2548,25 +2548,64 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* @return {String} ID of the latest event that the given user has read, or null.
*/
public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null {
const timelineSet = this.getUnfilteredTimelineSet();
const publicReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.Read);
const privateReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.ReadPrivate);
// XXX: This is very very ugly and I hope I won't have to ever add a new
// receipt type here again. IMHO this should be done by the server in
// some more intelligent manner or the client should just use timestamps
// If we have both, compare them
let comparison: number | undefined;
if (publicReadReceipt?.eventId && privateReadReceipt?.eventId) {
comparison = timelineSet.compareEventOrdering(publicReadReceipt?.eventId, privateReadReceipt?.eventId);
const timelineSet = this.getUnfilteredTimelineSet();
const publicReadReceipt = this.getReadReceiptForUserId(
userId,
ignoreSynthesized,
ReceiptType.Read,
);
const privateReadReceipt = this.getReadReceiptForUserId(
userId,
ignoreSynthesized,
ReceiptType.ReadPrivate,
);
const unstablePrivateReadReceipt = this.getReadReceiptForUserId(
userId,
ignoreSynthesized,
ReceiptType.UnstableReadPrivate,
);
// If we have all, compare them
if (publicReadReceipt?.eventId && privateReadReceipt?.eventId && unstablePrivateReadReceipt?.eventId) {
const comparison1 = timelineSet.compareEventOrdering(
publicReadReceipt.eventId,
privateReadReceipt.eventId,
);
const comparison2 = timelineSet.compareEventOrdering(
publicReadReceipt.eventId,
unstablePrivateReadReceipt.eventId,
);
const comparison3 = timelineSet.compareEventOrdering(
privateReadReceipt.eventId,
unstablePrivateReadReceipt.eventId,
);
if (comparison1 && comparison2 && comparison3) {
return (comparison1 > 0)
? ((comparison2 > 0) ? publicReadReceipt.eventId : unstablePrivateReadReceipt.eventId)
: ((comparison3 > 0) ? privateReadReceipt.eventId : unstablePrivateReadReceipt.eventId);
}
}
// If we didn't get a comparison try to compare the ts of the receipts
if (!comparison) comparison = publicReadReceipt?.data?.ts - privateReadReceipt?.data?.ts;
let latest = privateReadReceipt;
[unstablePrivateReadReceipt, publicReadReceipt].forEach((receipt) => {
if (receipt?.data?.ts > latest?.data?.ts) {
latest = receipt;
}
});
if (latest?.eventId) return latest?.eventId;
// The public receipt is more likely to drift out of date so the private
// one has precedence
if (!comparison) return privateReadReceipt?.eventId ?? publicReadReceipt?.eventId ?? null;
// If public read receipt is older, return the private one
return (comparison < 0) ? privateReadReceipt?.eventId : publicReadReceipt?.eventId;
// The more less likely it is for a read receipt to drift out of date
// the bigger is its precedence
return (
privateReadReceipt?.eventId ??
unstablePrivateReadReceipt?.eventId ??
publicReadReceipt?.eventId ??
null
);
}
/**
+1 -1
View File
@@ -57,7 +57,7 @@ export class MatrixScheduler<T = ISendEventResponse> {
* failure was due to a rate limited request, the time specified in the error is
* waited before being retried.
* @param {MatrixEvent} event
* @param {Number} attempts
* @param {Number} attempts Number of attempts that have been made, including the one that just failed (ie. starting at 1)
* @param {MatrixError} err
* @return {Number}
* @see module:scheduler~retryAlgorithm
+4 -1
View File
@@ -44,6 +44,9 @@ export interface MSC3575Filter {
is_invite?: boolean;
is_tombstoned?: boolean;
room_name_like?: string;
room_types?: string[];
not_room_types?: string[];
spaces?: string[];
}
/**
@@ -602,7 +605,7 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
listIndex,
op.range[0],
op.range[1],
op.room_ids.join(" "),
(op.room_ids || []).join(" "),
";",
);
break;
+53 -22
View File
@@ -23,6 +23,7 @@ import { RoomSummary } from "../models/room-summary";
import { IMinimalEvent, IRooms, ISyncResponse } from "../sync-accumulator";
import { IStartClientOpts } from "../client";
import { IStateEventWithRoomId } from "../@types/search";
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
export interface ISavedSync {
nextBatch: string;
@@ -31,8 +32,7 @@ export interface ISavedSync {
}
/**
* Construct a stub store. This does no-ops on most store methods.
* @constructor
* A store for most of the data js-sdk needs to store, apart from crypto data
*/
export interface IStore {
readonly accountData: Record<string, MatrixEvent>; // type : content
@@ -57,21 +57,21 @@ export interface IStore {
setSyncToken(token: string): void;
/**
* No-op.
* @param {Room} room
* Store the given room.
* @param {Room} room The room to be stored. All properties must be stored.
*/
storeRoom(room: Room): void;
/**
* No-op.
* @param {string} roomId
* @return {null}
* Retrieve a room by its' room ID.
* @param {string} roomId The room ID.
* @return {Room} The room or null.
*/
getRoom(roomId: string): Room | null;
/**
* No-op.
* @return {Array} An empty array.
* Retrieve all known rooms.
* @return {Room[]} A list of rooms, which may be empty.
*/
getRooms(): Room[];
@@ -82,35 +82,36 @@ export interface IStore {
removeRoom(roomId: string): void;
/**
* No-op.
* @return {Array} An empty array.
* Retrieve a summary of all the rooms.
* @return {RoomSummary[]} A summary of each room.
*/
getRoomSummaries(): RoomSummary[];
/**
* No-op.
* @param {User} user
* Store a User.
* @param {User} user The user to store.
*/
storeUser(user: User): void;
/**
* No-op.
* @param {string} userId
* @return {null}
* Retrieve a User by its' user ID.
* @param {string} userId The user ID.
* @return {User} The user or null.
*/
getUser(userId: string): User | null;
/**
* No-op.
* @return {User[]}
* Retrieve all known users.
* @return {User[]} A list of users, which may be empty.
*/
getUsers(): User[];
/**
* No-op.
* @param {Room} room
* @param {number} limit
* @return {Array}
* Retrieve scrollback for this room.
* @param {Room} room The matrix room
* @param {number} limit The max number of old events to retrieve.
* @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(room: Room, limit: number): MatrixEvent[];
@@ -209,8 +210,23 @@ export interface IStore {
*/
deleteAllData(): Promise<void>;
/**
* 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
*/
getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null>;
/**
* 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
*/
setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void>;
clearOutOfBandMembers(roomId: string): Promise<void>;
@@ -222,4 +238,19 @@ export interface IStore {
getPendingEvents(roomId: string): Promise<Partial<IEvent>[]>;
setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void>;
/**
* Stores batches of outgoing to-device messages
*/
saveToDeviceBatches(batch: ToDeviceBatchWithTxnId[]): Promise<void>;
/**
* Fetches the oldest batch of to-device messages in the queue
*/
getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch>;
/**
* Removes a specific batch of to-device messages from the queue
*/
removeToDeviceBatch(id: number): Promise<void>;
}
+4
View File
@@ -16,6 +16,7 @@ limitations under the License.
import { ISavedSync } from "./index";
import { IEvent, IStartClientOpts, IStateEventWithRoomId, ISyncResponse } from "..";
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
export interface IIndexedDBBackend {
connect(): Promise<void>;
@@ -31,6 +32,9 @@ export interface IIndexedDBBackend {
getUserPresenceEvents(): Promise<UserTuple[]>;
getClientOptions(): Promise<IStartClientOpts>;
storeClientOptions(options: IStartClientOpts): Promise<void>;
saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void>;
getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch>;
removeToDeviceBatch(id: number): Promise<void>;
}
export type UserTuple = [userId: string, presenceEvent: Partial<IEvent>];
+44 -4
View File
@@ -21,8 +21,9 @@ import { logger } from '../logger';
import { IStartClientOpts, IStateEventWithRoomId } from "..";
import { ISavedSync } from "./index";
import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend";
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
const VERSION = 3;
const VERSION = 4;
function createDatabase(db: IDBDatabase): void {
// Make user store, clobber based on user ID. (userId property of User objects)
@@ -49,6 +50,10 @@ function upgradeSchemaV3(db: IDBDatabase): void {
{ keyPath: ["clobber"] });
}
function upgradeSchemaV4(db: IDBDatabase): void {
db.createObjectStore("to_device_queue", { autoIncrement: true });
}
/**
* Helper method to collect results from a Cursor and promiseify it.
* @param {ObjectStore|Index} store The store to perform openCursor on.
@@ -112,7 +117,7 @@ function reqAsPromise(req: IDBRequest): Promise<IDBRequest> {
});
}
function reqAsCursorPromise(req: IDBRequest<IDBCursor | null>): Promise<IDBCursor> {
function reqAsCursorPromise<T>(req: IDBRequest<T>): Promise<T> {
return reqAsEventPromise(req).then((event) => req.result);
}
@@ -177,6 +182,9 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
if (oldVersion < 3) {
upgradeSchemaV3(db);
}
if (oldVersion < 4) {
upgradeSchemaV4(db);
}
// Expand as needed.
};
@@ -401,8 +409,6 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
}
public async syncToDatabase(userTuples: UserTuple[]): Promise<void> {
const syncData = this.syncAccumulator.getJSON(true);
if (this.isPersisting) {
logger.warn("Skipping syncToDatabase() as persist already in flight");
this.pendingUserPresenceData.push(...userTuples);
@@ -413,6 +419,8 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
}
try {
const syncData = this.syncAccumulator.getJSON(true);
await Promise.all([
this.persistUserPresenceEvents(userTuples),
this.persistAccountData(syncData.accountData),
@@ -561,4 +569,36 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
}); // put == UPSERT
await txnAsPromise(txn);
}
public async saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> {
const txn = this.db.transaction(["to_device_queue"], "readwrite");
const store = txn.objectStore("to_device_queue");
for (const batch of batches) {
store.add(batch);
}
await txnAsPromise(txn);
}
public async getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> {
const txn = this.db.transaction(["to_device_queue"], "readonly");
const store = txn.objectStore("to_device_queue");
const cursor = await reqAsCursorPromise(store.openCursor());
if (!cursor) return null;
const resultBatch = cursor.value as ToDeviceBatchWithTxnId;
return {
id: cursor.key as number,
txnId: resultBatch.txnId,
eventType: resultBatch.eventType,
batch: resultBatch.batch,
};
}
public async removeToDeviceBatch(id: number): Promise<void> {
const txn = this.db.transaction(["to_device_queue"], "readwrite");
const store = txn.objectStore("to_device_queue");
store.delete(id);
await txnAsPromise(txn);
}
}
+13
View File
@@ -20,6 +20,7 @@ import { ISavedSync } from "./index";
import { IStartClientOpts } from "../client";
import { IStateEventWithRoomId, ISyncResponse } from "..";
import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend";
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
private worker: Worker;
@@ -133,6 +134,18 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
return this.doCmd('getUserPresenceEvents');
}
public async saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> {
return this.doCmd('saveToDeviceBatches', [batches]);
}
public async getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch> {
return this.doCmd('getOldestToDeviceBatch');
}
public async removeToDeviceBatch(id: number): Promise<void> {
return this.doCmd('removeToDeviceBatch', [id]);
}
private ensureStarted(): Promise<void> {
if (this.startPromise === null) {
this.worker = this.workerFactory();
+9
View File
@@ -103,6 +103,15 @@ export class IndexedDBStoreWorker {
case 'storeClientOptions':
prom = this.backend.storeClientOptions(msg.args[0]);
break;
case 'saveToDeviceBatches':
prom = this.backend.saveToDeviceBatches(msg.args[0]);
break;
case 'getOldestToDeviceBatch':
prom = this.backend.getOldestToDeviceBatch();
break;
case 'removeToDeviceBatch':
prom = this.backend.removeToDeviceBatch(msg.args[0]);
break;
}
if (prom === undefined) {
+13
View File
@@ -27,6 +27,7 @@ import { IIndexedDBBackend } from "./indexeddb-backend";
import { ISyncResponse } from "../sync-accumulator";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { IStateEventWithRoomId } from "../@types/search";
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
/**
* This is an internal module. See {@link IndexedDBStore} for the public class.
@@ -351,6 +352,18 @@ export class IndexedDBStore extends MemoryStore {
this.localStorage.removeItem(pendingEventsKey(roomId));
}
}
public saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> {
return this.backend.saveToDeviceBatches(batches);
}
public getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch> {
return this.backend.getOldestToDeviceBatch();
}
public removeToDeviceBatch(id: number): Promise<void> {
return this.backend.removeToDeviceBatch(id);
}
}
/**
+25
View File
@@ -30,6 +30,7 @@ import { ISavedSync, IStore } from "./index";
import { RoomSummary } from "../models/room-summary";
import { ISyncResponse } from "../sync-accumulator";
import { IStateEventWithRoomId } from "../@types/search";
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
function isValidFilterId(filterId: string): boolean {
const isValidStr = typeof filterId === "string" &&
@@ -64,6 +65,8 @@ export class MemoryStore implements IStore {
private oobMembers: Record<string, IStateEventWithRoomId[]> = {}; // roomId: [member events]
private pendingEvents: { [roomId: string]: Partial<IEvent>[] } = {};
private clientOptions = {};
private pendingToDeviceBatches: IndexedToDeviceBatch[] = [];
private nextToDeviceBatchId = 0;
constructor(opts: IOpts = {}) {
this.localStorage = opts.localStorage;
@@ -429,4 +432,26 @@ export class MemoryStore implements IStore {
public async setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void> {
this.pendingEvents[roomId] = events;
}
public saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> {
for (const batch of batches) {
this.pendingToDeviceBatches.push({
id: this.nextToDeviceBatchId++,
eventType: batch.eventType,
txnId: batch.txnId,
batch: batch.batch,
});
}
return Promise.resolve();
}
public async getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> {
if (this.pendingToDeviceBatches.length === 0) return null;
return this.pendingToDeviceBatches[0];
}
public removeToDeviceBatch(id: number): Promise<void> {
this.pendingToDeviceBatches = this.pendingToDeviceBatches.filter(batch => batch.id !== id);
return Promise.resolve();
}
}
+13
View File
@@ -28,6 +28,7 @@ import { ISavedSync, IStore } from "./index";
import { RoomSummary } from "../models/room-summary";
import { ISyncResponse } from "../sync-accumulator";
import { IStateEventWithRoomId } from "../@types/search";
import { IndexedToDeviceBatch, ToDeviceBatch } from "../models/ToDeviceMessage";
/**
* Construct a stub store. This does no-ops on most store methods.
@@ -270,4 +271,16 @@ export class StubStore implements IStore {
public setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void> {
return Promise.resolve();
}
public async saveToDeviceBatches(batch: ToDeviceBatch[]): Promise<void> {
return Promise.resolve();
}
public getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> {
return Promise.resolve(null);
}
public async removeToDeviceBatch(id: number): Promise<void> {
return Promise.resolve();
}
}
+8 -21
View File
@@ -20,7 +20,7 @@ limitations under the License.
*/
import { logger } from './logger';
import { deepCopy } from "./utils";
import { deepCopy, isSupportedReceiptType } from "./utils";
import { IContent, IUnsigned } from "./models/event";
import { IRoomSummary } from "./models/room-summary";
import { EventType } from "./@types/event";
@@ -417,31 +417,18 @@ export class SyncAccumulator {
// of a hassle to work with. We'll inflate this back out when
// getJSON() is called.
Object.keys(e.content).forEach((eventId) => {
if (!e.content[eventId][ReceiptType.Read] && !e.content[eventId][ReceiptType.ReadPrivate]) {
return;
}
const read = e.content[eventId][ReceiptType.Read];
if (read) {
Object.keys(read).forEach((userId) => {
Object.entries(e.content[eventId]).forEach(([key, value]) => {
if (!isSupportedReceiptType(key)) return;
Object.keys(value).forEach((userId) => {
// clobber on user ID
currentData._readReceipts[userId] = {
data: e.content[eventId][ReceiptType.Read][userId],
type: ReceiptType.Read,
data: e.content[eventId][key][userId],
type: key as ReceiptType,
eventId: eventId,
};
});
}
const readPrivate = e.content[eventId][ReceiptType.ReadPrivate];
if (readPrivate) {
Object.keys(readPrivate).forEach((userId) => {
// clobber on user ID
currentData._readReceipts[userId] = {
data: e.content[eventId][ReceiptType.ReadPrivate][userId],
type: ReceiptType.ReadPrivate,
eventId: eventId,
};
});
}
});
});
});
}
+17 -1
View File
@@ -24,8 +24,9 @@ import unhomoglyph from "unhomoglyph";
import promiseRetry from "p-retry";
import type * as NodeCrypto from "crypto";
import { MatrixEvent } from ".";
import { MatrixClient, MatrixEvent } from ".";
import { M_TIMESTAMP } from "./@types/location";
import { ReceiptType } from "./@types/read_receipts";
/**
* Encode a dictionary of query parameters.
@@ -648,3 +649,18 @@ function getContentTimestampWithFallback(event: MatrixEvent): number {
export function sortEventsByLatestContentTimestamp(left: MatrixEvent, right: MatrixEvent): number {
return getContentTimestampWithFallback(right) - getContentTimestampWithFallback(left);
}
export async function getPrivateReadReceiptField(client: MatrixClient): Promise<ReceiptType | null> {
if (await client.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) return ReceiptType.ReadPrivate;
if (await client.doesServerSupportUnstableFeature("org.matrix.msc2285")) return ReceiptType.UnstableReadPrivate;
return null;
}
export function isSupportedReceiptType(receiptType: string): boolean {
return [
ReceiptType.Read,
ReceiptType.ReadPrivate,
ReceiptType.UnstableReadPrivate,
].includes(receiptType as ReceiptType);
}
+49 -54
View File
@@ -523,24 +523,22 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
return;
}
// Try to find a feed with the same purpose as the new stream,
// if we find it replace the old stream with the new one
const existingFeed = this.getRemoteFeeds().find((feed) => feed.purpose === purpose);
if (existingFeed) {
existingFeed.setNewStream(stream);
} else {
this.feeds.push(new CallFeed({
client: this.client,
roomId: this.roomId,
userId,
stream,
purpose,
audioMuted,
videoMuted,
}));
this.emit(CallEvent.FeedsChanged, this.feeds);
if (this.getFeedByStreamId(stream.id)) {
logger.warn(`Ignoring stream with id ${stream.id} because we already have a feed for it`);
return;
}
this.feeds.push(new CallFeed({
client: this.client,
roomId: this.roomId,
userId,
stream,
purpose,
audioMuted,
videoMuted,
}));
this.emit(CallEvent.FeedsChanged, this.feeds);
logger.info(`Pushed remote stream (id="${stream.id}", active="${stream.active}", purpose=${purpose})`);
}
@@ -562,24 +560,22 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
return;
}
// Try to find a feed with the same stream id as the new stream,
// if we find it replace the old stream with the new one
const feed = this.getFeedByStreamId(stream.id);
if (feed) {
feed.setNewStream(stream);
} else {
this.feeds.push(new CallFeed({
client: this.client,
roomId: this.roomId,
audioMuted: false,
videoMuted: false,
userId,
stream,
purpose,
}));
this.emit(CallEvent.FeedsChanged, this.feeds);
if (this.getFeedByStreamId(stream.id)) {
logger.warn(`Ignoring stream with id ${stream.id} because we already have a feed for it`);
return;
}
this.feeds.push(new CallFeed({
client: this.client,
roomId: this.roomId,
audioMuted: false,
videoMuted: false,
userId,
stream,
purpose,
}));
this.emit(CallEvent.FeedsChanged, this.feeds);
logger.info(`Pushed remote stream (id="${stream.id}", active="${stream.active}")`);
}
@@ -592,25 +588,23 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
setTracksEnabled(stream.getAudioTracks(), true);
setTracksEnabled(stream.getVideoTracks(), true);
// We try to replace an existing feed if there already is one with the same purpose
const existingFeed = this.getLocalFeeds().find((feed) => feed.purpose === purpose);
if (existingFeed) {
existingFeed.setNewStream(stream);
} else {
this.pushLocalFeed(
new CallFeed({
client: this.client,
roomId: this.roomId,
audioMuted: false,
videoMuted: false,
userId,
stream,
purpose,
}),
addToPeerConnection,
);
this.emit(CallEvent.FeedsChanged, this.feeds);
if (this.getFeedByStreamId(stream.id)) {
logger.warn(`Ignoring stream with id ${stream.id} because we already have a feed for it`);
return;
}
this.pushLocalFeed(
new CallFeed({
client: this.client,
roomId: this.roomId,
audioMuted: false,
videoMuted: false,
userId,
stream,
purpose,
}),
addToPeerConnection,
);
}
/**
@@ -1142,7 +1136,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
await this.upgradeCall(false, true);
return this.isLocalVideoMuted();
}
this.localUsermediaFeed?.setVideoMuted(muted);
this.localUsermediaFeed?.setAudioVideoMuted(null, muted);
this.updateMuteStatus();
return this.isLocalVideoMuted();
}
@@ -1174,7 +1168,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
await this.upgradeCall(true, false);
return this.isMicrophoneMuted();
}
this.localUsermediaFeed?.setAudioMuted(muted);
this.localUsermediaFeed?.setAudioVideoMuted(muted, null);
this.updateMuteStatus();
return this.isMicrophoneMuted();
}
@@ -1585,8 +1579,9 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
this.remoteSDPStreamMetadata = utils.recursivelyAssign(this.remoteSDPStreamMetadata || {}, metadata, true);
for (const feed of this.getRemoteFeeds()) {
const streamId = feed.stream.id;
feed.setAudioMuted(this.remoteSDPStreamMetadata[streamId]?.audio_muted);
feed.setVideoMuted(this.remoteSDPStreamMetadata[streamId]?.video_muted);
const metadata = this.remoteSDPStreamMetadata[streamId];
feed.setAudioVideoMuted(metadata?.audio_muted, metadata?.video_muted);
feed.purpose = this.remoteSDPStreamMetadata[streamId]?.purpose;
}
}
+10 -22
View File
@@ -175,30 +175,18 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
}
/**
* Replaces the current MediaStream with a new one.
* This method should be only used by MatrixCall.
* @param newStream new stream with which to replace the current one
*/
public setNewStream(newStream: MediaStream): void {
this.updateStream(this.stream, newStream);
}
/**
* Set feed's internal audio mute state
* @param muted is the feed's audio muted?
*/
public setAudioMuted(muted: boolean): void {
this.audioMuted = muted;
this.speakingVolumeSamples.fill(-Infinity);
this.emit(CallFeedEvent.MuteStateChanged, this.audioMuted, this.videoMuted);
}
/**
* Set feed's internal video mute state
* Set one or both of feed's internal audio and video video mute state
* Either value may be null to leave it as-is
* @param muted is the feed's video muted?
*/
public setVideoMuted(muted: boolean): void {
this.videoMuted = muted;
public setAudioVideoMuted(audioMuted: boolean, videoMuted: boolean): void {
if (audioMuted !== null) {
if (this.audioMuted !== audioMuted) {
this.speakingVolumeSamples.fill(-Infinity);
}
this.audioMuted = audioMuted;
}
if (videoMuted !== null) this.videoMuted = videoMuted;
this.emit(CallFeedEvent.MuteStateChanged, this.audioMuted, this.videoMuted);
}
+391 -391
View File
File diff suppressed because it is too large Load Diff