Compare commits
72 Commits
v19.2.0
...
v19.4.0-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 528e9343ae | |||
| 6571b6a1ab | |||
| 1df329df7c | |||
| 760eeaeed7 | |||
| 438fc70615 | |||
| de3b3960d2 | |||
| b265d795a4 | |||
| eb79f6246d | |||
| 37f8f736e0 | |||
| 4b1a443f90 | |||
| 3ae974e23e | |||
| 566b4ba56c | |||
| 13291f33d2 | |||
| 8502759e3e | |||
| 24f9075a84 | |||
| d18aae09c8 | |||
| be3e731499 | |||
| a9f2ae6b55 | |||
| b254ca7fc8 | |||
| 3f6f5b69c7 | |||
| 0e8bd3f02d | |||
| 478270b225 | |||
| 9eb72908a7 | |||
| 2728d74771 | |||
| edcef9364c | |||
| 1635ac9971 | |||
| 8f13df2dd9 | |||
| fa9f078a75 | |||
| 055af933cc | |||
| 1ba2730e25 | |||
| 1c9d644a23 | |||
| e29e0d15a5 | |||
| 9ee94c9902 | |||
| 1645867ea6 | |||
| 24d4181a08 | |||
| f576a9f2e9 | |||
| fed121b0aa | |||
| 3e37c74264 | |||
| 3762c20aad | |||
| 2596999cb8 | |||
| a3248c0aa1 | |||
| c96f1ba22b | |||
| 43c81358b2 | |||
| e05f9b5815 | |||
| 6316a6ae44 | |||
| 575b416856 | |||
| 7b7f8c1592 | |||
| 3907d1c28f | |||
| c629d2f60e | |||
| 43b453804b | |||
| d867affc40 | |||
| c36bfc821c | |||
| 7e784da00a | |||
| b79f469008 | |||
| cf33569a21 | |||
| fb0a0c66c8 | |||
| f0991348e2 | |||
| fa6708c27e | |||
| 4427201326 | |||
| 4a4241806e | |||
| 3824f65d15 | |||
| 3c17e4a6d6 | |||
| 75513d08de | |||
| 7cb3b40493 | |||
| ab89804c55 | |||
| ab6cf93c2b | |||
| 4c80762e22 | |||
| 1f7e80c68d | |||
| e91b879a69 | |||
| 14885ba7a2 | |||
| 0dda187d96 | |||
| 680d8cac4d |
@@ -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"],
|
||||
@@ -55,6 +57,10 @@ module.exports = {
|
||||
// We're okay with assertion errors when we ask for them
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
|
||||
// The non-TypeScript rule produces false positives
|
||||
"func-call-spacing": "off",
|
||||
"@typescript-eslint/func-call-spacing": ["error"],
|
||||
|
||||
"quotes": "off",
|
||||
// We use a `logger` intermediary module
|
||||
"no-console": "error",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"github>matrix-org/renovate-config-element-web"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Backport
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- closed
|
||||
- labeled
|
||||
branches:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
backport:
|
||||
name: Backport
|
||||
runs-on: ubuntu-latest
|
||||
# Only react to merged PRs for security reasons.
|
||||
# See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target.
|
||||
if: >
|
||||
github.event.pull_request.merged
|
||||
&& (
|
||||
github.event.action == 'closed'
|
||||
|| (
|
||||
github.event.action == 'labeled'
|
||||
&& contains(github.event.label.name, 'backport')
|
||||
)
|
||||
)
|
||||
steps:
|
||||
- uses: tibdex/backport@v2
|
||||
with:
|
||||
labels_template: "<%= JSON.stringify(labels) %>"
|
||||
# We can't use GITHUB_TOKEN here or CI won't run on the new PR
|
||||
github_token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
cp -a "$RUNNER_TEMP/$VERSION" .
|
||||
|
||||
# Add the new directory to the index if it isn't there already
|
||||
if ! grep -q "Version $VERSION" index.html; then
|
||||
if ! grep -q ">Version $VERSION</a>" index.html; then
|
||||
perl -i -pe 'BEGIN {$rel=shift} $_ =~ /^<\/ul>/ && print
|
||||
"<li><a href=\"${rel}/index.html\">Version ${rel}</a></li>\n"' "$VERSION" index.html
|
||||
fi
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
Changes in [19.4.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.4.0-rc.1) (2022-08-23)
|
||||
============================================================================================================
|
||||
|
||||
## ✨ Features
|
||||
* Re-emit room state events on rooms ([\#2607](https://github.com/matrix-org/matrix-js-sdk/pull/2607)).
|
||||
* Add ability to override built in room name generator for an i18n'able one ([\#2609](https://github.com/matrix-org/matrix-js-sdk/pull/2609)).
|
||||
* Add txn_id support to sliding sync ([\#2567](https://github.com/matrix-org/matrix-js-sdk/pull/2567)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fixed a sliding sync bug which could cause the `roomIndexToRoomId` map to be incorrect when a new room is added in the middle of the list or when an existing room is deleted from the middle of the list. ([\#2610](https://github.com/matrix-org/matrix-js-sdk/pull/2610)).
|
||||
* Fix: Handle parsing of a beacon info event without asset ([\#2591](https://github.com/matrix-org/matrix-js-sdk/pull/2591)). Fixes vector-im/element-web#23078.
|
||||
* Fix finding event read up to if stable private read receipts is missing ([\#2585](https://github.com/matrix-org/matrix-js-sdk/pull/2585)). Fixes vector-im/element-web#23027.
|
||||
* fixed a sliding sync issue where history could be interpreted as live events. ([\#2583](https://github.com/matrix-org/matrix-js-sdk/pull/2583)).
|
||||
|
||||
Changes in [19.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.3.0) (2022-08-16)
|
||||
==================================================================================================
|
||||
|
||||
## ✨ Features
|
||||
* Add txn_id support to sliding sync ([\#2567](https://github.com/matrix-org/matrix-js-sdk/pull/2567)).
|
||||
* 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)).
|
||||
* 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
|
||||
* Fix: Handle parsing of a beacon info event without asset ([\#2591](https://github.com/matrix-org/matrix-js-sdk/pull/2591)). Fixes vector-im/element-web#23078.
|
||||
* Fix finding event read up to if stable private read receipts is missing ([\#2585](https://github.com/matrix-org/matrix-js-sdk/pull/2585)). Fixes vector-im/element-web#23027.
|
||||
* Fixed a sliding sync issue where history could be interpreted as live events. ([\#2583](https://github.com/matrix-org/matrix-js-sdk/pull/2583)).
|
||||
* 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)
|
||||
==================================================================================================
|
||||
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "19.2.0",
|
||||
"version": "19.4.0-rc.1",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=12.9.0"
|
||||
@@ -92,17 +92,17 @@
|
||||
"better-docs": "^2.4.0-beta.9",
|
||||
"browserify": "^17.0.0",
|
||||
"docdash": "^1.2.0",
|
||||
"eslint": "8.19.0",
|
||||
"eslint": "8.22.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-matrix-org": "^0.5.0",
|
||||
"eslint-plugin-matrix-org": "^0.6.0",
|
||||
"exorcist": "^2.0.0",
|
||||
"fake-indexeddb": "^4.0.0",
|
||||
"jest": "^28.0.0",
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { SlidingSyncSdk } from "../../src/sliding-sync-sdk";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import { IStoredClientOpts } from "../../src/client";
|
||||
import { logger } from "../../src/logger";
|
||||
|
||||
describe("SlidingSyncSdk", () => {
|
||||
let client: MatrixClient = null;
|
||||
@@ -372,6 +373,36 @@ describe("SlidingSyncSdk", () => {
|
||||
gotRoom.getUnreadNotificationCount(NotificationCountType.Total),
|
||||
).toEqual(1);
|
||||
});
|
||||
|
||||
// Regression test for a bug which caused the timeline entries to be out-of-order
|
||||
// when the same room appears twice with different timeline limits. E.g appears in
|
||||
// the list with timeline_limit:1 then appears again as a room subscription with
|
||||
// timeline_limit:50
|
||||
it("can return history with a larger timeline_limit", async () => {
|
||||
const timeline = data[roomA].timeline;
|
||||
const oldTimeline = [
|
||||
mkOwnEvent(EventType.RoomMessage, { body: "old event A" }),
|
||||
mkOwnEvent(EventType.RoomMessage, { body: "old event B" }),
|
||||
mkOwnEvent(EventType.RoomMessage, { body: "old event C" }),
|
||||
...timeline,
|
||||
];
|
||||
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomA, {
|
||||
timeline: oldTimeline,
|
||||
required_state: [],
|
||||
name: data[roomA].name,
|
||||
initial: true, // e.g requested via room subscription
|
||||
});
|
||||
const gotRoom = client.getRoom(roomA);
|
||||
expect(gotRoom).toBeDefined();
|
||||
|
||||
logger.log("want:", oldTimeline.map((e) => (e.type + " : " + (e.content || {}).body)));
|
||||
logger.log("got:", gotRoom.getLiveTimeline().getEvents().map(
|
||||
(e) => (e.getType() + " : " + e.getContent().body)),
|
||||
);
|
||||
|
||||
// we expect the timeline now to be oldTimeline (so the old events are in fact old)
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents(), oldTimeline);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -558,6 +558,372 @@ describe("SlidingSync", () => {
|
||||
await httpBackend.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
});
|
||||
|
||||
// this refers to a set of operations where the end result is no change.
|
||||
it("should handle net zero operations correctly", async () => {
|
||||
const indexToRoomId = {
|
||||
0: roomB,
|
||||
1: roomC,
|
||||
};
|
||||
expect(slidingSync.getListData(0).roomIndexToRoomId).toEqual(indexToRoomId);
|
||||
httpBackend.when("POST", syncUrl).respond(200, {
|
||||
pos: "f",
|
||||
// currently the list is [B,C] so we will insert D then immediately delete it
|
||||
lists: [{
|
||||
count: 500,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE", index: 2,
|
||||
},
|
||||
{
|
||||
op: "INSERT", index: 0, room_id: roomA,
|
||||
},
|
||||
{
|
||||
op: "DELETE", index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
count: 50,
|
||||
}],
|
||||
});
|
||||
const listPromise = listenUntil(slidingSync, "SlidingSync.List",
|
||||
(listIndex, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listIndex).toEqual(0);
|
||||
expect(joinedCount).toEqual(500);
|
||||
expect(roomIndexToRoomId).toEqual(indexToRoomId);
|
||||
return true;
|
||||
});
|
||||
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
});
|
||||
|
||||
it("should handle deletions correctly", async () => {
|
||||
expect(slidingSync.getListData(0).roomIndexToRoomId).toEqual({
|
||||
0: roomB,
|
||||
1: roomC,
|
||||
});
|
||||
httpBackend.when("POST", syncUrl).respond(200, {
|
||||
pos: "g",
|
||||
lists: [{
|
||||
count: 499,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE", index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
count: 50,
|
||||
}],
|
||||
});
|
||||
const listPromise = listenUntil(slidingSync, "SlidingSync.List",
|
||||
(listIndex, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listIndex).toEqual(0);
|
||||
expect(joinedCount).toEqual(499);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
});
|
||||
|
||||
it("should handle insertions correctly", async () => {
|
||||
expect(slidingSync.getListData(0).roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
});
|
||||
httpBackend.when("POST", syncUrl).respond(200, {
|
||||
pos: "h",
|
||||
lists: [{
|
||||
count: 500,
|
||||
ops: [
|
||||
{
|
||||
op: "INSERT", index: 1, room_id: roomA,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
count: 50,
|
||||
}],
|
||||
});
|
||||
let listPromise = listenUntil(slidingSync, "SlidingSync.List",
|
||||
(listIndex, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listIndex).toEqual(0);
|
||||
expect(joinedCount).toEqual(500);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
1: roomA,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
|
||||
httpBackend.when("POST", syncUrl).respond(200, {
|
||||
pos: "h",
|
||||
lists: [{
|
||||
count: 501,
|
||||
ops: [
|
||||
{
|
||||
op: "INSERT", index: 1, room_id: roomB,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
count: 50,
|
||||
}],
|
||||
});
|
||||
listPromise = listenUntil(slidingSync, "SlidingSync.List",
|
||||
(listIndex, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listIndex).toEqual(0);
|
||||
expect(joinedCount).toEqual(501);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
1: roomB,
|
||||
2: roomA,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
slidingSync.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transaction IDs", () => {
|
||||
beforeAll(setupClient);
|
||||
afterAll(teardownClient);
|
||||
const roomId = "!foo:bar";
|
||||
|
||||
let slidingSync: SlidingSync;
|
||||
|
||||
// really this applies to them all but it's easier to just test one
|
||||
it("should resolve modifyRoomSubscriptions after SlidingSync.start() is called", async () => {
|
||||
const roomSubInfo = {
|
||||
timeline_limit: 1,
|
||||
required_state: [
|
||||
["m.room.name", ""],
|
||||
],
|
||||
};
|
||||
// add the subscription
|
||||
slidingSync = new SlidingSync(proxyBaseUrl, [], roomSubInfo, client, 1);
|
||||
// modification before SlidingSync.start()
|
||||
const subscribePromise = slidingSync.modifyRoomSubscriptions(new Set([roomId]));
|
||||
let txnId;
|
||||
httpBackend.when("POST", syncUrl).check(function(req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeTruthy();
|
||||
expect(body.room_subscriptions[roomId]).toEqual(roomSubInfo);
|
||||
expect(body.txn_id).toBeTruthy();
|
||||
txnId = body.txn_id;
|
||||
}).respond(200, function() {
|
||||
return {
|
||||
pos: "aaa",
|
||||
txn_id: txnId,
|
||||
lists: [],
|
||||
extensions: {},
|
||||
rooms: {
|
||||
[roomId]: {
|
||||
name: "foo bar",
|
||||
required_state: [],
|
||||
timeline: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
slidingSync.start();
|
||||
await httpBackend.flushAllExpected();
|
||||
await subscribePromise;
|
||||
});
|
||||
it("should resolve setList during a connection", async () => {
|
||||
const newList = {
|
||||
ranges: [[0, 20]],
|
||||
};
|
||||
const promise = slidingSync.setList(0, newList);
|
||||
let txnId;
|
||||
httpBackend.when("POST", syncUrl).check(function(req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeFalsy();
|
||||
expect(body.lists[0]).toEqual(newList);
|
||||
expect(body.txn_id).toBeTruthy();
|
||||
txnId = body.txn_id;
|
||||
}).respond(200, function() {
|
||||
return {
|
||||
pos: "bbb",
|
||||
txn_id: txnId,
|
||||
lists: [{ count: 5 }],
|
||||
extensions: {},
|
||||
};
|
||||
});
|
||||
await httpBackend.flushAllExpected();
|
||||
await promise;
|
||||
expect(txnId).toBeDefined();
|
||||
});
|
||||
it("should resolve setListRanges during a connection", async () => {
|
||||
const promise = slidingSync.setListRanges(0, [[20, 40]]);
|
||||
let txnId;
|
||||
httpBackend.when("POST", syncUrl).check(function(req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeFalsy();
|
||||
expect(body.lists[0]).toEqual({
|
||||
ranges: [[20, 40]],
|
||||
});
|
||||
expect(body.txn_id).toBeTruthy();
|
||||
txnId = body.txn_id;
|
||||
}).respond(200, function() {
|
||||
return {
|
||||
pos: "ccc",
|
||||
txn_id: txnId,
|
||||
lists: [{ count: 5 }],
|
||||
extensions: {},
|
||||
};
|
||||
});
|
||||
await httpBackend.flushAllExpected();
|
||||
await promise;
|
||||
expect(txnId).toBeDefined();
|
||||
});
|
||||
it("should resolve modifyRoomSubscriptionInfo during a connection", async () => {
|
||||
const promise = slidingSync.modifyRoomSubscriptionInfo({
|
||||
timeline_limit: 99,
|
||||
});
|
||||
let txnId;
|
||||
httpBackend.when("POST", syncUrl).check(function(req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeTruthy();
|
||||
expect(body.room_subscriptions[roomId]).toEqual({
|
||||
timeline_limit: 99,
|
||||
});
|
||||
expect(body.txn_id).toBeTruthy();
|
||||
txnId = body.txn_id;
|
||||
}).respond(200, function() {
|
||||
return {
|
||||
pos: "ddd",
|
||||
txn_id: txnId,
|
||||
extensions: {},
|
||||
};
|
||||
});
|
||||
await httpBackend.flushAllExpected();
|
||||
await promise;
|
||||
expect(txnId).toBeDefined();
|
||||
});
|
||||
it("should reject earlier pending promises if a later transaction is acknowledged", async () => {
|
||||
// i.e if we have [A,B,C] and see txn_id=C then A,B should be rejected.
|
||||
const gotTxnIds = [];
|
||||
const pushTxn = function(req) {
|
||||
gotTxnIds.push(req.data.txn_id);
|
||||
};
|
||||
const failPromise = slidingSync.setListRanges(0, [[20, 40]]);
|
||||
httpBackend.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "e" }); // missing txn_id
|
||||
await httpBackend.flushAllExpected();
|
||||
const failPromise2 = slidingSync.setListRanges(0, [[60, 70]]);
|
||||
httpBackend.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "f" }); // missing txn_id
|
||||
await httpBackend.flushAllExpected();
|
||||
|
||||
// attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection
|
||||
// which is a fail.
|
||||
expect(failPromise).rejects.toEqual(gotTxnIds[0]);
|
||||
expect(failPromise2).rejects.toEqual(gotTxnIds[1]);
|
||||
|
||||
const okPromise = slidingSync.setListRanges(0, [[0, 20]]);
|
||||
let txnId;
|
||||
httpBackend.when("POST", syncUrl).check((req) => {
|
||||
txnId = req.data.txn_id;
|
||||
}).respond(200, () => {
|
||||
// include the txn_id, earlier requests should now be reject()ed.
|
||||
return {
|
||||
pos: "g",
|
||||
txn_id: txnId,
|
||||
};
|
||||
});
|
||||
await httpBackend.flushAllExpected();
|
||||
await okPromise;
|
||||
|
||||
expect(txnId).toBeDefined();
|
||||
});
|
||||
it("should not reject later pending promises if an earlier transaction is acknowledged", async () => {
|
||||
// i.e if we have [A,B,C] and see txn_id=B then C should not be rejected but A should.
|
||||
const gotTxnIds = [];
|
||||
const pushTxn = function(req) {
|
||||
gotTxnIds.push(req.data.txn_id);
|
||||
};
|
||||
const A = slidingSync.setListRanges(0, [[20, 40]]);
|
||||
httpBackend.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "A" });
|
||||
await httpBackend.flushAllExpected();
|
||||
const B = slidingSync.setListRanges(0, [[60, 70]]);
|
||||
httpBackend.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "B" }); // missing txn_id
|
||||
await httpBackend.flushAllExpected();
|
||||
|
||||
// attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection
|
||||
// which is a fail.
|
||||
expect(A).rejects.toEqual(gotTxnIds[0]);
|
||||
|
||||
const C = slidingSync.setListRanges(0, [[0, 20]]);
|
||||
let pendingC = true;
|
||||
C.finally(() => {
|
||||
pendingC = false;
|
||||
});
|
||||
httpBackend.when("POST", syncUrl).check(pushTxn).respond(200, () => {
|
||||
// include the txn_id for B, so C's promise is outstanding
|
||||
return {
|
||||
pos: "C",
|
||||
txn_id: gotTxnIds[1],
|
||||
};
|
||||
});
|
||||
await httpBackend.flushAllExpected();
|
||||
// A is rejected, see above
|
||||
expect(B).resolves.toEqual(gotTxnIds[1]); // B is resolved
|
||||
expect(pendingC).toBe(true); // C is pending still
|
||||
});
|
||||
it("should do nothing for unknown txn_ids", async () => {
|
||||
const promise = slidingSync.setListRanges(0, [[20, 40]]);
|
||||
let pending = true;
|
||||
promise.finally(() => {
|
||||
pending = false;
|
||||
});
|
||||
let txnId;
|
||||
httpBackend.when("POST", syncUrl).check(function(req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeFalsy();
|
||||
expect(body.lists[0]).toEqual({
|
||||
ranges: [[20, 40]],
|
||||
});
|
||||
expect(body.txn_id).toBeTruthy();
|
||||
txnId = body.txn_id;
|
||||
}).respond(200, function() {
|
||||
return {
|
||||
pos: "ccc",
|
||||
txn_id: "bogus transaction id",
|
||||
lists: [{ count: 5 }],
|
||||
extensions: {},
|
||||
};
|
||||
});
|
||||
await httpBackend.flushAllExpected();
|
||||
expect(txnId).toBeDefined();
|
||||
expect(pending).toBe(true);
|
||||
slidingSync.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
@@ -148,31 +153,32 @@ describe("AutoDiscovery", function() {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return FAIL_PROMPT when .well-known returns not-JSON", function() {
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, "abc");
|
||||
it("should return FAIL_PROMPT when .well-known returns not-JSON", async () => {
|
||||
const httpBackend = getHttpBackend();
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, "abc", true);
|
||||
const expected = {
|
||||
"m.homeserver": {
|
||||
state: "FAIL_PROMPT",
|
||||
error: AutoDiscovery.ERROR_INVALID,
|
||||
base_url: null,
|
||||
},
|
||||
"m.identity_server": {
|
||||
state: "PROMPT",
|
||||
error: null,
|
||||
base_url: null,
|
||||
},
|
||||
};
|
||||
return Promise.all([
|
||||
httpBackend.flushAllExpected(),
|
||||
AutoDiscovery.findClientConfig("example.org").then((conf) => {
|
||||
const expected = {
|
||||
"m.homeserver": {
|
||||
state: "FAIL_PROMPT",
|
||||
error: AutoDiscovery.ERROR_INVALID,
|
||||
base_url: null,
|
||||
},
|
||||
"m.identity_server": {
|
||||
state: "PROMPT",
|
||||
error: null,
|
||||
base_url: null,
|
||||
},
|
||||
};
|
||||
|
||||
expect(conf).toEqual(expected);
|
||||
}),
|
||||
AutoDiscovery.findClientConfig("example.org").then(
|
||||
expect(expected).toEqual,
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
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 +207,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 +234,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 +263,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 +293,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 +323,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 +355,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 +388,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 +424,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 +465,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 +506,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 +548,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 +590,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 +633,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+153
-102
@@ -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);
|
||||
@@ -1,280 +0,0 @@
|
||||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger } from "../../src/logger";
|
||||
import { InteractiveAuth } from "../../src/interactive-auth";
|
||||
import { MatrixError } from "../../src/http-api";
|
||||
import { sleep } from "../../src/utils";
|
||||
import { randomString } from "../../src/randomstring";
|
||||
|
||||
// Trivial client object to test interactive auth
|
||||
// (we do not need TestClient here)
|
||||
class FakeClient {
|
||||
generateClientSecret() {
|
||||
return "testcl1Ent5EcreT";
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
doRequest: doRequest,
|
||||
stateUpdated: stateUpdated,
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
flows: [
|
||||
{ stages: ["logintype"] },
|
||||
],
|
||||
params: {
|
||||
"logintype": { param: "aa" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(ia.getSessionId()).toEqual("sessionId");
|
||||
expect(ia.getStageParams("logintype")).toEqual({
|
||||
param: "aa",
|
||||
});
|
||||
|
||||
// first we expect a call here
|
||||
stateUpdated.mockImplementation(function(stage) {
|
||||
logger.log('aaaa');
|
||||
expect(stage).toEqual("logintype");
|
||||
ia.submitAuthDict({
|
||||
type: "logintype",
|
||||
foo: "bar",
|
||||
});
|
||||
});
|
||||
|
||||
// .. which should trigger a call here
|
||||
const requestRes = { "a": "b" };
|
||||
doRequest.mockImplementation(function(authData) {
|
||||
logger.log('cccc');
|
||||
expect(authData).toEqual({
|
||||
session: "sessionId",
|
||||
type: "logintype",
|
||||
foo: "bar",
|
||||
});
|
||||
return Promise.resolve(requestRes);
|
||||
});
|
||||
|
||||
return ia.attemptAuth().then(function(res) {
|
||||
expect(res).toBe(requestRes);
|
||||
expect(doRequest).toBeCalledTimes(1);
|
||||
expect(stateUpdated).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should make a request if no authdata is provided", function() {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: new FakeClient(),
|
||||
stateUpdated: stateUpdated,
|
||||
doRequest: doRequest,
|
||||
});
|
||||
|
||||
expect(ia.getSessionId()).toBe(undefined);
|
||||
expect(ia.getStageParams("logintype")).toBe(undefined);
|
||||
|
||||
// first we expect a call to doRequest
|
||||
doRequest.mockImplementation(function(authData) {
|
||||
logger.log("request1", authData);
|
||||
expect(authData).toEqual(null); // first request should be null
|
||||
const err = new MatrixError({
|
||||
session: "sessionId",
|
||||
flows: [
|
||||
{ stages: ["logintype"] },
|
||||
],
|
||||
params: {
|
||||
"logintype": { param: "aa" },
|
||||
},
|
||||
});
|
||||
err.httpStatus = 401;
|
||||
throw err;
|
||||
});
|
||||
|
||||
// .. which should be followed by a call to stateUpdated
|
||||
const requestRes = { "a": "b" };
|
||||
stateUpdated.mockImplementation(function(stage) {
|
||||
expect(stage).toEqual("logintype");
|
||||
expect(ia.getSessionId()).toEqual("sessionId");
|
||||
expect(ia.getStageParams("logintype")).toEqual({
|
||||
param: "aa",
|
||||
});
|
||||
|
||||
// submitAuthDict should trigger another call to doRequest
|
||||
doRequest.mockImplementation(function(authData) {
|
||||
logger.log("request2", authData);
|
||||
expect(authData).toEqual({
|
||||
session: "sessionId",
|
||||
type: "logintype",
|
||||
foo: "bar",
|
||||
});
|
||||
return Promise.resolve(requestRes);
|
||||
});
|
||||
|
||||
ia.submitAuthDict({
|
||||
type: "logintype",
|
||||
foo: "bar",
|
||||
});
|
||||
});
|
||||
|
||||
return ia.attemptAuth().then(function(res) {
|
||||
expect(res).toBe(requestRes);
|
||||
expect(doRequest).toBeCalledTimes(2);
|
||||
expect(stateUpdated).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should start an auth stage and reject if no auth flow", function() {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: new FakeClient(),
|
||||
doRequest: doRequest,
|
||||
stateUpdated: stateUpdated,
|
||||
});
|
||||
|
||||
doRequest.mockImplementation(function(authData) {
|
||||
logger.log("request1", authData);
|
||||
expect(authData).toEqual(null); // first request should be null
|
||||
const err = new MatrixError({
|
||||
session: "sessionId",
|
||||
flows: [],
|
||||
params: {
|
||||
"logintype": { param: "aa" },
|
||||
},
|
||||
});
|
||||
err.httpStatus = 401;
|
||||
throw err;
|
||||
});
|
||||
|
||||
return ia.attemptAuth().catch(function(error) {
|
||||
expect(error.message).toBe('No appropriate authentication flow found');
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestEmailToken", () => {
|
||||
it("increases auth attempts", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
requestEmailToken.mockImplementation(async () => ({ sid: "" }));
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: new FakeClient(),
|
||||
doRequest, stateUpdated, requestEmailToken,
|
||||
});
|
||||
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 1, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 2, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 3, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 4, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 5, undefined);
|
||||
});
|
||||
|
||||
it("increases auth attempts", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
requestEmailToken.mockImplementation(async () => ({ sid: "" }));
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: new FakeClient(),
|
||||
doRequest, stateUpdated, requestEmailToken,
|
||||
});
|
||||
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 1, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 2, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 3, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 4, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 5, undefined);
|
||||
});
|
||||
|
||||
it("passes errors through", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
requestEmailToken.mockImplementation(async () => {
|
||||
throw new Error("unspecific network error");
|
||||
});
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: new FakeClient(),
|
||||
doRequest, stateUpdated, requestEmailToken,
|
||||
});
|
||||
|
||||
expect(async () => await ia.requestEmailToken()).rejects.toThrowError("unspecific network error");
|
||||
});
|
||||
|
||||
it("only starts one request at a time", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
requestEmailToken.mockImplementation(() => sleep(500, { sid: "" }));
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: new FakeClient(),
|
||||
doRequest, stateUpdated, requestEmailToken,
|
||||
});
|
||||
|
||||
await Promise.all([ia.requestEmailToken(), ia.requestEmailToken(), ia.requestEmailToken()]);
|
||||
expect(requestEmailToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stores result in email sid", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
const sid = randomString(24);
|
||||
requestEmailToken.mockImplementation(() => sleep(500, { sid }));
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: new FakeClient(),
|
||||
doRequest, stateUpdated, requestEmailToken,
|
||||
});
|
||||
|
||||
await ia.requestEmailToken();
|
||||
expect(ia.getEmailSid()).toEqual(sid);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,552 @@
|
||||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { logger } from "../../src/logger";
|
||||
import { InteractiveAuth, AuthType } from "../../src/interactive-auth";
|
||||
import { MatrixError } from "../../src/http-api";
|
||||
import { sleep } from "../../src/utils";
|
||||
import { randomString } from "../../src/randomstring";
|
||||
|
||||
// Trivial client object to test interactive auth
|
||||
// (we do not need TestClient here)
|
||||
class FakeClient {
|
||||
generateClientSecret() {
|
||||
return "testcl1Ent5EcreT";
|
||||
}
|
||||
}
|
||||
|
||||
const getFakeClient = (): MatrixClient => new FakeClient() as unknown as MatrixClient;
|
||||
|
||||
describe("InteractiveAuth", () => {
|
||||
it("should start an auth stage and complete it", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest: doRequest,
|
||||
stateUpdated: stateUpdated,
|
||||
requestEmailToken: jest.fn(),
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
flows: [
|
||||
{ stages: [AuthType.Password] },
|
||||
],
|
||||
params: {
|
||||
[AuthType.Password]: { param: "aa" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(ia.getSessionId()).toEqual("sessionId");
|
||||
expect(ia.getStageParams(AuthType.Password)).toEqual({
|
||||
param: "aa",
|
||||
});
|
||||
|
||||
// first we expect a call here
|
||||
stateUpdated.mockImplementation((stage) => {
|
||||
logger.log('aaaa');
|
||||
expect(stage).toEqual(AuthType.Password);
|
||||
ia.submitAuthDict({
|
||||
type: AuthType.Password,
|
||||
});
|
||||
});
|
||||
|
||||
// .. which should trigger a call here
|
||||
const requestRes = { "a": "b" };
|
||||
doRequest.mockImplementation(async (authData) => {
|
||||
logger.log('cccc');
|
||||
expect(authData).toEqual({
|
||||
session: "sessionId",
|
||||
type: AuthType.Password,
|
||||
});
|
||||
return requestRes;
|
||||
});
|
||||
|
||||
const res = await ia.attemptAuth();
|
||||
expect(res).toBe(requestRes);
|
||||
expect(doRequest).toBeCalledTimes(1);
|
||||
expect(stateUpdated).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle auth errcode presence ", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest: doRequest,
|
||||
stateUpdated: stateUpdated,
|
||||
requestEmailToken: jest.fn(),
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
flows: [
|
||||
{ stages: [AuthType.Password] },
|
||||
],
|
||||
errcode: "MockError0",
|
||||
params: {
|
||||
[AuthType.Password]: { param: "aa" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(ia.getSessionId()).toEqual("sessionId");
|
||||
expect(ia.getStageParams(AuthType.Password)).toEqual({
|
||||
param: "aa",
|
||||
});
|
||||
|
||||
// first we expect a call here
|
||||
stateUpdated.mockImplementation((stage) => {
|
||||
logger.log('aaaa');
|
||||
expect(stage).toEqual(AuthType.Password);
|
||||
ia.submitAuthDict({
|
||||
type: AuthType.Password,
|
||||
});
|
||||
});
|
||||
|
||||
// .. which should trigger a call here
|
||||
const requestRes = { "a": "b" };
|
||||
doRequest.mockImplementation(async (authData) => {
|
||||
logger.log('cccc');
|
||||
expect(authData).toEqual({
|
||||
session: "sessionId",
|
||||
type: AuthType.Password,
|
||||
});
|
||||
return requestRes;
|
||||
});
|
||||
|
||||
const res = await ia.attemptAuth();
|
||||
expect(res).toBe(requestRes);
|
||||
expect(doRequest).toBeCalledTimes(1);
|
||||
expect(stateUpdated).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle set emailSid for email flow", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
doRequest,
|
||||
stateUpdated,
|
||||
requestEmailToken,
|
||||
matrixClient: getFakeClient(),
|
||||
emailSid: 'myEmailSid',
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
flows: [
|
||||
{ stages: [AuthType.Email, AuthType.Password] },
|
||||
],
|
||||
params: {
|
||||
[AuthType.Email]: { param: "aa" },
|
||||
[AuthType.Password]: { param: "bb" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(ia.getSessionId()).toEqual("sessionId");
|
||||
expect(ia.getStageParams(AuthType.Email)).toEqual({
|
||||
param: "aa",
|
||||
});
|
||||
|
||||
// first we expect a call here
|
||||
stateUpdated.mockImplementation((stage) => {
|
||||
logger.log('husky');
|
||||
expect(stage).toEqual(AuthType.Email);
|
||||
ia.submitAuthDict({
|
||||
type: AuthType.Email,
|
||||
});
|
||||
});
|
||||
|
||||
// .. which should trigger a call here
|
||||
const requestRes = { "a": "b" };
|
||||
doRequest.mockImplementation(async (authData) => {
|
||||
logger.log('barfoo');
|
||||
expect(authData).toEqual({
|
||||
session: "sessionId",
|
||||
type: AuthType.Email,
|
||||
});
|
||||
return requestRes;
|
||||
});
|
||||
|
||||
const res = await ia.attemptAuth();
|
||||
expect(res).toBe(requestRes);
|
||||
expect(doRequest).toBeCalledTimes(1);
|
||||
expect(stateUpdated).toBeCalledTimes(1);
|
||||
expect(requestEmailToken).toBeCalledTimes(0);
|
||||
expect(ia.getEmailSid()).toBe("myEmailSid");
|
||||
});
|
||||
|
||||
it("should make a request if no authdata is provided", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
stateUpdated,
|
||||
doRequest,
|
||||
requestEmailToken,
|
||||
});
|
||||
|
||||
expect(ia.getSessionId()).toBe(undefined);
|
||||
expect(ia.getStageParams(AuthType.Password)).toBe(undefined);
|
||||
|
||||
// first we expect a call to doRequest
|
||||
doRequest.mockImplementation((authData) => {
|
||||
logger.log("request1", authData);
|
||||
expect(authData).toEqual(null); // first request should be null
|
||||
const err = new MatrixError({
|
||||
session: "sessionId",
|
||||
flows: [
|
||||
{ stages: [AuthType.Password] },
|
||||
],
|
||||
params: {
|
||||
[AuthType.Password]: { param: "aa" },
|
||||
},
|
||||
});
|
||||
err.httpStatus = 401;
|
||||
throw err;
|
||||
});
|
||||
|
||||
// .. which should be followed by a call to stateUpdated
|
||||
const requestRes = { "a": "b" };
|
||||
stateUpdated.mockImplementation((stage) => {
|
||||
expect(stage).toEqual(AuthType.Password);
|
||||
expect(ia.getSessionId()).toEqual("sessionId");
|
||||
expect(ia.getStageParams(AuthType.Password)).toEqual({
|
||||
param: "aa",
|
||||
});
|
||||
|
||||
// submitAuthDict should trigger another call to doRequest
|
||||
doRequest.mockImplementation(async (authData) => {
|
||||
logger.log("request2", authData);
|
||||
expect(authData).toEqual({
|
||||
session: "sessionId",
|
||||
type: AuthType.Password,
|
||||
});
|
||||
return requestRes;
|
||||
});
|
||||
|
||||
ia.submitAuthDict({
|
||||
type: AuthType.Password,
|
||||
});
|
||||
});
|
||||
|
||||
const res = await ia.attemptAuth();
|
||||
expect(res).toBe(requestRes);
|
||||
expect(doRequest).toBeCalledTimes(2);
|
||||
expect(stateUpdated).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should make a request if authdata is null", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
authData: null,
|
||||
matrixClient: getFakeClient(),
|
||||
stateUpdated,
|
||||
doRequest,
|
||||
requestEmailToken,
|
||||
});
|
||||
|
||||
expect(ia.getSessionId()).toBe(undefined);
|
||||
expect(ia.getStageParams(AuthType.Password)).toBe(undefined);
|
||||
|
||||
// first we expect a call to doRequest
|
||||
doRequest.mockImplementation((authData) => {
|
||||
logger.log("request1", authData);
|
||||
expect(authData).toEqual(null); // first request should be null
|
||||
const err = new MatrixError({
|
||||
session: "sessionId",
|
||||
flows: [
|
||||
{ stages: [AuthType.Password] },
|
||||
],
|
||||
params: {
|
||||
[AuthType.Password]: { param: "aa" },
|
||||
},
|
||||
});
|
||||
err.httpStatus = 401;
|
||||
throw err;
|
||||
});
|
||||
|
||||
// .. which should be followed by a call to stateUpdated
|
||||
const requestRes = { "a": "b" };
|
||||
stateUpdated.mockImplementation((stage) => {
|
||||
expect(stage).toEqual(AuthType.Password);
|
||||
expect(ia.getSessionId()).toEqual("sessionId");
|
||||
expect(ia.getStageParams(AuthType.Password)).toEqual({
|
||||
param: "aa",
|
||||
});
|
||||
|
||||
// submitAuthDict should trigger another call to doRequest
|
||||
doRequest.mockImplementation(async (authData) => {
|
||||
logger.log("request2", authData);
|
||||
expect(authData).toEqual({
|
||||
session: "sessionId",
|
||||
type: AuthType.Password,
|
||||
});
|
||||
return requestRes;
|
||||
});
|
||||
|
||||
ia.submitAuthDict({
|
||||
type: AuthType.Password,
|
||||
});
|
||||
});
|
||||
|
||||
const res = await ia.attemptAuth();
|
||||
expect(res).toBe(requestRes);
|
||||
expect(doRequest).toBeCalledTimes(2);
|
||||
expect(stateUpdated).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should start an auth stage and reject if no auth flow", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest,
|
||||
stateUpdated,
|
||||
requestEmailToken,
|
||||
});
|
||||
|
||||
doRequest.mockImplementation((authData) => {
|
||||
logger.log("request1", authData);
|
||||
expect(authData).toEqual(null); // first request should be null
|
||||
const err = new MatrixError({
|
||||
session: "sessionId",
|
||||
flows: [],
|
||||
params: {
|
||||
[AuthType.Password]: { param: "aa" },
|
||||
},
|
||||
});
|
||||
err.httpStatus = 401;
|
||||
throw err;
|
||||
});
|
||||
|
||||
await expect(ia.attemptAuth.bind(ia)).rejects.toThrow(
|
||||
new Error('No appropriate authentication flow found'),
|
||||
);
|
||||
});
|
||||
|
||||
it("should start an auth stage and reject if no auth flow but has session", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest,
|
||||
stateUpdated,
|
||||
requestEmailToken,
|
||||
authData: {
|
||||
},
|
||||
sessionId: "sessionId",
|
||||
});
|
||||
|
||||
doRequest.mockImplementation((authData) => {
|
||||
logger.log("request1", authData);
|
||||
expect(authData).toEqual({ "session": "sessionId" }); // has existing sessionId
|
||||
const err = new MatrixError({
|
||||
session: "sessionId",
|
||||
flows: [],
|
||||
params: {
|
||||
[AuthType.Password]: { param: "aa" },
|
||||
},
|
||||
error: "Mock Error 1",
|
||||
errcode: "MOCKERR1",
|
||||
});
|
||||
err.httpStatus = 401;
|
||||
throw err;
|
||||
});
|
||||
|
||||
await expect(ia.attemptAuth.bind(ia)).rejects.toThrow(
|
||||
new Error('No appropriate authentication flow found'),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle unexpected error types without data propery set", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest,
|
||||
stateUpdated,
|
||||
requestEmailToken,
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
},
|
||||
});
|
||||
|
||||
doRequest.mockImplementation((authData) => {
|
||||
logger.log("request1", authData);
|
||||
expect(authData).toEqual({ "session": "sessionId" }); // has existing sessionId
|
||||
const err = new Error('myerror');
|
||||
(err as any).httpStatus = 401;
|
||||
throw err;
|
||||
});
|
||||
|
||||
await expect(ia.attemptAuth.bind(ia)).rejects.toThrow(
|
||||
new Error("myerror"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow dummy auth", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest,
|
||||
stateUpdated,
|
||||
requestEmailToken,
|
||||
authData: {
|
||||
session: 'sessionId',
|
||||
flows: [
|
||||
{ stages: [AuthType.Dummy] },
|
||||
],
|
||||
params: {},
|
||||
},
|
||||
});
|
||||
|
||||
const requestRes = { "a": "b" };
|
||||
doRequest.mockImplementation((authData) => {
|
||||
logger.log("request1", authData);
|
||||
expect(authData).toEqual({
|
||||
session: "sessionId",
|
||||
type: AuthType.Dummy,
|
||||
});
|
||||
return requestRes;
|
||||
});
|
||||
|
||||
const res = await ia.attemptAuth();
|
||||
expect(res).toBe(requestRes);
|
||||
expect(doRequest).toBeCalledTimes(1);
|
||||
expect(stateUpdated).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
describe("requestEmailToken", () => {
|
||||
it("increases auth attempts", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
requestEmailToken.mockImplementation(async () => ({ sid: "" }));
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest, stateUpdated, requestEmailToken,
|
||||
});
|
||||
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 1, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 2, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 3, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 4, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 5, undefined);
|
||||
});
|
||||
|
||||
it("increases auth attempts", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
requestEmailToken.mockImplementation(async () => ({ sid: "" }));
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest, stateUpdated, requestEmailToken,
|
||||
});
|
||||
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 1, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 2, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 3, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 4, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 5, undefined);
|
||||
});
|
||||
|
||||
it("passes errors through", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
requestEmailToken.mockImplementation(async () => {
|
||||
throw new Error("unspecific network error");
|
||||
});
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest, stateUpdated, requestEmailToken,
|
||||
});
|
||||
|
||||
await expect(ia.requestEmailToken.bind(ia)).rejects.toThrowError("unspecific network error");
|
||||
});
|
||||
|
||||
it("only starts one request at a time", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
requestEmailToken.mockImplementation(() => sleep(500, { sid: "" }));
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest, stateUpdated, requestEmailToken,
|
||||
});
|
||||
|
||||
await Promise.all([ia.requestEmailToken(), ia.requestEmailToken(), ia.requestEmailToken()]);
|
||||
expect(requestEmailToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stores result in email sid", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
const sid = randomString(24);
|
||||
requestEmailToken.mockImplementation(() => sleep(500, { sid }));
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest, stateUpdated, requestEmailToken,
|
||||
});
|
||||
|
||||
await ia.requestEmailToken();
|
||||
expect(ia.getEmailSid()).toEqual(sid);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TestClient } from '../TestClient';
|
||||
|
||||
describe('Login request', function() {
|
||||
let client;
|
||||
let client: TestClient;
|
||||
|
||||
beforeEach(function() {
|
||||
client = new TestClient();
|
||||
+131
-16
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent } from "../../../src";
|
||||
import { M_BEACON_INFO } from "../../../src/@types/beacon";
|
||||
import {
|
||||
isTimestampInDuration,
|
||||
Beacon,
|
||||
@@ -129,6 +130,24 @@ describe('Beacon', () => {
|
||||
expect(beacon.beaconInfo).toBeTruthy();
|
||||
});
|
||||
|
||||
it('creates beacon without error from a malformed event', () => {
|
||||
const event = new MatrixEvent({
|
||||
type: M_BEACON_INFO.name,
|
||||
room_id: roomId,
|
||||
state_key: userId,
|
||||
content: {},
|
||||
});
|
||||
const beacon = new Beacon(event);
|
||||
|
||||
expect(beacon.beaconInfoId).toEqual(event.getId());
|
||||
expect(beacon.roomId).toEqual(roomId);
|
||||
expect(beacon.isLive).toEqual(false);
|
||||
expect(beacon.beaconInfoOwner).toEqual(userId);
|
||||
expect(beacon.beaconInfoEventType).toEqual(liveBeaconEvent.getType());
|
||||
expect(beacon.identifier).toEqual(`${roomId}_${userId}`);
|
||||
expect(beacon.beaconInfo).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('isLive()', () => {
|
||||
it('returns false when beacon is explicitly set to not live', () => {
|
||||
const beacon = new Beacon(notLiveBeaconEvent);
|
||||
|
||||
@@ -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({
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
+119
-9
@@ -2435,16 +2435,126 @@ 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}`);
|
||||
}
|
||||
});
|
||||
|
||||
describe("correctly compares by timestamp", () => {
|
||||
it("should correctly compare, if we have all receipts", () => {
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("should correctly compare, if private read receipt is missing", () => {
|
||||
room.getUnfilteredTimelineSet = () => ({
|
||||
compareEventOrdering: (_1, _2) => null,
|
||||
} as EventTimelineSet);
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
|
||||
if (receiptType === ReceiptType.UnstableReadPrivate) {
|
||||
return { eventId: "eventId1", data: { ts: 0 } } as IWrappedReceipt;
|
||||
}
|
||||
if (receiptType === ReceiptType.Read) {
|
||||
return { eventId: "eventId2", data: { ts: 1 } } as IWrappedReceipt;
|
||||
}
|
||||
};
|
||||
|
||||
expect(room.getEventReadUpTo(userA)).toEqual(`eventId2`);
|
||||
});
|
||||
});
|
||||
|
||||
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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("roomNameGenerator", () => {
|
||||
const client = new TestClient(userA).client;
|
||||
client.roomNameGenerator = jest.fn().mockReturnValue(null);
|
||||
const room = new Room(roomId, client, userA);
|
||||
|
||||
it("should call fn when recalculating room name", () => {
|
||||
(client.roomNameGenerator as jest.Mock).mockClear();
|
||||
room.recalculate();
|
||||
expect(client.roomNameGenerator).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,7 +24,16 @@ import { ListenerMap, TypedEventEmitter } from "./models/typed-event-emitter";
|
||||
export class ReEmitter {
|
||||
constructor(private readonly target: EventEmitter) {}
|
||||
|
||||
// Map from emitter to event name to re-emitter
|
||||
private reEmitters = new Map<EventEmitter, Map<string, (...args: any[]) => void>>();
|
||||
|
||||
public reEmit(source: EventEmitter, eventNames: string[]): void {
|
||||
let reEmittersByEvent = this.reEmitters.get(source);
|
||||
if (!reEmittersByEvent) {
|
||||
reEmittersByEvent = new Map();
|
||||
this.reEmitters.set(source, reEmittersByEvent);
|
||||
}
|
||||
|
||||
for (const eventName of eventNames) {
|
||||
// We include the source as the last argument for event handlers which may need it,
|
||||
// such as read receipt listeners on the client class which won't have the context
|
||||
@@ -44,8 +53,21 @@ export class ReEmitter {
|
||||
this.target.emit(eventName, ...args, source);
|
||||
};
|
||||
source.on(eventName, forSource);
|
||||
reEmittersByEvent.set(eventName, forSource);
|
||||
}
|
||||
}
|
||||
|
||||
public stopReEmitting(source: EventEmitter, eventNames: string[]): void {
|
||||
const reEmittersByEvent = this.reEmitters.get(source);
|
||||
if (!reEmittersByEvent) return; // We were never re-emitting these events in the first place
|
||||
|
||||
for (const eventName of eventNames) {
|
||||
source.off(eventName, reEmittersByEvent.get(eventName));
|
||||
reEmittersByEvent.delete(eventName);
|
||||
}
|
||||
|
||||
if (reEmittersByEvent.size === 0) this.reEmitters.delete(source);
|
||||
}
|
||||
}
|
||||
|
||||
export class TypedReEmitter<
|
||||
@@ -62,4 +84,11 @@ export class TypedReEmitter<
|
||||
): void {
|
||||
super.reEmit(source, eventNames);
|
||||
}
|
||||
|
||||
public stopReEmitting<ReEmittedEvents extends string, T extends Events & ReEmittedEvents>(
|
||||
source: TypedEventEmitter<ReEmittedEvents, any>,
|
||||
eventNames: T[],
|
||||
): void {
|
||||
super.stopReEmitting(source, eventNames);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
-24
@@ -17,6 +17,8 @@ limitations under the License.
|
||||
|
||||
/** @module auto-discovery */
|
||||
|
||||
import { ServerResponse } from "http";
|
||||
|
||||
import { IClientWellKnown, IWellKnownConfig } from "./client";
|
||||
import { logger } from './logger';
|
||||
|
||||
@@ -409,39 +411,41 @@ export class AutoDiscovery {
|
||||
* @return {Promise<object>} Resolves to the returned state.
|
||||
* @private
|
||||
*/
|
||||
private static fetchWellKnownObject(url: string): Promise<IWellKnownConfig> {
|
||||
return new Promise(function(resolve) {
|
||||
private static fetchWellKnownObject(uri: string): Promise<IWellKnownConfig> {
|
||||
return new Promise((resolve) => {
|
||||
// eslint-disable-next-line
|
||||
const request = require("./matrix").getRequest();
|
||||
if (!request) throw new Error("No request library available");
|
||||
request(
|
||||
{ method: "GET", uri: url, timeout: 5000 },
|
||||
(err, response, body) => {
|
||||
if (err || response &&
|
||||
(response.statusCode < 200 || response.statusCode >= 300)
|
||||
) {
|
||||
let action = AutoDiscoveryAction.FAIL_PROMPT;
|
||||
let reason = (err ? err.message : null) || "General failure";
|
||||
if (response && response.statusCode === 404) {
|
||||
action = AutoDiscoveryAction.IGNORE;
|
||||
reason = AutoDiscovery.ERROR_MISSING_WELLKNOWN;
|
||||
}
|
||||
resolve({ raw: {}, action: action, reason: reason, error: err });
|
||||
return;
|
||||
{ method: "GET", uri, timeout: 5000 },
|
||||
(error: Error, response: ServerResponse, body: string) => {
|
||||
if (error || response?.statusCode < 200 || response?.statusCode >= 300) {
|
||||
const result = { error, raw: {} };
|
||||
return resolve(response?.statusCode === 404
|
||||
? {
|
||||
...result,
|
||||
action: AutoDiscoveryAction.IGNORE,
|
||||
reason: AutoDiscovery.ERROR_MISSING_WELLKNOWN,
|
||||
} : {
|
||||
...result,
|
||||
action: AutoDiscoveryAction.FAIL_PROMPT,
|
||||
reason: error?.message || "General failure",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
resolve({ raw: JSON.parse(body), action: AutoDiscoveryAction.SUCCESS });
|
||||
} catch (e) {
|
||||
let reason = AutoDiscovery.ERROR_INVALID;
|
||||
if (e.name === "SyntaxError") {
|
||||
reason = AutoDiscovery.ERROR_INVALID_JSON;
|
||||
}
|
||||
resolve({
|
||||
return resolve({
|
||||
raw: JSON.parse(body),
|
||||
action: AutoDiscoveryAction.SUCCESS,
|
||||
});
|
||||
} catch (err) {
|
||||
return resolve({
|
||||
error: err,
|
||||
raw: {},
|
||||
action: AutoDiscoveryAction.FAIL_PROMPT,
|
||||
reason: reason,
|
||||
error: e,
|
||||
reason: err?.name === "SyntaxError"
|
||||
? AutoDiscovery.ERROR_INVALID_JSON
|
||||
: AutoDiscovery.ERROR_INVALID,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
+101
-18
@@ -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';
|
||||
@@ -136,7 +137,7 @@ import { VerificationRequest } from "./crypto/verification/request/VerificationR
|
||||
import { VerificationBase as Verification } from "./crypto/verification/Base";
|
||||
import * as ContentHelpers from "./content-helpers";
|
||||
import { CrossSigningInfo, DeviceTrustLevel, ICacheCallbacks, UserTrustLevel } from "./crypto/CrossSigning";
|
||||
import { Room } from "./models/room";
|
||||
import { Room, RoomNameState } from "./models/room";
|
||||
import {
|
||||
IAddThreePidOnlyBody,
|
||||
IBindThreePidBody,
|
||||
@@ -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;
|
||||
}
|
||||
@@ -341,6 +344,12 @@ export interface ICreateClientOpts {
|
||||
fallbackICEServerAllowed?: boolean;
|
||||
|
||||
cryptoCallbacks?: ICryptoCallbacks;
|
||||
|
||||
/**
|
||||
* Method to generate room names for empty rooms and rooms names based on membership.
|
||||
* Defaults to a built-in English handler with basic pluralisation.
|
||||
*/
|
||||
roomNameGenerator?: (roomId: string, state: RoomNameState) => string | null;
|
||||
}
|
||||
|
||||
export interface IMatrixClientCreateOpts extends ICreateClientOpts {
|
||||
@@ -502,7 +511,7 @@ interface ITurnServerResponse {
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
interface ITurnServer {
|
||||
export interface ITurnServer {
|
||||
urls: string[];
|
||||
username: string;
|
||||
credential: string;
|
||||
@@ -787,6 +796,8 @@ export enum ClientEvent {
|
||||
DeleteRoom = "deleteRoom",
|
||||
SyncUnexpectedError = "sync.unexpectedError",
|
||||
ClientWellKnown = "WellKnown.client",
|
||||
TurnServers = "turnServers",
|
||||
TurnServersError = "turnServers.error",
|
||||
}
|
||||
|
||||
type RoomEvents = RoomEvent.Name
|
||||
@@ -857,6 +868,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
|
||||
@@ -911,6 +924,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
protected fallbackICEServerAllowed = false;
|
||||
protected roomList: RoomList;
|
||||
protected syncApi: SlidingSyncSdk | SyncApi;
|
||||
public roomNameGenerator?: ICreateClientOpts["roomNameGenerator"];
|
||||
public pushRules: IPushRules;
|
||||
protected syncLeftRoomsPromise: Promise<Room[]>;
|
||||
protected syncedLeftRooms = false;
|
||||
@@ -933,12 +947,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();
|
||||
|
||||
@@ -1032,6 +1048,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// we still want to know which rooms are encrypted even if crypto is disabled:
|
||||
// we don't want to start sending unencrypted events to them.
|
||||
this.roomList = new RoomList(this.cryptoStore);
|
||||
this.roomNameGenerator = opts.roomNameGenerator;
|
||||
|
||||
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.
|
||||
@@ -1077,11 +1096,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 +1216,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}, 1000 * this.clientOpts.clientWellKnownPollPeriod);
|
||||
this.fetchClientWellKnown();
|
||||
}
|
||||
|
||||
this.toDeviceMessageQueue.start();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1220,9 +1242,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 +1587,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 +2574,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 +3554,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 +3562,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 +4669,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 +6357,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 +6388,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 +7509,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 +7522,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 +8763,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 +8798,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
|
||||
|
||||
@@ -247,7 +247,7 @@ export const makeBeaconInfoContent: MakeBeaconInfoContent = (
|
||||
});
|
||||
|
||||
export type BeaconInfoState = MBeaconInfoContent & {
|
||||
assetType: LocationAssetType;
|
||||
assetType?: LocationAssetType;
|
||||
timestamp: number;
|
||||
};
|
||||
/**
|
||||
@@ -255,14 +255,14 @@ export type BeaconInfoState = MBeaconInfoContent & {
|
||||
*/
|
||||
export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): BeaconInfoState => {
|
||||
const { description, timeout, live } = content;
|
||||
const { type: assetType } = M_ASSET.findIn<MAssetContent>(content);
|
||||
const timestamp = M_TIMESTAMP.findIn<number>(content);
|
||||
const asset = M_ASSET.findIn<MAssetContent>(content);
|
||||
|
||||
return {
|
||||
description,
|
||||
timeout,
|
||||
live,
|
||||
assetType,
|
||||
assetType: asset?.type,
|
||||
timestamp,
|
||||
};
|
||||
};
|
||||
|
||||
+3
-3
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+13
-15
@@ -250,12 +250,9 @@ export class InteractiveAuth {
|
||||
if (!this.data?.flows) {
|
||||
this.busyChangedCallback?.(true);
|
||||
// use the existing sessionId, if one is present.
|
||||
let auth = null;
|
||||
if (this.data.session) {
|
||||
auth = {
|
||||
session: this.data.session,
|
||||
};
|
||||
}
|
||||
const auth = this.data.session
|
||||
? { session: this.data.session }
|
||||
: null;
|
||||
this.doRequest(auth).finally(() => {
|
||||
this.busyChangedCallback?.(false);
|
||||
});
|
||||
@@ -312,7 +309,7 @@ export class InteractiveAuth {
|
||||
* @return {string} session id
|
||||
*/
|
||||
public getSessionId(): string {
|
||||
return this.data ? this.data.session : undefined;
|
||||
return this.data?.session;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -477,6 +474,9 @@ export class InteractiveAuth {
|
||||
logger.log("Background poll request failed doing UI auth: ignoring", error);
|
||||
}
|
||||
}
|
||||
if (!error.data) {
|
||||
error.data = {};
|
||||
}
|
||||
// if the error didn't come with flows, completed flows or session ID,
|
||||
// copy over the ones we have. Synapse sometimes sends responses without
|
||||
// any UI auth data (eg. when polling for email validation, if the email
|
||||
@@ -539,19 +539,17 @@ export class InteractiveAuth {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.data && this.data.errcode || this.data.error) {
|
||||
if (this.data?.errcode || this.data?.error) {
|
||||
this.stateUpdatedCallback(nextStage, {
|
||||
errcode: this.data.errcode || "",
|
||||
error: this.data.error || "",
|
||||
errcode: this.data?.errcode || "",
|
||||
error: this.data?.error || "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const stageStatus: IStageStatus = {};
|
||||
if (nextStage == EMAIL_STAGE_TYPE) {
|
||||
stageStatus.emailSid = this.emailSid;
|
||||
}
|
||||
this.stateUpdatedCallback(nextStage, stageStatus);
|
||||
this.stateUpdatedCallback(nextStage, nextStage === EMAIL_STAGE_TYPE
|
||||
? { emailSid: this.emailSid }
|
||||
: {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -197,7 +197,7 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
|
||||
const startTimestamp = this._beaconInfo?.timestamp > Date.now() ?
|
||||
this._beaconInfo?.timestamp - 360000 /* 6min */ :
|
||||
this._beaconInfo?.timestamp;
|
||||
this._isLive = this._beaconInfo?.live &&
|
||||
this._isLive = !!this._beaconInfo?.live &&
|
||||
isTimestampInDuration(startTimestamp, this._beaconInfo?.timeout, Date.now());
|
||||
|
||||
if (prevLiveness !== this.isLive) {
|
||||
|
||||
+202
-44
@@ -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.
|
||||
@@ -37,7 +37,8 @@ import {
|
||||
import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client";
|
||||
import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
|
||||
import { Filter, IFilterDefinition } from "../filter";
|
||||
import { RoomState } from "./room-state";
|
||||
import { RoomState, RoomStateEvent, RoomStateEventHandlerMap } from "./room-state";
|
||||
import { BeaconEvent, BeaconEventHandlerMap } from "./beacon";
|
||||
import {
|
||||
Thread,
|
||||
ThreadEvent,
|
||||
@@ -72,7 +73,7 @@ function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: Rece
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "m.receipt",
|
||||
type: EventType.Receipt,
|
||||
room_id: event.getRoomId(),
|
||||
});
|
||||
}
|
||||
@@ -172,16 +173,19 @@ export enum RoomEvent {
|
||||
}
|
||||
|
||||
type EmittedEvents = RoomEvent
|
||||
| RoomStateEvent.Events
|
||||
| RoomStateEvent.Members
|
||||
| RoomStateEvent.NewMember
|
||||
| RoomStateEvent.Update
|
||||
| RoomStateEvent.Marker
|
||||
| ThreadEvent.New
|
||||
| ThreadEvent.Update
|
||||
| ThreadEvent.NewReply
|
||||
| RoomEvent.Timeline
|
||||
| RoomEvent.TimelineReset
|
||||
| RoomEvent.TimelineRefresh
|
||||
| RoomEvent.HistoryImportedWithinTimeline
|
||||
| RoomEvent.OldStateUpdated
|
||||
| RoomEvent.CurrentStateUpdated
|
||||
| MatrixEventEvent.BeforeRedaction;
|
||||
| MatrixEventEvent.BeforeRedaction
|
||||
| BeaconEvent.New
|
||||
| BeaconEvent.Update
|
||||
| BeaconEvent.Destroy
|
||||
| BeaconEvent.LivenessChange;
|
||||
|
||||
export type RoomEventHandlerMap = {
|
||||
[RoomEvent.MyMembership]: (room: Room, membership: string, prevMembership?: string) => void;
|
||||
@@ -205,7 +209,21 @@ export type RoomEventHandlerMap = {
|
||||
) => void;
|
||||
[RoomEvent.TimelineRefresh]: (room: Room, eventTimelineSet: EventTimelineSet) => void;
|
||||
[ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void;
|
||||
} & ThreadHandlerMap & MatrixEventHandlerMap;
|
||||
} & ThreadHandlerMap
|
||||
& MatrixEventHandlerMap
|
||||
& Pick<
|
||||
RoomStateEventHandlerMap,
|
||||
RoomStateEvent.Events
|
||||
| RoomStateEvent.Members
|
||||
| RoomStateEvent.NewMember
|
||||
| RoomStateEvent.Update
|
||||
| RoomStateEvent.Marker
|
||||
| BeaconEvent.New
|
||||
>
|
||||
& Pick<
|
||||
BeaconEventHandlerMap,
|
||||
BeaconEvent.Update | BeaconEvent.Destroy | BeaconEvent.LivenessChange
|
||||
>;
|
||||
|
||||
export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap> {
|
||||
public readonly reEmitter: TypedReEmitter<EmittedEvents, RoomEventHandlerMap>;
|
||||
@@ -1068,6 +1086,32 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
|
||||
if (previousCurrentState !== this.currentState) {
|
||||
this.emit(RoomEvent.CurrentStateUpdated, this, previousCurrentState, this.currentState);
|
||||
|
||||
// Re-emit various events on the current room state
|
||||
// TODO: If currentState really only exists for backwards
|
||||
// compatibility, shouldn't we be doing this some other way?
|
||||
this.reEmitter.stopReEmitting(previousCurrentState, [
|
||||
RoomStateEvent.Events,
|
||||
RoomStateEvent.Members,
|
||||
RoomStateEvent.NewMember,
|
||||
RoomStateEvent.Update,
|
||||
RoomStateEvent.Marker,
|
||||
BeaconEvent.New,
|
||||
BeaconEvent.Update,
|
||||
BeaconEvent.Destroy,
|
||||
BeaconEvent.LivenessChange,
|
||||
]);
|
||||
this.reEmitter.reEmit(this.currentState, [
|
||||
RoomStateEvent.Events,
|
||||
RoomStateEvent.Members,
|
||||
RoomStateEvent.NewMember,
|
||||
RoomStateEvent.Update,
|
||||
RoomStateEvent.Marker,
|
||||
BeaconEvent.New,
|
||||
BeaconEvent.Update,
|
||||
BeaconEvent.Destroy,
|
||||
BeaconEvent.LivenessChange,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2423,9 +2467,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 +2558,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 +2592,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) {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2875,6 +2958,33 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
return this.getType() === RoomType.ElementVideo;
|
||||
}
|
||||
|
||||
private roomNameGenerator(state: RoomNameState): string {
|
||||
if (this.client.roomNameGenerator) {
|
||||
const name = this.client.roomNameGenerator(this.roomId, state);
|
||||
if (name !== null) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
switch (state.type) {
|
||||
case RoomNameType.Actual:
|
||||
return state.name;
|
||||
case RoomNameType.Generated:
|
||||
switch (state.subtype) {
|
||||
case "Inviting":
|
||||
return `Inviting ${memberNamesToRoomName(state.names, state.count)}`;
|
||||
default:
|
||||
return memberNamesToRoomName(state.names, state.count);
|
||||
}
|
||||
case RoomNameType.EmptyRoom:
|
||||
if (state.oldName) {
|
||||
return `Empty room (was ${state.oldName})`;
|
||||
} else {
|
||||
return "Empty room";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is an internal method. Calculates the name of the room from the current
|
||||
* room state.
|
||||
@@ -2889,14 +2999,20 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
// check for an alias, if any. for now, assume first alias is the
|
||||
// official one.
|
||||
const mRoomName = this.currentState.getStateEvents(EventType.RoomName, "");
|
||||
if (mRoomName && mRoomName.getContent() && mRoomName.getContent().name) {
|
||||
return mRoomName.getContent().name;
|
||||
if (mRoomName?.getContent().name) {
|
||||
return this.roomNameGenerator({
|
||||
type: RoomNameType.Actual,
|
||||
name: mRoomName.getContent().name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const alias = this.getCanonicalAlias();
|
||||
if (alias) {
|
||||
return alias;
|
||||
return this.roomNameGenerator({
|
||||
type: RoomNameType.Actual,
|
||||
name: alias,
|
||||
});
|
||||
}
|
||||
|
||||
const joinedMemberCount = this.currentState.getJoinedMemberCount();
|
||||
@@ -2928,8 +3044,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
});
|
||||
} else {
|
||||
let otherMembers = this.currentState.getMembers().filter((m) => {
|
||||
return m.userId !== userId &&
|
||||
(m.membership === "invite" || m.membership === "join");
|
||||
return m.userId !== userId && (m.membership === "invite" || m.membership === "join");
|
||||
});
|
||||
otherMembers = otherMembers.filter(({ userId }) => {
|
||||
// filter service members
|
||||
@@ -2947,24 +3062,33 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
}
|
||||
|
||||
if (inviteJoinCount) {
|
||||
return memberNamesToRoomName(otherNames, inviteJoinCount);
|
||||
return this.roomNameGenerator({
|
||||
type: RoomNameType.Generated,
|
||||
names: otherNames,
|
||||
count: inviteJoinCount,
|
||||
});
|
||||
}
|
||||
|
||||
const myMembership = this.getMyMembership();
|
||||
// if I have created a room and invited people through
|
||||
// 3rd party invites
|
||||
if (myMembership == 'join') {
|
||||
const thirdPartyInvites =
|
||||
this.currentState.getStateEvents(EventType.RoomThirdPartyInvite);
|
||||
const thirdPartyInvites = this.currentState.getStateEvents(EventType.RoomThirdPartyInvite);
|
||||
|
||||
if (thirdPartyInvites && thirdPartyInvites.length) {
|
||||
if (thirdPartyInvites?.length) {
|
||||
const thirdPartyNames = thirdPartyInvites.map((i) => {
|
||||
return i.getContent().display_name;
|
||||
});
|
||||
|
||||
return `Inviting ${memberNamesToRoomName(thirdPartyNames)}`;
|
||||
return this.roomNameGenerator({
|
||||
type: RoomNameType.Generated,
|
||||
subtype: "Inviting",
|
||||
names: thirdPartyNames,
|
||||
count: thirdPartyNames.length + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// let's try to figure out who was here before
|
||||
let leftNames = otherNames;
|
||||
// if we didn't have heroes, try finding them in the room state
|
||||
@@ -2975,11 +3099,20 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
m.membership !== "join";
|
||||
}).map((m) => m.name);
|
||||
}
|
||||
|
||||
let oldName: string;
|
||||
if (leftNames.length) {
|
||||
return `Empty room (was ${memberNamesToRoomName(leftNames)})`;
|
||||
} else {
|
||||
return "Empty room";
|
||||
oldName = this.roomNameGenerator({
|
||||
type: RoomNameType.Generated,
|
||||
names: leftNames,
|
||||
count: leftNames.length + 1,
|
||||
});
|
||||
}
|
||||
|
||||
return this.roomNameGenerator({
|
||||
type: RoomNameType.EmptyRoom,
|
||||
oldName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3164,8 +3297,33 @@ const ALLOWED_TRANSITIONS: Record<EventStatus, EventStatus[]> = {
|
||||
[EventStatus.CANCELLED]: [],
|
||||
};
|
||||
|
||||
// TODO i18n
|
||||
function memberNamesToRoomName(names: string[], count = (names.length + 1)) {
|
||||
export enum RoomNameType {
|
||||
EmptyRoom,
|
||||
Generated,
|
||||
Actual,
|
||||
}
|
||||
|
||||
export interface EmptyRoomNameState {
|
||||
type: RoomNameType.EmptyRoom;
|
||||
oldName?: string;
|
||||
}
|
||||
|
||||
export interface GeneratedRoomNameState {
|
||||
type: RoomNameType.Generated;
|
||||
subtype?: "Inviting";
|
||||
names: string[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ActualRoomNameState {
|
||||
type: RoomNameType.Actual;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type RoomNameState = EmptyRoomNameState | GeneratedRoomNameState | ActualRoomNameState;
|
||||
|
||||
// Can be overriden by IMatrixClientCreateOpts::memberNamesToRoomNameFn
|
||||
function memberNamesToRoomName(names: string[], count: number): string {
|
||||
const countWithoutMe = count - 1;
|
||||
if (!names.length) {
|
||||
return "Empty room";
|
||||
|
||||
@@ -362,13 +362,9 @@ export class PushProcessor {
|
||||
return false;
|
||||
}
|
||||
|
||||
let regex;
|
||||
|
||||
if (cond.key == 'content.body') {
|
||||
regex = this.createCachedRegex('(^|\\W)', cond.pattern, '(\\W|$)');
|
||||
} else {
|
||||
regex = this.createCachedRegex('^', cond.pattern, '$');
|
||||
}
|
||||
const regex = cond.key === 'content.body'
|
||||
? this.createCachedRegex('(^|\\W)', cond.pattern, '(\\W|$)')
|
||||
: this.createCachedRegex('^', cond.pattern, '$');
|
||||
|
||||
return !!val.match(regex);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+45
-59
@@ -19,13 +19,11 @@ import { logger } from './logger';
|
||||
import * as utils from "./utils";
|
||||
import { EventTimeline } from "./models/event-timeline";
|
||||
import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering } from "./client";
|
||||
import { ISyncStateData, SyncState } from "./sync";
|
||||
import { ISyncStateData, SyncState, _createAndReEmitRoom } from "./sync";
|
||||
import { MatrixEvent } from "./models/event";
|
||||
import { Crypto } from "./crypto";
|
||||
import { IMinimalEvent, IRoomEvent, IStateEvent, IStrippedState } from "./sync-accumulator";
|
||||
import { MatrixError } from "./http-api";
|
||||
import { RoomStateEvent } from "./models/room-state";
|
||||
import { RoomMemberEvent } from "./models/room-member";
|
||||
import {
|
||||
Extension,
|
||||
ExtensionState,
|
||||
@@ -290,7 +288,7 @@ export class SlidingSyncSdk {
|
||||
logger.debug("initial flag not set but no stored room exists for room ", roomId, roomData);
|
||||
return;
|
||||
}
|
||||
room = createRoom(this.client, roomId, this.opts);
|
||||
room = _createAndReEmitRoom(this.client, roomId, this.opts);
|
||||
}
|
||||
this.processRoomData(this.client, room, roomData);
|
||||
}
|
||||
@@ -406,9 +404,51 @@ export class SlidingSyncSdk {
|
||||
// this helps large account to speed up faster
|
||||
// room::decryptCriticalEvent is in charge of decrypting all the events
|
||||
// required for a client to function properly
|
||||
const timelineEvents = mapEvents(this.client, room.roomId, roomData.timeline, false);
|
||||
let timelineEvents = mapEvents(this.client, room.roomId, roomData.timeline, false);
|
||||
const ephemeralEvents = []; // TODO this.mapSyncEventsFormat(joinObj.ephemeral);
|
||||
|
||||
// TODO: handle threaded / beacon events
|
||||
|
||||
if (roomData.initial) {
|
||||
// we should not know about any of these timeline entries if this is a genuinely new room.
|
||||
// If we do, then we've effectively done scrollback (e.g requesting timeline_limit: 1 for
|
||||
// this room, then timeline_limit: 50).
|
||||
const knownEvents = new Set<string>();
|
||||
room.getLiveTimeline().getEvents().forEach((e) => {
|
||||
knownEvents.add(e.getId());
|
||||
});
|
||||
// all unknown events BEFORE a known event must be scrollback e.g:
|
||||
// D E <-- what we know
|
||||
// A B C D E F <-- what we just received
|
||||
// means:
|
||||
// A B C <-- scrollback
|
||||
// D E <-- dupes
|
||||
// F <-- new event
|
||||
// We bucket events based on if we have seen a known event yet.
|
||||
const oldEvents: MatrixEvent[] = [];
|
||||
const newEvents: MatrixEvent[] = [];
|
||||
let seenKnownEvent = false;
|
||||
for (let i = timelineEvents.length-1; i >= 0; i--) {
|
||||
const recvEvent = timelineEvents[i];
|
||||
if (knownEvents.has(recvEvent.getId())) {
|
||||
seenKnownEvent = true;
|
||||
continue; // don't include this event, it's a dupe
|
||||
}
|
||||
if (seenKnownEvent) {
|
||||
// old -> new
|
||||
oldEvents.push(recvEvent);
|
||||
} else {
|
||||
// old -> new
|
||||
newEvents.unshift(recvEvent);
|
||||
}
|
||||
}
|
||||
timelineEvents = newEvents;
|
||||
if (oldEvents.length > 0) {
|
||||
// old events are scrollback, insert them now
|
||||
room.addEventsToTimeline(oldEvents, true, room.getLiveTimeline(), roomData.prev_batch);
|
||||
}
|
||||
}
|
||||
|
||||
const encrypted = this.client.isRoomEncrypted(room.roomId);
|
||||
// we do this first so it's correct when any of the events fire
|
||||
if (roomData.notification_count != null) {
|
||||
@@ -494,7 +534,6 @@ export class SlidingSyncSdk {
|
||||
}
|
||||
|
||||
if (limited) {
|
||||
deregisterStateListeners(room);
|
||||
room.resetLiveTimeline(
|
||||
roomData.prev_batch,
|
||||
null, // TODO this.opts.canResetEntireTimeline(room.roomId) ? null : syncEventData.oldSyncToken,
|
||||
@@ -504,7 +543,6 @@ export class SlidingSyncSdk {
|
||||
// reason to stop incrementally tracking notifications and
|
||||
// reset the timeline.
|
||||
this.client.resetNotifTimelineSet();
|
||||
registerStateListeners(this.client, room);
|
||||
}
|
||||
} */
|
||||
|
||||
@@ -774,58 +812,6 @@ function ensureNameEvent(client: MatrixClient, roomId: string, roomData: MSC3575
|
||||
// Helper functions which set up JS SDK structs are below and are identical to the sync v2 counterparts,
|
||||
// just outside the class.
|
||||
|
||||
function createRoom(client: MatrixClient, roomId: string, opts: Partial<IStoredClientOpts>): Room { // XXX cargoculted from sync.ts
|
||||
const { timelineSupport } = client;
|
||||
const room = new Room(roomId, client, client.getUserId(), {
|
||||
lazyLoadMembers: opts.lazyLoadMembers,
|
||||
pendingEventOrdering: opts.pendingEventOrdering,
|
||||
timelineSupport,
|
||||
});
|
||||
client.reEmitter.reEmit(room, [
|
||||
RoomEvent.Name,
|
||||
RoomEvent.Redaction,
|
||||
RoomEvent.RedactionCancelled,
|
||||
RoomEvent.Receipt,
|
||||
RoomEvent.Tags,
|
||||
RoomEvent.LocalEchoUpdated,
|
||||
RoomEvent.AccountData,
|
||||
RoomEvent.MyMembership,
|
||||
RoomEvent.Timeline,
|
||||
RoomEvent.TimelineReset,
|
||||
]);
|
||||
registerStateListeners(client, room);
|
||||
return room;
|
||||
}
|
||||
|
||||
function registerStateListeners(client: MatrixClient, room: Room): void { // XXX cargoculted from sync.ts
|
||||
// we need to also re-emit room state and room member events, so hook it up
|
||||
// to the client now. We need to add a listener for RoomState.members in
|
||||
// order to hook them correctly.
|
||||
client.reEmitter.reEmit(room.currentState, [
|
||||
RoomStateEvent.Events,
|
||||
RoomStateEvent.Members,
|
||||
RoomStateEvent.NewMember,
|
||||
RoomStateEvent.Update,
|
||||
]);
|
||||
room.currentState.on(RoomStateEvent.NewMember, function(event, state, member) {
|
||||
member.user = client.getUser(member.userId);
|
||||
client.reEmitter.reEmit(member, [
|
||||
RoomMemberEvent.Name,
|
||||
RoomMemberEvent.Typing,
|
||||
RoomMemberEvent.PowerLevel,
|
||||
RoomMemberEvent.Membership,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
function deregisterStateListeners(room: Room): void { // XXX cargoculted from sync.ts
|
||||
// could do with a better way of achieving this.
|
||||
room.currentState.removeAllListeners(RoomStateEvent.Events);
|
||||
room.currentState.removeAllListeners(RoomStateEvent.Members);
|
||||
room.currentState.removeAllListeners(RoomStateEvent.NewMember);
|
||||
} */
|
||||
|
||||
function mapEvents(client: MatrixClient, roomId: string, events: object[], decrypt = true): MatrixEvent[] {
|
||||
const mapper = client.getEventMapper({ decrypt });
|
||||
return (events as Array<IStrippedState | IRoomEvent | IStateEvent | IMinimalEvent>).map(function(e) {
|
||||
|
||||
+157
-42
@@ -19,7 +19,7 @@ import { IAbortablePromise } from "./@types/partials";
|
||||
import { MatrixClient } from "./client";
|
||||
import { IRoomEvent, IStateEvent } from "./sync-accumulator";
|
||||
import { TypedEventEmitter } from "./models//typed-event-emitter";
|
||||
import { sleep } from "./utils";
|
||||
import { sleep, IDeferred, defer } from "./utils";
|
||||
|
||||
// /sync requests allow you to set a timeout= but the request may continue
|
||||
// beyond that and wedge forever, so we need to track how long we are willing
|
||||
@@ -44,6 +44,11 @@ export interface MSC3575Filter {
|
||||
is_invite?: boolean;
|
||||
is_tombstoned?: boolean;
|
||||
room_name_like?: string;
|
||||
room_types?: string[];
|
||||
not_room_types?: string[];
|
||||
spaces?: string[];
|
||||
tags?: string[];
|
||||
not_tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,6 +70,7 @@ export interface MSC3575SlidingSyncRequest {
|
||||
unsubscribe_rooms?: string[];
|
||||
room_subscriptions?: Record<string, MSC3575RoomSubscription>;
|
||||
extensions?: object;
|
||||
txn_id?: string;
|
||||
|
||||
// query params
|
||||
pos?: string;
|
||||
@@ -123,6 +129,7 @@ type Operation = DeleteOperation | InsertOperation | InvalidateOperation | SyncO
|
||||
*/
|
||||
export interface MSC3575SlidingSyncResponse {
|
||||
pos: string;
|
||||
txn_id?: string;
|
||||
lists: ListResponse[];
|
||||
rooms: Record<string, MSC3575RoomData>;
|
||||
extensions: object;
|
||||
@@ -331,6 +338,11 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
private terminated = false;
|
||||
// flag set when resend() is called because we cannot rely on detecting AbortError in JS SDK :(
|
||||
private needsResend = false;
|
||||
// the txn_id to send with the next request.
|
||||
private txnId?: string = null;
|
||||
// a list (in chronological order of when they were sent) of objects containing the txn ID and
|
||||
// a defer to resolve/reject depending on whether they were successfully sent or not.
|
||||
private txnIdDefers: (IDeferred<string> & { txnId: string})[] = [];
|
||||
// map of extension name to req/resp handler
|
||||
private extensions: Record<string, Extension> = {};
|
||||
|
||||
@@ -400,10 +412,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
* whereas setList always will.
|
||||
* @param index The list index to modify
|
||||
* @param ranges The new ranges to apply.
|
||||
* @return A promise which resolves to the transaction ID when it has been received down sync
|
||||
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
|
||||
* immediately after sending, in which case the action will be applied in the subsequent request)
|
||||
*/
|
||||
public setListRanges(index: number, ranges: number[][]): void {
|
||||
public setListRanges(index: number, ranges: number[][]): Promise<string> {
|
||||
this.lists[index].updateListRange(ranges);
|
||||
this.resend();
|
||||
return this.resend();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -411,15 +426,18 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
* lists.
|
||||
* @param index The index to modify
|
||||
* @param list The new list parameters.
|
||||
* @return A promise which resolves to the transaction ID when it has been received down sync
|
||||
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
|
||||
* immediately after sending, in which case the action will be applied in the subsequent request)
|
||||
*/
|
||||
public setList(index: number, list: MSC3575List): void {
|
||||
public setList(index: number, list: MSC3575List): Promise<string> {
|
||||
if (this.lists[index]) {
|
||||
this.lists[index].replaceList(list);
|
||||
} else {
|
||||
this.lists[index] = new SlidingList(list);
|
||||
}
|
||||
this.listModifiedCount += 1;
|
||||
this.resend();
|
||||
return this.resend();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -435,21 +453,27 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
* /sync request to resend new subscriptions. If the /sync stream has not started, this will
|
||||
* prepare the room subscriptions for when start() is called.
|
||||
* @param s The new desired room subscriptions.
|
||||
* @return A promise which resolves to the transaction ID when it has been received down sync
|
||||
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
|
||||
* immediately after sending, in which case the action will be applied in the subsequent request)
|
||||
*/
|
||||
public modifyRoomSubscriptions(s: Set<string>) {
|
||||
public modifyRoomSubscriptions(s: Set<string>): Promise<string> {
|
||||
this.desiredRoomSubscriptions = s;
|
||||
this.resend();
|
||||
return this.resend();
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify which events to retrieve for room subscriptions. Invalidates all room subscriptions
|
||||
* such that they will be sent up afresh.
|
||||
* @param rs The new room subscription fields to fetch.
|
||||
* @return A promise which resolves to the transaction ID when it has been received down sync
|
||||
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
|
||||
* immediately after sending, in which case the action will be applied in the subsequent request)
|
||||
*/
|
||||
public modifyRoomSubscriptionInfo(rs: MSC3575RoomSubscription): void {
|
||||
public modifyRoomSubscriptionInfo(rs: MSC3575RoomSubscription): Promise<string> {
|
||||
this.roomSubscriptionInfo = rs;
|
||||
this.confirmedRoomSubscriptions = new Set<string>();
|
||||
this.resend();
|
||||
return this.resend();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -508,6 +532,65 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
this.emit(SlidingSyncEvent.Lifecycle, state, resp, err);
|
||||
}
|
||||
|
||||
private shiftRight(listIndex: number, hi: number, low: number) {
|
||||
// l h
|
||||
// 0,1,2,3,4 <- before
|
||||
// 0,1,2,2,3 <- after, hi is deleted and low is duplicated
|
||||
for (let i = hi; i > low; i--) {
|
||||
if (this.lists[listIndex].isIndexInRange(i)) {
|
||||
this.lists[listIndex].roomIndexToRoomId[i] =
|
||||
this.lists[listIndex].roomIndexToRoomId[
|
||||
i - 1
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shiftLeft(listIndex: number, hi: number, low: number) {
|
||||
// l h
|
||||
// 0,1,2,3,4 <- before
|
||||
// 0,1,3,4,4 <- after, low is deleted and hi is duplicated
|
||||
for (let i = low; i < hi; i++) {
|
||||
if (this.lists[listIndex].isIndexInRange(i)) {
|
||||
this.lists[listIndex].roomIndexToRoomId[i] =
|
||||
this.lists[listIndex].roomIndexToRoomId[
|
||||
i + 1
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeEntry(listIndex: number, index: number) {
|
||||
// work out the max index
|
||||
let max = -1;
|
||||
for (const n in this.lists[listIndex].roomIndexToRoomId) {
|
||||
if (Number(n) > max) {
|
||||
max = Number(n);
|
||||
}
|
||||
}
|
||||
if (max < 0 || index > max) {
|
||||
return;
|
||||
}
|
||||
// Everything higher than the gap needs to be shifted left.
|
||||
this.shiftLeft(listIndex, max, index);
|
||||
delete this.lists[listIndex].roomIndexToRoomId[max];
|
||||
}
|
||||
|
||||
private addEntry(listIndex: number, index: number) {
|
||||
// work out the max index
|
||||
let max = -1;
|
||||
for (const n in this.lists[listIndex].roomIndexToRoomId) {
|
||||
if (Number(n) > max) {
|
||||
max = Number(n);
|
||||
}
|
||||
}
|
||||
if (max < 0 || index > max) {
|
||||
return;
|
||||
}
|
||||
// Everything higher than the gap needs to be shifted right, +1 so we don't delete the highest element
|
||||
this.shiftRight(listIndex, max+1, index);
|
||||
}
|
||||
|
||||
private processListOps(list: ListResponse, listIndex: number): void {
|
||||
let gapIndex = -1;
|
||||
list.ops.forEach((op: Operation) => {
|
||||
@@ -515,6 +598,10 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
case "DELETE": {
|
||||
logger.debug("DELETE", listIndex, op.index, ";");
|
||||
delete this.lists[listIndex].roomIndexToRoomId[op.index];
|
||||
if (gapIndex !== -1) {
|
||||
// we already have a DELETE operation to process, so process it.
|
||||
this.removeEntry(listIndex, gapIndex);
|
||||
}
|
||||
gapIndex = op.index;
|
||||
break;
|
||||
}
|
||||
@@ -529,20 +616,9 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
if (this.lists[listIndex].roomIndexToRoomId[op.index]) {
|
||||
// something is in this space, shift items out of the way
|
||||
if (gapIndex < 0) {
|
||||
logger.debug(
|
||||
"cannot work out where gap is, INSERT without previous DELETE! List: ",
|
||||
listIndex,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 0,1,2,3 index
|
||||
// [A,B,C,D]
|
||||
// DEL 3
|
||||
// [A,B,C,_]
|
||||
// INSERT E 0
|
||||
// [E,A,B,C]
|
||||
// gapIndex=3, op.index=0
|
||||
if (gapIndex > op.index) {
|
||||
// we haven't been told where to shift from, so make way for a new room entry.
|
||||
this.addEntry(listIndex, op.index);
|
||||
} else if (gapIndex > op.index) {
|
||||
// the gap is further down the list, shift every element to the right
|
||||
// starting at the gap so we can just shift each element in turn:
|
||||
// [A,B,C,_] gapIndex=3, op.index=0
|
||||
@@ -550,26 +626,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
// [A,B,B,C] i=2
|
||||
// [A,A,B,C] i=1
|
||||
// Terminate. We'll assign into op.index next.
|
||||
for (let i = gapIndex; i > op.index; i--) {
|
||||
if (this.lists[listIndex].isIndexInRange(i)) {
|
||||
this.lists[listIndex].roomIndexToRoomId[i] =
|
||||
this.lists[listIndex].roomIndexToRoomId[
|
||||
i - 1
|
||||
];
|
||||
}
|
||||
}
|
||||
this.shiftRight(listIndex, gapIndex, op.index);
|
||||
} else if (gapIndex < op.index) {
|
||||
// the gap is further up the list, shift every element to the left
|
||||
// starting at the gap so we can just shift each element in turn
|
||||
for (let i = gapIndex; i < op.index; i++) {
|
||||
if (this.lists[listIndex].isIndexInRange(i)) {
|
||||
this.lists[listIndex].roomIndexToRoomId[i] =
|
||||
this.lists[listIndex].roomIndexToRoomId[
|
||||
i + 1
|
||||
];
|
||||
}
|
||||
}
|
||||
this.shiftLeft(listIndex, op.index, gapIndex);
|
||||
}
|
||||
gapIndex = -1; // forget the gap, we don't need it anymore.
|
||||
}
|
||||
this.lists[listIndex].roomIndexToRoomId[op.index] = op.room_id;
|
||||
break;
|
||||
@@ -602,21 +665,67 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
listIndex,
|
||||
op.range[0],
|
||||
op.range[1],
|
||||
op.room_ids.join(" "),
|
||||
(op.room_ids || []).join(" "),
|
||||
";",
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (gapIndex !== -1) {
|
||||
// we already have a DELETE operation to process, so process it
|
||||
// Everything higher than the gap needs to be shifted left.
|
||||
this.removeEntry(listIndex, gapIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend a Sliding Sync request. Used when something has changed in the request.
|
||||
* Resend a Sliding Sync request. Used when something has changed in the request. Resolves with
|
||||
* the transaction ID of this request on success. Rejects with the transaction ID of this request
|
||||
* on failure.
|
||||
*/
|
||||
public resend(): void {
|
||||
public resend(): Promise<string> {
|
||||
if (this.needsResend && this.txnIdDefers.length > 0) {
|
||||
// we already have a resend queued, so just return the same promise
|
||||
return this.txnIdDefers[this.txnIdDefers.length-1].promise;
|
||||
}
|
||||
this.needsResend = true;
|
||||
this.txnId = this.client.makeTxnId();
|
||||
const d = defer<string>();
|
||||
this.txnIdDefers.push({
|
||||
...d,
|
||||
txnId: this.txnId,
|
||||
});
|
||||
this.pendingReq?.abort();
|
||||
return d.promise;
|
||||
}
|
||||
|
||||
private resolveTransactionDefers(txnId?: string) {
|
||||
if (!txnId) {
|
||||
return;
|
||||
}
|
||||
// find the matching index
|
||||
let txnIndex = -1;
|
||||
for (let i = 0; i < this.txnIdDefers.length; i++) {
|
||||
if (this.txnIdDefers[i].txnId === txnId) {
|
||||
txnIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (txnIndex === -1) {
|
||||
// this shouldn't happen; we shouldn't be seeing txn_ids for things we don't know about,
|
||||
// whine about it.
|
||||
logger.warn(`resolveTransactionDefers: seen ${txnId} but it isn't a pending txn, ignoring.`);
|
||||
return;
|
||||
}
|
||||
// This list is sorted in time, so if the input txnId ACKs in the middle of this array,
|
||||
// then everything before it that hasn't been ACKed yet never will and we should reject them.
|
||||
for (let i = 0; i < txnIndex; i++) {
|
||||
this.txnIdDefers[i].reject(this.txnIdDefers[i].txnId);
|
||||
}
|
||||
this.txnIdDefers[txnIndex].resolve(txnId);
|
||||
// clear out settled promises, incuding the one we resolved.
|
||||
this.txnIdDefers = this.txnIdDefers.slice(txnIndex+1);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -663,6 +772,10 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
reqBody.room_subscriptions[roomId] = this.roomSubscriptionInfo;
|
||||
}
|
||||
}
|
||||
if (this.txnId) {
|
||||
reqBody.txn_id = this.txnId;
|
||||
this.txnId = null;
|
||||
}
|
||||
this.pendingReq = this.client.slidingSync(reqBody, this.proxyBaseUrl);
|
||||
resp = await this.pendingReq;
|
||||
logger.debug(resp);
|
||||
@@ -744,6 +857,8 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
i, this.lists[i].joinedCount, Object.assign({}, this.lists[i].roomIndexToRoomId),
|
||||
);
|
||||
});
|
||||
|
||||
this.resolveTransactionDefers(resp.txn_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-22
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+51
-77
@@ -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.
|
||||
@@ -51,7 +51,7 @@ import { MatrixError, Method } from "./http-api";
|
||||
import { ISavedSync } from "./store";
|
||||
import { EventType } from "./@types/event";
|
||||
import { IPushRules } from "./@types/PushRules";
|
||||
import { RoomState, RoomStateEvent, IMarkerFoundOptions } from "./models/room-state";
|
||||
import { RoomStateEvent, IMarkerFoundOptions } from "./models/room-state";
|
||||
import { RoomMemberEvent } from "./models/room-member";
|
||||
import { BeaconEvent } from "./models/beacon";
|
||||
import { IEventsResponse } from "./@types/requests";
|
||||
@@ -199,85 +199,13 @@ export class SyncApi {
|
||||
* @return {Room}
|
||||
*/
|
||||
public createRoom(roomId: string): Room {
|
||||
const client = this.client;
|
||||
const {
|
||||
timelineSupport,
|
||||
} = client;
|
||||
const room = new Room(roomId, client, client.getUserId(), {
|
||||
lazyLoadMembers: this.opts.lazyLoadMembers,
|
||||
pendingEventOrdering: this.opts.pendingEventOrdering,
|
||||
timelineSupport,
|
||||
});
|
||||
client.reEmitter.reEmit(room, [
|
||||
RoomEvent.Name,
|
||||
RoomEvent.Redaction,
|
||||
RoomEvent.RedactionCancelled,
|
||||
RoomEvent.Receipt,
|
||||
RoomEvent.Tags,
|
||||
RoomEvent.LocalEchoUpdated,
|
||||
RoomEvent.AccountData,
|
||||
RoomEvent.MyMembership,
|
||||
RoomEvent.Timeline,
|
||||
RoomEvent.TimelineReset,
|
||||
]);
|
||||
this.registerStateListeners(room);
|
||||
// Register listeners again after the state reference changes
|
||||
room.on(RoomEvent.CurrentStateUpdated, (targetRoom, previousCurrentState) => {
|
||||
if (targetRoom !== room) {
|
||||
return;
|
||||
}
|
||||
const room = _createAndReEmitRoom(this.client, roomId, this.opts);
|
||||
|
||||
this.deregisterStateListeners(previousCurrentState);
|
||||
this.registerStateListeners(room);
|
||||
});
|
||||
return room;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Room} room
|
||||
* @private
|
||||
*/
|
||||
private registerStateListeners(room: Room): void {
|
||||
const client = this.client;
|
||||
// we need to also re-emit room state and room member events, so hook it up
|
||||
// to the client now. We need to add a listener for RoomState.members in
|
||||
// order to hook them correctly. (TODO: find a better way?)
|
||||
client.reEmitter.reEmit(room.currentState, [
|
||||
RoomStateEvent.Events,
|
||||
RoomStateEvent.Members,
|
||||
RoomStateEvent.NewMember,
|
||||
RoomStateEvent.Update,
|
||||
BeaconEvent.New,
|
||||
BeaconEvent.Update,
|
||||
BeaconEvent.Destroy,
|
||||
BeaconEvent.LivenessChange,
|
||||
]);
|
||||
|
||||
room.currentState.on(RoomStateEvent.NewMember, function(event, state, member) {
|
||||
member.user = client.getUser(member.userId);
|
||||
client.reEmitter.reEmit(member, [
|
||||
RoomMemberEvent.Name,
|
||||
RoomMemberEvent.Typing,
|
||||
RoomMemberEvent.PowerLevel,
|
||||
RoomMemberEvent.Membership,
|
||||
]);
|
||||
});
|
||||
|
||||
room.currentState.on(RoomStateEvent.Marker, (markerEvent, markerFoundOptions) => {
|
||||
room.on(RoomStateEvent.Marker, (markerEvent, markerFoundOptions) => {
|
||||
this.onMarkerStateEvent(room, markerEvent, markerFoundOptions);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RoomState} roomState The roomState to clear listeners from
|
||||
* @private
|
||||
*/
|
||||
private deregisterStateListeners(roomState: RoomState): void {
|
||||
// could do with a better way of achieving this.
|
||||
roomState.removeAllListeners(RoomStateEvent.Events);
|
||||
roomState.removeAllListeners(RoomStateEvent.Members);
|
||||
roomState.removeAllListeners(RoomStateEvent.NewMember);
|
||||
roomState.removeAllListeners(RoomStateEvent.Marker);
|
||||
return room;
|
||||
}
|
||||
|
||||
/** When we see the marker state change in the room, we know there is some
|
||||
@@ -1792,3 +1720,49 @@ function createNewUser(client: MatrixClient, userId: string): User {
|
||||
return user;
|
||||
}
|
||||
|
||||
// /!\ This function is not intended for public use! It's only exported from
|
||||
// here in order to share some common logic with sliding-sync-sdk.ts.
|
||||
export function _createAndReEmitRoom(client: MatrixClient, roomId: string, opts: Partial<IStoredClientOpts>): Room {
|
||||
const { timelineSupport } = client;
|
||||
|
||||
const room = new Room(roomId, client, client.getUserId(), {
|
||||
lazyLoadMembers: opts.lazyLoadMembers,
|
||||
pendingEventOrdering: opts.pendingEventOrdering,
|
||||
timelineSupport,
|
||||
});
|
||||
|
||||
client.reEmitter.reEmit(room, [
|
||||
RoomEvent.Name,
|
||||
RoomEvent.Redaction,
|
||||
RoomEvent.RedactionCancelled,
|
||||
RoomEvent.Receipt,
|
||||
RoomEvent.Tags,
|
||||
RoomEvent.LocalEchoUpdated,
|
||||
RoomEvent.AccountData,
|
||||
RoomEvent.MyMembership,
|
||||
RoomEvent.Timeline,
|
||||
RoomEvent.TimelineReset,
|
||||
RoomStateEvent.Events,
|
||||
RoomStateEvent.Members,
|
||||
RoomStateEvent.NewMember,
|
||||
RoomStateEvent.Update,
|
||||
BeaconEvent.New,
|
||||
BeaconEvent.Update,
|
||||
BeaconEvent.Destroy,
|
||||
BeaconEvent.LivenessChange,
|
||||
]);
|
||||
|
||||
// We need to add a listener for RoomState.members in order to hook them
|
||||
// correctly.
|
||||
room.on(RoomStateEvent.NewMember, (event, state, member) => {
|
||||
member.user = client.getUser(member.userId);
|
||||
client.reEmitter.reEmit(member, [
|
||||
RoomMemberEvent.Name,
|
||||
RoomMemberEvent.Typing,
|
||||
RoomMemberEvent.PowerLevel,
|
||||
RoomMemberEvent.Membership,
|
||||
]);
|
||||
});
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
+35
-13
@@ -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.
|
||||
@@ -327,22 +328,28 @@ export function escapeRegExp(string: string): string {
|
||||
}
|
||||
|
||||
export function globToRegexp(glob: string, extended?: any): string {
|
||||
extended = typeof(extended) === 'boolean' ? extended : true;
|
||||
// From
|
||||
// https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132
|
||||
// Because micromatch is about 130KB with dependencies,
|
||||
// and minimatch is not much better.
|
||||
let pat = escapeRegExp(glob);
|
||||
pat = pat.replace(/\\\*/g, '.*');
|
||||
pat = pat.replace(/\?/g, '.');
|
||||
if (extended) {
|
||||
pat = pat.replace(/\\\[(!|)(.*)\\]/g, function(match, p1, p2, offset, string) {
|
||||
const first = p1 && '^' || '';
|
||||
const second = p2.replace(/\\-/, '-');
|
||||
return '[' + first + second + ']';
|
||||
});
|
||||
}
|
||||
return pat;
|
||||
const replacements: ([RegExp, string | ((substring: string, ...args: any[]) => string) ])[] = [
|
||||
[/\\\*/g, '.*'],
|
||||
[/\?/g, '.'],
|
||||
extended !== false && [
|
||||
/\\\[(!|)(.*)\\]/g,
|
||||
(_match: string, neg: string, pat: string) => [
|
||||
'[',
|
||||
neg ? '^' : '',
|
||||
pat.replace(/\\-/, '-'),
|
||||
']',
|
||||
].join(''),
|
||||
],
|
||||
];
|
||||
return replacements.reduce(
|
||||
// https://github.com/microsoft/TypeScript/issues/30134
|
||||
(pat, args) => args ? pat.replace(args[0], args[1] as any) : pat,
|
||||
escapeRegExp(glob),
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureNoTrailingSlash(url: string): string {
|
||||
@@ -648,3 +655,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);
|
||||
}
|
||||
|
||||
|
||||
+51
-58
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -872,8 +866,6 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
public answerWithCallFeeds(callFeeds: CallFeed[]): void {
|
||||
if (this.inviteOrAnswerSent) return;
|
||||
|
||||
logger.debug(`Answering call ${this.callId}`);
|
||||
|
||||
this.gotCallFeedsForAnswer(callFeeds);
|
||||
}
|
||||
|
||||
@@ -1142,7 +1134,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 +1166,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 +1577,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;
|
||||
}
|
||||
}
|
||||
@@ -1870,10 +1863,10 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
const shouldTerminate = (
|
||||
// reject events also end the call if it's ringing: it's another of
|
||||
// our devices rejecting the call.
|
||||
([CallState.InviteSent, CallState.Ringing].includes(this.state)) ||
|
||||
[CallState.InviteSent, CallState.Ringing].includes(this.state) ||
|
||||
// also if we're in the init state and it's an inbound call, since
|
||||
// this means we just haven't entered the ringing state yet
|
||||
this.state === CallState.Fledgling && this.direction === CallDirection.Inbound
|
||||
(this.state === CallState.Fledgling && this.direction === CallDirection.Inbound)
|
||||
);
|
||||
|
||||
if (shouldTerminate) {
|
||||
|
||||
+10
-22
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user