Compare commits

...

94 Commits

Author SHA1 Message Date
RiotRobot c10152e098 v23.1.0-rc.2 2023-01-12 13:31:20 +00:00
RiotRobot 6b7efbcd91 Prepare changelog for v23.1.0-rc.2 2023-01-12 13:31:18 +00:00
Michael Telatynski f35298a326 [Release] Fix browser entrypoint (#3052) 2023-01-11 16:31:02 +00:00
RiotRobot 1d87f5b163 v23.1.0-rc.1 2023-01-11 13:29:05 +00:00
RiotRobot 3e97067b3e Prepare changelog for v23.1.0-rc.1 2023-01-11 13:29:02 +00:00
Michael Telatynski 3ce582d004 Fix dated example (#3049) 2023-01-11 13:08:30 +00:00
Michael Telatynski 3e48c76a77 Avoid use of mkdirp (#3050) 2023-01-11 12:02:56 +00:00
Germain 8e29f8ead0 Improve hasUserReadEvent and getUserReadUpTo realibility with threads (#3031)
Co-authored-by: Patrick Cloke <clokep@users.noreply.github.com>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-01-11 09:53:27 +00:00
Travis Ralston 185ded4ebc Remove extensible events v1 field population on legacy events (#3040)
* Remove extensible events v1 field population on legacy events

With extensible events v2, affected events are now gated by a room version, so we don't need this code anymore. 

The proposal has generally moved away from mixing m.room.message with extensible fields as well.

* Run prettier

* Remove unstable identifier from tests too

* Run prettier again
2023-01-10 09:19:55 -07:00
Andy Balaam 3564a3546f Merge pull request #3039 from andybalaam/factor-out-findPredecessor
Factor out a (public) function to find a room's predecessor
2023-01-10 13:43:59 +00:00
Andy Balaam c6090325b3 Update id string to reflect spec 2023-01-10 13:29:06 +00:00
Andy Balaam 999e355136 Use Room.getLiveTimeline instead of deprecated this.currentState 2023-01-10 10:16:22 +00:00
Andy Balaam 7de4164444 Factor out a (public) function to find a room's predecessor 2023-01-10 10:16:22 +00:00
Andy Balaam e2ce379b56 Merge pull request #3038 from andybalaam/andybalaam/tests-for-getVisibleRooms
Tests for getVisibleRooms
2023-01-10 10:07:53 +00:00
Andy Balaam 424212cd65 Merge pull request #2915 from matrix-org/madlittlemods/stablize-msc3030-timestamp-to-event
Prefer stable `/timestamp_to_event` endpoint first - MSC3030
2023-01-09 17:01:10 +00:00
Andy Balaam c7c16256df Update comment to reflect commonality between 404 and 405 status 2023-01-09 16:53:52 +00:00
Andy Balaam 8a4c95ee72 Tests for getVisibleRooms 2023-01-09 12:17:53 +00:00
Clark Fischer c3d422f5fb Remove 'qs' dependency (#3033)
'qs' appears to be unused since 34c5598 (PR #2719).

Signed-off-by: Clark Fischer <clark.fischer@gmail.com>

Signed-off-by: Clark Fischer <clark.fischer@gmail.com>
2023-01-06 22:37:05 +00:00
Šimon Brandner fdb80ad259 Remove video track when muting video (#3028) 2023-01-06 12:09:01 -05:00
Andy Balaam 981acf0044 Rename test to fit renamed function.
Co-authored-by: Eric Eastwood <erice@element.io>
2023-01-06 16:38:02 +00:00
Andy Balaam f00f70bfb8 Merge branch 'develop' into madlittlemods/stablize-msc3030-timestamp-to-event 2023-01-06 15:38:08 +00:00
Andy Balaam 12cc7be31c Test 400, 429 and 502 responses 2023-01-06 15:33:48 +00:00
Andy Balaam 628bcbf33a Fall back to the unstable endpoint if we receive a 405 status 2023-01-06 15:22:58 +00:00
Andy Balaam c4ca0b2e07 Refactor timestampToEvent tests 2023-01-06 15:13:44 +00:00
Andy Balaam d7442147b9 Rename convertQueryDictToMap 2023-01-06 14:21:35 +00:00
Andy Balaam b1566ee540 Switch to a Map for convertQueryDictToStringRecord 2023-01-06 14:20:24 +00:00
Andy Balaam ca98d9ff11 Tests for convertQueryDictToStringRecord 2023-01-06 14:20:24 +00:00
Andy Balaam bba4a35665 Merge pull request #3034 from matrix-org/johannes/poll-start-sent-receipt
Make poll start event type available (PSG-962)
2023-01-06 11:10:00 +00:00
Germain 896f6227a0 Fix threaded cache receipt when event holds multiple receipts (#3026) 2023-01-06 10:27:35 +00:00
Johannes Marbach 4d10cf3074 Make poll start event type available 2023-01-06 10:17:29 +01:00
Kerry bb23df9423 Add alt event type matching in Relations model (#3018)
* allow alt event types in relations model

* remove unneccesary checks on remove relation

* comment

* assert on event emitted
2023-01-05 20:00:12 +00:00
Richard van der Hoff d02559cf3c Make error handling in decryptionLoop more generic (#3024)
Not everything is a `DecryptionError`, and there's no real reason that we
should only do retries for `DecryptionError`s
2023-01-05 15:02:19 +00:00
Richard van der Hoff ec6272aa3d Fix outgoing messages for rust-crypto (#3025)
It turns out that MatrixClient uses a `FetchHttpApi` instance with
`opts.onlyData = true`, so it was returning the json-parsed response rather
than the raw response. Change the way we call `authedRequest` so that we get
the raw body back.
2023-01-05 15:00:37 +00:00
Michael Weimann 695b773f8b Fix false key requests after verifying new device (#3029) 2023-01-05 15:27:09 +01:00
Richard van der Hoff 030abe1563 Pass to-device messages into rust crypto-sdk (#3021)
We need a separate API, because `ClientEvent.ToDeviceEvent` is only emitted for
successfully decrypted to-device events
2023-01-05 09:54:56 +00:00
Richard van der Hoff 22f10f71b8 indirect decryption attempts via Client (#3023)
... to reduce the number of things referring to `client.crypto`
2023-01-05 09:54:44 +00:00
renovate[bot] 9ca3e7272e chore(deps): lock file maintenance (#3014)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-04 13:44:26 -07:00
Richard van der Hoff 64119ef915 Avoid logical assignment operator (#3022)
Apparently `??=` was only added to javascript in ES12, and our eleweb build
doesn't support it.

Fixes breakage introduced in #3019.
2023-01-04 14:17:55 +00:00
Richard van der Hoff 9ac7165e99 Handle outgoing requests from rust crypto SDK (#3019)
The rust matrix-sdk-crypto has an `outgoingRequests()` method which we need to poll, and make the requested requests.
2023-01-04 12:17:42 +00:00
Hubert Chathi 6168cedf32 Avoid triggering decryption errors when decrypting redacted events (#3004) 2023-01-03 11:06:54 -05:00
Richard van der Hoff 7c34deecb6 Pass CryptoBackend into SyncApi (#3010)
I need to start calling back into the new rust crypto implementation from the /sync loops, so I need to pass it into SyncApi. To reduce the coupling, I've defined a new interface specifying the methods which exist for that purpose. Currently it's only onSyncCompleted.
2023-01-03 15:37:51 +00:00
Ankit cef5507ab1 Include 'yarn install' . (#3011) 2023-01-03 14:07:37 +00:00
Richard van der Hoff 9b372d23ca Factor SyncApi options out of IStoredClientOptions (#3009)
There are a couple of callback interfaces which are currently stuffed into
`IStoredClientOpts` to make it easier to pass them into the `SyncApi`
constructor.

Before we add more fields to this, let's separate it out to a separate object.
2023-01-03 13:38:21 +00:00
renovate[bot] e9fef19c8f Update dependency @types/node to v18.11.18 (#2984)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-03 13:28:02 +00:00
Andy Balaam 9ebc91aa5a Merge pull request #3017 from andybalaam/andybalaam/use-env-variable-for-ref_name
In release.yml use an env section to get ref_name
2023-01-03 13:10:05 +00:00
Andy Balaam fcb75d547e Remove unneeded variable in workflow 2023-01-03 12:25:13 +00:00
Andy Balaam 33c9af952e Reformat release.yml 2023-01-03 10:25:59 +00:00
Andy Balaam 3b66b28e71 In release.yml use an env section to get ref_name 2023-01-03 10:21:40 +00:00
Travis Ralston 7d37bb1edb Remove usage of v1 Identity Server API (#3003)
* Remove usage of v1 Identity Server API

It's been deprecated for over a year at this point - everyone should be able to support v2.

* Missed one.
2023-01-03 00:59:12 -07:00
Travis Ralston 0f717a9306 Update comment for m.key.verification.ready event type definition (#3006) 2023-01-03 00:58:55 -07:00
Richard van der Hoff ce776b9989 Break coupling between Room and Crypto.trackRoomDevices (#2998)
`Room` and `Crypto` currently have some tight coupling in the form of a call to
`trackRoomDevices` when out-of-band members are loaded. We can improve this by
instead having Crypto listen out for a `RoomSateEvent.Update` notification.
2022-12-23 11:03:14 +00:00
kegsay ff1b0e51ea Merge pull request #3008 from matrix-org/kegan/upload-otks
bugfix: upload OTKs in sliding sync mode
2022-12-22 16:26:47 +01:00
Kegan Dougal 21e66a5c34 bugfix: upload OTKs in sliding sync mode 2022-12-22 14:52:16 +00:00
Germain aead401005 Apply edits discovered from sync after thread is initialised (#3002) 2022-12-22 08:54:55 +00:00
Travis Ralston af9525ed5f Add device_id to /account/whoami types (#3005)
* Add `device_id` to `/account/whoami` types

https://spec.matrix.org/v1.5/client-server-api/#get_matrixclientv3accountwhoami

* Appease the linter

* Modernize area of code

* Remove unused eslint disable comment
2022-12-21 16:46:10 -07:00
RiotRobot 1ebcac37cc Resetting package fields for development 2022-12-21 16:49:19 +00:00
RiotRobot 51a4cc5e18 Fix post-release script compatibility with prettier 2022-12-21 16:49:11 +00:00
RiotRobot 48baa6315c Merge branch 'master' into develop
# Conflicts:
#	release.sh
#	spec/integ/matrix-client-event-timeline.spec.ts
2022-12-21 16:48:43 +00:00
Michael Telatynski ec2405ac99 Fix release scripts to not fight with prettier (#2999) 2022-12-21 09:31:16 +00:00
kegsay 61e2606bc4 Merge pull request #2991 from S7evinK/s7evink/slidingsync-unsub
sliding sync: Fix issue where no unsubs are sent when switching rooms
2022-12-20 17:57:19 +01:00
Richard van der Hoff 45f6c5b079 Add exportRoomKeys to CryptoBackend (#2970)
Element-web calls `exportRoomKeys` on logout, so we need a stub implementation
to get it EW working with the rust crypto sdk.
2022-12-20 11:11:00 +00:00
Michael Weimann b83c372848 Implement MSC3912: Relation-based redactions (#2954) 2022-12-20 09:22:26 +01:00
Šimon Brandner 6d58a54039 Don't use RoomMember as a calls a key on GroupCall (#2993) 2022-12-19 14:53:08 +01:00
Till 3872c5f099 Update src/sliding-sync.ts
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2022-12-19 11:54:20 +01:00
Janne Mareike Koschinski 4f86eee250 Threads are missing from the timeline (#2996) 2022-12-19 10:32:37 +00:00
David Baker 618242ef3c Merge pull request #2992 from matrix-org/dbkr/close_all_streams
Close all streams when a call ends
2022-12-19 10:31:36 +00:00
David Baker 96ee5b1256 Close all streams when a call ends
We didn't close streams in group calls (presumably from back when
we used the same stream for all calls rather than cloning?) but this
left stray screenshare streams in the mediahandler when a participant
left whilst we were screensharing.

Fixes https://github.com/vector-im/element-call/issues/742
2022-12-16 18:19:36 +00:00
Till Faelligen 8af0ff111b Add tests for custom subscriptions 2022-12-16 13:30:40 +01:00
Till Faelligen a04800f030 Fix issue where no unsubs are sent when switching rooms 2022-12-16 11:49:31 +01:00
Eric Eastwood 4683fbe848 Merge branch 'develop' into madlittlemods/stablize-msc3030-timestamp-to-event
Conflicts:
	spec/unit/matrix-client.spec.ts
2022-12-16 00:56:06 -06:00
renovate[bot] c973b26fa2 Update all non-major dependencies (#2981)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-12-15 22:06:40 -07:00
renovate[bot] 7b96c730b8 Update typescript-eslint monorepo to v5.46.0 (#2985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-12-15 22:05:22 -07:00
renovate[bot] f8bf6083de Update dependency uuid to v9 (#2988)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-12-15 22:05:05 -07:00
Anderas 447319737a Merge pull request #2920 from Anderas/resume-todevice-queue
Resume to-device message queue after resumed sync
2022-12-14 14:27:47 +00:00
Janne Mareike Koschinski 39e127b4e3 Write test to validate #2971 (#2972) 2022-12-14 13:01:52 +01:00
Richard van der Hoff 15ef8fabb7 Introduce a mechanism for using the rust-crypto-sdk (#2969)
This PR introduces MatrixClient.initRustCrypto, which is similar to initCrypto, except that it will use the Rust crypto SDK instead of the old libolm-based implementation.

This is very much not something you want to use in production code right now, because the integration with the rust sdk is extremely skeletal and almost everything crypto-related will raise an exception rather than doing anything useful.

It is, however, enough to demonstrate the loading of the wasmified rust sdk in element web, and a react sdk with light modifications can successfully log in and out.

Part of vector-im/element-web#21972.
2022-12-14 11:02:02 +00:00
renovate[bot] df42014ef5 Update dependency @types/jest to v29.2.4 (#2980)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-12-14 10:55:40 +00:00
Michael Weimann b765b18381 Add prettier formatting to .git-blame-ignore-revs (#2979) 2022-12-14 11:46:18 +01:00
Kerry b2a10e6db3 Support MSC3391: Account data deletion (#2967)
* add deleteAccountData endpoint

* check server support and test

* test current state of memorystore

* interpret account data events with empty content as deleted

* add handling for (future) stable version of endpoint

* add getSafeUserId

* user getSafeUserId in deleteAccountData

* better jsdoc for throws documentation
2022-12-14 04:14:21 +00:00
Eric Eastwood a0aa5074ed Fix prefix lint
See https://github.com/matrix-org/matrix-js-sdk/pull/2915#discussion_r1043951639
2022-12-13 16:37:47 -06:00
Eric Eastwood 70a033c2fd Prettier fixes 2022-12-13 16:26:14 -06:00
Eric Eastwood fcf12b49e3 Merge branch 'develop' into madlittlemods/stablize-msc3030-timestamp-to-event
Conflicts:
	spec/unit/matrix-client.spec.ts
	src/client.ts
2022-12-13 16:18:33 -06:00
Andy Uhnak 11ac3d9e58 Add unit tests 2022-12-13 12:26:52 +00:00
Andy Uhnak 071d5e71e4 Resume to-device message queue after resumed sync 2022-12-13 11:54:52 +00:00
Eric Eastwood 9841f92415 Fix some eslint 2022-12-08 18:37:33 -06:00
Eric Eastwood ed91bd9c11 Merge branch 'develop' into madlittlemods/stablize-msc3030-timestamp-to-event
Conflicts:
	spec/unit/matrix-client.spec.ts
	src/client.ts
2022-12-08 18:29:14 -06:00
Eric Eastwood c953fc9fb7 Update casing
See https://github.com/matrix-org/matrix-js-sdk/pull/2915#discussion_r1041542066
2022-12-08 17:56:53 -06:00
Eric Eastwood bf78a64d82 Remove console coloring in favor of future PR
See https://github.com/matrix-org/matrix-js-sdk/pull/2915#discussion_r1041539703
2022-12-08 17:47:56 -06:00
Eric Eastwood 9a98e8008f Fix relevant strict ts error 2022-11-30 19:13:29 -06:00
Eric Eastwood ad8bb5d2cd Fix lints 2022-11-30 19:01:20 -06:00
Eric Eastwood 9a731cdf4f Add some comments 2022-11-30 18:53:23 -06:00
Eric Eastwood d1ede036e2 Add return type 2022-11-30 18:51:49 -06:00
Eric Eastwood d3f08fec03 Add tests 2022-11-30 18:45:05 -06:00
Eric Eastwood 3a1897629a Prefer stable endpoint first 2022-11-28 22:50:37 -06:00
64 changed files with 3540 additions and 1002 deletions
+2 -1
View File
@@ -38,4 +38,5 @@ cee7f7a280a8c20bafc21c0a2911f60851f7a7ca
7ed65407e6cdf292ce3cf659310c68d19dcd52b2
# Switch to ESLint from JSHint (Google eslint rules as a base)
e057956ede9ad1a931ff8050c411aca7907e0394
# prettier
349c2c2587c2885bb69eda4aa078b5383724cf5e
+3 -2
View File
@@ -32,9 +32,10 @@ jobs:
ref: gh-pages
- name: 🔪 Prepare
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
run: |
tag="${{ github.ref_name }}"
VERSION="${tag#v}"
VERSION="${GITHUB_REF_NAME#v}"
[ ! -e "$VERSION" ] || rm -r $VERSION
cp -r $RUNNER_TEMP/_docs/ $VERSION
+34
View File
@@ -1,3 +1,37 @@
Changes in [23.1.0-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.1.0-rc.2) (2023-01-12)
============================================================================================================
## 🐛 Bug Fixes
* Fix browser entrypoint ([\#3051](https://github.com/matrix-org/matrix-js-sdk/pull/3051)). Fixes #3013.
Changes in [23.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.1.0-rc.1) (2023-01-11)
============================================================================================================
## 🦖 Deprecations
* Remove extensible events v1 field population on legacy events ([\#3040](https://github.com/matrix-org/matrix-js-sdk/pull/3040)).
## ✨ Features
* Improve hasUserReadEvent and getUserReadUpTo realibility with threads ([\#3031](https://github.com/matrix-org/matrix-js-sdk/pull/3031)). Fixes vector-im/element-web#24164.
* Remove video track when muting video ([\#3028](https://github.com/matrix-org/matrix-js-sdk/pull/3028)). Fixes vector-im/element-call#209.
* Make poll start event type available (PSG-962) ([\#3034](https://github.com/matrix-org/matrix-js-sdk/pull/3034)).
* Add alt event type matching in Relations model ([\#3018](https://github.com/matrix-org/matrix-js-sdk/pull/3018)).
* Remove usage of v1 Identity Server API ([\#3003](https://github.com/matrix-org/matrix-js-sdk/pull/3003)).
* Add `device_id` to `/account/whoami` types ([\#3005](https://github.com/matrix-org/matrix-js-sdk/pull/3005)).
* Implement MSC3912: Relation-based redactions ([\#2954](https://github.com/matrix-org/matrix-js-sdk/pull/2954)).
* Introduce a mechanism for using the rust-crypto-sdk ([\#2969](https://github.com/matrix-org/matrix-js-sdk/pull/2969)).
* Support MSC3391: Account data deletion ([\#2967](https://github.com/matrix-org/matrix-js-sdk/pull/2967)).
## 🐛 Bug Fixes
* Fix threaded cache receipt when event holds multiple receipts ([\#3026](https://github.com/matrix-org/matrix-js-sdk/pull/3026)).
* Fix false key requests after verifying new device ([\#3029](https://github.com/matrix-org/matrix-js-sdk/pull/3029)). Fixes vector-im/element-web#24167 and vector-im/element-web#23333.
* Avoid triggering decryption errors when decrypting redacted events ([\#3004](https://github.com/matrix-org/matrix-js-sdk/pull/3004)). Fixes vector-im/element-web#24084.
* bugfix: upload OTKs in sliding sync mode ([\#3008](https://github.com/matrix-org/matrix-js-sdk/pull/3008)).
* Apply edits discovered from sync after thread is initialised ([\#3002](https://github.com/matrix-org/matrix-js-sdk/pull/3002)). Fixes vector-im/element-web#23921.
* sliding sync: Fix issue where no unsubs are sent when switching rooms ([\#2991](https://github.com/matrix-org/matrix-js-sdk/pull/2991)).
* Threads are missing from the timeline ([\#2996](https://github.com/matrix-org/matrix-js-sdk/pull/2996)). Fixes vector-im/element-web#24036.
* Close all streams when a call ends ([\#2992](https://github.com/matrix-org/matrix-js-sdk/pull/2992)). Fixes vector-im/element-call#742.
* Resume to-device message queue after resumed sync ([\#2920](https://github.com/matrix-org/matrix-js-sdk/pull/2920)). Fixes matrix-org/element-web-rageshakes#17170.
Changes in [23.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.0.0) (2022-12-21)
==================================================================================================
+1 -1
View File
@@ -46,7 +46,7 @@ if you do not have it already.
```javascript
import * as sdk from "matrix-js-sdk";
const client = sdk.createClient("https://matrix.org");
const client = sdk.createClient({ baseUrl: "https://matrix.org" });
client.publicRooms(function (err, data) {
console.log("Public Rooms: %s", JSON.stringify(data));
});
+1
View File
@@ -1,6 +1,7 @@
To try it out, **you must build the SDK first** and then host this folder:
```
$ yarn install
$ yarn build
$ cd examples/browser
$ python -m http.server 8003
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "23.0.0",
"version": "23.1.0-rc.2",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=16.0.0"
@@ -14,7 +14,7 @@
"build:dev": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types",
"build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly",
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
"build:compile-browser": "mkdirp dist && browserify -d src/browser-index.ts -p [ tsify -p ./tsconfig-build.json ] -t [ babelify --sourceMaps=inline --presets [ @babel/preset-env @babel/preset-typescript ] ] | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js",
"build:compile-browser": "mkdir dist && browserify -d src/browser-index.ts -p [ tsify -p ./tsconfig-build.json ] -t [ babelify --sourceMaps=inline --presets [ @babel/preset-env @babel/preset-typescript ] ] | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js",
"build:minify-browser": "terser dist/browser-matrix.js --compress --mangle --source-map --output dist/browser-matrix.min.js",
"gendoc": "typedoc",
"lint": "yarn lint:types && yarn lint:js",
@@ -33,10 +33,11 @@
"matrix-org"
],
"main": "./lib/index.js",
"browser": "./lib/browser-index.ts",
"browser": "./lib/browser-index.js",
"matrix_src_main": "./src/index.ts",
"matrix_src_browser": "./src/browser-index.ts",
"matrix_lib_main": "./lib/index.js",
"matrix_lib_browser": "./lib/browser-index.js",
"matrix_lib_typings": "./lib/index.d.ts",
"author": "matrix.org",
"license": "Apache-2.0",
@@ -54,6 +55,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.2",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
@@ -61,10 +63,9 @@
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.0.0",
"p-retry": "4",
"qs": "^6.9.6",
"sdp-transform": "^2.14.1",
"unhomoglyph": "^1.0.6",
"uuid": "7"
"uuid": "9"
},
"devDependencies": {
"@babel/cli": "^7.12.10",
@@ -97,7 +98,7 @@
"browserify": "^17.0.0",
"docdash": "^2.0.0",
"domexception": "^4.0.0",
"eslint": "8.28.0",
"eslint": "8.29.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.5.1",
@@ -113,7 +114,7 @@
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"matrix-mock-request": "^2.5.0",
"prettier": "2.8.0",
"prettier": "2.8.1",
"rimraf": "^3.0.2",
"terser": "^5.5.1",
"tsify": "^5.0.2",
+2 -2
View File
@@ -21,9 +21,9 @@ if [ "$(git branch -lr | grep origin/develop -c)" -ge 1 ]; then
# to the TypeScript source.
src_value=$(jq -r ".matrix_src_$i" package.json)
if [ "$src_value" != "null" ]; then
jq ".$i = .matrix_src_$i" package.json > package.json.new && mv package.json.new package.json
jq ".$i = .matrix_src_$i" package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json
else
jq "del(.$i)" package.json > package.json.new && mv package.json.new package.json
jq "del(.$i)" package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json
fi
fi
done
+8 -3
View File
@@ -24,7 +24,7 @@ import MockHttpBackend from "matrix-mock-request";
import { LocalStorageCryptoStore } from "../src/crypto/store/localStorage-crypto-store";
import { logger } from "../src/logger";
import { syncPromise } from "./test-utils/test-utils";
import { createClient } from "../src/matrix";
import { createClient, IStartClientOpts } from "../src/matrix";
import { ICreateClientOpts, IDownloadKeyResult, MatrixClient, PendingEventOrdering } from "../src/client";
import { MockStorageApi } from "./MockStorageApi";
import { encodeUri } from "../src/utils";
@@ -79,9 +79,12 @@ export class TestClient {
/**
* start the client, and wait for it to initialise.
*/
public start(): Promise<void> {
public start(opts: IStartClientOpts = {}): Promise<void> {
logger.log(this + ": starting");
this.httpBackend.when("GET", "/versions").respond(200, {});
this.httpBackend.when("GET", "/versions").respond(200, {
// we have tests that rely on support for lazy-loading members
versions: ["r0.5.0"],
});
this.httpBackend.when("GET", "/pushrules").respond(200, {});
this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
this.expectDeviceKeyUpload();
@@ -93,6 +96,8 @@ export class TestClient {
this.client.startClient({
// set this so that we can get hold of failed events
pendingEventOrdering: PendingEventOrdering.Detached,
...opts,
});
return Promise.all([this.httpBackend.flushAllExpected(), syncPromise(this.client)]).then(() => {
@@ -1073,6 +1073,97 @@ describe("MatrixClient event timelines", function () {
});
});
it("should ensure thread events are ordered correctly", async () => {
// Test data for a second reply to the first thread
const THREAD_REPLY2 = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply 2",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: true,
});
THREAD_REPLY2.localTimestamp += 1000;
// Test data for a second reply to the first thread
const THREAD_REPLY3 = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply 3",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: true,
});
THREAD_REPLY3.localTimestamp += 2000;
// Test data for the first thread, with the second reply
const THREAD_ROOT_UPDATED = {
...THREAD_ROOT,
unsigned: {
...THREAD_ROOT.unsigned,
"m.relations": {
...THREAD_ROOT.unsigned!["m.relations"],
"io.element.thread": {
...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"],
count: 3,
latest_event: THREAD_REPLY3.event,
},
},
},
};
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
client.fetchRoomEvent = () => Promise.resolve(THREAD_ROOT_UPDATED);
await client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
const prom = emitPromise(room, ThreadEvent.Update);
// Assume we're seeing the reply while loading backlog
room.addLiveEvents([THREAD_REPLY2]);
httpBackend
.when(
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name),
)
.respond(200, {
chunk: [THREAD_REPLY3.event, THREAD_REPLY2.event, THREAD_REPLY],
});
await flushHttp(prom);
// but while loading the metadata, a new reply has arrived
room.addLiveEvents([THREAD_REPLY3]);
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
// then the events should still be all in the right order
expect(thread.events.map((it) => it.getId())).toEqual([
THREAD_ROOT.event_id,
THREAD_REPLY.event_id,
THREAD_REPLY2.getId(),
THREAD_REPLY3.getId(),
]);
});
describe("paginateEventTimeline for thread list timeline", function () {
const RANDOM_TOKEN = "7280349c7bee430f91defe2a38a0a08c";
+8 -12
View File
@@ -35,9 +35,7 @@ describe("MatrixClient", function () {
let store: MemoryStore | undefined;
const defaultClientOpts: IStoredClientOpts = {
canResetEntireTimeline: (roomId) => false,
experimentalThreadSupport: false,
crypto: {} as unknown as IStoredClientOpts["crypto"],
};
const setupTests = (): [MatrixClient, HttpBackend, MemoryStore] => {
const store = new MemoryStore();
@@ -1179,11 +1177,10 @@ describe("MatrixClient", function () {
.when("PUT", "/send")
.check((req) => {
expect(req.data).toStrictEqual({
"msgtype": "m.emote",
"body": "Body",
"formatted_body": "<h1>Body</h1>",
"format": "org.matrix.custom.html",
"org.matrix.msc1767.message": expect.anything(),
msgtype: "m.emote",
body: "Body",
formatted_body: "<h1>Body</h1>",
format: "org.matrix.custom.html",
});
})
.respond(200, { event_id: "$foobar" });
@@ -1199,11 +1196,10 @@ describe("MatrixClient", function () {
.when("PUT", "/send")
.check((req) => {
expect(req.data).toStrictEqual({
"msgtype": "m.text",
"body": "Body",
"formatted_body": "<h1>Body</h1>",
"format": "org.matrix.custom.html",
"org.matrix.msc1767.message": expect.anything(),
msgtype: "m.text",
body: "Body",
formatted_body: "<h1>Body</h1>",
format: "org.matrix.custom.html",
});
})
.respond(200, { event_id: "$foobar" });
+46
View File
@@ -1543,6 +1543,52 @@ describe("MatrixClient syncing", () => {
});
});
});
it("only replays receipts relevant to the current context", async () => {
const THREAD_ID = "$unknownthread:localhost";
const receipt = {
type: "m.receipt",
room_id: "!foo:bar",
content: {
"$event1:localhost": {
[ReceiptType.Read]: {
"@alice:localhost": { ts: 666, thread_id: THREAD_ID },
},
},
"$otherevent:localhost": {
[ReceiptType.Read]: {
"@alice:localhost": { ts: 999, thread_id: "$otherthread:localhost" },
},
},
},
};
syncData.rooms.join[roomOne].ephemeral.events = [receipt];
httpBackend!.when("GET", "/sync").respond(200, syncData);
client!.startClient();
return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]).then(() => {
const room = client?.getRoom(roomOne);
expect(room).toBeInstanceOf(Room);
expect(room?.cachedThreadReadReceipts.has(THREAD_ID)).toBe(true);
const thread = room!.createThread(THREAD_ID, undefined, [], true);
expect(room?.cachedThreadReadReceipts.has(THREAD_ID)).toBe(false);
const receipt = thread.getReadReceiptForUserId("@alice:localhost");
expect(receipt).toStrictEqual({
data: {
thread_id: "$unknownthread:localhost",
ts: 666,
},
eventId: "$event1:localhost",
});
});
});
});
describe("of a room", () => {
+88
View File
@@ -1590,4 +1590,92 @@ describe("megolm", () => {
aliceTestClient.httpBackend.flush("/send/m.room.encrypted/", 1),
]);
});
describe("Lazy-loading member lists", () => {
let p2pSession: Olm.Session;
beforeEach(async () => {
// set up the aliceTestClient so that it is a room with no known members
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await aliceTestClient.start({ lazyLoadMembers: true });
aliceTestClient.client.setGlobalErrorOnUnknownDevices(false);
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse([]));
await aliceTestClient.flushSync();
p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount);
});
async function expectMembershipRequest(roomId: string, members: string[]): Promise<void> {
const membersPath = `/rooms/${encodeURIComponent(roomId)}/members?not_membership=leave`;
aliceTestClient.httpBackend.when("GET", membersPath).respond(200, {
chunk: [
testUtils.mkMembershipCustom({
membership: "join",
sender: "@bob:xyz",
}),
],
});
await aliceTestClient.httpBackend.flush(membersPath, 1);
}
it("Sending an event initiates a member list sync", async () => {
// we expect a call to the /members list...
const memberListPromise = expectMembershipRequest(ROOM_ID, ["@bob:xyz"]);
// then a request for bob's devices...
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz"));
// then a to-device with the room_key
const inboundGroupSessionPromise = expectSendRoomKey(
aliceTestClient.httpBackend,
"@bob:xyz",
testOlmAccount,
p2pSession,
);
// and finally the megolm message
const megolmMessagePromise = expectSendMegolmMessage(
aliceTestClient.httpBackend,
inboundGroupSessionPromise,
);
// kick it off
const sendPromise = aliceTestClient.client.sendTextMessage(ROOM_ID, "test");
await Promise.all([
sendPromise,
megolmMessagePromise,
memberListPromise,
aliceTestClient.httpBackend.flush("/keys/query", 1),
]);
});
it("loading the membership list inhibits a later load", async () => {
const room = aliceTestClient.client.getRoom(ROOM_ID)!;
await Promise.all([room.loadMembersIfNeeded(), expectMembershipRequest(ROOM_ID, ["@bob:xyz"])]);
// expect a request for bob's devices...
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz"));
// then a to-device with the room_key
const inboundGroupSessionPromise = expectSendRoomKey(
aliceTestClient.httpBackend,
"@bob:xyz",
testOlmAccount,
p2pSession,
);
// and finally the megolm message
const megolmMessagePromise = expectSendMegolmMessage(
aliceTestClient.httpBackend,
inboundGroupSessionPromise,
);
// kick it off
const sendPromise = aliceTestClient.client.sendTextMessage(ROOM_ID, "test");
await Promise.all([sendPromise, megolmMessagePromise, aliceTestClient.httpBackend.flush("/keys/query", 1)]);
});
});
});
+90
View File
@@ -0,0 +1,90 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { createClient } from "../../src";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
});
describe("MatrixClient.initRustCrypto", () => {
it("should raise if userId or deviceId is unknown", async () => {
const unknownUserClient = createClient({
baseUrl: "http://test.server",
deviceId: "aliceDevice",
});
await expect(() => unknownUserClient.initRustCrypto()).rejects.toThrow("unknown userId");
const unknownDeviceClient = createClient({
baseUrl: "http://test.server",
userId: "@alice:test",
});
await expect(() => unknownDeviceClient.initRustCrypto()).rejects.toThrow("unknown deviceId");
});
it("should create the indexed dbs", async () => {
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: "@alice:localhost",
deviceId: "aliceDevice",
});
// No databases.
expect(await indexedDB.databases()).toHaveLength(0);
await matrixClient.initRustCrypto();
// should have two dbs now
const databaseNames = (await indexedDB.databases()).map((db) => db.name);
expect(databaseNames).toEqual(
expect.arrayContaining(["matrix-js-sdk::matrix-sdk-crypto", "matrix-js-sdk::matrix-sdk-crypto-meta"]),
);
});
it("should ignore a second call", async () => {
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: "@alice:localhost",
deviceId: "aliceDevice",
});
await matrixClient.initRustCrypto();
await matrixClient.initRustCrypto();
});
});
describe("MatrixClient.clearStores", () => {
it("should clear the indexeddbs", async () => {
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: "@alice:localhost",
deviceId: "aliceDevice",
});
await matrixClient.initRustCrypto();
expect(await indexedDB.databases()).toHaveLength(2);
await matrixClient.stopClient();
await matrixClient.clearStores();
expect(await indexedDB.databases()).toHaveLength(0);
});
});
+4 -3
View File
@@ -38,7 +38,7 @@ import {
IRoomTimelineData,
} from "../../src";
import { SlidingSyncSdk } from "../../src/sliding-sync-sdk";
import { SyncState } from "../../src/sync";
import { SyncApiOptions, SyncState } from "../../src/sync";
import { IStoredClientOpts } from "../../src/client";
import { logger } from "../../src/logger";
import { emitPromise } from "../test-utils/test-utils";
@@ -111,6 +111,7 @@ describe("SlidingSyncSdk", () => {
// assign client/httpBackend globals
const setupClient = async (testOpts?: Partial<IStoredClientOpts & { withCrypto: boolean }>) => {
testOpts = testOpts || {};
const syncOpts: SyncApiOptions = {};
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
httpBackend = testClient.httpBackend;
client = testClient.client;
@@ -118,10 +119,10 @@ describe("SlidingSyncSdk", () => {
if (testOpts.withCrypto) {
httpBackend!.when("GET", "/room_keys/version").respond(404, {});
await client!.initCrypto();
testOpts.crypto = client!.crypto;
syncOpts.cryptoCallbacks = syncOpts.crypto = client!.crypto;
}
httpBackend!.when("GET", "/_matrix/client/r0/pushrules").respond(200, {});
sdk = new SlidingSyncSdk(mockSlidingSync, client, testOpts);
sdk = new SlidingSyncSdk(mockSlidingSync, client, testOpts, syncOpts);
};
// tear down client/httpBackend globals
+96
View File
@@ -1418,6 +1418,102 @@ describe("SlidingSync", () => {
await httpBackend!.flushAllExpected();
slidingSync.stop();
});
it("should not be possible to add/modify an already added custom subscription", async () => {
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
slidingSync.addCustomSubscription(customSubName1, customSub1);
slidingSync.addCustomSubscription(customSubName1, customSub2);
slidingSync.useCustomSubscription(roomA, customSubName1);
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomA]).toEqual(customSub1);
})
.respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
slidingSync.stop();
});
it("should change the custom subscription if they are different", async () => {
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
slidingSync.addCustomSubscription(customSubName1, customSub1);
slidingSync.addCustomSubscription(customSubName2, customSub2);
slidingSync.useCustomSubscription(roomA, customSubName1);
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomA]).toEqual(customSub1);
expect(body.unsubscribe_rooms).toBeUndefined();
})
.respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
// using the same subscription doesn't unsub nor changes subscriptions
slidingSync.useCustomSubscription(roomA, customSubName1);
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeUndefined();
expect(body.unsubscribe_rooms).toBeUndefined();
})
.respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
// Changing the subscription works
slidingSync.useCustomSubscription(roomA, customSubName2);
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomA]).toEqual(customSub2);
expect(body.unsubscribe_rooms).toBeUndefined();
})
.respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
slidingSync.stop();
});
});
describe("extensions", () => {
-3
View File
@@ -513,9 +513,6 @@ export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandle
public sendMetadataUpdate = jest.fn<void, []>();
public on = jest.fn();
public removeListener = jest.fn();
public getOpponentMember(): Partial<RoomMember> {
return this.opponentMember;
}
+106
View File
@@ -0,0 +1,106 @@
import { ConnectionError } from "../../src/http-api/errors";
import { ClientEvent, MatrixClient, Store } from "../../src/client";
import { ToDeviceMessageQueue } from "../../src/ToDeviceMessageQueue";
import { getMockClientWithEventEmitter } from "../test-utils/client";
import { StubStore } from "../../src/store/stub";
import { IndexedToDeviceBatch } from "../../src/models/ToDeviceMessage";
import { SyncState } from "../../src/sync";
describe("onResumedSync", () => {
let batch: IndexedToDeviceBatch | null;
let shouldFailSendToDevice: Boolean;
let onSendToDeviceFailure: () => void;
let onSendToDeviceSuccess: () => void;
let resumeSync: (newState: SyncState, oldState: SyncState) => void;
let store: Store;
let mockClient: MatrixClient;
let queue: ToDeviceMessageQueue;
beforeEach(() => {
batch = {
id: 0,
txnId: "123",
eventType: "m.dummy",
batch: [],
};
shouldFailSendToDevice = true;
onSendToDeviceFailure = () => {};
onSendToDeviceSuccess = () => {};
resumeSync = (newState, oldState) => {
shouldFailSendToDevice = false;
mockClient.emit(ClientEvent.Sync, newState, oldState);
};
store = new StubStore();
store.getOldestToDeviceBatch = jest.fn().mockImplementation(() => {
return batch;
});
store.removeToDeviceBatch = jest.fn().mockImplementation(() => {
batch = null;
});
mockClient = getMockClientWithEventEmitter({});
mockClient.store = store;
mockClient.sendToDevice = jest.fn().mockImplementation(async () => {
if (shouldFailSendToDevice) {
await Promise.reject(new ConnectionError("")).finally(() => {
setTimeout(onSendToDeviceFailure, 0);
});
} else {
await Promise.resolve({}).finally(() => {
setTimeout(onSendToDeviceSuccess, 0);
});
}
});
queue = new ToDeviceMessageQueue(mockClient);
});
it("resends queue after connectivity restored", (done) => {
onSendToDeviceFailure = () => {
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
expect(store.removeToDeviceBatch).not.toHaveBeenCalled();
resumeSync(SyncState.Syncing, SyncState.Catchup);
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(2);
};
onSendToDeviceSuccess = () => {
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(3);
expect(store.removeToDeviceBatch).toHaveBeenCalled();
done();
};
queue.start();
});
it("does not resend queue if client sync still catching up", (done) => {
onSendToDeviceFailure = () => {
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
expect(store.removeToDeviceBatch).not.toHaveBeenCalled();
resumeSync(SyncState.Catchup, SyncState.Catchup);
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
done();
};
queue.start();
});
it("does not resend queue if connectivity restored after queue stopped", (done) => {
onSendToDeviceFailure = () => {
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
expect(store.removeToDeviceBatch).not.toHaveBeenCalled();
queue.stop();
resumeSync(SyncState.Syncing, SyncState.Catchup);
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
done();
};
queue.start();
});
});
+6 -6
View File
@@ -544,7 +544,7 @@ describe("AutoDiscovery", function () {
.respond(200, {
versions: ["r0.0.1"],
});
httpBackend.when("GET", "/_matrix/identity/api/v1").respond(404, {});
httpBackend.when("GET", "/_matrix/identity/v2").respond(404, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
// Note: we also expect this test to trim the trailing slash
@@ -591,7 +591,7 @@ describe("AutoDiscovery", function () {
.respond(200, {
versions: ["r0.0.1"],
});
httpBackend.when("GET", "/_matrix/identity/api/v1").respond(500, {});
httpBackend.when("GET", "/_matrix/identity/v2").respond(500, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
// Note: we also expect this test to trim the trailing slash
@@ -636,9 +636,9 @@ describe("AutoDiscovery", function () {
versions: ["r0.0.1"],
});
httpBackend
.when("GET", "/_matrix/identity/api/v1")
.when("GET", "/_matrix/identity/v2")
.check((req) => {
expect(req.path).toEqual("https://identity.example.org/_matrix/identity/api/v1");
expect(req.path).toEqual("https://identity.example.org/_matrix/identity/v2");
})
.respond(200, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
@@ -682,9 +682,9 @@ describe("AutoDiscovery", function () {
versions: ["r0.0.1"],
});
httpBackend
.when("GET", "/_matrix/identity/api/v1")
.when("GET", "/_matrix/identity/v2")
.check((req) => {
expect(req.path).toEqual("https://identity.example.org/_matrix/identity/api/v1");
expect(req.path).toEqual("https://identity.example.org/_matrix/identity/v2");
})
.respond(200, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
+32
View File
@@ -167,6 +167,38 @@ describe("Crypto", function () {
client.stopClient();
});
it("doesn't throw an error when attempting to decrypt a redacted event", async () => {
const client = new TestClient("@alice:example.com", "deviceid").client;
await client.initCrypto();
const event = new MatrixEvent({
content: {},
event_id: "$event_id",
room_id: "!room_id",
sender: "@bob:example.com",
type: "m.room.encrypted",
unsigned: {
redacted_because: {
content: {},
event_id: "$redaction_event_id",
redacts: "$event_id",
room_id: "!room_id",
origin_server_ts: 1234567890,
sender: "@bob:example.com",
type: "m.room.redaction",
unsigned: {},
},
},
});
await event.attemptDecryption(client.crypto!);
expect(event.isDecryptionFailure()).toBeFalsy();
// since the redaction event isn't encrypted, the redacted_because
// should be the same as in the original event
expect(event.getRedactionEvent()).toEqual(event.getUnsigned().redacted_because);
client.stopClient();
});
});
describe("Session management", function () {
@@ -1,56 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "../../../olm-loader";
import { CRYPTO_ENABLED, MatrixClient } from "../../../../src/client";
import { TestClient } from "../../../TestClient";
const Olm = global.Olm;
describe("crypto.setDeviceVerification", () => {
const userId = "@alice:example.com";
const deviceId1 = "device1";
let client: MatrixClient;
if (!CRYPTO_ENABLED) {
return;
}
beforeAll(async () => {
await Olm.init();
});
beforeEach(async () => {
client = new TestClient(userId, deviceId1).client;
await client.initCrypto();
});
it("client should provide crypto", () => {
expect(client.crypto).not.toBeUndefined();
});
describe("when setting an own device as verified", () => {
beforeEach(async () => {
jest.spyOn(client.crypto!, "cancelAndResendAllOutgoingKeyRequests");
await client.crypto!.setDeviceVerification(userId, deviceId1, true);
});
it("cancelAndResendAllOutgoingKeyRequests should be called", () => {
expect(client.crypto!.cancelAndResendAllOutgoingKeyRequests).toHaveBeenCalled();
});
});
});
+86 -1
View File
@@ -24,10 +24,13 @@ import {
MatrixClient,
MatrixEvent,
MatrixEventEvent,
RelationType,
Room,
RoomEvent,
} from "../../src";
import { Thread } from "../../src/models/thread";
import { FeatureSupport, Thread } from "../../src/models/thread";
import { ReEmitter } from "../../src/ReEmitter";
import { eventMapperFor } from "../../src/event-mapper";
describe("EventTimelineSet", () => {
const roomId = "!foo:bar";
@@ -202,6 +205,88 @@ describe("EventTimelineSet", () => {
expect(liveTimeline.getEvents().length).toStrictEqual(0);
});
it("should allow edits to be added to thread timeline", async () => {
jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true);
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
Thread.hasServerSideSupport = FeatureSupport.Stable;
const sender = "@alice:matrix.org";
const root = utils.mkEvent({
event: true,
content: {
body: "Thread root",
},
type: EventType.RoomMessage,
sender,
});
room.addLiveEvents([root]);
const threadReply = utils.mkEvent({
event: true,
content: {
"body": "Thread reply",
"m.relates_to": {
event_id: root.getId()!,
rel_type: RelationType.Thread,
},
},
type: EventType.RoomMessage,
sender,
});
root.setUnsigned({
"m.relations": {
[RelationType.Thread]: {
count: 1,
latest_event: {
content: threadReply.getContent(),
origin_server_ts: 5,
room_id: room.roomId,
sender,
type: EventType.RoomMessage,
event_id: threadReply.getId()!,
user_id: sender,
age: 1,
},
current_user_participated: true,
},
},
});
const editToThreadReply = utils.mkEvent({
event: true,
content: {
"body": " * edit",
"m.new_content": {
"body": "edit",
"msgtype": "m.text",
"org.matrix.msc1767.text": "edit",
},
"m.relates_to": {
event_id: threadReply.getId()!,
rel_type: RelationType.Replace,
},
},
type: EventType.RoomMessage,
sender,
});
jest.spyOn(client, "paginateEventTimeline").mockImplementation(async () => {
thread.timelineSet.getLiveTimeline().addEvent(threadReply, { toStartOfTimeline: true });
return true;
});
jest.spyOn(client, "relations").mockResolvedValue({
events: [],
});
const thread = room.createThread(root.getId()!, root, [threadReply, editToThreadReply], false);
thread.once(RoomEvent.TimelineReset, () => {
const lastEvent = thread.timeline.at(-1)!;
expect(lastEvent.getContent().body).toBe(" * edit");
});
});
describe("non-room timeline", () => {
it("Adds event to timeline", () => {
const nonRoomEventTimelineSet = new EventTimelineSet(
+460 -13
View File
@@ -22,11 +22,13 @@ import { Filter } from "../../src/filter";
import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace";
import {
EventType,
RelationType,
RoomCreateTypeField,
RoomType,
UNSTABLE_MSC3088_ENABLED,
UNSTABLE_MSC3088_PURPOSE,
UNSTABLE_MSC3089_TREE_SUBTYPE,
MSC3912_RELATION_BASED_REDACTIONS_PROP,
} from "../../src/@types/event";
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
import { Crypto } from "../../src/crypto";
@@ -38,6 +40,8 @@ import { makeBeaconInfoContent } from "../../src/content-helpers";
import { M_BEACON_INFO } from "../../src/@types/beacon";
import {
ContentHelpers,
ClientPrefix,
Direction,
EventTimeline,
ICreateRoomOpts,
IRequestOpts,
@@ -57,6 +61,8 @@ import {
import { IOlmDevice } from "../../src/crypto/algorithms/megolm";
import { QueryDict } from "../../src/utils";
import { SyncState } from "../../src/sync";
import * as featureUtils from "../../src/feature";
import { StubStore } from "../../src/store/stub";
jest.useFakeTimers();
@@ -65,9 +71,20 @@ jest.mock("../../src/webrtc/call", () => ({
supportsMatrixCall: jest.fn(() => false),
}));
// Utility function to ease the transition from our QueryDict type to a Map
// which we can use to build a URLSearchParams
function convertQueryDictToMap(queryDict?: QueryDict): Map<string, string> {
if (!queryDict) {
return new Map();
}
return new Map(Object.entries(queryDict).map(([k, v]) => [k, String(v)]));
}
type HttpLookup = {
method: string;
path: string;
prefix?: string;
data?: Record<string, any>;
error?: object;
expectBody?: Record<string, any>;
@@ -84,6 +101,42 @@ type WrappedRoom = Room & {
_state: Map<string, any>;
};
describe("convertQueryDictToMap", () => {
it("returns an empty map when dict is undefined", () => {
expect(convertQueryDictToMap(undefined)).toEqual(new Map());
});
it("converts an empty QueryDict to an empty map", () => {
expect(convertQueryDictToMap({})).toEqual(new Map());
});
it("converts a QueryDict of strings to the equivalent map", () => {
expect(convertQueryDictToMap({ a: "b", c: "d" })).toEqual(
new Map([
["a", "b"],
["c", "d"],
]),
);
});
it("converts the values of the supplied QueryDict to strings", () => {
expect(convertQueryDictToMap({ arr: ["b", "c"], num: 45, boo: true, und: undefined })).toEqual(
new Map([
["arr", "b,c"],
["num", "45"],
["boo", "true"],
["und", "undefined"],
]),
);
});
it("produces sane URLSearchParams conversions", () => {
expect(new URLSearchParams(Array.from(convertQueryDictToMap({ a: "b", c: "d" }))).toString()).toEqual(
"a=b&c=d",
);
});
});
describe("MatrixClient", function () {
const userId = "@alice:bar";
const identityServerUrl = "https://identity.server";
@@ -120,6 +173,10 @@ describe("MatrixClient", function () {
data: SYNC_DATA,
};
const unstableFeatures: Record<string, boolean> = {
"org.matrix.msc3440.stable": true,
};
// items are popped off when processed and block if no items left.
let httpLookups: HttpLookup[] = [];
let acceptKeepalives: boolean;
@@ -128,12 +185,17 @@ describe("MatrixClient", function () {
method: string;
path: string;
} | null = null;
function httpReq(method: Method, path: string, qp?: QueryDict, data?: BodyInit, opts?: IRequestOpts) {
function httpReq(
method: Method,
path: string,
queryParams?: QueryDict,
body?: BodyInit,
requestOpts: IRequestOpts = {},
) {
const { prefix } = requestOpts;
if (path === KEEP_ALIVE_PATH && acceptKeepalives) {
return Promise.resolve({
unstable_features: {
"org.matrix.msc3440.stable": true,
},
unstable_features: unstableFeatures,
versions: ["r0.6.0", "r0.6.1"],
});
}
@@ -166,14 +228,17 @@ describe("MatrixClient", function () {
};
return pendingLookup.promise;
}
if (next.path === path && next.method === method) {
// Either we don't care about the prefix if it wasn't defined in the expected
// lookup or it should match.
const doesMatchPrefix = !next.prefix || next.prefix === prefix;
if (doesMatchPrefix && next.path === path && next.method === method) {
logger.log("MatrixClient[UT] Matched. Returning " + (next.error ? "BAD" : "GOOD") + " response");
if (next.expectBody) {
expect(data).toEqual(next.expectBody);
expect(body).toEqual(next.expectBody);
}
if (next.expectQueryParams) {
Object.keys(next.expectQueryParams).forEach(function (k) {
expect(qp?.[k]).toEqual(next.expectQueryParams![k]);
expect(queryParams?.[k]).toEqual(next.expectQueryParams![k]);
});
}
@@ -193,12 +258,22 @@ describe("MatrixClient", function () {
}
return Promise.resolve(next.data);
}
// Jest doesn't let us have custom expectation errors, so if you're seeing this then
// you forgot to handle at least 1 pending request. Check your tests to ensure your
// number of expectations lines up with your number of requests made, and that those
// requests match your expectations.
expect(true).toBe(false);
return new Promise(() => {});
const receivedRequestQueryString = new URLSearchParams(
Array.from(convertQueryDictToMap(queryParams)),
).toString();
const receivedRequestDebugString = `${method} ${prefix}${path}${receivedRequestQueryString}`;
const expectedQueryString = new URLSearchParams(
Array.from(convertQueryDictToMap(next.expectQueryParams)),
).toString();
const expectedRequestDebugString = `${next.method} ${next.prefix ?? ""}${next.path}${expectedQueryString}`;
// If you're seeing this then you forgot to handle at least 1 pending request.
throw new Error(
`A pending request was not handled: ${receivedRequestDebugString} ` +
`(next request expected was ${expectedRequestDebugString})\n` +
`Check your tests to ensure your number of expectations lines up with your number of requests ` +
`made, and that those requests match your expectations.`,
);
}
function makeClient() {
@@ -281,6 +356,183 @@ describe("MatrixClient", function () {
client.stopClient();
});
describe("timestampToEvent", () => {
const roomId = "!room:server.org";
const eventId = "$eventId:example.org";
const unstableMSC3030Prefix = "/_matrix/client/unstable/org.matrix.msc3030";
async function assertRequestsMade(
responses: {
prefix?: string;
error?: { httpStatus: Number; errcode: string };
data?: { event_id: string };
}[],
expectRejects = false,
) {
const queryParams = {
ts: "0",
dir: "f",
};
const path = `/rooms/${encodeURIComponent(roomId)}/timestamp_to_event`;
// Set up the responses we are going to send back
httpLookups = responses.map((res) => {
return {
method: "GET",
path,
expectQueryParams: queryParams,
...res,
};
});
// When we ask for the event timestamp (this is what we are testing)
const answer = client.timestampToEvent(roomId, 0, Direction.Forward);
if (expectRejects) {
await expect(answer).rejects.toBeDefined();
} else {
await answer;
}
// Then the number of requests me made matches our expectation
const calls = mocked(client.http.authedRequest).mock.calls;
expect(calls.length).toStrictEqual(responses.length);
// And each request was as we expected
let i = 0;
for (const call of calls) {
const response = responses[i];
const [callMethod, callPath, callQueryParams, , callOpts] = call;
const callPrefix = callOpts?.prefix;
expect(callMethod).toStrictEqual("GET");
if (response.prefix) {
expect(callPrefix).toStrictEqual(response.prefix);
}
expect(callPath).toStrictEqual(path);
expect(callQueryParams).toStrictEqual(queryParams);
i++;
}
}
it("should call stable endpoint", async () => {
await assertRequestsMade([
{
data: { event_id: eventId },
},
]);
});
it("should fallback to unstable endpoint when stable endpoint 400s", async () => {
await assertRequestsMade([
{
prefix: ClientPrefix.V1,
error: {
httpStatus: 400,
errcode: "M_UNRECOGNIZED",
},
},
{
prefix: unstableMSC3030Prefix,
data: { event_id: eventId },
},
]);
});
it("should fallback to unstable endpoint when stable endpoint 404s", async () => {
await assertRequestsMade([
{
prefix: ClientPrefix.V1,
error: {
httpStatus: 404,
errcode: "M_UNRECOGNIZED",
},
},
{
prefix: unstableMSC3030Prefix,
data: { event_id: eventId },
},
]);
});
it("should fallback to unstable endpoint when stable endpoint 405s", async () => {
await assertRequestsMade([
{
prefix: ClientPrefix.V1,
error: {
httpStatus: 405,
errcode: "M_UNRECOGNIZED",
},
},
{
prefix: unstableMSC3030Prefix,
data: { event_id: eventId },
},
]);
});
it("should not fallback to unstable endpoint when stable endpoint returns an error (500)", async () => {
await assertRequestsMade(
[
{
prefix: ClientPrefix.V1,
error: {
httpStatus: 500,
errcode: "Fake response error",
},
},
],
true,
);
});
it("should not fallback to unstable endpoint when stable endpoint is rate-limiting (429)", async () => {
await assertRequestsMade(
[
{
prefix: ClientPrefix.V1,
error: {
httpStatus: 429,
errcode: "M_UNRECOGNIZED", // Still refuses even if the errcode claims unrecognised
},
},
],
true,
);
});
it("should not fallback to unstable endpoint when stable endpoint says bad gateway (502)", async () => {
await assertRequestsMade(
[
{
prefix: ClientPrefix.V1,
error: {
httpStatus: 502,
errcode: "Fake response error",
},
},
],
true,
);
});
});
describe("getSafeUserId()", () => {
it("returns the logged in user id", () => {
expect(client.getSafeUserId()).toEqual(userId);
});
it("throws when there is not logged in user", () => {
const notLoggedInClient = new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: identityServerUrl,
fetchFn: function () {} as any, // NOP
store: store,
scheduler: scheduler,
});
expect(() => notLoggedInClient.getSafeUserId()).toThrow("Expected logged in user but found none.");
});
});
describe("sendEvent", () => {
const roomId = "!room:example.org";
const body = "This is the body";
@@ -1067,6 +1319,59 @@ describe("MatrixClient", function () {
await client.redactEvent(roomId, eventId, txnId, { reason });
});
describe("when calling with with_relations", () => {
const eventId = "$event42:example.org";
it("should raise an error if server has no support for relation based redactions", async () => {
// load supported features
await client.getVersions();
const txnId = client.makeTxnId();
expect(() => {
client.redactEvent(roomId, eventId, txnId, {
with_relations: [RelationType.Reference],
});
}).toThrowError(
new Error(
"Server does not support relation based redactions " +
`roomId ${roomId} eventId ${eventId} txnId: ${txnId} threadId null`,
),
);
});
describe("and the server supports relation based redactions (unstable)", () => {
beforeEach(async () => {
unstableFeatures["org.matrix.msc3912"] = true;
// load supported features
await client.getVersions();
});
it("should send with_relations in the request body", async () => {
const txnId = client.makeTxnId();
httpLookups = [
{
method: "PUT",
path:
`/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}` +
`/${encodeURIComponent(txnId)}`,
expectBody: {
reason: "redaction test",
[MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!]: [RelationType.Reference],
},
data: { event_id: eventId },
},
];
await client.redactEvent(roomId, eventId, txnId, {
reason: "redaction test",
with_relations: [RelationType.Reference],
});
});
});
});
});
describe("cancelPendingEvent", () => {
@@ -1828,4 +2133,146 @@ describe("MatrixClient", function () {
expect(client.getUseE2eForGroupCall()).toBe(false);
});
});
describe("delete account data", () => {
afterEach(() => {
jest.spyOn(featureUtils, "buildFeatureSupportMap").mockRestore();
});
it("makes correct request when deletion is supported by server in unstable versions", async () => {
const eventType = "im.vector.test";
const versionsResponse = {
versions: ["1"],
unstable_features: {
"org.matrix.msc3391": true,
},
};
jest.spyOn(client.http, "request").mockResolvedValue(versionsResponse);
const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve());
const unstablePrefix = "/_matrix/client/unstable/org.matrix.msc3391";
const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`;
// populate version support
await client.getVersions();
await client.deleteAccountData(eventType);
expect(requestSpy).toHaveBeenCalledWith(Method.Delete, path, undefined, undefined, {
prefix: unstablePrefix,
});
});
it("makes correct request when deletion is supported by server based on matrix version", async () => {
const eventType = "im.vector.test";
// we don't have a stable version for account data deletion yet to test this code path with
// so mock the support map to fake stable support
const stableSupportedDeletionMap = new Map();
stableSupportedDeletionMap.set(featureUtils.Feature.AccountDataDeletion, featureUtils.ServerSupport.Stable);
jest.spyOn(featureUtils, "buildFeatureSupportMap").mockResolvedValue(new Map());
const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve());
const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`;
// populate version support
await client.getVersions();
await client.deleteAccountData(eventType);
expect(requestSpy).toHaveBeenCalledWith(Method.Delete, path, undefined, undefined, undefined);
});
it("makes correct request when deletion is not supported by server", async () => {
const eventType = "im.vector.test";
const versionsResponse = {
versions: ["1"],
unstable_features: {
"org.matrix.msc3391": false,
},
};
jest.spyOn(client.http, "request").mockResolvedValue(versionsResponse);
const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve());
const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`;
// populate version support
await client.getVersions();
await client.deleteAccountData(eventType);
// account data updated with empty content
expect(requestSpy).toHaveBeenCalledWith(Method.Put, path, undefined, {});
});
});
describe("getVisibleRooms", () => {
function roomCreateEvent(newRoomId: string, predecessorRoomId: string): MatrixEvent {
return new MatrixEvent({
content: {
"creator": "@daryl:alexandria.example.com",
"m.federate": true,
"predecessor": {
event_id: "spec_is_not_clear_what_id_this_is",
room_id: predecessorRoomId,
},
"room_version": "9",
},
event_id: `create_event_id_pred_${predecessorRoomId}`,
origin_server_ts: 1432735824653,
room_id: newRoomId,
sender: "@daryl:alexandria.example.com",
state_key: "",
type: "m.room.create",
});
}
function tombstoneEvent(newRoomId: string, predecessorRoomId: string): MatrixEvent {
return new MatrixEvent({
content: {
body: "This room has been replaced",
replacement_room: newRoomId,
},
event_id: `tombstone_event_id_pred_${predecessorRoomId}`,
origin_server_ts: 1432735824653,
room_id: predecessorRoomId,
sender: "@daryl:alexandria.example.com",
state_key: "",
type: "m.room.tombstone",
});
}
it("Returns an empty list if there are no rooms", () => {
client.store = new StubStore();
client.store.getRooms = () => [];
const rooms = client.getVisibleRooms();
expect(rooms).toHaveLength(0);
});
it("Returns all non-replaced rooms", () => {
const room1 = new Room("room1", client, "@carol:alexandria.example.com");
const room2 = new Room("room2", client, "@daryl:alexandria.example.com");
client.store = new StubStore();
client.store.getRooms = () => [room1, room2];
const rooms = client.getVisibleRooms();
expect(rooms).toContain(room1);
expect(rooms).toContain(room2);
expect(rooms).toHaveLength(2);
});
it("Does not return replaced rooms", () => {
// Given 4 rooms, 2 of which have been replaced
const room1 = new Room("room1", client, "@carol:alexandria.example.com");
const replacedRoom1 = new Room("replacedRoom1", client, "@carol:alexandria.example.com");
const replacedRoom2 = new Room("replacedRoom2", client, "@carol:alexandria.example.com");
const room2 = new Room("room2", client, "@daryl:alexandria.example.com");
client.store = new StubStore();
client.store.getRooms = () => [room1, replacedRoom1, replacedRoom2, room2];
room1.addLiveEvents([roomCreateEvent(room1.roomId, replacedRoom1.roomId)], {});
room2.addLiveEvents([roomCreateEvent(room2.roomId, replacedRoom2.roomId)], {});
replacedRoom1.addLiveEvents([tombstoneEvent(room1.roomId, replacedRoom1.roomId)], {});
replacedRoom2.addLiveEvents([tombstoneEvent(room2.roomId, replacedRoom2.roomId)], {});
// When we ask for the visible rooms
const rooms = client.getVisibleRooms();
// Then we only get the ones that have not been replaced
expect(rooms).not.toContain(replacedRoom1);
expect(rooms).not.toContain(replacedRoom2);
expect(rooms).toContain(room1);
expect(rooms).toContain(room2);
});
});
});
+15 -17
View File
@@ -16,7 +16,6 @@ limitations under the License.
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
import { emitPromise } from "../../test-utils/test-utils";
import { EventType } from "../../../src";
import { Crypto } from "../../../src/crypto";
describe("MatrixEvent", () => {
@@ -88,22 +87,6 @@ describe("MatrixEvent", () => {
expect(ev.getWireContent().ciphertext).toBeUndefined();
});
it("should abort decryption if fails with an error other than a DecryptionError", async () => {
const ev = new MatrixEvent({
type: EventType.RoomMessageEncrypted,
content: {
body: "Test",
},
event_id: "$event1:server",
});
await ev.attemptDecryption({
decryptEvent: jest.fn().mockRejectedValue(new Error("Not a DecryptionError")),
} as unknown as Crypto);
expect(ev.isEncrypted()).toBeTruthy();
expect(ev.isBeingDecrypted()).toBeFalsy();
expect(ev.isDecryptionFailure()).toBeFalsy();
});
describe("applyVisibilityEvent", () => {
it("should emit VisibilityChange if a change was made", async () => {
const ev = new MatrixEvent({
@@ -134,6 +117,21 @@ describe("MatrixEvent", () => {
});
});
it("should report decryption errors", async () => {
const crypto = {
decryptEvent: jest.fn().mockRejectedValue(new Error("test error")),
} as unknown as Crypto;
await encryptedEvent.attemptDecryption(crypto);
expect(encryptedEvent.isEncrypted()).toBeTruthy();
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
expect(encryptedEvent.getContent()).toEqual({
msgtype: "m.bad.encrypted",
body: "** Unable to decrypt: Error: test error **",
});
});
it("should retry decryption if a retry is queued", async () => {
const eventAttemptDecryptionSpy = jest.spyOn(encryptedEvent, "attemptDecryption");
+179 -9
View File
@@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -19,8 +19,12 @@ import { Room } from "../../../src/models/room";
import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread";
import { mkThread } from "../../test-utils/thread";
import { TestClient } from "../../TestClient";
import { emitPromise, mkMessage } from "../../test-utils/test-utils";
import { EventStatus } from "../../../src";
import { emitPromise, mkMessage, mock } from "../../test-utils/test-utils";
import { EventStatus, MatrixEvent } from "../../../src";
import { ReceiptType } from "../../../src/@types/read_receipts";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
import { ReEmitter } from "../../../src/ReEmitter";
import { Feature, ServerSupport } from "../../../src/feature";
describe("Thread", () => {
describe("constructor", () => {
@@ -71,17 +75,54 @@ describe("Thread", () => {
});
describe("hasUserReadEvent", () => {
const myUserId = "@bob:example.org";
let myUserId: string;
let client: MatrixClient;
let room: Room;
beforeEach(() => {
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
timelineSupport: false,
client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
getRoom: jest.fn().mockImplementation(() => room),
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
});
client = testClient.client;
client.reEmitter = mock(ReEmitter, "ReEmitter");
client.canSupport = new Map();
Object.keys(Feature).forEach((feature) => {
client.canSupport.set(feature as Feature, ServerSupport.Stable);
});
myUserId = client.getUserId()!;
room = new Room("123", client, myUserId);
const receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
// first threaded receipt
"$event0:localhost": {
[ReceiptType.Read]: {
[client.getUserId()!]: { ts: 100, thread_id: "$threadId:localhost" },
},
},
// last unthreaded receipt
"$event1:localhost": {
[ReceiptType.Read]: {
[client.getUserId()!]: { ts: 200 },
["@alice:example.org"]: { ts: 200 },
},
},
// last threaded receipt
"$event2:localhost": {
[ReceiptType.Read]: {
[client.getUserId()!]: { ts: 300, thread_id: "$threadId" },
},
},
},
});
room.addReceipt(receipt);
jest.spyOn(client, "getRoom").mockReturnValue(room);
});
@@ -98,6 +139,7 @@ describe("Thread", () => {
length: 2,
});
// The event is automatically considered read as the current user is the sender
expect(thread.hasUserReadEvent(myUserId, events.at(-1)!.getId() ?? "")).toBeTruthy();
});
@@ -106,11 +148,139 @@ describe("Thread", () => {
room,
client,
authorId: myUserId,
participantUserIds: ["@alice:example.org"],
length: 2,
participantUserIds: [myUserId],
length: 25,
ts: 190,
});
// Before alice's last unthreaded receipt
expect(thread.hasUserReadEvent("@alice:example.org", events.at(1)!.getId() ?? "")).toBeTruthy();
// After alice's last unthreaded receipt
expect(thread.hasUserReadEvent("@alice:example.org", events.at(-1)!.getId() ?? "")).toBeFalsy();
});
it("considers event as read if there's a more recent unthreaded receipt", () => {
const { thread, events } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: ["@alice:example.org"],
length: 2,
ts: 150, // before the latest unthreaded receipt
});
expect(thread.hasUserReadEvent(client.getUserId()!, events.at(-1)!.getId() ?? "")).toBe(true);
});
it("considers event as unread if there's no more recent unthreaded receipt", () => {
const { thread, events } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: ["@alice:example.org"],
length: 2,
ts: 1000,
});
expect(thread.hasUserReadEvent(client.getUserId()!, events.at(-1)!.getId() ?? "")).toBe(false);
});
});
describe("getEventReadUpTo", () => {
let myUserId: string;
let client: MatrixClient;
let room: Room;
beforeEach(() => {
client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
getRoom: jest.fn().mockImplementation(() => room),
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
});
client.reEmitter = mock(ReEmitter, "ReEmitter");
client.canSupport = new Map();
Object.keys(Feature).forEach((feature) => {
client.canSupport.set(feature as Feature, ServerSupport.Stable);
});
myUserId = client.getUserId()!;
room = new Room("123", client, myUserId);
jest.spyOn(client, "getRoom").mockReturnValue(room);
});
afterAll(() => {
jest.resetAllMocks();
});
it("uses unthreaded receipt to figure out read up to", () => {
const receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
// last unthreaded receipt
"$event1:localhost": {
[ReceiptType.Read]: {
["@alice:example.org"]: { ts: 200 },
},
},
},
});
room.addReceipt(receipt);
const { thread, events } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: [myUserId],
length: 25,
ts: 190,
});
// The 10th event has been read, as alice's last unthreaded receipt is at ts 200
// and `mkThread` increment every thread response by 1ms.
expect(thread.getEventReadUpTo("@alice:example.org")).toBe(events.at(9)!.getId());
});
it("considers thread created before the first threaded receipt to be read", () => {
const receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
// last unthreaded receipt
"$event1:localhost": {
[ReceiptType.Read]: {
[myUserId]: { ts: 200, thread_id: "$threadId" },
},
},
},
});
room.addReceipt(receipt);
const { thread, events } = mkThread({
room,
client,
authorId: "@alice:example.org",
participantUserIds: ["@alice:example.org"],
length: 2,
ts: 10,
});
// This is marked as read as it is before alice's first threaded receipt...
expect(thread.getEventReadUpTo(myUserId)).toBe(events.at(-1)!.getId());
const { thread: thread2 } = mkThread({
room,
client,
authorId: "@alice:example.org",
participantUserIds: ["@alice:example.org"],
length: 2,
ts: 1000,
});
// Nothing has been read, this thread is after the first threaded receipt...
expect(thread2.getEventReadUpTo(myUserId)).toBe(null);
});
});
});
+29 -1
View File
@@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ReceiptType } from "../../src/@types/read_receipts";
import { Feature, ServerSupport } from "../../src/feature";
import {
EventType,
@@ -64,6 +65,30 @@ describe("fixNotificationCountOnDecryption", () => {
});
room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "");
const receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
"$event0:localhost": {
[ReceiptType.Read]: {
[mockClient.getUserId()!]: { ts: 123 },
},
},
"$event1:localhost": {
[ReceiptType.Read]: {
[mockClient.getUserId()!]: { ts: 666, thread_id: THREAD_ID },
},
},
"$otherevent:localhost": {
[ReceiptType.Read]: {
[mockClient.getUserId()!]: { ts: 999, thread_id: "$otherthread:localhost" },
},
},
},
});
room.addReceipt(receipt);
room.setUnreadNotificationCount(NotificationCountType.Total, 1);
room.setUnreadNotificationCount(NotificationCountType.Highlight, 0);
@@ -75,6 +100,7 @@ describe("fixNotificationCountOnDecryption", () => {
body: "Hello world!",
},
event: true,
ts: 1234,
},
mockClient,
);
@@ -90,6 +116,7 @@ describe("fixNotificationCountOnDecryption", () => {
"msgtype": MsgType.Text,
"body": "Thread reply",
},
ts: 5678,
event: true,
});
room.createThread(THREAD_ID, event, [threadEvent], false);
@@ -155,6 +182,7 @@ describe("fixNotificationCountOnDecryption", () => {
"msgtype": MsgType.Text,
"body": "Thread reply",
},
ts: 8901,
event: true,
});
+95 -1
View File
@@ -14,13 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { M_POLL_START } from "matrix-events-sdk";
import { EventTimelineSet } from "../../src/models/event-timeline-set";
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { Room } from "../../src/models/room";
import { Relations } from "../../src/models/relations";
import { Relations, RelationsEvent } from "../../src/models/relations";
import { TestClient } from "../TestClient";
import { RelationType } from "../../src";
import { logger } from "../../src/logger";
describe("Relations", function () {
afterEach(() => {
jest.spyOn(logger, "error").mockRestore();
});
it("should deduplicate annotations", function () {
const room = new Room("room123", null!, null!);
const relations = new Relations("m.annotation", "m.reaction", room);
@@ -75,6 +83,92 @@ describe("Relations", function () {
}
});
describe("addEvent()", () => {
const relationType = RelationType.Reference;
const eventType = M_POLL_START.stable!;
const altEventTypes = [M_POLL_START.unstable!];
const room = new Room("room123", null!, null!);
it("should not add events without a relation", async () => {
// dont pollute console
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
const relations = new Relations(relationType, eventType, room);
const emitSpy = jest.spyOn(relations, "emit");
const event = new MatrixEvent({ type: eventType });
await relations.addEvent(event);
expect(logSpy).toHaveBeenCalledWith("Event must have relation info");
// event not added
expect(relations.getRelations().length).toBe(0);
expect(emitSpy).not.toHaveBeenCalled();
});
it("should not add events of incorrect event type", async () => {
// dont pollute console
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
const relations = new Relations(relationType, eventType, room);
const emitSpy = jest.spyOn(relations, "emit");
const event = new MatrixEvent({
type: "different-event-type",
content: {
"m.relates_to": {
event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o",
rel_type: relationType,
},
},
});
await relations.addEvent(event);
expect(logSpy).toHaveBeenCalledWith(`Event relation info doesn't match this container`);
// event not added
expect(relations.getRelations().length).toBe(0);
expect(emitSpy).not.toHaveBeenCalled();
});
it("adds events that match alt event types", async () => {
const relations = new Relations(relationType, eventType, room, altEventTypes);
const emitSpy = jest.spyOn(relations, "emit");
const event = new MatrixEvent({
type: M_POLL_START.unstable!,
content: {
"m.relates_to": {
event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o",
rel_type: relationType,
},
},
});
await relations.addEvent(event);
// event added
expect(relations.getRelations()).toEqual([event]);
expect(emitSpy).toHaveBeenCalledWith(RelationsEvent.Add, event);
});
it("should not add events of incorrect relation type", async () => {
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
const relations = new Relations(relationType, eventType, room);
const event = new MatrixEvent({
type: eventType,
content: {
"m.relates_to": {
event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o",
rel_type: "m.annotation",
},
},
});
await relations.addEvent(event);
const emitSpy = jest.spyOn(relations, "emit");
expect(logSpy).toHaveBeenCalledWith(`Event relation info doesn't match this container`);
// event not added
expect(relations.getRelations().length).toBe(0);
expect(emitSpy).not.toHaveBeenCalled();
});
});
it("should emit created regardless of ordering", async function () {
const targetEvent = new MatrixEvent({
sender: "@bob:example.com",
+58
View File
@@ -33,6 +33,7 @@ import {
IRelationsRequestOpts,
IStateEventWithRoomId,
JoinRule,
MatrixClient,
MatrixEvent,
MatrixEventEvent,
PendingEventOrdering,
@@ -49,6 +50,7 @@ import { ReceiptType, WrappedReceipt } from "../../src/@types/read_receipts";
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread";
import { Crypto } from "../../src/crypto";
import { mkThread } from "../test-utils/thread";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../test-utils/client";
describe("Room", function () {
const roomId = "!foo:bar";
@@ -3225,4 +3227,60 @@ describe("Room", function () {
expect(room.getBlacklistUnverifiedDevices()).toBe(false);
});
});
describe("findPredecessorRoomId", () => {
let client: MatrixClient | null = null;
beforeEach(() => {
client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
});
});
function roomCreateEvent(newRoomId: string, predecessorRoomId: string | null): MatrixEvent {
const content: {
creator: string;
["m.federate"]: boolean;
room_version: string;
predecessor: { event_id: string; room_id: string } | undefined;
} = {
"creator": "@daryl:alexandria.example.com",
"predecessor": undefined,
"m.federate": true,
"room_version": "9",
};
if (predecessorRoomId) {
content.predecessor = {
event_id: "id_of_last_known_event",
room_id: predecessorRoomId,
};
}
return new MatrixEvent({
content,
event_id: `create_event_id_pred_${predecessorRoomId}`,
origin_server_ts: 1432735824653,
room_id: newRoomId,
sender: "@daryl:alexandria.example.com",
state_key: "",
type: "m.room.create",
});
}
it("Returns null if there is no create event", () => {
const room = new Room("roomid", client!, "@u:example.com");
expect(room.findPredecessorRoomId()).toBeNull();
});
it("Returns null if the create event has no predecessor", () => {
const room = new Room("roomid", client!, "@u:example.com");
room.addLiveEvents([roomCreateEvent("roomid", null)]);
expect(room.findPredecessorRoomId()).toBeNull();
});
it("Returns the predecessor ID if one is provided via create event", () => {
const room = new Room("roomid", client!, "@u:example.com");
room.addLiveEvents([roomCreateEvent("roomid", "replacedroomid")]);
expect(room.findPredecessorRoomId()).toBe("replacedroomid");
});
});
});
+248
View File
@@ -0,0 +1,248 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import {
KeysBackupRequest,
KeysClaimRequest,
KeysQueryRequest,
KeysUploadRequest,
OlmMachine,
SignatureUploadRequest,
} from "@matrix-org/matrix-sdk-crypto-js";
import { Mocked } from "jest-mock";
import MockHttpBackend from "matrix-mock-request";
import { RustCrypto } from "../../src/rust-crypto/rust-crypto";
import { initRustCrypto } from "../../src/rust-crypto";
import { HttpApiEvent, HttpApiEventHandlerMap, IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../src";
import { TypedEventEmitter } from "../../src/models/typed-event-emitter";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
});
describe("RustCrypto", () => {
const TEST_USER = "@alice:example.com";
const TEST_DEVICE_ID = "TEST_DEVICE";
describe(".exportRoomKeys", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
});
it("should return a list", async () => {
const keys = await rustCrypto.exportRoomKeys();
expect(Array.isArray(keys)).toBeTruthy();
});
});
describe("to-device messages", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
});
it("should pass through unencrypted to-device messages", async () => {
const inputs: IToDeviceEvent[] = [
{ content: { key: "value" }, type: "org.matrix.test", sender: "@alice:example.com" },
];
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
expect(res).toEqual(inputs);
});
it("should pass through bad encrypted messages", async () => {
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
const keys = olmMachine.identityKeys;
const inputs: IToDeviceEvent[] = [
{
type: "m.room.encrypted",
content: {
algorithm: "m.olm.v1.curve25519-aes-sha2",
sender_key: "IlRMeOPX2e0MurIyfWEucYBRVOEEUMrOHqn/8mLqMjA",
ciphertext: {
[keys.curve25519.toBase64()]: {
type: 0,
body: "ajyjlghi",
},
},
},
sender: "@alice:example.com",
},
];
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
expect(res).toEqual(inputs);
});
});
describe("outgoing requests", () => {
/** the RustCrypto implementation under test */
let rustCrypto: RustCrypto;
/** A mock http backend which rustCrypto is connected to */
let httpBackend: MockHttpBackend;
/** a mocked-up OlmMachine which rustCrypto is connected to */
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
/** A list of results to be returned from olmMachine.outgoingRequest. Each call will shift a result off
* the front of the queue, until it is empty. */
let outgoingRequestQueue: Array<Array<any>>;
/** wait for a call to olmMachine.markRequestAsSent */
function awaitCallToMarkAsSent(): Promise<void> {
return new Promise((resolve, _reject) => {
olmMachine.markRequestAsSent.mockImplementationOnce(async () => {
resolve(undefined);
});
});
}
beforeEach(async () => {
httpBackend = new MockHttpBackend();
await RustSdkCryptoJs.initAsync();
const dummyEventEmitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
const httpApi = new MatrixHttpApi(dummyEventEmitter, {
baseUrl: "https://example.com",
prefix: "/_matrix",
fetchFn: httpBackend.fetchFn as typeof global.fetch,
onlyData: true,
});
// for these tests we use a mock OlmMachine, with an implementation of outgoingRequests that
// returns objects from outgoingRequestQueue
outgoingRequestQueue = [];
olmMachine = {
outgoingRequests: jest.fn().mockImplementation(() => {
return Promise.resolve(outgoingRequestQueue.shift() ?? []);
}),
markRequestAsSent: jest.fn(),
close: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
rustCrypto = new RustCrypto(olmMachine, httpApi, TEST_USER, TEST_DEVICE_ID);
});
it("should poll for outgoing messages", () => {
rustCrypto.onSyncCompleted({});
expect(olmMachine.outgoingRequests).toHaveBeenCalled();
});
/* simple requests that map directly to the request body */
const tests: Array<[any, "POST" | "PUT", string]> = [
[KeysUploadRequest, "POST", "https://example.com/_matrix/client/v3/keys/upload"],
[KeysQueryRequest, "POST", "https://example.com/_matrix/client/v3/keys/query"],
[KeysClaimRequest, "POST", "https://example.com/_matrix/client/v3/keys/claim"],
[SignatureUploadRequest, "POST", "https://example.com/_matrix/client/v3/keys/signatures/upload"],
[KeysBackupRequest, "PUT", "https://example.com/_matrix/client/v3/room_keys/keys"],
];
for (const [RequestClass, expectedMethod, expectedPath] of tests) {
it(`should handle ${RequestClass.name}s`, async () => {
const testBody = '{ "foo": "bar" }';
const outgoingRequest = new RequestClass("1234", testBody);
outgoingRequestQueue.push([outgoingRequest]);
const testResponse = '{ "result": 1 }';
httpBackend
.when(expectedMethod, "/_matrix")
.check((req) => {
expect(req.path).toEqual(expectedPath);
expect(req.rawData).toEqual(testBody);
expect(req.headers["Accept"]).toEqual("application/json");
expect(req.headers["Content-Type"]).toEqual("application/json");
})
.respond(200, testResponse, true);
rustCrypto.onSyncCompleted({});
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(1);
const markSentCallPromise = awaitCallToMarkAsSent();
await httpBackend.flushAllExpected();
await markSentCallPromise;
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
httpBackend.verifyNoOutstandingRequests();
});
}
it("does not explode with unknown requests", async () => {
const outgoingRequest = { id: "5678", type: 987 };
outgoingRequestQueue.push([outgoingRequest]);
rustCrypto.onSyncCompleted({});
await awaitCallToMarkAsSent();
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("5678", 987, "");
});
it("stops looping when stop() is called", async () => {
const testResponse = '{ "result": 1 }';
for (let i = 0; i < 5; i++) {
outgoingRequestQueue.push([new KeysQueryRequest("1234", "{}")]);
httpBackend.when("POST", "/_matrix").respond(200, testResponse, true);
}
rustCrypto.onSyncCompleted({});
expect(rustCrypto["outgoingRequestLoopRunning"]).toBeTruthy();
// go a couple of times round the loop
await httpBackend.flush("/_matrix", 1);
await awaitCallToMarkAsSent();
await httpBackend.flush("/_matrix", 1);
await awaitCallToMarkAsSent();
// a second sync while this is going on shouldn't make any difference
rustCrypto.onSyncCompleted({});
await httpBackend.flush("/_matrix", 1);
await awaitCallToMarkAsSent();
// now stop...
rustCrypto.stop();
// which should (eventually) cause the loop to stop with no further calls to outgoingRequests
olmMachine.outgoingRequests.mockReset();
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
expect(rustCrypto["outgoingRequestLoopRunning"]).toBeFalsy();
httpBackend.verifyNoOutstandingRequests();
expect(olmMachine.outgoingRequests).not.toHaveBeenCalled();
// we sent three, so there should be 2 left
expect(outgoingRequestQueue.length).toEqual(2);
});
});
});
+65
View File
@@ -0,0 +1,65 @@
/*
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 { MatrixEvent, MemoryStore } from "../../../src";
describe("MemoryStore", () => {
const event1 = new MatrixEvent({ type: "event1-type", content: { test: 1 } });
const event2 = new MatrixEvent({ type: "event2-type", content: { test: 1 } });
const event3 = new MatrixEvent({ type: "event3-type", content: { test: 1 } });
const event4 = new MatrixEvent({ type: "event4-type", content: { test: 1 } });
const event4Updated = new MatrixEvent({ type: "event4-type", content: { test: 2 } });
const event1Empty = new MatrixEvent({ type: "event1-type", content: {} });
describe("account data", () => {
it("sets account data events correctly", () => {
const store = new MemoryStore();
store.storeAccountDataEvents([event1, event2]);
expect(store.getAccountData(event1.getType())).toEqual(event1);
expect(store.getAccountData(event2.getType())).toEqual(event2);
});
it("returns undefined when no account data event exists for type", () => {
const store = new MemoryStore();
expect(store.getAccountData("my-event-type")).toEqual(undefined);
});
it("updates account data events correctly", () => {
const store = new MemoryStore();
// init store with event1, event2
store.storeAccountDataEvents([event1, event2, event4]);
// remove event1, add event3
store.storeAccountDataEvents([event1Empty, event3, event4Updated]);
// removed
expect(store.getAccountData(event1.getType())).toEqual(undefined);
// not removed
expect(store.getAccountData(event2.getType())).toEqual(event2);
// added
expect(store.getAccountData(event3.getType())).toEqual(event3);
// updated
expect(store.getAccountData(event4.getType())).toEqual(event4Updated);
});
it("removes all account data from state on deleteAllData", async () => {
const store = new MemoryStore();
store.storeAccountDataEvents([event1, event2]);
await store.deleteAllData();
// empty object
expect(store.accountData).toEqual({});
});
});
});
+113 -20
View File
@@ -142,6 +142,15 @@ describe("Group Call", function () {
} as unknown as RoomMember;
});
it.each(Object.values(GroupCallState).filter((v) => v !== GroupCallState.LocalCallFeedUninitialized))(
"throws when initializing local call feed in %s state",
async (state: GroupCallState) => {
// @ts-ignore
groupCall.state = state;
await expect(groupCall.initLocalCallFeed()).rejects.toThrowError();
},
);
it("does not initialize local call feed, if it already is", async () => {
await groupCall.initLocalCallFeed();
jest.spyOn(groupCall, "initLocalCallFeed");
@@ -308,6 +317,17 @@ describe("Group Call", function () {
}
});
describe("hasLocalParticipant()", () => {
it("should return false, if we don't have a local participant", () => {
expect(groupCall.hasLocalParticipant()).toBeFalsy();
});
it("should return true, if we do have local participant", async () => {
await groupCall.enter();
expect(groupCall.hasLocalParticipant()).toBeTruthy();
});
});
describe("call feeds changing", () => {
let call: MockMatrixCall;
const currentFeed = new MockCallFeed(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, new MockMediaStream("current"));
@@ -475,7 +495,7 @@ describe("Group Call", function () {
const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId);
// @ts-ignore
groupCall.calls.set(
mockCall.getOpponentMember() as RoomMember,
mockCall.getOpponentMember().userId!,
new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]),
);
@@ -501,7 +521,7 @@ describe("Group Call", function () {
const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId);
// @ts-ignore
groupCall.calls.set(
mockCall.getOpponentMember() as RoomMember,
mockCall.getOpponentMember().userId!,
new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]),
);
@@ -663,9 +683,7 @@ describe("Group Call", function () {
expect(client1.sendToDevice).toHaveBeenCalled();
// @ts-ignore
const oldCall = groupCall1.calls
.get(groupCall1.room.getMember(client2.userId)!)!
.get(client2.deviceId)!;
const oldCall = groupCall1.calls.get(client2.userId)!.get(client2.deviceId)!;
oldCall.emit(CallEvent.Hangup, oldCall!);
client1.sendToDevice.mockClear();
@@ -685,9 +703,7 @@ describe("Group Call", function () {
let newCall: MatrixCall | undefined;
while (
// @ts-ignore
(newCall = groupCall1.calls
.get(groupCall1.room.getMember(client2.userId)!)
?.get(client2.deviceId)) === undefined ||
(newCall = groupCall1.calls.get(client2.userId)?.get(client2.deviceId)) === undefined ||
newCall.peerConn === undefined ||
newCall.callId == oldCall.callId
) {
@@ -730,7 +746,7 @@ describe("Group Call", function () {
groupCall1.setLocalVideoMuted(false);
// @ts-ignore
const call = groupCall1.calls.get(groupCall1.room.getMember(client2.userId)!)!.get(client2.deviceId)!;
const call = groupCall1.calls.get(client2.userId)!.get(client2.deviceId)!;
call.isMicrophoneMuted = jest.fn().mockReturnValue(true);
call.setMicrophoneMuted = jest.fn();
call.isLocalVideoMuted = jest.fn().mockReturnValue(true);
@@ -794,7 +810,10 @@ describe("Group Call", function () {
it("should mute local video when calling setLocalVideoMuted()", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
groupCall.localCallFeed!.setAudioVideoMuted = jest.fn();
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream");
jest.spyOn(groupCall, "updateLocalUsermediaStream");
jest.spyOn(groupCall.localCallFeed!, "setAudioVideoMuted");
const setAVMutedArray: ((audioMuted: boolean | null, videoMuted: boolean | null) => void)[] = [];
const tracksArray: MediaStreamTrack[] = [];
const sendMetadataUpdateArray: (() => Promise<void>)[] = [];
@@ -808,7 +827,8 @@ describe("Group Call", function () {
await groupCall.setLocalVideoMuted(true);
groupCall.localCallFeed!.stream.getVideoTracks().forEach((track) => expect(track.enabled).toBe(false));
expect(groupCall.localCallFeed!.setAudioVideoMuted).toHaveBeenCalledWith(null, true);
expect(mockClient.getMediaHandler().getUserMediaStream).toHaveBeenCalledWith(true, false);
expect(groupCall.updateLocalUsermediaStream).toHaveBeenCalled();
setAVMutedArray.forEach((f) => expect(f).toHaveBeenCalledWith(null, true));
tracksArray.forEach((track) => expect(track.enabled).toBe(false));
sendMetadataUpdateArray.forEach((f) => expect(f).toHaveBeenCalled());
@@ -839,7 +859,7 @@ describe("Group Call", function () {
await sleep(10);
// @ts-ignore
const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!;
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
// @ts-ignore Mock
call.pushRemoteFeed(
@@ -866,7 +886,7 @@ describe("Group Call", function () {
await sleep(10);
// @ts-ignore
const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!;
const call = groupCall.calls.get(FAKE_USER_ID_2).get(FAKE_DEVICE_ID_2)!;
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
// @ts-ignore Mock
call.pushRemoteFeed(
@@ -943,9 +963,7 @@ describe("Group Call", function () {
expect(mockCall.reject).not.toHaveBeenCalled();
expect(mockCall.answerWithCallFeeds).toHaveBeenCalled();
// @ts-ignore
expect(groupCall.calls).toEqual(
new Map([[groupCall.room.getMember(FAKE_USER_ID_1)!, new Map([[FAKE_DEVICE_ID_1, mockCall]])]]),
);
expect(groupCall.calls).toEqual(new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_1, mockCall]])]]));
});
it("replaces calls if it already has one with the same user", async () => {
@@ -960,9 +978,7 @@ describe("Group Call", function () {
expect(oldMockCall.hangup).toHaveBeenCalled();
expect(newMockCall.answerWithCallFeeds).toHaveBeenCalled();
// @ts-ignore
expect(groupCall.calls).toEqual(
new Map([[groupCall.room.getMember(FAKE_USER_ID_1)!, new Map([[FAKE_DEVICE_ID_1, newMockCall]])]]),
);
expect(groupCall.calls).toEqual(new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_1, newMockCall]])]]));
});
it("starts to process incoming calls when we've entered", async () => {
@@ -975,6 +991,83 @@ describe("Group Call", function () {
expect(call.answerWithCallFeeds).toHaveBeenCalled();
});
describe("handles call being replaced", () => {
let callChangedListener: jest.Mock;
let oldMockCall: MockMatrixCall;
let newMockCall: MockMatrixCall;
let newCallsMap: Map<string, Map<string, MatrixCall>>;
beforeEach(() => {
callChangedListener = jest.fn();
groupCall.addListener(GroupCallEvent.CallsChanged, callChangedListener);
oldMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
newMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
newCallsMap = new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_1, newMockCall.typed()]])]]);
newMockCall.opponentMember = oldMockCall.opponentMember; // Ensure referential equality
newMockCall.callId = "not " + oldMockCall.callId;
mockClient.emit(CallEventHandlerEvent.Incoming, oldMockCall.typed());
});
it("handles regular case", () => {
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed());
expect(oldMockCall.hangup).toHaveBeenCalled();
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);
// @ts-ignore
expect(groupCall.calls).toEqual(newCallsMap);
});
it("handles case where call is missing from the calls map", () => {
// @ts-ignore
groupCall.calls = new Map();
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed());
expect(oldMockCall.hangup).toHaveBeenCalled();
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);
// @ts-ignore
expect(groupCall.calls).toEqual(newCallsMap);
});
});
describe("handles call being hangup", () => {
let callChangedListener: jest.Mock;
let mockCall: MockMatrixCall;
beforeEach(() => {
callChangedListener = jest.fn();
groupCall.addListener(GroupCallEvent.CallsChanged, callChangedListener);
mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
});
it("doesn't throw when calls map is empty", () => {
// @ts-ignore
expect(() => groupCall.onCallHangup(mockCall)).not.toThrow();
});
it("clears map completely when we're the last users device left", () => {
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall.typed());
mockCall.emit(CallEvent.Hangup, mockCall.typed());
// @ts-ignore
expect(groupCall.calls).toEqual(new Map());
});
it("doesn't remove another call of the same user", () => {
const anotherCallOfTheSameUser = new MockMatrixCall(room.roomId, groupCall.groupCallId);
anotherCallOfTheSameUser.callId = "another call id";
anotherCallOfTheSameUser.getOpponentDeviceId = () => FAKE_DEVICE_ID_2;
mockClient.emit(CallEventHandlerEvent.Incoming, anotherCallOfTheSameUser.typed());
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall.typed());
mockCall.emit(CallEvent.Hangup, mockCall.typed());
// @ts-ignore
expect(groupCall.calls).toEqual(
new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_2, anotherCallOfTheSameUser.typed()]])]]),
);
});
});
});
describe("screensharing", () => {
@@ -1039,7 +1132,7 @@ describe("Group Call", function () {
await sleep(10);
// @ts-ignore
const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!;
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
call.onNegotiateReceived({
getContent: () => ({
+8 -10
View File
@@ -308,20 +308,18 @@ describe("Media Handler", function () {
expect(stream2.isCloneOf(stream1)).toEqual(false);
});
it("strips unwanted audio tracks from re-used stream", async () => {
const stream1 = await mediaHandler.getUserMediaStream(true, true);
const stream2 = (await mediaHandler.getUserMediaStream(false, true)) as unknown as MockMediaStream;
it("creates new stream when we no longer want audio", async () => {
await mediaHandler.getUserMediaStream(true, true);
const stream = await mediaHandler.getUserMediaStream(false, true);
expect(stream2.isCloneOf(stream1)).toEqual(true);
expect(stream2.getAudioTracks().length).toEqual(0);
expect(stream.getAudioTracks().length).toEqual(0);
});
it("strips unwanted video tracks from re-used stream", async () => {
const stream1 = await mediaHandler.getUserMediaStream(true, true);
const stream2 = (await mediaHandler.getUserMediaStream(true, false)) as unknown as MockMediaStream;
it("creates new stream when we no longer want video", async () => {
await mediaHandler.getUserMediaStream(true, true);
const stream = await mediaHandler.getUserMediaStream(true, false);
expect(stream2.isCloneOf(stream1)).toEqual(true);
expect(stream2.getVideoTracks().length).toEqual(0);
expect(stream.getVideoTracks().length).toEqual(0);
});
});
+26
View File
@@ -44,3 +44,29 @@ export interface IEventDecryptionResult {
claimedEd25519Key?: string;
untrusted?: boolean;
}
interface Extensible {
[key: string]: any;
}
/* eslint-disable camelcase */
/** The result of a call to {@link MatrixClient.exportRoomKeys} */
export interface IMegolmSessionData extends Extensible {
/** Sender's Curve25519 device key */
sender_key: string;
/** Devices which forwarded this session to us (normally empty). */
forwarding_curve25519_key_chain: string[];
/** Other keys the sender claims. */
sender_claimed_keys: Record<string, string>;
/** Room this session is used in */
room_id: string;
/** Unique id for the session */
session_id: string;
/** Base64'ed key data */
session_key: string;
algorithm?: string;
untrusted?: boolean;
}
/* eslint-enable camelcase */
+11 -1
View File
@@ -61,11 +61,12 @@ export enum EventType {
KeyVerificationDone = "m.key.verification.done",
KeyVerificationKey = "m.key.verification.key",
KeyVerificationAccept = "m.key.verification.accept",
// XXX this event is not yet supported by js-sdk
// Not used directly - see READY_TYPE in VerificationRequest.
KeyVerificationReady = "m.key.verification.ready",
// use of this is discouraged https://matrix.org/docs/spec/client_server/r0.6.1#m-room-message-feedback
RoomMessageFeedback = "m.room.message.feedback",
Reaction = "m.reaction",
PollStart = "org.matrix.msc3381.poll.start",
// Room ephemeral events
Typing = "m.typing",
@@ -165,6 +166,15 @@ export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix
*/
export const UNSTABLE_MSC2716_MARKER = new UnstableValue("m.room.marker", "org.matrix.msc2716.marker");
/**
* Name of the "with_relations" request property for relation based redactions.
* {@link https://github.com/matrix-org/matrix-spec-proposals/pull/3912}
*/
export const MSC3912_RELATION_BASED_REDACTIONS_PROP = new UnstableValue(
"with_relations",
"org.matrix.msc3912.with_relations",
);
/**
* Functional members type for declaring a purpose of room members (e.g. helpful bots).
* Note that this reference is UNSTABLE and subject to breaking changes, including its
+8
View File
@@ -54,3 +54,11 @@ export type Receipts = {
[userId: string]: [WrappedReceipt | null, WrappedReceipt | null]; // Pair<real receipt, synthetic receipt> (both nullable)
};
};
export type CachedReceiptStructure = {
eventId: string;
receiptType: string | ReceiptType;
userId: string;
receipt: Receipt;
synthetic: boolean;
};
+13 -1
View File
@@ -21,7 +21,7 @@ import { IRoomEventFilter } from "../filter";
import { Direction } from "../models/event-timeline";
import { PushRuleAction } from "./PushRules";
import { IRoomEvent } from "../sync-accumulator";
import { EventType, RoomType } from "./event";
import { EventType, RelationType, RoomType } from "./event";
// allow camelcase as these are things that go onto the wire
/* eslint-disable camelcase */
@@ -47,6 +47,18 @@ export interface IJoinRoomOpts {
export interface IRedactOpts {
reason?: string;
/**
* Whether events related to the redacted event should be redacted.
*
* If specified, then any events which relate to the event being redacted with
* any of the relationship types listed will also be redacted.
*
* <b>Raises an Error if the server does not support it.</b>
* Check for server-side support before using this param with
* <code>client.canSupport.get(Feature.RelationBasedRedactions)</code>.
* {@link https://github.com/matrix-org/matrix-spec-proposals/pull/3912}
*/
with_relations?: Array<RelationType | string>;
}
export interface ISendEventResponse {
+16 -1
View File
@@ -16,9 +16,11 @@ limitations under the License.
import { ToDeviceMessageId } from "./@types/event";
import { logger } from "./logger";
import { MatrixError, MatrixClient } from "./matrix";
import { MatrixClient, ClientEvent } from "./client";
import { MatrixError } from "./http-api";
import { IndexedToDeviceBatch, ToDeviceBatch, ToDeviceBatchWithTxnId, ToDevicePayload } from "./models/ToDeviceMessage";
import { MatrixScheduler } from "./scheduler";
import { SyncState } from "./sync";
const MAX_BATCH_SIZE = 20;
@@ -37,12 +39,14 @@ export class ToDeviceMessageQueue {
public start(): void {
this.running = true;
this.sendQueue();
this.client.on(ClientEvent.Sync, this.onResumedSync);
}
public stop(): void {
this.running = false;
if (this.retryTimeout !== null) clearTimeout(this.retryTimeout);
this.retryTimeout = null;
this.client.removeListener(ClientEvent.Sync, this.onResumedSync);
}
public async queueBatch(batch: ToDeviceBatch): Promise<void> {
@@ -132,4 +136,15 @@ export class ToDeviceMessageQueue {
await this.client.sendToDevice(batch.eventType, contentMap, batch.txnId);
}
/**
* Listen to sync state changes and automatically resend any pending events
* once syncing is resumed
*/
private onResumedSync = (state: SyncState | null, oldState: SyncState | null): void => {
if (state === SyncState.Syncing && oldState !== SyncState.Syncing) {
logger.info(`Resuming queue after resumed sync`);
this.sendQueue();
}
};
}
+2 -2
View File
@@ -214,9 +214,9 @@ export class AutoDiscovery {
// Step 5b: Verify there is an identity server listening on the provided
// URL.
const isResponse = await this.fetchWellKnownObject(`${isUrl}/_matrix/identity/api/v1`);
const isResponse = await this.fetchWellKnownObject(`${isUrl}/_matrix/identity/v2`);
if (!isResponse?.raw || isResponse.action !== AutoDiscoveryAction.SUCCESS) {
logger.error("Invalid /api/v1 response");
logger.error("Invalid /v2 response");
failingClientConfig["m.identity_server"].error = AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER;
// Supply the base_url to the caller because they may be ignoring
+220 -96
View File
@@ -18,9 +18,10 @@ limitations under the License.
* This is an internal module. See {@link MatrixClient} for the public class.
*/
import { EmoteEvent, IPartialEvent, MessageEvent, NoticeEvent, Optional } from "matrix-events-sdk";
import { Optional } from "matrix-events-sdk";
import { ISyncStateData, SyncApi, SyncState } from "./sync";
import type { IMegolmSessionData } from "./@types/crypto";
import { ISyncStateData, SyncApi, SyncApiOptions, SyncState } from "./sync";
import {
EventStatus,
IContent,
@@ -74,7 +75,6 @@ import {
ICryptoCallbacks,
IBootstrapCrossSigningOpts,
ICheckOwnCrossSigningTrustOpts,
IMegolmSessionData,
isCryptoAvailable,
VerificationMethod,
IRoomKeyRequestBody,
@@ -154,6 +154,7 @@ import {
UNSTABLE_MSC3088_ENABLED,
UNSTABLE_MSC3088_PURPOSE,
UNSTABLE_MSC3089_TREE_SUBTYPE,
MSC3912_RELATION_BASED_REDACTIONS_PROP,
} from "./@types/event";
import { IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials";
import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper";
@@ -211,6 +212,7 @@ import { LocalNotificationSettings } from "./@types/local_notifications";
import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync";
import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature";
import { CryptoBackend } from "./common-crypto/CryptoBackend";
import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants";
export type Store = IStore;
@@ -454,17 +456,7 @@ export interface IStartClientOpts {
slidingSync?: SlidingSync;
}
export interface IStoredClientOpts extends IStartClientOpts {
// Crypto manager
crypto?: Crypto;
/**
* A function which is called
* with a room ID and returns a boolean. It should return 'true' if the SDK can
* SAFELY remove events from this room. It may not be safe to remove events if
* there are other references to the timelines for this room.
*/
canResetEntireTimeline: ResetTimelineCallback;
}
export interface IStoredClientOpts extends IStartClientOpts {}
export enum RoomVersionStability {
Stable = "stable",
@@ -840,6 +832,11 @@ interface ITimestampToEventResponse {
event_id: string;
origin_server_ts: string;
}
interface IWhoamiResponse {
user_id: string;
device_id?: string;
}
/* eslint-enable camelcase */
// We're using this constant for methods overloading and inspect whether a variable
@@ -1426,20 +1423,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
logger.error("Can't fetch server versions, continuing to initialise sync, this will be retried later", e);
}
// shallow-copy the opts dict before modifying and storing it
this.clientOpts = Object.assign({}, opts) as IStoredClientOpts;
this.clientOpts.crypto = this.crypto;
this.clientOpts.canResetEntireTimeline = (roomId): boolean => {
if (!this.canResetTimelineCallback) {
return false;
}
return this.canResetTimelineCallback(roomId);
};
this.clientOpts = opts ?? {};
if (this.clientOpts.slidingSync) {
this.syncApi = new SlidingSyncSdk(this.clientOpts.slidingSync, this, this.clientOpts);
this.syncApi = new SlidingSyncSdk(
this.clientOpts.slidingSync,
this,
this.clientOpts,
this.buildSyncApiOptions(),
);
} else {
this.syncApi = new SyncApi(this, this.clientOpts);
this.syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
}
this.syncApi.sync();
if (this.clientOpts.clientWellKnownPollPeriod !== undefined) {
@@ -1452,6 +1447,22 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.toDeviceMessageQueue.start();
}
/**
* Construct a SyncApiOptions for this client, suitable for passing into the SyncApi constructor
*/
protected buildSyncApiOptions(): SyncApiOptions {
return {
crypto: this.crypto,
cryptoCallbacks: this.cryptoBackend,
canResetEntireTimeline: (roomId: string): boolean => {
if (!this.canResetTimelineCallback) {
return false;
}
return this.canResetTimelineCallback(roomId);
},
};
}
/**
* High level helper method to stop the client from polling and allow a
* clean shutdown.
@@ -1657,6 +1668,41 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (this.cryptoStore) {
promises.push(this.cryptoStore.deleteAllData());
}
// delete the stores used by the rust matrix-sdk-crypto, in case they were used
const deleteRustSdkStore = async (): Promise<void> => {
let indexedDB: IDBFactory;
try {
indexedDB = global.indexedDB;
} catch (e) {
// No indexeddb support
return;
}
for (const dbname of [
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`,
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
]) {
const prom = new Promise((resolve, reject) => {
logger.info(`Removing IndexedDB instance ${dbname}`);
const req = indexedDB.deleteDatabase(dbname);
req.onsuccess = (_): void => {
logger.info(`Removed IndexedDB instance ${dbname}`);
resolve(0);
};
req.onerror = (e): void => {
logger.error(`Failed to remove IndexedDB instance ${dbname}: ${e}`);
reject(new Error(`Error clearing storage: ${e}`));
};
req.onblocked = (e): void => {
logger.info(`cannot yet remove IndexedDB instance ${dbname}`);
//reject(new Error(`Error clearing storage: ${e}`));
};
});
await prom;
}
};
promises.push(deleteRustSdkStore());
return Promise.all(promises).then(); // .then to fix types
}
@@ -1672,6 +1718,20 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return null;
}
/**
* Get the user-id of the logged-in user
*
* @returns MXID for the logged-in user
* @throws Error if not logged in
*/
public getSafeUserId(): string {
const userId = this.getUserId();
if (!userId) {
throw new Error("Expected logged in user but found none.");
}
return userId;
}
/**
* Get the domain for this client's MXID
* @returns Domain of this MXID
@@ -2051,6 +2111,46 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
});
}
/**
* Initialise support for end-to-end encryption in this client, using the rust matrix-sdk-crypto.
*
* An alternative to {@link initCrypto}.
*
* *WARNING*: this API is very experimental, should not be used in production, and may change without notice!
* Eventually it will be deprecated and `initCrypto` will do the same thing.
*
* @experimental
*
* @returns a Promise which will resolve when the crypto layer has been
* successfully initialised.
*/
public async initRustCrypto(): Promise<void> {
if (this.cryptoBackend) {
logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
return;
}
const userId = this.getUserId();
if (userId === null) {
throw new Error(
`Cannot enable encryption on MatrixClient with unknown userId: ` +
`ensure userId is passed in createClient().`,
);
}
const deviceId = this.getDeviceId();
if (deviceId === null) {
throw new Error(
`Cannot enable encryption on MatrixClient with unknown deviceId: ` +
`ensure deviceId is passed in createClient().`,
);
}
// importing rust-crypto will download the webassembly, so we delay it until we know it will be
// needed.
const RustCrypto = await import("./rust-crypto");
this.cryptoBackend = await RustCrypto.initRustCrypto(this.http, userId, deviceId);
}
/**
* Is end-to-end crypto enabled for this client.
* @returns True if end-to-end is enabled.
@@ -2943,10 +3043,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* session export objects
*/
public exportRoomKeys(): Promise<IMegolmSessionData[]> {
if (!this.crypto) {
if (!this.cryptoBackend) {
return Promise.reject(new Error("End-to-end encryption disabled"));
}
return this.crypto.exportRoomKeys();
return this.cryptoBackend.exportRoomKeys();
}
/**
@@ -3670,13 +3770,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const replacedRooms = new Set();
for (const r of allRooms) {
const createEvent = r.currentState.getStateEvents(EventType.RoomCreate, "");
// invites are included in this list and we don't know their create events yet
if (createEvent) {
const predecessor = createEvent.getContent()["predecessor"];
if (predecessor && predecessor["room_id"]) {
replacedRooms.add(predecessor["room_id"]);
}
const predecessor = r.findPredecessorRoomId();
if (predecessor) {
replacedRooms.add(predecessor);
}
}
@@ -3766,6 +3862,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
}
public async deleteAccountData(eventType: string): Promise<void> {
const msc3391DeleteAccountDataServerSupport = this.canSupport.get(Feature.AccountDataDeletion);
// if deletion is not supported overwrite with empty content
if (msc3391DeleteAccountDataServerSupport === ServerSupport.Unsupported) {
await this.setAccountData(eventType, {});
return;
}
const path = utils.encodeUri("/user/$userId/account_data/$type", {
$userId: this.getSafeUserId(),
$type: eventType,
});
const options =
msc3391DeleteAccountDataServerSupport === ServerSupport.Unstable
? { prefix: "/_matrix/client/unstable/org.matrix.msc3391" }
: undefined;
return await this.http.authedRequest(Method.Delete, path, undefined, undefined, options);
}
/**
* Gets the users that are ignored by this client
* @returns The array of users that are ignored (empty if none)
@@ -3840,7 +3954,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
const roomId = res.room_id;
const syncApi = new SyncApi(this, this.clientOpts);
const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
const room = syncApi.createRoom(roomId);
if (opts.syncRoom) {
// v2 will do this for us
@@ -4336,9 +4450,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @param txnId - transaction id. One will be made up if not supplied.
* @param opts - Options to pass on, may contain `reason`.
* @param opts - Options to pass on, may contain `reason` and `with_relations` (MSC3912)
* @returns Promise which resolves: TODO
* @returns Rejects: with an error response.
* @throws Error if called with `with_relations` (MSC3912) but the server does not support it.
* Callers should check whether the server supports MSC3912 via `MatrixClient.canSupport`.
*/
public redactEvent(
roomId: string,
@@ -4367,12 +4483,34 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
threadId = null;
}
const reason = opts?.reason;
if (
opts?.with_relations &&
this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Unsupported
) {
throw new Error(
"Server does not support relation based redactions " +
`roomId ${roomId} eventId ${eventId} txnId: ${txnId} threadId ${threadId}`,
);
}
const withRelations = opts?.with_relations
? {
[this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Stable
? MSC3912_RELATION_BASED_REDACTIONS_PROP.stable!
: MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!]: opts?.with_relations,
}
: {};
return this.sendCompleteEvent(
roomId,
threadId,
{
type: EventType.RoomRedaction,
content: { reason },
content: {
...withRelations,
reason,
},
redacts: eventId,
},
txnId as string,
@@ -4403,44 +4541,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
threadId = null;
}
// Populate all outbound events with Extensible Events metadata to ensure there's a
// reasonably large pool of messages to parse.
let eventType: string = EventType.RoomMessage;
let sendContent: IContent = content as IContent;
const makeContentExtensible = (content: IContent = {}, recurse = true): IPartialEvent<object> | undefined => {
let newEvent: IPartialEvent<IContent> | undefined;
if (content["msgtype"] === MsgType.Text) {
newEvent = MessageEvent.from(content["body"], content["formatted_body"]).serialize();
} else if (content["msgtype"] === MsgType.Emote) {
newEvent = EmoteEvent.from(content["body"], content["formatted_body"]).serialize();
} else if (content["msgtype"] === MsgType.Notice) {
newEvent = NoticeEvent.from(content["body"], content["formatted_body"]).serialize();
}
if (newEvent && content["m.new_content"] && recurse) {
const newContent = makeContentExtensible(content["m.new_content"], false);
if (newContent) {
newEvent.content["m.new_content"] = newContent.content;
}
}
if (newEvent) {
// copy over all other fields we don't know about
for (const [k, v] of Object.entries(content)) {
if (!newEvent.content.hasOwnProperty(k)) {
newEvent.content[k] = v;
}
}
}
return newEvent;
};
const result = makeContentExtensible(sendContent);
if (result) {
eventType = result.type;
sendContent = result.content;
}
const eventType: string = EventType.RoomMessage;
const sendContent: IContent = content as IContent;
return this.sendEvent(roomId, threadId as string | null, eventType, sendContent, txnId);
}
@@ -6016,7 +6118,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public peekInRoom(roomId: string): Promise<Room> {
this.peekSync?.stopPeeking();
this.peekSync = new SyncApi(this, this.clientOpts);
this.peekSync = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
return this.peekSync.peek(roomId);
}
@@ -6523,7 +6625,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (this.syncLeftRoomsPromise) {
return this.syncLeftRoomsPromise; // return the ongoing request
}
const syncApi = new SyncApi(this, this.clientOpts);
const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
this.syncLeftRoomsPromise = syncApi.syncLeftRooms();
// cleanup locks
@@ -9242,36 +9344,58 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* Fetches the user_id of the configured access token.
* Fetches information about the user for the configured access token.
*/
public async whoami(): Promise<{ user_id: string }> {
// eslint-disable-line camelcase
public async whoami(): Promise<IWhoamiResponse> {
return this.http.authedRequest(Method.Get, "/account/whoami");
}
/**
* Find the event_id closest to the given timestamp in the given direction.
* @returns A promise of an object containing the event_id and
* origin_server_ts of the closest event to the timestamp in the given
* direction
* @returns Resolves: A promise of an object containing the event_id and
* origin_server_ts of the closest event to the timestamp in the given direction
* @returns Rejects: when the request fails (module:http-api.MatrixError)
*/
public timestampToEvent(roomId: string, timestamp: number, dir: Direction): Promise<ITimestampToEventResponse> {
public async timestampToEvent(
roomId: string,
timestamp: number,
dir: Direction,
): Promise<ITimestampToEventResponse> {
const path = utils.encodeUri("/rooms/$roomId/timestamp_to_event", {
$roomId: roomId,
});
const queryParams = {
ts: timestamp.toString(),
dir: dir,
};
return this.http.authedRequest(
Method.Get,
path,
{
ts: timestamp.toString(),
dir: dir,
},
undefined,
{
prefix: "/_matrix/client/unstable/org.matrix.msc3030",
},
);
try {
return await this.http.authedRequest(Method.Get, path, queryParams, undefined, {
prefix: ClientPrefix.V1,
});
} catch (err) {
// Fallback to the prefixed unstable endpoint. Since the stable endpoint is
// new, we should also try the unstable endpoint before giving up. We can
// remove this fallback request in a year (remove after 2023-11-28).
if (
(<MatrixError>err).errcode === "M_UNRECOGNIZED" &&
// XXX: The 400 status code check should be removed in the future
// when Synapse is compliant with MSC3743.
((<MatrixError>err).httpStatus === 400 ||
// This the correct standard status code for an unsupported
// endpoint according to MSC3743. Not Found and Method Not Allowed
// both indicate that this endpoint+verb combination is
// not supported.
(<MatrixError>err).httpStatus === 404 ||
(<MatrixError>err).httpStatus === 405)
) {
return await this.http.authedRequest(Method.Get, path, queryParams, undefined, {
prefix: "/_matrix/client/unstable/org.matrix.msc3030",
});
}
throw err;
}
}
}
+51 -2
View File
@@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { IEventDecryptionResult } from "../@types/crypto";
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
import type { IToDeviceEvent } from "../sync-accumulator";
import { MatrixEvent } from "../models/event";
/**
* Common interface for the crypto implementations
*/
export interface CryptoBackend {
export interface CryptoBackend extends SyncCryptoCallbacks {
/**
* Global override for whether the client should ever send encrypted
* messages to unverified devices. This provides the default for rooms which
@@ -60,4 +61,52 @@ export interface CryptoBackend {
* Rejects with an error if there is a problem decrypting the event.
*/
decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult>;
/**
* Get a list containing all of the room keys
*
* This should be encrypted before returning it to the user.
*
* @returns a promise which resolves to a list of
* session export objects
*/
exportRoomKeys(): Promise<IMegolmSessionData[]>;
}
/** The methods which crypto implementations should expose to the Sync api */
export interface SyncCryptoCallbacks {
/**
* Called by the /sync loop whenever there are incoming to-device messages.
*
* The implementation may preprocess the received messages (eg, decrypt them) and return an
* updated list of messages for dispatch to the rest of the system.
*
* Note that, unlike {@link ClientEvent.ToDeviceEvent} events, this is called on the raw to-device
* messages, rather than the results of any decryption attempts.
*
* @param events - the received to-device messages
* @returns A list of preprocessed to-device messages.
*/
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
/**
* Called by the /sync loop after each /sync response is processed.
*
* Used to complete batch processing, or to initiate background processes
*
* @param syncState - information about the completed sync.
*/
onSyncCompleted(syncState: OnSyncCompletedData): void;
}
export interface OnSyncCompletedData {
/**
* The 'next_batch' result from /sync, which will become the 'since' token for the next call to /sync.
*/
nextSyncToken?: string;
/**
* True if we are working our way through a backlog of events after connecting.
*/
catchingUp?: boolean;
}
+1 -2
View File
@@ -21,8 +21,7 @@ import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
import * as algorithms from "./algorithms";
import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base";
import { IOlmDevice, IOutboundGroupSessionKey } from "./algorithms/megolm";
import { IMegolmSessionData } from "./index";
import { OlmGroupSessionExtraData } from "../@types/crypto";
import { IMegolmSessionData, OlmGroupSessionExtraData } from "../@types/crypto";
import { IMessage } from "./algorithms/olm";
// The maximum size of an event is 65K, and we base64 the content, so this is a
+2 -1
View File
@@ -18,11 +18,12 @@ limitations under the License.
* Internal module. Defines the base classes of the encryption implementations
*/
import type { IMegolmSessionData } from "../../@types/crypto";
import { MatrixClient } from "../../client";
import { Room } from "../../models/room";
import { OlmDevice } from "../OlmDevice";
import { IContent, MatrixEvent, RoomMember } from "../../matrix";
import { Crypto, IEncryptedContent, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "..";
import { Crypto, IEncryptedContent, IEventDecryptionResult, IncomingRoomKeyRequest } from "..";
import { DeviceInfo } from "../deviceinfo";
import { IRoomEncryption } from "../RoomList";
+2 -2
View File
@@ -20,7 +20,7 @@ limitations under the License.
import { v4 as uuidv4 } from "uuid";
import type { IEventDecryptionResult } from "../../@types/crypto";
import type { IEventDecryptionResult, IMegolmSessionData } from "../../@types/crypto";
import { logger } from "../../logger";
import * as olmlib from "../olmlib";
import {
@@ -39,7 +39,7 @@ import { IOlmSessionResult } from "../olmlib";
import { DeviceInfoMap } from "../DeviceList";
import { IContent, MatrixEvent } from "../../models/event";
import { EventType, MsgType, ToDeviceMessageId } from "../../@types/event";
import { IMegolmEncryptedContent, IMegolmSessionData, IncomingRoomKeyRequest, IEncryptedContent } from "../index";
import { IMegolmEncryptedContent, IncomingRoomKeyRequest, IEncryptedContent } from "../index";
import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager";
import { OlmGroupSessionExtraData } from "../../@types/crypto";
import { MatrixError } from "../../http-api";
+2 -1
View File
@@ -18,6 +18,7 @@ limitations under the License.
* Classes for dealing with key backup.
*/
import type { IMegolmSessionData } from "../@types/crypto";
import { MatrixClient } from "../client";
import { logger } from "../logger";
import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib";
@@ -36,7 +37,7 @@ import {
IKeyBackupSession,
} from "./keybackup";
import { UnstableValue } from "../NamespacedValue";
import { CryptoEvent, IMegolmSessionData } from "./index";
import { CryptoEvent } from "./index";
import { crypto } from "./crypto";
import { HTTPError, MatrixError } from "../http-api";
+51 -36
View File
@@ -20,7 +20,7 @@ limitations under the License.
import anotherjson from "another-json";
import { v4 as uuidv4 } from "uuid";
import type { IEventDecryptionResult } from "../@types/crypto";
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
import type { PkDecryption, PkSigning } from "@matrix-org/olm";
import { EventType, ToDeviceMessageId } from "../@types/event";
import { TypedReEmitter } from "../ReEmitter";
@@ -85,10 +85,11 @@ import { CryptoStore } from "./store/base";
import { IVerificationChannel } from "./verification/request/Channel";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { IContent } from "../models/event";
import { ISyncResponse } from "../sync-accumulator";
import { ISyncResponse, IToDeviceEvent } from "../sync-accumulator";
import { ISignatures } from "../@types/signed";
import { IMessage } from "./algorithms/olm";
import { CryptoBackend } from "../common-crypto/CryptoBackend";
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
import { RoomState, RoomStateEvent } from "../models/room-state";
const DeviceVerification = DeviceInfo.DeviceVerification;
@@ -171,26 +172,6 @@ export interface IRoomKeyRequestBody extends IRoomKey {
sender_key: string;
}
interface Extensible {
[key: string]: any;
}
export interface IMegolmSessionData extends Extensible {
// Sender's Curve25519 device key
sender_key: string;
// Devices which forwarded this session to us (normally empty).
forwarding_curve25519_key_chain: string[];
// Other keys the sender claims.
sender_claimed_keys: Record<string, string>;
// Room this session is used in
room_id: string;
// Unique id for the session
session_id: string;
// Base64'ed key data
session_key: string;
algorithm?: string;
untrusted?: boolean;
}
/* eslint-enable camelcase */
interface IDeviceVerificationUpgrade {
@@ -2244,9 +2225,6 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
await upload({ shouldEmit: true });
// XXX: we'll need to wait for the device list to be updated
}
// redo key requests after verification
this.cancelAndResendAllOutgoingKeyRequests();
}
const deviceObj = DeviceInfo.fromStorage(dev, deviceId);
@@ -2626,14 +2604,23 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
await storeConfigPromise;
}
if (!this.lazyLoadMembers) {
logger.log(
"Enabling encryption in " + roomId + "; " + "starting to track device lists for all users therein",
);
logger.log(`Enabling encryption in ${roomId}`);
// we don't want to force a download of the full membership list of this room, but as soon as we have that
// list we can start tracking the device list.
if (room.membersLoaded()) {
await this.trackRoomDevicesImpl(room);
} else {
logger.log("Enabling encryption in " + roomId);
// wait for the membership list to be loaded
const onState = (_state: RoomState): void => {
room.off(RoomStateEvent.Update, onState);
if (room.membersLoaded()) {
this.trackRoomDevicesImpl(room).catch((e) => {
logger.error(`Error enabling device tracking in ${roomId}`, e);
});
}
};
room.on(RoomStateEvent.Update, onState);
}
}
@@ -2642,6 +2629,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*
* @param roomId - The room ID to start tracking devices in.
* @returns when all devices for the room have been fetched and marked to track
* @deprecated there's normally no need to call this function: device list tracking
* will be enabled as soon as we have the full membership list.
*/
public trackRoomDevices(roomId: string): Promise<void> {
const room = this.clientStore.getRoom(roomId);
@@ -2886,11 +2875,22 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*/
public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
if (event.isRedacted()) {
// Try to decrypt the redaction event, to support encrypted
// redaction reasons. If we can't decrypt, just fall back to using
// the original redacted_because.
const redactionEvent = new MatrixEvent({
room_id: event.getRoomId(),
...event.getUnsigned().redacted_because,
});
const decryptedEvent = await this.decryptEvent(redactionEvent);
let redactedBecause: IEvent = event.getUnsigned().redacted_because!;
if (redactionEvent.isEncrypted()) {
try {
const decryptedEvent = await this.decryptEvent(redactionEvent);
redactedBecause = decryptedEvent.clearEvent as IEvent;
} catch (e) {
logger.warn("Decryption of redaction failed. Falling back to unencrypted event.", e);
}
}
return {
clearEvent: {
@@ -2898,7 +2898,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
type: "m.room.message",
content: {},
unsigned: {
redacted_because: decryptedEvent.clearEvent as IEvent,
redacted_because: redactedBecause,
},
},
};
@@ -3021,7 +3021,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*
* @param syncData - the data from the 'MatrixClient.sync' event
*/
public async onSyncCompleted(syncData: ISyncStateData): Promise<void> {
public async onSyncCompleted(syncData: OnSyncCompletedData): Promise<void> {
this.deviceList.setSyncToken(syncData.nextSyncToken ?? null);
this.deviceList.saveIfDirty();
@@ -3195,6 +3195,21 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
};
public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> {
// all we do here is filter out encrypted to-device messages with the wrong algorithm. Decryption
// happens later in decryptEvent, via the EventMapper
return events.filter((toDevice) => {
if (
toDevice.type === EventType.RoomMessageEncrypted &&
!["m.olm.v1.curve25519-aes-sha2"].includes(toDevice.content?.algorithm)
) {
logger.log("Ignoring invalid encrypted to-device event from " + toDevice.sender);
return false;
}
return true;
});
}
private onToDeviceEvent = (event: MatrixEvent): void => {
try {
logger.log(
@@ -3894,5 +3909,5 @@ class IncomingRoomKeyRequestCancellation {
}
}
// IEventDecryptionResult is re-exported for backwards compatibility, in case any applications are referencing it.
export type { IEventDecryptionResult } from "../@types/crypto";
// a number of types are re-exported for backwards compatibility, in case any applications are referencing it.
export type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
+2 -2
View File
@@ -167,9 +167,9 @@ export class RoomWidgetClient extends MatrixClient {
// still has some valuable helper methods that we make use of, so we
// instantiate it anyways
if (opts.slidingSync) {
this.syncApi = new SlidingSyncSdk(opts.slidingSync, this, opts);
this.syncApi = new SlidingSyncSdk(opts.slidingSync, this, opts, this.buildSyncApiOptions());
} else {
this.syncApi = new SyncApi(this, opts);
this.syncApi = new SyncApi(this, opts, this.buildSyncApiOptions());
}
this.room = this.syncApi.createRoom(this.roomId);
+3
View File
@@ -60,6 +60,9 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event
event.setThread(thread);
}
// TODO: once we get rid of the old libolm-backed crypto, we can restrict this to room events (rather than
// to-device events), because the rust implementation decrypts to-device messages at a higher level.
// Generally we probably want to use a different eventMapper implementation for to-device events because
if (event.isEncrypted()) {
if (!preventReEmit) {
client.reEmitter.reEmit(event, [MatrixEventEvent.Decrypted]);
+8
View File
@@ -26,6 +26,8 @@ export enum Feature {
Thread = "Thread",
ThreadUnreadNotifications = "ThreadUnreadNotifications",
LoginTokenRequest = "LoginTokenRequest",
RelationBasedRedactions = "RelationBasedRedactions",
AccountDataDeletion = "AccountDataDeletion",
}
type FeatureSupportCondition = {
@@ -45,6 +47,12 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
[Feature.LoginTokenRequest]: {
unstablePrefixes: ["org.matrix.msc3882"],
},
[Feature.RelationBasedRedactions]: {
unstablePrefixes: ["org.matrix.msc3912"],
},
[Feature.AccountDataDeletion]: {
unstablePrefixes: ["org.matrix.msc3391"],
},
};
export async function buildFeatureSupportMap(versions: IServerVersions): Promise<Map<Feature, ServerSupport>> {
-5
View File
@@ -34,11 +34,6 @@ export enum ClientPrefix {
}
export enum IdentityPrefix {
/**
* URI path for v1 of the identity API
* @deprecated Use v2.
*/
V1 = "/_matrix/identity/api/v1",
/**
* URI path for the v2 identity API
*/
+4 -17
View File
@@ -828,17 +828,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
}
}
} catch (e) {
if ((<Error>e).name !== "DecryptionError") {
// not a decryption error: log the whole exception as an error
// (and don't bother with a retry)
const re = options.isRetry ? "re" : "";
// For find results: this can produce "Error decrypting event (id=$ev)" and
// "Error redecrypting event (id=$ev)".
logger.error(`Error ${re}decrypting event (${this.getDetails()})`, e);
this.decryptionPromise = null;
this.retryDecryption = false;
return;
}
const detailedError = e instanceof DecryptionError ? (<DecryptionError>e).detailedString : String(e);
err = e as Error;
@@ -858,10 +848,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
//
if (this.retryDecryption) {
// decryption error, but we have a retry queued.
logger.log(
`Error decrypting event (${this.getDetails()}), but retrying: ` +
(<DecryptionError>e).detailedString,
);
logger.log(`Error decrypting event (${this.getDetails()}), but retrying: ${detailedError}`);
continue;
}
@@ -870,9 +857,9 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
//
// the detailedString already includes the name and message of the error, and the stack isn't much use,
// so we don't bother to log `e` separately.
logger.warn(`Error decrypting event (${this.getDetails()}): ` + (<DecryptionError>e).detailedString);
logger.warn(`Error decrypting event (${this.getDetails()}): ${detailedError}`);
res = this.badEncryptedMessage((<DecryptionError>e).message);
res = this.badEncryptedMessage(String(e));
}
// at this point, we've either successfully decrypted the event, or have given up
+6 -15
View File
@@ -33,6 +33,9 @@ export type EventHandlerMap = {
[RelationsEvent.Redaction]: (event: MatrixEvent) => void;
};
const matchesEventType = (eventType: string, targetEventType: string, altTargetEventTypes: string[] = []): boolean =>
[targetEventType, ...altTargetEventTypes].includes(eventType);
/**
* A container for relation events that supports easy access to common ways of
* aggregating such events. Each instance holds events that of a single relation
@@ -55,11 +58,13 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
* @param relationType - The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc.
* @param eventType - The relation event's type, such as "m.reaction", etc.
* @param client - The client which created this instance. For backwards compatibility also accepts a Room.
* @param altEventTypes - alt event types for relation events, for example to support unstable prefixed event types
*/
public constructor(
public readonly relationType: RelationType | string,
public readonly eventType: string,
client: MatrixClient | Room,
public readonly altEventTypes?: string[],
) {
super();
this.client = client instanceof Room ? client.client : client;
@@ -84,7 +89,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
const relationType = relation.rel_type;
const eventType = event.getType();
if (this.relationType !== relationType || this.eventType !== eventType) {
if (this.relationType !== relationType || !matchesEventType(eventType, this.eventType, this.altEventTypes)) {
logger.error("Event relation info doesn't match this container");
return;
}
@@ -122,20 +127,6 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
return;
}
const relation = event.getRelation();
if (!relation) {
logger.error("Event must have relation info");
return;
}
const relationType = relation.rel_type;
const eventType = event.getType();
if (this.relationType !== relationType || this.eventType !== eventType) {
logger.error("Event relation info doesn't match this container");
return;
}
this.relations.delete(event);
if (this.relationType === RelationType.Annotation) {
+10
View File
@@ -623,6 +623,16 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
return this.oobMemberFlags.status === OobStatus.NotStarted;
}
/**
* Check if loading of out-of-band-members has completed
*
* @returns true if the full membership list of this room has been loaded. False if it is not started or is in
* progress.
*/
public outOfBandMembersReady(): boolean {
return this.oobMemberFlags.status === OobStatus.Finished;
}
/**
* Mark this room state as waiting for out-of-band members,
* ensuring it doesn't ask for them to be requested again
+89 -15
View File
@@ -1,5 +1,5 @@
/*
Copyright 2015 - 2022 The Matrix.org Foundation C.I.C.
Copyright 2015 - 2023 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.
@@ -54,7 +54,13 @@ import {
FILTER_RELATED_BY_SENDERS,
ThreadFilterType,
} from "./thread";
import { MAIN_ROOM_TIMELINE, Receipt, ReceiptContent, ReceiptType } from "../@types/read_receipts";
import {
CachedReceiptStructure,
MAIN_ROOM_TIMELINE,
Receipt,
ReceiptContent,
ReceiptType,
} from "../@types/read_receipts";
import { IStateEventWithRoomId } from "../@types/search";
import { RelationsContainer } from "./relations-container";
import { ReadReceipt, synthesizeReceipt } from "./read-receipt";
@@ -302,7 +308,14 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
private txnToEvent: Record<string, MatrixEvent> = {}; // Pending in-flight requests { string: MatrixEvent }
private notificationCounts: NotificationCount = {};
private readonly threadNotifications = new Map<string, NotificationCount>();
public readonly cachedThreadReadReceipts = new Map<string, { event: MatrixEvent; synthetic: boolean }[]>();
public readonly cachedThreadReadReceipts = new Map<string, CachedReceiptStructure[]>();
// Useful to know at what point the current user has started using threads in this room
private oldestThreadedReceiptTs = Infinity;
/**
* A record of the latest unthread receipts per user
* This is useful in determining whether a user has read a thread or not
*/
private unthreadedReceipts = new Map<string, Receipt>();
private readonly timelineSets: EventTimelineSet[];
public readonly threadsTimelineSets: EventTimelineSet[] = [];
// any filtered timeline sets we're maintaining for this room
@@ -441,9 +454,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
});
events.forEach(async (serializedEvent: Partial<IEvent>) => {
const event = mapper(serializedEvent);
if (event.getType() === EventType.RoomMessageEncrypted && this.client.isCryptoEnabled()) {
await event.attemptDecryption(this.client.crypto!);
}
await client.decryptEventIfNeeded(event);
event.setStatus(EventStatus.NOT_SENT);
this.addPendingEvent(event, event.getTxnId()!);
});
@@ -503,9 +514,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
const decryptionPromises = events
.slice(readReceiptTimelineIndex)
.filter((event) => event.shouldAttemptDecryption())
.reverse()
.map((event) => event.attemptDecryption(this.client.crypto!, { isRetry: true }));
.map((event) => this.client.decryptEventIfNeeded(event, { isRetry: true }));
await Promise.allSettled(decryptionPromises);
}
@@ -521,9 +531,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
const decryptionPromises = this.getUnfilteredTimelineSet()
.getLiveTimeline()
.getEvents()
.filter((event) => event.shouldAttemptDecryption())
.slice(0) // copy before reversing
.reverse()
.map((event) => event.attemptDecryption(this.client.crypto!, { isRetry: true }));
.map((event) => this.client.decryptEventIfNeeded(event, { isRetry: true }));
await Promise.allSettled(decryptionPromises);
}
@@ -888,6 +898,20 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return { memberEvents, fromServer };
}
/**
* Check if loading of out-of-band-members has completed
*
* @returns true if the full membership list of this room has been loaded (including if lazy-loading is disabled).
* False if the load is not started or is in progress.
*/
public membersLoaded(): boolean {
if (!this.opts.lazyLoadMembers) {
return true;
}
return this.currentState.outOfBandMembersReady();
}
/**
* Preloads the member list in case lazy loading
* of memberships is in use. Can be called multiple times,
@@ -909,10 +933,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
const inMemoryUpdate = this.loadMembers()
.then((result) => {
this.currentState.setOutOfBandMembers(result.memberEvents);
// now the members are loaded, start to track the e2e devices if needed
if (this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId)) {
this.client.crypto!.trackRoomDevices(this.roomId);
}
return result.fromServer;
})
.catch((err) => {
@@ -2711,9 +2731,20 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// when the thread is created
this.cachedThreadReadReceipts.set(receipt.thread_id!, [
...(this.cachedThreadReadReceipts.get(receipt.thread_id!) ?? []),
{ event, synthetic },
{ eventId, receiptType, userId, receipt, synthetic },
]);
}
const me = this.client.getUserId();
// Track the time of the current user's oldest threaded receipt in the room.
if (userId === me && !receiptForMainTimeline && receipt.ts < this.oldestThreadedReceiptTs) {
this.oldestThreadedReceiptTs = receipt.ts;
}
// Track each user's unthreaded read receipt.
if (!receipt.thread_id && receipt.ts > (this.unthreadedReceipts.get(userId)?.ts ?? 0)) {
this.unthreadedReceipts.set(userId, receipt);
}
});
});
});
@@ -2956,6 +2987,29 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return this.getType() === RoomType.ElementVideo;
}
/**
* @returns the ID of the room that was this room's predecessor, or null if
* this room has no predecessor.
*/
public findPredecessorRoomId(): string | null {
const currentState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
if (!currentState) {
return null;
}
const createEvent = currentState.getStateEvents(EventType.RoomCreate, "");
if (createEvent) {
const predecessor = createEvent.getContent()["predecessor"];
if (predecessor) {
const roomId = predecessor["room_id"];
if (roomId) {
return roomId;
}
}
}
return null;
}
private roomNameGenerator(state: RoomNameState): string {
if (this.client.roomNameGenerator) {
const name = this.client.roomNameGenerator(this.roomId, state);
@@ -3264,6 +3318,26 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
event.applyVisibilityEvent(visibilityChange);
}
/**
* Find when a client has gained thread capabilities by inspecting the oldest
* threaded receipt
* @returns the timestamp of the oldest threaded receipt
*/
public getOldestThreadedReceiptTs(): number {
return this.oldestThreadedReceiptTs;
}
/**
* Returns the most recent unthreaded receipt for a given user
* @param userId - the MxID of the User
* @returns an unthreaded Receipt. Can be undefined if receipts have been disabled
* or a user chooses to use private read receipts (or we have simply not received
* a receipt from this user yet).
*/
public getLastUnthreadedReceiptFor(userId: string): Receipt | undefined {
return this.unthreadedReceipts.get(userId);
}
}
// a map from current event status to a list of allowed next statuses
+102 -24
View File
@@ -1,5 +1,5 @@
/*
Copyright 2021-2022 The Matrix.org Foundation C.I.C.
Copyright 2021 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -22,12 +22,12 @@ import { RelationType } from "../@types/event";
import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "./event";
import { Direction, EventTimeline } from "./event-timeline";
import { EventTimelineSet, EventTimelineSetHandlerMap } from "./event-timeline-set";
import { NotificationCountType, Room, RoomEvent } from "./room";
import { Room, RoomEvent } from "./room";
import { RoomState } from "./room-state";
import { ServerControlledNamespacedValue } from "../NamespacedValue";
import { logger } from "../logger";
import { ReadReceipt } from "./read-receipt";
import { Receipt, ReceiptContent, ReceiptType } from "../@types/read_receipts";
import { CachedReceiptStructure, ReceiptType } from "../@types/read_receipts";
export enum ThreadEvent {
New = "Thread.new",
@@ -50,7 +50,7 @@ interface IThreadOpts {
room: Room;
client: MatrixClient;
pendingEventOrdering?: PendingEventOrdering;
receipts?: { event: MatrixEvent; synthetic: boolean }[];
receipts?: CachedReceiptStructure[];
}
export enum FeatureSupport {
@@ -97,6 +97,11 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
private readonly pendingEventOrdering: PendingEventOrdering;
public initialEventsFetched = !Thread.hasServerSideSupport;
/**
* An array of events to add to the timeline once the thread has been initialised
* with server suppport.
*/
public replayEvents: MatrixEvent[] | null = [];
public constructor(public readonly id: string, public rootEvent: MatrixEvent | undefined, opts: IThreadOpts) {
super();
@@ -266,6 +271,20 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
this.addEventToTimeline(event, false);
this.fetchEditsWhereNeeded(event);
} else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) {
if (!this.initialEventsFetched) {
/**
* A thread can be fully discovered via a single sync response
* And when that's the case we still ask the server to do an initialisation
* as it's the safest to ensure we have everything.
* However when we are in that scenario we might loose annotation or edits
*
* This fix keeps a reference to those events and replay them once the thread
* has been initialised properly.
*/
this.replayEvents?.push(event);
} else {
this.addEventToTimeline(event, toStartOfTimeline);
}
// Apply annotations and replace relations to the relations of the timeline only
this.timelineSet.relations?.aggregateParentEvent(event);
this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
@@ -298,17 +317,9 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
* and apply them to the current thread
* @param receipts - A collection of the receipts cached from initial sync
*/
private processReceipts(receipts: { event: MatrixEvent; synthetic: boolean }[] = []): void {
for (const { event, synthetic } of receipts) {
const content = event.getContent<ReceiptContent>();
Object.keys(content).forEach((eventId: string) => {
Object.keys(content[eventId]).forEach((receiptType: ReceiptType | string) => {
Object.keys(content[eventId][receiptType]).forEach((userId: string) => {
const receipt = content[eventId][receiptType][userId] as Receipt;
this.addReceiptToStructure(eventId, receiptType as ReceiptType, userId, receipt, synthetic);
});
});
});
private processReceipts(receipts: CachedReceiptStructure[] = []): void {
for (const { eventId, receiptType, userId, receipt, synthetic } of receipts) {
this.addReceiptToStructure(eventId, receiptType as ReceiptType, userId, receipt, synthetic);
}
}
@@ -375,6 +386,10 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
limit: Math.max(1, this.length),
});
}
for (const event of this.replayEvents!) {
this.addEvent(event, false);
}
this.replayEvents = null;
// just to make sure that, if we've created a timeline window for this thread before the thread itself
// existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly.
this.emit(RoomEvent.TimelineReset, this.room, this.timelineSet, true);
@@ -489,17 +504,80 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
throw new Error("Unsupported function on the thread model");
}
/**
* Get the ID of the event that a given user has read up to within this thread,
* or null if we have received no read receipt (at all) from them.
* @param userId - The user ID to get read receipt event ID for
* @param ignoreSynthesized - If true, return only receipts that have been
* sent by the server, not implicit ones generated
* by the JS SDK.
* @returns ID of the latest event that the given user has read, or null.
*/
public getEventReadUpTo(userId: string, ignoreSynthesized?: boolean): string | null {
const isCurrentUser = userId === this.client.getUserId();
const lastReply = this.timeline.at(-1);
if (isCurrentUser && lastReply) {
// If the last activity in a thread is prior to the first threaded read receipt
// sent in the room (suggesting that it was sent before the user started
// using a client that supported threaded read receipts), we want to
// consider this thread as read.
const beforeFirstThreadedReceipt = lastReply.getTs() < this.room.getOldestThreadedReceiptTs();
const lastReplyId = lastReply.getId();
// Some unsent events do not have an ID, we do not want to consider them read
if (beforeFirstThreadedReceipt && lastReplyId) {
return lastReplyId;
}
}
const readUpToId = super.getEventReadUpTo(userId, ignoreSynthesized);
// Check whether the unthreaded read receipt for that user is more recent
// than the read receipt inside that thread.
if (lastReply) {
const unthreadedReceipt = this.room.getLastUnthreadedReceiptFor(userId);
if (!unthreadedReceipt) {
return readUpToId;
}
for (let i = this.timeline?.length - 1; i >= 0; --i) {
const ev = this.timeline[i];
// If we encounter the `readUpToId` we do not need to look further
// there is no "more recent" unthreaded read receipt
if (ev.getId() === readUpToId) return readUpToId;
// Inspecting events from most recent to oldest, we're checking
// whether an unthreaded read receipt is more recent that the current event.
// We usually prefer relying on the order of the DAG but in this scenario
// it is not possible and we have to rely on timestamp
if (ev.getTs() < unthreadedReceipt.ts) return ev.getId() ?? readUpToId;
}
}
return readUpToId;
}
/**
* Determine if the given user has read a particular event.
*
* It is invalid to call this method with an event that is not part of this thread.
*
* This is not a definitive check as it only checks the events that have been
* loaded client-side at the time of execution.
* @param userId - The user ID to check the read state of.
* @param eventId - The event ID to check if the user read.
* @returns True if the user has read the event, false otherwise.
*/
public hasUserReadEvent(userId: string, eventId: string): boolean {
if (userId === this.client.getUserId()) {
const publicReadReceipt = this.getReadReceiptForUserId(userId, false, ReceiptType.Read);
const privateReadReceipt = this.getReadReceiptForUserId(userId, false, ReceiptType.ReadPrivate);
const hasUnreads = this.room.getThreadUnreadNotificationCount(this.id, NotificationCountType.Total) > 0;
if (!publicReadReceipt && !privateReadReceipt && !hasUnreads) {
// Consider an event read if it's part of a thread that has no
// read receipts and has no notifications. It is likely that it is
// part of a thread that was created before read receipts for threads
// were supported (via MSC3771)
// Consider an event read if it's part of a thread that is before the
// first threaded receipt sent in that room. It is likely that it is
// part of a thread that was created before MSC3771 was implemented.
// Or before the last unthreaded receipt for the logged in user
const beforeFirstThreadedReceipt =
(this.lastReply()?.getTs() ?? 0) < this.room.getOldestThreadedReceiptTs();
const unthreadedReceiptTs = this.room.getLastUnthreadedReceiptFor(userId)?.ts ?? 0;
const beforeLastUnthreadedReceipt = (this?.lastReply()?.getTs() ?? 0) < unthreadedReceiptTs;
if (beforeFirstThreadedReceipt || beforeLastUnthreadedReceipt) {
return true;
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
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.
*/
/** The prefix used on indexeddbs created by rust-crypto */
export const RUST_SDK_STORE_PREFIX = "matrix-js-sdk";
+46
View File
@@ -0,0 +1,46 @@
/*
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 * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import { RustCrypto } from "./rust-crypto";
import { logger } from "../logger";
import { CryptoBackend } from "../common-crypto/CryptoBackend";
import { RUST_SDK_STORE_PREFIX } from "./constants";
import { IHttpOpts, MatrixHttpApi } from "../http-api";
export async function initRustCrypto(
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
userId: string,
deviceId: string,
): Promise<CryptoBackend> {
// initialise the rust matrix-sdk-crypto-js, if it hasn't already been done
await RustSdkCryptoJs.initAsync();
// enable tracing in the rust-sdk
new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Debug).turnOn();
const u = new RustSdkCryptoJs.UserId(userId);
const d = new RustSdkCryptoJs.DeviceId(deviceId);
logger.info("Init OlmMachine");
// TODO: use the pickle key for the passphrase
const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(u, d, RUST_SDK_STORE_PREFIX, "test pass");
const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId);
logger.info("Completed rust crypto-sdk setup");
return rustCrypto;
}
+201
View File
@@ -0,0 +1,201 @@
/*
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 * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import {
KeysBackupRequest,
KeysClaimRequest,
KeysQueryRequest,
KeysUploadRequest,
SignatureUploadRequest,
} from "@matrix-org/matrix-sdk-crypto-js";
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
import type { IToDeviceEvent } from "../sync-accumulator";
import { MatrixEvent } from "../models/event";
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
import { logger } from "../logger";
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
import { QueryDict } from "../utils";
/**
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
*/
interface OutgoingRequest {
readonly id: string | undefined;
readonly type: number;
}
/**
* An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
*/
export class RustCrypto implements CryptoBackend {
public globalBlacklistUnverifiedDevices = false;
public globalErrorOnUnknownDevices = false;
/** whether {@link stop} has been called */
private stopped = false;
/** whether {@link outgoingRequestLoop} is currently running */
private outgoingRequestLoopRunning = false;
public constructor(
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
_userId: string,
_deviceId: string,
) {}
public stop(): void {
// stop() may be called multiple times, but attempting to close() the OlmMachine twice
// will cause an error.
if (this.stopped) {
return;
}
this.stopped = true;
// make sure we close() the OlmMachine; doing so means that all the Rust objects will be
// cleaned up; in particular, the indexeddb connections will be closed, which means they
// can then be deleted.
this.olmMachine.close();
}
public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
await this.olmMachine.decryptRoomEvent("event", new RustSdkCryptoJs.RoomId("room"));
throw new Error("not implemented");
}
public async userHasCrossSigningKeys(): Promise<boolean> {
// TODO
return false;
}
public async exportRoomKeys(): Promise<IMegolmSessionData[]> {
// TODO
return [];
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// SyncCryptoCallbacks implementation
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/** called by the sync loop to preprocess incoming to-device messages
*
* @param events - the received to-device messages
* @returns A list of preprocessed to-device messages.
*/
public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> {
// send the received to-device messages into receiveSyncChanges. We have no info on device-list changes,
// one-time-keys, or fallback keys, so just pass empty data.
const result = await this.olmMachine.receiveSyncChanges(
JSON.stringify(events),
new RustSdkCryptoJs.DeviceLists(),
new Map(),
new Set(),
);
// receiveSyncChanges returns a JSON-encoded list of decrypted to-device messages.
return JSON.parse(result);
}
/** called by the sync loop after processing each sync.
*
* TODO: figure out something equivalent for sliding sync.
*
* @param syncState - information on the completed sync.
*/
public onSyncCompleted(syncState: OnSyncCompletedData): void {
// Processing the /sync may have produced new outgoing requests which need sending, so kick off the outgoing
// request loop, if it's not already running.
this.outgoingRequestLoop();
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Outgoing requests
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
private async outgoingRequestLoop(): Promise<void> {
if (this.outgoingRequestLoopRunning) {
return;
}
this.outgoingRequestLoopRunning = true;
try {
while (!this.stopped) {
const outgoingRequests: Object[] = await this.olmMachine.outgoingRequests();
if (outgoingRequests.length == 0 || this.stopped) {
// no more messages to send (or we have been told to stop): exit the loop
return;
}
for (const msg of outgoingRequests) {
await this.doOutgoingRequest(msg as OutgoingRequest);
}
}
} catch (e) {
logger.error("Error processing outgoing-message requests from rust crypto-sdk", e);
} finally {
this.outgoingRequestLoopRunning = false;
}
}
private async doOutgoingRequest(msg: OutgoingRequest): Promise<void> {
let resp: string;
/* refer https://docs.rs/matrix-sdk-crypto/0.6.0/matrix_sdk_crypto/requests/enum.OutgoingRequests.html
* for the complete list of request types
*/
if (msg instanceof KeysUploadRequest) {
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/upload", {}, msg.body);
} else if (msg instanceof KeysQueryRequest) {
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/query", {}, msg.body);
} else if (msg instanceof KeysClaimRequest) {
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/claim", {}, msg.body);
} else if (msg instanceof SignatureUploadRequest) {
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/signatures/upload", {}, msg.body);
} else if (msg instanceof KeysBackupRequest) {
resp = await this.rawJsonRequest(Method.Put, "/_matrix/client/v3/room_keys/keys", {}, msg.body);
} else {
// TODO: ToDeviceRequest, RoomMessageRequest
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
resp = "";
}
if (msg.id) {
await this.olmMachine.markRequestAsSent(msg.id, msg.type, resp);
}
}
private async rawJsonRequest(method: Method, path: string, queryParams: QueryDict, body: string): Promise<string> {
const opts = {
// inhibit the JSON stringification and parsing within HttpApi.
json: false,
// nevertheless, we are sending, and accept, JSON.
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
// we use the full prefix
prefix: "",
};
return await this.http.authedRequest<string>(method, path, queryParams, body, opts);
}
}
+30 -23
View File
@@ -14,12 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend";
import { NotificationCountType, Room, RoomEvent } from "./models/room";
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, _createAndReEmitRoom } from "./sync";
import { ClientEvent, IStoredClientOpts, MatrixClient } from "./client";
import {
ISyncStateData,
SyncState,
_createAndReEmitRoom,
SyncApiOptions,
defaultClientOpts,
defaultSyncApiOpts,
} from "./sync";
import { MatrixEvent } from "./models/event";
import { Crypto } from "./crypto";
import { IMinimalEvent, IRoomEvent, IStateEvent, IStrippedState, ISyncResponse } from "./sync-accumulator";
@@ -102,6 +110,7 @@ class ExtensionE2EE implements Extension<ExtensionE2EERequest, ExtensionE2EEResp
Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"),
);
}
this.crypto.onSyncCompleted({});
}
}
@@ -119,7 +128,7 @@ type ExtensionToDeviceResponse = {
class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, ExtensionToDeviceResponse> {
private nextBatch: string | null = null;
public constructor(private readonly client: MatrixClient) {}
public constructor(private readonly client: MatrixClient, private readonly cryptoCallbacks?: SyncCryptoCallbacks) {}
public name(): string {
return "to_device";
@@ -142,8 +151,12 @@ class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, Extension
public async onResponse(data: ExtensionToDeviceResponse): Promise<void> {
const cancelledKeyVerificationTxns: string[] = [];
data.events
?.map(this.client.getEventMapper())
let events = data["events"] || [];
if (events.length > 0 && this.cryptoCallbacks) {
events = await this.cryptoCallbacks.preprocessToDeviceMessages(events);
}
events
.map(this.client.getEventMapper())
.map((toDeviceEvent) => {
// map is a cheap inline forEach
// We want to flag m.key.verification.start events as cancelled
@@ -341,6 +354,8 @@ class ExtensionReceipts implements Extension<ExtensionReceiptsRequest, Extension
* sliding sync API, see sliding-sync.ts or the class SlidingSync.
*/
export class SlidingSyncSdk {
private readonly opts: IStoredClientOpts;
private readonly syncOpts: SyncApiOptions;
private syncState: SyncState | null = null;
private syncStateData?: ISyncStateData;
private lastPos: string | null = null;
@@ -350,19 +365,11 @@ export class SlidingSyncSdk {
public constructor(
private readonly slidingSync: SlidingSync,
private readonly client: MatrixClient,
private readonly opts: Partial<IStoredClientOpts> = {},
opts?: IStoredClientOpts,
syncOpts?: SyncApiOptions,
) {
this.opts.initialSyncLimit = this.opts.initialSyncLimit ?? 8;
this.opts.resolveInvitesToProfiles = this.opts.resolveInvitesToProfiles || false;
this.opts.pollTimeout = this.opts.pollTimeout || 30 * 1000;
this.opts.pendingEventOrdering = this.opts.pendingEventOrdering || PendingEventOrdering.Chronological;
this.opts.experimentalThreadSupport = this.opts.experimentalThreadSupport === true;
if (!opts.canResetEntireTimeline) {
opts.canResetEntireTimeline = (_roomId: string): boolean => {
return false;
};
}
this.opts = defaultClientOpts(opts);
this.syncOpts = defaultSyncApiOpts(syncOpts);
if (client.getNotifTimelineSet()) {
client.reEmitter.reEmit(client.getNotifTimelineSet()!, [RoomEvent.Timeline, RoomEvent.TimelineReset]);
@@ -371,13 +378,13 @@ export class SlidingSyncSdk {
this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle.bind(this));
this.slidingSync.on(SlidingSyncEvent.RoomData, this.onRoomData.bind(this));
const extensions: Extension<any, any>[] = [
new ExtensionToDevice(this.client),
new ExtensionToDevice(this.client, this.syncOpts.cryptoCallbacks),
new ExtensionAccountData(this.client),
new ExtensionTyping(this.client),
new ExtensionReceipts(this.client),
];
if (this.opts.crypto) {
extensions.push(new ExtensionE2EE(this.opts.crypto));
if (this.syncOpts.crypto) {
extensions.push(new ExtensionE2EE(this.syncOpts.crypto));
}
extensions.forEach((ext) => {
this.slidingSync.registerExtension(ext);
@@ -697,7 +704,7 @@ export class SlidingSyncSdk {
if (limited) {
room.resetLiveTimeline(
roomData.prev_batch,
null, // TODO this.opts.canResetEntireTimeline(room.roomId) ? null : syncEventData.oldSyncToken,
null, // TODO this.syncOpts.canResetEntireTimeline(room.roomId) ? null : syncEventData.oldSyncToken,
);
// We have to assume any gap in any timeline is
@@ -729,8 +736,8 @@ export class SlidingSyncSdk {
const processRoomEvent = async (e: MatrixEvent): Promise<void> => {
client.emit(ClientEvent.Event, e);
if (e.isState() && e.getType() == EventType.RoomEncryption && this.opts.crypto) {
await this.opts.crypto.onCryptoEvent(room, e);
if (e.isState() && e.getType() == EventType.RoomEncryption && this.syncOpts.crypto) {
await this.syncOpts.crypto.onCryptoEvent(room, e);
}
};
+9
View File
@@ -397,6 +397,10 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
* @param sub - The subscription information.
*/
public addCustomSubscription(name: string, sub: MSC3575RoomSubscription): void {
if (this.customSubscriptions.has(name)) {
logger.warn(`addCustomSubscription: ${name} already exists as a custom subscription, ignoring.`);
return;
}
this.customSubscriptions.set(name, sub);
}
@@ -408,6 +412,11 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
* will be used.
*/
public useCustomSubscription(roomId: string, name: string): void {
// We already know about this custom subscription, as it is immutable,
// we don't need to unconfirm the subscription.
if (this.roomIdToCustomSubscription.get(roomId) === name) {
return;
}
this.roomIdToCustomSubscription.set(roomId, name);
// unconfirm this subscription so a resend() will send it up afresh.
this.confirmedRoomSubscriptions.delete(roomId);
+7 -1
View File
@@ -286,7 +286,13 @@ export class MemoryStore implements IStore {
*/
public storeAccountDataEvents(events: MatrixEvent[]): void {
events.forEach((event) => {
this.accountData[event.getType()] = event;
// MSC3391: an event with content of {} should be interpreted as deleted
const isDeleted = !Object.keys(event.getContent()).length;
if (isDeleted) {
delete this.accountData[event.getType()];
} else {
this.accountData[event.getType()] = event;
}
});
}
+80 -42
View File
@@ -25,6 +25,7 @@ limitations under the License.
import { Optional } from "matrix-events-sdk";
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend";
import { User, UserEvent } from "./models/user";
import { NotificationCountType, Room, RoomEvent } from "./models/room";
import * as utils from "./utils";
@@ -34,7 +35,7 @@ import { EventTimeline } from "./models/event-timeline";
import { PushProcessor } from "./pushprocessor";
import { logger } from "./logger";
import { InvalidStoreError, InvalidStoreState } from "./errors";
import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering } from "./client";
import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering, ResetTimelineCallback } from "./client";
import {
IEphemeral,
IInvitedRoom,
@@ -47,6 +48,7 @@ import {
IStrippedState,
ISyncResponse,
ITimeline,
IToDeviceEvent,
} from "./sync-accumulator";
import { MatrixEvent } from "./models/event";
import { MatrixError, Method } from "./http-api";
@@ -59,6 +61,7 @@ import { BeaconEvent } from "./models/beacon";
import { IEventsResponse } from "./@types/requests";
import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync";
import { Feature, ServerSupport } from "./feature";
import { Crypto } from "./crypto";
const DEBUG = true;
@@ -110,6 +113,31 @@ function debuglog(...params: any[]): void {
logger.log(...params);
}
/**
* Options passed into the constructor of SyncApi by MatrixClient
*/
export interface SyncApiOptions {
/**
* Crypto manager
*
* @deprecated in favour of cryptoCallbacks
*/
crypto?: Crypto;
/**
* If crypto is enabled on our client, callbacks into the crypto module
*/
cryptoCallbacks?: SyncCryptoCallbacks;
/**
* A function which is called
* with a room ID and returns a boolean. It should return 'true' if the SDK can
* SAFELY remove events from this room. It may not be safe to remove events if
* there are other references to the timelines for this room.
*/
canResetEntireTimeline?: ResetTimelineCallback;
}
interface ISyncOptions {
filter?: string;
hasSyncedBefore?: boolean;
@@ -163,7 +191,29 @@ type WrappedRoom<T> = T & {
isBrandNewRoom: boolean;
};
/** add default settings to an IStoredClientOpts */
export function defaultClientOpts(opts?: IStoredClientOpts): IStoredClientOpts {
return {
initialSyncLimit: 8,
resolveInvitesToProfiles: false,
pollTimeout: 30 * 1000,
pendingEventOrdering: PendingEventOrdering.Chronological,
experimentalThreadSupport: false,
...opts,
};
}
export function defaultSyncApiOpts(syncOpts?: SyncApiOptions): SyncApiOptions {
return {
canResetEntireTimeline: (_roomId): boolean => false,
...syncOpts,
};
}
export class SyncApi {
private readonly opts: IStoredClientOpts;
private readonly syncOpts: SyncApiOptions;
private _peekRoom: Optional<Room> = null;
private currentSyncRequest?: Promise<ISyncResponse>;
private abortController?: AbortController;
@@ -180,21 +230,13 @@ export class SyncApi {
/**
* Construct an entity which is able to sync with a homeserver.
* @param client - The matrix client instance to use.
* @param opts - Config options
* @param opts - client config options
* @param syncOpts - sync-specific options passed by the client
* @internal
*/
public constructor(private readonly client: MatrixClient, private readonly opts: Partial<IStoredClientOpts> = {}) {
this.opts.initialSyncLimit = this.opts.initialSyncLimit ?? 8;
this.opts.resolveInvitesToProfiles = this.opts.resolveInvitesToProfiles || false;
this.opts.pollTimeout = this.opts.pollTimeout || 30 * 1000;
this.opts.pendingEventOrdering = this.opts.pendingEventOrdering || PendingEventOrdering.Chronological;
this.opts.experimentalThreadSupport = this.opts.experimentalThreadSupport === true;
if (!opts.canResetEntireTimeline) {
opts.canResetEntireTimeline = (roomId: string): boolean => {
return false;
};
}
public constructor(private readonly client: MatrixClient, opts?: IStoredClientOpts, syncOpts?: SyncApiOptions) {
this.opts = defaultClientOpts(opts);
this.syncOpts = defaultSyncApiOpts(syncOpts);
if (client.getNotifTimelineSet()) {
client.reEmitter.reEmit(client.getNotifTimelineSet()!, [RoomEvent.Timeline, RoomEvent.TimelineReset]);
@@ -632,7 +674,7 @@ export class SyncApi {
return;
}
if (this.opts.lazyLoadMembers) {
this.opts.crypto?.enableLazyLoading();
this.syncOpts.crypto?.enableLazyLoading();
}
try {
debuglog("Storing client options...");
@@ -866,10 +908,10 @@ export class SyncApi {
catchingUp: this.catchingUp,
};
if (this.opts.crypto) {
if (this.syncOpts.crypto) {
// tell the crypto module we're about to process a sync
// response
await this.opts.crypto.onSyncWillProcess(syncEventData);
await this.syncOpts.crypto.onSyncWillProcess(syncEventData);
}
try {
@@ -894,8 +936,8 @@ export class SyncApi {
// tell the crypto module to do its processing. It may block (to do a
// /keys/changes request).
if (this.opts.crypto) {
await this.opts.crypto.onSyncCompleted(syncEventData);
if (this.syncOpts.cryptoCallbacks) {
await this.syncOpts.cryptoCallbacks.onSyncCompleted(syncEventData);
}
// keep emitting SYNCING -> SYNCING for clients who want to do bulk updates
@@ -907,8 +949,8 @@ export class SyncApi {
// stored sync data which means we don't have to worry that we may have missed
// device changes. We can also skip the delay since we're not calling this very
// frequently (and we don't really want to delay the sync for it).
if (this.opts.crypto) {
await this.opts.crypto.saveDeviceList(0);
if (this.syncOpts.crypto) {
await this.syncOpts.crypto.saveDeviceList(0);
}
// tell databases that everything is now in a consistent state and can be saved.
@@ -1129,19 +1171,15 @@ export class SyncApi {
}
// handle to-device events
if (Array.isArray(data.to_device?.events) && data.to_device!.events.length > 0) {
const cancelledKeyVerificationTxns: string[] = [];
data.to_device!.events.filter((eventJSON) => {
if (
eventJSON.type === EventType.RoomMessageEncrypted &&
!["m.olm.v1.curve25519-aes-sha2"].includes(eventJSON.content?.algorithm)
) {
logger.log("Ignoring invalid encrypted to-device event from " + eventJSON.sender);
return false;
}
if (data.to_device && Array.isArray(data.to_device.events) && data.to_device.events.length > 0) {
let toDeviceMessages: IToDeviceEvent[] = data.to_device.events;
return true;
})
if (this.syncOpts.cryptoCallbacks) {
toDeviceMessages = await this.syncOpts.cryptoCallbacks.preprocessToDeviceMessages(toDeviceMessages);
}
const cancelledKeyVerificationTxns: string[] = [];
toDeviceMessages
.map(client.getEventMapper({ toDevice: true }))
.map((toDeviceEvent) => {
// map is a cheap inline forEach
@@ -1356,7 +1394,7 @@ export class SyncApi {
if (limited) {
room.resetLiveTimeline(
joinObj.timeline.prev_batch,
this.opts.canResetEntireTimeline!(room.roomId) ? null : syncEventData.oldSyncToken ?? null,
this.syncOpts.canResetEntireTimeline!(room.roomId) ? null : syncEventData.oldSyncToken ?? null,
);
// We have to assume any gap in any timeline is
@@ -1370,10 +1408,10 @@ export class SyncApi {
// avoids a race condition if the application tries to send a message after the
// state event is processed, but before crypto is enabled, which then causes the
// crypto layer to complain.
if (this.opts.crypto) {
if (this.syncOpts.crypto) {
for (const e of stateEvents.concat(events)) {
if (e.isState() && e.getType() === EventType.RoomEncryption && e.getStateKey() === "") {
await this.opts.crypto.onCryptoEvent(room, e);
await this.syncOpts.crypto.onCryptoEvent(room, e);
}
}
}
@@ -1462,8 +1500,8 @@ export class SyncApi {
// Handle device list updates
if (data.device_lists) {
if (this.opts.crypto) {
await this.opts.crypto.handleDeviceListChanges(syncEventData, data.device_lists);
if (this.syncOpts.crypto) {
await this.syncOpts.crypto.handleDeviceListChanges(syncEventData, data.device_lists);
} else {
// FIXME if we *don't* have a crypto module, we still need to
// invalidate the device lists. But that would require a
@@ -1472,12 +1510,12 @@ export class SyncApi {
}
// Handle one_time_keys_count
if (this.opts.crypto && data.device_one_time_keys_count) {
if (this.syncOpts.crypto && data.device_one_time_keys_count) {
const currentCount = data.device_one_time_keys_count.signed_curve25519 || 0;
this.opts.crypto.updateOneTimeKeyCount(currentCount);
this.syncOpts.crypto.updateOneTimeKeyCount(currentCount);
}
if (
this.opts.crypto &&
this.syncOpts.crypto &&
(data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"])
) {
// The presence of device_unused_fallback_key_types indicates that the
@@ -1485,7 +1523,7 @@ export class SyncApi {
// signed_curve25519 fallback key we need a new one.
const unusedFallbackKeys =
data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"];
this.opts.crypto.setNeedsNewFallback(
this.syncOpts.crypto.setNeedsNewFallback(
Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"),
);
}
+38 -11
View File
@@ -1280,7 +1280,10 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
setTracksEnabled(stream.getAudioTracks(), audioEnabled);
setTracksEnabled(stream.getVideoTracks(), videoEnabled);
// We want to keep the same stream id, so we replace the tracks rather than the whole stream
// We want to keep the same stream id, so we replace the tracks rather
// than the whole stream.
// Firstly, we replace the tracks in our localUsermediaStream.
for (const track of this.localUsermediaStream!.getTracks()) {
this.localUsermediaStream!.removeTrack(track);
track.stop();
@@ -1289,10 +1292,23 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
this.localUsermediaStream!.addTrack(track);
}
// Secondly, we remove tracks that we no longer need from the peer
// connection, if any. This only happens when we mute the video atm.
// This will change the transceiver direction to "inactive" and
// therefore cause re-negotiation.
for (const kind of ["audio", "video"]) {
const sender = this.transceivers.get(getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, kind))?.sender;
// Only remove the track if we aren't going to immediately replace it
if (sender && !stream.getTracks().find((t) => t.kind === kind)) {
this.peerConn?.removeTrack(sender);
}
}
// Thirdly, we replace the old tracks, if possible.
for (const track of stream.getTracks()) {
const tKey = getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, track.kind);
const oldSender = this.transceivers.get(tKey)?.sender;
const transceiver = this.transceivers.get(tKey);
const oldSender = transceiver?.sender;
let added = false;
if (oldSender) {
try {
@@ -1306,6 +1322,10 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
`) to peer connection`,
);
await oldSender.replaceTrack(track);
// Set the direction to indicate we're going to be sending.
// This is only necessary in the cases where we're upgrading
// the call to video after downgrading it.
transceiver.direction = transceiver.direction === "inactive" ? "sendonly" : "sendrecv";
added = true;
} catch (error) {
logger.warn(`replaceTrack failed: adding new transceiver instead`, error);
@@ -1349,7 +1369,12 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
await this.upgradeCall(false, true);
return this.isLocalVideoMuted();
}
this.localUsermediaFeed?.setAudioVideoMuted(null, muted);
if (this.opponentSupportsSDPStreamMetadata()) {
const stream = await this.client.getMediaHandler().getUserMediaStream(true, !muted);
await this.updateLocalUsermediaStream(stream);
} else {
this.localUsermediaFeed?.setAudioVideoMuted(null, muted);
}
this.updateMuteStatus();
await this.sendMetadataUpdate();
return this.isLocalVideoMuted();
@@ -2475,18 +2500,20 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
}
private stopAllMedia(): void {
logger.debug(
!this.groupCallId
? `Call ${this.callId} stopping all media`
: `Call ${this.callId} stopping all media except local feeds`,
);
logger.debug(`Call ${this.callId} stopping all media`);
for (const feed of this.feeds) {
if (feed.isLocal() && feed.purpose === SDPStreamMetadataPurpose.Usermedia && !this.groupCallId) {
// Slightly awkward as local feed need to go via the correct method on
// the mediahandler so they get removed from mediahandler (remote tracks
// don't)
// NB. We clone local streams when passing them to individual calls in a group
// call, so we can (and should) stop the clones once we no longer need them:
// the other clones will continue fine.
if (feed.isLocal() && feed.purpose === SDPStreamMetadataPurpose.Usermedia) {
this.client.getMediaHandler().stopUserMediaStream(feed.stream);
} else if (feed.isLocal() && feed.purpose === SDPStreamMetadataPurpose.Screenshare && !this.groupCallId) {
} else if (feed.isLocal() && feed.purpose === SDPStreamMetadataPurpose.Screenshare) {
this.client.getMediaHandler().stopScreensharingStream(feed.stream);
} else if (!feed.isLocal() || !this.groupCallId) {
} else if (!feed.isLocal()) {
logger.debug("Stopping remote stream", feed.stream.id);
for (const track of feed.stream.getTracks()) {
track.stop();
+38 -35
View File
@@ -55,7 +55,7 @@ export enum GroupCallEvent {
export type GroupCallEventHandlerMap = {
[GroupCallEvent.GroupCallStateChanged]: (newState: GroupCallState, oldState: GroupCallState) => void;
[GroupCallEvent.ActiveSpeakerChanged]: (activeSpeaker: CallFeed | undefined) => void;
[GroupCallEvent.CallsChanged]: (calls: Map<RoomMember, Map<string, MatrixCall>>) => void;
[GroupCallEvent.CallsChanged]: (calls: Map<string, Map<string, MatrixCall>>) => void;
[GroupCallEvent.UserMediaFeedsChanged]: (feeds: CallFeed[]) => void;
[GroupCallEvent.ScreenshareFeedsChanged]: (feeds: CallFeed[]) => void;
[GroupCallEvent.LocalScreenshareStateChanged]: (
@@ -197,11 +197,11 @@ export class GroupCall extends TypedEventEmitter<
public readonly screenshareFeeds: CallFeed[] = [];
public groupCallId: string;
private readonly calls = new Map<RoomMember, Map<string, MatrixCall>>(); // RoomMember -> device ID -> MatrixCall
private callHandlers = new Map<string, Map<string, ICallHandlers>>(); // User ID -> device ID -> handlers
private readonly calls = new Map<string, Map<string, MatrixCall>>(); // user_id -> device_id -> MatrixCall
private callHandlers = new Map<string, Map<string, ICallHandlers>>(); // user_id -> device_id -> ICallHandlers
private activeSpeakerLoopInterval?: ReturnType<typeof setTimeout>;
private retryCallLoopInterval?: ReturnType<typeof setTimeout>;
private retryCallCounts: Map<RoomMember, Map<string, number>> = new Map();
private retryCallCounts: Map<string, Map<string, number>> = new Map(); // user_id -> device_id -> count
private reEmitter: ReEmitter;
private transmitTimer: ReturnType<typeof setTimeout> | null = null;
private participantsExpirationTimer: ReturnType<typeof setTimeout> | null = null;
@@ -609,6 +609,9 @@ export class GroupCall extends TypedEventEmitter<
logger.log(
`groupCall ${this.groupCallId} setLocalVideoMuted stream ${this.localCallFeed.stream.id} muted ${muted}`,
);
const stream = await this.client.getMediaHandler().getUserMediaStream(true, !muted);
await this.updateLocalUsermediaStream(stream);
this.localCallFeed.setAudioVideoMuted(null, muted);
setTracksEnabled(this.localCallFeed.stream.getVideoTracks(), !muted);
} else {
@@ -728,18 +731,18 @@ export class GroupCall extends TypedEventEmitter<
return;
}
const opponent = newCall.getOpponentMember();
if (opponent === undefined) {
const opponentUserId = newCall.getOpponentMember()?.userId;
if (opponentUserId === undefined) {
logger.warn("Incoming call with no member. Ignoring.");
return;
}
const deviceMap = this.calls.get(opponent) ?? new Map<string, MatrixCall>();
const deviceMap = this.calls.get(opponentUserId) ?? new Map<string, MatrixCall>();
const prevCall = deviceMap.get(newCall.getOpponentDeviceId()!);
if (prevCall?.callId === newCall.callId) return;
logger.log(`GroupCall: incoming call from ${opponent.userId} with ID ${newCall.callId}`);
logger.log(`GroupCall: incoming call from ${opponentUserId} with ID ${newCall.callId}`);
if (prevCall) this.disposeCall(prevCall, CallErrorCode.Replaced);
@@ -747,7 +750,7 @@ export class GroupCall extends TypedEventEmitter<
newCall.answerWithCallFeeds(this.getLocalFeeds().map((feed) => feed.clone()));
deviceMap.set(newCall.getOpponentDeviceId()!, newCall);
this.calls.set(opponent, deviceMap);
this.calls.set(opponentUserId, deviceMap);
this.emit(GroupCallEvent.CallsChanged, this.calls);
};
@@ -775,38 +778,38 @@ export class GroupCall extends TypedEventEmitter<
private placeOutgoingCalls(): void {
let callsChanged = false;
for (const [member, participantMap] of this.participants) {
const callMap = this.calls.get(member) ?? new Map<string, MatrixCall>();
for (const [{ userId }, participantMap] of this.participants) {
const callMap = this.calls.get(userId) ?? new Map<string, MatrixCall>();
for (const [deviceId, participant] of participantMap) {
const prevCall = callMap.get(deviceId);
if (
prevCall?.getOpponentSessionId() !== participant.sessionId &&
this.wantsOutgoingCall(member.userId, deviceId)
this.wantsOutgoingCall(userId, deviceId)
) {
callsChanged = true;
if (prevCall !== undefined) {
logger.debug(`Replacing call ${prevCall.callId} to ${member.userId} ${deviceId}`);
logger.debug(`Replacing call ${prevCall.callId} to ${userId} ${deviceId}`);
this.disposeCall(prevCall, CallErrorCode.NewSession);
}
const newCall = createNewMatrixCall(this.client, this.room.roomId, {
invitee: member.userId,
invitee: userId,
opponentDeviceId: deviceId,
opponentSessionId: participant.sessionId,
groupCallId: this.groupCallId,
});
if (newCall === null) {
logger.error(`Failed to create call with ${member.userId} ${deviceId}`);
logger.error(`Failed to create call with ${userId} ${deviceId}`);
callMap.delete(deviceId);
} else {
this.initCall(newCall);
callMap.set(deviceId, newCall);
logger.debug(`Placing call to ${member.userId} ${deviceId} (session ${participant.sessionId})`);
logger.debug(`Placing call to ${userId} ${deviceId} (session ${participant.sessionId})`);
newCall
.placeCallWithCallFeeds(
@@ -819,7 +822,7 @@ export class GroupCall extends TypedEventEmitter<
}
})
.catch((e) => {
logger.warn(`Failed to place call to ${member.userId}`, e);
logger.warn(`Failed to place call to ${userId}`, e);
if (e instanceof CallError && e.code === GroupCallErrorCode.UnknownDevice) {
this.emit(GroupCallEvent.Error, e);
@@ -828,7 +831,7 @@ export class GroupCall extends TypedEventEmitter<
GroupCallEvent.Error,
new GroupCallError(
GroupCallErrorCode.PlaceCallFailed,
`Failed to place call to ${member.userId}`,
`Failed to place call to ${userId}`,
),
);
}
@@ -841,9 +844,9 @@ export class GroupCall extends TypedEventEmitter<
}
if (callMap.size > 0) {
this.calls.set(member, callMap);
this.calls.set(userId, callMap);
} else {
this.calls.delete(member);
this.calls.delete(userId);
}
}
@@ -865,9 +868,9 @@ export class GroupCall extends TypedEventEmitter<
private onRetryCallLoop = (): void => {
let needsRetry = false;
for (const [member, participantMap] of this.participants) {
const callMap = this.calls.get(member);
let retriesMap = this.retryCallCounts.get(member);
for (const [{ userId }, participantMap] of this.participants) {
const callMap = this.calls.get(userId);
let retriesMap = this.retryCallCounts.get(userId);
for (const [deviceId, participant] of participantMap) {
const call = callMap?.get(deviceId);
@@ -875,12 +878,12 @@ export class GroupCall extends TypedEventEmitter<
if (
call?.getOpponentSessionId() !== participant.sessionId &&
this.wantsOutgoingCall(member.userId, deviceId) &&
this.wantsOutgoingCall(userId, deviceId) &&
retries < 3
) {
if (retriesMap === undefined) {
retriesMap = new Map();
this.retryCallCounts.set(member, retriesMap);
this.retryCallCounts.set(userId, retriesMap);
}
retriesMap.set(deviceId, retries + 1);
needsRetry = true;
@@ -1020,36 +1023,36 @@ export class GroupCall extends TypedEventEmitter<
call.setLocalVideoMuted(videoMuted);
}
if (state === CallState.Connected) {
const opponent = call.getOpponentMember()!;
const retriesMap = this.retryCallCounts.get(opponent);
const opponentUserId = call.getOpponentMember()?.userId;
if (state === CallState.Connected && opponentUserId) {
const retriesMap = this.retryCallCounts.get(opponentUserId);
retriesMap?.delete(call.getOpponentDeviceId()!);
if (retriesMap?.size === 0) this.retryCallCounts.delete(opponent);
if (retriesMap?.size === 0) this.retryCallCounts.delete(opponentUserId);
}
};
private onCallHangup = (call: MatrixCall): void => {
if (call.hangupReason === CallErrorCode.Replaced) return;
const opponent = call.getOpponentMember() ?? this.room.getMember(call.invitee!)!;
const deviceMap = this.calls.get(opponent);
const opponentUserId = call.getOpponentMember()?.userId ?? this.room.getMember(call.invitee!)!.userId;
const deviceMap = this.calls.get(opponentUserId);
// Sanity check that this call is in fact in the map
if (deviceMap?.get(call.getOpponentDeviceId()!) === call) {
this.disposeCall(call, call.hangupReason as CallErrorCode);
deviceMap.delete(call.getOpponentDeviceId()!);
if (deviceMap.size === 0) this.calls.delete(opponent);
if (deviceMap.size === 0) this.calls.delete(opponentUserId);
this.emit(GroupCallEvent.CallsChanged, this.calls);
}
};
private onCallReplaced = (prevCall: MatrixCall, newCall: MatrixCall): void => {
const opponent = prevCall.getOpponentMember()!;
const opponentUserId = prevCall.getOpponentMember()!.userId;
let deviceMap = this.calls.get(opponent);
let deviceMap = this.calls.get(opponentUserId);
if (deviceMap === undefined) {
deviceMap = new Map();
this.calls.set(opponent, deviceMap);
this.calls.set(opponentUserId, deviceMap);
}
this.disposeCall(prevCall, CallErrorCode.Replaced);
+20 -14
View File
@@ -203,24 +203,30 @@ export class MediaHandler extends TypedEventEmitter<
let canReuseStream = true;
if (this.localUserMediaStream) {
// This figures out if we can reuse the current localUsermediaStream
// based on whether or not the "mute state" (presence of tracks of a
// given kind) matches what is being requested
if (shouldRequestAudio !== this.localUserMediaStream.getAudioTracks().length > 0) {
canReuseStream = false;
}
if (shouldRequestVideo !== this.localUserMediaStream.getVideoTracks().length > 0) {
canReuseStream = false;
}
// This code checks that the device ID is the same as the localUserMediaStream stream, but we update
// the localUserMediaStream whenever the device ID changes (apart from when restoring) so it's not
// clear why this would ever be different, unless there's a race.
if (shouldRequestAudio) {
if (
this.localUserMediaStream.getAudioTracks().length === 0 ||
this.localUserMediaStream.getAudioTracks()[0]?.getSettings()?.deviceId !== this.audioInput
) {
canReuseStream = false;
}
if (
shouldRequestAudio &&
this.localUserMediaStream.getAudioTracks()[0]?.getSettings()?.deviceId !== this.audioInput
) {
canReuseStream = false;
}
if (shouldRequestVideo) {
if (
this.localUserMediaStream.getVideoTracks().length === 0 ||
this.localUserMediaStream.getVideoTracks()[0]?.getSettings()?.deviceId !== this.videoInput
) {
canReuseStream = false;
}
if (
shouldRequestVideo &&
this.localUserMediaStream.getVideoTracks()[0]?.getSettings()?.deviceId !== this.videoInput
) {
canReuseStream = false;
}
} else {
canReuseStream = false;
+442 -485
View File
File diff suppressed because it is too large Load Diff