Compare commits

...

84 Commits

Author SHA1 Message Date
RiotRobot 2a5e5e6a59 v23.4.0 2023-02-28 10:25:34 +00:00
RiotRobot 68e354752b Prepare changelog for v23.4.0 2023-02-28 10:25:31 +00:00
RiotRobot 0f1f1db3d2 v23.4.0-rc.2 2023-02-22 11:07:38 +00:00
RiotRobot 77b91a45cb Prepare changelog for v23.4.0-rc.2 2023-02-22 11:07:35 +00:00
RiotRobot fe6add9396 Merge branch 'develop' into staging 2023-02-22 10:59:21 +00:00
renovate[bot] 2d59c4647d Update typescript-eslint monorepo to v5.52.0 (#3172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-22 10:19:53 +00:00
Florian Duros 1f0c6a6dc9 Add easy way to determine if the decryption failure is due to "DecryptionError: The sender has disabled encrypting to unverified devices." (#3167)
* Add isEncryptedDisabledForUnverifiedDevices in event.ts

* Add Tests

* Add isEncryptedDisabledForUnverifiedDevices properties to event

* Use WITHHELD_MESSAGES instead of hardcoded string

* Use getter instead of function

* Add documentation
2023-02-21 16:13:43 +00:00
RiotRobot c9b502fb0e v23.4.0-rc.1 2023-02-21 11:56:15 +00:00
RiotRobot 330fbaccfc Prepare changelog for v23.4.0-rc.1 2023-02-21 11:56:12 +00:00
Michael Telatynski 937f370655 Run matrix-react-sdk tests on merge queue (#3161)
* First attempt at merge queues for downstream testing

* Debug

* delint

* Fix typo

* Test

* Iterate

* Fix checkout

* rerun

* experiment

* Iterate

* Iterate

* iterate

* Iterate

* Finalise

* Disable coverage for downstream tests

* Update tests.yml

* Apply merge_queue trigger to other CI

* delint

* delint

* Add exception
2023-02-21 11:09:23 +00:00
Kerry a8ad3ed26d Polls: expose end event id on poll model (#3160) 2023-02-20 10:30:19 +00:00
Michael Telatynski decac58a18 Better type guard parseTopicContent (#3165) 2023-02-20 10:29:33 +00:00
Kerry 1a91ba59a6 Polls: count undecryptable poll relations (#3163) 2023-02-20 10:10:38 +00:00
Michael Telatynski 89df43a975 Fix predecessor types, nowhere does the spec say it can be null (#3159)
* Fix predecessor types, nowhere does the spec say it can be `null`

* Iterate

* Update comment

* update test
2023-02-16 09:38:36 +00:00
Michael Telatynski ad98706db4 Iterate types (#3156) 2023-02-15 13:38:41 +00:00
Will Hunt 195d1730bd Fix notification counts for encrypted rooms with ignored event rules (#3130)
* Validate vars early

* Split out unread  counts for total and highlight to different logic blocks

* Add tests for ignoring non notifying events

* Fix possibly incorrect tests?

* lint fix

* Refactor currentTotalCount

* Track Total locally too

* Lots of total count assumptions and comments

* Adjust for threading too

* Fixup tests

* a word

* lint fix
2023-02-15 11:25:13 +00:00
Richard van der Hoff db4bd907f8 Switch crypto.spec.ts away from TestClient and matrix-mock-request. (#3142)
I became sufficiently annoyed with matrix-mock-request that I decided to replace it with fetch-mock, which is what we use in matrix-react-sdk and is generally more powerful, easier to use, and actually maintained.

Unfortunately, we have a TestClient utility which is widely used and quite tightly integrated with matrix-mock-request. It wasn't going to be possible to change TestClient without changing all the tests that use it.

I also don't find TestClient particularly easy to use - it does a lot of stuff which I'm not convinced ought to be done for every single test.

So... I've introduced a couple of new classes (SyncResponder, E2EKeyReceiver) which do some of the useful bits of TestClient, but in a more granular way, and have switched crypto.spec.ts over so that rather than instantiating a TestClient for each test, it creates a MatrixClient directly and intercepts the endpoints necessary.
2023-02-15 10:39:24 +00:00
Kerry cdd7dbbb2b decrypt poll relations before processing (#3148) 2023-02-14 21:49:52 +00:00
RiotRobot 108f157324 Resetting package fields for development 2023-02-14 10:23:07 +00:00
RiotRobot 8da39ec8f4 Merge branch 'master' into develop 2023-02-14 10:23:02 +00:00
RiotRobot 182534288c v23.3.0 2023-02-14 10:21:24 +00:00
RiotRobot f81b7e5e6f Prepare changelog for v23.3.0 2023-02-14 10:21:21 +00:00
ElementRobot 6dda9e532d Include 'browser' in list of adjusted properties in release.sh (#3149) (#3151)
(cherry picked from commit 5e17626fe0)

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
2023-02-14 09:58:17 +00:00
Andy Balaam 5e17626fe0 Include 'browser' in list of adjusted properties in release.sh (#3149) 2023-02-14 09:50:55 +00:00
David Baker abc9c9dcb0 Merge pull request #3147 from matrix-org/dbkr/stop_ice_timer_on_terminate
Stop the ICE disconnected timer on call terminate
2023-02-13 17:35:45 +01:00
David Baker f346fcb056 Stop the ICE disconnected timer on call terminate
It just wasn't getting stopped, so if the call ended while ICE was
disconnected, we'd get confusing error messages after the call ended.
2023-02-13 16:15:57 +00:00
Eric Eastwood c67325ba07 Add matrix-org/jest linting (#2973) 2023-02-10 12:05:40 +01:00
renovate[bot] a063ae8ce7 Update jest monorepo to v29.4.1 (#3133)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-09 15:44:23 +00:00
renovate[bot] f9e5535492 Update typescript-eslint monorepo to v5.50.0 (#3136)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-09 16:29:03 +01:00
renovate[bot] 015d9c5c4f Update dependency @types/node to v18.11.19 (#3137)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-09 16:28:22 +01:00
Germain b6d40078d9 Clear notifications when we can infer read status from receipts (#3139) 2023-02-09 10:18:18 +00:00
David Baker b8a8f4850a Merge pull request #3123 from matrix-org/SimonBrandner/task/logging
Improve WebRTC logging
2023-02-08 17:10:40 +00:00
Šimon Brandner 1cc23d789c Add new tests to groupCall
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2023-02-08 17:24:15 +01:00
Janne Mareike Koschinski 5cf0bb46a4 Messages sent out of order after one message fails (#3131)
* Instead of skipping, bail out by clearing queue
* Allow additional status transition for events from QUEUED to NOT_SENT
2023-02-08 13:23:30 +01:00
renovate[bot] 16672b3d0c Update all non-major dependencies (#3132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-07 17:36:38 +00:00
RiotRobot f61db81961 v23.3.0-rc.1 2023-02-07 12:00:46 +00:00
RiotRobot e2a694115f Prepare changelog for v23.3.0-rc.1 2023-02-07 12:00:44 +00:00
Michael Telatynski 71cf812d24 Add @typescript-eslint/no-base-to-string (#3129) 2023-02-07 10:08:03 +00:00
Richard van der Hoff 8a3d7d5671 Element-R: log outgoing HTTP requests (#3127)
otherwise it's rather hard to see them, at least in Firefox.
2023-02-06 10:50:05 +00:00
Richard van der Hoff 2a363598dd Element-R: fix a bug which prevented encryption working after a reload (#3126)
Upgrade matrix-sdk-crypto-js, to pick up
https://github.com/matrix-org/matrix-rust-sdk/pull/1429
2023-02-03 16:16:32 +00:00
Richard van der Hoff 05bf6428bc Element-R: implement encryption of outgoing events (#3122)
This PR wires up the Rust-SDK into the event encryption path
2023-02-03 15:58:50 +00:00
Michael Telatynski e492a44dde Update typescript bits to aid matrix-react-sdk achieve noImplicitAny (#3079) 2023-02-03 14:01:53 +00:00
Richard van der Hoff 44d2e47f96 Fix invite processing on Element-R (#3121)
Currently, whenever we receive an invite on element R, it crashes the sync
loop. A quick fix to make it not do that.
2023-02-03 10:56:24 +00:00
Šimon Brandner 31459a5d63 Improve logging
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2023-02-03 11:40:48 +01:00
Richard van der Hoff 8f5db463e7 Element R: Fix obscure errors when we fail to decrypt to-device events (#3117)
Previously, if we failed to decrypt a to-device event, we would raise an
"expected a string" error when we later tried to decrypt it as a room
event. This at least makes the error clearer.
2023-02-02 08:28:44 +00:00
Kerry 4e8affafcc Poll model - page /relations results (#3073)
* first cut poll model

* process incoming poll relations

* allow alt event types in relations model

* allow alt event types in relations model

* remove unneccesary checks on remove relation

* comment

* Revert "allow alt event types in relations model"

This reverts commit e578d84464403d4a15ee8a7cf3ac643f4fb86d69.

* Revert "Revert "allow alt event types in relations model""

This reverts commit 515db7a8bc2df5a1c619a37c86e17ccbe287ba7a.

* basic handling for new poll relations

* tests

* test room.processPollEvents

* join processBeaconEvents and poll events in client

* tidy and set 23 copyrights

* use rooms instance of matrixClient

* tidy

* more copyright

* simplify processPollEvent code

* throw when poll start event has no roomId

* updates for events-sdk move

* more type changes for events-sdk changes

* page poll relation results

* validate poll end event senders

* reformatted copyright

* undo more comment reformatting

* test paging

* use correct pollstartevent type

* emit after updating _isFetchingResponses state

* make rootEvent public readonly

* fix poll end validation logic to allow poll creator to end poll regardless of redaction
2023-02-01 20:44:40 +00:00
Kerry 2800681bb1 Poll model - validate end events (#3072)
* first cut poll model

* process incoming poll relations

* allow alt event types in relations model

* allow alt event types in relations model

* remove unneccesary checks on remove relation

* comment

* Revert "allow alt event types in relations model"

This reverts commit e578d84464403d4a15ee8a7cf3ac643f4fb86d69.

* Revert "Revert "allow alt event types in relations model""

This reverts commit 515db7a8bc2df5a1c619a37c86e17ccbe287ba7a.

* basic handling for new poll relations

* tests

* test room.processPollEvents

* join processBeaconEvents and poll events in client

* tidy and set 23 copyrights

* use rooms instance of matrixClient

* tidy

* more copyright

* simplify processPollEvent code

* throw when poll start event has no roomId

* updates for events-sdk move

* more type changes for events-sdk changes

* validate poll end event senders

* reformatted copyright

* undo more comment reformatting

* fix poll end validation logic to allow poll creator to end poll regardless of redaction

* Update src/models/poll.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* correct creator == sender validationin poll end

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-02-01 20:32:37 +00:00
Andy Balaam b2a9e6f12f Handle optional last_known_event_id property in m.predecessor (#3119) 2023-02-01 14:31:07 +00:00
Andy Balaam fbd2c97f87 Move the logic of roomPredecessor into the RoomState class (#3114)
* Move the logic of roomPredecessor into the RoomState class

* Fix review comments
2023-02-01 10:44:32 +00:00
Germain 6c6304a620 Cleanup pre MSC3773 thread unread notif logic (#3115) 2023-01-31 16:59:13 +00:00
Richard van der Hoff 0c1d5f6b25 Element-R: implement remaining OutgoingMessage request types (#3083)
This is a follow-up to #3019: it implements the remaining two types of message types, now that rust SDK has sensibly-shaped types for them.
2023-01-31 15:44:14 +00:00
RiotRobot 1c26dc0233 Resetting package fields for development 2023-01-31 10:47:39 +00:00
RiotRobot a163a202e7 Merge branch 'master' into develop 2023-01-31 10:47:34 +00:00
RiotRobot e15cf9976f v23.2.0 2023-01-31 10:46:15 +00:00
RiotRobot bdc3926417 Prepare changelog for v23.2.0 2023-01-31 10:46:12 +00:00
Hubert Chathi 4f918f684e add support for stable identifier for fixed MAC in SAS verification (#3101) 2023-01-30 09:26:43 -05:00
Germain c142232f4d Stop labelling threads as experimental (#3064) 2023-01-30 11:25:27 +00:00
Šimon Brandner c9bc20aa4d Don't throw with no opponentDeviceInfo (#3107) 2023-01-26 16:28:56 +00:00
renovate[bot] 5c0cb3a536 fix(deps): update all non-major dependencies (#3099)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-26 14:50:36 +00:00
Andy Balaam 415576d0a0 Provide eventId as well as roomId from Room.findPredecessor (#3095) 2023-01-26 10:58:33 +00:00
Andy Balaam 4f9fad66e4 MSC3946 Dynamic room predecessors (#3042)
* Implement MSC3946 for getVisibleRooms

* Implement MSC3946 for getRoomUpgradeHistory
2023-01-26 10:28:07 +00:00
renovate[bot] ebd9854980 chore(deps): update dependency @types/jest to v29.2.6 (#3096)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-26 09:42:54 +00:00
renovate[bot] cb2fab64d8 chore(deps): update typescript-eslint monorepo to v5.48.2 (#3097)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-26 09:41:59 +00:00
renovate[bot] f446b49e49 fix(deps): update dependency @babel/runtime to v7.20.13 (#3100)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-26 09:41:40 +00:00
renovate[bot] 22f5e41058 chore(deps): update dependency @types/uuid to v9 (#3102)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-26 09:41:33 +00:00
renovate[bot] fee5a006f1 chore(deps): update dependency rimraf to v4 (#3103)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-26 09:41:21 +00:00
Kerry ef51ee28fd Poll model (#3036)
* first cut poll model

* process incoming poll relations

* allow alt event types in relations model

* allow alt event types in relations model

* remove unneccesary checks on remove relation

* comment

* Revert "allow alt event types in relations model"

This reverts commit e578d84464403d4a15ee8a7cf3ac643f4fb86d69.

* Revert "Revert "allow alt event types in relations model""

This reverts commit 515db7a8bc2df5a1c619a37c86e17ccbe287ba7a.

* basic handling for new poll relations

* tests

* test room.processPollEvents

* join processBeaconEvents and poll events in client

* tidy and set 23 copyrights

* use rooms instance of matrixClient

* tidy

* more copyright

* simplify processPollEvent code

* throw when poll start event has no roomId

* updates for events-sdk move

* more type changes for events-sdk changes

* comment
2023-01-26 02:07:55 +00:00
David Baker cb61345780 Merge pull request #3091 from matrix-org/dbkr/video_mute_no_renegotiate
Remove video tracks on video mute without renegotiating
2023-01-25 15:38:51 +00:00
David Baker a18d4e226e Actually check audio sender too 2023-01-25 15:07:51 +00:00
David Baker b09b33eb4c Add tests 2023-01-25 15:06:36 +00:00
Clark Fischer 5fedc06d7c Remove flaky test (#3098)
I introduced a flaky test to confirm that `MegolmEncryption#prepareToEncrypt`
didn't block the main thread too much, but it turns out that, when run in
varying environments, it tends to fail.

The same behavior is guaranteed by the following cancellation test - if the
thread is blocked, it can't be cancelled.

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

Signed-off-by: Clark Fischer <clark.fischer@gmail.com>
2023-01-25 14:27:02 +00:00
David Baker d8c9f6db33 Check we have both a sender and a track to send 2023-01-25 11:20:25 +00:00
David Baker 0af5fa0328 Actually check we have a sender, not just a transceiver 2023-01-25 11:16:19 +00:00
David Baker b328b72cd5 Move timeout clear to be with its friends 2023-01-25 11:07:14 +00:00
David Baker ce2a9d7036 Fix test 2023-01-25 10:59:03 +00:00
Andy Balaam 66ae985af5 Refactor getRoomUpgradeHistory to use Room.findPredecessorRoomId (#3090)
* Refactor getRoomUpgradeHistory to use Room.findPredecessorRoomId

* Simplify getRoomUpgradeHistory implementation a little
2023-01-25 09:48:56 +01:00
David Baker 1828e2849c Remove timer on call terminate 2023-01-24 20:30:10 +00:00
David Baker b4f8b0fe4f Remove video tracks on video mute without renegotiating 2023-01-24 18:19:19 +00:00
Robin 70656e954f Merge pull request #3035 from clarkf/megolm-cancellation
Reduce Megolm blocking and add cancellation
2023-01-24 12:26:23 -05:00
Robin 40a4c8d954 Merge branch 'develop' into megolm-cancellation 2023-01-24 12:20:42 -05:00
Andy Balaam 7ed787b86a Fix bug in getRoomUpgradeHistory's verifyLinks functionality (#3089) 2023-01-24 13:44:03 +00:00
Clark Fischer 1ee487a2ff Make prepareToEncrypt cancellable.
NOTE: This commit introduces a backwards-compatible API change.

Adds the ability to cancel `MegolmEncryption#prepareToEncrypt` by returning
a cancellation function. The bulk of the processing happens in
`getDevicesInRoom`, which now accepts a 'getter' that allows the caller to
indicate cancellation.

See https://github.com/matrix-org/matrix-js-sdk/issues/1255
Closes #1255

Signed-off-by: Clark Fischer <clark.fischer@gmail.com>
2023-01-16 10:23:13 -08:00
Clark Fischer b76e7ca782 Reduce blocking while pre-fetching Megolm keys
Currently, calling `Client#prepareToEncrypt` in a megolm room has the potential
to block for multiple seconds while it crunches numbers.

Sleeping for 0 seconds (approximating `setImmediate`) allows the engine to
process other events, updates, or re-renders in between checks.

See
- https://github.com/vector-im/element-web/issues/21612
- https://github.com/vector-im/element-web/issues/11836

Signed-off-by: Clark Fischer <clark.fischer@gmail.com>
2023-01-16 10:11:37 -08:00
Clark Fischer ddce1bcd28 Add async setImmediate util
Adds an async/promise-based version of `setImmediate`. Note that, despite being
poorly adopted, `setImmediate` is polyfilled, and should be more performant
than `sleep(0)`.

Signed-off-by: Clark Fischer <clark.fischer@gmail.com>
2023-01-16 10:11:37 -08:00
90 changed files with 5845 additions and 2220 deletions
+1 -1
View File
@@ -23,4 +23,4 @@ indent_size = 4
trim_trailing_whitespace = true
[*.{yml,yaml}]
indent_size = 2
indent_size = 4
+21 -1
View File
@@ -1,6 +1,9 @@
module.exports = {
plugins: ["matrix-org", "import", "jsdoc"],
extends: ["plugin:matrix-org/babel", "plugin:import/typescript"],
extends: ["plugin:matrix-org/babel", "plugin:matrix-org/jest", "plugin:import/typescript"],
parserOptions: {
project: ["./tsconfig.json"],
},
env: {
browser: true,
node: true,
@@ -60,6 +63,23 @@ module.exports = {
],
},
],
// Disabled tests are a reality for now but as soon as all of the xits are
// eliminated, we should enforce this.
"jest/no-disabled-tests": "off",
// TODO: There are many tests with invalid expects that should be fixed,
// https://github.com/matrix-org/matrix-js-sdk/issues/2976
"jest/valid-expect": "off",
// TODO: There are many cases to refactor away,
// https://github.com/matrix-org/matrix-js-sdk/issues/2978
"jest/no-conditional-expect": "off",
// Also treat "oldBackendOnly" as a test function.
// Used in some crypto tests.
"jest/no-standalone-expect": [
"error",
{
additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly"],
},
],
},
overrides: [
{
+3 -6
View File
@@ -2,13 +2,9 @@ name: Pull Request
on:
pull_request_target:
types: [opened, edited, labeled, unlabeled, synchronize]
merge_group:
types: [checks_requested]
workflow_call:
inputs:
labels:
type: string
default: "T-Defect,T-Deprecation,T-Enhancement,T-Task"
required: false
description: "No longer used, uses allchange logic now, will be removed at a later date"
secrets:
ELEMENT_BOT_TOKEN:
required: true
@@ -19,6 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: matrix-org/allchange@main
if: github.event_name != 'merge_group'
with:
ghToken: ${{ secrets.GITHUB_TOKEN }}
requireLabel: true
+2
View File
@@ -1,6 +1,8 @@
name: Static Analysis
on:
pull_request: {}
merge_group:
types: [checks_requested]
push:
branches: [develop, master]
concurrency:
+30 -8
View File
@@ -1,6 +1,8 @@
name: Tests
on:
pull_request: {}
merge_group:
types: [checks_requested]
push:
branches: [develop, master]
concurrency:
@@ -36,18 +38,19 @@ jobs:
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@v1
- name: Run tests with coverage and metrics
- name: Load metrics reporter
id: metrics
if: github.ref == 'refs/heads/develop'
run: |
yarn coverage --ci --reporters github-actions '--reporters=<rootDir>/spec/slowReporter.js' --max-workers ${{ steps.cpu-cores.outputs.count }} ./spec/${{ matrix.specs }}
mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
env:
JEST_SONAR_UNIQUE_OUTPUT_NAME: true
echo "extra-reporter='--reporters=<rootDir>/spec/slowReporter.js'" >> $GITHUB_OUTPUT
- name: Run tests with coverage
if: github.ref != 'refs/heads/develop'
- name: Run tests
run: |
yarn coverage --ci --reporters github-actions --max-workers ${{ steps.cpu-cores.outputs.count }} ./spec/${{ matrix.specs }}
yarn coverage \
--ci \
--reporters github-actions ${{ steps.metrics.outputs.extra-reporter }} \
--max-workers ${{ steps.cpu-cores.outputs.count }} \
./spec/${{ matrix.specs }}
mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
env:
JEST_SONAR_UNIQUE_OUTPUT_NAME: true
@@ -59,3 +62,22 @@ jobs:
path: |
coverage
!coverage/lcov-report
matrix-react-sdk:
name: Downstream test matrix-react-sdk
if: github.event_name == 'merge_group'
uses: matrix-org/matrix-react-sdk/.github/workflows/tests.yml@develop
with:
disable_coverage: true
matrix-js-sdk-sha: ${{ github.sha }}
# Hook for branch protection to work outside merge queues
downstream:
name: Downstream tests
runs-on: ubuntu-latest
if: always()
needs:
- matrix-react-sdk
steps:
- if: needs.matrix-react-sdk.result != 'skipped' && needs.matrix-react-sdk.result != 'success'
run: exit 1
+44 -2
View File
@@ -1,5 +1,47 @@
Changes in [23.2.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.2.0-rc.1) (2023-01-24)
============================================================================================================
Changes in [23.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.4.0) (2023-02-28)
==================================================================================================
## ✨ Features
* Add easy way to determine if the decryption failure is due to "DecryptionError: The sender has disabled encrypting to unverified devices." ([\#3167](https://github.com/matrix-org/matrix-js-sdk/pull/3167)). Contributed by @florianduros.
* Polls: expose end event id on poll model ([\#3160](https://github.com/matrix-org/matrix-js-sdk/pull/3160)). Contributed by @kerryarchibald.
* Polls: count undecryptable poll relations ([\#3163](https://github.com/matrix-org/matrix-js-sdk/pull/3163)). Contributed by @kerryarchibald.
## 🐛 Bug Fixes
* Fix spec compliance issue around encrypted `m.relates_to` ([\#3178](https://github.com/matrix-org/matrix-js-sdk/pull/3178)).
* Fix reactions in threads sometimes causing stuck notifications ([\#3146](https://github.com/matrix-org/matrix-js-sdk/pull/3146)). Fixes vector-im/element-web#24000. Contributed by @justjanne.
* Better type guard parseTopicContent ([\#3165](https://github.com/matrix-org/matrix-js-sdk/pull/3165)). Fixes matrix-org/element-web-rageshakes#20177 and matrix-org/element-web-rageshakes#20178.
* Fix a bug where events in encrypted rooms would sometimes erroneously increment the total unread counter after being processed locally. ([\#3130](https://github.com/matrix-org/matrix-js-sdk/pull/3130)). Fixes vector-im/element-web#24448. Contributed by @Half-Shot.
* Stop the ICE disconnected timer on call terminate ([\#3147](https://github.com/matrix-org/matrix-js-sdk/pull/3147)).
* Clear notifications when we can infer read status from receipts ([\#3139](https://github.com/matrix-org/matrix-js-sdk/pull/3139)). Fixes vector-im/element-web#23991.
* Messages sent out of order after one message fails ([\#3131](https://github.com/matrix-org/matrix-js-sdk/pull/3131)). Fixes vector-im/element-web#22885 and vector-im/element-web#18942. Contributed by @justjanne.
Changes in [23.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.3.0) (2023-02-14)
==================================================================================================
## ✨ Features
* Element-R: implement encryption of outgoing events ([\#3122](https://github.com/matrix-org/matrix-js-sdk/pull/3122)).
* Poll model - page /relations results ([\#3073](https://github.com/matrix-org/matrix-js-sdk/pull/3073)). Contributed by @kerryarchibald.
* Poll model - validate end events ([\#3072](https://github.com/matrix-org/matrix-js-sdk/pull/3072)). Contributed by @kerryarchibald.
* Handle optional last_known_event_id property in m.predecessor ([\#3119](https://github.com/matrix-org/matrix-js-sdk/pull/3119)). Contributed by @andybalaam.
* Add support for stable identifier for fixed MAC in SAS verification ([\#3101](https://github.com/matrix-org/matrix-js-sdk/pull/3101)).
* Provide eventId as well as roomId from Room.findPredecessor ([\#3095](https://github.com/matrix-org/matrix-js-sdk/pull/3095)). Contributed by @andybalaam.
* MSC3946 Dynamic room predecessors ([\#3042](https://github.com/matrix-org/matrix-js-sdk/pull/3042)). Contributed by @andybalaam.
* Poll model ([\#3036](https://github.com/matrix-org/matrix-js-sdk/pull/3036)). Contributed by @kerryarchibald.
* Remove video tracks on video mute without renegotiating ([\#3091](https://github.com/matrix-org/matrix-js-sdk/pull/3091)).
* Introduces a backwards-compatible API change. `MegolmEncrypter#prepareToEncrypt`'s return type has changed from `void` to `() => void`. ([\#3035](https://github.com/matrix-org/matrix-js-sdk/pull/3035)). Contributed by @clarkf.
## 🐛 Bug Fixes
* Stop the ICE disconnected timer on call terminate ([\#3147](https://github.com/matrix-org/matrix-js-sdk/pull/3147)).
* Clear notifications when we can infer read status from receipts ([\#3139](https://github.com/matrix-org/matrix-js-sdk/pull/3139)). Fixes vector-im/element-web#23991.
* Messages sent out of order after one message fails ([\#3131](https://github.com/matrix-org/matrix-js-sdk/pull/3131)). Fixes vector-im/element-web#22885 and vector-im/element-web#18942. Contributed by @justjanne.
* Element-R: fix a bug which prevented encryption working after a reload ([\#3126](https://github.com/matrix-org/matrix-js-sdk/pull/3126)).
* Element-R: Fix invite processing ([\#3121](https://github.com/matrix-org/matrix-js-sdk/pull/3121)).
* Don't throw with no `opponentDeviceInfo` ([\#3107](https://github.com/matrix-org/matrix-js-sdk/pull/3107)).
* Remove flaky megolm test ([\#3098](https://github.com/matrix-org/matrix-js-sdk/pull/3098)). Contributed by @clarkf.
* Fix "verifyLinks" functionality of getRoomUpgradeHistory ([\#3089](https://github.com/matrix-org/matrix-js-sdk/pull/3089)). Contributed by @andybalaam.
Changes in [23.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.2.0) (2023-01-31)
==================================================================================================
## ✨ Features
* Implement decryption via the rust sdk ([\#3074](https://github.com/matrix-org/matrix-js-sdk/pull/3074)).
+12 -8
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "23.2.0-rc.1",
"version": "23.4.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=16.0.0"
@@ -33,7 +33,7 @@
"matrix-org"
],
"main": "./lib/index.js",
"browser": "./src/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",
@@ -55,7 +55,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.2",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.3",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
@@ -84,11 +84,12 @@
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz",
"@types/bs58": "^4.0.1",
"@types/content-type": "^1.1.5",
"@types/debug": "^4.1.7",
"@types/domexception": "^4.0.0",
"@types/jest": "^29.0.0",
"@types/node": "18",
"@types/sdp-transform": "^2.4.5",
"@types/uuid": "7",
"@types/uuid": "9",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"allchange": "^1.0.6",
@@ -96,26 +97,29 @@
"babelify": "^10.0.0",
"better-docs": "^2.4.0-beta.9",
"browserify": "^17.0.0",
"debug": "^4.3.4",
"docdash": "^2.0.0",
"domexception": "^4.0.0",
"eslint": "8.31.0",
"eslint": "8.33.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^27.1.6",
"eslint-plugin-jsdoc": "^39.6.4",
"eslint-plugin-matrix-org": "^0.9.0",
"eslint-plugin-matrix-org": "^1.0.0",
"eslint-plugin-tsdoc": "^0.2.17",
"eslint-plugin-unicorn": "^45.0.0",
"exorcist": "^2.0.0",
"fake-indexeddb": "^4.0.0",
"fetch-mock-jest": "^1.5.1",
"jest": "^29.0.0",
"jest-environment-jsdom": "^29.0.0",
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"matrix-mock-request": "^2.5.0",
"prettier": "2.8.2",
"rimraf": "^3.0.2",
"prettier": "2.8.3",
"rimraf": "^4.0.0",
"terser": "^5.5.1",
"tsify": "^5.0.2",
"typedoc": "^0.23.20",
+1 -1
View File
@@ -11,7 +11,7 @@ jq --version > /dev/null || (echo "jq is required: please install it"; kill $$)
if [ "$(git branch -lr | grep origin/develop -c)" -ge 1 ]; then
# When merging to develop, we need revert the `main` and `typings` fields if we adjusted them previously.
for i in main typings
for i in main typings browser
do
# If a `lib` prefixed value is present, it means we adjusted the field
# earlier at publish time, so we should revert it now.
+1 -1
View File
@@ -180,7 +180,7 @@ yarn version --no-git-tag-version --new-version "$release"
# they exist). This small bit of gymnastics allows us to use the TypeScript
# source directly for development without needing to build before linting or
# testing.
for i in main typings
for i in main typings browser
do
lib_value=$(jq -r ".matrix_lib_$i" package.json)
if [ "$lib_value" != "null" ]; then
+25 -2
View File
@@ -16,11 +16,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// `expect` is allowed in helper functions which are called within `test`/`it` blocks
/* eslint-disable jest/no-standalone-expect */
// load olm before the sdk if possible
import "./olm-loader";
import MockHttpBackend from "matrix-mock-request";
import type { IDeviceKeys, IOneTimeKey } from "../src/@types/crypto";
import type { IE2EKeyReceiver } from "./test-utils/E2EKeyReceiver";
import { LocalStorageCryptoStore } from "../src/crypto/store/localStorage-crypto-store";
import { logger } from "../src/logger";
import { syncPromise } from "./test-utils/test-utils";
@@ -28,14 +33,18 @@ import { createClient, IStartClientOpts } from "../src/matrix";
import { ICreateClientOpts, IDownloadKeyResult, MatrixClient, PendingEventOrdering } from "../src/client";
import { MockStorageApi } from "./MockStorageApi";
import { encodeUri } from "../src/utils";
import { IDeviceKeys, IOneTimeKey } from "../src/crypto/dehydration";
import { IKeyBackupSession } from "../src/crypto/keybackup";
import { IKeysUploadResponse, IUploadKeysRequest } from "../src/client";
import { ISyncResponder } from "./test-utils/SyncResponder";
/**
* Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient
*
* @deprecated Avoid using this; it is tied too tightly to matrix-mock-request and is generally inconvenient to use.
* Instead, construct a MatrixClient manually, use fetch-mock-jest to intercept the HTTP requests, and
* use things like {@link E2EKeyReceiver} and {@link SyncResponder} to manage the requests.
*/
export class TestClient {
export class TestClient implements IE2EKeyReceiver, ISyncResponder {
public readonly httpBackend: MockHttpBackend;
public readonly client: MatrixClient;
public deviceKeys?: IDeviceKeys | null;
@@ -240,8 +249,22 @@ export class TestClient {
return this.deviceKeys!.keys[keyId];
}
/** Next time we see a sync request (or immediately, if there is one waiting), send the given response
*
* Calling this will register a response for `/sync`, and then, in the background, flush a single `/sync` request.
* Try calling {@link syncPromise} to wait for the sync to complete.
*
* @param response - response to /sync request
*/
public sendOrQueueSyncResponse(syncResponse: object): void {
this.httpBackend.when("GET", "/sync").respond(200, syncResponse);
this.httpBackend.flush("/sync", 1);
}
/**
* flush a single /sync request, and wait for the syncing event
*
* @deprecated: prefer to use {@link #sendOrQueueSyncResponse} followed by {@link syncPromise}.
*/
public flushSync(): Promise<void> {
logger.log(`${this}: flushSync`);
+534 -449
View File
File diff suppressed because it is too large Load Diff
@@ -175,7 +175,7 @@ describe("MatrixClient events", function () {
});
});
it("should emit User events", function (done) {
it("should emit User events", async () => {
httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA);
httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA);
let fired = false;
@@ -192,10 +192,8 @@ describe("MatrixClient events", function () {
});
client!.startClient();
httpBackend!.flushAllExpected().then(function () {
expect(fired).toBe(true);
done();
});
await httpBackend!.flushAllExpected();
expect(fired).toBe(true);
});
it("should emit Room events", function () {
+14 -14
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.
@@ -590,7 +590,7 @@ describe("MatrixClient event timelines", function () {
it("should handle thread replies with server support by fetching a contiguous thread timeline", async () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
await client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
@@ -647,7 +647,7 @@ describe("MatrixClient event timelines", function () {
it("should return relevant timeline from non-thread timelineSet when asking for the thread root", async () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
@@ -680,7 +680,7 @@ describe("MatrixClient event timelines", function () {
it("should return undefined when event is not in the thread that the given timelineSet is representing", () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
@@ -709,7 +709,7 @@ describe("MatrixClient event timelines", function () {
it("should return undefined when event is within a thread but timelineSet is not", () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
@@ -1127,7 +1127,7 @@ describe("MatrixClient event timelines", function () {
};
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
@@ -1263,7 +1263,7 @@ describe("MatrixClient event timelines", function () {
describe("with server compatibility", function () {
beforeEach(() => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
@@ -1421,7 +1421,7 @@ describe("MatrixClient event timelines", function () {
};
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
@@ -1473,7 +1473,7 @@ describe("MatrixClient event timelines", function () {
describe("without server compatibility", function () {
beforeEach(() => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
Thread.setServerSideListSupport(FeatureSupport.None);
});
@@ -1539,7 +1539,7 @@ describe("MatrixClient event timelines", function () {
it("should add lazy loading filter", async () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
Thread.setServerSideListSupport(FeatureSupport.Stable);
// @ts-ignore
@@ -1567,7 +1567,7 @@ describe("MatrixClient event timelines", function () {
it("should correctly pass pagination token", async () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
Thread.setServerSideListSupport(FeatureSupport.Stable);
@@ -1892,7 +1892,7 @@ describe("MatrixClient event timelines", function () {
it("in stable mode", async () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
@@ -1902,7 +1902,7 @@ describe("MatrixClient event timelines", function () {
it("in backwards compatible unstable mode", async () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
Thread.setServerSideListSupport(FeatureSupport.Experimental);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Experimental);
@@ -1912,7 +1912,7 @@ describe("MatrixClient event timelines", function () {
it("in backwards compatible mode", async () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
Thread.setServerSideListSupport(FeatureSupport.None);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.None);
+24 -38
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.
@@ -35,7 +35,7 @@ describe("MatrixClient", function () {
let store: MemoryStore | undefined;
const defaultClientOpts: IStoredClientOpts = {
experimentalThreadSupport: false,
threadSupport: false,
};
const setupTests = (): [MatrixClient, HttpBackend, MemoryStore] => {
const store = new MemoryStore();
@@ -205,19 +205,17 @@ describe("MatrixClient", function () {
describe("getFilter", function () {
const filterId = "f1lt3r1d";
it("should return a filter from the store if allowCached", function (done) {
it("should return a filter from the store if allowCached", async () => {
const filter = Filter.fromJson(userId, filterId, {
event_format: "client",
});
store!.storeFilter(filter);
client!.getFilter(userId, filterId, true).then(function (gotFilter) {
expect(gotFilter).toEqual(filter);
done();
});
const gotFilter = await client!.getFilter(userId, filterId, true);
expect(gotFilter).toEqual(filter);
httpBackend!.verifyNoOutstandingRequests();
});
it("should do an HTTP request if !allowCached even if one exists", function (done) {
it("should do an HTTP request if !allowCached even if one exists", async () => {
const httpFilterDefinition = {
event_format: "federation",
};
@@ -230,15 +228,11 @@ describe("MatrixClient", function () {
event_format: "client",
});
store!.storeFilter(storeFilter);
client!.getFilter(userId, filterId, false).then(function (gotFilter) {
expect(gotFilter.getDefinition()).toEqual(httpFilterDefinition);
done();
});
httpBackend!.flush("");
const [gotFilter] = await Promise.all([client!.getFilter(userId, filterId, false), httpBackend!.flush("")]);
expect(gotFilter.getDefinition()).toEqual(httpFilterDefinition);
});
it("should do an HTTP request if nothing is in the cache and then store it", function (done) {
it("should do an HTTP request if nothing is in the cache and then store it", async () => {
const httpFilterDefinition = {
event_format: "federation",
};
@@ -247,20 +241,16 @@ describe("MatrixClient", function () {
httpBackend!
.when("GET", "/user/" + encodeURIComponent(userId) + "/filter/" + filterId)
.respond(200, httpFilterDefinition);
client!.getFilter(userId, filterId, true).then(function (gotFilter) {
expect(gotFilter.getDefinition()).toEqual(httpFilterDefinition);
expect(store!.getFilter(userId, filterId)).toBeTruthy();
done();
});
httpBackend!.flush("");
const [gotFilter] = await Promise.all([client!.getFilter(userId, filterId, true), httpBackend!.flush("")]);
expect(gotFilter.getDefinition()).toEqual(httpFilterDefinition);
expect(store!.getFilter(userId, filterId)).toBeTruthy();
});
});
describe("createFilter", function () {
const filterId = "f1llllllerid";
it("should do an HTTP request and then store the filter", function (done) {
it("should do an HTTP request and then store the filter", async () => {
expect(store!.getFilter(userId, filterId)).toBe(null);
const filterDefinition = {
@@ -276,13 +266,9 @@ describe("MatrixClient", function () {
filter_id: filterId,
});
client!.createFilter(filterDefinition).then(function (gotFilter) {
expect(gotFilter.getDefinition()).toEqual(filterDefinition);
expect(store!.getFilter(userId, filterId)).toEqual(gotFilter);
done();
});
httpBackend!.flush("");
const [gotFilter] = await Promise.all([client!.createFilter(filterDefinition), httpBackend!.flush("")]);
expect(gotFilter.getDefinition()).toEqual(filterDefinition);
expect(store!.getFilter(userId, filterId)).toEqual(gotFilter);
});
});
@@ -671,7 +657,7 @@ describe("MatrixClient", function () {
// @ts-ignore setting private property
client!.clientOpts = {
...defaultClientOpts,
experimentalThreadSupport: true,
threadSupport: true,
};
const eventPollResponseReference = buildEventPollResponseReference();
@@ -702,7 +688,7 @@ describe("MatrixClient", function () {
// @ts-ignore setting private property
client!.clientOpts = {
...defaultClientOpts,
experimentalThreadSupport: true,
threadSupport: true,
};
const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
@@ -726,7 +712,7 @@ describe("MatrixClient", function () {
// @ts-ignore setting private property
client!.clientOpts = {
...defaultClientOpts,
experimentalThreadSupport: true,
threadSupport: true,
};
const eventPollResponseReference = buildEventPollResponseReference();
@@ -750,7 +736,7 @@ describe("MatrixClient", function () {
// @ts-ignore setting private property
client!.clientOpts = {
...defaultClientOpts,
experimentalThreadSupport: true,
threadSupport: true,
};
const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
@@ -774,7 +760,7 @@ describe("MatrixClient", function () {
// @ts-ignore setting private property
client!.clientOpts = {
...defaultClientOpts,
experimentalThreadSupport: true,
threadSupport: true,
};
// This is based on recording the events in a real room:
@@ -831,7 +817,7 @@ describe("MatrixClient", function () {
// @ts-ignore setting private property
client!.clientOpts = {
...defaultClientOpts,
experimentalThreadSupport: true,
threadSupport: true,
};
const threadRootEvent = buildEventPollStartThreadRoot();
@@ -857,7 +843,7 @@ describe("MatrixClient", function () {
// @ts-ignore setting private property
client!.clientOpts = {
...defaultClientOpts,
experimentalThreadSupport: true,
threadSupport: true,
};
const threadRootEvent = buildEventPollStartThreadRoot();
@@ -878,7 +864,7 @@ describe("MatrixClient", function () {
// @ts-ignore setting private property
client!.clientOpts = {
...defaultClientOpts,
experimentalThreadSupport: true,
threadSupport: true,
};
const threadRootEvent = buildEventPollStartThreadRoot();
+37 -31
View File
@@ -94,16 +94,16 @@ describe("MatrixClient opts", function () {
client.stopClient();
});
it("should be able to send messages", function (done) {
it("should be able to send messages", async () => {
const eventId = "$flibble:wibble";
httpBackend.when("PUT", "/txn1").respond(200, {
event_id: eventId,
});
client.sendTextMessage("!foo:bar", "a body", "txn1").then(function (res) {
expect(res.event_id).toEqual(eventId);
done();
});
httpBackend.flush("/txn1", 1);
const [res] = await Promise.all([
client.sendTextMessage("!foo:bar", "a body", "txn1"),
httpBackend.flush("/txn1", 1),
]);
expect(res.event_id).toEqual(eventId);
});
it("should be able to sync / get new events", async function () {
@@ -149,7 +149,7 @@ describe("MatrixClient opts", function () {
client.stopClient();
});
it("shouldn't retry sending events", function (done) {
it("shouldn't retry sending events", async () => {
httpBackend.when("PUT", "/txn1").respond(
500,
new MatrixError({
@@ -157,19 +157,17 @@ describe("MatrixClient opts", function () {
error: "Ruh roh",
}),
);
client.sendTextMessage("!foo:bar", "a body", "txn1").then(
function (res) {
expect(false).toBe(true);
},
function (err) {
expect(err.errcode).toEqual("M_SOMETHING");
done();
},
);
httpBackend.flush("/txn1", 1);
try {
await Promise.all([
expect(client.sendTextMessage("!foo:bar", "a body", "txn1")).rejects.toThrow(),
httpBackend.flush("/txn1", 1),
]);
} catch (err) {
expect((<MatrixError>err).errcode).toEqual("M_SOMETHING");
}
});
it("shouldn't queue events", function (done) {
it("shouldn't queue events", async () => {
httpBackend.when("PUT", "/txn1").respond(200, {
event_id: "AAA",
});
@@ -178,30 +176,38 @@ describe("MatrixClient opts", function () {
});
let sentA = false;
let sentB = false;
client.sendTextMessage("!foo:bar", "a body", "txn1").then(function (res) {
const messageASendPromise = client.sendTextMessage("!foo:bar", "a body", "txn1").then(function (res) {
sentA = true;
// We expect messageB to be sent before messageA to ensure as we're
// testing that there is no queueing that blocks each other
expect(sentB).toBe(true);
});
client.sendTextMessage("!foo:bar", "b body", "txn2").then(function (res) {
const messageBSendPromise = client.sendTextMessage("!foo:bar", "b body", "txn2").then(function (res) {
sentB = true;
// We expect messageB to be sent before messageA to ensure as we're
// testing that there is no queueing that blocks each other
expect(sentA).toBe(false);
});
httpBackend.flush("/txn2", 1).then(function () {
httpBackend.flush("/txn1", 1).then(function () {
done();
});
});
// Allow messageB to succeed first
await httpBackend.flush("/txn2", 1);
// Then allow messageA to succeed
await httpBackend.flush("/txn1", 1);
// Now await the message send promises to
await messageBSendPromise;
await messageASendPromise;
});
it("should be able to send messages", function (done) {
it("should be able to send messages", async () => {
httpBackend.when("PUT", "/txn1").respond(200, {
event_id: "foo",
});
client.sendTextMessage("!foo:bar", "a body", "txn1").then(function (res) {
expect(res.event_id).toEqual("foo");
done();
});
httpBackend.flush("/txn1", 1);
const [res] = await Promise.all([
client.sendTextMessage("!foo:bar", "a body", "txn1"),
httpBackend.flush("/txn1", 1),
]);
expect(res.event_id).toEqual("foo");
});
});
});
+6 -6
View File
@@ -48,13 +48,13 @@ describe("MatrixClient retrying", function () {
return httpBackend!.stop();
});
xit("should retry according to MatrixScheduler.retryFn", function () {});
it.skip("should retry according to MatrixScheduler.retryFn", function () {});
xit("should queue according to MatrixScheduler.queueFn", function () {});
it.skip("should queue according to MatrixScheduler.queueFn", function () {});
xit("should mark events as EventStatus.NOT_SENT when giving up", function () {});
it.skip("should mark events as EventStatus.NOT_SENT when giving up", function () {});
xit("should mark events as EventStatus.QUEUED when queued", function () {});
it.skip("should mark events as EventStatus.QUEUED when queued", function () {});
it("should mark events as EventStatus.CANCELLED when cancelled", function () {
// send a couple of events; the second will be queued
@@ -130,7 +130,7 @@ describe("MatrixClient retrying", function () {
});
describe("resending", function () {
xit("should be able to resend a NOT_SENT event", function () {});
xit("should be able to resend a sent event", function () {});
it.skip("should be able to resend a NOT_SENT event", function () {});
it.skip("should be able to resend a sent event", function () {});
});
});
+105 -102
View File
@@ -163,36 +163,38 @@ describe("MatrixClient room timelines", function () {
it(
"should be added immediately after calling MatrixClient.sendEvent " +
"with EventStatus.SENDING and the right event.sender",
function (done) {
client!.on(ClientEvent.Sync, function (state) {
if (state !== "PREPARED") {
return;
}
const room = client!.getRoom(roomId)!;
expect(room.timeline.length).toEqual(1);
async () => {
const wasMessageAddedPromise = new Promise((resolve) => {
client!.on(ClientEvent.Sync, async (state) => {
if (state !== "PREPARED") {
return;
}
const room = client!.getRoom(roomId)!;
expect(room.timeline.length).toEqual(1);
client!.sendTextMessage(roomId, "I am a fish", "txn1");
// check it was added
expect(room.timeline.length).toEqual(2);
// check status
expect(room.timeline[1].status).toEqual(EventStatus.SENDING);
// check member
const member = room.timeline[1].sender;
expect(member?.userId).toEqual(userId);
expect(member?.name).toEqual(userName);
client!.sendTextMessage(roomId, "I am a fish", "txn1");
// check it was added
expect(room.timeline.length).toEqual(2);
// check status
expect(room.timeline[1].status).toEqual(EventStatus.SENDING);
// check member
const member = room.timeline[1].sender;
expect(member?.userId).toEqual(userId);
expect(member?.name).toEqual(userName);
httpBackend!.flush("/sync", 1).then(function () {
done();
await httpBackend!.flush("/sync", 1);
resolve(null);
});
});
httpBackend!.flush("/sync", 1);
await httpBackend!.flush("/sync", 1);
await wasMessageAddedPromise;
},
);
it(
"should be updated correctly when the send request finishes " +
"BEFORE the event comes down the event stream",
function (done) {
async () => {
const eventId = "$foo:bar";
httpBackend!.when("PUT", "/txn1").respond(200, {
event_id: eventId,
@@ -207,28 +209,30 @@ describe("MatrixClient room timelines", function () {
ev.unsigned = { transaction_id: "txn1" };
setNextSyncData([ev]);
client!.on(ClientEvent.Sync, function (state) {
if (state !== "PREPARED") {
return;
}
const room = client!.getRoom(roomId)!;
client!.sendTextMessage(roomId, "I am a fish", "txn1").then(function () {
expect(room.timeline[1].getId()).toEqual(eventId);
httpBackend!.flush("/sync", 1).then(function () {
const wasMessageAddedPromise = new Promise((resolve) => {
client!.on(ClientEvent.Sync, function (state) {
if (state !== "PREPARED") {
return;
}
const room = client!.getRoom(roomId)!;
client!.sendTextMessage(roomId, "I am a fish", "txn1").then(async () => {
expect(room.timeline[1].getId()).toEqual(eventId);
done();
await httpBackend!.flush("/sync", 1);
expect(room.timeline[1].getId()).toEqual(eventId);
resolve(null);
});
httpBackend!.flush("/txn1", 1);
});
httpBackend!.flush("/txn1", 1);
});
httpBackend!.flush("/sync", 1);
await httpBackend!.flush("/sync", 1);
await wasMessageAddedPromise;
},
);
it(
"should be updated correctly when the send request finishes " +
"AFTER the event comes down the event stream",
function (done) {
async () => {
const eventId = "$foo:bar";
httpBackend!.when("PUT", "/txn1").respond(200, {
event_id: eventId,
@@ -243,23 +247,24 @@ describe("MatrixClient room timelines", function () {
ev.unsigned = { transaction_id: "txn1" };
setNextSyncData([ev]);
client!.on(ClientEvent.Sync, function (state) {
if (state !== "PREPARED") {
return;
}
const room = client!.getRoom(roomId)!;
const promise = client!.sendTextMessage(roomId, "I am a fish", "txn1");
httpBackend!.flush("/sync", 1).then(function () {
const wasMessageAddedPromise = new Promise((resolve) => {
client!.on(ClientEvent.Sync, async (state) => {
if (state !== "PREPARED") {
return;
}
const room = client!.getRoom(roomId)!;
const messageSendPromise = client!.sendTextMessage(roomId, "I am a fish", "txn1");
await httpBackend!.flush("/sync", 1);
expect(room.timeline.length).toEqual(2);
httpBackend!.flush("/txn1", 1);
promise.then(function () {
expect(room.timeline.length).toEqual(2);
expect(room.timeline[1].getId()).toEqual(eventId);
done();
});
await messageSendPromise;
expect(room.timeline.length).toEqual(2);
expect(room.timeline[1].getId()).toEqual(eventId);
resolve(null);
});
});
httpBackend!.flush("/sync", 1);
await httpBackend!.flush("/sync", 1);
await wasMessageAddedPromise;
},
);
});
@@ -279,30 +284,29 @@ describe("MatrixClient room timelines", function () {
});
});
it("should set Room.oldState.paginationToken to null at the start" + " of the timeline.", function (done) {
client!.on(ClientEvent.Sync, function (state) {
if (state !== "PREPARED") {
return;
}
const room = client!.getRoom(roomId)!;
expect(room.timeline.length).toEqual(1);
it("should set Room.oldState.paginationToken to null at the start of the timeline.", async () => {
const didPaginatePromise = new Promise((resolve) => {
client!.on(ClientEvent.Sync, async (state) => {
if (state !== "PREPARED") {
return;
}
const room = client!.getRoom(roomId)!;
expect(room.timeline.length).toEqual(1);
client!.scrollback(room).then(function () {
await Promise.all([client!.scrollback(room), httpBackend!.flush("/messages", 1)]);
expect(room.timeline.length).toEqual(1);
expect(room.oldState.paginationToken).toBe(null);
// still have a sync to flush
httpBackend!.flush("/sync", 1).then(() => {
done();
});
await httpBackend!.flush("/sync", 1);
resolve(null);
});
httpBackend!.flush("/messages", 1);
});
httpBackend!.flush("/sync", 1);
await httpBackend!.flush("/sync", 1);
await didPaginatePromise;
});
it("should set the right event.sender values", function (done) {
it("should set the right event.sender values", async () => {
// We're aiming for an eventual timeline of:
//
// 'Old Alice' joined the room
@@ -353,15 +357,17 @@ describe("MatrixClient room timelines", function () {
joinMshipEvent,
];
client!.on(ClientEvent.Sync, function (state) {
if (state !== "PREPARED") {
return;
}
const room = client!.getRoom(roomId)!;
// sync response
expect(room.timeline.length).toEqual(1);
const didPaginatePromise = new Promise((resolve) => {
client!.on(ClientEvent.Sync, async (state) => {
if (state !== "PREPARED") {
return;
}
const room = client!.getRoom(roomId)!;
// sync response
expect(room.timeline.length).toEqual(1);
await Promise.all([client!.scrollback(room), httpBackend!.flush("/messages", 1)]);
client!.scrollback(room).then(function () {
expect(room.timeline.length).toEqual(5);
const joinMsg = room.timeline[0];
expect(joinMsg.sender?.name).toEqual("Old Alice");
@@ -371,17 +377,15 @@ describe("MatrixClient room timelines", function () {
expect(newMsg.sender?.name).toEqual(userName);
// still have a sync to flush
httpBackend!.flush("/sync", 1).then(() => {
done();
});
await httpBackend!.flush("/sync", 1);
resolve(null);
});
httpBackend!.flush("/messages", 1);
});
httpBackend!.flush("/sync", 1);
await httpBackend!.flush("/sync", 1);
await didPaginatePromise;
});
it("should add it them to the right place in the timeline", function (done) {
it("should add it them to the right place in the timeline", async () => {
// set the list of events to return on scrollback
sbEvents = [
utils.mkMessage({
@@ -396,30 +400,30 @@ describe("MatrixClient room timelines", function () {
}),
];
client!.on(ClientEvent.Sync, function (state) {
if (state !== "PREPARED") {
return;
}
const room = client!.getRoom(roomId)!;
expect(room.timeline.length).toEqual(1);
const didPaginatePromise = new Promise((resolve) => {
client!.on(ClientEvent.Sync, async (state) => {
if (state !== "PREPARED") {
return;
}
const room = client!.getRoom(roomId)!;
expect(room.timeline.length).toEqual(1);
await Promise.all([client!.scrollback(room), httpBackend!.flush("/messages", 1)]);
client!.scrollback(room).then(function () {
expect(room.timeline.length).toEqual(3);
expect(room.timeline[0].event).toEqual(sbEvents[1]);
expect(room.timeline[1].event).toEqual(sbEvents[0]);
// still have a sync to flush
httpBackend!.flush("/sync", 1).then(() => {
done();
});
await httpBackend!.flush("/sync", 1);
resolve(null);
});
httpBackend!.flush("/messages", 1);
});
httpBackend!.flush("/sync", 1);
await httpBackend!.flush("/sync", 1);
await didPaginatePromise;
});
it("should use 'end' as the next pagination token", function (done) {
it("should use 'end' as the next pagination token", async () => {
// set the list of events to return on scrollback
sbEvents = [
utils.mkMessage({
@@ -429,25 +433,24 @@ describe("MatrixClient room timelines", function () {
}),
];
client!.on(ClientEvent.Sync, function (state) {
if (state !== "PREPARED") {
return;
}
const room = client!.getRoom(roomId)!;
expect(room.oldState.paginationToken).toBeTruthy();
const didPaginatePromise = new Promise((resolve) => {
client!.on(ClientEvent.Sync, async (state) => {
if (state !== "PREPARED") {
return;
}
const room = client!.getRoom(roomId)!;
expect(room.oldState.paginationToken).toBeTruthy();
client!.scrollback(room, 1).then(function () {
await Promise.all([client!.scrollback(room, 1), httpBackend!.flush("/messages", 1)]);
expect(room.oldState.paginationToken).toEqual(sbEndTok);
});
httpBackend!.flush("/messages", 1).then(function () {
// still have a sync to flush
httpBackend!.flush("/sync", 1).then(() => {
done();
});
await httpBackend!.flush("/sync", 1);
resolve(null);
});
});
httpBackend!.flush("/sync", 1);
await httpBackend!.flush("/sync", 1);
await didPaginatePromise;
});
});
+16 -22
View File
@@ -81,17 +81,15 @@ describe("MatrixClient syncing", () => {
presence: {},
};
it("should /sync after /pushrules and /filter.", (done) => {
it("should /sync after /pushrules and /filter.", async () => {
httpBackend!.when("GET", "/sync").respond(200, syncData);
client!.startClient();
httpBackend!.flushAllExpected().then(() => {
done();
});
await httpBackend!.flushAllExpected();
});
it("should pass the 'next_batch' token from /sync to the since= param of the next /sync", (done) => {
it("should pass the 'next_batch' token from /sync to the since= param of the next /sync", async () => {
httpBackend!.when("GET", "/sync").respond(200, syncData);
httpBackend!
.when("GET", "/sync")
@@ -102,9 +100,7 @@ describe("MatrixClient syncing", () => {
client!.startClient();
httpBackend!.flushAllExpected().then(() => {
done();
});
await httpBackend!.flushAllExpected();
});
it("should emit RoomEvent.MyMembership for invite->leave->invite cycles", async () => {
@@ -724,7 +720,7 @@ describe("MatrixClient syncing", () => {
// events that arrive in the incremental sync as if they preceeded the
// timeline events, however this breaks peeking, so it's disabled
// (see sync.js)
xit("should correctly interpret state in incremental sync.", () => {
it.skip("should correctly interpret state in incremental sync.", () => {
httpBackend!.when("GET", "/sync").respond(200, syncData);
httpBackend!.when("GET", "/sync").respond(200, nextSyncData);
@@ -741,9 +737,9 @@ describe("MatrixClient syncing", () => {
});
});
xit("should update power levels for users in a room", () => {});
it.skip("should update power levels for users in a room", () => {});
xit("should update the room topic", () => {});
it.skip("should update the room topic", () => {});
describe("onMarkerStateEvent", () => {
const normalMessageEvent = utils.mkMessage({
@@ -840,6 +836,7 @@ describe("MatrixClient syncing", () => {
roomVersion: "org.matrix.msc2716v3",
},
].forEach((testMeta) => {
// eslint-disable-next-line jest/valid-title
describe(testMeta.label, () => {
const roomCreateEvent = utils.mkEvent({
type: "m.room.create",
@@ -1592,27 +1589,24 @@ describe("MatrixClient syncing", () => {
});
describe("of a room", () => {
xit(
it.skip(
"should sync when a join event (which changes state) for the user" +
" arrives down the event stream (e.g. join from another device)",
() => {},
);
xit("should sync when the user explicitly calls joinRoom", () => {});
it.skip("should sync when the user explicitly calls joinRoom", () => {});
});
describe("syncLeftRooms", () => {
beforeEach((done) => {
beforeEach(async () => {
client!.startClient();
httpBackend!.flushAllExpected().then(() => {
// the /sync call from syncLeftRooms ends up in the request
// queue behind the call from the running client; add a response
// to flush the client's one out.
httpBackend!.when("GET", "/sync").respond(200, {});
done();
});
await httpBackend!.flushAllExpected();
// the /sync call from syncLeftRooms ends up in the request
// queue behind the call from the running client; add a response
// to flush the client's one out.
await httpBackend!.when("GET", "/sync").respond(200, {});
});
it("should create and use an appropriate filter", () => {
@@ -0,0 +1,290 @@
/*
Copyright 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.
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 HttpBackend from "matrix-mock-request";
import { Category, ISyncResponse, MatrixClient, NotificationCountType, Room } from "../../src";
import { TestClient } from "../TestClient";
describe("MatrixClient syncing", () => {
const userA = "@alice:localhost";
const userB = "@bob:localhost";
const selfUserId = userA;
const selfAccessToken = "aseukfgwef";
let client: MatrixClient | undefined;
let httpBackend: HttpBackend | undefined;
const setupTestClient = (): [MatrixClient, HttpBackend] => {
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
const httpBackend = testClient.httpBackend;
const client = testClient.client;
httpBackend!.when("GET", "/versions").respond(200, {});
httpBackend!.when("GET", "/pushrules").respond(200, {});
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
return [client, httpBackend];
};
beforeEach(() => {
[client, httpBackend] = setupTestClient();
});
afterEach(() => {
httpBackend!.verifyNoOutstandingExpectation();
client!.stopClient();
return httpBackend!.stop();
});
describe("Stuck unread notifications integration tests", () => {
const ROOM_ID = "!room:localhost";
const syncData = getSampleStuckNotificationSyncResponse(ROOM_ID);
it("resets notifications if the last event originates from the logged in user", async () => {
httpBackend!
.when("GET", "/sync")
.check((req) => {
expect(req.queryParams!.filter).toEqual("a filter id");
})
.respond(200, syncData);
client!.store.getSavedSyncToken = jest.fn().mockResolvedValue("this-is-a-token");
client!.startClient({ initialSyncLimit: 1 });
await httpBackend!.flushAllExpected();
const room = client?.getRoom(ROOM_ID);
expect(room).toBeInstanceOf(Room);
expect(room?.getUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
});
});
function getSampleStuckNotificationSyncResponse(roomId: string): Partial<ISyncResponse> {
return {
next_batch: "batch_token",
rooms: {
[Category.Join]: {
[roomId]: {
timeline: {
events: [
{
content: {
creator: userB,
room_version: "9",
},
origin_server_ts: 1,
sender: userB,
state_key: "",
type: "m.room.create",
event_id: "$event1",
},
{
content: {
avatar_url: "",
displayname: userB,
membership: "join",
},
origin_server_ts: 2,
sender: userB,
state_key: userB,
type: "m.room.member",
event_id: "$event2",
},
{
content: {
ban: 50,
events: {
"m.room.avatar": 50,
"m.room.canonical_alias": 50,
"m.room.encryption": 100,
"m.room.history_visibility": 100,
"m.room.name": 50,
"m.room.power_levels": 100,
"m.room.server_acl": 100,
"m.room.tombstone": 100,
},
events_default: 0,
historical: 100,
invite: 0,
kick: 50,
redact: 50,
state_default: 50,
users: {
[userA]: 100,
[userB]: 100,
},
users_default: 0,
},
origin_server_ts: 3,
sender: userB,
state_key: "",
type: "m.room.power_levels",
event_id: "$event3",
},
{
content: {
join_rule: "invite",
},
origin_server_ts: 4,
sender: userB,
state_key: "",
type: "m.room.join_rules",
event_id: "$event4",
},
{
content: {
history_visibility: "shared",
},
origin_server_ts: 5,
sender: userB,
state_key: "",
type: "m.room.history_visibility",
event_id: "$event5",
},
{
content: {
guest_access: "can_join",
},
origin_server_ts: 6,
sender: userB,
state_key: "",
type: "m.room.guest_access",
unsigned: {
age: 1651569,
},
event_id: "$event6",
},
{
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
origin_server_ts: 7,
sender: userB,
state_key: "",
type: "m.room.encryption",
event_id: "$event7",
},
{
content: {
avatar_url: "",
displayname: userA,
is_direct: true,
membership: "invite",
},
origin_server_ts: 8,
sender: userB,
state_key: userA,
type: "m.room.member",
event_id: "$event8",
},
{
content: {
msgtype: "m.text",
body: "hello",
},
origin_server_ts: 9,
sender: userB,
type: "m.room.message",
event_id: "$event9",
},
{
content: {
avatar_url: "",
displayname: userA,
membership: "join",
},
origin_server_ts: 10,
sender: userA,
state_key: userA,
type: "m.room.member",
event_id: "$event10",
},
{
content: {
msgtype: "m.text",
body: "world",
},
origin_server_ts: 11,
sender: userA,
type: "m.room.message",
event_id: "$event11",
},
],
prev_batch: "123",
limited: false,
},
state: {
events: [],
},
account_data: {
events: [
{
type: "m.fully_read",
content: {
event_id: "$dER5V1RCMxzAhHXQJoMjqyuoxpPtK2X6hCb9T8Jg2wU",
},
},
],
},
ephemeral: {
events: [
{
type: "m.receipt",
content: {
$event9: {
"m.read": {
[userA]: {
ts: 100,
},
},
"m.read.private": {
[userA]: {
ts: 100,
},
},
},
dER5V1RCMxzAhHXQJoMjqyuoxpPtK2X6hCb9T8Jg2wU: {
"m.read": {
[userB]: {
ts: 666,
},
},
},
},
},
],
},
unread_notifications: {
notification_count: 1,
highlight_count: 0,
},
summary: {
"m.joined_member_count": 2,
"m.invited_member_count": 0,
"m.heroes": [userB],
},
},
},
[Category.Leave]: {},
[Category.Invite]: {},
},
};
}
});
+1 -1
View File
@@ -29,13 +29,13 @@ limitations under the License.
import "../olm-loader";
import type { Session } from "@matrix-org/olm";
import type { IDeviceKeys, IOneTimeKey } from "../../src/@types/crypto";
import { logger } from "../../src/logger";
import * as testUtils from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
import { CRYPTO_ENABLED, IClaimKeysRequest, IQueryKeysRequest, IUploadKeysRequest } from "../../src/client";
import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../src/matrix";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { IDeviceKeys, IOneTimeKey } from "../../src/crypto/dehydration";
let aliTestClient: TestClient;
const roomId = "!room:localhost";
+4 -4
View File
@@ -153,11 +153,11 @@ describe("SlidingSyncSdk", () => {
const hasSynced = sdk!.sync();
await httpBackend!.flushAllExpected();
await hasSynced;
expect(mockSlidingSync!.start).toBeCalled();
expect(mockSlidingSync!.start).toHaveBeenCalled();
});
it("can stop()", async () => {
sdk!.stop();
expect(mockSlidingSync!.stop).toBeCalled();
expect(mockSlidingSync!.stop).toHaveBeenCalled();
});
});
@@ -584,7 +584,7 @@ describe("SlidingSyncSdk", () => {
});
it("emits SyncState.Error immediately when receiving M_UNKNOWN_TOKEN and stops syncing", async () => {
expect(mockSlidingSync!.stop).not.toBeCalled();
expect(mockSlidingSync!.stop).not.toHaveBeenCalled();
mockSlidingSync!.emit(
SlidingSyncEvent.Lifecycle,
SlidingSyncState.RequestFinished,
@@ -595,7 +595,7 @@ describe("SlidingSyncSdk", () => {
}),
);
expect(sdk!.getSyncState()).toEqual(SyncState.Error);
expect(mockSlidingSync!.stop).toBeCalled();
expect(mockSlidingSync!.stop).toHaveBeenCalled();
});
});
+159
View File
@@ -0,0 +1,159 @@
/*
Copyright 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.
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 debugFunc from "debug";
import { Debugger } from "debug";
import fetchMock from "fetch-mock-jest";
import type { IDeviceKeys, IOneTimeKey } from "../../src/@types/crypto";
/** Interface implemented by classes that intercept `/keys/upload` requests from test clients to catch the uploaded keys
*
* Common interface implemented by {@link TestClient} and {@link E2EKeyReceiver}
*/
export interface IE2EKeyReceiver {
/**
* get the uploaded ed25519 device key
*
* @returns base64 device key
*/
getSigningKey(): string;
/**
* get the uploaded curve25519 device key
*
* @returns base64 device key
*/
getDeviceKey(): string;
/**
* Wait for one-time-keys to be uploaded, then return them.
*
* @returns Promise for the one-time keys
*/
awaitOneTimeKeyUpload(): Promise<Record<string, IOneTimeKey>>;
}
/** E2EKeyReceiver: An object which intercepts `/keys/uploads` fetches via fetch-mock.
*
* It stashes the uploaded keys for use elsewhere in the tests.
*/
export class E2EKeyReceiver implements IE2EKeyReceiver {
private readonly debug: Debugger;
private deviceKeys: IDeviceKeys | null = null;
private oneTimeKeys: Record<string, IOneTimeKey> = {};
private readonly oneTimeKeysPromise: Promise<void>;
/**
* Construct a new E2EKeyReceiver.
*
* It will immediately register an intercept of `/keys/uploads` requests for the given homeserverUrl.
* Only /upload requests made to this server will be intercepted: this allows a single test to use more than one
* client and have the keys collected separately.
*
* @param homeserverUrl - the Homeserver Url of the client under test.
*/
public constructor(homeserverUrl: string) {
this.debug = debugFunc(`e2e-key-receiver:[${homeserverUrl}]`);
// set up a listener for /keys/upload.
this.oneTimeKeysPromise = new Promise((resolveOneTimeKeys) => {
const listener = (url: string, options: RequestInit) =>
this.onKeyUploadRequest(resolveOneTimeKeys, options);
// catch both r0 and v3 variants
fetchMock.post(new URL("/_matrix/client/r0/keys/upload", homeserverUrl).toString(), listener);
fetchMock.post(new URL("/_matrix/client/v3/keys/upload", homeserverUrl).toString(), listener);
});
}
private async onKeyUploadRequest(onOnTimeKeysUploaded: () => void, options: RequestInit): Promise<object> {
const content = JSON.parse(options.body as string);
// device keys may only be uploaded once
if (content.device_keys && Object.keys(content.device_keys).length > 0) {
if (this.deviceKeys) {
throw new Error("Application attempted to upload E2E device keys multiple times");
}
this.debug(`received device keys`);
this.deviceKeys = content.device_keys;
}
if (content.one_time_keys && Object.keys(content.one_time_keys).length > 0) {
// this is a one-time-key upload
// if we already have a batch of one-time keys, then slow-roll the response,
// otherwise the client ends up tight-looping one-time-key-uploads and filling the logs with junk.
if (Object.keys(this.oneTimeKeys).length > 0) {
this.debug(`received second batch of one-time keys: blocking response`);
await new Promise(() => {});
}
this.debug(`received ${Object.keys(content.one_time_keys).length} one-time keys`);
Object.assign(this.oneTimeKeys, content.one_time_keys);
onOnTimeKeysUploaded();
}
return {
one_time_key_counts: {
signed_curve25519: Object.keys(this.oneTimeKeys).length,
},
};
}
/** Get the uploaded Ed25519 key
*
* If device keys have not yet been uploaded, throws an error
*/
public getSigningKey(): string {
if (!this.deviceKeys) {
throw new Error("Device keys not yet uploaded");
}
const keyIds = Object.keys(this.deviceKeys.keys).filter((v) => v.startsWith("ed25519:"));
if (keyIds.length != 1) {
throw new Error(`Expected exactly 1 ed25519 key uploaded, got ${keyIds}`);
}
return this.deviceKeys.keys[keyIds[0]];
}
/** Get the uploaded Curve25519 key
*
* If device keys have not yet been uploaded, throws an error
*/
public getDeviceKey(): string {
if (!this.deviceKeys) {
throw new Error("Device keys not yet uploaded");
}
const keyIds = Object.keys(this.deviceKeys.keys).filter((v) => v.startsWith("curve25519:"));
if (keyIds.length != 1) {
throw new Error(`Expected exactly 1 curve25519 key uploaded, got ${keyIds}`);
}
return this.deviceKeys.keys[keyIds[0]];
}
/**
* If one-time keys have already been uploaded, return them. Otherwise,
* set up an expectation that the keys will be uploaded, and wait for
* that to happen.
*
* @returns Promise for the one-time keys
*/
public async awaitOneTimeKeyUpload(): Promise<Record<string, IOneTimeKey>> {
await this.oneTimeKeysPromise;
return this.oneTimeKeys;
}
}
+131
View File
@@ -0,0 +1,131 @@
/*
Copyright 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.
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 debugFunc from "debug";
import { Debugger } from "debug";
import fetchMock from "fetch-mock-jest";
import { MockResponse } from "fetch-mock";
/** Interface implemented by classes that intercept `/sync` requests from test clients
*
* Common interface implemented by {@link TestClient} and {@link SyncResponder}
*/
export interface ISyncResponder {
/** Next time we see a sync request (or immediately, if there is one waiting), send the given response
*
* @param response - response to /sync request
*/
sendOrQueueSyncResponse(response: object): void;
}
enum SyncResponderState {
IDLE,
WAITING_FOR_REQUEST,
WAITING_FOR_RESPONSE,
}
/** SyncResponder: An object which intercepts `/sync` fetches via fetch-mock.
*
* Two modes are possible:
* * A response can be queued up; the next call to `/sync` will return it.
* * If a call to `/sync` arrives before a response is queued, it will block until a call to {@link #sendOrQueueSyncResponse}.
*/
export class SyncResponder implements ISyncResponder {
private readonly debug: Debugger;
private state: SyncResponderState = SyncResponderState.IDLE;
/*
* properties that are only valid in WAITING_FOR_REQUEST
*/
/** the response to be sent when the request is made */
private pendingResponse: object | null = null;
/*
* properties that are only valid in WAITING_FOR_RESPONSE
*/
/** a callback to be called with a response once one is registered.
*
* It will release the /sync request and update the state.
*/
private onResponseReceived: ((response: object) => void) | null = null;
/**
* Construct a new SyncResponder.
*
* It will immediately register an intercept of `/sync` requests for the given homeserverUrl.
* Only /sync requests made to this server will be intercepted: this allows a single test to use more than one
* client and have overlapping /sync requests.
*
* @param homeserverUrl - the Homeserver Url of the client under test.
*/
public constructor(homeserverUrl: string) {
this.debug = debugFunc(`sync-responder:[${homeserverUrl}]`);
fetchMock.get("begin:" + new URL("/_matrix/client/r0/sync?", homeserverUrl).toString(), (_url, _options) =>
this.onSyncRequest(),
);
}
private async onSyncRequest(): Promise<MockResponse> {
switch (this.state) {
case SyncResponderState.IDLE: {
this.debug("Got /sync request: waiting for response to be ready");
const res = await new Promise<object>((resolve) => {
this.onResponseReceived = resolve;
this.state = SyncResponderState.WAITING_FOR_RESPONSE;
});
this.debug("Responding to /sync");
this.state = SyncResponderState.IDLE;
this.onResponseReceived = null;
return res;
}
case SyncResponderState.WAITING_FOR_REQUEST: {
this.debug("Got /sync request: responding immediately with queued response");
const res = this.pendingResponse!;
this.state = SyncResponderState.IDLE;
this.pendingResponse = null;
return res;
}
default:
// we must already be in WAITING_FOR_RESPONSE, ie we already have a /sync request in progress
throw new Error(`Got unexpected /sync request in state ${this.state}`);
}
}
/** Next time we see a sync request (or immediately, if there is one waiting), send the given response
*
* @param response - response to /sync request
*/
public sendOrQueueSyncResponse(response: object): void {
switch (this.state) {
case SyncResponderState.IDLE:
this.pendingResponse = response;
this.state = SyncResponderState.WAITING_FOR_REQUEST;
break;
case SyncResponderState.WAITING_FOR_RESPONSE:
this.onResponseReceived!(response);
break;
default:
// we already have a response queued
throw new Error(`Cannot queue more than one /sync response`);
}
}
}
+1
View File
@@ -60,6 +60,7 @@ export const getMockClientWithEventEmitter = (
*/
export const mockClientMethodsUser = (userId = "@alice:domain") => ({
getUserId: jest.fn().mockReturnValue(userId),
getSafeUserId: jest.fn().mockReturnValue(userId),
getUser: jest.fn().mockReturnValue(new User(userId)),
isGuest: jest.fn().mockReturnValue(false),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
+6 -2
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.
@@ -450,6 +450,10 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
]
>();
public isInitialSyncComplete(): boolean {
return false;
}
public getMediaHandler(): MediaHandler {
return this.mediaHandler.typed();
}
@@ -476,7 +480,7 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
public getRooms = jest.fn<Room[], []>().mockReturnValue([]);
public getRoom = jest.fn();
public supportsExperimentalThreads(): boolean {
public supportsThreads(): boolean {
return true;
}
public async decryptEventIfNeeded(): Promise<void> {}
+16 -6
View File
@@ -5,6 +5,7 @@ import { getMockClientWithEventEmitter } from "../test-utils/client";
import { StubStore } from "../../src/store/stub";
import { IndexedToDeviceBatch } from "../../src/models/ToDeviceMessage";
import { SyncState } from "../../src/sync";
import { defer } from "../../src/utils";
describe("onResumedSync", () => {
let batch: IndexedToDeviceBatch | null;
@@ -58,7 +59,9 @@ describe("onResumedSync", () => {
queue = new ToDeviceMessageQueue(mockClient);
});
it("resends queue after connectivity restored", (done) => {
it("resends queue after connectivity restored", async () => {
const deferred = defer();
onSendToDeviceFailure = () => {
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
expect(store.removeToDeviceBatch).not.toHaveBeenCalled();
@@ -70,26 +73,32 @@ describe("onResumedSync", () => {
onSendToDeviceSuccess = () => {
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(3);
expect(store.removeToDeviceBatch).toHaveBeenCalled();
done();
deferred.resolve();
};
queue.start();
return deferred.promise;
});
it("does not resend queue if client sync still catching up", (done) => {
it("does not resend queue if client sync still catching up", async () => {
const deferred = defer();
onSendToDeviceFailure = () => {
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
expect(store.removeToDeviceBatch).not.toHaveBeenCalled();
resumeSync(SyncState.Catchup, SyncState.Catchup);
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
done();
deferred.resolve();
};
queue.start();
return deferred.promise;
});
it("does not resend queue if connectivity restored after queue stopped", (done) => {
it("does not resend queue if connectivity restored after queue stopped", async () => {
const deferred = defer();
onSendToDeviceFailure = () => {
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
expect(store.removeToDeviceBatch).not.toHaveBeenCalled();
@@ -98,9 +107,10 @@ describe("onResumedSync", () => {
resumeSync(SyncState.Syncing, SyncState.Catchup);
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
done();
deferred.resolve();
};
queue.start();
return deferred.promise;
});
});
+2 -2
View File
@@ -550,7 +550,7 @@ describe("Crypto", function () {
aliceClient.crypto!.outgoingRoomKeyRequestManager.sendQueuedRequests();
jest.runAllTimers();
await Promise.resolve();
expect(aliceSendToDevice).toBeCalledTimes(1);
expect(aliceSendToDevice).toHaveBeenCalledTimes(1);
const txnId = aliceSendToDevice.mock.calls[0][2];
// give the room key request manager time to update the state
@@ -564,7 +564,7 @@ describe("Crypto", function () {
// cancelAndResend will call sendToDevice twice:
// the first call to sendToDevice will be the cancellation
// the second call to sendToDevice will be the key request
expect(aliceSendToDevice).toBeCalledTimes(3);
expect(aliceSendToDevice).toHaveBeenCalledTimes(3);
expect(aliceSendToDevice.mock.calls[2][2]).not.toBe(txnId);
});
+5 -1
View File
@@ -148,6 +148,10 @@ describe("DeviceList", function () {
dl.invalidateUserDeviceList("@test1:sw1v.org");
dl.refreshOutdatedDeviceLists();
// TODO: Fix this test so we actually await the call and assertions and remove
// the eslint disable, https://github.com/matrix-org/matrix-js-sdk/issues/2977
//
// eslint-disable-next-line jest/valid-expect-in-promise
dl.saveIfDirty()
.then(() => {
// the first request completes
@@ -196,7 +200,7 @@ describe("DeviceList", function () {
downloadSpy.mockReturnValueOnce(queryDefer2.promise);
const prom1 = dl.refreshOutdatedDeviceLists();
expect(downloadSpy).toBeCalledTimes(2);
expect(downloadSpy).toHaveBeenCalledTimes(2);
expect(downloadSpy).toHaveBeenNthCalledWith(1, ["@test1:sw1v.org"], {});
expect(downloadSpy).toHaveBeenNthCalledWith(2, ["@test2:sw1v.org"], {});
queryDefer1.resolve(utils.deepCopy(signedDeviceList));
+82 -6
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.
@@ -16,6 +16,7 @@ limitations under the License.
import { mocked, MockedObject } from "jest-mock";
import type { DeviceInfoMap } from "../../../../src/crypto/DeviceList";
import "../../../olm-loader";
import type { OutboundGroupSession } from "@matrix-org/olm";
import * as algorithms from "../../../../src/crypto/algorithms";
@@ -33,6 +34,7 @@ import { ClientEvent, MatrixClient, RoomMember } from "../../../../src";
import { DeviceInfo, IDevice } from "../../../../src/crypto/deviceinfo";
import { DeviceTrustLevel } from "../../../../src/crypto/CrossSigning";
import { MegolmEncryption as MegolmEncryptionClass } from "../../../../src/crypto/algorithms/megolm";
import { sleep } from "../../../../src/utils";
const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get("m.megolm.v1.aes-sha2")!;
const MegolmEncryption = algorithms.ENCRYPTION_CLASSES.get("m.megolm.v1.aes-sha2")!;
@@ -58,6 +60,12 @@ describe("MegolmDecryption", function () {
beforeEach(async function () {
mockCrypto = testUtils.mock(Crypto, "Crypto") as MockedObject<Crypto>;
// @ts-ignore assigning to readonly prop
mockCrypto.backupManager = {
backupGroupSession: () => {},
};
mockBaseApis = {
claimOneTimeKeys: jest.fn(),
sendToDevice: jest.fn(),
@@ -203,7 +211,7 @@ describe("MegolmDecryption", function () {
.then(() => {
// check that it called encryptMessageForDevice with
// appropriate args.
expect(mockOlmLib.encryptMessageForDevice).toBeCalledTimes(1);
expect(mockOlmLib.encryptMessageForDevice).toHaveBeenCalledTimes(1);
const call = mockOlmLib.encryptMessageForDevice.mock.calls[0];
const payload = call[6];
@@ -314,10 +322,6 @@ describe("MegolmDecryption", function () {
let olmDevice: OlmDevice;
beforeEach(async () => {
// @ts-ignore assigning to readonly prop
mockCrypto.backupManager = {
backupGroupSession: () => {},
};
const cryptoStore = new MemoryCryptoStore();
olmDevice = new OlmDevice(cryptoStore);
@@ -515,6 +519,78 @@ describe("MegolmDecryption", function () {
});
});
describe("prepareToEncrypt", () => {
let megolm: MegolmEncryptionClass;
let room: jest.Mocked<Room>;
const deviceMap: DeviceInfoMap = {
"user-a": {
"device-a": new DeviceInfo("device-a"),
"device-b": new DeviceInfo("device-b"),
"device-c": new DeviceInfo("device-c"),
},
"user-b": {
"device-d": new DeviceInfo("device-d"),
"device-e": new DeviceInfo("device-e"),
"device-f": new DeviceInfo("device-f"),
},
"user-c": {
"device-g": new DeviceInfo("device-g"),
"device-h": new DeviceInfo("device-h"),
"device-i": new DeviceInfo("device-i"),
},
};
beforeEach(() => {
room = testUtils.mock(Room, "Room") as jest.Mocked<Room>;
room.getEncryptionTargetMembers.mockImplementation(async () => [
new RoomMember(room.roomId, "@user:example.org"),
]);
room.getBlacklistUnverifiedDevices.mockReturnValue(false);
mockCrypto.downloadKeys.mockImplementation(async () => deviceMap);
mockCrypto.checkDeviceTrust.mockImplementation(() => new DeviceTrustLevel(true, true, true, true));
const olmDevice = new OlmDevice(new MemoryCryptoStore());
megolm = new MegolmEncryptionClass({
userId: "@user:id",
deviceId: "12345",
crypto: mockCrypto,
olmDevice,
baseApis: mockBaseApis,
roomId: room.roomId,
config: {
algorithm: "m.megolm.v1.aes-sha2",
rotation_period_ms: 9_999_999,
},
});
});
it("checks each device", async () => {
megolm.prepareToEncrypt(room);
//@ts-ignore private member access, gross
await megolm.encryptionPreparation?.promise;
for (const userId in deviceMap) {
for (const deviceId in deviceMap[userId]) {
expect(mockCrypto.checkDeviceTrust).toHaveBeenCalledWith(userId, deviceId);
}
}
});
it("is cancellable", async () => {
const stop = megolm.prepareToEncrypt(room);
const before = mockCrypto.checkDeviceTrust.mock.calls.length;
stop();
// Ensure that no more devices were checked after cancellation.
await sleep(10);
expect(mockCrypto.checkDeviceTrust).toHaveBeenCalledTimes(before);
});
});
it("notifies devices that have been blocked", async function () {
const aliceClient = new TestClient("@alice:example.com", "alicedevice").client;
const bobClient1 = new TestClient("@bob:example.com", "bobdevice1").client;
+1 -1
View File
@@ -1148,6 +1148,6 @@ describe("userHasCrossSigningKeys", function () {
it("throws an error if crypto is disabled", () => {
aliceClient["cryptoBackend"] = undefined;
expect(() => aliceClient.userHasCrossSigningKeys()).toThrowError("encryption disabled");
expect(() => aliceClient.userHasCrossSigningKeys()).toThrow("encryption disabled");
});
});
+1 -1
View File
@@ -215,7 +215,7 @@ describe("SAS verification", function () {
]);
// make sure that it uses the preferred method
expect(macMethod).toBe("org.matrix.msc3783.hkdf-hmac-sha256");
expect(macMethod).toBe("hkdf-hmac-sha256.v2");
expect(keyAgreement).toBe("curve25519-hkdf-sha256");
// make sure Alice and Bob verified each other
+6 -6
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.
@@ -147,7 +147,7 @@ describe("EventTimelineSet", () => {
let thread: Thread;
beforeEach(() => {
(client.supportsExperimentalThreads as jest.Mock).mockReturnValue(true);
(client.supportsThreads as jest.Mock).mockReturnValue(true);
thread = new Thread("!thread_id:server", messageEvent, { room, client });
});
@@ -179,7 +179,7 @@ describe("EventTimelineSet", () => {
eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline2, {
toStartOfTimeline: true,
});
}).toThrowError();
}).toThrow();
});
it("should not add a threaded reply to the main room timeline", () => {
@@ -206,7 +206,7 @@ describe("EventTimelineSet", () => {
});
it("should allow edits to be added to thread timeline", async () => {
jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true);
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
Thread.hasServerSideSupport = FeatureSupport.Stable;
@@ -393,13 +393,13 @@ describe("EventTimelineSet", () => {
let thread: Thread;
beforeEach(() => {
(client.supportsExperimentalThreads as jest.Mock).mockReturnValue(true);
(client.supportsThreads as jest.Mock).mockReturnValue(true);
thread = new Thread("!thread_id:server", messageEvent, { room, client });
});
it("should throw if timeline set has no room", () => {
const eventTimelineSet = new EventTimelineSet(undefined, {}, client);
expect(() => eventTimelineSet.canContain(messageEvent)).toThrowError();
expect(() => eventTimelineSet.canContain(messageEvent)).toThrow();
});
it("should return false if timeline set is for thread but event is not threaded", () => {
@@ -150,26 +150,6 @@ describe("PollResponseEvent", () => {
expect(response.spoiled).toBe(true);
});
it("should spoil the vote when answers are empty", () => {
const input: IPartialEvent<PollResponseEventContent> = {
type: M_POLL_RESPONSE.name,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: "$poll",
},
[M_POLL_RESPONSE.name]: {
answers: [],
},
},
};
const response = new PollResponseEvent(input);
expect(response.spoiled).toBe(true);
response.validateAgainst(SAMPLE_POLL);
expect(response.spoiled).toBe(true);
});
it("should spoil the vote when answers are not strings", () => {
const input: IPartialEvent<PollResponseEventContent> = {
type: M_POLL_RESPONSE.name,
+15 -44
View File
@@ -78,11 +78,11 @@ describe("InteractiveAuth", () => {
const res = await ia.attemptAuth();
expect(res).toBe(requestRes);
expect(doRequest).toBeCalledTimes(1);
expect(stateUpdated).toBeCalledTimes(1);
expect(doRequest).toHaveBeenCalledTimes(1);
expect(stateUpdated).toHaveBeenCalledTimes(1);
});
it("should handle auth errcode presence ", async () => {
it("should handle auth errcode presence", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
@@ -128,8 +128,8 @@ describe("InteractiveAuth", () => {
const res = await ia.attemptAuth();
expect(res).toBe(requestRes);
expect(doRequest).toBeCalledTimes(1);
expect(stateUpdated).toBeCalledTimes(1);
expect(doRequest).toHaveBeenCalledTimes(1);
expect(stateUpdated).toHaveBeenCalledTimes(1);
});
it("should handle set emailSid for email flow", async () => {
@@ -180,9 +180,9 @@ describe("InteractiveAuth", () => {
const res = await ia.attemptAuth();
expect(res).toBe(requestRes);
expect(doRequest).toBeCalledTimes(1);
expect(stateUpdated).toBeCalledTimes(1);
expect(requestEmailToken).toBeCalledTimes(0);
expect(doRequest).toHaveBeenCalledTimes(1);
expect(stateUpdated).toHaveBeenCalledTimes(1);
expect(requestEmailToken).toHaveBeenCalledTimes(0);
expect(ia.getEmailSid()).toBe("myEmailSid");
});
@@ -244,8 +244,8 @@ describe("InteractiveAuth", () => {
const res = await ia.attemptAuth();
expect(res).toBe(requestRes);
expect(doRequest).toBeCalledTimes(2);
expect(stateUpdated).toBeCalledTimes(1);
expect(doRequest).toHaveBeenCalledTimes(2);
expect(stateUpdated).toHaveBeenCalledTimes(1);
});
it("should make a request if authdata is null", async () => {
@@ -306,8 +306,8 @@ describe("InteractiveAuth", () => {
const res = await ia.attemptAuth();
expect(res).toBe(requestRes);
expect(doRequest).toBeCalledTimes(2);
expect(stateUpdated).toBeCalledTimes(1);
expect(doRequest).toHaveBeenCalledTimes(2);
expect(stateUpdated).toHaveBeenCalledTimes(1);
});
it("should start an auth stage and reject if no auth flow", async () => {
@@ -430,8 +430,8 @@ describe("InteractiveAuth", () => {
const res = await ia.attemptAuth();
expect(res).toBe(requestRes);
expect(doRequest).toBeCalledTimes(1);
expect(stateUpdated).toBeCalledTimes(0);
expect(doRequest).toHaveBeenCalledTimes(1);
expect(stateUpdated).toHaveBeenCalledTimes(0);
});
describe("requestEmailToken", () => {
@@ -464,35 +464,6 @@ describe("InteractiveAuth", () => {
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 5, undefined);
});
it("increases auth attempts", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
requestEmailToken.mockImplementation(async () => ({ sid: "" }));
const ia = new InteractiveAuth({
matrixClient: getFakeClient(),
doRequest,
stateUpdated,
requestEmailToken,
});
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 1, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 2, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 3, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 4, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 5, undefined);
});
it("passes errors through", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
@@ -508,7 +479,7 @@ describe("InteractiveAuth", () => {
requestEmailToken,
});
await expect(ia.requestEmailToken.bind(ia)).rejects.toThrowError("unspecific network error");
await expect(ia.requestEmailToken.bind(ia)).rejects.toThrow("unspecific network error");
});
it("only starts one request at a time", async () => {
+435 -101
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.
@@ -17,7 +17,7 @@ limitations under the License.
import { mocked } from "jest-mock";
import { logger } from "../../src/logger";
import { ClientEvent, ITurnServerResponse, MatrixClient, Store } from "../../src/client";
import { ClientEvent, IMatrixClientCreateOpts, ITurnServerResponse, MatrixClient, Store } from "../../src/client";
import { Filter } from "../../src/filter";
import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace";
import {
@@ -276,7 +276,7 @@ describe("MatrixClient", function () {
);
}
function makeClient() {
function makeClient(opts?: Partial<IMatrixClientCreateOpts>) {
client = new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: identityServerUrl,
@@ -285,6 +285,7 @@ describe("MatrixClient", function () {
store: store,
scheduler: scheduler,
userId: userId,
...(opts || {}),
});
// FIXME: We shouldn't be yanking http like this.
client.http = (["authedRequest", "getContentUri", "request", "uploadContent"] as const).reduce((r, k) => {
@@ -892,7 +893,7 @@ describe("MatrixClient", function () {
describe("getOrCreateFilter", function () {
it("should POST createFilter if no id is present in localStorage", function () {});
it("should use an existing filter if id is present in localStorage", function () {});
it("should handle localStorage filterId missing from the server", function (done) {
it("should handle localStorage filterId missing from the server", async () => {
function getFilterName(userId: string, suffix?: string) {
// scope this on the user ID because people may login on many accounts
// and they all need to be stored!
@@ -918,10 +919,8 @@ describe("MatrixClient", function () {
client.store.setFilterIdByName(filterName, invalidFilterId);
const filter = new Filter(client.credentials.userId);
client.getOrCreateFilter(filterName, filter).then(function (filterId) {
expect(filterId).toEqual(FILTER_RESPONSE.data?.filter_id);
done();
});
const filterId = await client.getOrCreateFilter(filterName, filter);
expect(filterId).toEqual(FILTER_RESPONSE.data?.filter_id);
});
});
@@ -932,7 +931,7 @@ describe("MatrixClient", function () {
expect(client.retryImmediately()).toBe(false);
});
it("should work on /filter", function (done) {
it("should work on /filter", async () => {
httpLookups = [];
httpLookups.push(PUSH_RULES_RESPONSE);
httpLookups.push({
@@ -943,23 +942,26 @@ describe("MatrixClient", function () {
httpLookups.push(FILTER_RESPONSE);
httpLookups.push(SYNC_RESPONSE);
client.on(ClientEvent.Sync, function syncListener(state) {
if (state === "ERROR" && httpLookups.length > 0) {
expect(httpLookups.length).toEqual(2);
expect(client.retryImmediately()).toBe(true);
jest.advanceTimersByTime(1);
} else if (state === "PREPARED" && httpLookups.length === 0) {
client.removeListener(ClientEvent.Sync, syncListener);
done();
} else {
// unexpected state transition!
expect(state).toEqual(null);
}
const wasPreparedPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, function syncListener(state) {
if (state === "ERROR" && httpLookups.length > 0) {
expect(httpLookups.length).toEqual(2);
expect(client.retryImmediately()).toBe(true);
jest.advanceTimersByTime(1);
} else if (state === "PREPARED" && httpLookups.length === 0) {
client.removeListener(ClientEvent.Sync, syncListener);
resolve(null);
} else {
// unexpected state transition!
expect(state).toEqual(null);
}
});
});
client.startClient();
await client.startClient();
await wasPreparedPromise;
});
it("should work on /sync", function (done) {
it("should work on /sync", async () => {
httpLookups.push({
method: "GET",
path: "/sync",
@@ -971,22 +973,25 @@ describe("MatrixClient", function () {
data: SYNC_DATA,
});
client.on(ClientEvent.Sync, function syncListener(state) {
if (state === "ERROR" && httpLookups.length > 0) {
expect(httpLookups.length).toEqual(1);
expect(client.retryImmediately()).toBe(true);
jest.advanceTimersByTime(1);
} else if (state === "RECONNECTING" && httpLookups.length > 0) {
jest.advanceTimersByTime(10000);
} else if (state === "SYNCING" && httpLookups.length === 0) {
client.removeListener(ClientEvent.Sync, syncListener);
done();
}
const isSyncingPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, function syncListener(state) {
if (state === "ERROR" && httpLookups.length > 0) {
expect(httpLookups.length).toEqual(1);
expect(client.retryImmediately()).toBe(true);
jest.advanceTimersByTime(1);
} else if (state === "RECONNECTING" && httpLookups.length > 0) {
jest.advanceTimersByTime(10000);
} else if (state === "SYNCING" && httpLookups.length === 0) {
client.removeListener(ClientEvent.Sync, syncListener);
resolve(null);
}
});
});
client.startClient();
await client.startClient();
await isSyncingPromise;
});
it("should work on /pushrules", function (done) {
it("should work on /pushrules", async () => {
httpLookups = [];
httpLookups.push({
method: "GET",
@@ -997,20 +1002,23 @@ describe("MatrixClient", function () {
httpLookups.push(FILTER_RESPONSE);
httpLookups.push(SYNC_RESPONSE);
client.on(ClientEvent.Sync, function syncListener(state) {
if (state === "ERROR" && httpLookups.length > 0) {
expect(httpLookups.length).toEqual(3);
expect(client.retryImmediately()).toBe(true);
jest.advanceTimersByTime(1);
} else if (state === "PREPARED" && httpLookups.length === 0) {
client.removeListener(ClientEvent.Sync, syncListener);
done();
} else {
// unexpected state transition!
expect(state).toEqual(null);
}
const wasPreparedPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, function syncListener(state) {
if (state === "ERROR" && httpLookups.length > 0) {
expect(httpLookups.length).toEqual(3);
expect(client.retryImmediately()).toBe(true);
jest.advanceTimersByTime(1);
} else if (state === "PREPARED" && httpLookups.length === 0) {
client.removeListener(ClientEvent.Sync, syncListener);
resolve(null);
} else {
// unexpected state transition!
expect(state).toEqual(null);
}
});
});
client.startClient();
await client.startClient();
await wasPreparedPromise;
});
});
@@ -1034,14 +1042,17 @@ describe("MatrixClient", function () {
};
}
it("should transition null -> PREPARED after the first /sync", function (done) {
it("should transition null -> PREPARED after the first /sync", async () => {
const expectedStates: [string, string | null][] = [];
expectedStates.push(["PREPARED", null]);
client.on(ClientEvent.Sync, syncChecker(expectedStates, done));
client.startClient();
const didSyncPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, syncChecker(expectedStates, resolve));
});
await client.startClient();
await didSyncPromise;
});
it("should transition null -> ERROR after a failed /filter", function (done) {
it("should transition null -> ERROR after a failed /filter", async () => {
const expectedStates: [string, string | null][] = [];
httpLookups = [];
httpLookups.push(PUSH_RULES_RESPONSE);
@@ -1051,14 +1062,17 @@ describe("MatrixClient", function () {
error: { errcode: "NOPE_NOPE_NOPE" },
});
expectedStates.push(["ERROR", null]);
client.on(ClientEvent.Sync, syncChecker(expectedStates, done));
client.startClient();
const didSyncPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, syncChecker(expectedStates, resolve));
});
await client.startClient();
await didSyncPromise;
});
// Disabled because now `startClient` makes a legit call to `/versions`
// And those tests are really unhappy about it... Not possible to figure
// out what a good resolution would look like
xit("should transition ERROR -> CATCHUP after /sync if prev failed", function (done) {
it.skip("should transition ERROR -> CATCHUP after /sync if prev failed", async () => {
const expectedStates: [string, string | null][] = [];
acceptKeepalives = false;
httpLookups = [];
@@ -1088,19 +1102,25 @@ describe("MatrixClient", function () {
expectedStates.push(["RECONNECTING", null]);
expectedStates.push(["ERROR", "RECONNECTING"]);
expectedStates.push(["CATCHUP", "ERROR"]);
client.on(ClientEvent.Sync, syncChecker(expectedStates, done));
client.startClient();
const didSyncPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, syncChecker(expectedStates, resolve));
});
await client.startClient();
await didSyncPromise;
});
it("should transition PREPARED -> SYNCING after /sync", function (done) {
it("should transition PREPARED -> SYNCING after /sync", async () => {
const expectedStates: [string, string | null][] = [];
expectedStates.push(["PREPARED", null]);
expectedStates.push(["SYNCING", "PREPARED"]);
client.on(ClientEvent.Sync, syncChecker(expectedStates, done));
client.startClient();
const didSyncPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, syncChecker(expectedStates, resolve));
});
await client.startClient();
await didSyncPromise;
});
xit("should transition SYNCING -> ERROR after a failed /sync", function (done) {
it.skip("should transition SYNCING -> ERROR after a failed /sync", async () => {
acceptKeepalives = false;
const expectedStates: [string, string | null][] = [];
httpLookups.push({
@@ -1118,11 +1138,14 @@ describe("MatrixClient", function () {
expectedStates.push(["SYNCING", "PREPARED"]);
expectedStates.push(["RECONNECTING", "SYNCING"]);
expectedStates.push(["ERROR", "RECONNECTING"]);
client.on(ClientEvent.Sync, syncChecker(expectedStates, done));
client.startClient();
const didSyncPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, syncChecker(expectedStates, resolve));
});
await client.startClient();
await didSyncPromise;
});
xit("should transition ERROR -> SYNCING after /sync if prev failed", function (done) {
it.skip("should transition ERROR -> SYNCING after /sync if prev failed", async () => {
const expectedStates: [string, string | null][] = [];
httpLookups.push({
method: "GET",
@@ -1134,11 +1157,14 @@ describe("MatrixClient", function () {
expectedStates.push(["PREPARED", null]);
expectedStates.push(["SYNCING", "PREPARED"]);
expectedStates.push(["ERROR", "SYNCING"]);
client.on(ClientEvent.Sync, syncChecker(expectedStates, done));
client.startClient();
const didSyncPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, syncChecker(expectedStates, resolve));
});
await client.startClient();
await didSyncPromise;
});
it("should transition SYNCING -> SYNCING on subsequent /sync successes", function (done) {
it("should transition SYNCING -> SYNCING on subsequent /sync successes", async () => {
const expectedStates: [string, string | null][] = [];
httpLookups.push(SYNC_RESPONSE);
httpLookups.push(SYNC_RESPONSE);
@@ -1146,11 +1172,14 @@ describe("MatrixClient", function () {
expectedStates.push(["PREPARED", null]);
expectedStates.push(["SYNCING", "PREPARED"]);
expectedStates.push(["SYNCING", "SYNCING"]);
client.on(ClientEvent.Sync, syncChecker(expectedStates, done));
client.startClient();
const didSyncPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, syncChecker(expectedStates, resolve));
});
await client.startClient();
await didSyncPromise;
});
xit("should transition ERROR -> ERROR if keepalive keeps failing", function (done) {
it.skip("should transition ERROR -> ERROR if keepalive keeps failing", async () => {
acceptKeepalives = false;
const expectedStates: [string, string | null][] = [];
httpLookups.push({
@@ -1174,8 +1203,11 @@ describe("MatrixClient", function () {
expectedStates.push(["RECONNECTING", "SYNCING"]);
expectedStates.push(["ERROR", "RECONNECTING"]);
expectedStates.push(["ERROR", "ERROR"]);
client.on(ClientEvent.Sync, syncChecker(expectedStates, done));
client.startClient();
const didSyncPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, syncChecker(expectedStates, resolve));
});
await client.startClient();
await didSyncPromise;
});
});
@@ -1213,7 +1245,7 @@ describe("MatrixClient", function () {
expect(httpLookups.length).toBe(0);
});
xit("should be able to peek into a room using peekInRoom", function (done) {});
it.skip("should be able to peek into a room using peekInRoom", function () {});
});
describe("getPresence", function () {
@@ -1333,7 +1365,7 @@ describe("MatrixClient", function () {
client.redactEvent(roomId, eventId, txnId, {
with_relations: [RelationType.Reference],
});
}).toThrowError(
}).toThrow(
new Error(
"Server does not support relation based redactions " +
`roomId ${roomId} eventId ${eventId} txnId: ${txnId} threadId null`,
@@ -1414,7 +1446,7 @@ describe("MatrixClient", function () {
expect(getRoomId).toEqual(roomId);
return mockRoom;
};
client.crypto = {
client.crypto = client["cryptoBackend"] = {
// mock crypto
encryptEvent: () => new Promise(() => {}),
stop: jest.fn(),
@@ -1436,8 +1468,9 @@ describe("MatrixClient", function () {
it("should cancel an event which is encrypting", async () => {
// @ts-ignore protected method access
client.encryptAndSendEvent(null, event);
client.encryptAndSendEvent(mockRoom, event);
await testUtils.emitPromise(event, "Event.status");
expect(event.status).toBe(EventStatus.ENCRYPTING);
client.cancelPendingEvent(event);
assertCancelled();
});
@@ -1456,9 +1489,20 @@ describe("MatrixClient", function () {
});
describe("threads", () => {
it.each([
{ startOpts: {}, hasThreadSupport: false },
{ startOpts: { threadSupport: true }, hasThreadSupport: true },
{ startOpts: { threadSupport: false }, hasThreadSupport: false },
{ startOpts: { experimentalThreadSupport: true }, hasThreadSupport: true },
{ startOpts: { experimentalThreadSupport: true, threadSupport: false }, hasThreadSupport: false },
])("enabled thread support for the SDK instance", async ({ startOpts, hasThreadSupport }) => {
await client.startClient(startOpts);
expect(client.supportsThreads()).toBe(hasThreadSupport);
});
it("partitions root events to room timeline and thread timeline", () => {
const supportsExperimentalThreads = client.supportsExperimentalThreads;
client.supportsExperimentalThreads = () => true;
const supportsThreads = client.supportsThreads;
client.supportsThreads = () => true;
const room = new Room("!room1:matrix.org", client, userId);
const rootEvent = new MatrixEvent({
@@ -1487,7 +1531,7 @@ describe("MatrixClient", function () {
expect(threadEvents).toHaveLength(1);
// Restore method
client.supportsExperimentalThreads = supportsExperimentalThreads;
client.supportsThreads = supportsThreads;
});
});
@@ -2205,7 +2249,7 @@ describe("MatrixClient", function () {
"creator": "@daryl:alexandria.example.com",
"m.federate": true,
"predecessor": {
event_id: "spec_is_not_clear_what_id_this_is",
event_id: "id_of_last_event",
room_id: predecessorRoomId,
},
"room_version": "9",
@@ -2234,7 +2278,78 @@ describe("MatrixClient", function () {
});
}
function predecessorEvent(newRoomId: string, predecessorRoomId: string): MatrixEvent {
return new MatrixEvent({
content: {
predecessor_room_id: predecessorRoomId,
},
event_id: `predecessor_event_id_pred_${predecessorRoomId}`,
origin_server_ts: 1432735824653,
room_id: newRoomId,
sender: "@daryl:alexandria.example.com",
state_key: "",
type: "org.matrix.msc3946.room_predecessor",
});
}
describe("getVisibleRooms", () => {
function setUpReplacedRooms(): {
room1: Room;
room2: Room;
replacedByCreate1: Room;
replacedByCreate2: Room;
replacedByDynamicPredecessor1: Room;
replacedByDynamicPredecessor2: Room;
} {
const room1 = new Room("room1", client, "@carol:alexandria.example.com");
const replacedByCreate1 = new Room("replacedByCreate1", client, "@carol:alexandria.example.com");
const replacedByCreate2 = new Room("replacedByCreate2", client, "@carol:alexandria.example.com");
const replacedByDynamicPredecessor1 = new Room("dyn1", client, "@carol:alexandria.example.com");
const replacedByDynamicPredecessor2 = new Room("dyn2", client, "@carol:alexandria.example.com");
const room2 = new Room("room2", client, "@daryl:alexandria.example.com");
client.store = new StubStore();
client.store.getRooms = () => [
room1,
replacedByCreate1,
replacedByCreate2,
replacedByDynamicPredecessor1,
replacedByDynamicPredecessor2,
room2,
];
room1.addLiveEvents(
[
roomCreateEvent(room1.roomId, replacedByCreate1.roomId),
predecessorEvent(room1.roomId, replacedByDynamicPredecessor1.roomId),
],
{},
);
room2.addLiveEvents(
[
roomCreateEvent(room2.roomId, replacedByCreate2.roomId),
predecessorEvent(room2.roomId, replacedByDynamicPredecessor2.roomId),
],
{},
);
replacedByCreate1.addLiveEvents([tombstoneEvent(room1.roomId, replacedByCreate1.roomId)], {});
replacedByCreate2.addLiveEvents([tombstoneEvent(room2.roomId, replacedByCreate2.roomId)], {});
replacedByDynamicPredecessor1.addLiveEvents(
[tombstoneEvent(room1.roomId, replacedByDynamicPredecessor1.roomId)],
{},
);
replacedByDynamicPredecessor2.addLiveEvents(
[tombstoneEvent(room2.roomId, replacedByDynamicPredecessor2.roomId)],
{},
);
return {
room1,
room2,
replacedByCreate1,
replacedByCreate2,
replacedByDynamicPredecessor1,
replacedByDynamicPredecessor2,
};
}
it("Returns an empty list if there are no rooms", () => {
client.store = new StubStore();
client.store.getRooms = () => [];
@@ -2275,42 +2390,139 @@ describe("MatrixClient", function () {
expect(rooms).toContain(room1);
expect(rooms).toContain(room2);
});
it("Ignores m.predecessor if we don't ask to use it", () => {
// Given 6 rooms, 2 of which have been replaced, and 2 of which WERE
// replaced by create events, but are now NOT replaced, because an
// m.predecessor event has changed the room's predecessor.
const {
room1,
room2,
replacedByCreate1,
replacedByCreate2,
replacedByDynamicPredecessor1,
replacedByDynamicPredecessor2,
} = setUpReplacedRooms();
// When we ask for the visible rooms
const rooms = client.getVisibleRooms(); // Don't supply msc3946ProcessDynamicPredecessor
// Then we only get the ones that have not been replaced
expect(rooms).not.toContain(replacedByCreate1);
expect(rooms).not.toContain(replacedByCreate2);
expect(rooms).toContain(replacedByDynamicPredecessor1);
expect(rooms).toContain(replacedByDynamicPredecessor2);
expect(rooms).toContain(room1);
expect(rooms).toContain(room2);
});
it("Considers rooms replaced with m.predecessor events to be replaced", () => {
// Given 6 rooms, 2 of which have been replaced, and 2 of which WERE
// replaced by create events, but are now NOT replaced, because an
// m.predecessor event has changed the room's predecessor.
const {
room1,
room2,
replacedByCreate1,
replacedByCreate2,
replacedByDynamicPredecessor1,
replacedByDynamicPredecessor2,
} = setUpReplacedRooms();
// When we ask for the visible rooms
const useMsc3946 = true;
const rooms = client.getVisibleRooms(useMsc3946);
// Then we only get the ones that have not been replaced
expect(rooms).not.toContain(replacedByDynamicPredecessor1);
expect(rooms).not.toContain(replacedByDynamicPredecessor2);
expect(rooms).toContain(replacedByCreate1);
expect(rooms).toContain(replacedByCreate2);
expect(rooms).toContain(room1);
expect(rooms).toContain(room2);
});
});
describe("getRoomUpgradeHistory", () => {
function createRoomHistory(): [Room, Room, Room, Room] {
/**
* Create a chain of room history with create events and tombstones.
*
* @param creates include create events (default=true)
* @param tombstones include tomstone events (default=true)
* @returns 4 rooms chained together with tombstones and create
* events, in order from oldest to latest.
*/
function createRoomHistory(creates = true, tombstones = true): [Room, Room, Room, Room] {
const room1 = new Room("room1", client, "@carol:alexandria.example.com");
const room2 = new Room("room2", client, "@daryl:alexandria.example.com");
const room3 = new Room("room3", client, "@rick:helicopter.example.com");
const room4 = new Room("room4", client, "@michonne:hawthorne.example.com");
room1.addLiveEvents([tombstoneEvent(room2.roomId, room1.roomId)], {});
room2.addLiveEvents([roomCreateEvent(room2.roomId, room1.roomId)]);
if (creates) {
room2.addLiveEvents([roomCreateEvent(room2.roomId, room1.roomId)]);
room3.addLiveEvents([roomCreateEvent(room3.roomId, room2.roomId)]);
room4.addLiveEvents([roomCreateEvent(room4.roomId, room3.roomId)]);
}
room2.addLiveEvents([tombstoneEvent(room3.roomId, room2.roomId)], {});
room3.addLiveEvents([roomCreateEvent(room3.roomId, room2.roomId)]);
room3.addLiveEvents([tombstoneEvent(room4.roomId, room3.roomId)], {});
room4.addLiveEvents([roomCreateEvent(room4.roomId, room3.roomId)]);
if (tombstones) {
room1.addLiveEvents([tombstoneEvent(room2.roomId, room1.roomId)], {});
room2.addLiveEvents([tombstoneEvent(room3.roomId, room2.roomId)], {});
room3.addLiveEvents([tombstoneEvent(room4.roomId, room3.roomId)], {});
}
mocked(store.getRoom).mockImplementation((roomId: string) => {
switch (roomId) {
case "room1":
return room1;
case "room2":
return room2;
case "room3":
return room3;
case "room4":
return room4;
default:
return null;
}
return { room1, room2, room3, room4 }[roomId] || null;
});
return [room1, room2, room3, room4];
}
/**
* Creates 2 alternate chains of room history: one using create
* events, and one using MSC2946 predecessor+tombstone events.
*
* Using create, history looks like:
* room1->room2->room3->room4 (but note we do not create tombstones)
*
* Using predecessor+tombstone, history looks like:
* dynRoom1->dynRoom2->room3->dynRoom4->dynRoom4
*
* @returns [room1, room2, room3, room4, dynRoom1, dynRoom2,
* dynRoom4, dynRoom5].
*/
function createDynamicRoomHistory(): [Room, Room, Room, Room, Room, Room, Room, Room] {
// Don't create tombstones for the old versions - we generally
// expect only one tombstone in a room, and we are confused by
// anything else.
const creates = true;
const tombstones = false;
const [room1, room2, room3, room4] = createRoomHistory(creates, tombstones);
const dynRoom1 = new Room("dynRoom1", client, "@rick:grimes.example.com");
const dynRoom2 = new Room("dynRoom2", client, "@rick:grimes.example.com");
const dynRoom4 = new Room("dynRoom4", client, "@rick:grimes.example.com");
const dynRoom5 = new Room("dynRoom5", client, "@rick:grimes.example.com");
dynRoom1.addLiveEvents([tombstoneEvent(dynRoom2.roomId, dynRoom1.roomId)], {});
dynRoom2.addLiveEvents([predecessorEvent(dynRoom2.roomId, dynRoom1.roomId)]);
dynRoom2.addLiveEvents([tombstoneEvent(room3.roomId, dynRoom2.roomId)], {});
room3.addLiveEvents([predecessorEvent(room3.roomId, dynRoom2.roomId)]);
room3.addLiveEvents([tombstoneEvent(dynRoom4.roomId, room3.roomId)], {});
dynRoom4.addLiveEvents([predecessorEvent(dynRoom4.roomId, room3.roomId)]);
dynRoom4.addLiveEvents([tombstoneEvent(dynRoom5.roomId, dynRoom4.roomId)], {});
dynRoom5.addLiveEvents([predecessorEvent(dynRoom5.roomId, dynRoom4.roomId)]);
mocked(store.getRoom)
.mockClear()
.mockImplementation((roomId: string) => {
return { room1, room2, room3, room4, dynRoom1, dynRoom2, dynRoom4, dynRoom5 }[roomId] || null;
});
return [room1, room2, room3, room4, dynRoom1, dynRoom2, dynRoom4, dynRoom5];
}
it("Returns an empty list if room does not exist", () => {
const history = client.getRoomUpgradeHistory("roomthatdoesnotexist");
expect(history).toHaveLength(0);
@@ -2334,6 +2546,49 @@ describe("MatrixClient", function () {
]);
});
it("Returns the predecessors of this room (with verify links)", () => {
const [room1, room2, room3, room4] = createRoomHistory();
const verifyLinks = true;
const history = client.getRoomUpgradeHistory(room4.roomId, verifyLinks);
expect(history.map((room) => room.roomId)).toEqual([
room1.roomId,
room2.roomId,
room3.roomId,
room4.roomId,
]);
});
it("With verify links, rejects predecessors that don't point forwards", () => {
// Given successors point back with create events, but
// predecessors do not point forwards with tombstones
const [, , , room4] = createRoomHistory(true, false);
// When I ask for history with verifyLinks on
const verifyLinks = true;
const history = client.getRoomUpgradeHistory(room4.roomId, verifyLinks);
// Then the predecessors are not included in the history
expect(history.map((room) => room.roomId)).toEqual([room4.roomId]);
});
it("Without verify links, includes predecessors that don't point forwards", () => {
// Given successors point back with create events, but
// predecessors do not point forwards with tombstones
const [room1, room2, room3, room4] = createRoomHistory(true, false);
// When I ask for history with verifyLinks off
const verifyLinks = false;
const history = client.getRoomUpgradeHistory(room4.roomId, verifyLinks);
// Then the predecessors are included in the history
expect(history.map((room) => room.roomId)).toEqual([
room1.roomId,
room2.roomId,
room3.roomId,
room4.roomId,
]);
});
it("Returns the subsequent rooms", () => {
const [room1, room2, room3, room4] = createRoomHistory();
const history = client.getRoomUpgradeHistory(room1.roomId);
@@ -2345,6 +2600,49 @@ describe("MatrixClient", function () {
]);
});
it("Returns the subsequent rooms (with verify links)", () => {
const [room1, room2, room3, room4] = createRoomHistory();
const verifyLinks = true;
const history = client.getRoomUpgradeHistory(room1.roomId, verifyLinks);
expect(history.map((room) => room.roomId)).toEqual([
room1.roomId,
room2.roomId,
room3.roomId,
room4.roomId,
]);
});
it("With verify links, rejects successors that don't point backwards", () => {
// Given predecessors point forwards with tombstones, but
// successors do not point back with create events.
const [room1, , ,] = createRoomHistory(false, true);
// When I ask for history with verifyLinks on
const verifyLinks = true;
const history = client.getRoomUpgradeHistory(room1.roomId, verifyLinks);
// Then the successors are not included in the history
expect(history.map((room) => room.roomId)).toEqual([room1.roomId]);
});
it("Without verify links, includes successors that don't point backwards", () => {
// Given predecessors point forwards with tombstones, but
// successors do not point back with create events.
const [room1, room2, room3, room4] = createRoomHistory(false, true);
// When I ask for history with verifyLinks off
const verifyLinks = false;
const history = client.getRoomUpgradeHistory(room1.roomId, verifyLinks);
// Then the successors are included in the history
expect(history.map((room) => room.roomId)).toEqual([
room1.roomId,
room2.roomId,
room3.roomId,
room4.roomId,
]);
});
it("Returns the predecessors and subsequent rooms", () => {
const [room1, room2, room3, room4] = createRoomHistory();
const history = client.getRoomUpgradeHistory(room3.roomId);
@@ -2355,6 +2653,42 @@ describe("MatrixClient", function () {
room4.roomId,
]);
});
it("Returns the predecessors and subsequent rooms (with verify links)", () => {
const [room1, room2, room3, room4] = createRoomHistory();
const verifyLinks = true;
const history = client.getRoomUpgradeHistory(room3.roomId, verifyLinks);
expect(history.map((room) => room.roomId)).toEqual([
room1.roomId,
room2.roomId,
room3.roomId,
room4.roomId,
]);
});
it("Returns the predecessors and subsequent rooms using MSC3945 dynamic room predecessors", () => {
const [, , room3, , dynRoom1, dynRoom2, dynRoom4, dynRoom5] = createDynamicRoomHistory();
const useMsc3946 = true;
const verifyLinks = false;
const history = client.getRoomUpgradeHistory(room3.roomId, verifyLinks, useMsc3946);
expect(history.map((room) => room.roomId)).toEqual([
dynRoom1.roomId,
dynRoom2.roomId,
room3.roomId,
dynRoom4.roomId,
dynRoom5.roomId,
]);
});
it("When not asking for MSC3946, verified history without tombstones is empty", () => {
// There no tombstones to match the create events
const [, , room3] = createDynamicRoomHistory();
const useMsc3946 = false;
const verifyLinks = true;
const history = client.getRoomUpgradeHistory(room3.roomId, verifyLinks, useMsc3946);
// So we get no history back
expect(history.map((room) => room.roomId)).toEqual([room3.roomId]);
});
});
});
});
+19
View File
@@ -126,12 +126,31 @@ describe("MatrixEvent", () => {
expect(encryptedEvent.isEncrypted()).toBeTruthy();
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeFalsy();
expect(encryptedEvent.getContent()).toEqual({
msgtype: "m.bad.encrypted",
body: "** Unable to decrypt: Error: test error **",
});
});
it(`should report "DecryptionError: The sender has disabled encrypting to unverified devices."`, async () => {
const crypto = {
decryptEvent: jest
.fn()
.mockRejectedValue("DecryptionError: The sender has disabled encrypting to unverified devices."),
} as unknown as Crypto;
await encryptedEvent.attemptDecryption(crypto);
expect(encryptedEvent.isEncrypted()).toBeTruthy();
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeTruthy();
expect(encryptedEvent.getContent()).toEqual({
msgtype: "m.bad.encrypted",
body: "** Unable to decrypt: DecryptionError: The sender has disabled encrypting to unverified devices. **",
});
});
it("should retry decryption if a retry is queued", async () => {
const eventAttemptDecryptionSpy = jest.spyOn(encryptedEvent, "attemptDecryption");
+456
View File
@@ -0,0 +1,456 @@
/*
Copyright 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.
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 { IEvent, MatrixEvent, PollEvent, Room } from "../../../src";
import { REFERENCE_RELATION } from "../../../src/@types/extensible_events";
import { M_POLL_END, M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE } from "../../../src/@types/polls";
import { PollStartEvent } from "../../../src/extensible_events_v1/PollStartEvent";
import { Poll } from "../../../src/models/poll";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
import { flushPromises } from "../../test-utils/flushPromises";
jest.useFakeTimers();
describe("Poll", () => {
const userId = "@alice:server.org";
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
decryptEventIfNeeded: jest.fn().mockResolvedValue(true),
relations: jest.fn(),
});
const roomId = "!room:server";
const room = new Room(roomId, mockClient, userId);
const maySendRedactionForEventSpy = jest.spyOn(room.currentState, "maySendRedactionForEvent");
// 14.03.2022 16:15
const now = 1647270879403;
const basePollStartEvent = new MatrixEvent({
...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
room_id: roomId,
sender: userId,
});
basePollStartEvent.event.event_id = "$12345";
beforeEach(() => {
jest.clearAllMocks();
jest.setSystemTime(now);
mockClient.relations.mockReset().mockResolvedValue({ events: [] });
maySendRedactionForEventSpy.mockClear().mockReturnValue(true);
});
let eventId = 1;
const makeRelatedEvent = (eventProps: Partial<IEvent>, timestamp = now): MatrixEvent => {
const event = new MatrixEvent({
...eventProps,
content: {
...(eventProps.content || {}),
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: basePollStartEvent.getId(),
},
},
});
event.event.origin_server_ts = timestamp;
event.event.event_id = `${eventId++}`;
return event;
};
it("initialises with root event", () => {
const poll = new Poll(basePollStartEvent, mockClient, room);
expect(poll.roomId).toEqual(roomId);
expect(poll.pollId).toEqual(basePollStartEvent.getId());
expect(poll.pollEvent).toEqual(basePollStartEvent.unstableExtensibleEvent);
expect(poll.isEnded).toBe(false);
expect(poll.endEventId).toBe(undefined);
});
it("throws when poll start has no room id", () => {
const pollStartEvent = new MatrixEvent(
PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
);
expect(() => new Poll(pollStartEvent, mockClient, room)).toThrow("Invalid poll start event.");
});
it("throws when poll start has no event id", () => {
const pollStartEvent = new MatrixEvent({
...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
room_id: roomId,
});
expect(() => new Poll(pollStartEvent, mockClient, room)).toThrow("Invalid poll start event.");
});
describe("fetching responses", () => {
it("calls relations api and emits", async () => {
const poll = new Poll(basePollStartEvent, mockClient, room);
const emitSpy = jest.spyOn(poll, "emit");
const fetchResponsePromise = poll.getResponses();
expect(poll.isFetchingResponses).toBe(true);
const responses = await fetchResponsePromise;
expect(poll.isFetchingResponses).toBe(false);
expect(mockClient.relations).toHaveBeenCalledWith(
roomId,
basePollStartEvent.getId(),
"m.reference",
undefined,
{ from: undefined },
);
expect(emitSpy).toHaveBeenCalledWith(PollEvent.Responses, responses);
});
it("returns existing responses object after initial fetch", async () => {
const poll = new Poll(basePollStartEvent, mockClient, room);
const responses = await poll.getResponses();
const responses2 = await poll.getResponses();
// only fetched relations once
expect(mockClient.relations).toHaveBeenCalledTimes(1);
// strictly equal
expect(responses).toBe(responses2);
});
it("waits for existing relations request to finish when getting responses", async () => {
const poll = new Poll(basePollStartEvent, mockClient, room);
const firstResponsePromise = poll.getResponses();
const secondResponsePromise = poll.getResponses();
await firstResponsePromise;
expect(firstResponsePromise).toEqual(secondResponsePromise);
await secondResponsePromise;
expect(mockClient.relations).toHaveBeenCalledTimes(1);
});
it("filters relations for relevent response events", async () => {
const replyEvent = makeRelatedEvent({ type: "m.room.message" });
const stableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.stable! });
const unstableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.unstable });
mockClient.relations.mockResolvedValue({
events: [replyEvent, stableResponseEvent, unstableResponseEvent],
});
const poll = new Poll(basePollStartEvent, mockClient, room);
const responses = await poll.getResponses();
expect(responses.getRelations()).toEqual([stableResponseEvent, unstableResponseEvent]);
});
describe("with multiple pages of relations", () => {
const makeResponses = (count = 1, timestamp = now): MatrixEvent[] =>
new Array(count)
.fill("x")
.map((_x, index) =>
makeRelatedEvent(
{ type: M_POLL_RESPONSE.stable!, sender: "@bob@server.org" },
timestamp + index,
),
);
it("page relations responses", async () => {
const responseEvents = makeResponses(6);
mockClient.relations
.mockResolvedValueOnce({
events: responseEvents.slice(0, 2),
nextBatch: "test-next-1",
})
.mockResolvedValueOnce({
events: responseEvents.slice(2, 4),
nextBatch: "test-next-2",
})
.mockResolvedValueOnce({
events: responseEvents.slice(4),
});
const poll = new Poll(basePollStartEvent, mockClient, room);
jest.spyOn(poll, "emit");
const responses = await poll.getResponses();
await flushPromises();
expect(mockClient.relations.mock.calls).toEqual([
[roomId, basePollStartEvent.getId(), "m.reference", undefined, { from: undefined }],
[roomId, basePollStartEvent.getId(), "m.reference", undefined, { from: "test-next-1" }],
[roomId, basePollStartEvent.getId(), "m.reference", undefined, { from: "test-next-2" }],
]);
expect(poll.emit).toHaveBeenCalledTimes(3);
expect(poll.isFetchingResponses).toBeFalsy();
expect(responses.getRelations().length).toEqual(6);
});
});
describe("undecryptable relations", () => {
it("counts undecryptable relation events when getting responses", async () => {
const replyEvent = makeRelatedEvent({ type: "m.room.message" });
const stableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.stable! });
const undecryptableEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.unstable });
jest.spyOn(undecryptableEvent, "isDecryptionFailure").mockReturnValue(true);
mockClient.relations.mockResolvedValue({
events: [replyEvent, stableResponseEvent, undecryptableEvent],
});
const poll = new Poll(basePollStartEvent, mockClient, room);
jest.spyOn(poll, "emit");
await poll.getResponses();
expect(poll.undecryptableRelationsCount).toBe(1);
expect(poll.emit).toHaveBeenCalledWith(PollEvent.UndecryptableRelations, 1);
});
it("adds to undercryptable event count when new relation is undecryptable", async () => {
const replyEvent = makeRelatedEvent({ type: "m.room.message" });
const stableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.stable! });
const undecryptableEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.unstable });
const undecryptableEvent2 = makeRelatedEvent({ type: M_POLL_RESPONSE.unstable });
jest.spyOn(undecryptableEvent, "isDecryptionFailure").mockReturnValue(true);
jest.spyOn(undecryptableEvent2, "isDecryptionFailure").mockReturnValue(true);
mockClient.relations.mockResolvedValue({
events: [replyEvent, stableResponseEvent, undecryptableEvent],
});
const poll = new Poll(basePollStartEvent, mockClient, room);
jest.spyOn(poll, "emit");
await poll.getResponses();
expect(poll.undecryptableRelationsCount).toBe(1);
await poll.onNewRelation(undecryptableEvent2);
expect(poll.undecryptableRelationsCount).toBe(2);
expect(poll.emit).toHaveBeenCalledWith(PollEvent.UndecryptableRelations, 2);
});
});
describe("with poll end event", () => {
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@bob@server.org" });
const unstablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.unstable!, sender: "@bob@server.org" });
const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000);
const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
beforeEach(() => {
mockClient.relations.mockResolvedValue({
events: [responseEventAfterEnd, responseEventAtEnd, responseEventBeforeEnd, stablePollEndEvent],
});
});
it("sets poll end event with stable event type", async () => {
const poll = new Poll(basePollStartEvent, mockClient, room);
jest.spyOn(poll, "emit");
await poll.getResponses();
expect(maySendRedactionForEventSpy).toHaveBeenCalledWith(basePollStartEvent, "@bob@server.org");
expect(poll.isEnded).toBe(true);
expect(poll.endEventId).toBe(stablePollEndEvent.getId()!);
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
});
it("sets poll end event when endevent sender also created the poll, but does not have redaction rights", async () => {
const pollStartEvent = new MatrixEvent({
...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
room_id: roomId,
sender: "@bob:domain.org",
});
pollStartEvent.event.event_id = "$6789";
const poll = new Poll(pollStartEvent, mockClient, room);
const pollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@bob:domain.org" });
mockClient.relations.mockResolvedValue({
events: [pollEndEvent],
});
maySendRedactionForEventSpy.mockReturnValue(false);
jest.spyOn(poll, "emit");
await poll.getResponses();
expect(maySendRedactionForEventSpy).not.toHaveBeenCalled();
expect(poll.isEnded).toBe(true);
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
});
it("sets poll end event with unstable event type", async () => {
mockClient.relations.mockResolvedValue({
events: [unstablePollEndEvent],
});
const poll = new Poll(basePollStartEvent, mockClient, room);
jest.spyOn(poll, "emit");
await poll.getResponses();
expect(poll.isEnded).toBe(true);
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
});
it("filters out responses that were sent after poll end", async () => {
const poll = new Poll(basePollStartEvent, mockClient, room);
const responses = await poll.getResponses();
// just response type events
// and response with ts after poll end event is excluded
expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]);
});
});
});
describe("onNewRelation()", () => {
it("discards response if poll responses have not been initialised", () => {
const poll = new Poll(basePollStartEvent, mockClient, room);
jest.spyOn(poll, "emit");
const responseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
poll.onNewRelation(responseEvent);
// did not add response -> no emit
expect(poll.emit).not.toHaveBeenCalled();
});
it("sets poll end event when responses are not initialised", () => {
const poll = new Poll(basePollStartEvent, mockClient, room);
jest.spyOn(poll, "emit");
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: userId });
poll.onNewRelation(stablePollEndEvent);
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
});
it("does not set poll end event when sent by invalid user", async () => {
maySendRedactionForEventSpy.mockReturnValue(false);
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@charlie:server.org" });
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
mockClient.relations.mockResolvedValue({
events: [responseEventAfterEnd],
});
const poll = new Poll(basePollStartEvent, mockClient, room);
await poll.getResponses();
jest.spyOn(poll, "emit");
poll.onNewRelation(stablePollEndEvent);
// didn't end, didn't refilter responses
expect(poll.emit).not.toHaveBeenCalled();
expect(poll.isEnded).toBeFalsy();
expect(maySendRedactionForEventSpy).toHaveBeenCalledWith(basePollStartEvent, "@charlie:server.org");
});
it("replaces poll end event and refilters when an older end event already exists", async () => {
const earlierPollEndEvent = makeRelatedEvent(
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
now,
);
const laterPollEndEvent = makeRelatedEvent(
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
now + 2000,
);
const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000);
const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
mockClient.relations.mockResolvedValue({
events: [responseEventAfterEnd, responseEventAtEnd, responseEventBeforeEnd, laterPollEndEvent],
});
const poll = new Poll(basePollStartEvent, mockClient, room);
const responses = await poll.getResponses();
// all responses have a timestamp < laterPollEndEvent
expect(responses.getRelations().length).toEqual(3);
// first end event set correctly
expect(poll.isEnded).toBeTruthy();
// reset spy count
jest.spyOn(poll, "emit").mockClear();
// add a valid end event with earlier timestamp
poll.onNewRelation(earlierPollEndEvent);
// emitted new end event
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
// filtered responses and emitted
expect(poll.emit).toHaveBeenCalledWith(PollEvent.Responses, responses);
expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]);
});
it("does not set poll end event when an earlier end event already exists", async () => {
const earlierPollEndEvent = makeRelatedEvent(
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
now,
);
const laterPollEndEvent = makeRelatedEvent(
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
now + 2000,
);
const poll = new Poll(basePollStartEvent, mockClient, room);
await poll.getResponses();
poll.onNewRelation(earlierPollEndEvent);
// first end event set correctly
expect(poll.isEnded).toBeTruthy();
// reset spy count
jest.spyOn(poll, "emit").mockClear();
poll.onNewRelation(laterPollEndEvent);
// didn't set new end event, didn't refilter responses
expect(poll.emit).not.toHaveBeenCalled();
expect(poll.isEnded).toBeTruthy();
});
it("sets poll end event and refilters responses based on timestamp", async () => {
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: userId });
const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000);
const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
mockClient.relations.mockResolvedValue({
events: [responseEventAfterEnd, responseEventAtEnd, responseEventBeforeEnd],
});
const poll = new Poll(basePollStartEvent, mockClient, room);
const responses = await poll.getResponses();
jest.spyOn(poll, "emit");
expect(responses.getRelations().length).toEqual(3);
poll.onNewRelation(stablePollEndEvent);
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
expect(poll.emit).toHaveBeenCalledWith(PollEvent.Responses, responses);
expect(responses.getRelations().length).toEqual(2);
// after end timestamp event is removed
expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]);
});
it("filters out irrelevant relations", async () => {
const poll = new Poll(basePollStartEvent, mockClient, room);
// init responses
const responses = await poll.getResponses();
jest.spyOn(poll, "emit");
const replyEvent = new MatrixEvent({ type: "m.room.message" });
poll.onNewRelation(replyEvent);
// did not add response -> no emit
expect(poll.emit).not.toHaveBeenCalled();
expect(responses.getRelations().length).toEqual(0);
});
it("adds poll response relations to responses", async () => {
const poll = new Poll(basePollStartEvent, mockClient, room);
// init responses
const responses = await poll.getResponses();
jest.spyOn(poll, "emit");
const responseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
poll.onNewRelation(responseEvent);
// did not add response -> no emit
expect(poll.emit).toHaveBeenCalledWith(PollEvent.Responses, responses);
expect(responses.getRelations()).toEqual([responseEvent]);
});
});
});
+4 -2
View File
@@ -82,9 +82,10 @@ describe("Thread", () => {
beforeEach(() => {
client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
isInitialSyncComplete: jest.fn().mockReturnValue(false),
getRoom: jest.fn().mockImplementation(() => room),
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
supportsThreads: jest.fn().mockReturnValue(true),
});
client.reEmitter = mock(ReEmitter, "ReEmitter");
client.canSupport = new Map();
@@ -193,9 +194,10 @@ describe("Thread", () => {
beforeEach(() => {
client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
isInitialSyncComplete: jest.fn().mockReturnValue(false),
getRoom: jest.fn().mockImplementation(() => room),
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
supportsThreads: jest.fn().mockReturnValue(true),
});
client.reEmitter = mock(ReEmitter, "ReEmitter");
client.canSupport = new Map();
+30 -4
View File
@@ -53,10 +53,11 @@ describe("fixNotificationCountOnDecryption", () => {
beforeEach(() => {
mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
isInitialSyncComplete: jest.fn().mockReturnValue(false),
getPushActionsForEvent: jest.fn().mockReturnValue(mkPushAction(true, true)),
getRoom: jest.fn().mockImplementation(() => room),
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
supportsThreads: jest.fn().mockReturnValue(true),
});
mockClient.reEmitter = mock(ReEmitter, "ReEmitter");
mockClient.canSupport = new Map();
@@ -134,7 +135,7 @@ describe("fixNotificationCountOnDecryption", () => {
fixNotificationCountOnDecryption(mockClient, event);
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(2);
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(3);
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toBe(1);
});
@@ -154,11 +155,11 @@ describe("fixNotificationCountOnDecryption", () => {
fixNotificationCountOnDecryption(mockClient, threadEvent);
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(1);
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(2);
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(1);
});
it("does not change the room count when there's no unread count", () => {
it("does not change the thread count when there's no unread count", () => {
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
@@ -192,6 +193,31 @@ describe("fixNotificationCountOnDecryption", () => {
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(0);
});
it("does not change the total room count when an event is marked as non-notifying", () => {
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
room.setUnreadNotificationCount(NotificationCountType.Total, 0);
room.setUnreadNotificationCount(NotificationCountType.Highlight, 0);
event.getPushActions = jest.fn().mockReturnValue(mkPushAction(true, false));
mockClient.getPushActionsForEvent = jest.fn().mockReturnValue(mkPushAction(false, false));
fixNotificationCountOnDecryption(mockClient, event);
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toBe(0);
});
it("does not change the total room count when a threaded event is marked as non-notifying", () => {
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
threadEvent.getPushActions = jest.fn().mockReturnValue(mkPushAction(true, false));
mockClient.getPushActionsForEvent = jest.fn().mockReturnValue(mkPushAction(false, false));
fixNotificationCountOnDecryption(mockClient, event);
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(0);
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(0);
});
it("emits events", () => {
const cb = jest.fn();
room.on(RoomEvent.UnreadNotifications, cb);
-52
View File
@@ -18,7 +18,6 @@ import MockHttpBackend from "matrix-mock-request";
import { MAIN_ROOM_TIMELINE, ReceiptType } from "../../src/@types/read_receipts";
import { MatrixClient } from "../../src/client";
import { Feature, ServerSupport } from "../../src/feature";
import { EventType } from "../../src/matrix";
import { synthesizeReceipt } from "../../src/models/read-receipt";
import { encodeUri } from "../../src/utils";
@@ -70,10 +69,6 @@ const roomEvent = utils.mkEvent({
},
});
function mockServerSideSupport(client: MatrixClient, serverSideSupport: ServerSupport) {
client.canSupport.set(Feature.ThreadUnreadNotifications, serverSideSupport);
}
describe("Read receipt", () => {
beforeEach(() => {
httpBackend = new MockHttpBackend();
@@ -101,7 +96,6 @@ describe("Read receipt", () => {
})
.respond(200, {});
mockServerSideSupport(client, ServerSupport.Stable);
client.sendReceipt(threadEvent, ReceiptType.Read, {});
await httpBackend.flushAllExpected();
@@ -123,7 +117,6 @@ describe("Read receipt", () => {
})
.respond(200, {});
mockServerSideSupport(client, ServerSupport.Stable);
client.sendReadReceipt(threadEvent, ReceiptType.Read, true);
await httpBackend.flushAllExpected();
@@ -145,56 +138,11 @@ describe("Read receipt", () => {
})
.respond(200, {});
mockServerSideSupport(client, ServerSupport.Stable);
client.sendReceipt(roomEvent, ReceiptType.Read, {});
await httpBackend.flushAllExpected();
await flushPromises();
});
it("sends a room read receipt when there's no server support", async () => {
httpBackend
.when(
"POST",
encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
$roomId: ROOM_ID,
$receiptType: ReceiptType.Read,
$eventId: threadEvent.getId()!,
}),
)
.check((request) => {
expect(request.data.thread_id).toBeUndefined();
})
.respond(200, {});
mockServerSideSupport(client, ServerSupport.Unsupported);
client.sendReceipt(threadEvent, ReceiptType.Read, {});
await httpBackend.flushAllExpected();
await flushPromises();
});
it("sends a valid room read receipt even when body omitted", async () => {
httpBackend
.when(
"POST",
encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
$roomId: ROOM_ID,
$receiptType: ReceiptType.Read,
$eventId: threadEvent.getId()!,
}),
)
.check((request) => {
expect(request.data).toEqual({});
})
.respond(200, {});
mockServerSideSupport(client, ServerSupport.Unsupported);
client.sendReceipt(threadEvent, ReceiptType.Read, undefined);
await httpBackend.flushAllExpected();
await flushPromises();
});
});
describe("synthesizeReceipt", () => {
+2 -2
View File
@@ -146,7 +146,7 @@ describe("ECDHv1", function () {
// send a message without encryption
await aliceTransport.send({ iv: "dummy", ciphertext: "dummy" });
expect(bob.receive()).rejects.toThrowError();
expect(bob.receive()).rejects.toThrow();
await alice.cancel(RendezvousFailureReason.Unknown);
await bob.cancel(RendezvousFailureReason.Unknown);
@@ -164,7 +164,7 @@ describe("ECDHv1", function () {
await bobTransport.send({ iv: "dummy", ciphertext: "dummy" });
expect(alice.receive()).rejects.toThrowError();
expect(alice.receive()).rejects.toThrow();
await alice.cancel(RendezvousFailureReason.Unknown);
});
+5 -5
View File
@@ -220,7 +220,7 @@ describe("Rendezvous", function () {
await bobStartPromise;
});
it("new device declines protocol", async function () {
it("new device declines protocol with outcome unsupported", async function () {
const aliceTransport = makeTransport("Alice", "https://test.rz/123456");
const bobTransport = makeTransport("Bob", "https://test.rz/999999");
transports.push(aliceTransport, bobTransport);
@@ -278,7 +278,7 @@ describe("Rendezvous", function () {
expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.UnsupportedAlgorithm);
});
it("new device declines protocol", async function () {
it("new device requests an invalid protocol", async function () {
const aliceTransport = makeTransport("Alice", "https://test.rz/123456");
const bobTransport = makeTransport("Bob", "https://test.rz/999999");
transports.push(aliceTransport, bobTransport);
@@ -570,7 +570,7 @@ describe("Rendezvous", function () {
it("device not online within timeout", async function () {
const { aliceRz } = await completeLogin({});
expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrowError();
expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow();
});
it("device appears online within timeout", async function () {
@@ -594,7 +594,7 @@ describe("Rendezvous", function () {
getFingerprint: () => "bbbb",
};
}, 1500);
expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrowError();
expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow();
});
it("mismatched device key", async function () {
@@ -603,6 +603,6 @@ describe("Rendezvous", function () {
getFingerprint: () => "XXXX",
},
});
expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrowError(/different key/);
expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(/different key/);
});
});
@@ -98,7 +98,7 @@ describe("SimpleHttpRendezvousTransport", function () {
it("should throw an error when no server available", function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false });
const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, fetchFn });
expect(simpleHttpTransport.send({})).rejects.toThrowError("Invalid rendezvous URI");
expect(simpleHttpTransport.send({})).rejects.toThrow("Invalid rendezvous URI");
});
it("POST to fallback server", async function () {
@@ -130,7 +130,7 @@ describe("SimpleHttpRendezvousTransport", function () {
fetchFn,
});
const prom = simpleHttpTransport.send({});
expect(prom).rejects.toThrowError();
expect(prom).rejects.toThrow();
httpBackend.when("POST", "https://fallbackserver/rz").response = {
body: null,
response: {
@@ -163,15 +163,6 @@ describe("SimpleHttpRendezvousTransport", function () {
);
});
it("POST with relative path response including parent", async function () {
await postAndCheckLocation(
false,
"https://fallbackserver/rz/abc",
"../xyz/123",
"https://fallbackserver/rz/xyz/123",
);
});
it("POST to follow 307 to other server", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false });
const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({
@@ -373,7 +364,7 @@ describe("SimpleHttpRendezvousTransport", function () {
fallbackRzServer: "https://fallbackserver/rz",
fetchFn,
});
expect(simpleHttpTransport.details()).rejects.toThrowError();
expect(simpleHttpTransport.details()).rejects.toThrow();
});
it("send after cancelled", async function () {
@@ -394,7 +385,7 @@ describe("SimpleHttpRendezvousTransport", function () {
fallbackRzServer: "https://fallbackserver/rz",
fetchFn,
});
expect(simpleHttpTransport.receive()).rejects.toThrowError();
expect(simpleHttpTransport.receive()).rejects.toThrow();
});
it("404 failure callback", async function () {
@@ -416,7 +407,7 @@ describe("SimpleHttpRendezvousTransport", function () {
},
};
await httpBackend.flush("", 1);
expect(onFailure).toBeCalledWith(RendezvousFailureReason.Unknown);
expect(onFailure).toHaveBeenCalledWith(RendezvousFailureReason.Unknown);
});
it("404 failure callback mapped to expired", async function () {
@@ -456,7 +447,7 @@ describe("SimpleHttpRendezvousTransport", function () {
},
};
await httpBackend.flush("");
expect(onFailure).toBeCalledWith(RendezvousFailureReason.Expired);
expect(onFailure).toHaveBeenCalledWith(RendezvousFailureReason.Expired);
}
});
});
+162 -14
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,6 +19,7 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, PollStartEvent } from "matrix-events-sdk";
import * as utils from "../test-utils/test-utils";
import { emitPromise } from "../test-utils/test-utils";
@@ -37,6 +38,7 @@ import {
MatrixEvent,
MatrixEventEvent,
PendingEventOrdering,
PollEvent,
RelationType,
RoomEvent,
RoomMember,
@@ -786,8 +788,12 @@ describe("Room", function () {
});
};
describe("resetLiveTimeline with timeline support enabled", resetTimelineTests.bind(null, true));
describe("resetLiveTimeline with timeline support disabled", resetTimelineTests.bind(null, false));
describe("resetLiveTimeline with timeline support enabled", () => {
resetTimelineTests.bind(null, true);
});
describe("resetLiveTimeline with timeline support disabled", () => {
resetTimelineTests.bind(null, false);
});
describe("compareEventOrdering", function () {
beforeEach(function () {
@@ -1622,7 +1628,7 @@ describe("Room", function () {
describe("addPendingEvent", function () {
it("should add pending events to the pendingEventList if " + "pendingEventOrdering == 'detached'", function () {
const client = new TestClient("@alice:example.com", "alicedevice").client;
client.supportsExperimentalThreads = () => true;
client.supportsThreads = () => true;
const room = new Room(roomId, client, userA, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
@@ -2468,7 +2474,7 @@ describe("Room", function () {
});
it("Edits update the lastReply event", async () => {
room.client.supportsExperimentalThreads = () => true;
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const randomMessage = mkMessage();
@@ -2539,7 +2545,7 @@ describe("Room", function () {
});
it("Redactions to thread responses decrement the length", async () => {
room.client.supportsExperimentalThreads = () => true;
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage();
@@ -2606,7 +2612,7 @@ describe("Room", function () {
});
it("Redactions to reactions in threads do not decrement the length", async () => {
room.client.supportsExperimentalThreads = () => true;
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage();
@@ -2646,7 +2652,7 @@ describe("Room", function () {
});
it("should not decrement the length when the thread root is redacted", async () => {
room.client.supportsExperimentalThreads = () => true;
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage();
@@ -2687,7 +2693,7 @@ describe("Room", function () {
});
it("Redacting the lastEvent finds a new lastEvent", async () => {
room.client.supportsExperimentalThreads = () => true;
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
@@ -2794,7 +2800,7 @@ describe("Room", function () {
describe("eventShouldLiveIn", () => {
const client = new TestClient(userA).client;
client.supportsExperimentalThreads = () => true;
client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const room = new Room(roomId, client, userA);
@@ -3228,12 +3234,85 @@ describe("Room", function () {
});
});
describe("processPollEvents()", () => {
let room: Room;
let client: MatrixClient;
beforeEach(() => {
client = getMockClientWithEventEmitter({
decryptEventIfNeeded: jest.fn(),
});
room = new Room(roomId, client, userA);
jest.spyOn(room, "emit").mockClear();
});
const makePollStart = (id: string): MatrixEvent => {
const event = new MatrixEvent({
...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
room_id: roomId,
});
event.event.event_id = id;
return event;
};
it("adds poll models to room state for a poll start event", async () => {
const pollStartEvent = makePollStart("1");
const events = [pollStartEvent];
await room.processPollEvents(events);
expect(client.decryptEventIfNeeded).toHaveBeenCalledWith(pollStartEvent);
const pollInstance = room.polls.get(pollStartEvent.getId()!);
expect(pollInstance).toBeTruthy();
expect(room.emit).toHaveBeenCalledWith(PollEvent.New, pollInstance);
});
it("adds related events to poll models", async () => {
const pollStartEvent = makePollStart("1");
const pollStartEvent2 = makePollStart("2");
const events = [pollStartEvent, pollStartEvent2];
const pollResponseEvent = new MatrixEvent({
type: M_POLL_RESPONSE.name,
content: {
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: pollStartEvent.getId(),
},
},
});
const messageEvent = new MatrixEvent({
type: "m.room.messsage",
content: {
text: "hello",
},
});
// init poll
await room.processPollEvents(events);
const poll = room.polls.get(pollStartEvent.getId()!)!;
const poll2 = room.polls.get(pollStartEvent2.getId()!)!;
jest.spyOn(poll, "onNewRelation");
jest.spyOn(poll2, "onNewRelation");
await room.processPollEvents([pollResponseEvent, messageEvent]);
// only called for relevant event
expect(poll.onNewRelation).toHaveBeenCalledTimes(1);
expect(poll.onNewRelation).toHaveBeenCalledWith(pollResponseEvent);
// only called on poll with relation
expect(poll2.onNewRelation).not.toHaveBeenCalled();
});
});
describe("findPredecessorRoomId", () => {
let client: MatrixClient | null = null;
beforeEach(() => {
client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
isInitialSyncComplete: jest.fn().mockReturnValue(false),
supportsThreads: jest.fn().mockReturnValue(true),
});
});
@@ -3266,21 +3345,90 @@ describe("Room", function () {
});
}
function predecessorEvent(
newRoomId: string,
predecessorRoomId: string,
tombstoneEventId: string | null = null,
): MatrixEvent {
const content =
tombstoneEventId === null
? { predecessor_room_id: predecessorRoomId }
: { predecessor_room_id: predecessorRoomId, last_known_event_id: tombstoneEventId };
return new MatrixEvent({
content,
event_id: `predecessor_event_id_pred_${predecessorRoomId}`,
origin_server_ts: 1432735824653,
room_id: newRoomId,
sender: "@daryl:alexandria.example.com",
state_key: "",
type: "org.matrix.msc3946.room_predecessor",
});
}
it("Returns null if there is no create event", () => {
const room = new Room("roomid", client!, "@u:example.com");
expect(room.findPredecessorRoomId()).toBeNull();
expect(room.findPredecessor()).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();
expect(room.findPredecessor()).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");
expect(room.findPredecessor()).toEqual({ roomId: "replacedroomid", eventId: "id_of_last_known_event" });
});
it("Prefers the m.predecessor event if one exists", () => {
const room = new Room("roomid", client!, "@u:example.com");
room.addLiveEvents([
roomCreateEvent("roomid", "replacedroomid"),
predecessorEvent("roomid", "otherreplacedroomid"),
]);
const useMsc3946 = true;
expect(room.findPredecessor(useMsc3946)).toEqual({
roomId: "otherreplacedroomid",
eventId: undefined, // m.predecessor did not include an event_id
});
});
it("uses the m.predecessor event ID if provided", () => {
const room = new Room("roomid", client!, "@u:example.com");
room.addLiveEvents([
roomCreateEvent("roomid", "replacedroomid"),
predecessorEvent("roomid", "otherreplacedroomid", "lstevtid"),
]);
const useMsc3946 = true;
expect(room.findPredecessor(useMsc3946)).toEqual({
roomId: "otherreplacedroomid",
eventId: "lstevtid",
});
});
it("Ignores the m.predecessor event if we don't ask to use it", () => {
const room = new Room("roomid", client!, "@u:example.com");
room.addLiveEvents([
roomCreateEvent("roomid", "replacedroomid"),
predecessorEvent("roomid", "otherreplacedroomid"),
]);
// Don't provide an argument for msc3946ProcessDynamicPredecessor -
// we should ignore the predecessor event.
expect(room.findPredecessor()).toEqual({ roomId: "replacedroomid", eventId: "id_of_last_known_event" });
});
it("Ignores the m.predecessor event and returns null if we don't ask to use it", () => {
const room = new Room("roomid", client!, "@u:example.com");
room.addLiveEvents([
roomCreateEvent("roomid", null), // Create event has no predecessor
predecessorEvent("roomid", "otherreplacedroomid", "lastevtid"),
]);
// Don't provide an argument for msc3946ProcessDynamicPredecessor -
// we should ignore the predecessor event.
expect(room.findPredecessor()).toBeNull();
});
});
});
@@ -0,0 +1,158 @@
/*
Copyright 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.
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 fetchMock from "fetch-mock-jest";
import { Mocked } from "jest-mock";
import { KeysClaimRequest, UserId } from "@matrix-org/matrix-sdk-crypto-js";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { KeyClaimManager } from "../../../src/rust-crypto/KeyClaimManager";
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi } from "../../../src";
afterEach(() => {
fetchMock.mockReset();
});
describe("KeyClaimManager", () => {
/* for these tests, we connect a KeyClaimManager to a mock OlmMachine, and a real OutgoingRequestProcessor
* (which is connected to a mock fetch implementation)
*/
/** the KeyClaimManager implementation under test */
let keyClaimManager: KeyClaimManager;
/** a mocked-up OlmMachine which the OutgoingRequestProcessor and KeyClaimManager are connected to */
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
beforeEach(async () => {
const dummyEventEmitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
const httpApi = new MatrixHttpApi(dummyEventEmitter, {
baseUrl: "https://example.com",
prefix: "/_matrix",
onlyData: true,
});
olmMachine = {
getMissingSessions: jest.fn(),
markRequestAsSent: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
const outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, httpApi);
keyClaimManager = new KeyClaimManager(olmMachine, outgoingRequestProcessor);
});
/**
* Returns a promise which resolve once olmMachine.markRequestAsSent is called.
*
* The call itself will block initially.
*
* The promise returned by this function yields a callback function, which should be called to unblock the
* markRequestAsSent call.
*/
function awaitCallToMarkRequestAsSent(): Promise<() => void> {
return new Promise<() => void>((resolveCalledPromise, _reject) => {
olmMachine.markRequestAsSent.mockImplementationOnce(async () => {
// the mock implementation returns a promise...
const completePromise = new Promise<void>((resolveCompletePromise, _reject) => {
// ... and we now resolve the original promise with the resolver for that second promise.
resolveCalledPromise(resolveCompletePromise);
});
return completePromise;
});
});
}
it("should claim missing keys", async () => {
const u1 = new UserId("@alice:example.com");
const u2 = new UserId("@bob:example.com");
// stub out olmMachine.getMissingSessions(), with a result indicating that it needs a keyclaim
const keysClaimRequest = new KeysClaimRequest("1234", '{ "k1": "v1" }');
olmMachine.getMissingSessions.mockResolvedValueOnce(keysClaimRequest);
// have the claim request return a 200
fetchMock.postOnce("https://example.com/_matrix/client/v3/keys/claim", '{ "k": "v" }');
// also stub out olmMachine.markRequestAsSent
olmMachine.markRequestAsSent.mockResolvedValueOnce(undefined);
// fire off the request
await keyClaimManager.ensureSessionsForUsers([u1, u2]);
// check that all the calls were made
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u1, u2]);
expect(fetchMock).toHaveFetched("https://example.com/_matrix/client/v3/keys/claim", {
method: "POST",
body: { k1: "v1" },
});
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", keysClaimRequest.type, '{ "k": "v" }');
});
it("should wait for previous claims to complete before making another", async () => {
const u1 = new UserId("@alice:example.com");
const u2 = new UserId("@bob:example.com");
// stub out olmMachine.getMissingSessions(), with a result indicating that it needs a keyclaim
const keysClaimRequest = new KeysClaimRequest("1234", '{ "k1": "v1" }');
olmMachine.getMissingSessions.mockResolvedValue(keysClaimRequest);
// have the claim request return a 200
fetchMock.post("https://example.com/_matrix/client/v3/keys/claim", '{ "k": "v" }');
// stub out olmMachine.markRequestAsSent, and have it block
let markRequestAsSentPromise = awaitCallToMarkRequestAsSent();
// fire off two requests, and keep track of whether their promises resolve
let req1Resolved = false;
keyClaimManager.ensureSessionsForUsers([u1]).then(() => {
req1Resolved = true;
});
let req2Resolved = false;
const req2 = keyClaimManager.ensureSessionsForUsers([u2]).then(() => {
req2Resolved = true;
});
// now: wait for the (first) call to OlmMachine.markRequestAsSent
let resolveMarkRequestAsSentCallback = await markRequestAsSentPromise;
// at this point, there should have been a single call to getMissingSessions, and a single fetch; and neither
// call to ensureSessionsAsUsers should have completed
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u1]);
expect(olmMachine.getMissingSessions).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(req1Resolved).toBe(false);
expect(req2Resolved).toBe(false);
// await the next call to markRequestAsSent, and release the first one
markRequestAsSentPromise = awaitCallToMarkRequestAsSent();
resolveMarkRequestAsSentCallback();
resolveMarkRequestAsSentCallback = await markRequestAsSentPromise;
// the first request should now have completed, and we should have more calls and fetches
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u2]);
expect(olmMachine.getMissingSessions).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(req1Resolved).toBe(true);
expect(req2Resolved).toBe(false);
// finally, release the second call to markRequestAsSent and check that the second request completes
resolveMarkRequestAsSentCallback();
await req2;
});
});
@@ -0,0 +1,180 @@
/*
Copyright 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.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import MockHttpBackend from "matrix-mock-request";
import { Mocked } from "jest-mock";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import {
KeysBackupRequest,
KeysClaimRequest,
KeysQueryRequest,
KeysUploadRequest,
RoomMessageRequest,
SignatureUploadRequest,
ToDeviceRequest,
} from "@matrix-org/matrix-sdk-crypto-js";
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi } from "../../../src";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
describe("OutgoingRequestProcessor", () => {
/** the OutgoingRequestProcessor implementation under test */
let processor: OutgoingRequestProcessor;
/** A mock http backend which processor is connected to */
let httpBackend: MockHttpBackend;
/** a mocked-up OlmMachine which processor is connected to */
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
/** 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();
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,
});
olmMachine = {
markRequestAsSent: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
processor = new OutgoingRequestProcessor(olmMachine, httpApi);
});
/* simple requests that map directly to the request body */
const tests: Array<[string, any, "POST" | "PUT", string]> = [
["KeysUploadRequest", KeysUploadRequest, "POST", "https://example.com/_matrix/client/v3/keys/upload"],
["KeysQueryRequest", KeysQueryRequest, "POST", "https://example.com/_matrix/client/v3/keys/query"],
["KeysClaimRequest", KeysClaimRequest, "POST", "https://example.com/_matrix/client/v3/keys/claim"],
[
"SignatureUploadRequest",
SignatureUploadRequest,
"POST",
"https://example.com/_matrix/client/v3/keys/signatures/upload",
],
["KeysBackupRequest", KeysBackupRequest, "PUT", "https://example.com/_matrix/client/v3/room_keys/keys"],
];
test.each(tests)(`should handle %ss`, async (_, RequestClass, expectedMethod, expectedPath) => {
// first, mock up a request as we might expect to receive it from the Rust layer ...
const testBody = '{ "foo": "bar" }';
const outgoingRequest = new RequestClass("1234", testBody);
// ... then poke it into the OutgoingRequestProcessor under test.
const reqProm = processor.makeOutgoingRequest(outgoingRequest);
// Now: check that it makes a matching HTTP request ...
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);
// ... and that it calls OlmMachine.markAsSent.
const markSentCallPromise = awaitCallToMarkAsSent();
await httpBackend.flushAllExpected();
await Promise.all([reqProm, markSentCallPromise]);
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
httpBackend.verifyNoOutstandingRequests();
});
it("should handle ToDeviceRequests", async () => {
// first, mock up the ToDeviceRequest as we might expect to receive it from the Rust layer ...
const testBody = '{ "foo": "bar" }';
const outgoingRequest = new ToDeviceRequest("1234", "test/type", "test/txnid", testBody);
// ... then poke it into the OutgoingRequestProcessor under test.
const reqProm = processor.makeOutgoingRequest(outgoingRequest);
// Now: check that it makes a matching HTTP request ...
const testResponse = '{ "result": 1 }';
httpBackend
.when("PUT", "/_matrix")
.check((req) => {
expect(req.path).toEqual("https://example.com/_matrix/client/v3/sendToDevice/test%2Ftype/test%2Ftxnid");
expect(req.rawData).toEqual(testBody);
expect(req.headers["Accept"]).toEqual("application/json");
expect(req.headers["Content-Type"]).toEqual("application/json");
})
.respond(200, testResponse, true);
// ... and that it calls OlmMachine.markAsSent.
const markSentCallPromise = awaitCallToMarkAsSent();
await httpBackend.flushAllExpected();
await Promise.all([reqProm, markSentCallPromise]);
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
httpBackend.verifyNoOutstandingRequests();
});
it("should handle RoomMessageRequests", async () => {
// first, mock up the RoomMessageRequest as we might expect to receive it from the Rust layer ...
const testBody = '{ "foo": "bar" }';
const outgoingRequest = new RoomMessageRequest("1234", "test/room", "test/txnid", "test/type", testBody);
// ... then poke it into the OutgoingRequestProcessor under test.
const reqProm = processor.makeOutgoingRequest(outgoingRequest);
// Now: check that it makes a matching HTTP request ...
const testResponse = '{ "result": 1 }';
httpBackend
.when("PUT", "/_matrix")
.check((req) => {
expect(req.path).toEqual(
"https://example.com/_matrix/client/v3/room/test%2Froom/send/test%2Ftype/test%2Ftxnid",
);
expect(req.rawData).toEqual(testBody);
expect(req.headers["Accept"]).toEqual("application/json");
expect(req.headers["Content-Type"]).toEqual("application/json");
})
.respond(200, testResponse, true);
// ... and that it calls OlmMachine.markAsSent.
const markSentCallPromise = awaitCallToMarkAsSent();
await httpBackend.flushAllExpected();
await Promise.all([reqProm, 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 };
const markSentCallPromise = awaitCallToMarkAsSent();
await Promise.all([processor.makeOutgoingRequest(outgoingRequest), markSentCallPromise]);
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("5678", 987, "");
});
});
@@ -17,24 +17,16 @@ 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 { KeysQueryRequest, OlmMachine } 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";
import { mkEvent } from "../test-utils/test-utils";
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
import { IEventDecryptionResult } from "../../src/@types/crypto";
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
import { initRustCrypto } from "../../../src/rust-crypto";
import { IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../../src";
import { mkEvent } from "../../test-utils/test-utils";
import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
import { IEventDecryptionResult } from "../../../src/@types/crypto";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -106,8 +98,8 @@ describe("RustCrypto", () => {
/** the RustCrypto implementation under test */
let rustCrypto: RustCrypto;
/** A mock http backend which rustCrypto is connected to */
let httpBackend: MockHttpBackend;
/** A mock OutgoingRequestProcessor which rustCrypto is connected to */
let outgoingRequestProcessor: Mocked<OutgoingRequestProcessor>;
/** a mocked-up OlmMachine which rustCrypto is connected to */
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
@@ -116,28 +108,25 @@ describe("RustCrypto", () => {
* 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);
/** wait for a call to outgoingRequestProcessor.makeOutgoingRequest.
*
* The promise resolves to a callback: the makeOutgoingRequest call will not complete until the returned
* callback is called.
*/
function awaitCallToMakeOutgoingRequest(): Promise<() => void> {
return new Promise<() => void>((resolveCalledPromise, _reject) => {
outgoingRequestProcessor.makeOutgoingRequest.mockImplementationOnce(async () => {
const completePromise = new Promise<void>((resolveCompletePromise, _reject) => {
resolveCalledPromise(resolveCompletePromise);
});
return completePromise;
});
});
}
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 = [];
@@ -145,91 +134,55 @@ describe("RustCrypto", () => {
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);
outgoingRequestProcessor = {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
rustCrypto = new RustCrypto(olmMachine, {} as MatrixHttpApi<any>, TEST_USER, TEST_DEVICE_ID);
rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor;
});
it("should poll for outgoing messages", () => {
it("should poll for outgoing messages and send them", async () => {
const testReq = new KeysQueryRequest("1234", "{}");
outgoingRequestQueue.push([testReq]);
const makeRequestPromise = awaitCallToMakeOutgoingRequest();
rustCrypto.onSyncCompleted({});
await makeRequestPromise;
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, "");
expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalledWith(testReq);
});
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);
}
let makeRequestPromise = awaitCallToMakeOutgoingRequest();
rustCrypto.onSyncCompleted({});
expect(rustCrypto["outgoingRequestLoopRunning"]).toBeTruthy();
// go a couple of times round the loop
await httpBackend.flush("/_matrix", 1);
await awaitCallToMarkAsSent();
let resolveMakeRequest = await makeRequestPromise;
makeRequestPromise = awaitCallToMakeOutgoingRequest();
resolveMakeRequest();
await httpBackend.flush("/_matrix", 1);
await awaitCallToMarkAsSent();
resolveMakeRequest = await makeRequestPromise;
makeRequestPromise = awaitCallToMakeOutgoingRequest();
resolveMakeRequest();
// a second sync while this is going on shouldn't make any difference
rustCrypto.onSyncCompleted({});
await httpBackend.flush("/_matrix", 1);
await awaitCallToMarkAsSent();
resolveMakeRequest = await makeRequestPromise;
outgoingRequestProcessor.makeOutgoingRequest.mockReset();
resolveMakeRequest();
// now stop...
rustCrypto.stop();
@@ -241,7 +194,7 @@ describe("RustCrypto", () => {
setTimeout(resolve, 100);
});
expect(rustCrypto["outgoingRequestLoopRunning"]).toBeFalsy();
httpBackend.verifyNoOutstandingRequests();
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
expect(olmMachine.outgoingRequests).not.toHaveBeenCalled();
// we sent three, so there should be 2 left
+21 -23
View File
@@ -112,7 +112,7 @@ describe("MatrixScheduler", function () {
expect(procCount).toEqual(2);
});
it("should give up if the retryFn on failure returns -1 and try the next event", async function () {
it("should give up if the retryFn on failure returns -1", async function () {
// Queue A & B.
// Reject A and return -1 on retry.
// Expect B to be tried next and the promise for A to be rejected.
@@ -139,22 +139,18 @@ describe("MatrixScheduler", function () {
return new Promise<Record<string, boolean>>(() => {});
});
const globalA = scheduler.queueEvent(eventA);
scheduler.queueEvent(eventB);
const queuedA = scheduler.queueEvent(eventA);
const queuedB = scheduler.queueEvent(eventB);
await Promise.resolve();
deferA.reject(new Error("Testerror"));
// as queueing doesn't start processing synchronously anymore (see commit bbdb5ac)
// wait just long enough before it does
await Promise.resolve();
await expect(queuedA).rejects.toThrow("Testerror");
await expect(queuedB).rejects.toThrow("Testerror");
expect(procCount).toEqual(1);
deferA.reject({});
try {
await globalA;
} catch (err) {
await Promise.resolve();
expect(procCount).toEqual(2);
}
});
it("should treat each queue separately", function (done) {
it("should treat each queue separately", async () => {
// Queue messages A B C D.
// Bucket A&D into queue_A
// Bucket B&C into queue_B
@@ -179,13 +175,15 @@ describe("MatrixScheduler", function () {
const expectOrder = [eventA.getId(), eventB.getId(), eventD.getId()];
const deferA = defer<Record<string, boolean>>();
scheduler.setProcessFunction(function (event) {
const id = expectOrder.shift();
expect(id).toEqual(event.getId());
if (expectOrder.length === 0) {
done();
}
return id === eventA.getId() ? deferA.promise : deferred.promise;
const allExpectedEventsSeenInOrderPromise = new Promise((resolve) => {
scheduler.setProcessFunction(function (event) {
const id = expectOrder.shift();
expect(id).toEqual(event.getId());
if (expectOrder.length === 0) {
resolve(null);
}
return id === eventA.getId() ? deferA.promise : deferred.promise;
});
});
scheduler.queueEvent(eventA);
scheduler.queueEvent(eventB);
@@ -197,6 +195,7 @@ describe("MatrixScheduler", function () {
deferA.resolve({});
}, 1000);
jest.advanceTimersByTime(1000);
await allExpectedEventsSeenInOrderPromise;
});
describe("queueEvent", function () {
@@ -294,7 +293,7 @@ describe("MatrixScheduler", function () {
});
describe("setProcessFunction", function () {
it("should call the processFn if there are queued events", function () {
it("should call the processFn if there are queued events", async () => {
queueFn = function () {
return "yep";
};
@@ -307,9 +306,8 @@ describe("MatrixScheduler", function () {
});
// as queueing doesn't start processing synchronously anymore (see commit bbdb5ac)
// wait just long enough before it does
Promise.resolve().then(() => {
expect(procCount).toEqual(1);
});
await Promise.resolve();
expect(procCount).toEqual(1);
});
it("should not call the processFn if there are no queued events", function () {
+36
View File
@@ -1,3 +1,19 @@
/*
Copyright 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.
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 utils from "../../src/utils";
import {
alphabetPad,
@@ -129,9 +145,11 @@ describe("utils", function () {
describe("deepCompare", function () {
const assert = {
isTrue: function (x: any) {
// eslint-disable-next-line jest/no-standalone-expect
expect(x).toBe(true);
},
isFalse: function (x: any) {
// eslint-disable-next-line jest/no-standalone-expect
expect(x).toBe(false);
},
};
@@ -587,4 +605,22 @@ describe("utils", function () {
expect(utils.isSupportedReceiptType("this is a receipt type")).toBeFalsy();
});
});
describe("sleep", () => {
it("resolves", async () => {
await utils.sleep(0);
});
it("resolves with the provided value", async () => {
const expected = Symbol("hi");
const result = await utils.sleep(0, expected);
expect(result).toBe(expected);
});
});
describe("immediate", () => {
it("resolves", async () => {
await utils.immediate();
});
});
});
+60 -2
View File
@@ -40,7 +40,9 @@ import {
MockMediaStreamTrack,
installWebRTCMocks,
MockRTCPeerConnection,
MockRTCRtpTransceiver,
SCREENSHARE_STREAM_ID,
MockRTCRtpSender,
} from "../../test-utils/webrtc";
import { CallFeed } from "../../../src/webrtc/callFeed";
import { EventType, IContent, ISendEventResponse, MatrixEvent, Room } from "../../../src";
@@ -536,8 +538,15 @@ describe("Call", function () {
it("if local video", async () => {
call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" });
// since this is testing for the presence of a local sender, we need to add a transciever
// rather than just a source track
const mockTrack = new MockMediaStreamTrack("track_id", "video");
const mockTransceiver = new MockRTCRtpTransceiver(call.peerConn as unknown as MockRTCPeerConnection);
mockTransceiver.sender = new MockRTCRtpSender(mockTrack) as unknown as RTCRtpSender;
(call as any).transceivers.set("m.usermedia:video", mockTransceiver);
(call as any).pushNewLocalFeed(
new MockMediaStream("remote_stream1", [new MockMediaStreamTrack("track_id", "video")]),
new MockMediaStream("remote_stream1", [mockTrack]),
SDPStreamMetadataPurpose.Usermedia,
false,
);
@@ -829,6 +838,55 @@ describe("Call", function () {
await startVideoCall(client, call);
});
afterEach(() => {
jest.useRealTimers();
});
it("should not remove video sender on video mute", async () => {
await call.setLocalVideoMuted(true);
expect((call as any).hasUserMediaVideoSender).toBe(true);
});
it("should release camera after short delay on video mute", async () => {
jest.useFakeTimers();
await call.setLocalVideoMuted(true);
jest.advanceTimersByTime(500);
expect(call.hasLocalUserMediaVideoTrack).toBe(false);
});
it("should re-request video feed on video unmute if it doesn't have one", async () => {
jest.useFakeTimers();
const mockGetUserMediaStream = jest
.fn()
.mockReturnValue(client.client.getMediaHandler().getUserMediaStream(true, true));
client.client.getMediaHandler().getUserMediaStream = mockGetUserMediaStream;
await call.setLocalVideoMuted(true);
jest.advanceTimersByTime(500);
await call.setLocalVideoMuted(false);
expect(mockGetUserMediaStream).toHaveBeenCalled();
});
it("should not release camera on fast mute and unmute", async () => {
const mockGetUserMediaStream = jest.fn();
client.client.getMediaHandler().getUserMediaStream = mockGetUserMediaStream;
await call.setLocalVideoMuted(true);
await call.setLocalVideoMuted(false);
expect(mockGetUserMediaStream).not.toHaveBeenCalled();
expect(call.hasLocalUserMediaVideoTrack).toBe(true);
});
describe("sending sdp_stream_metadata_changed events", () => {
it("should send sdp_stream_metadata_changed when muting audio", async () => {
await call.setMicrophoneMuted(true);
@@ -1521,7 +1579,7 @@ describe("Call", function () {
hasAdvancedBy += advanceBy;
expect(lengthChangedListener).toHaveBeenCalledTimes(hasAdvancedBy);
expect(lengthChangedListener).toBeCalledWith(hasAdvancedBy);
expect(lengthChangedListener).toHaveBeenCalledWith(hasAdvancedBy);
}
});
+1 -1
View File
@@ -73,7 +73,7 @@ describe("CallFeed", () => {
});
describe("muting after calling setAudioVideoMuted()", () => {
it("should mute audio by default ", () => {
it("should mute audio by default", () => {
// @ts-ignore Mock
feed.stream.addTrack(new MockMediaStreamTrack("track", "audio", true));
feed.setAudioVideoMuted(true, false);
+54 -2
View File
@@ -147,10 +147,17 @@ describe("Group Call", function () {
async (state: GroupCallState) => {
// @ts-ignore
groupCall.state = state;
await expect(groupCall.initLocalCallFeed()).rejects.toThrowError();
await expect(groupCall.initLocalCallFeed()).rejects.toThrow();
},
);
it.each([0, 3, 5, 10, 5000])("sets correct creation timestamp when creating a call", async (time: number) => {
jest.spyOn(Date, "now").mockReturnValue(time);
await groupCall.create();
expect(groupCall.creationTs).toBe(time);
});
it("does not initialize local call feed, if it already is", async () => {
await groupCall.initLocalCallFeed();
jest.spyOn(groupCall, "initLocalCallFeed");
@@ -161,6 +168,25 @@ describe("Group Call", function () {
groupCall.leave();
});
it("does not start initializing local call feed twice", () => {
const promise1 = groupCall.initLocalCallFeed();
// @ts-ignore Mock
groupCall.state = GroupCallState.LocalCallFeedUninitialized;
const promise2 = groupCall.initLocalCallFeed();
expect(promise1).toEqual(promise2);
});
it("sets state to local call feed uninitialized when getUserMedia() fails", async () => {
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValue("Error");
try {
await groupCall.initLocalCallFeed();
} catch (e) {}
expect(groupCall.state).toBe(GroupCallState.LocalCallFeedUninitialized);
});
it("stops initializing local call feed when leaving", async () => {
const initPromise = groupCall.initLocalCallFeed();
groupCall.leave();
@@ -317,6 +343,20 @@ describe("Group Call", function () {
}
});
it("does not throw when calling updateLocalUsermediaStream() without local usermedia stream", () => {
expect(async () => await groupCall.updateLocalUsermediaStream({} as MediaStream)).not.toThrow();
});
it.each([GroupCallState.Ended, GroupCallState.Entered, GroupCallState.InitializingLocalCallFeed])(
"throws when entering call in the wrong state",
async (state: GroupCallState) => {
// @ts-ignore Mock
groupCall.state = state;
await expect(groupCall.enter()).rejects.toThrow();
},
);
describe("hasLocalParticipant()", () => {
it("should return false, if we don't have a local participant", () => {
expect(groupCall.hasLocalParticipant()).toBeFalsy();
@@ -349,7 +389,7 @@ describe("Group Call", function () {
jest.spyOn(call, "getOpponentMember").mockReturnValue({ userId: undefined });
// @ts-ignore Mock
expect(() => groupCall.onCallFeedsChanged(call)).toThrowError();
expect(() => groupCall.onCallFeedsChanged(call)).toThrow();
});
describe("usermedia feeds", () => {
@@ -835,6 +875,18 @@ describe("Group Call", function () {
groupCall.terminate();
});
it("returns false when unmuting audio with no audio device", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
jest.spyOn(mockClient.getMediaHandler(), "hasAudioDevice").mockResolvedValue(false);
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
});
it("returns false when unmuting video with no video device", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
jest.spyOn(mockClient.getMediaHandler(), "hasVideoDevice").mockResolvedValue(false);
expect(await groupCall.setLocalVideoMuted(false)).toBe(false);
});
});
describe("remote muting", () => {
+2 -2
View File
@@ -48,7 +48,7 @@ describe("Media Handler", function () {
} as unknown as MatrixClient);
});
it("does not trigger update after restore media settings ", () => {
it("does not trigger update after restore media settings", () => {
mediaHandler.restoreMediaSettings(FAKE_AUDIO_INPUT_ID, FAKE_VIDEO_INPUT_ID);
expect(mockMediaDevices.getUserMedia).not.toHaveBeenCalled();
@@ -401,7 +401,7 @@ describe("Media Handler", function () {
});
});
describe("stopUserMediaStream", () => {
describe("stopScreensharingStream", () => {
let stream: MediaStream;
beforeEach(async () => {
+28 -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.
@@ -15,6 +15,7 @@ limitations under the License.
*/
import type { IClearEvent } from "../models/event";
import type { ISignatures } from "./signed";
export type OlmGroupSessionExtraData = {
untrusted?: boolean;
@@ -43,6 +44,10 @@ export interface IEventDecryptionResult {
*/
claimedEd25519Key?: string;
untrusted?: boolean;
/**
* The sender doesn't authorize the unverified devices to decrypt his messages
*/
encryptedDisabledForUnverifiedDevices?: boolean;
}
interface Extensible {
@@ -70,3 +75,25 @@ export interface IMegolmSessionData extends Extensible {
}
/* eslint-enable camelcase */
/** the type of the `device_keys` parameter on `/_matrix/client/v3/keys/upload`
*
* @see https://spec.matrix.org/v1.5/client-server-api/#post_matrixclientv3keysupload
*/
export interface IDeviceKeys {
algorithms: Array<string>;
device_id: string; // eslint-disable-line camelcase
user_id: string; // eslint-disable-line camelcase
keys: Record<string, string>;
signatures?: ISignatures;
}
/** the type of the `one_time_keys` and `fallback_keys` parameters on `/_matrix/client/v3/keys/upload`
*
* @see https://spec.matrix.org/v1.5/client-server-api/#post_matrixclientv3keysupload
*/
export interface IOneTimeKey {
key: string;
fallback?: boolean;
signatures?: ISignatures;
}
+1
View File
@@ -33,6 +33,7 @@ export enum EventType {
RoomGuestAccess = "m.room.guest_access",
RoomServerAcl = "m.room.server_acl",
RoomTombstone = "m.room.tombstone",
RoomPredecessor = "org.matrix.msc3946.room_predecessor",
SpaceChild = "m.space.child",
SpaceParent = "m.space.parent",
+1 -1
View File
@@ -47,7 +47,7 @@ interface WellKnownConfig extends Omit<IWellKnownConfig, "error"> {
error?: IWellKnownConfig["error"] | null;
}
interface ClientConfig extends Omit<IClientWellKnown, "m.homeserver" | "m.identity_server"> {
export interface ClientConfig extends Omit<IClientWellKnown, "m.homeserver" | "m.identity_server"> {
"m.homeserver": WellKnownConfig;
"m.identity_server": WellKnownConfig;
}
+219 -124
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.
@@ -20,7 +20,7 @@ limitations under the License.
import { Optional } from "matrix-events-sdk";
import type { IMegolmSessionData } from "./@types/crypto";
import type { IDeviceKeys, IMegolmSessionData, IOneTimeKey } from "./@types/crypto";
import { ISyncStateData, SyncApi, SyncApiOptions, SyncState } from "./sync";
import {
EventStatus,
@@ -85,13 +85,7 @@ import { keyFromAuthData } from "./crypto/key_passphrase";
import { User, UserEvent, UserEventHandlerMap } from "./models/user";
import { getHttpUriForMxc } from "./content-repo";
import { SearchResult } from "./models/search-result";
import {
DEHYDRATION_ALGORITHM,
IDehydratedDevice,
IDehydratedDeviceKeyInfo,
IDeviceKeys,
IOneTimeKey,
} from "./crypto/dehydration";
import { DEHYDRATION_ALGORITHM, IDehydratedDevice, IDehydratedDeviceKeyInfo } from "./crypto/dehydration";
import {
IKeyBackupInfo,
IKeyBackupPrepareOpts,
@@ -209,7 +203,6 @@ import { ToDeviceBatch } from "./models/ToDeviceMessage";
import { IgnoredInvites } from "./models/invites-ignorer";
import { UIARequest, UIAResponse } from "./@types/uia";
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";
@@ -446,10 +439,16 @@ export interface IStartClientOpts {
clientWellKnownPollPeriod?: number;
/**
* @experimental
* @deprecated use `threadSupport` instead
*/
experimentalThreadSupport?: boolean;
/**
* Will organises events in threaded conversations when
* a thread relation is encountered
*/
threadSupport?: boolean;
/**
* @experimental
*/
@@ -568,6 +567,8 @@ export interface IWellKnownConfig {
error?: Error | string;
// eslint-disable-next-line
base_url?: string | null;
// XXX: this is undocumented
server_name?: string;
}
export interface IDelegatedAuthConfig {
@@ -1300,6 +1301,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.on(ClientEvent.Sync, this.startCallEventHandler);
}
this.on(ClientEvent.Sync, this.fixupRoomNotifications);
this.timelineSupport = Boolean(opts.timelineSupport);
this.cryptoStore = opts.cryptoStore;
@@ -1448,6 +1451,19 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
}
if (this.clientOpts.hasOwnProperty("experimentalThreadSupport")) {
logger.warn("`experimentalThreadSupport` has been deprecated, use `threadSupport` instead");
}
// If `threadSupport` is omitted and the deprecated `experimentalThreadSupport` has been passed
// We should fallback to that value for backwards compatibility purposes
if (
!this.clientOpts.hasOwnProperty("threadSupport") &&
this.clientOpts.hasOwnProperty("experimentalThreadSupport")
) {
this.clientOpts.threadSupport = this.clientOpts.experimentalThreadSupport;
}
this.syncApi.sync();
if (this.clientOpts.clientWellKnownPollPeriod !== undefined) {
@@ -2165,7 +2181,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// 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);
const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId);
this.cryptoBackend = rustCrypto;
// attach the event listeners needed by RustCrypto
this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto));
}
/**
@@ -2588,10 +2608,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param room - the room the event is in
*/
public prepareToEncrypt(room: Room): void {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
this.crypto.prepareToEncrypt(room);
this.cryptoBackend.prepareToEncrypt(room);
}
/**
@@ -3780,14 +3800,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* This is essentially getRooms() with some rooms filtered out, eg. old versions
* of rooms that have been replaced or (in future) other rooms that have been
* marked at the protocol level as not to be displayed to the user.
*
* @param msc3946ProcessDynamicPredecessor - if true, look for an
* m.room.predecessor state event and
* use it if found (MSC3946).
* @returns A list of rooms, or an empty list if there is no data store.
*/
public getVisibleRooms(): Room[] {
public getVisibleRooms(msc3946ProcessDynamicPredecessor = false): Room[] {
const allRooms = this.store.getRooms();
const replacedRooms = new Set();
for (const r of allRooms) {
const predecessor = r.findPredecessorRoomId();
const predecessor = r.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
if (predecessor) {
replacedRooms.add(predecessor);
}
@@ -4107,12 +4131,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
roomId: string,
userId: string | string[],
powerLevel: number,
event: MatrixEvent,
event: MatrixEvent | null,
): Promise<ISendEventResponse> {
let content = {
users: {} as Record<string, number>,
};
if (event.getType() === EventType.RoomPowerLevels) {
if (event?.getType() === EventType.RoomPowerLevels) {
// take a copy of the content to ensure we don't corrupt
// existing client state with a failed power level change
content = utils.deepCopy(event.getContent());
@@ -4368,11 +4392,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return null;
}
if (!this.isRoomEncrypted(event.getRoomId()!)) {
if (!room || !this.isRoomEncrypted(event.getRoomId()!)) {
return null;
}
if (!this.crypto && this.usingExternalCrypto) {
if (!this.cryptoBackend && this.usingExternalCrypto) {
// The client has opted to allow sending messages to encrypted
// rooms even if the room is encrypted, and we haven't setup
// crypto. This is useful for users of matrix-org/pantalaimon
@@ -4393,13 +4417,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return null;
}
if (!this.crypto) {
throw new Error(
"This room is configured to use encryption, but your client does " + "not support encryption.",
);
if (!this.cryptoBackend) {
throw new Error("This room is configured to use encryption, but your client does not support encryption.");
}
return this.crypto.encryptEvent(event, room);
return this.cryptoBackend.encryptEvent(event, room);
}
/**
@@ -4817,8 +4839,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$eventId: event.getId()!,
});
const supportsThreadRR = this.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported;
if (supportsThreadRR && !unthreaded) {
if (!unthreaded) {
const isThread = !!event.threadRootId;
body = {
...body,
@@ -4986,70 +5007,83 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* which can be proven to be linked. For example, rooms which have a create
* event pointing to an old room which the client is not aware of or doesn't
* have a matching tombstone would not be returned.
* @param msc3946ProcessDynamicPredecessor - if true, look for
* m.room.predecessor state events as well as create events, and prefer
* predecessor events where they exist (MSC3946).
* @returns An array of rooms representing the upgrade
* history.
*/
public getRoomUpgradeHistory(roomId: string, verifyLinks = false): Room[] {
let currentRoom = this.getRoom(roomId);
public getRoomUpgradeHistory(
roomId: string,
verifyLinks = false,
msc3946ProcessDynamicPredecessor = false,
): Room[] {
const currentRoom = this.getRoom(roomId);
if (!currentRoom) return [];
const upgradeHistory = [currentRoom];
const before = this.findPredecessorRooms(currentRoom, verifyLinks, msc3946ProcessDynamicPredecessor);
const after = this.findSuccessorRooms(currentRoom, verifyLinks, msc3946ProcessDynamicPredecessor);
// Work backwards first, looking at create events.
let createEvent = currentRoom.currentState.getStateEvents(EventType.RoomCreate, "");
while (createEvent) {
const predecessor = createEvent.getContent()["predecessor"];
if (predecessor && predecessor["room_id"]) {
const refRoom = this.getRoom(predecessor["room_id"]);
if (!refRoom) break; // end of the chain
return [...before, currentRoom, ...after];
}
if (verifyLinks) {
const tombstone = refRoom.currentState.getStateEvents(EventType.RoomTombstone, "");
private findPredecessorRooms(room: Room, verifyLinks: boolean, msc3946ProcessDynamicPredecessor: boolean): Room[] {
const ret: Room[] = [];
if (!tombstone || tombstone.getContent()["replacement_room"] !== refRoom.roomId) {
break;
}
}
// Insert at the front because we're working backwards from the currentRoom
upgradeHistory.splice(0, 0, refRoom);
createEvent = refRoom.currentState.getStateEvents(EventType.RoomCreate, "");
} else {
// No further create events to look at
// Work backwards from newer to older rooms
let predecessorRoomId = room.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
while (predecessorRoomId !== null) {
const predecessorRoom = this.getRoom(predecessorRoomId);
if (predecessorRoom === null) {
break;
}
}
if (verifyLinks) {
const tombstone = predecessorRoom.currentState.getStateEvents(EventType.RoomTombstone, "");
if (!tombstone || tombstone.getContent()["replacement_room"] !== room.roomId) {
break;
}
}
// Work forwards next, looking at tombstone events
let tombstoneEvent = currentRoom.currentState.getStateEvents(EventType.RoomTombstone, "");
// Insert at the front because we're working backwards from the currentRoom
ret.splice(0, 0, predecessorRoom);
room = predecessorRoom;
predecessorRoomId = room.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
}
return ret;
}
private findSuccessorRooms(room: Room, verifyLinks: boolean, msc3946ProcessDynamicPredecessor: boolean): Room[] {
const ret: Room[] = [];
// Work forwards, looking at tombstone events
let tombstoneEvent = room.currentState.getStateEvents(EventType.RoomTombstone, "");
while (tombstoneEvent) {
const refRoom = this.getRoom(tombstoneEvent.getContent()["replacement_room"]);
if (!refRoom) break; // end of the chain
if (refRoom.roomId === currentRoom.roomId) break; // Tombstone is referencing it's own room
const successorRoom = this.getRoom(tombstoneEvent.getContent()["replacement_room"]);
if (!successorRoom) break; // end of the chain
if (successorRoom.roomId === room.roomId) break; // Tombstone is referencing its own room
if (verifyLinks) {
createEvent = refRoom.currentState.getStateEvents(EventType.RoomCreate, "");
if (!createEvent || !createEvent.getContent()["predecessor"]) break;
const predecessor = createEvent.getContent()["predecessor"];
if (predecessor["room_id"] !== currentRoom.roomId) break;
const predecessorRoomId = successorRoom.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
if (!predecessorRoomId || predecessorRoomId !== room.roomId) {
break;
}
}
// Push to the end because we're looking forwards
upgradeHistory.push(refRoom);
const roomIds = new Set(upgradeHistory.map((ref) => ref.roomId));
if (roomIds.size < upgradeHistory.length) {
ret.push(successorRoom);
const roomIds = new Set(ret.map((ref) => ref.roomId));
if (roomIds.size < ret.length) {
// The last room added to the list introduced a previous roomId
// To avoid recursion, return the last rooms - 1
return upgradeHistory.slice(0, upgradeHistory.length - 1);
return ret.slice(0, ret.length - 1);
}
// Set the current room to the reference room so we know where we're at
currentRoom = refRoom;
tombstoneEvent = currentRoom.currentState.getStateEvents(EventType.RoomTombstone, "");
room = successorRoom;
tombstoneEvent = room.currentState.getStateEvents(EventType.RoomTombstone, "");
}
return upgradeHistory;
return ret;
}
/**
@@ -5420,7 +5454,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents);
this.processBeaconEvents(room, timelineEvents);
this.processAggregatedTimelineEvents(room, timelineEvents);
room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
this.processThreadEvents(room, threadedEvents, true);
@@ -5481,7 +5515,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return timelineSet.getTimelineForEvent(eventId);
}
if (timelineSet.thread && this.supportsExperimentalThreads()) {
if (timelineSet.thread && this.supportsThreads()) {
return this.getThreadTimeline(timelineSet, eventId);
}
@@ -5535,7 +5569,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start);
// The target event is not in a thread but process the contextual events, so we can show any threads around it.
this.processThreadEvents(timelineSet.room, threadedEvents, true);
this.processBeaconEvents(timelineSet.room, timelineEvents);
this.processAggregatedTimelineEvents(timelineSet.room, timelineEvents);
// There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring
// timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up
@@ -5548,7 +5582,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
public async getThreadTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<EventTimeline | undefined> {
if (!this.supportsExperimentalThreads()) {
if (!this.supportsThreads()) {
throw new Error("could not get thread timeline: no client support");
}
@@ -5630,7 +5664,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward);
timeline.setPaginationToken(resNewer.next_batch ?? null, Direction.Forward);
this.processBeaconEvents(timelineSet.room, events);
this.processAggregatedTimelineEvents(timelineSet.room, events);
// There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring
// timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up
@@ -5687,7 +5721,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward);
timeline.setPaginationToken(null, Direction.Forward);
this.processBeaconEvents(timelineSet.room, events);
this.processAggregatedTimelineEvents(timelineSet.room, events);
return timeline;
}
@@ -5940,7 +5974,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// in the notification timeline set
const timelineSet = eventTimeline.getTimelineSet();
timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
this.processBeaconEvents(timelineSet.room, matrixEvents);
this.processAggregatedTimelineEvents(timelineSet.room, matrixEvents);
// if we've hit the end of the timeline, we need to stop trying to
// paginate. We need to keep the 'forwards' token though, to make sure
@@ -5982,7 +6016,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const timelineSet = eventTimeline.getTimelineSet();
timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
this.processBeaconEvents(room, matrixEvents);
this.processAggregatedTimelineEvents(room, matrixEvents);
this.processThreadRoots(room, matrixEvents, backwards);
// if we've hit the end of the timeline, we need to stop trying to
@@ -6029,7 +6063,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const originalEvent = await this.fetchRoomEvent(eventTimeline.getRoomId() ?? "", thread.id);
timelineSet.addEventsToTimeline([mapper(originalEvent)], true, eventTimeline, null);
}
this.processBeaconEvents(timelineSet.room, matrixEvents);
this.processAggregatedTimelineEvents(timelineSet.room, matrixEvents);
// if we've hit the end of the timeline, we need to stop trying to
// paginate. We need to keep the 'forwards' token though, to make sure
@@ -6067,7 +6101,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const timelineSet = eventTimeline.getTimelineSet();
const [timelineEvents] = room.partitionThreadedEvents(matrixEvents);
timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
this.processBeaconEvents(room, timelineEvents);
this.processAggregatedTimelineEvents(room, timelineEvents);
this.processThreadRoots(
room,
timelineEvents.filter((it) => it.getServerAggregatedRelation(THREAD_RELATION_TYPE.name)),
@@ -6779,6 +6813,31 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
};
/**
* Once the client has been initialised, we want to clear notifications we
* know for a fact should be here.
* This issue should also be addressed on synapse's side and is tracked as part
* of https://github.com/matrix-org/synapse/issues/14837
*
* We consider a room or a thread as fully read if the current user has sent
* the last event in the live timeline of that context and if the read receipt
* we have on record matches.
*/
private fixupRoomNotifications = (): void => {
if (this.isInitialSyncComplete()) {
const unreadRooms = (this.getRooms() ?? []).filter((room) => {
return room.getUnreadNotificationCount(NotificationCountType.Total) > 0;
});
for (const room of unreadRooms) {
const currentUserId = this.getSafeUserId();
room.fixupNotifications(currentUserId);
}
this.off(ClientEvent.Sync, this.fixupRoomNotifications);
}
};
/**
* @returns Promise which resolves: ITurnServerResponse object
* @returns Rejects: with an error response.
@@ -7009,10 +7068,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const serverVersions = await this.serverVersionsPromise;
this.canSupport = await buildFeatureSupportMap(serverVersions);
// We can set flag values to use their stable or unstable version
const support = this.canSupport.get(Feature.ThreadUnreadNotifications);
UNREAD_THREAD_NOTIFICATIONS.setPreferUnstable(support === ServerSupport.Unstable);
return this.serverVersionsPromise;
}
@@ -9320,12 +9375,21 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* @experimental
* @deprecated use supportsThreads() instead
*/
public supportsExperimentalThreads(): boolean {
logger.warn(`supportsExperimentalThreads() is deprecated, use supportThreads() instead`);
return this.clientOpts?.experimentalThreadSupport || false;
}
/**
* A helper to determine thread support
* @returns a boolean to determine if threads are enabled
*/
public supportsThreads(): boolean {
return this.clientOpts?.threadSupport || false;
}
/**
* Fetches the summary of a room as defined by an initial version of MSC3266 and implemented in Synapse
* Proposed at https://github.com/matrix-org/matrix-doc/pull/3266
@@ -9340,24 +9404,42 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* @experimental
* Processes a list of threaded events and adds them to their respective timelines
* @param room - the room the adds the threaded events
* @param threadedEvents - an array of the threaded events
* @param toStartOfTimeline - the direction in which we want to add the events
*/
public processThreadEvents(room: Room, threadedEvents: MatrixEvent[], toStartOfTimeline: boolean): void {
room.processThreadedEvents(threadedEvents, toStartOfTimeline);
}
/**
* @experimental
* Processes a list of thread roots and creates a thread model
* @param room - the room to create the threads in
* @param threadedEvents - an array of thread roots
* @param toStartOfTimeline - the direction
*/
public processThreadRoots(room: Room, threadedEvents: MatrixEvent[], toStartOfTimeline: boolean): void {
room.processThreadRoots(threadedEvents, toStartOfTimeline);
}
public processBeaconEvents(room?: Room, events?: MatrixEvent[]): void {
this.processAggregatedTimelineEvents(room, events);
}
/**
* Calls aggregation functions for event types that are aggregated
* Polls and location beacons
* @param room - room the events belong to
* @param events - timeline events to be processed
* @returns
*/
public processAggregatedTimelineEvents(room?: Room, events?: MatrixEvent[]): void {
if (!events?.length) return;
if (!room) return;
room.currentState.processBeaconEvents(events, this);
room.processPollEvents(events);
}
/**
@@ -9422,64 +9504,77 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* accurate notification_count
*/
export function fixNotificationCountOnDecryption(cli: MatrixClient, event: MatrixEvent): void {
const ourUserId = cli.getUserId();
const eventId = event.getId();
const room = cli.getRoom(event.getRoomId());
if (!room || !ourUserId || !eventId) return;
const oldActions = event.getPushActions();
const actions = cli.getPushActionsForEvent(event, true);
const room = cli.getRoom(event.getRoomId());
if (!room || !cli.getUserId()) return;
const isThreadEvent = !!event.threadRootId && !event.isThreadRoot;
const currentCount = room.getUnreadCountForEventContext(NotificationCountType.Highlight, event);
const currentHighlightCount = room.getUnreadCountForEventContext(NotificationCountType.Highlight, event);
// Ensure the unread counts are kept up to date if the event is encrypted
// We also want to make sure that the notification count goes up if we already
// have encrypted events to avoid other code from resetting 'highlight' to zero.
const oldHighlight = !!oldActions?.tweaks?.highlight;
const newHighlight = !!actions?.tweaks?.highlight;
if (oldHighlight !== newHighlight || currentCount > 0) {
let hasReadEvent;
if (isThreadEvent) {
const thread = room.getThread(event.threadRootId);
hasReadEvent = thread
? thread.hasUserReadEvent(ourUserId, eventId)
: // If the thread object does not exist in the room yet, we don't
// want to calculate notification for this event yet. We have not
// restored the read receipts yet and can't accurately calculate
// notifications at this stage.
//
// This issue can likely go away when MSC3874 is implemented
true;
} else {
hasReadEvent = room.hasUserReadEvent(ourUserId, eventId);
}
if (hasReadEvent) {
// If the event has been read, ignore it.
return;
}
if (oldHighlight !== newHighlight || currentHighlightCount > 0) {
// TODO: Handle mentions received while the client is offline
// See also https://github.com/vector-im/element-web/issues/9069
let hasReadEvent;
let newCount = currentHighlightCount;
if (newHighlight && !oldHighlight) newCount++;
if (!newHighlight && oldHighlight) newCount--;
if (isThreadEvent) {
const thread = room.getThread(event.threadRootId);
hasReadEvent = thread
? thread.hasUserReadEvent(cli.getUserId()!, event.getId()!)
: // If the thread object does not exist in the room yet, we don't
// want to calculate notification for this event yet. We have not
// restored the read receipts yet and can't accurately calculate
// highlight notifications at this stage.
//
// This issue can likely go away when MSC3874 is implemented
true;
room.setThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Highlight, newCount);
} else {
hasReadEvent = room.hasUserReadEvent(cli.getUserId()!, event.getId()!);
room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount);
}
}
if (!hasReadEvent) {
let newCount = currentCount;
if (newHighlight && !oldHighlight) newCount++;
if (!newHighlight && oldHighlight) newCount--;
// Total count is used to typically increment a room notification counter, but not loudly highlight it.
const currentTotalCount = room.getUnreadCountForEventContext(NotificationCountType.Total, event);
if (isThreadEvent) {
room.setThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Highlight, newCount);
} else {
room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount);
}
// `notify` is used in practice for incrementing the total count
const newNotify = !!actions?.notify;
// Fix 'Mentions Only' rooms from not having the right badge count
const totalCount =
(isThreadEvent
? room.getThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Total)
: room.getRoomUnreadNotificationCount(NotificationCountType.Total)) ?? 0;
if (totalCount < newCount) {
if (isThreadEvent) {
room.setThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Total, newCount);
} else {
room.setUnreadNotificationCount(NotificationCountType.Total, newCount);
}
}
// The room total count is NEVER incremented by the server for encrypted rooms. We basically ignore
// the server here as it's always going to tell us to increment for encrypted events.
if (newNotify) {
if (isThreadEvent) {
room.setThreadUnreadNotificationCount(
event.threadRootId,
NotificationCountType.Total,
currentTotalCount + 1,
);
} else {
room.setUnreadNotificationCount(NotificationCountType.Total, currentTotalCount + 1);
}
}
}
+35
View File
@@ -18,6 +18,7 @@ import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypt
import type { IToDeviceEvent } from "../sync-accumulator";
import type { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
import { MatrixEvent } from "../models/event";
import { Room } from "../models/room";
import { IEncryptedEventInfo } from "../crypto/api";
/**
@@ -75,6 +76,26 @@ export interface CryptoBackend extends SyncCryptoCallbacks {
*/
checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel;
/**
* Perform any background tasks that can be done before a message is ready to
* send, in order to speed up sending of the message.
*
* @param room - the room the event is in
*/
prepareToEncrypt(room: Room): void;
/**
* Encrypt an event according to the configuration of the room.
*
* @param event - event to be sent
*
* @param room - destination room.
*
* @returns Promise which resolves when the event has been
* encrypted, or null if nothing was needed
*/
encryptEvent(event: MatrixEvent, room: Room): Promise<void>;
/**
* Decrypt a received event
*
@@ -117,6 +138,20 @@ export interface SyncCryptoCallbacks {
*/
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
/**
* Called by the /sync loop whenever an m.room.encryption event is received.
*
* This is called before RoomStateEvents are emitted for any of the events in the /sync
* response (even if the other events technically happened first). This works around a problem
* if the client uses a RoomStateEvent (typically a membership event) as a trigger to send a message
* in a new room (or one where encryption has been newly enabled): that would otherwise leave the
* crypto layer confused because it expects crypto to be set up, but it has not yet been.
*
* @param room - in which the event was received
* @param event - encryption event to be processed
*/
onCryptoEvent(room: Room, event: MatrixEvent): Promise<void>;
/**
* Called by the /sync loop after each /sync response is processed.
*
+3
View File
@@ -202,6 +202,9 @@ export type TopicState = {
export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => {
const mtopic = M_TOPIC.findIn<MTopicContent>(content);
if (!Array.isArray(mtopic)) {
return { text: content.topic };
}
const text = mtopic?.find((r) => !isProvided(r.mimetype) || r.mimetype === "text/plain")?.body ?? content.topic;
const html = mtopic?.find((r) => r.mimetype === "text/html")?.body;
return { text, html };
+55 -14
View File
@@ -1,5 +1,5 @@
/*
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2015 - 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.
@@ -43,6 +43,7 @@ import { IMegolmEncryptedContent, IncomingRoomKeyRequest, IEncryptedContent } fr
import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager";
import { OlmGroupSessionExtraData } from "../../@types/crypto";
import { MatrixError } from "../../http-api";
import { immediate } from "../../utils";
// determine whether the key can be shared with invitees
export function isRoomSharedHistory(room: Room): boolean {
@@ -73,7 +74,6 @@ export interface IOlmDevice<T = DeviceInfo> {
deviceInfo: T;
}
/* eslint-disable camelcase */
export interface IOutboundGroupSessionKey {
chain_index: number;
key: string;
@@ -106,7 +106,6 @@ interface IPayload extends Partial<IMessage> {
algorithm?: string;
sender_key?: string;
}
/* eslint-enable camelcase */
interface SharedWithData {
// The identity key of the device we shared with
@@ -223,6 +222,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
private encryptionPreparation?: {
promise: Promise<void>;
startTime: number;
cancel: () => void;
};
protected readonly roomId: string;
@@ -974,30 +974,36 @@ export class MegolmEncryption extends EncryptionAlgorithm {
* send, in order to speed up sending of the message.
*
* @param room - the room the event is in
* @returns A function that, when called, will stop the preparation
*/
public prepareToEncrypt(room: Room): void {
public prepareToEncrypt(room: Room): () => void {
if (room.roomId !== this.roomId) {
throw new Error("MegolmEncryption.prepareToEncrypt called on unexpected room");
}
if (this.encryptionPreparation != null) {
// We're already preparing something, so don't do anything else.
// FIXME: check if we need to restart
// (https://github.com/matrix-org/matrix-js-sdk/issues/1255)
const elapsedTime = Date.now() - this.encryptionPreparation.startTime;
this.prefixedLogger.debug(
`Already started preparing to encrypt for this room ${elapsedTime}ms ago, skipping`,
);
return;
return this.encryptionPreparation.cancel;
}
this.prefixedLogger.debug("Preparing to encrypt events");
let cancelled = false;
const isCancelled = (): boolean => cancelled;
this.encryptionPreparation = {
startTime: Date.now(),
promise: (async (): Promise<void> => {
try {
const [devicesInRoom, blocked] = await this.getDevicesInRoom(room);
// Attempt to enumerate the devices in room, and gracefully
// handle cancellation if it occurs.
const getDevicesResult = await this.getDevicesInRoom(room, false, isCancelled);
if (getDevicesResult === null) return;
const [devicesInRoom, blocked] = getDevicesResult;
if (this.crypto.globalErrorOnUnknownDevices) {
// Drop unknown devices for now. When the message gets sent, we'll
@@ -1016,7 +1022,16 @@ export class MegolmEncryption extends EncryptionAlgorithm {
delete this.encryptionPreparation;
}
})(),
cancel: (): void => {
// The caller has indicated that the process should be cancelled,
// so tell the promise that we'd like to halt, and reset the preparation state.
cancelled = true;
delete this.encryptionPreparation;
},
};
return this.encryptionPreparation.cancel;
}
/**
@@ -1165,17 +1180,32 @@ export class MegolmEncryption extends EncryptionAlgorithm {
*
* @param forceDistributeToUnverified - if set to true will include the unverified devices
* even if setting is set to block them (useful for verification)
* @param isCancelled - will cause the procedure to abort early if and when it starts
* returning `true`. If omitted, cancellation won't happen.
*
* @returns Promise which resolves to an array whose
* first element is a map from userId to deviceId to deviceInfo indicating
* @returns Promise which resolves to `null`, or an array whose
* first element is a {@link DeviceInfoMap} indicating
* the devices that messages should be encrypted to, and whose second
* element is a map from userId to deviceId to data indicating the devices
* that are in the room but that have been blocked
* that are in the room but that have been blocked.
* If `isCancelled` is provided and returns `true` while processing, `null`
* will be returned.
* If `isCancelled` is not provided, the Promise will never resolve to `null`.
*/
private async getDevicesInRoom(
room: Room,
forceDistributeToUnverified?: boolean,
): Promise<[DeviceInfoMap, IBlockedMap]>;
private async getDevicesInRoom(
room: Room,
forceDistributeToUnverified?: boolean,
isCancelled?: () => boolean,
): Promise<null | [DeviceInfoMap, IBlockedMap]>;
private async getDevicesInRoom(
room: Room,
forceDistributeToUnverified = false,
): Promise<[DeviceInfoMap, IBlockedMap]> {
isCancelled?: () => boolean,
): Promise<null | [DeviceInfoMap, IBlockedMap]> {
const members = await room.getEncryptionTargetMembers();
this.prefixedLogger.debug(
`Encrypting for users (shouldEncryptForInvitedMembers: ${room.shouldEncryptForInvitedMembers()}):`,
@@ -1201,6 +1231,11 @@ export class MegolmEncryption extends EncryptionAlgorithm {
// See https://github.com/vector-im/element-web/issues/2305 for details.
const devices = await this.crypto.downloadKeys(roomMembers, false);
const blocked: IBlockedMap = {};
if (isCancelled?.() === true) {
return null;
}
// remove any blocked devices
for (const userId in devices) {
if (!devices.hasOwnProperty(userId)) {
@@ -1213,6 +1248,11 @@ export class MegolmEncryption extends EncryptionAlgorithm {
continue;
}
// Yield prior to checking each device so that we don't block
// updating/rendering for too long.
// See https://github.com/vector-im/element-web/issues/21612
if (isCancelled !== undefined) await immediate();
if (isCancelled?.() === true) return null;
const deviceTrust = this.crypto.checkDeviceTrust(userId, deviceId);
if (
@@ -1304,7 +1344,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
errorCode = "OLM_UNKNOWN_MESSAGE_INDEX";
}
throw new DecryptionError(errorCode, e ? e.toString() : "Unknown Error: Error is undefined", {
throw new DecryptionError(errorCode, e instanceof Error ? e.message : "Unknown Error: Error is undefined", {
session: content.sender_key + "|" + content.session_id,
});
}
@@ -1327,7 +1367,8 @@ export class MegolmDecryption extends DecryptionAlgorithm {
if (problem) {
this.prefixedLogger.info(
`When handling UISI from ${event.getSender()} (sender key ${content.sender_key}): ` +
`recent session problem with that sender: ${problem}`,
`recent session problem with that sender:`,
problem,
);
let problemDescription = PROBLEM_DESCRIPTIONS[problem.type as "no_olm"] || PROBLEM_DESCRIPTIONS.unknown;
if (problem.fixed) {
+1 -15
View File
@@ -16,6 +16,7 @@ limitations under the License.
import anotherjson from "another-json";
import type { IDeviceKeys, IOneTimeKey } from "../@types/crypto";
import { decodeBase64, encodeBase64 } from "./olmlib";
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
import { decryptAES, encryptAES } from "./aes";
@@ -23,7 +24,6 @@ import { logger } from "../logger";
import { ISecretStorageKeyInfo } from "./api";
import { Crypto } from "./index";
import { Method } from "../http-api";
import { ISignatures } from "../@types/signed";
export interface IDehydratedDevice {
device_id: string; // eslint-disable-line camelcase
@@ -38,20 +38,6 @@ export interface IDehydratedDeviceKeyInfo {
passphrase?: string;
}
export interface IDeviceKeys {
algorithms: Array<string>;
device_id: string; // eslint-disable-line camelcase
user_id: string; // eslint-disable-line camelcase
keys: Record<string, string>;
signatures?: ISignatures;
}
export interface IOneTimeKey {
key: string;
fallback?: boolean;
signatures?: ISignatures;
}
export const DEHYDRATION_ALGORITHM = "org.matrix.msc2697.v1.olm.libolm_pickle";
const oneweek = 7 * 24 * 60 * 60 * 1000;
+5 -8
View File
@@ -20,7 +20,7 @@ limitations under the License.
import anotherjson from "another-json";
import { v4 as uuidv4 } from "uuid";
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
import type { IDeviceKeys, IEventDecryptionResult, IMegolmSessionData, IOneTimeKey } from "../@types/crypto";
import type { PkDecryption, PkSigning } from "@matrix-org/olm";
import { EventType, ToDeviceMessageId } from "../@types/event";
import { TypedReEmitter } from "../ReEmitter";
@@ -63,7 +63,7 @@ import { ToDeviceChannel, ToDeviceRequests, Request } from "./verification/reque
import { IllegalMethod } from "./verification/IllegalMethod";
import { KeySignatureUploadError } from "../errors";
import { calculateKeyCheck, decryptAES, encryptAES } from "./aes";
import { DehydrationManager, IDeviceKeys, IOneTimeKey } from "./dehydration";
import { DehydrationManager } from "./dehydration";
import { BackupManager } from "./backup";
import { IStore } from "../store";
import { Room, RoomEvent } from "../models/room";
@@ -151,7 +151,7 @@ export interface ICryptoCallbacks {
requestId: string,
secretName: string,
deviceTrust: DeviceTrustLevel,
) => Promise<string>;
) => Promise<string | undefined>;
getDehydrationKey?: (keyInfo: ISecretStorageKeyInfo, checkFunc: (key: Uint8Array) => void) => Promise<Uint8Array>;
getBackupKey?: () => Promise<Uint8Array>;
}
@@ -1202,6 +1202,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*/
public async storeSessionBackupPrivateKey(key: ArrayLike<number>): Promise<void> {
if (!(key instanceof Uint8Array)) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`);
}
const pickleKey = Buffer.from(this.olmDevice.pickleKey);
@@ -2808,11 +2809,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* @returns Promise which resolves when the event has been
* encrypted, or null if nothing was needed
*/
public async encryptEvent(event: MatrixEvent, room?: Room): Promise<void> {
if (!room) {
throw new Error("Cannot send encrypted messages in unknown rooms");
}
public async encryptEvent(event: MatrixEvent, room: Room): Promise<void> {
const roomId = event.getRoomId()!;
const alg = this.roomEncryptors.get(roomId);
+1 -1
View File
@@ -21,10 +21,10 @@ limitations under the License.
import anotherjson from "another-json";
import type { PkSigning } from "@matrix-org/olm";
import type { IOneTimeKey } from "../@types/crypto";
import { OlmDevice } from "./OlmDevice";
import { DeviceInfo } from "./deviceinfo";
import { logger } from "../logger";
import { IOneTimeKey } from "./dehydration";
import { IClaimOTKsResult, MatrixClient } from "../client";
import { ISignatures } from "../@types/signed";
import { MatrixEvent } from "../models/event";
+7 -1
View File
@@ -159,6 +159,7 @@ function generateSas(sasBytes: Uint8Array, methods: string[]): IGeneratedSas {
const macMethods = {
"hkdf-hmac-sha256": "calculate_mac",
"org.matrix.msc3783.hkdf-hmac-sha256": "calculate_mac_fixed_base64",
"hkdf-hmac-sha256.v2": "calculate_mac_fixed_base64",
"hmac-sha256": "calculate_mac_long_kdf",
} as const;
@@ -202,7 +203,12 @@ type KeyAgreement = keyof typeof calculateKeyAgreement;
*/
const KEY_AGREEMENT_LIST: KeyAgreement[] = ["curve25519-hkdf-sha256", "curve25519"];
const HASHES_LIST = ["sha256"];
const MAC_LIST: MacMethod[] = ["org.matrix.msc3783.hkdf-hmac-sha256", "hkdf-hmac-sha256", "hmac-sha256"];
const MAC_LIST: MacMethod[] = [
"hkdf-hmac-sha256.v2",
"org.matrix.msc3783.hkdf-hmac-sha256",
"hkdf-hmac-sha256",
"hmac-sha256",
];
const SAS_LIST = Object.keys(sasGenerators);
const KEY_AGREEMENT_SET = new Set(KEY_AGREEMENT_LIST);
+8 -8
View File
@@ -24,7 +24,7 @@ import { MatrixError } from "./http-api";
const EMAIL_STAGE_TYPE = "m.login.email.identity";
const MSISDN_STAGE_TYPE = "m.login.msisdn";
interface IFlow {
export interface UIAFlow {
stages: AuthType[];
}
@@ -48,8 +48,8 @@ export interface IAuthData {
session?: string;
type?: string;
completed?: string[];
flows?: IFlow[];
available_flows?: IFlow[];
flows?: UIAFlow[];
available_flows?: UIAFlow[];
stages?: string[];
required_stages?: AuthType[];
params?: Record<string, Record<string, any>>;
@@ -101,7 +101,7 @@ class NoAuthFlowFoundError extends Error {
public name = "NoAuthFlowFoundError";
// eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
public constructor(m: string, public readonly required_stages: string[], public readonly flows: IFlow[]) {
public constructor(m: string, public readonly required_stages: string[], public readonly flows: UIAFlow[]) {
super(m);
}
}
@@ -198,7 +198,7 @@ export class InteractiveAuth {
private emailSid?: string;
private requestingEmailToken = false;
private attemptAuthDeferred: IDeferred<IAuthData> | null = null;
private chosenFlow: IFlow | null = null;
private chosenFlow: UIAFlow | null = null;
private currentStage: string | null = null;
private emailAttempt = 1;
@@ -320,7 +320,7 @@ export class InteractiveAuth {
return this.data.params?.[loginType];
}
public getChosenFlow(): IFlow | null {
public getChosenFlow(): UIAFlow | null {
return this.chosenFlow;
}
@@ -573,7 +573,7 @@ export class InteractiveAuth {
* @returns flow
* @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found
*/
private chooseFlow(): IFlow {
private chooseFlow(): UIAFlow {
const flows = this.data.flows || [];
// we've been given an email or we've already done an email part
@@ -610,7 +610,7 @@ export class InteractiveAuth {
* @internal
* @returns login type
*/
private firstUncompletedStage(flow: IFlow): AuthType | undefined {
private firstUncompletedStage(flow: UIAFlow): AuthType | undefined {
const completed = this.data.completed || [];
return flow.stages.find((stageType) => !completed.includes(stageType));
}
+1
View File
@@ -34,6 +34,7 @@ export * from "./models/event";
export * from "./models/room";
export * from "./models/event-timeline";
export * from "./models/event-timeline-set";
export * from "./models/poll";
export * from "./models/room-member";
export * from "./models/room-state";
export * from "./models/user";
+22 -6
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.
@@ -36,6 +36,7 @@ import { TypedEventEmitter } from "./typed-event-emitter";
import { EventStatus } from "./event-status";
import { DecryptionError } from "../crypto/algorithms";
import { CryptoBackend } from "../common-crypto/CryptoBackend";
import { WITHHELD_MESSAGES } from "../crypto/OlmDevice";
export { EventStatus } from "./event-status";
@@ -267,12 +268,17 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
private txnId?: string;
/**
* @experimental
* A reference to the thread this event belongs to
*/
private thread?: Thread;
private threadId?: string;
/*
* True if this event is an encrypted event which we failed to decrypt, the receiver's device is unverified and
* the sender has disabled encrypting to unverified devices.
*/
private encryptedDisabledForUnverifiedDevices = false;
/* Set an approximate timestamp for the event relative the local clock.
* This will inherently be approximate because it doesn't take into account
* the time between the server putting the 'age' field on the event as it sent
@@ -546,7 +552,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
}
/**
* @experimental
* Get the event ID of the thread head
*/
public get threadRootId(): string | undefined {
@@ -559,7 +564,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
}
/**
* @experimental
* A helper to check if an event is a thread's head or not
*/
public get isThreadRoot(): boolean {
const threadDetails = this.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
@@ -706,6 +711,14 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
return this.clearEvent?.content?.msgtype === "m.bad.encrypted";
}
/*
* True if this event is an encrypted event which we failed to decrypt, the receiver's device is unverified and
* the sender has disabled encrypting to unverified devices.
*/
public get isEncryptedDisabledForUnverifiedDevices(): boolean {
return this.isDecryptionFailure() && this.encryptedDisabledForUnverifiedDevices;
}
public shouldAttemptDecryption(): boolean {
if (this.isRedacted()) return false;
if (this.isBeingDecrypted()) return false;
@@ -901,6 +914,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
body: "** Unable to decrypt: " + reason + " **",
},
},
encryptedDisabledForUnverifiedDevices: reason === `DecryptionError: ${WITHHELD_MESSAGES["m.unverified"]}`,
};
}
@@ -922,6 +936,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
this.claimedEd25519Key = decryptionResult.claimedEd25519Key ?? null;
this.forwardingCurve25519KeyChain = decryptionResult.forwardingCurve25519KeyChain || [];
this.untrusted = decryptionResult.untrusted || false;
this.encryptedDisabledForUnverifiedDevices = decryptionResult.encryptedDisabledForUnverifiedDevices || false;
this.invalidateExtensibleEvent();
}
@@ -1557,7 +1572,8 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
}
/**
* @experimental
* Set the instance of a thread associated with the current event
* @param thread - the thread
*/
public setThread(thread?: Thread): void {
if (this.thread) {
@@ -1571,7 +1587,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
}
/**
* @experimental
* Get the instance of the thread associated with the current event
*/
public getThread(): Thread | undefined {
return this.thread;
+268
View File
@@ -0,0 +1,268 @@
/*
Copyright 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.
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 { M_POLL_END, M_POLL_RESPONSE } from "../@types/polls";
import { MatrixClient } from "../client";
import { PollStartEvent } from "../extensible_events_v1/PollStartEvent";
import { MatrixEvent } from "./event";
import { Relations } from "./relations";
import { Room } from "./room";
import { TypedEventEmitter } from "./typed-event-emitter";
export enum PollEvent {
New = "Poll.new",
End = "Poll.end",
Update = "Poll.update",
Responses = "Poll.Responses",
Destroy = "Poll.Destroy",
UndecryptableRelations = "Poll.UndecryptableRelations",
}
export type PollEventHandlerMap = {
[PollEvent.Update]: (event: MatrixEvent, poll: Poll) => void;
[PollEvent.Destroy]: (pollIdentifier: string) => void;
[PollEvent.End]: () => void;
[PollEvent.Responses]: (responses: Relations) => void;
[PollEvent.UndecryptableRelations]: (count: number) => void;
};
const filterResponseRelations = (
relationEvents: MatrixEvent[],
pollEndTimestamp: number,
): {
responseEvents: MatrixEvent[];
} => {
const responseEvents = relationEvents.filter((event) => {
if (event.isDecryptionFailure()) {
return;
}
return (
M_POLL_RESPONSE.matches(event.getType()) &&
// From MSC3381:
// "Votes sent on or before the end event's timestamp are valid votes"
event.getTs() <= pollEndTimestamp
);
});
return { responseEvents };
};
export class Poll extends TypedEventEmitter<Exclude<PollEvent, PollEvent.New>, PollEventHandlerMap> {
public readonly roomId: string;
public readonly pollEvent: PollStartEvent;
private _isFetchingResponses = false;
private relationsNextBatch: string | undefined;
private responses: null | Relations = null;
private endEvent: MatrixEvent | undefined;
/**
* Keep track of undecryptable relations
* As incomplete result sets affect poll results
*/
private undecryptableRelationEventIds = new Set<string>();
public constructor(public readonly rootEvent: MatrixEvent, private matrixClient: MatrixClient, private room: Room) {
super();
if (!this.rootEvent.getRoomId() || !this.rootEvent.getId()) {
throw new Error("Invalid poll start event.");
}
this.roomId = this.rootEvent.getRoomId()!;
this.pollEvent = this.rootEvent.unstableExtensibleEvent as unknown as PollStartEvent;
}
public get pollId(): string {
return this.rootEvent.getId()!;
}
public get endEventId(): string | undefined {
return this.endEvent?.getId();
}
public get isEnded(): boolean {
return !!this.endEvent;
}
public get isFetchingResponses(): boolean {
return this._isFetchingResponses;
}
public get undecryptableRelationsCount(): number {
return this.undecryptableRelationEventIds.size;
}
public async getResponses(): Promise<Relations> {
// if we have already fetched some responses
// just return them
if (this.responses) {
return this.responses;
}
// if there is no fetching in progress
// start fetching
if (!this.isFetchingResponses) {
await this.fetchResponses();
}
// return whatever responses we got from the first page
return this.responses!;
}
/**
*
* @param event - event with a relation to the rootEvent
* @returns void
*/
public onNewRelation(event: MatrixEvent): void {
if (M_POLL_END.matches(event.getType()) && this.validateEndEvent(event)) {
this.endEvent = event;
this.refilterResponsesOnEnd();
this.emit(PollEvent.End);
}
// wait for poll responses to be initialised
if (!this.responses) {
return;
}
const pollEndTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER;
const { responseEvents } = filterResponseRelations([event], pollEndTimestamp);
this.countUndecryptableEvents([event]);
if (responseEvents.length) {
responseEvents.forEach((event) => {
this.responses!.addEvent(event);
});
this.emit(PollEvent.Responses, this.responses);
}
}
private async fetchResponses(): Promise<void> {
this._isFetchingResponses = true;
// we want:
// - stable and unstable M_POLL_RESPONSE
// - stable and unstable M_POLL_END
// so make one api call and filter by event type client side
const allRelations = await this.matrixClient.relations(
this.roomId,
this.rootEvent.getId()!,
"m.reference",
undefined,
{
from: this.relationsNextBatch || undefined,
},
);
await Promise.all(allRelations.events.map((event) => this.matrixClient.decryptEventIfNeeded(event)));
const responses =
this.responses ||
new Relations("m.reference", M_POLL_RESPONSE.name, this.matrixClient, [M_POLL_RESPONSE.altName!]);
const pollEndEvent = allRelations.events.find((event) => M_POLL_END.matches(event.getType()));
if (this.validateEndEvent(pollEndEvent)) {
this.endEvent = pollEndEvent;
this.refilterResponsesOnEnd();
this.emit(PollEvent.End);
}
const pollCloseTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER;
const { responseEvents } = filterResponseRelations(allRelations.events, pollCloseTimestamp);
responseEvents.forEach((event) => {
responses.addEvent(event);
});
this.relationsNextBatch = allRelations.nextBatch ?? undefined;
this.responses = responses;
this.countUndecryptableEvents(allRelations.events);
// while there are more pages of relations
// fetch them
if (this.relationsNextBatch) {
// don't await
// we want to return the first page as soon as possible
this.fetchResponses();
} else {
// no more pages
this._isFetchingResponses = false;
}
// emit after updating _isFetchingResponses state
this.emit(PollEvent.Responses, this.responses);
}
/**
* Only responses made before the poll ended are valid
* Refilter after an end event is recieved
* To ensure responses are valid
*/
private refilterResponsesOnEnd(): void {
if (!this.responses) {
return;
}
const pollEndTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER;
this.responses.getRelations().forEach((event) => {
if (event.getTs() > pollEndTimestamp) {
this.responses?.removeEvent(event);
}
});
this.emit(PollEvent.Responses, this.responses);
}
private countUndecryptableEvents = (events: MatrixEvent[]): void => {
const undecryptableEventIds = events
.filter((event) => event.isDecryptionFailure())
.map((event) => event.getId()!);
const previousCount = this.undecryptableRelationsCount;
this.undecryptableRelationEventIds = new Set([...this.undecryptableRelationEventIds, ...undecryptableEventIds]);
if (this.undecryptableRelationsCount !== previousCount) {
this.emit(PollEvent.UndecryptableRelations, this.undecryptableRelationsCount);
}
};
private validateEndEvent(endEvent?: MatrixEvent): boolean {
if (!endEvent) {
return false;
}
/**
* Repeated end events are ignored -
* only the first (valid) closure event by origin_server_ts is counted.
*/
if (this.endEvent && this.endEvent.getTs() < endEvent.getTs()) {
return false;
}
/**
* MSC3381
* If a m.poll.end event is received from someone other than the poll creator or user with permission to redact
* others' messages in the room, the event must be ignored by clients due to being invalid.
*/
const roomCurrentState = this.room.currentState;
const endEventSender = endEvent.getSender();
return (
!!endEventSender &&
(endEventSender === this.rootEvent.getSender() ||
roomCurrentState.maySendRedactionForEvent(this.rootEvent, endEventSender))
);
}
}
+24
View File
@@ -25,6 +25,7 @@ import * as utils from "../utils";
import { MatrixEvent } from "./event";
import { EventType } from "../@types/event";
import { EventTimelineSet } from "./event-timeline-set";
import { NotificationCountType } from "./room";
export function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: ReceiptType): MatrixEvent {
return new MatrixEvent({
@@ -219,6 +220,29 @@ export abstract class ReadReceipt<
public abstract addReceipt(event: MatrixEvent, synthetic: boolean): void;
public abstract setUnread(type: NotificationCountType, count: number): void;
/**
* This issue should also be addressed on synapse's side and is tracked as part
* of https://github.com/matrix-org/synapse/issues/14837
*
* Retrieves the read receipt for the logged in user and checks if it matches
* the last event in the room and whether that event originated from the logged
* in user.
* Under those conditions we can consider the context as read. This is useful
* because we never send read receipts against our own events
* @param userId - the logged in user
*/
public fixupNotifications(userId: string): void {
const receipt = this.getReadReceiptForUserId(userId, false);
const lastEvent = this.timeline[this.timeline.length - 1];
if (lastEvent && receipt?.eventId === lastEvent.getId() && userId === lastEvent.getSender()) {
this.setUnread(NotificationCountType.Total, 0);
this.setUnread(NotificationCountType.Highlight, 0);
}
}
/**
* Add a temporary local-echo receipt to the room to reflect in the
* client the fact that we've sent one.
+2 -2
View File
@@ -1,5 +1,5 @@
/*
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Copyright 2019, 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.
@@ -122,7 +122,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
*
* @param event - The relation event to remove.
*/
private async removeEvent(event: MatrixEvent): Promise<void> {
public async removeEvent(event: MatrixEvent): Promise<void> {
if (!this.relations.has(event)) {
return;
}
+56
View File
@@ -964,6 +964,62 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
return guestAccessContent["guest_access"] || GuestAccess.Forbidden;
}
/**
* Find the predecessor room based on this room state.
*
* @param msc3946ProcessDynamicPredecessor - if true, look for an
* m.room.predecessor state event and use it if found (MSC3946).
* @returns null if this room has no predecessor. Otherwise, returns
* the roomId and last eventId of the predecessor room.
* If msc3946ProcessDynamicPredecessor is true, use m.predecessor events
* as well as m.room.create events to find predecessors.
* Note: if an m.predecessor event is used, eventId may be undefined
* since last_known_event_id is optional.
*/
public findPredecessor(msc3946ProcessDynamicPredecessor = false): { roomId: string; eventId?: string } | null {
// Note: the tests for this function are against Room.findPredecessor,
// which just calls through to here.
if (msc3946ProcessDynamicPredecessor) {
const predecessorEvent = this.getStateEvents(EventType.RoomPredecessor, "");
if (predecessorEvent) {
const content = predecessorEvent.getContent<{
predecessor_room_id: string;
last_known_event_id?: string;
}>();
const roomId = content.predecessor_room_id;
let eventId = content.last_known_event_id;
if (typeof eventId !== "string") {
eventId = undefined;
}
if (typeof roomId === "string") {
return { roomId, eventId };
}
}
}
const createEvent = this.getStateEvents(EventType.RoomCreate, "");
if (createEvent) {
const predecessor = createEvent.getContent<{
predecessor?: Partial<{
room_id: string;
event_id: string;
}>;
}>()["predecessor"];
if (predecessor) {
const roomId = predecessor["room_id"];
if (typeof roomId === "string") {
let eventId = predecessor["event_id"];
if (typeof eventId !== "string" || eventId === "") {
eventId = undefined;
}
return { roomId, eventId };
}
}
}
return null;
}
private updateThirdPartyTokenCache(memberEvent: MatrixEvent): void {
if (!memberEvent.getContent().third_party_invite) {
return;
+109 -36
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Optional } from "matrix-events-sdk";
import { M_POLL_START, Optional } from "matrix-events-sdk";
import {
EventTimelineSet,
@@ -64,7 +64,7 @@ import {
import { IStateEventWithRoomId } from "../@types/search";
import { RelationsContainer } from "./relations-container";
import { ReadReceipt, synthesizeReceipt } from "./read-receipt";
import { Feature, ServerSupport } from "../feature";
import { Poll, PollEvent } from "./poll";
// These constants are used as sane defaults when the homeserver doesn't support
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@@ -162,7 +162,8 @@ export type RoomEmittedEvents =
| BeaconEvent.New
| BeaconEvent.Update
| BeaconEvent.Destroy
| BeaconEvent.LivenessChange;
| BeaconEvent.LivenessChange
| PollEvent.New;
export type RoomEventHandlerMap = {
/**
@@ -289,6 +290,11 @@ export type RoomEventHandlerMap = {
[RoomEvent.UnreadNotifications]: (unreadNotifications?: NotificationCount, threadId?: string) => void;
[RoomEvent.TimelineRefresh]: (room: Room, eventTimelineSet: EventTimelineSet) => void;
[ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void;
/**
* Fires when a new poll instance is added to the room state
* @param poll - the new poll
*/
[PollEvent.New]: (poll: Poll) => void;
} & Pick<ThreadHandlerMap, ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete> &
EventTimelineSetHandlerMap &
Pick<MatrixEventHandlerMap, MatrixEventEvent.BeforeRedaction> &
@@ -317,6 +323,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
*/
private unthreadedReceipts = new Map<string, Receipt>();
private readonly timelineSets: EventTimelineSet[];
public readonly polls: Map<string, Poll> = new Map<string, Poll>();
public readonly threadsTimelineSets: EventTimelineSet[] = [];
// any filtered timeline sets we're maintaining for this room
private readonly filteredTimelineSets: Record<string, EventTimelineSet> = {}; // filter_id: timelineSet
@@ -375,7 +382,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
public readonly relations = new RelationsContainer(this.client, this);
/**
* @experimental
* A collection of events known by the client
* This is not a comprehensive list of the threads that exist in this room
*/
private threads = new Map<string, Thread>();
public lastThread?: Thread;
@@ -475,7 +483,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return this.threadTimelineSetsPromise;
}
if (this.client?.supportsExperimentalThreads()) {
if (this.client?.supportsThreads()) {
try {
this.threadTimelineSetsPromise = Promise.all([
this.createThreadTimelineSet(),
@@ -1290,10 +1298,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
*/
public getUnreadNotificationCount(type = NotificationCountType.Total): number {
let count = this.getRoomUnreadNotificationCount(type);
if (this.client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported) {
for (const threadNotification of this.threadNotifications.values()) {
count += threadNotification[type] ?? 0;
}
for (const threadNotification of this.threadNotifications.values()) {
count += threadNotification[type] ?? 0;
}
return count;
}
@@ -1322,7 +1328,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
/**
* @experimental
* Get one of the notification counts for a thread
* @param threadId - the root event ID
* @param type - The type of notification count to get. default: 'total'
@@ -1334,7 +1339,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
/**
* @experimental
* Checks if the current room has unread thread notifications
* @returns
*/
@@ -1348,7 +1352,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
/**
* @experimental
* Swet one of the notification count for a thread
* @param threadId - the root event ID
* @param type - The type of notification count to get. default: 'total'
@@ -1369,7 +1372,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
/**
* @experimental
* @returns the notification count type for all the threads in the room
*/
public get threadsAggregateNotificationType(): NotificationCountType | null {
@@ -1385,7 +1387,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
/**
* @experimental
* Resets the thread notifications for this room
*/
public resetThreadUnreadNotificationCount(notificationsToKeep?: string[]): void {
@@ -1411,6 +1412,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
this.emit(RoomEvent.UnreadNotifications, this.notificationCounts);
}
public setUnread(type: NotificationCountType, count: number): void {
return this.setUnreadNotificationCount(type, count);
}
public setSummary(summary: IRoomSummary): void {
const heroes = summary["m.heroes"];
const joinedCount = summary["m.joined_member_count"];
@@ -1545,14 +1550,16 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
/**
* @experimental
* Get the instance of the thread associated with the current event
* @param eventId - the ID of the current event
* @returns a thread instance if known
*/
public getThread(eventId: string): Thread | null {
return this.threads.get(eventId) ?? null;
}
/**
* @experimental
* Get all the known threads in the room
*/
public getThreads(): Thread[] {
return Array.from(this.threads.values());
@@ -1819,7 +1826,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* Without server support that means fetching as much at once as the server allows us to.
*/
public async fetchRoomThreads(): Promise<void> {
if (this.threadsReady || !this.client.supportsExperimentalThreads()) {
if (this.threadsReady || !this.client.supportsThreads()) {
return;
}
@@ -1890,6 +1897,38 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
this.threadsReady = true;
}
public async processPollEvents(events: MatrixEvent[]): Promise<void> {
const processPollStartEvent = (event: MatrixEvent): void => {
if (!M_POLL_START.matches(event.getType())) return;
try {
const poll = new Poll(event, this.client, this);
this.polls.set(event.getId()!, poll);
this.emit(PollEvent.New, poll);
} catch {}
// poll creation can fail for malformed poll start events
};
const processPollRelationEvent = (event: MatrixEvent): void => {
const relationEventId = event.relationEventId;
if (relationEventId && this.polls.has(relationEventId)) {
const poll = this.polls.get(relationEventId);
poll?.onNewRelation(event);
}
};
const processPollEvent = (event: MatrixEvent): void => {
processPollStartEvent(event);
processPollRelationEvent(event);
};
for (const event of events) {
try {
await this.client.decryptEventIfNeeded(event);
processPollEvent(event);
} catch {}
}
}
/**
* Fetch a single page of threadlist messages for the specific thread filter
* @internal
@@ -1964,7 +2003,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
shouldLiveInThread: boolean;
threadId?: string;
} {
if (!this.client?.supportsExperimentalThreads()) {
if (!this.client?.supportsThreads()) {
return {
shouldLiveInRoom: true,
shouldLiveInThread: false,
@@ -2033,7 +2072,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
/**
* Adds events to a thread's timeline. Will fire "Thread.update"
* @experimental
*/
public processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
events.forEach(this.applyRedaction);
@@ -2662,7 +2700,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// Indices to the events array, for readability
const ROOM = 0;
const THREAD = 1;
if (this.client.supportsExperimentalThreads()) {
if (this.client.supportsThreads()) {
const threadRoots = this.findThreadRoots(events);
return events.reduce(
(memo, event: MatrixEvent) => {
@@ -2728,6 +2766,21 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
receipt,
synthetic,
);
// If the read receipt sent for the logged in user matches
// the last event of the live timeline, then we know for a fact
// that the user has read that message.
// We can mark the room as read and not wait for the local echo
// from synapse
// This needs to be done after the initial sync as we do not want this
// logic to run whilst the room is being initialised
if (this.client.isInitialSyncComplete() && userId === this.client.getUserId()) {
const lastEvent = receiptDestination.timeline[receiptDestination.timeline.length - 1];
if (lastEvent && eventId === lastEvent.getId() && userId === lastEvent.getSender()) {
receiptDestination.setUnread(NotificationCountType.Total, 0);
receiptDestination.setUnread(NotificationCountType.Highlight, 0);
}
}
} else {
// The thread does not exist locally, keep the read receipt
// in a cache locally, and re-apply the `addReceipt` logic
@@ -2991,26 +3044,23 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
/**
* @returns the ID of the room that was this room's predecessor, or null if
* this room has no predecessor.
* Find the predecessor of this room.
*
* @param msc3946ProcessDynamicPredecessor - if true, look for an
* m.room.predecessor state event and use it if found (MSC3946).
* @returns null if this room has no predecessor. Otherwise, returns
* the roomId and last eventId of the predecessor room.
* If msc3946ProcessDynamicPredecessor is true, use m.predecessor events
* as well as m.room.create events to find predecessors.
* Note: if an m.predecessor event is used, eventId may be undefined
* since last_known_event_id is optional.
*/
public findPredecessorRoomId(): string | null {
public findPredecessor(msc3946ProcessDynamicPredecessor = false): { roomId: string; eventId?: 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;
return currentState.findPredecessor(msc3946ProcessDynamicPredecessor);
}
private roomNameGenerator(state: RoomNameState): string {
@@ -3341,13 +3391,36 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
public getLastUnthreadedReceiptFor(userId: string): Receipt | undefined {
return this.unthreadedReceipts.get(userId);
}
/**
* This issue should also be addressed on synapse's side and is tracked as part
* of https://github.com/matrix-org/synapse/issues/14837
*
*
* We consider a room fully read if the current user has sent
* the last event in the live timeline of that context and if the read receipt
* we have on record matches.
* This also detects all unread threads and applies the same logic to those
* contexts
*/
public fixupNotifications(userId: string): void {
super.fixupNotifications(userId);
const unreadThreads = this.getThreads().filter(
(thread) => this.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) > 0,
);
for (const thread of unreadThreads) {
thread.fixupNotifications(userId);
}
}
}
// a map from current event status to a list of allowed next statuses
const ALLOWED_TRANSITIONS: Record<EventStatus, EventStatus[]> = {
[EventStatus.ENCRYPTING]: [EventStatus.SENDING, EventStatus.NOT_SENT, EventStatus.CANCELLED],
[EventStatus.SENDING]: [EventStatus.ENCRYPTING, EventStatus.QUEUED, EventStatus.NOT_SENT, EventStatus.SENT],
[EventStatus.QUEUED]: [EventStatus.SENDING, EventStatus.CANCELLED],
[EventStatus.QUEUED]: [EventStatus.SENDING, EventStatus.NOT_SENT, EventStatus.CANCELLED],
[EventStatus.SENT]: [],
[EventStatus.NOT_SENT]: [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.CANCELLED],
[EventStatus.CANCELLED]: [],
+5 -4
View File
@@ -22,7 +22,7 @@ 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 { Room, RoomEvent } from "./room";
import { NotificationCountType, Room, RoomEvent } from "./room";
import { RoomState } from "./room-state";
import { ServerControlledNamespacedValue } from "../NamespacedValue";
import { logger } from "../logger";
@@ -69,9 +69,6 @@ export function determineFeatureSupport(stable: boolean, unstable: boolean): Fea
}
}
/**
* @experimental
*/
export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
public static hasServerSideSupport = FeatureSupport.None;
public static hasServerSideListSupport = FeatureSupport.None;
@@ -641,6 +638,10 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
return super.hasUserReadEvent(userId, eventId);
}
public setUnread(type: NotificationCountType, count: number): void {
return this.room.setThreadUnreadNotificationCount(this.id, type, count);
}
}
export const FILTER_RELATED_BY_SENDERS = new ServerControlledNamespacedValue(
+72
View File
@@ -0,0 +1,72 @@
/*
Copyright 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.
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 { OlmMachine, UserId } from "@matrix-org/matrix-sdk-crypto-js";
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
/**
* KeyClaimManager: linearises calls to OlmMachine.getMissingSessions to avoid races
*
* We have one of these per `RustCrypto` (and hence per `MatrixClient`).
*/
export class KeyClaimManager {
private currentClaimPromise: Promise<void>;
private stopped = false;
public constructor(
private readonly olmMachine: OlmMachine,
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
) {
this.currentClaimPromise = Promise.resolve();
}
/**
* Tell the KeyClaimManager to immediately stop processing requests.
*
* Any further calls, and any still in the queue, will fail with an error.
*/
public stop(): void {
this.stopped = true;
}
/**
* Given a list of users, attempt to ensure that we have Olm Sessions active with each of their devices
*
* If we don't have an active olm session, we will claim a one-time key and start one.
*
* @param userList - list of userIDs to claim
*/
public ensureSessionsForUsers(userList: Array<UserId>): Promise<void> {
// The Rust-SDK requires that we only have one getMissingSessions process in flight at once. This little dance
// ensures that, by only having one call to ensureSessionsForUsersInner active at once (and making them
// queue up in order).
const prom = this.currentClaimPromise.finally(() => this.ensureSessionsForUsersInner(userList));
this.currentClaimPromise = prom;
return prom;
}
private async ensureSessionsForUsersInner(userList: Array<UserId>): Promise<void> {
// bail out quickly if we've been stopped.
if (this.stopped) {
throw new Error(`Cannot ensure Olm sessions: shutting down`);
}
const claimRequest = await this.olmMachine.getMissingSessions(userList);
if (claimRequest) {
await this.outgoingRequestProcessor.makeOutgoingRequest(claimRequest);
}
}
}
+116
View File
@@ -0,0 +1,116 @@
/*
Copyright 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.
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 {
OlmMachine,
KeysBackupRequest,
KeysClaimRequest,
KeysQueryRequest,
KeysUploadRequest,
RoomMessageRequest,
SignatureUploadRequest,
ToDeviceRequest,
} from "@matrix-org/matrix-sdk-crypto-js";
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`.
*/
export interface OutgoingRequest {
readonly id: string | undefined;
readonly type: number;
}
/**
* OutgoingRequestManager: turns `OutgoingRequest`s from the rust sdk into HTTP requests
*
* We have one of these per `RustCrypto` (and hence per `MatrixClient`), not that it does anything terribly complicated.
* It's responsible for:
*
* * holding the reference to the `MatrixHttpApi`
* * turning `OutgoingRequest`s from the rust backend into HTTP requests, and sending them
* * sending the results of such requests back to the rust backend.
*/
export class OutgoingRequestProcessor {
public constructor(
private readonly olmMachine: OlmMachine,
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
) {}
public async makeOutgoingRequest(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 if (msg instanceof ToDeviceRequest) {
const path =
`/_matrix/client/v3/sendToDevice/${encodeURIComponent(msg.event_type)}/` +
encodeURIComponent(msg.txn_id);
resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
} else if (msg instanceof RoomMessageRequest) {
const path =
`/_matrix/client/v3/room/${encodeURIComponent(msg.room_id)}/send/` +
`${encodeURIComponent(msg.event_type)}/${encodeURIComponent(msg.txn_id)}`;
resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
} else {
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: "",
};
try {
const response = await this.http.authedRequest<string>(method, path, queryParams, body, opts);
logger.info(`rust-crypto: successfully made HTTP request: ${method} ${path}`);
return response;
} catch (e) {
logger.warn(`rust-crypto: error making HTTP request: ${method} ${path}: ${e}`);
throw e;
}
}
}
+130
View File
@@ -0,0 +1,130 @@
/*
Copyright 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.
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 { EncryptionSettings, OlmMachine, RoomId, UserId } from "@matrix-org/matrix-sdk-crypto-js";
import { EventType } from "../@types/event";
import { IContent, MatrixEvent } from "../models/event";
import { Room } from "../models/room";
import { logger, PrefixedLogger } from "../logger";
import { KeyClaimManager } from "./KeyClaimManager";
import { RoomMember } from "../models/room-member";
/**
* RoomEncryptor: responsible for encrypting messages to a given room
*/
export class RoomEncryptor {
private readonly prefixedLogger: PrefixedLogger;
/**
* @param olmMachine - The rust-sdk's OlmMachine
* @param keyClaimManager - Our KeyClaimManager, which manages the queue of one-time-key claim requests
* @param room - The room we want to encrypt for
* @param encryptionSettings - body of the m.room.encryption event currently in force in this room
*/
public constructor(
private readonly olmMachine: OlmMachine,
private readonly keyClaimManager: KeyClaimManager,
private readonly room: Room,
private encryptionSettings: IContent,
) {
this.prefixedLogger = logger.withPrefix(`[${room.roomId} encryption]`);
}
/**
* Handle a new `m.room.encryption` event in this room
*
* @param config - The content of the encryption event
*/
public onCryptoEvent(config: IContent): void {
if (JSON.stringify(this.encryptionSettings) != JSON.stringify(config)) {
this.prefixedLogger.error(`Ignoring m.room.encryption event which requests a change of config`);
}
}
/**
* Handle a new `m.room.member` event in this room
*
* @param member - new membership state
*/
public onRoomMembership(member: RoomMember): void {
this.prefixedLogger.debug(`${member.membership} event for ${member.userId}`);
if (
member.membership == "join" ||
(member.membership == "invite" && this.room.shouldEncryptForInvitedMembers())
) {
// make sure we are tracking the deviceList for this user
this.prefixedLogger.debug(`starting to track devices for: ${member.userId}`);
this.olmMachine.updateTrackedUsers([new UserId(member.userId)]);
}
// TODO: handle leaves (including our own)
}
/**
* Prepare to encrypt events in this room.
*
* This ensures that we have a megolm session ready to use and that we have shared its key with all the devices
* in the room.
*/
public async ensureEncryptionSession(): Promise<void> {
if (this.encryptionSettings.algorithm !== "m.megolm.v1.aes-sha2") {
throw new Error(
`Cannot encrypt in ${this.room.roomId} for unsupported algorithm '${this.encryptionSettings.algorithm}'`,
);
}
const members = await this.room.getEncryptionTargetMembers();
this.prefixedLogger.debug(
`Encrypting for users (shouldEncryptForInvitedMembers: ${this.room.shouldEncryptForInvitedMembers()}):`,
members.map((u) => `${u.userId} (${u.membership})`),
);
const userList = members.map((u) => new UserId(u.userId));
await this.keyClaimManager.ensureSessionsForUsers(userList);
const rustEncryptionSettings = new EncryptionSettings();
/* FIXME historyVisibility, rotation, etc */
await this.olmMachine.shareRoomKey(new RoomId(this.room.roomId), userList, rustEncryptionSettings);
}
/**
* Encrypt an event for this room
*
* This will ensure that we have a megolm session for this room, share it with the devices in the room, and
* then encrypt the event using the session.
*
* @param event - Event to be encrypted.
*/
public async encryptEvent(event: MatrixEvent): Promise<void> {
await this.ensureEncryptionSession();
const encryptedContent = await this.olmMachine.encryptRoomEvent(
new RoomId(this.room.roomId),
event.getType(),
JSON.stringify(event.getContent()),
);
event.makeEncrypted(
EventType.RoomMessageEncrypted,
JSON.parse(encryptedContent),
this.olmMachine.identityKeys.curve25519.toBase64(),
this.olmMachine.identityKeys.ed25519.toBase64(),
);
}
}
+1 -2
View File
@@ -18,7 +18,6 @@ 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";
@@ -26,7 +25,7 @@ export async function initRustCrypto(
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
userId: string,
deviceId: string,
): Promise<CryptoBackend> {
): Promise<RustCrypto> {
// initialise the rust matrix-sdk-crypto-js, if it hasn't already been done
await RustSdkCryptoJs.initAsync();
+100 -67
View File
@@ -15,32 +15,20 @@ limitations under the License.
*/
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import {
DecryptedRoomEvent,
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 type { IEncryptedEventInfo } from "../crypto/api";
import { MatrixEvent } from "../models/event";
import { Room } from "../models/room";
import { RoomMember } from "../models/room-member";
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
import { logger } from "../logger";
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
import { QueryDict } from "../utils";
import { IHttpOpts, MatrixHttpApi } from "../http-api";
import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
/**
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
*/
interface OutgoingRequest {
readonly id: string | undefined;
readonly type: number;
}
import { RoomEncryptor } from "./RoomEncryptor";
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
import { KeyClaimManager } from "./KeyClaimManager";
/**
* An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
@@ -55,12 +43,27 @@ export class RustCrypto implements CryptoBackend {
/** whether {@link outgoingRequestLoop} is currently running */
private outgoingRequestLoopRunning = false;
/** mapping of roomId → encryptor class */
private roomEncryptors: Record<string, RoomEncryptor> = {};
private keyClaimManager: KeyClaimManager;
private outgoingRequestProcessor: OutgoingRequestProcessor;
public constructor(
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
_userId: string,
_deviceId: string,
) {}
) {
this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http);
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// CryptoBackend implementation
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public stop(): void {
// stop() may be called multiple times, but attempting to close() the OlmMachine twice
@@ -70,13 +73,43 @@ export class RustCrypto implements CryptoBackend {
}
this.stopped = true;
this.keyClaimManager.stop();
// 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 prepareToEncrypt(room: Room): void {
const encryptor = this.roomEncryptors[room.roomId];
if (encryptor) {
encryptor.ensureEncryptionSession();
}
}
public async encryptEvent(event: MatrixEvent, _room: Room): Promise<void> {
const roomId = event.getRoomId()!;
const encryptor = this.roomEncryptors[roomId];
if (!encryptor) {
throw new Error(`Cannot encrypt event in unconfigured room ${roomId}`);
}
await encryptor.encryptEvent(event);
}
public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
const roomId = event.getRoomId();
if (!roomId) {
// presumably, a to-device message. These are normally decrypted in preprocessToDeviceMessages
// so the fact it has come back here suggests that decryption failed.
//
// once we drop support for the libolm crypto implementation, we can stop passing to-device messages
// through decryptEvent and hence get rid of this case.
throw new Error("to-device event was not decrypted in preprocessToDeviceMessages");
}
const res = (await this.olmMachine.decryptRoomEvent(
JSON.stringify({
event_id: event.getId(),
@@ -87,7 +120,7 @@ export class RustCrypto implements CryptoBackend {
origin_server_ts: event.getTs(),
}),
new RustSdkCryptoJs.RoomId(event.getRoomId()!),
)) as DecryptedRoomEvent;
)) as RustSdkCryptoJs.DecryptedRoomEvent;
return {
clearEvent: JSON.parse(res.event),
claimedEd25519Key: res.senderClaimedEd25519Key,
@@ -159,6 +192,30 @@ export class RustCrypto implements CryptoBackend {
return JSON.parse(result);
}
/** called by the sync loop on m.room.encrypted events
*
* @param room - in which the event was received
* @param event - encryption event to be processed
*/
public async onCryptoEvent(room: Room, event: MatrixEvent): Promise<void> {
const config = event.getContent();
const existingEncryptor = this.roomEncryptors[room.roomId];
if (existingEncryptor) {
existingEncryptor.onCryptoEvent(config);
} else {
this.roomEncryptors[room.roomId] = new RoomEncryptor(this.olmMachine, this.keyClaimManager, room, config);
}
// start tracking devices for any users already known to be in this room.
const members = await room.getEncryptionTargetMembers();
logger.debug(
`[${room.roomId} encryption] starting to track devices for: `,
members.map((u) => `${u.userId} (${u.membership})`),
);
await this.olmMachine.updateTrackedUsers(members.map((u) => new RustSdkCryptoJs.UserId(u.userId)));
}
/** called by the sync loop after processing each sync.
*
* TODO: figure out something equivalent for sliding sync.
@@ -171,6 +228,27 @@ export class RustCrypto implements CryptoBackend {
this.outgoingRequestLoop();
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Other public functions
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/** called by the MatrixClient on a room membership event
*
* @param event - The matrix event which caused this event to fire.
* @param member - The member whose RoomMember.membership changed.
* @param oldMembership - The previous membership state. Null if it's a new member.
*/
public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {
const enc = this.roomEncryptors[event.getRoomId()!];
if (!enc) {
// not encrypting in this room
return;
}
enc.onRoomMembership(member);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Outgoing requests
@@ -190,7 +268,7 @@ export class RustCrypto implements CryptoBackend {
return;
}
for (const msg of outgoingRequests) {
await this.doOutgoingRequest(msg as OutgoingRequest);
await this.outgoingRequestProcessor.makeOutgoingRequest(msg as OutgoingRequest);
}
}
} catch (e) {
@@ -199,49 +277,4 @@ export class RustCrypto implements CryptoBackend {
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);
}
}
+20 -10
View File
@@ -245,12 +245,7 @@ export class MatrixScheduler<T = ISendEventResponse> {
// get head of queue
const obj = this.peekNextEvent(queueName);
if (!obj) {
// queue is empty. Mark as inactive and stop recursing.
const index = this.activeQueues.indexOf(queueName);
if (index >= 0) {
this.activeQueues.splice(index, 1);
}
debuglog("Stopping queue '%s' as it is now empty", queueName);
this.disableQueue(queueName);
return;
}
debuglog("Queue '%s' has %s pending events", queueName, this.queues[queueName].length);
@@ -289,10 +284,7 @@ export class MatrixScheduler<T = ISendEventResponse> {
// give up (you quitter!)
debuglog("Queue '%s' giving up on event %s", queueName, obj.event.getId());
// remove this from the queue
this.removeNextEvent(queueName);
obj.defer.reject(err);
// process next event
this.processQueue(queueName);
this.clearQueue(queueName, err);
} else {
setTimeout(this.processQueue, waitTimeMs, queueName);
}
@@ -300,6 +292,24 @@ export class MatrixScheduler<T = ISendEventResponse> {
);
};
private disableQueue(queueName: string): void {
// queue is empty. Mark as inactive and stop recursing.
const index = this.activeQueues.indexOf(queueName);
if (index >= 0) {
this.activeQueues.splice(index, 1);
}
debuglog("Stopping queue '%s' as it is now empty", queueName);
}
private clearQueue(queueName: string, err: unknown): void {
debuglog("clearing queue '%s'", queueName);
let obj: IQueueEntry<T> | undefined;
while ((obj = this.removeNextEvent(queueName))) {
obj.defer.reject(err);
}
this.disableQueue(queueName);
}
private peekNextEvent(queueName: string): IQueueEntry<T> | undefined {
const queue = this.queues[queueName];
if (!Array.isArray(queue)) {
+2 -2
View File
@@ -736,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.syncOpts.crypto) {
await this.syncOpts.crypto.onCryptoEvent(room, e);
if (e.isState() && e.getType() == EventType.RoomEncryption && this.syncOpts.cryptoCallbacks) {
await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e);
}
};
+35 -21
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.
@@ -198,7 +198,7 @@ export function defaultClientOpts(opts?: IStoredClientOpts): IStoredClientOpts {
resolveInvitesToProfiles: false,
pollTimeout: 30 * 1000,
pendingEventOrdering: PendingEventOrdering.Chronological,
experimentalThreadSupport: false,
threadSupport: false,
...opts,
};
}
@@ -1254,11 +1254,12 @@ export class SyncApi {
const inviter = room.currentState.getStateEvents(EventType.RoomMember, client.getUserId()!)?.getSender();
if (client.isCryptoEnabled()) {
const parkedHistory = await client.crypto!.cryptoStore.takeParkedSharedHistory(room.roomId);
const crypto = client.crypto;
if (crypto) {
const parkedHistory = await crypto.cryptoStore.takeParkedSharedHistory(room.roomId);
for (const parked of parkedHistory) {
if (parked.senderId === inviter) {
await client.crypto!.olmDevice.addInboundGroupSession(
await crypto.olmDevice.addInboundGroupSession(
room.roomId,
parked.senderKey,
parked.forwardingCurve25519KeyChain,
@@ -1298,18 +1299,29 @@ export class SyncApi {
const accountDataEvents = this.mapSyncEventsFormat(joinObj.account_data);
const encrypted = client.isRoomEncrypted(room.roomId);
// we do this first so it's correct when any of the events fire
// We store the server-provided value first so it's correct when any of the events fire.
if (joinObj.unread_notifications) {
room.setUnreadNotificationCount(
NotificationCountType.Total,
joinObj.unread_notifications.notification_count ?? 0,
);
/**
* We track unread notifications ourselves in encrypted rooms, so don't
* bother setting it here. We trust our calculations better than the
* server's for this case, and therefore will assume that our non-zero
* count is accurate.
*
* @see import("./client").fixNotificationCountOnDecryption
*/
if (!encrypted || joinObj.unread_notifications.notification_count === 0) {
// In an encrypted room, if the room has notifications enabled then it's typical for
// the server to flag all new messages as notifying. However, some push rules calculate
// events as ignored based on their event contents (e.g. ignoring msgtype=m.notice messages)
// so we want to calculate this figure on the client in all cases.
room.setUnreadNotificationCount(
NotificationCountType.Total,
joinObj.unread_notifications.notification_count ?? 0,
);
}
// We track unread notifications ourselves in encrypted rooms, so don't
// bother setting it here. We trust our calculations better than the
// server's for this case, and therefore will assume that our non-zero
// count is accurate.
if (!encrypted || room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0) {
// If the locally stored highlight count is zero, use the server provided value.
room.setUnreadNotificationCount(
NotificationCountType.Highlight,
joinObj.unread_notifications.highlight_count ?? 0,
@@ -1326,11 +1338,13 @@ export class SyncApi {
// decryption
room.resetThreadUnreadNotificationCount(Object.keys(unreadThreadNotifications));
for (const [threadId, unreadNotification] of Object.entries(unreadThreadNotifications)) {
room.setThreadUnreadNotificationCount(
threadId,
NotificationCountType.Total,
unreadNotification.notification_count ?? 0,
);
if (!encrypted || unreadNotification.notification_count === 0) {
room.setThreadUnreadNotificationCount(
threadId,
NotificationCountType.Total,
unreadNotification.notification_count ?? 0,
);
}
const hasNoNotifications =
room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight) <= 0;
@@ -1408,10 +1422,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.syncOpts.crypto) {
if (this.syncOpts.cryptoCallbacks) {
for (const e of stateEvents.concat(events)) {
if (e.isState() && e.getType() === EventType.RoomEncryption && e.getStateKey() === "") {
await this.syncOpts.crypto.onCryptoEvent(room, e);
await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e);
}
}
}
+11 -3
View File
@@ -1,6 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016, 2019, 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.
@@ -392,13 +391,22 @@ export function ensureNoTrailingSlash(url?: string): string | undefined {
}
}
// Returns a promise which resolves with a given value after the given number of ms
/**
* Returns a promise which resolves with a given value after the given number of ms
*/
export function sleep<T>(ms: number, value?: T): Promise<T> {
return new Promise((resolve) => {
setTimeout(resolve, ms, value);
});
}
/**
* Promise/async version of {@link setImmediate}.
*/
export function immediate(): Promise<void> {
return new Promise(setImmediate);
}
export function isNullOrUndefined(val: any): boolean {
return val === null || val === undefined;
}
+240 -153
View File
File diff suppressed because it is too large Load Diff
+23 -17
View File
@@ -131,7 +131,7 @@ export class CallEventHandler {
try {
await this.handleCallEvent(event);
} catch (e) {
logger.error("Caught exception handling call event", e);
logger.error("CallEventHandler evaluateEventBuffer() caught exception handling call event", e);
}
}
}
@@ -207,20 +207,26 @@ export class CallEventHandler {
groupCall = this.client.groupCallEventHandler!.getGroupCallById(groupCallId);
if (!groupCall) {
logger.warn(`Cannot find a group call ${groupCallId} for event ${type}. Ignoring event.`);
logger.warn(
`CallEventHandler handleCallEvent() could not find a group call - ignoring event (groupCallId=${groupCallId}, type=${type})`,
);
return;
}
opponentDeviceId = content.device_id;
if (!opponentDeviceId) {
logger.warn(`Cannot find a device id for ${senderId}. Ignoring event.`);
logger.warn(
`CallEventHandler handleCallEvent() could not find a device id - ignoring event (senderId=${senderId})`,
);
groupCall.emit(GroupCallEvent.Error, new GroupCallUnknownDeviceError(senderId));
return;
}
if (content.dest_session_id !== this.client.getSessionId()) {
logger.warn("Call event does not match current session id, ignoring.");
logger.warn(
"CallEventHandler handleCallEvent() call event does not match current session id - ignoring",
);
return;
}
}
@@ -240,8 +246,8 @@ export class CallEventHandler {
if (call && call.state === CallState.Ended) return;
if (call) {
logger.log(
`WARN: Already have a MatrixCall with id ${content.call_id} but got an ` + `invite. Clobbering.`,
logger.warn(
`CallEventHandler handleCallEvent() already has a call but got an invite - clobbering (callId=${content.call_id})`,
);
}
@@ -250,7 +256,9 @@ export class CallEventHandler {
}
const timeUntilTurnCresExpire = (this.client.getTurnServersExpiry() ?? 0) - Date.now();
logger.info("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
logger.info(
"CallEventHandler handleCallEvent() current turn creds expire in " + timeUntilTurnCresExpire + " ms",
);
call =
createNewMatrixCall(this.client, callRoomId, {
forceTURN: this.client.forceTURN,
@@ -259,7 +267,9 @@ export class CallEventHandler {
opponentSessionId: content.sender_session_id,
}) ?? undefined;
if (!call) {
logger.log("Incoming call ID " + content.call_id + " but this client " + "doesn't support WebRTC");
logger.log(
`CallEventHandler handleCallEvent() this client does not support WebRTC (callId=${content.call_id})`,
);
// don't hang up the call: there could be other clients
// connected that do support WebRTC and declining the
// the call on their behalf would be really annoying.
@@ -308,18 +318,12 @@ export class CallEventHandler {
if (existingCall) {
if (existingCall.callId > call.callId) {
logger.log(
"Glare detected: answering incoming call " +
call.callId +
" and canceling outgoing call " +
existingCall.callId,
`CallEventHandler handleCallEvent() detected glare - answering incoming call and canceling outgoing call (incomingId=${call.callId}, outgoingId=${existingCall.callId})`,
);
existingCall.replacedBy(call);
} else {
logger.log(
"Glare detected: rejecting incoming call " +
call.callId +
" and keeping outgoing call " +
existingCall.callId,
`CallEventHandler handleCallEvent() detected glare - hanging up incoming call (incomingId=${call.callId}, outgoingId=${existingCall.callId})`,
);
call.hangup(CallErrorCode.Replaced, true);
}
@@ -376,7 +380,9 @@ export class CallEventHandler {
// The following events need a call and a peer connection
if (!call || !call.hasPeerConnection) {
logger.info(`Discarding possible call event ${event.getId()} as we don't have a call/peerConn`, type);
logger.info(
`CallEventHandler handleCallEvent() discarding possible call event as we don't have a call (type=${type})`,
);
return;
}
// Ignore remote echo
+1 -1
View File
@@ -309,7 +309,7 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
public clone(): CallFeed {
const mediaHandler = this.client.getMediaHandler();
const stream = this.stream.clone();
logger.log(`callFeed cloning stream ${this.stream.id} newStream ${stream.id}`);
logger.log(`CallFeed clone() cloning stream (originalStreamId=${this.stream.id}, newStreamId${stream.id})`);
if (this.purpose === SDPStreamMetadataPurpose.Usermedia) {
mediaHandler.userMediaStreams.push(stream);
+64 -24
View File
@@ -367,7 +367,7 @@ export class GroupCall extends TypedEventEmitter<
}
private async initLocalCallFeedInternal(): Promise<void> {
logger.log(`groupCall ${this.groupCallId} initLocalCallFeed`);
logger.log(`GroupCall ${this.groupCallId} initLocalCallFeedInternal() running`);
let stream: MediaStream;
@@ -413,7 +413,7 @@ export class GroupCall extends TypedEventEmitter<
const micShouldBeMuted = this.localCallFeed.isAudioMuted();
const vidShouldBeMuted = this.localCallFeed.isVideoMuted();
logger.log(
`groupCall ${this.groupCallId} updateLocalUsermediaStream oldStream ${oldStream.id} newStream ${stream.id} micShouldBeMuted ${micShouldBeMuted} vidShouldBeMuted ${vidShouldBeMuted}`,
`GroupCall ${this.groupCallId} updateLocalUsermediaStream() (oldStreamId=${oldStream.id}, newStreamId=${stream.id}, micShouldBeMuted=${micShouldBeMuted}, vidShouldBeMuted=${vidShouldBeMuted})`,
);
setTracksEnabled(stream.getAudioTracks(), !micShouldBeMuted);
setTracksEnabled(stream.getVideoTracks(), !vidShouldBeMuted);
@@ -428,7 +428,7 @@ export class GroupCall extends TypedEventEmitter<
throw new Error(`Cannot enter call in the "${this.state}" state`);
}
logger.log(`Entered group call ${this.groupCallId}`);
logger.log(`GroupCall ${this.groupCallId} enter() running`);
this.state = GroupCallState.Entered;
this.client.on(CallEventHandlerEvent.Incoming, this.onIncomingCall);
@@ -570,14 +570,19 @@ export class GroupCall extends TypedEventEmitter<
const updates: Promise<void>[] = [];
this.forEachCall((call) => updates.push(call.sendMetadataUpdate()));
await Promise.all(updates).catch((e) => logger.info("Failed to send some metadata updates", e));
await Promise.all(updates).catch((e) =>
logger.info(
`GroupCall ${this.groupCallId} setMicrophoneMuted() failed to send some metadata updates`,
e,
),
);
};
if (sendUpdatesBefore) await sendUpdates();
if (this.localCallFeed) {
logger.log(
`groupCall ${this.groupCallId} setMicrophoneMuted stream ${this.localCallFeed.stream.id} muted ${muted}`,
`GroupCall ${this.groupCallId} setMicrophoneMuted() (streamId=${this.localCallFeed.stream.id}, muted=${muted})`,
);
this.localCallFeed.setAudioVideoMuted(muted, null);
// I don't believe its actually necessary to enable these tracks: they
@@ -586,7 +591,7 @@ export class GroupCall extends TypedEventEmitter<
// anywhere. Let's do it anyway to avoid confusion.
setTracksEnabled(this.localCallFeed.stream.getAudioTracks(), !muted);
} else {
logger.log(`groupCall ${this.groupCallId} setMicrophoneMuted no stream muted ${muted}`);
logger.log(`GroupCall ${this.groupCallId} setMicrophoneMuted() no stream muted (muted=${muted})`);
this.initWithAudioMuted = muted;
}
@@ -613,7 +618,7 @@ export class GroupCall extends TypedEventEmitter<
if (this.localCallFeed) {
logger.log(
`groupCall ${this.groupCallId} setLocalVideoMuted stream ${this.localCallFeed.stream.id} muted ${muted}`,
`GroupCall ${this.groupCallId} setLocalVideoMuted() (stream=${this.localCallFeed.stream.id}, muted=${muted})`,
);
const stream = await this.client.getMediaHandler().getUserMediaStream(true, !muted);
@@ -621,7 +626,7 @@ export class GroupCall extends TypedEventEmitter<
this.localCallFeed.setAudioVideoMuted(null, muted);
setTracksEnabled(this.localCallFeed.stream.getVideoTracks(), !muted);
} else {
logger.log(`groupCall ${this.groupCallId} setLocalVideoMuted no stream muted ${muted}`);
logger.log(`GroupCall ${this.groupCallId} setLocalVideoMuted() no stream muted (muted=${muted})`);
this.initWithVideoMuted = muted;
}
@@ -641,7 +646,9 @@ export class GroupCall extends TypedEventEmitter<
if (enabled) {
try {
logger.log("Asking for screensharing permissions...");
logger.log(
`GroupCall ${this.groupCallId} setScreensharingEnabled() is asking for screensharing permissions`,
);
const stream = await this.client.getMediaHandler().getScreensharingStream(opts);
for (const track of stream.getTracks()) {
@@ -653,7 +660,9 @@ export class GroupCall extends TypedEventEmitter<
track.addEventListener("ended", onTrackEnded);
}
logger.log("Screensharing permissions granted. Setting screensharing enabled on all calls");
logger.log(
`GroupCall ${this.groupCallId} setScreensharingEnabled() granted screensharing permissions. Setting screensharing enabled on all calls`,
);
this.localDesktopCapturerSourceId = opts.desktopCapturerSourceId;
this.localScreenshareFeed = new CallFeed({
@@ -681,7 +690,10 @@ export class GroupCall extends TypedEventEmitter<
return true;
} catch (error) {
if (opts.throwOnFail) throw error;
logger.error("Enabling screensharing error", error);
logger.error(
`GroupCall ${this.groupCallId} setScreensharingEnabled() enabling screensharing error`,
error,
);
this.emit(
GroupCallEvent.Error,
new GroupCallError(
@@ -725,13 +737,15 @@ export class GroupCall extends TypedEventEmitter<
}
if (newCall.state !== CallState.Ringing) {
logger.warn("Incoming call no longer in ringing state. Ignoring.");
logger.warn(
`GroupCall ${this.groupCallId} onIncomingCall() incoming call no longer in ringing state - ignoring`,
);
return;
}
if (!newCall.groupCallId || newCall.groupCallId !== this.groupCallId) {
logger.log(
`Incoming call with groupCallId ${newCall.groupCallId} ignored because it doesn't match the current group call`,
`GroupCall ${this.groupCallId} onIncomingCall() ignored because it doesn't match the current group call`,
);
newCall.reject();
return;
@@ -739,7 +753,7 @@ export class GroupCall extends TypedEventEmitter<
const opponentUserId = newCall.getOpponentMember()?.userId;
if (opponentUserId === undefined) {
logger.warn("Incoming call with no member. Ignoring.");
logger.warn(`GroupCall ${this.groupCallId} onIncomingCall() incoming call with no member - ignoring`);
return;
}
@@ -748,7 +762,9 @@ export class GroupCall extends TypedEventEmitter<
if (prevCall?.callId === newCall.callId) return;
logger.log(`GroupCall: incoming call from ${opponentUserId} with ID ${newCall.callId}`);
logger.log(
`GroupCall ${this.groupCallId} onIncomingCall() incoming call (userId=${opponentUserId}, callId=${newCall.callId})`,
);
if (prevCall) this.disposeCall(prevCall, CallErrorCode.Replaced);
@@ -797,7 +813,9 @@ export class GroupCall extends TypedEventEmitter<
callsChanged = true;
if (prevCall !== undefined) {
logger.debug(`Replacing call ${prevCall.callId} to ${userId} ${deviceId}`);
logger.debug(
`GroupCall ${this.groupCallId} placeOutgoingCalls() replacing call (userId=${userId}, deviceId=${deviceId}, callId=${prevCall.callId})`,
);
this.disposeCall(prevCall, CallErrorCode.NewSession);
}
@@ -809,13 +827,17 @@ export class GroupCall extends TypedEventEmitter<
});
if (newCall === null) {
logger.error(`Failed to create call with ${userId} ${deviceId}`);
logger.error(
`GroupCall ${this.groupCallId} placeOutgoingCalls() failed to create call (userId=${userId}, device=${deviceId})`,
);
callMap.delete(deviceId);
} else {
this.initCall(newCall);
callMap.set(deviceId, newCall);
logger.debug(`Placing call to ${userId} ${deviceId} (session ${participant.sessionId})`);
logger.debug(
`GroupCall ${this.groupCallId} placeOutgoingCalls() placing call (userId=${userId}, deviceId=${deviceId}, sessionId=${participant.sessionId})`,
);
newCall
.placeCallWithCallFeeds(
@@ -828,7 +850,10 @@ export class GroupCall extends TypedEventEmitter<
}
})
.catch((e) => {
logger.warn(`Failed to place call to ${userId}`, e);
logger.warn(
`GroupCall ${this.groupCallId} placeOutgoingCalls() failed to place call (userId=${userId})`,
e,
);
if (e instanceof CallError && e.code === GroupCallErrorCode.UnknownDevice) {
this.emit(GroupCallEvent.Error, e);
@@ -1192,7 +1217,9 @@ export class GroupCall extends TypedEventEmitter<
if (!localMember) {
// The client hasn't fetched enough of the room state to get our own member
// event. This probably shouldn't happen, but sanity check & exit for now.
logger.warn("Tried to update participants before local room member is available");
logger.warn(
`GroupCall ${this.groupCallId} updateParticipants() tried to update participants before local room member is available`,
);
return;
}
@@ -1355,11 +1382,14 @@ export class GroupCall extends TypedEventEmitter<
// Resend the state event every so often so it doesn't become stale
this.resendMemberStateTimer = setInterval(async () => {
logger.log("Resending call member state");
logger.log(`GroupCall ${this.groupCallId} updateMemberState() resending call member state"`);
try {
await this.addDeviceToMemberState();
} catch (e) {
logger.error("Failed to resend call member state", e);
logger.error(
`GroupCall ${this.groupCallId} updateMemberState() failed to resend call member state`,
e,
);
}
}, (DEVICE_TIMEOUT * 3) / 4);
} else {
@@ -1412,13 +1442,23 @@ export class GroupCall extends TypedEventEmitter<
) {
// We either entered, left, or ended the call
this.updateParticipants();
this.updateMemberState().catch((e) => logger.error("Failed to update member state devices", e));
this.updateMemberState().catch((e) =>
logger.error(
`GroupCall ${this.groupCallId} onStateChanged() failed to update member state devices"`,
e,
),
);
}
};
private onLocalFeedsChanged = (): void => {
if (this.state === GroupCallState.Entered) {
this.updateMemberState().catch((e) => logger.error("Failed to update member state feeds", e));
this.updateMemberState().catch((e) =>
logger.error(
`GroupCall ${this.groupCallId} onLocalFeedsChanged() failed to update member state feeds`,
e,
),
);
}
};
}
+14 -9
View File
@@ -61,7 +61,7 @@ export class GroupCallEventHandler {
// we create a group call for the room so we can be fairly sure that
// the group call we create is really the latest one.
if (this.client.getSyncState() !== SyncState.Syncing) {
logger.debug("Waiting for client to start syncing...");
logger.debug("GroupCallEventHandler start() waiting for client to start syncing");
await new Promise<void>((resolve) => {
const onSync = (): void => {
if (this.client.getSyncState() === SyncState.Syncing) {
@@ -123,15 +123,16 @@ export class GroupCallEventHandler {
}
logger.debug(
`Choosing group call ${callEvent.getStateKey()} with TS ` +
`${callEvent.getTs()} for room ${room.roomId} from ${callEvents.length} possible calls.`,
`GroupCallEventHandler createGroupCallForRoom() choosing group call from possible calls (stateKey=${callEvent.getStateKey()}, ts=${callEvent.getTs()}, roomId=${
room.roomId
}, numOfPossibleCalls=${callEvents.length})`,
);
this.createGroupCallFromRoomStateEvent(callEvent);
break;
}
logger.info("Group call event handler processed room", room.roomId);
logger.info(`GroupCallEventHandler createGroupCallForRoom() processed room (roomId=${room.roomId})`);
this.getRoomDeferred(room.roomId).resolve!();
}
@@ -142,7 +143,9 @@ export class GroupCallEventHandler {
const room = this.client.getRoom(roomId);
if (!room) {
logger.warn(`Couldn't find room ${roomId} for GroupCall`);
logger.warn(
`GroupCallEventHandler createGroupCallFromRoomStateEvent() couldn't find room for call (roomId=${roomId})`,
);
return;
}
@@ -151,14 +154,16 @@ export class GroupCallEventHandler {
const callType = content["m.type"];
if (!Object.values(GroupCallType).includes(callType)) {
logger.warn(`Received invalid group call type ${callType} for room ${roomId}.`);
logger.warn(
`GroupCallEventHandler createGroupCallFromRoomStateEvent() received invalid call type (type=${callType}, roomId=${roomId})`,
);
return;
}
const callIntent = content["m.intent"];
if (!Object.values(GroupCallIntent).includes(callIntent)) {
logger.warn(`Received invalid group call intent ${callType} for room ${roomId}.`);
logger.warn(`Received invalid group call intent (type=${callType}, roomId=${roomId})`);
return;
}
@@ -210,13 +215,13 @@ export class GroupCallEventHandler {
} else if (content["m.type"] !== currentGroupCall.type) {
// TODO: Handle the callType changing when the room state changes
logger.warn(
`The group call type changed for room: ${state.roomId}. Changing the group call type is currently unsupported.`,
`GroupCallEventHandler onRoomStateChanged() currently does not support changing type (roomId=${state.roomId})`,
);
}
} else if (currentGroupCall && currentGroupCall.groupCallId !== groupCallId) {
// TODO: Handle new group calls and multiple group calls
logger.warn(
`Multiple group calls detected for room: ${state.roomId}. Multiple group calls are currently unsupported.`,
`GroupCallEventHandler onRoomStateChanged() currently does not support multiple calls (roomId=${state.roomId})`,
);
}
}
+30 -20
View File
@@ -75,7 +75,7 @@ export class MediaHandler extends TypedEventEmitter<
* undefined treated as unset
*/
public async setAudioInput(deviceId: string): Promise<void> {
logger.info("Setting audio input to", deviceId);
logger.info(`MediaHandler setAudioInput() running (deviceId=${deviceId})`);
if (this.audioInput === deviceId) return;
@@ -88,7 +88,7 @@ export class MediaHandler extends TypedEventEmitter<
* @param opts - audio options to set
*/
public async setAudioSettings(opts: AudioSettings): Promise<void> {
logger.info("Setting audio settings to", opts);
logger.info(`MediaHandler setAudioSettings() running (opts=${JSON.stringify(opts)})`);
this.audioSettings = Object.assign({}, opts) as AudioSettings;
await this.updateLocalUsermediaStreams();
@@ -100,7 +100,7 @@ export class MediaHandler extends TypedEventEmitter<
* undefined treated as unset
*/
public async setVideoInput(deviceId: string): Promise<void> {
logger.info("Setting video input to", deviceId);
logger.info(`MediaHandler setVideoInput() running (deviceId=${deviceId})`);
if (this.videoInput === deviceId) return;
@@ -115,7 +115,7 @@ export class MediaHandler extends TypedEventEmitter<
* undefined treated as unset
*/
public async setMediaInputs(audioInput: string, videoInput: string): Promise<void> {
logger.log(`mediaHandler setMediaInputs audioInput: ${audioInput} videoInput: ${videoInput}`);
logger.log(`MediaHandler setMediaInputs() running (audioInput: ${audioInput} videoInput: ${videoInput})`);
this.audioInput = audioInput;
this.videoInput = videoInput;
await this.updateLocalUsermediaStreams();
@@ -136,7 +136,7 @@ export class MediaHandler extends TypedEventEmitter<
}
for (const stream of this.userMediaStreams) {
logger.log(`mediaHandler stopping all tracks for stream ${stream.id}`);
logger.log(`MediaHandler updateLocalUsermediaStreams() stopping all tracks (streamId=${stream.id})`);
for (const track of stream.getTracks()) {
track.stop();
}
@@ -152,7 +152,9 @@ export class MediaHandler extends TypedEventEmitter<
const { audio, video } = callMediaStreamParams.get(call.callId)!;
logger.log(`mediaHandler updateLocalUsermediaStreams getUserMediaStream call ${call.callId}`);
logger.log(
`MediaHandler updateLocalUsermediaStreams() calling getUserMediaStream() (callId=${call.callId})`,
);
const stream = await this.getUserMediaStream(audio, video);
if (call.callHasEnded()) {
@@ -168,7 +170,7 @@ export class MediaHandler extends TypedEventEmitter<
}
logger.log(
`mediaHandler updateLocalUsermediaStreams getUserMediaStream groupCall ${groupCall.groupCallId}`,
`MediaHandler updateLocalUsermediaStreams() calling getUserMediaStream() (groupCallId=${groupCall.groupCallId})`,
);
const stream = await this.getUserMediaStream(true, groupCall.type === GroupCallType.Video);
@@ -252,8 +254,11 @@ export class MediaHandler extends TypedEventEmitter<
const constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo);
stream = await navigator.mediaDevices.getUserMedia(constraints);
logger.log(
`mediaHandler getUserMediaStream streamId ${stream.id} shouldRequestAudio ${shouldRequestAudio} shouldRequestVideo ${shouldRequestVideo}`,
constraints,
`MediaHandler getUserMediaStreamInternal() calling getUserMediaStream (streamId=${
stream.id
}, shouldRequestAudio=${shouldRequestAudio}, shouldRequestVideo=${shouldRequestVideo}, constraints=${JSON.stringify(
constraints,
)})`,
);
for (const track of stream.getTracks()) {
@@ -272,7 +277,7 @@ export class MediaHandler extends TypedEventEmitter<
} else {
stream = this.localUserMediaStream!.clone();
logger.log(
`mediaHandler clone userMediaStream ${this.localUserMediaStream?.id} new stream ${stream.id} shouldRequestAudio ${shouldRequestAudio} shouldRequestVideo ${shouldRequestVideo}`,
`MediaHandler getUserMediaStreamInternal() cloning (oldStreamId=${this.localUserMediaStream?.id} newStreamId=${stream.id} shouldRequestAudio=${shouldRequestAudio} shouldRequestVideo=${shouldRequestVideo})`,
);
if (!shouldRequestAudio) {
@@ -301,7 +306,7 @@ export class MediaHandler extends TypedEventEmitter<
* Stops all tracks on the provided usermedia stream
*/
public stopUserMediaStream(mediaStream: MediaStream): void {
logger.log(`mediaHandler stopUserMediaStream stopping stream ${mediaStream.id}`);
logger.log(`MediaHandler stopUserMediaStream() stopping (streamId=${mediaStream.id})`);
for (const track of mediaStream.getTracks()) {
track.stop();
}
@@ -309,7 +314,10 @@ export class MediaHandler extends TypedEventEmitter<
const index = this.userMediaStreams.indexOf(mediaStream);
if (index !== -1) {
logger.debug("Splicing usermedia stream out stream array", mediaStream.id);
logger.debug(
`MediaHandler stopUserMediaStream() splicing usermedia stream out stream array (streamId=${mediaStream.id})`,
mediaStream.id,
);
this.userMediaStreams.splice(index, 1);
}
@@ -333,16 +341,20 @@ export class MediaHandler extends TypedEventEmitter<
if (opts.desktopCapturerSourceId) {
// We are using Electron
logger.debug("Getting screensharing stream using getUserMedia()", opts);
logger.debug(
`MediaHandler getScreensharingStream() calling getUserMedia() (opts=${JSON.stringify(opts)})`,
);
stream = await navigator.mediaDevices.getUserMedia(screenshareConstraints);
} else {
// We are not using Electron
logger.debug("Getting screensharing stream using getDisplayMedia()", opts);
logger.debug(
`MediaHandler getScreensharingStream() calling getDisplayMedia() (opts=${JSON.stringify(opts)})`,
);
stream = await navigator.mediaDevices.getDisplayMedia(screenshareConstraints);
}
} else {
const matchingStream = this.screensharingStreams[this.screensharingStreams.length - 1];
logger.log("Cloning screensharing stream", matchingStream.id);
logger.log(`MediaHandler getScreensharingStream() cloning (streamId=${matchingStream.id})`);
stream = matchingStream.clone();
}
@@ -359,7 +371,7 @@ export class MediaHandler extends TypedEventEmitter<
* Stops all tracks on the provided screensharing stream
*/
public stopScreensharingStream(mediaStream: MediaStream): void {
logger.debug("Stopping screensharing stream", mediaStream.id);
logger.debug(`MediaHandler stopScreensharingStream() stopping stream (streamId=${mediaStream.id})`);
for (const track of mediaStream.getTracks()) {
track.stop();
}
@@ -367,7 +379,7 @@ export class MediaHandler extends TypedEventEmitter<
const index = this.screensharingStreams.indexOf(mediaStream);
if (index !== -1) {
logger.debug("Splicing screensharing stream out stream array", mediaStream.id);
logger.debug(`MediaHandler stopScreensharingStream() splicing stream out (streamId=${mediaStream.id})`);
this.screensharingStreams.splice(index, 1);
}
@@ -379,7 +391,7 @@ export class MediaHandler extends TypedEventEmitter<
*/
public stopAllStreams(): void {
for (const stream of this.userMediaStreams) {
logger.log(`mediaHandler stopAllStreams stopping stream ${stream.id}`);
logger.log(`MediaHandler stopAllStreams() stopping (streamId=${stream.id})`);
for (const track of stream.getTracks()) {
track.stop();
}
@@ -428,7 +440,6 @@ export class MediaHandler extends TypedEventEmitter<
private getScreenshareContraints(opts: IScreensharingOpts): DesktopCapturerConstraints {
const { desktopCapturerSourceId, audio } = opts;
if (desktopCapturerSourceId) {
logger.debug("Using desktop capturer source", desktopCapturerSourceId);
return {
audio: audio ?? false,
video: {
@@ -439,7 +450,6 @@ export class MediaHandler extends TypedEventEmitter<
},
};
} else {
logger.debug("Not using desktop capturer source");
return {
audio: audio ?? false,
video: true,
+857 -559
View File
File diff suppressed because it is too large Load Diff