Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4414341d6 | |||
| f173014fc0 | |||
| 81cb44db7f | |||
| 71f9b25db7 | |||
| 9c5c7ddb17 | |||
| feb424b0a9 | |||
| 648a1a09b1 | |||
| 56c5375bbd | |||
| b29e1e9d21 | |||
| 7ade461a4c | |||
| b5414ea914 | |||
| 711bf4710d | |||
| 48b60bb885 | |||
| 7ed14dc749 | |||
| 26736b6bb1 | |||
| 056aae823d | |||
| d1bfdca0c9 | |||
| c0577c29c4 | |||
| a2d1dee0a1 | |||
| d7bcdff29b | |||
| ed71cdeccd | |||
| 729f924de1 | |||
| 4732098731 | |||
| a7d503a8ac | |||
| 063d69eff1 | |||
| 614f446361 | |||
| b62fac9dad | |||
| ff07cb642f | |||
| fba3cf2756 | |||
| 5973c66726 | |||
| b22fc1f9d9 | |||
| 3f48a954d8 | |||
| b5d544df68 | |||
| cd26ba67d4 | |||
| 488390365a | |||
| b7b1129478 | |||
| 0fa9528a3f | |||
| a7b1dcaf95 | |||
| bb5bccbf78 | |||
| ece3ccb958 | |||
| a769cf88f7 | |||
| 8b0f1a0c59 | |||
| 978e748246 | |||
| 32a5cc4728 | |||
| d580f56c0b | |||
| 789458e0d4 | |||
| 8d14d45272 | |||
| 2ec1fa6605 | |||
| f15d682938 | |||
| 21a10a2d14 | |||
| fc02e550bd | |||
| 78637a0689 | |||
| 4ca882fcd4 | |||
| 13ee0eb7f5 | |||
| cb018dfc80 | |||
| 7574dacdb3 | |||
| 0c417b7c32 | |||
| daf845d7bd | |||
| 52792ec89b | |||
| 6dc4a62e8c | |||
| 1cd8ea61ea | |||
| 0c5eb277e4 | |||
| d459a91af3 | |||
| 18722d0031 | |||
| 5119934268 | |||
| 077da23d08 | |||
| 083b4cb17e | |||
| 4316009401 | |||
| 72f3c360b6 | |||
| fcbc195fbe | |||
| af38021d28 | |||
| 5e8cb9fa18 | |||
| 6ef9f6c55e | |||
| e6a3b0ebc0 | |||
| aaae55736f | |||
| 9e586ab634 | |||
| 7ff44d4a50 | |||
| 63abd00ca7 | |||
| 40f2579158 | |||
| ceb2a57feb | |||
| 90e8336797 | |||
| 73ca9c9ed2 | |||
| cc065c2772 | |||
| 028be0fee2 | |||
| d4500da59a | |||
| b6aef6772e | |||
| 49696cecbd | |||
| 83cb52c89c | |||
| e82bae2c4d | |||
| ee2b0204aa | |||
| 1ec7670f6a | |||
| 8ab2e10471 | |||
| 8ff8685ae5 | |||
| f3772cdf82 | |||
| ee2f1cdfd4 | |||
| 0de73a0b3e | |||
| be742e811c | |||
| ca9263fa64 | |||
| 9f619be08d | |||
| 7792254c12 | |||
| fff41f1f27 | |||
| 3e0f9f582e | |||
| 47ba8cfa24 | |||
| 54bb4c8011 | |||
| 71e763263e | |||
| 2ebcda2a55 | |||
| e10db6f7e9 | |||
| 1e041a2957 | |||
| 1631d6f3c0 | |||
| 3d86821258 | |||
| 261bc81554 | |||
| 8f701f43fb | |||
| 4bdb9111dd | |||
| 93e2135223 | |||
| 56cb05aac0 | |||
| 38d5b202c9 | |||
| cfffa9c518 | |||
| 73dbd709d8 | |||
| eef67e2c03 | |||
| e3498f0668 | |||
| f04c147faa | |||
| c2afc357b9 | |||
| 1d3f67f2ce | |||
| 514e4f07f1 | |||
| fbb1c4b2bd | |||
| 63dea599b1 | |||
| 743ba5f050 | |||
| cb180b4195 | |||
| c805b7e29d | |||
| 8f6814450b | |||
| 37e8391cde | |||
| 90234402a7 | |||
| 8ecf603d73 | |||
| af243581ff | |||
| 01afed9ff9 | |||
| 2687bb37fb | |||
| 65b1a10803 | |||
| 9d230ef0d6 | |||
| a03438f2af | |||
| 8c30a3b0df | |||
| c61d53eed0 |
@@ -14,7 +14,7 @@ jobs:
|
||||
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
|
||||
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
|
||||
- name: 📥 Download artifact
|
||||
uses: dawidd6/action-download-artifact@5e780fc7bbd0cac69fc73271ed86edf5dcb72d67 # v2
|
||||
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
|
||||
with:
|
||||
workflow: static_analysis.yml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
path: docs
|
||||
|
||||
- name: 📤 Deploy to Netlify
|
||||
uses: matrix-org/netlify-pr-preview@v1
|
||||
uses: matrix-org/netlify-pr-preview@v2
|
||||
with:
|
||||
path: docs
|
||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
|
||||
@@ -11,6 +11,12 @@ jobs:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: 🧮 Checkout gh-pages
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: gh-pages
|
||||
path: _docs
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -19,38 +25,25 @@ jobs:
|
||||
- name: 🔨 Install dependencies
|
||||
run: "yarn install --frozen-lockfile"
|
||||
|
||||
- name: 📖 Generate JSDoc
|
||||
run: "yarn gendoc"
|
||||
|
||||
- name: 📋 Copy to temp
|
||||
- name: 🔨 Install symlinks
|
||||
run: |
|
||||
cp -a "./_docs" "$RUNNER_TEMP/"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y symlinks
|
||||
|
||||
- name: 🧮 Checkout gh-pages
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: gh-pages
|
||||
|
||||
- name: 🔪 Prepare
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
- name: 📖 Generate docs
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
[ ! -e "$VERSION" ] || rm -r $VERSION
|
||||
cp -r $RUNNER_TEMP/_docs/ $VERSION
|
||||
|
||||
# Add the new directory to the index if it isn't there already
|
||||
if ! grep -q ">Version $VERSION</a>" index.html; then
|
||||
perl -i -pe 'BEGIN {$rel=shift} $_ =~ /^<\/ul>/ && print
|
||||
"<li><a href=\"${rel}/index.html\">Version ${rel}</a></li>\n"' "$VERSION" index.html
|
||||
fi
|
||||
yarn tpv purge --yes --out _docs --stale --major 10
|
||||
yarn gendoc
|
||||
symlinks -rc _docs
|
||||
|
||||
- name: 🚀 Deploy
|
||||
uses: peaceiris/actions-gh-pages@373f7f263a76c20808c831209c920827a82a2847 # v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
keep_files: true
|
||||
publish_dir: .
|
||||
run: |
|
||||
git config --global user.email "releases@riot.im"
|
||||
git config --global user.name "RiotRobot"
|
||||
git add . --all
|
||||
git commit -m "Update docs"
|
||||
git push
|
||||
working-directory: _docs
|
||||
|
||||
npm:
|
||||
name: Publish
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
- name: "🩻 SonarCloud Scan"
|
||||
id: sonarcloud
|
||||
uses: matrix-org/sonarcloud-workflow-action@v2.3
|
||||
uses: matrix-org/sonarcloud-workflow-action@v2.5
|
||||
# workflow_run fails report against the develop commit always, we don't want that for PRs
|
||||
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
|
||||
with:
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
# This is a workaround for https://github.com/SonarSource/SonarJS/issues/578
|
||||
prepare:
|
||||
name: Prepare
|
||||
if: github.event.workflow_run.event != 'merge_group'
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
reportPaths: ${{ steps.extra_args.outputs.reportPaths }}
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
|
||||
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
|
||||
- name: 📥 Download artifact
|
||||
uses: dawidd6/action-download-artifact@5e780fc7bbd0cac69fc73271ed86edf5dcb72d67 # v2
|
||||
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
|
||||
with:
|
||||
workflow: tests.yaml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
sonarqube:
|
||||
name: 🩻 SonarQube
|
||||
if: github.event.workflow_run.event != 'merge_group'
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
|
||||
needs: prepare
|
||||
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
|
||||
secrets:
|
||||
|
||||
@@ -67,6 +67,13 @@ jobs:
|
||||
- name: Generate Docs
|
||||
run: "yarn run gendoc --treatWarningsAsErrors"
|
||||
|
||||
# Upload artifact duplicates symlink contents so we do this to save 75% space
|
||||
- name: Flatten symlink and write _redirects
|
||||
run: |
|
||||
find _docs -mindepth 1 -maxdepth 1 ! -type f ! -name stable -printf '/%f/* /stable/:splat\n' > _docs/_redirects
|
||||
find _docs -mindepth 1 -maxdepth 1 -type l -delete
|
||||
find _docs -mindepth 1 -maxdepth 1 -type d -execdir mv {} stable \; -quit
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@38e0b6e68b4c852a5500a94740f0e535e0d7ba54 # v4
|
||||
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 # v5
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/upgrade-deps
|
||||
|
||||
@@ -1,3 +1,56 @@
|
||||
Changes in [26.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.0.0-rc.1) (2023-06-01)
|
||||
============================================================================================================
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
* Ensure we do not add relations to the wrong timeline ([\#3427](https://github.com/matrix-org/matrix-js-sdk/pull/3427)). Fixes vector-im/element-web#25450 and vector-im/element-web#25494.
|
||||
* Deprecate `QrCodeEvent`, `SasEvent` and `VerificationEvent` ([\#3386](https://github.com/matrix-org/matrix-js-sdk/pull/3386)).
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Move crypto classes into a separate namespace ([\#3385](https://github.com/matrix-org/matrix-js-sdk/pull/3385)).
|
||||
|
||||
## ✨ Features
|
||||
* Mention deno support in the README ([\#3417](https://github.com/matrix-org/matrix-js-sdk/pull/3417)). Contributed by @sigmaSd.
|
||||
* Mark room version 10 as safe ([\#3425](https://github.com/matrix-org/matrix-js-sdk/pull/3425)).
|
||||
* Prioritise entirely supported flows for UIA ([\#3402](https://github.com/matrix-org/matrix-js-sdk/pull/3402)).
|
||||
* Add methods to terminate idb worker ([\#3362](https://github.com/matrix-org/matrix-js-sdk/pull/3362)).
|
||||
* Total summary count ([\#3351](https://github.com/matrix-org/matrix-js-sdk/pull/3351)). Contributed by @toger5.
|
||||
* Audio concealment ([\#3349](https://github.com/matrix-org/matrix-js-sdk/pull/3349)). Contributed by @toger5.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Correctly accumulate sync summaries. ([\#3366](https://github.com/matrix-org/matrix-js-sdk/pull/3366)). Fixes vector-im/element-web#23345.
|
||||
* Keep measuring a call feed's volume after a stream replacement ([\#3361](https://github.com/matrix-org/matrix-js-sdk/pull/3361)). Fixes vector-im/element-call#1051.
|
||||
* Element-R: Avoid uploading a new fallback key at every `/sync` ([\#3338](https://github.com/matrix-org/matrix-js-sdk/pull/3338)). Fixes vector-im/element-web#25215.
|
||||
* Accumulate receipts for the main thread and unthreaded separately ([\#3339](https://github.com/matrix-org/matrix-js-sdk/pull/3339)). Fixes vector-im/element-web#24629.
|
||||
* Remove spec non-compliant extended glob format ([\#3423](https://github.com/matrix-org/matrix-js-sdk/pull/3423)). Fixes vector-im/element-web#25474.
|
||||
* Fix bug where original event was inserted into timeline instead of the edit event ([\#3398](https://github.com/matrix-org/matrix-js-sdk/pull/3398)). Contributed by @andybalaam.
|
||||
* Only add a local receipt if it's after an existing receipt ([\#3399](https://github.com/matrix-org/matrix-js-sdk/pull/3399)). Contributed by @andybalaam.
|
||||
* Attempt a potential workaround for stuck notifs ([\#3384](https://github.com/matrix-org/matrix-js-sdk/pull/3384)). Fixes vector-im/element-web#25406. Contributed by @andybalaam.
|
||||
* Fix verification bug with `pendingEventOrdering: "chronological"` ([\#3382](https://github.com/matrix-org/matrix-js-sdk/pull/3382)).
|
||||
|
||||
Changes in [25.1.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.1.1) (2023-05-16)
|
||||
==================================================================================================
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Rebuild to fix packaging glitch in 25.1.0. Fixes #3363
|
||||
|
||||
Changes in [25.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.1.0) (2023-05-09)
|
||||
==================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Deprecate MatrixClient::resolveRoomAlias ([\#3316](https://github.com/matrix-org/matrix-js-sdk/pull/3316)).
|
||||
|
||||
## ✨ Features
|
||||
* add client method to remove pusher ([\#3324](https://github.com/matrix-org/matrix-js-sdk/pull/3324)). Contributed by @kerryarchibald.
|
||||
* Implement MSC 3981 ([\#3248](https://github.com/matrix-org/matrix-js-sdk/pull/3248)). Fixes vector-im/element-web#25021. Contributed by @justjanne.
|
||||
* Added `Room.getLastLiveEvent` and `Room.getLastThread`. Deprecated `Room.lastThread` in favour of `Room.getLastThread`. ([\#3321](https://github.com/matrix-org/matrix-js-sdk/pull/3321)).
|
||||
* Element-R: wire up device lists ([\#3272](https://github.com/matrix-org/matrix-js-sdk/pull/3272)). Contributed by @florianduros.
|
||||
* Node 20 support ([\#3302](https://github.com/matrix-org/matrix-js-sdk/pull/3302)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix racing between one-time-keys processing and sync ([\#3327](https://github.com/matrix-org/matrix-js-sdk/pull/3327)). Fixes vector-im/element-web#25214. Contributed by @florianduros.
|
||||
* Fix lack of media when a user reconnects ([\#3318](https://github.com/matrix-org/matrix-js-sdk/pull/3318)).
|
||||
* Fix TimelineWindow getEvents exploding if no neigbouring timeline ([\#3285](https://github.com/matrix-org/matrix-js-sdk/pull/3285)). Fixes vector-im/element-web#25104.
|
||||
|
||||
Changes in [25.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.0.0) (2023-04-25)
|
||||
==================================================================================================
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ client.publicRooms(function (err, data) {
|
||||
See below for how to include libolm to enable end-to-end-encryption. Please check
|
||||
[the Node.js terminal app](examples/node) for a more complex example.
|
||||
|
||||
You can also use the sdk with [Deno](https://deno.land/) (`import npm:matrix-js-sdk`) but its not officialy supported.
|
||||
|
||||
To start the client:
|
||||
|
||||
```javascript
|
||||
|
||||
+12
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "25.0.0",
|
||||
"version": "26.0.0-rc.1",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
@@ -55,7 +55,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.6",
|
||||
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.9",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^5.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -101,16 +101,16 @@
|
||||
"debug": "^4.3.4",
|
||||
"docdash": "^2.0.0",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.37.0",
|
||||
"eslint": "8.40.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": "^40.0.0",
|
||||
"eslint-plugin-jsdoc": "^44.0.0",
|
||||
"eslint-plugin-matrix-org": "^1.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.17",
|
||||
"eslint-plugin-unicorn": "^46.0.0",
|
||||
"eslint-plugin-unicorn": "^47.0.0",
|
||||
"exorcist": "^2.0.0",
|
||||
"fake-indexeddb": "^4.0.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
@@ -119,14 +119,17 @@
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-mock": "^29.0.0",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"prettier": "2.8.7",
|
||||
"rimraf": "^4.0.0",
|
||||
"prettier": "2.8.8",
|
||||
"rimraf": "^5.0.0",
|
||||
"terser": "^5.5.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsify": "^5.0.2",
|
||||
"typedoc": "^0.23.20",
|
||||
"typedoc": "^0.24.0",
|
||||
"typedoc-plugin-coverage": "^2.1.0",
|
||||
"typedoc-plugin-mdn-links": "^3.0.3",
|
||||
"typedoc-plugin-missing-exports": "^1.0.0",
|
||||
"typedoc-plugin-missing-exports": "^2.0.0",
|
||||
"typedoc-plugin-versions": "^0.2.3",
|
||||
"typedoc-plugin-versions-cli": "^0.1.12",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"@casualbot/jest-sonar-reporter": {
|
||||
|
||||
@@ -15,4 +15,4 @@ for line in sys.stdin:
|
||||
break
|
||||
found_first_header = True
|
||||
elif not re.match(r"^=+$", line) and len(line) > 0:
|
||||
print line
|
||||
print(line)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
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 fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { CRYPTO_BACKENDS, InitCrypto } from "../test-utils/test-utils";
|
||||
import { createClient, MatrixClient, UIAuthCallback } from "../../src";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
|
||||
// eslint-disable-next-line no-global-assign
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
const TEST_USER_ID = "@alice:localhost";
|
||||
const TEST_DEVICE_ID = "xzcvb";
|
||||
|
||||
/**
|
||||
* Integration tests for cross-signing functionality.
|
||||
*
|
||||
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
|
||||
* to provide the most effective integration tests possible.
|
||||
*/
|
||||
describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: string, initCrypto: InitCrypto) => {
|
||||
let aliceClient: MatrixClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
|
||||
const homeserverUrl = "https://alice-server.com";
|
||||
aliceClient = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
});
|
||||
|
||||
await initCrypto(aliceClient);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await aliceClient.stopClient();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
describe("bootstrapCrossSigning (before initialsync completes)", () => {
|
||||
it("publishes keys if none were yet published", async () => {
|
||||
// have account_data requests return an empty object
|
||||
fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {});
|
||||
|
||||
// we expect a request to upload signatures for our device ...
|
||||
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
|
||||
|
||||
// ... and one to upload the cross-signing keys (with UIA)
|
||||
fetchMock.post(
|
||||
// legacy crypto uses /unstable/; /v3/ is correct
|
||||
{
|
||||
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
|
||||
name: "upload-keys",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
// provide a UIA callback, so that the cross-signing keys are uploaded
|
||||
const authDict = { type: "test" };
|
||||
const uiaCallback: UIAuthCallback<void> = async (makeRequest) => {
|
||||
await makeRequest(authDict);
|
||||
};
|
||||
|
||||
// now bootstrap cross signing, and check it resolves successfully
|
||||
await aliceClient.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: uiaCallback,
|
||||
});
|
||||
|
||||
// check the cross-signing keys upload
|
||||
expect(fetchMock.called("upload-keys")).toBeTruthy();
|
||||
const [, keysOpts] = fetchMock.lastCall("upload-keys")!;
|
||||
const keysBody = JSON.parse(keysOpts!.body as string);
|
||||
expect(keysBody.auth).toEqual(authDict); // check uia dict was passed
|
||||
// there should be a key of each type
|
||||
// master key is signed by the device
|
||||
expect(keysBody).toHaveProperty(`master_key.signatures.[${TEST_USER_ID}].[ed25519:${TEST_DEVICE_ID}]`);
|
||||
const masterKeyId = Object.keys(keysBody.master_key.keys)[0];
|
||||
// ssk and usk are signed by the master key
|
||||
expect(keysBody).toHaveProperty(`self_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
|
||||
expect(keysBody).toHaveProperty(`user_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
|
||||
const sskId = Object.keys(keysBody.self_signing_key.keys)[0];
|
||||
|
||||
// check the publish call
|
||||
expect(fetchMock.called("upload-sigs")).toBeTruthy();
|
||||
const [, sigsOpts] = fetchMock.lastCall("upload-sigs")!;
|
||||
const body = JSON.parse(sigsOpts!.body as string);
|
||||
// there should be a signature for our device, by our self-signing key.
|
||||
expect(body).toHaveProperty(
|
||||
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[${sskId}]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+177
-1
@@ -48,6 +48,8 @@ import { DeviceInfo } from "../../src/crypto/deviceinfo";
|
||||
import { E2EKeyReceiver, IE2EKeyReceiver } from "../test-utils/E2EKeyReceiver";
|
||||
import { ISyncResponder, SyncResponder } from "../test-utils/SyncResponder";
|
||||
import { escapeRegExp } from "../../src/utils";
|
||||
import { downloadDeviceToJsDevice } from "../../src/rust-crypto/device-converter";
|
||||
import { flushPromises } from "../test-utils/flushPromises";
|
||||
|
||||
const ROOM_ID = "!room:id";
|
||||
|
||||
@@ -1109,7 +1111,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
} catch (e) {
|
||||
expect((e as any).name).toEqual("UnknownDeviceError");
|
||||
expect([...(e as any).devices.keys()]).toEqual([aliceClient.getUserId()!]);
|
||||
expect((e as any).devices.get(aliceClient.getUserId()!).has("DEVICE_ID"));
|
||||
expect((e as any).devices.get(aliceClient.getUserId()!).has("DEVICE_ID")).toBeTruthy();
|
||||
}
|
||||
|
||||
// mark the device as known, and resend.
|
||||
@@ -1997,4 +1999,178 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
expect(res.fallbackKeysCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserDeviceInfo", () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// From https://spec.matrix.org/v1.6/client-server-api/#post_matrixclientv3keysquery
|
||||
// Using extracted response from matrix.org, it needs to have real keys etc to pass old crypto verification
|
||||
const queryResponseBody = {
|
||||
device_keys: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
EBMMPAFOPU: {
|
||||
algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
|
||||
device_id: "EBMMPAFOPU",
|
||||
keys: {
|
||||
"curve25519:EBMMPAFOPU": "HyhQD4mXwNViqns0noABW9NxHbCAOkriQ4QKGGndk3w",
|
||||
"ed25519:EBMMPAFOPU": "xSQaxrFOTXH+7Zjo+iwb445hlNPFjnx1O3KaV3Am55k",
|
||||
},
|
||||
signatures: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
"ed25519:EBMMPAFOPU":
|
||||
"XFJVq9HmO5lfJN7l6muaUt887aUHg0/poR3p9XHGXBrLUqzfG7Qllq7jjtUjtcTc5CMD7/mpsXfuC2eV+X1uAw",
|
||||
},
|
||||
},
|
||||
user_id: "@testing_florian1:matrix.org",
|
||||
unsigned: {
|
||||
device_display_name: "display name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
failures: {},
|
||||
master_keys: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
user_id: "@testing_florian1:matrix.org",
|
||||
usage: ["master"],
|
||||
keys: {
|
||||
"ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0":
|
||||
"O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0",
|
||||
},
|
||||
signatures: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
"ed25519:UKAQMJSJZC":
|
||||
"q4GuzzuhZfTpwrlqnJ9+AEUtEfEQ0um1PO3puwp/+vidzFicw0xEPjedpJoASYQIJ8XJAAWX8Q235EKeCzEXCA",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
self_signing_keys: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
user_id: "@testing_florian1:matrix.org",
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
"ed25519:YYWIHBCuKGEy9CXiVrfBVR0N1I60JtiJTNCWjiLAFzo":
|
||||
"YYWIHBCuKGEy9CXiVrfBVR0N1I60JtiJTNCWjiLAFzo",
|
||||
},
|
||||
signatures: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
"ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0":
|
||||
"yckmxgQ3JA5bb205/RunJipnpZ37ycGNf4OFzDwAad++chd71aGHqAMQ1f6D2GVfl8XdHmiRaohZf4mGnDL0AA",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
user_signing_keys: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
user_id: "@testing_florian1:matrix.org",
|
||||
usage: ["user_signing"],
|
||||
keys: {
|
||||
"ed25519:Maa77okgZxnABGqaiChEUnV4rVsAI61WXWeL5TSEUhs":
|
||||
"Maa77okgZxnABGqaiChEUnV4rVsAI61WXWeL5TSEUhs",
|
||||
},
|
||||
signatures: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
"ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0":
|
||||
"WxNNXb13yCrBwXUQzdDWDvWSQ/qWCfwpvssOudlAgbtMzRESMbCTDkeA8sS1awaAtUmu7FrPtDb5LYfK/EE2CQ",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function awaitKeyQueryRequest(): Promise<Record<string, []>> {
|
||||
return new Promise((resolve) => {
|
||||
const listener = (url: string, options: RequestInit) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
// Resolve with request payload
|
||||
resolve(content.device_keys);
|
||||
|
||||
// Return response of `/keys/query`
|
||||
return queryResponseBody;
|
||||
};
|
||||
|
||||
for (const path of ["/_matrix/client/r0/keys/query", "/_matrix/client/v3/keys/query"]) {
|
||||
fetchMock.post(new URL(path, aliceClient.getHomeserverUrl()).toString(), listener);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("Download uncached keys for known user", async () => {
|
||||
const queryPromise = awaitKeyQueryRequest();
|
||||
|
||||
const user = "@testing_florian1:matrix.org";
|
||||
const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user], true);
|
||||
|
||||
// Wait for `/keys/query` to be called
|
||||
const deviceKeysPayload = await queryPromise;
|
||||
|
||||
expect(deviceKeysPayload).toStrictEqual({ [user]: [] });
|
||||
expect(devicesInfo.get(user)?.size).toBe(1);
|
||||
|
||||
// Convert the expected device to IDevice and check
|
||||
expect(devicesInfo.get(user)?.get("EBMMPAFOPU")).toStrictEqual(
|
||||
downloadDeviceToJsDevice(queryResponseBody.device_keys[user]?.EBMMPAFOPU),
|
||||
);
|
||||
});
|
||||
|
||||
it("Download uncached keys for unknown user", async () => {
|
||||
const queryPromise = awaitKeyQueryRequest();
|
||||
|
||||
const user = "@bob:xyz";
|
||||
const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user], true);
|
||||
|
||||
// Wait for `/keys/query` to be called
|
||||
const deviceKeysPayload = await queryPromise;
|
||||
|
||||
expect(deviceKeysPayload).toStrictEqual({ [user]: [] });
|
||||
// The old crypto has an empty map for `@bob:xyz`
|
||||
// The new crypto does not have the `@bob:xyz` entry in `devicesInfo`
|
||||
expect(devicesInfo.get(user)?.size).toBeFalsy();
|
||||
});
|
||||
|
||||
it("Get devices from tacked users", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
const queryPromise = awaitKeyQueryRequest();
|
||||
|
||||
const user = "@testing_florian1:matrix.org";
|
||||
// `user` will be added to the room
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse([user, "@bob:xyz"]));
|
||||
|
||||
// Advance local date to 2 minutes
|
||||
// The old crypto only runs the upload every 60 seconds
|
||||
jest.setSystemTime(Date.now() + 2 * 60 * 1000);
|
||||
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// Old crypto: for alice: run over the `sleep(5)` in `doQueuedQueries` of `DeviceList`
|
||||
jest.runAllTimers();
|
||||
// Old crypto: for alice: run the `processQueryResponseForUser` in `doQueuedQueries` of `DeviceList`
|
||||
await flushPromises();
|
||||
|
||||
// Wait for alice to query `user` keys
|
||||
await queryPromise;
|
||||
|
||||
// Old crypto: for `user`: run over the `sleep(5)` in `doQueuedQueries` of `DeviceList`
|
||||
jest.runAllTimers();
|
||||
// Old crypto: for `user`: run the `processQueryResponseForUser` in `doQueuedQueries` of `DeviceList`
|
||||
// It will add `@testing_florian1:matrix.org` devices to the DeviceList
|
||||
await flushPromises();
|
||||
|
||||
const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user]);
|
||||
|
||||
// We should only have the `user` in it
|
||||
expect(devicesInfo.size).toBe(1);
|
||||
// We are expecting only the EBMMPAFOPU device
|
||||
expect(devicesInfo.get(user)!.size).toBe(1);
|
||||
expect(devicesInfo.get(user)!.get("EBMMPAFOPU")).toEqual(
|
||||
downloadDeviceToJsDevice(queryResponseBody.device_keys[user]["EBMMPAFOPU"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,11 +21,13 @@ import {
|
||||
EventStatus,
|
||||
EventTimeline,
|
||||
EventTimelineSet,
|
||||
EventType,
|
||||
Filter,
|
||||
IEvent,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
PendingEventOrdering,
|
||||
RelationType,
|
||||
Room,
|
||||
} from "../../src/matrix";
|
||||
import { logger } from "../../src/logger";
|
||||
@@ -33,6 +35,7 @@ import { encodeParams, encodeUri, QueryDict, replaceParam } from "../../src/util
|
||||
import { TestClient } from "../TestClient";
|
||||
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread";
|
||||
import { emitPromise } from "../test-utils/test-utils";
|
||||
import { Feature, ServerSupport } from "../../src/feature";
|
||||
|
||||
const userId = "@alice:localhost";
|
||||
const userName = "Alice";
|
||||
@@ -1139,7 +1142,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
const prom = emitPromise(room, ThreadEvent.Update);
|
||||
// Assume we're seeing the reply while loading backlog
|
||||
room.addLiveEvents([THREAD_REPLY2]);
|
||||
await room.addLiveEvents([THREAD_REPLY2]);
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
@@ -1153,7 +1156,118 @@ describe("MatrixClient event timelines", function () {
|
||||
});
|
||||
await flushHttp(prom);
|
||||
// but while loading the metadata, a new reply has arrived
|
||||
room.addLiveEvents([THREAD_REPLY3]);
|
||||
await room.addLiveEvents([THREAD_REPLY3]);
|
||||
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
|
||||
// then the events should still be all in the right order
|
||||
expect(thread.events.map((it) => it.getId())).toEqual([
|
||||
THREAD_ROOT.event_id,
|
||||
THREAD_REPLY.event_id,
|
||||
THREAD_REPLY2.getId(),
|
||||
THREAD_REPLY3.getId(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should ensure thread events don't get reordered with recursive relations", async () => {
|
||||
// Test data for a second reply to the first thread
|
||||
const THREAD_REPLY2 = utils.mkEvent({
|
||||
room: roomId,
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": "thread reply 2",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
// We can't use the const here because we change server support mode for test
|
||||
rel_type: "io.element.thread",
|
||||
event_id: THREAD_ROOT.event_id,
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
THREAD_REPLY2.localTimestamp += 1000;
|
||||
const THREAD_ROOT_REACTION = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.Reaction,
|
||||
user: userId,
|
||||
room: roomId,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: THREAD_ROOT.event_id!,
|
||||
key: Math.random().toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
THREAD_ROOT_REACTION.localTimestamp += 2000;
|
||||
|
||||
// Test data for a second reply to the first thread
|
||||
const THREAD_REPLY3 = utils.mkEvent({
|
||||
room: roomId,
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": "thread reply 3",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
// We can't use the const here because we change server support mode for test
|
||||
rel_type: "io.element.thread",
|
||||
event_id: THREAD_ROOT.event_id,
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
THREAD_REPLY3.localTimestamp += 3000;
|
||||
|
||||
// Test data for the first thread, with the second reply
|
||||
const THREAD_ROOT_UPDATED = {
|
||||
...THREAD_ROOT,
|
||||
unsigned: {
|
||||
...THREAD_ROOT.unsigned,
|
||||
"m.relations": {
|
||||
...THREAD_ROOT.unsigned!["m.relations"],
|
||||
"io.element.thread": {
|
||||
...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"],
|
||||
count: 3,
|
||||
latest_event: THREAD_REPLY3.event,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
client.clientOpts.threadSupport = true;
|
||||
client.canSupport.set(Feature.RelationsRecursion, ServerSupport.Stable);
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
|
||||
|
||||
client.fetchRoomEvent = () => Promise.resolve(THREAD_ROOT_UPDATED);
|
||||
|
||||
await client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
|
||||
const prom = emitPromise(room, ThreadEvent.Update);
|
||||
// Assume we're seeing the reply while loading backlog
|
||||
await room.addLiveEvents([THREAD_REPLY2]);
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({
|
||||
dir: Direction.Backward,
|
||||
limit: 3,
|
||||
recurse: true,
|
||||
}),
|
||||
)
|
||||
.respond(200, {
|
||||
chunk: [THREAD_REPLY3.event, THREAD_ROOT_REACTION, THREAD_REPLY2.event, THREAD_REPLY],
|
||||
});
|
||||
await flushHttp(prom);
|
||||
// but while loading the metadata, a new reply has arrived
|
||||
await room.addLiveEvents([THREAD_REPLY3]);
|
||||
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
|
||||
// then the events should still be all in the right order
|
||||
expect(thread.events.map((it) => it.getId())).toEqual([
|
||||
@@ -1458,7 +1572,7 @@ describe("MatrixClient event timelines", function () {
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD2_ROOT);
|
||||
room.addLiveEvents([THREAD_REPLY2]);
|
||||
await room.addLiveEvents([THREAD_REPLY2]);
|
||||
await httpBackend.flushAllExpected();
|
||||
await prom;
|
||||
expect(thread.length).toBe(2);
|
||||
@@ -1823,11 +1937,6 @@ describe("MatrixClient event timelines", function () {
|
||||
.respond(200, function () {
|
||||
return THREAD_ROOT;
|
||||
});
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
return THREAD_ROOT;
|
||||
});
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
@@ -1847,7 +1956,10 @@ describe("MatrixClient event timelines", function () {
|
||||
encodeURIComponent(THREAD_ROOT.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({ dir: Direction.Backward, from: "start_token" }),
|
||||
buildRelationPaginationQuery({
|
||||
dir: Direction.Backward,
|
||||
from: "start_token",
|
||||
}),
|
||||
)
|
||||
.respond(200, function () {
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import HttpBackend from "matrix-mock-request";
|
||||
import { Mocked } from "jest-mock";
|
||||
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { CRYPTO_ENABLED, IStoredClientOpts, MatrixClient } from "../../src/client";
|
||||
@@ -24,6 +25,7 @@ import { THREAD_RELATION_TYPE } from "../../src/models/thread";
|
||||
import { IFilterDefinition } from "../../src/filter";
|
||||
import { ISearchResults } from "../../src/@types/search";
|
||||
import { IStore } from "../../src/store";
|
||||
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
|
||||
|
||||
describe("MatrixClient", function () {
|
||||
const userId = "@alice:localhost";
|
||||
@@ -1412,6 +1414,42 @@ describe("MatrixClient", function () {
|
||||
await client!.uploadKeys();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCryptoTrustCrossSignedDevices", () => {
|
||||
it("should throw if e2e is disabled", () => {
|
||||
expect(() => client!.getCryptoTrustCrossSignedDevices()).toThrow("End-to-end encryption disabled");
|
||||
});
|
||||
|
||||
it("should proxy to the crypto backend", async () => {
|
||||
const mockBackend = {
|
||||
getTrustCrossSignedDevices: jest.fn().mockReturnValue(true),
|
||||
} as unknown as Mocked<CryptoBackend>;
|
||||
client!["cryptoBackend"] = mockBackend;
|
||||
|
||||
expect(client!.getCryptoTrustCrossSignedDevices()).toBe(true);
|
||||
mockBackend.getTrustCrossSignedDevices.mockReturnValue(false);
|
||||
expect(client!.getCryptoTrustCrossSignedDevices()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCryptoTrustCrossSignedDevices", () => {
|
||||
it("should throw if e2e is disabled", () => {
|
||||
expect(() => client!.setCryptoTrustCrossSignedDevices(false)).toThrow("End-to-end encryption disabled");
|
||||
});
|
||||
|
||||
it("should proxy to the crypto backend", async () => {
|
||||
const mockBackend = {
|
||||
setTrustCrossSignedDevices: jest.fn(),
|
||||
} as unknown as Mocked<CryptoBackend>;
|
||||
client!["cryptoBackend"] = mockBackend;
|
||||
|
||||
client!.setCryptoTrustCrossSignedDevices(true);
|
||||
expect(mockBackend.setTrustCrossSignedDevices).toHaveBeenLastCalledWith(true);
|
||||
|
||||
client!.setCryptoTrustCrossSignedDevices(false);
|
||||
expect(mockBackend.setTrustCrossSignedDevices).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent {
|
||||
|
||||
@@ -36,11 +36,15 @@ import {
|
||||
NotificationCountType,
|
||||
IEphemeral,
|
||||
Room,
|
||||
IndexedDBStore,
|
||||
RelationType,
|
||||
} from "../../src";
|
||||
import { ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { UNREAD_THREAD_NOTIFICATIONS } from "../../src/@types/sync";
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { emitPromise, mkEvent, mkMessage } from "../test-utils/test-utils";
|
||||
import { THREAD_RELATION_TYPE } from "../../src/models/thread";
|
||||
|
||||
describe("MatrixClient syncing", () => {
|
||||
const selfUserId = "@alice:localhost";
|
||||
@@ -1867,4 +1871,124 @@ describe("MatrixClient syncing (IndexedDB version)", () => {
|
||||
idbClient.stopClient();
|
||||
idbHttpBackend.stop();
|
||||
});
|
||||
|
||||
it("should query server for which thread a 2nd order relation belongs to and stash in sync accumulator", async () => {
|
||||
const roomId = "!room:example.org";
|
||||
|
||||
async function startClient(client: MatrixClient): Promise<void> {
|
||||
await Promise.all([
|
||||
idbClient.startClient({
|
||||
// Without this all events just go into the main timeline
|
||||
threadSupport: true,
|
||||
}),
|
||||
idbHttpBackend.flushAllExpected(),
|
||||
emitPromise(idbClient, ClientEvent.Room),
|
||||
]);
|
||||
}
|
||||
|
||||
function assertEventsExpected(client: MatrixClient): void {
|
||||
const room = client.getRoom(roomId);
|
||||
const mainTimelineEvents = room!.getLiveTimeline().getEvents();
|
||||
expect(mainTimelineEvents).toHaveLength(1);
|
||||
expect(mainTimelineEvents[0].getContent().body).toEqual("Test");
|
||||
|
||||
const thread = room!.getThread("$someThreadId")!;
|
||||
expect(thread.replayEvents).toHaveLength(1);
|
||||
expect(thread.replayEvents![0].getRelation()!.key).toEqual("🪿");
|
||||
}
|
||||
|
||||
let idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken, undefined, {
|
||||
store: new IndexedDBStore({
|
||||
indexedDB: global.indexedDB,
|
||||
dbName: "test",
|
||||
}),
|
||||
});
|
||||
let idbHttpBackend = idbTestClient.httpBackend;
|
||||
let idbClient = idbTestClient.client;
|
||||
await idbClient.store.startup();
|
||||
|
||||
idbHttpBackend.when("GET", "/versions").respond(200, { versions: ["v1.4"] });
|
||||
idbHttpBackend.when("GET", "/pushrules/").respond(200, {});
|
||||
idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
|
||||
|
||||
const syncRoomSection = {
|
||||
join: {
|
||||
[roomId]: {
|
||||
timeline: {
|
||||
prev_batch: "foo",
|
||||
events: [
|
||||
mkMessage({
|
||||
room: roomId,
|
||||
user: selfUserId,
|
||||
msg: "Test",
|
||||
}),
|
||||
mkEvent({
|
||||
room: roomId,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: "$someUnknownEvent",
|
||||
key: "🪿",
|
||||
},
|
||||
},
|
||||
type: "m.reaction",
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
idbHttpBackend.when("GET", "/sync").respond(200, {
|
||||
...syncData,
|
||||
rooms: syncRoomSection,
|
||||
});
|
||||
idbHttpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/event/%24someUnknownEvent`).respond(
|
||||
200,
|
||||
mkEvent({
|
||||
room: roomId,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
"body": "Thread response",
|
||||
"m.relates_to": {
|
||||
rel_type: THREAD_RELATION_TYPE.name,
|
||||
event_id: "$someThreadId",
|
||||
},
|
||||
},
|
||||
type: "m.room.message",
|
||||
}),
|
||||
);
|
||||
|
||||
await startClient(idbClient);
|
||||
assertEventsExpected(idbClient);
|
||||
|
||||
idbHttpBackend.verifyNoOutstandingExpectation();
|
||||
// Force sync accumulator to persist, reset client, assert it doesn't re-fetch event on next start-up
|
||||
await idbClient.store.save(true);
|
||||
await idbClient.stopClient();
|
||||
await idbClient.store.destroy();
|
||||
await idbHttpBackend.stop();
|
||||
|
||||
idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken, undefined, {
|
||||
store: new IndexedDBStore({
|
||||
indexedDB: global.indexedDB,
|
||||
dbName: "test",
|
||||
}),
|
||||
});
|
||||
idbHttpBackend = idbTestClient.httpBackend;
|
||||
idbClient = idbTestClient.client;
|
||||
await idbClient.store.startup();
|
||||
|
||||
idbHttpBackend.when("GET", "/versions").respond(200, { versions: ["v1.4"] });
|
||||
idbHttpBackend.when("GET", "/pushrules/").respond(200, {});
|
||||
idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
|
||||
idbHttpBackend.when("GET", "/sync").respond(200, syncData);
|
||||
|
||||
await startClient(idbClient);
|
||||
assertEventsExpected(idbClient);
|
||||
|
||||
idbHttpBackend.verifyNoOutstandingExpectation();
|
||||
await idbClient.stopClient();
|
||||
await idbHttpBackend.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +89,7 @@ describe("MatrixClient syncing", () => {
|
||||
|
||||
const thread = mkThread({ room, client: client!, authorId: selfUserId, participantUserIds: [selfUserId] });
|
||||
const threadReply = thread.events.at(-1)!;
|
||||
room.addLiveEvents([thread.rootEvent]);
|
||||
await room.addLiveEvents([thread.rootEvent]);
|
||||
|
||||
// Initialize read receipt datastructure before testing the reaction
|
||||
room.addReceiptToStructure(thread.rootEvent.getId()!, ReceiptType.Read, selfUserId, { ts: 1 }, false);
|
||||
|
||||
@@ -42,6 +42,7 @@ import { SyncApiOptions, SyncState } from "../../src/sync";
|
||||
import { IStoredClientOpts } from "../../src/client";
|
||||
import { logger } from "../../src/logger";
|
||||
import { emitPromise } from "../test-utils/test-utils";
|
||||
import { defer } from "../../src/utils";
|
||||
|
||||
describe("SlidingSyncSdk", () => {
|
||||
let client: MatrixClient | undefined;
|
||||
@@ -301,67 +302,57 @@ describe("SlidingSyncSdk", () => {
|
||||
},
|
||||
};
|
||||
|
||||
it("can be created with required_state and timeline", () => {
|
||||
it("can be created with required_state and timeline", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomA);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.name).toEqual(data[roomA].name);
|
||||
expect(gotRoom.getMyMembership()).toEqual("join");
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline);
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.name).toEqual(data[roomA].name);
|
||||
expect(gotRoom!.getMyMembership()).toEqual("join");
|
||||
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline);
|
||||
});
|
||||
|
||||
it("can be created with timeline only", () => {
|
||||
it("can be created with timeline only", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomB);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.name).toEqual(data[roomB].name);
|
||||
expect(gotRoom.getMyMembership()).toEqual("join");
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline);
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.name).toEqual(data[roomB].name);
|
||||
expect(gotRoom!.getMyMembership()).toEqual("join");
|
||||
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline);
|
||||
});
|
||||
|
||||
it("can be created with a highlight_count", () => {
|
||||
it("can be created with a highlight_count", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomC);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(
|
||||
data[roomC].highlight_count,
|
||||
);
|
||||
});
|
||||
|
||||
it("can be created with a notification_count", () => {
|
||||
it("can be created with a notification_count", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomD);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(
|
||||
data[roomD].notification_count,
|
||||
);
|
||||
});
|
||||
|
||||
it("can be created with an invited/joined_count", () => {
|
||||
it("can be created with an invited/joined_count", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomG, data[roomG]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomG);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getInvitedMemberCount()).toEqual(data[roomG].invited_count);
|
||||
expect(gotRoom.getJoinedMemberCount()).toEqual(data[roomG].joined_count);
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.getInvitedMemberCount()).toEqual(data[roomG].invited_count);
|
||||
expect(gotRoom!.getJoinedMemberCount()).toEqual(data[roomG].joined_count);
|
||||
});
|
||||
|
||||
it("can be created with live events", () => {
|
||||
let seenLiveEvent = false;
|
||||
it("can be created with live events", async () => {
|
||||
const seenLiveEventDeferred = defer<boolean>();
|
||||
const listener = (
|
||||
ev: MatrixEvent,
|
||||
room?: Room,
|
||||
@@ -371,43 +362,37 @@ describe("SlidingSyncSdk", () => {
|
||||
) => {
|
||||
if (timelineData?.liveEvent) {
|
||||
assertTimelineEvents([ev], data[roomH].timeline.slice(-1));
|
||||
seenLiveEvent = true;
|
||||
seenLiveEventDeferred.resolve(true);
|
||||
}
|
||||
};
|
||||
client!.on(RoomEvent.Timeline, listener);
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomH, data[roomH]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
client!.off(RoomEvent.Timeline, listener);
|
||||
const gotRoom = client!.getRoom(roomH);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.name).toEqual(data[roomH].name);
|
||||
expect(gotRoom.getMyMembership()).toEqual("join");
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.name).toEqual(data[roomH].name);
|
||||
expect(gotRoom!.getMyMembership()).toEqual("join");
|
||||
// check the entire timeline is correct
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents(), data[roomH].timeline);
|
||||
expect(seenLiveEvent).toBe(true);
|
||||
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents(), data[roomH].timeline);
|
||||
await expect(seenLiveEventDeferred.promise).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("can be created with invite_state", () => {
|
||||
it("can be created with invite_state", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomE);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getMyMembership()).toEqual("invite");
|
||||
expect(gotRoom.currentState.getJoinRule()).toEqual(JoinRule.Invite);
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.getMyMembership()).toEqual("invite");
|
||||
expect(gotRoom!.currentState.getJoinRule()).toEqual(JoinRule.Invite);
|
||||
});
|
||||
|
||||
it("uses the 'name' field to caluclate the room name", () => {
|
||||
it("uses the 'name' field to caluclate the room name", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomF);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.name).toEqual(data[roomF].name);
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.name).toEqual(data[roomF].name);
|
||||
});
|
||||
|
||||
describe("updating", () => {
|
||||
@@ -419,33 +404,33 @@ describe("SlidingSyncSdk", () => {
|
||||
name: data[roomA].name,
|
||||
});
|
||||
const gotRoom = client!.getRoom(roomA);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
const newTimeline = data[roomA].timeline;
|
||||
newTimeline.push(newEvent);
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-3), newTimeline);
|
||||
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-3), newTimeline);
|
||||
});
|
||||
|
||||
it("can update with a new required_state event", async () => {
|
||||
let gotRoom = client!.getRoom(roomB);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Invite); // default
|
||||
expect(gotRoom!.getJoinRule()).toEqual(JoinRule.Invite); // default
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, {
|
||||
required_state: [mkOwnStateEvent("m.room.join_rules", { join_rule: "restricted" }, "")],
|
||||
timeline: [],
|
||||
name: data[roomB].name,
|
||||
});
|
||||
gotRoom = client!.getRoom(roomB);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Restricted);
|
||||
expect(gotRoom!.getJoinRule()).toEqual(JoinRule.Restricted);
|
||||
});
|
||||
|
||||
it("can update with a new highlight_count", async () => {
|
||||
@@ -456,11 +441,11 @@ describe("SlidingSyncSdk", () => {
|
||||
highlight_count: 1,
|
||||
});
|
||||
const gotRoom = client!.getRoom(roomC);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(1);
|
||||
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(1);
|
||||
});
|
||||
|
||||
it("can update with a new notification_count", async () => {
|
||||
@@ -471,11 +456,11 @@ describe("SlidingSyncSdk", () => {
|
||||
notification_count: 1,
|
||||
});
|
||||
const gotRoom = client!.getRoom(roomD);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(1);
|
||||
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(1);
|
||||
});
|
||||
|
||||
it("can update with a new joined_count", () => {
|
||||
@@ -486,11 +471,11 @@ describe("SlidingSyncSdk", () => {
|
||||
joined_count: 1,
|
||||
});
|
||||
const gotRoom = client!.getRoom(roomG);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getJoinedMemberCount()).toEqual(1);
|
||||
expect(gotRoom!.getJoinedMemberCount()).toEqual(1);
|
||||
});
|
||||
|
||||
// Regression test for a bug which caused the timeline entries to be out-of-order
|
||||
@@ -512,7 +497,7 @@ describe("SlidingSyncSdk", () => {
|
||||
initial: true, // e.g requested via room subscription
|
||||
});
|
||||
const gotRoom = client!.getRoom(roomA);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
@@ -530,7 +515,7 @@ describe("SlidingSyncSdk", () => {
|
||||
);
|
||||
|
||||
// we expect the timeline now to be oldTimeline (so the old events are in fact old)
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents(), oldTimeline);
|
||||
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents(), oldTimeline);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -626,9 +611,9 @@ describe("SlidingSyncSdk", () => {
|
||||
await httpBackend!.flush("/profile", 1, 1000);
|
||||
await emitPromise(client!, RoomMemberEvent.Name);
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
const inviteeMember = room.getMember(invitee)!;
|
||||
expect(inviteeMember).toBeDefined();
|
||||
expect(inviteeMember).toBeTruthy();
|
||||
expect(inviteeMember.getMxcAvatarUrl()).toEqual(inviteeProfile.avatar_url);
|
||||
expect(inviteeMember.name).toEqual(inviteeProfile.displayname);
|
||||
});
|
||||
@@ -723,7 +708,7 @@ describe("SlidingSyncSdk", () => {
|
||||
],
|
||||
});
|
||||
globalData = client!.getAccountData(globalType)!;
|
||||
expect(globalData).toBeDefined();
|
||||
expect(globalData).toBeTruthy();
|
||||
expect(globalData.getContent()).toEqual(globalContent);
|
||||
});
|
||||
|
||||
@@ -744,6 +729,7 @@ describe("SlidingSyncSdk", () => {
|
||||
foo: "bar",
|
||||
};
|
||||
const roomType = "test";
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
ext.onResponse({
|
||||
rooms: {
|
||||
[roomId]: [
|
||||
@@ -755,9 +741,9 @@ describe("SlidingSyncSdk", () => {
|
||||
},
|
||||
});
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
const event = room.getAccountData(roomType)!;
|
||||
expect(event).toBeDefined();
|
||||
expect(event).toBeTruthy();
|
||||
expect(event.getContent()).toEqual(roomContent);
|
||||
});
|
||||
|
||||
@@ -943,8 +929,9 @@ describe("SlidingSyncSdk", () => {
|
||||
],
|
||||
initial: true,
|
||||
});
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
expect(room.getMember(selfUserId)?.typing).toEqual(false);
|
||||
ext.onResponse({
|
||||
rooms: {
|
||||
@@ -984,7 +971,7 @@ describe("SlidingSyncSdk", () => {
|
||||
initial: true,
|
||||
});
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
expect(room.getMember(selfUserId)?.typing).toEqual(false);
|
||||
ext.onResponse({
|
||||
rooms: {
|
||||
@@ -1077,12 +1064,13 @@ describe("SlidingSyncSdk", () => {
|
||||
],
|
||||
initial: true,
|
||||
});
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
expect(room.getReadReceiptForUserId(alice, true)).toBeNull();
|
||||
ext.onResponse(generateReceiptResponse(alice, roomId, lastEvent.event_id, "m.read", 1234567));
|
||||
const receipt = room.getReadReceiptForUserId(alice);
|
||||
expect(receipt).toBeDefined();
|
||||
expect(receipt).toBeTruthy();
|
||||
expect(receipt?.eventId).toEqual(lastEvent.event_id);
|
||||
expect(receipt?.data.ts).toEqual(1234567);
|
||||
expect(receipt?.data.thread_id).toBeFalsy();
|
||||
|
||||
@@ -1698,7 +1698,7 @@ describe("SlidingSync", () => {
|
||||
});
|
||||
|
||||
function timeout(delayMs: number, reason: string): { promise: Promise<never>; cancel: () => void } {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
return {
|
||||
promise: new Promise((resolve, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
|
||||
+1
-1
@@ -23,5 +23,5 @@ try {
|
||||
global.Olm = require("@matrix-org/olm");
|
||||
logger.log("loaded libolm");
|
||||
} catch (e) {
|
||||
logger.warn("unable to run crypto tests: libolm not available");
|
||||
logger.warn("unable to run crypto tests: libolm not available", e);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import "../olm-loader";
|
||||
|
||||
import { logger } from "../../src/logger";
|
||||
import { IContent, IEvent, IEventRelation, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { ClientEvent, EventType, IPusher, MatrixClient, MsgType } from "../../src";
|
||||
import { ClientEvent, EventType, IPusher, MatrixClient, MsgType, RelationType } from "../../src";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import { eventMapperFor } from "../../src/event-mapper";
|
||||
|
||||
@@ -258,6 +258,9 @@ export interface IMessageOpts {
|
||||
* @param opts.user - The user ID for the event.
|
||||
* @param opts.msg - Optional. The content.body for the event.
|
||||
* @param opts.event - True to make a MatrixEvent.
|
||||
* @param opts.relatesTo - An IEventRelation relating this to another event.
|
||||
* @param opts.ts - The timestamp of the event.
|
||||
* @param opts.event - True to make a MatrixEvent.
|
||||
* @param client - If passed along with opts.event=true will be used to set up re-emitters.
|
||||
* @returns The event
|
||||
*/
|
||||
@@ -297,6 +300,7 @@ interface IReplyMessageOpts extends IMessageOpts {
|
||||
* @param opts.room - The room ID for the event.
|
||||
* @param opts.user - The user ID for the event.
|
||||
* @param opts.msg - Optional. The content.body for the event.
|
||||
* @param opts.ts - The timestamp of the event.
|
||||
* @param opts.replyToMessage - The replied message
|
||||
* @param opts.event - True to make a MatrixEvent.
|
||||
* @param client - If passed along with opts.event=true will be used to set up re-emitters.
|
||||
@@ -330,6 +334,73 @@ export function mkReplyMessage(
|
||||
return mkEvent(eventOpts, client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reaction event.
|
||||
*
|
||||
* @param target - the event we are reacting to.
|
||||
* @param client - the MatrixClient
|
||||
* @param userId - the userId of the sender
|
||||
* @param roomId - the id of the room we are in
|
||||
* @param ts - The timestamp of the event.
|
||||
* @returns The event
|
||||
*/
|
||||
export function mkReaction(
|
||||
target: MatrixEvent,
|
||||
client: MatrixClient,
|
||||
userId: string,
|
||||
roomId: string,
|
||||
ts?: number,
|
||||
): MatrixEvent {
|
||||
return mkEvent(
|
||||
{
|
||||
event: true,
|
||||
type: EventType.Reaction,
|
||||
user: userId,
|
||||
room: roomId,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: target.getId()!,
|
||||
key: Math.random().toString(),
|
||||
},
|
||||
},
|
||||
ts,
|
||||
},
|
||||
client,
|
||||
);
|
||||
}
|
||||
|
||||
export function mkEdit(
|
||||
target: MatrixEvent,
|
||||
client: MatrixClient,
|
||||
userId: string,
|
||||
roomId: string,
|
||||
msg?: string,
|
||||
ts?: number,
|
||||
) {
|
||||
msg = msg ?? `Edit of ${target.getId()}`;
|
||||
return mkEvent(
|
||||
{
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: userId,
|
||||
room: roomId,
|
||||
content: {
|
||||
"body": `* ${msg}`,
|
||||
"m.new_content": {
|
||||
body: msg,
|
||||
},
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: target.getId()!,
|
||||
},
|
||||
},
|
||||
ts,
|
||||
},
|
||||
client,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A mock implementation of webstorage
|
||||
*/
|
||||
|
||||
@@ -115,6 +115,26 @@ type MakeThreadProps = {
|
||||
ts?: number;
|
||||
};
|
||||
|
||||
type MakeThreadResult = {
|
||||
/**
|
||||
* Thread model
|
||||
*/
|
||||
thread: Thread;
|
||||
/**
|
||||
* Thread root event
|
||||
*/
|
||||
rootEvent: MatrixEvent;
|
||||
/**
|
||||
* Events added to the thread
|
||||
*/
|
||||
events: MatrixEvent[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a new thread in a room by creating a message as thread root.
|
||||
* Also creates a Thread model and adds it to the room.
|
||||
* Does not insert the messages into a timeline.
|
||||
*/
|
||||
export const mkThread = ({
|
||||
room,
|
||||
client,
|
||||
@@ -122,7 +142,7 @@ export const mkThread = ({
|
||||
participantUserIds,
|
||||
length = 2,
|
||||
ts = 1,
|
||||
}: MakeThreadProps): { thread: Thread; rootEvent: MatrixEvent; events: MatrixEvent[] } => {
|
||||
}: MakeThreadProps): MakeThreadResult => {
|
||||
const { rootEvent, events } = makeThreadEvents({
|
||||
roomId: room.roomId,
|
||||
authorId,
|
||||
|
||||
@@ -239,6 +239,8 @@ export class MockRTCPeerConnection {
|
||||
public triggerIncomingDataChannel(): void {
|
||||
this.onDataChannelListener?.({ channel: {} } as RTCDataChannelEvent);
|
||||
}
|
||||
|
||||
public restartIce(): void {}
|
||||
}
|
||||
|
||||
export class MockRTCRtpSender {
|
||||
|
||||
@@ -381,12 +381,7 @@ describe("Crypto", function () {
|
||||
event.senderCurve25519Key = null;
|
||||
// @ts-ignore private properties
|
||||
event.claimedEd25519Key = null;
|
||||
try {
|
||||
await bobClient.crypto!.decryptEvent(event);
|
||||
} catch (e) {
|
||||
// we expect this to fail because we don't have the
|
||||
// decryption keys yet
|
||||
}
|
||||
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -617,12 +612,7 @@ describe("Crypto", function () {
|
||||
event.senderCurve25519Key = null;
|
||||
// @ts-ignore private properties
|
||||
event.claimedEd25519Key = null;
|
||||
try {
|
||||
await secondAliceClient.crypto!.decryptEvent(event);
|
||||
} catch (e) {
|
||||
// we expect this to fail because we don't have the
|
||||
// decryption keys yet
|
||||
}
|
||||
await expect(secondAliceClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -725,12 +715,7 @@ describe("Crypto", function () {
|
||||
event.senderCurve25519Key = null;
|
||||
// @ts-ignore private properties
|
||||
event.claimedEd25519Key = null;
|
||||
try {
|
||||
await bobClient.crypto!.decryptEvent(event);
|
||||
} catch (e) {
|
||||
// we expect this to fail because we don't have the
|
||||
// decryption keys yet
|
||||
}
|
||||
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -805,12 +790,7 @@ describe("Crypto", function () {
|
||||
event.senderCurve25519Key = null;
|
||||
// @ts-ignore private properties
|
||||
event.claimedEd25519Key = null;
|
||||
try {
|
||||
await bobClient.crypto!.decryptEvent(event);
|
||||
} catch (e) {
|
||||
// we expect this to fail because we don't have the
|
||||
// decryption keys yet
|
||||
}
|
||||
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -897,12 +877,7 @@ describe("Crypto", function () {
|
||||
event.senderCurve25519Key = null;
|
||||
// @ts-ignore private properties
|
||||
event.claimedEd25519Key = null;
|
||||
try {
|
||||
await bobClient.crypto!.decryptEvent(event);
|
||||
} catch (e) {
|
||||
// we expect this to fail because we don't have the
|
||||
// decryption keys yet
|
||||
}
|
||||
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -24,10 +24,11 @@ import * as olmlib from "../../../src/crypto/olmlib";
|
||||
import { MatrixError } from "../../../src/http-api";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { ICrossSigningKey, ICreateClientOpts, ISignedKey, MatrixClient } from "../../../src/client";
|
||||
import { CryptoEvent, IBootstrapCrossSigningOpts } from "../../../src/crypto";
|
||||
import { CryptoEvent } from "../../../src/crypto";
|
||||
import { IDevice } from "../../../src/crypto/deviceinfo";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { resetCrossSigningKeys } from "./crypto-utils";
|
||||
import { BootstrapCrossSigningOpts } from "../../../src/crypto-api";
|
||||
|
||||
const PUSH_RULES_RESPONSE: Response = {
|
||||
method: "GET",
|
||||
@@ -146,7 +147,7 @@ describe("Cross Signing", function () {
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
alice.setAccountData = async () => ({});
|
||||
alice.getAccountDataFromServer = async <T extends { [k: string]: any }>(): Promise<T | null> => ({} as T);
|
||||
const authUploadDeviceSigningKeys: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
|
||||
const authUploadDeviceSigningKeys: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
|
||||
await func({});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
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 { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
import { DeviceVerification } from "../../../src";
|
||||
import { deviceInfoToDevice } from "../../../src/crypto/device-converter";
|
||||
|
||||
describe("device-converter", () => {
|
||||
const userId = "@alice:example.com";
|
||||
const deviceId = "xcvf";
|
||||
|
||||
// All parameters for DeviceInfo initialization
|
||||
const keys = {
|
||||
[`ed25519:${deviceId}`]: "key1",
|
||||
[`curve25519:${deviceId}`]: "key2",
|
||||
};
|
||||
const algorithms = ["algo1", "algo2"];
|
||||
const verified = DeviceVerification.Verified;
|
||||
const signatures = { [userId]: { [deviceId]: "sign1" } };
|
||||
const displayName = "display name";
|
||||
const unsigned = {
|
||||
device_display_name: displayName,
|
||||
};
|
||||
|
||||
describe("deviceInfoToDevice", () => {
|
||||
it("should convert a DeviceInfo to a Device", () => {
|
||||
const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified, signatures, unsigned }, deviceId);
|
||||
const device = deviceInfoToDevice(deviceInfo, userId);
|
||||
|
||||
expect(device.deviceId).toBe(deviceId);
|
||||
expect(device.userId).toBe(userId);
|
||||
expect(device.verified).toBe(verified);
|
||||
expect(device.getIdentityKey()).toBe(keys[`curve25519:${deviceId}`]);
|
||||
expect(device.getFingerprint()).toBe(keys[`ed25519:${deviceId}`]);
|
||||
expect(device.displayName).toBe(displayName);
|
||||
});
|
||||
|
||||
it("should add empty signatures", () => {
|
||||
const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified }, deviceId);
|
||||
const device = deviceInfoToDevice(deviceInfo, userId);
|
||||
|
||||
expect(device.signatures.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -148,22 +148,14 @@ describe("Secrets", function () {
|
||||
it("should throw if given a key that doesn't exist", async function () {
|
||||
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
|
||||
|
||||
try {
|
||||
await alice.storeSecret("foo", "bar", ["this secret does not exist"]);
|
||||
// should be able to use expect(...).toThrow() but mocha still fails
|
||||
// the test even when it throws for reasons I have no inclination to debug
|
||||
expect(true).toBeFalsy();
|
||||
} catch (e) {}
|
||||
await expect(alice.storeSecret("foo", "bar", ["this secret does not exist"])).rejects.toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
it("should refuse to encrypt with zero keys", async function () {
|
||||
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
|
||||
|
||||
try {
|
||||
await alice.storeSecret("foo", "bar", []);
|
||||
expect(true).toBeFalsy();
|
||||
} catch (e) {}
|
||||
await expect(alice.storeSecret("foo", "bar", [])).rejects.toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
@@ -214,10 +206,7 @@ describe("Secrets", function () {
|
||||
it("should refuse to encrypt if no keys given and no default key", async function () {
|
||||
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
|
||||
|
||||
try {
|
||||
await alice.storeSecret("foo", "bar");
|
||||
expect(true).toBeFalsy();
|
||||
} catch (e) {}
|
||||
await expect(alice.storeSecret("foo", "bar")).rejects.toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ describe("SAS verification", function () {
|
||||
expect(e.sas).toEqual(aliceSasEvent.sas);
|
||||
e.confirm();
|
||||
aliceSasEvent.confirm();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
e.mismatch();
|
||||
aliceSasEvent.mismatch();
|
||||
}
|
||||
@@ -169,7 +169,7 @@ describe("SAS verification", function () {
|
||||
expect(e.sas).toEqual(bobSasEvent.sas);
|
||||
e.confirm();
|
||||
bobSasEvent.confirm();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
e.mismatch();
|
||||
bobSasEvent.mismatch();
|
||||
}
|
||||
@@ -374,6 +374,12 @@ describe("SAS verification", function () {
|
||||
expect(bobDeviceTrust.isLocallyVerified()).toBeTruthy();
|
||||
expect(bobDeviceTrust.isCrossSigningVerified()).toBeFalsy();
|
||||
|
||||
const bobDeviceVerificationStatus = (await alice.client
|
||||
.getCrypto()!
|
||||
.getDeviceVerificationStatus("@bob:example.com", "Dynabook"))!;
|
||||
expect(bobDeviceVerificationStatus.localVerified).toBe(true);
|
||||
expect(bobDeviceVerificationStatus.crossSigningVerified).toBe(false);
|
||||
|
||||
const aliceTrust = bob.client.checkUserTrust("@alice:example.com");
|
||||
expect(aliceTrust.isCrossSigningVerified()).toBeTruthy();
|
||||
expect(aliceTrust.isTofu()).toBeTruthy();
|
||||
@@ -381,6 +387,17 @@ describe("SAS verification", function () {
|
||||
const aliceDeviceTrust = bob.client.checkDeviceTrust("@alice:example.com", "Osborne2");
|
||||
expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy();
|
||||
expect(aliceDeviceTrust.isCrossSigningVerified()).toBeFalsy();
|
||||
|
||||
const aliceDeviceVerificationStatus = (await bob.client
|
||||
.getCrypto()!
|
||||
.getDeviceVerificationStatus("@alice:example.com", "Osborne2"))!;
|
||||
expect(aliceDeviceVerificationStatus.localVerified).toBe(true);
|
||||
expect(aliceDeviceVerificationStatus.crossSigningVerified).toBe(false);
|
||||
|
||||
const unknownDeviceVerificationStatus = await bob.client
|
||||
.getCrypto()!
|
||||
.getDeviceVerificationStatus("@alice:example.com", "xyz");
|
||||
expect(unknownDeviceVerificationStatus).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -502,7 +519,7 @@ describe("SAS verification", function () {
|
||||
expect(e.sas).toEqual(aliceSasEvent.sas);
|
||||
e.confirm();
|
||||
aliceSasEvent.confirm();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
e.mismatch();
|
||||
aliceSasEvent.mismatch();
|
||||
}
|
||||
@@ -526,7 +543,7 @@ describe("SAS verification", function () {
|
||||
expect(e.sas).toEqual(bobSasEvent.sas);
|
||||
e.confirm();
|
||||
bobSasEvent.confirm();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
e.mismatch();
|
||||
bobSasEvent.mismatch();
|
||||
}
|
||||
|
||||
@@ -24,13 +24,10 @@ import {
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
} from "../../src";
|
||||
import { FeatureSupport, Thread } from "../../src/models/thread";
|
||||
import { Thread } from "../../src/models/thread";
|
||||
import { ReEmitter } from "../../src/ReEmitter";
|
||||
import { eventMapperFor } from "../../src/event-mapper";
|
||||
|
||||
describe("EventTimelineSet", () => {
|
||||
const roomId = "!foo:bar";
|
||||
@@ -82,6 +79,7 @@ describe("EventTimelineSet", () => {
|
||||
beforeEach(() => {
|
||||
client = utils.mock(MatrixClient, "MatrixClient");
|
||||
client.reEmitter = utils.mock(ReEmitter, "ReEmitter");
|
||||
client.canSupport = new Map();
|
||||
room = new Room(roomId, client, userA);
|
||||
eventTimelineSet = new EventTimelineSet(room);
|
||||
eventTimeline = new EventTimeline(eventTimelineSet);
|
||||
@@ -205,88 +203,6 @@ describe("EventTimelineSet", () => {
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
});
|
||||
|
||||
it("should allow edits to be added to thread timeline", async () => {
|
||||
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
|
||||
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
|
||||
Thread.hasServerSideSupport = FeatureSupport.Stable;
|
||||
|
||||
const sender = "@alice:matrix.org";
|
||||
|
||||
const root = utils.mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
body: "Thread root",
|
||||
},
|
||||
type: EventType.RoomMessage,
|
||||
sender,
|
||||
});
|
||||
room.addLiveEvents([root]);
|
||||
|
||||
const threadReply = utils.mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
"body": "Thread reply",
|
||||
"m.relates_to": {
|
||||
event_id: root.getId()!,
|
||||
rel_type: RelationType.Thread,
|
||||
},
|
||||
},
|
||||
type: EventType.RoomMessage,
|
||||
sender,
|
||||
});
|
||||
|
||||
root.setUnsigned({
|
||||
"m.relations": {
|
||||
[RelationType.Thread]: {
|
||||
count: 1,
|
||||
latest_event: {
|
||||
content: threadReply.getContent(),
|
||||
origin_server_ts: 5,
|
||||
room_id: room.roomId,
|
||||
sender,
|
||||
type: EventType.RoomMessage,
|
||||
event_id: threadReply.getId()!,
|
||||
user_id: sender,
|
||||
age: 1,
|
||||
},
|
||||
current_user_participated: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const editToThreadReply = utils.mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
"body": " * edit",
|
||||
"m.new_content": {
|
||||
"body": "edit",
|
||||
"msgtype": "m.text",
|
||||
"org.matrix.msc1767.text": "edit",
|
||||
},
|
||||
"m.relates_to": {
|
||||
event_id: threadReply.getId()!,
|
||||
rel_type: RelationType.Replace,
|
||||
},
|
||||
},
|
||||
type: EventType.RoomMessage,
|
||||
sender,
|
||||
});
|
||||
|
||||
jest.spyOn(client, "paginateEventTimeline").mockImplementation(async () => {
|
||||
thread.timelineSet.getLiveTimeline().addEvent(threadReply, { toStartOfTimeline: true });
|
||||
return true;
|
||||
});
|
||||
jest.spyOn(client, "relations").mockResolvedValue({
|
||||
events: [],
|
||||
});
|
||||
|
||||
const thread = room.createThread(root.getId()!, root, [threadReply, editToThreadReply], false);
|
||||
thread.once(RoomEvent.TimelineReset, () => {
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
expect(lastEvent.getContent().body).toBe(" * edit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-room timeline", () => {
|
||||
it("Adds event to timeline", () => {
|
||||
const nonRoomEventTimelineSet = new EventTimelineSet(
|
||||
|
||||
@@ -517,4 +517,47 @@ describe("InteractiveAuth", () => {
|
||||
expect(ia.getEmailSid()).toEqual(sid);
|
||||
});
|
||||
});
|
||||
|
||||
it("should prioritise shorter flows", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest: doRequest,
|
||||
stateUpdated: stateUpdated,
|
||||
requestEmailToken: jest.fn(),
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
flows: [{ stages: [AuthType.Recaptcha, AuthType.Password] }, { stages: [AuthType.Password] }],
|
||||
params: {},
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
ia.chooseStage();
|
||||
expect(ia.getChosenFlow()?.stages).toEqual([AuthType.Password]);
|
||||
});
|
||||
|
||||
it("should prioritise flows with entirely supported stages", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest: doRequest,
|
||||
stateUpdated: stateUpdated,
|
||||
requestEmailToken: jest.fn(),
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
flows: [{ stages: ["com.devture.shared_secret_auth"] }, { stages: [AuthType.Password] }],
|
||||
params: {},
|
||||
},
|
||||
supportedStages: [AuthType.Password],
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
ia.chooseStage();
|
||||
expect(ia.getChosenFlow()?.stages).toEqual([AuthType.Password]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ import { SyncState } from "../../src/sync";
|
||||
import * as featureUtils from "../../src/feature";
|
||||
import { StubStore } from "../../src/store/stub";
|
||||
import { SecretStorageKeyDescriptionAesV1, ServerSideSecretStorageImpl } from "../../src/secret-storage";
|
||||
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
@@ -2750,6 +2751,60 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
});
|
||||
|
||||
// these wrappers are deprecated, but we need coverage of them to pass the quality gate
|
||||
describe("Crypto wrappers", () => {
|
||||
describe("exception if no crypto", () => {
|
||||
it("isCrossSigningReady", () => {
|
||||
expect(() => client.isCrossSigningReady()).toThrow("End-to-end encryption disabled");
|
||||
});
|
||||
|
||||
it("bootstrapCrossSigning", () => {
|
||||
expect(() => client.bootstrapCrossSigning({})).toThrow("End-to-end encryption disabled");
|
||||
});
|
||||
|
||||
it("isSecretStorageReady", () => {
|
||||
expect(() => client.isSecretStorageReady()).toThrow("End-to-end encryption disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defer to crypto backend", () => {
|
||||
let mockCryptoBackend: Mocked<CryptoBackend>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCryptoBackend = {
|
||||
isCrossSigningReady: jest.fn(),
|
||||
bootstrapCrossSigning: jest.fn(),
|
||||
isSecretStorageReady: jest.fn(),
|
||||
stop: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Mocked<CryptoBackend>;
|
||||
client["cryptoBackend"] = mockCryptoBackend;
|
||||
});
|
||||
|
||||
it("isCrossSigningReady", async () => {
|
||||
const testResult = "test";
|
||||
mockCryptoBackend.isCrossSigningReady.mockResolvedValue(testResult as unknown as boolean);
|
||||
expect(await client.isCrossSigningReady()).toBe(testResult);
|
||||
expect(mockCryptoBackend.isCrossSigningReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bootstrapCrossSigning", async () => {
|
||||
const testOpts = {};
|
||||
mockCryptoBackend.bootstrapCrossSigning.mockResolvedValue(undefined);
|
||||
await client.bootstrapCrossSigning(testOpts);
|
||||
expect(mockCryptoBackend.bootstrapCrossSigning).toHaveBeenCalledTimes(1);
|
||||
expect(mockCryptoBackend.bootstrapCrossSigning).toHaveBeenCalledWith(testOpts);
|
||||
});
|
||||
|
||||
it("isSecretStorageReady", async () => {
|
||||
client["cryptoBackend"] = mockCryptoBackend;
|
||||
const testResult = "test";
|
||||
mockCryptoBackend.isSecretStorageReady.mockResolvedValue(testResult as unknown as boolean);
|
||||
expect(await client.isSecretStorageReady()).toBe(testResult);
|
||||
expect(mockCryptoBackend.isSecretStorageReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("paginateEventTimeline()", () => {
|
||||
describe("notifications timeline", () => {
|
||||
const unsafeNotification = {
|
||||
@@ -2909,4 +2964,43 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushers", () => {
|
||||
const pusher = {
|
||||
app_id: "test",
|
||||
app_display_name: "Test App",
|
||||
data: {},
|
||||
device_display_name: "test device",
|
||||
kind: "http",
|
||||
lang: "en-NZ",
|
||||
pushkey: "1234",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
makeClient();
|
||||
const response: HttpLookup = {
|
||||
method: Method.Post,
|
||||
path: "/pushers/set",
|
||||
data: {},
|
||||
};
|
||||
httpLookups = [response];
|
||||
jest.spyOn(client.http, "authedRequest").mockClear();
|
||||
});
|
||||
|
||||
it("should make correct request to set pusher", async () => {
|
||||
const result = await client.setPusher(pusher);
|
||||
expect(client.http.authedRequest).toHaveBeenCalledWith(Method.Post, "/pushers/set", undefined, pusher);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("should make correct request to remove pusher", async () => {
|
||||
const result = await client.removePusher(pusher.pushkey, pusher.app_id);
|
||||
expect(client.http.authedRequest).toHaveBeenCalledWith(Method.Post, "/pushers/set", undefined, {
|
||||
pushkey: pusher.pushkey,
|
||||
app_id: pusher.app_id,
|
||||
kind: null,
|
||||
});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,17 +14,20 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { MatrixClient, PendingEventOrdering } from "../../../src/client";
|
||||
import { Room } from "../../../src/models/room";
|
||||
import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread";
|
||||
import { mkThread } from "../../test-utils/thread";
|
||||
import { Room, RoomEvent } from "../../../src/models/room";
|
||||
import { Thread, THREAD_RELATION_TYPE, ThreadEvent, FeatureSupport } from "../../../src/models/thread";
|
||||
import { makeThreadEvent, mkThread } from "../../test-utils/thread";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { emitPromise, mkMessage, mock } from "../../test-utils/test-utils";
|
||||
import { Direction, EventStatus, MatrixEvent } from "../../../src";
|
||||
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
|
||||
import { Direction, EventStatus, EventType, MatrixEvent } from "../../../src";
|
||||
import { ReceiptType } from "../../../src/@types/read_receipts";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
|
||||
import { ReEmitter } from "../../../src/ReEmitter";
|
||||
import { Feature, ServerSupport } from "../../../src/feature";
|
||||
import { eventMapperFor } from "../../../src/event-mapper";
|
||||
|
||||
describe("Thread", () => {
|
||||
describe("constructor", () => {
|
||||
@@ -424,4 +427,301 @@ describe("Thread", () => {
|
||||
expect(mock).toHaveBeenCalledWith("b1", "f1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertEventIntoTimeline", () => {
|
||||
it("Inserts a reaction in timestamp order", () => {
|
||||
// Assumption: no server side support because if we have it, events
|
||||
// can only be added to the timeline after the thread has been
|
||||
// initialised, and we are not properly initialising it here.
|
||||
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
|
||||
|
||||
const client = createClientWithEventMapper();
|
||||
const userId = "user1";
|
||||
const room = new Room("room1", client, userId);
|
||||
|
||||
// Given a thread with a root plus 5 messages
|
||||
const { thread, events } = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: userId,
|
||||
participantUserIds: ["@bob:hs", "@chia:hs", "@dv:hs"],
|
||||
length: 6,
|
||||
ts: 100, // Events will be at ts 100, 101, 102, 103, 104 and 105
|
||||
});
|
||||
|
||||
// When we insert a reaction to the second thread message
|
||||
const replyEvent = mkReaction(events[2], client, userId, room.roomId, 104);
|
||||
thread.insertEventIntoTimeline(replyEvent);
|
||||
|
||||
// Then the reaction is inserted based on its timestamp
|
||||
expect(thread.events.map((ev) => ev.getId())).toEqual([
|
||||
events[0].getId(),
|
||||
events[1].getId(),
|
||||
events[2].getId(),
|
||||
events[3].getId(),
|
||||
events[4].getId(),
|
||||
replyEvent.getId(),
|
||||
events[5].getId(),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("Without relations recursion support", () => {
|
||||
it("Creates a local echo receipt for new events", async () => {
|
||||
// Assumption: no server side support because if we have it, events
|
||||
// can only be added to the timeline after the thread has been
|
||||
// initialised, and we are not properly initialising it here.
|
||||
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
|
||||
|
||||
// Given a client without relations recursion support
|
||||
const client = createClientWithEventMapper();
|
||||
|
||||
// And a thread with an added event (with later timestamp)
|
||||
const userId = "user1";
|
||||
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
|
||||
|
||||
// Then a receipt was added to the thread
|
||||
const receipt = thread.getReadReceiptForUserId(userId);
|
||||
expect(receipt).toBeTruthy();
|
||||
expect(receipt?.eventId).toEqual(message.getId());
|
||||
expect(receipt?.data.ts).toEqual(100);
|
||||
expect(receipt?.data.thread_id).toEqual(thread.id);
|
||||
|
||||
// (And the receipt was synthetic)
|
||||
expect(thread.getReadReceiptForUserId(userId, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("Doesn't create a local echo receipt for events before an existing receipt", async () => {
|
||||
// Assumption: no server side support because if we have it, events
|
||||
// can only be added to the timeline after the thread has been
|
||||
// initialised, and we are not properly initialising it here.
|
||||
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
|
||||
|
||||
// Given a client without relations recursion support
|
||||
const client = createClientWithEventMapper();
|
||||
|
||||
// And a thread with an added event with a lower timestamp than its other events
|
||||
const userId = "user1";
|
||||
const { thread } = await createThreadAndEvent(client, 200, 100, userId);
|
||||
|
||||
// Then no receipt was added to the thread (the receipt is still
|
||||
// for the thread root). This happens because since we have no
|
||||
// recursive relations support, we know that sometimes events
|
||||
// appear out of order, so we have to check their timestamps as
|
||||
// a guess of the correct order.
|
||||
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId());
|
||||
});
|
||||
});
|
||||
|
||||
describe("With relations recursion support", () => {
|
||||
it("Creates a local echo receipt for new events", async () => {
|
||||
// Assumption: no server side support because if we have it, events
|
||||
// can only be added to the timeline after the thread has been
|
||||
// initialised, and we are not properly initialising it here.
|
||||
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
|
||||
|
||||
// Given a client WITH relations recursion support
|
||||
const client = createClientWithEventMapper(
|
||||
new Map([[Feature.RelationsRecursion, ServerSupport.Stable]]),
|
||||
);
|
||||
|
||||
// And a thread with an added event (with later timestamp)
|
||||
const userId = "user1";
|
||||
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
|
||||
|
||||
// Then a receipt was added to the thread
|
||||
const receipt = thread.getReadReceiptForUserId(userId);
|
||||
expect(receipt?.eventId).toEqual(message.getId());
|
||||
});
|
||||
|
||||
it("Creates a local echo receipt even for events BEFORE an existing receipt", async () => {
|
||||
// Assumption: no server side support because if we have it, events
|
||||
// can only be added to the timeline after the thread has been
|
||||
// initialised, and we are not properly initialising it here.
|
||||
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
|
||||
|
||||
// Given a client WITH relations recursion support
|
||||
const client = createClientWithEventMapper(
|
||||
new Map([[Feature.RelationsRecursion, ServerSupport.Stable]]),
|
||||
);
|
||||
|
||||
// And a thread with an added event with a lower timestamp than its other events
|
||||
const userId = "user1";
|
||||
const { thread, message } = await createThreadAndEvent(client, 200, 100, userId);
|
||||
|
||||
// Then a receipt was added to the thread, because relations
|
||||
// recursion is available, so we trust the server to have
|
||||
// provided us with events in the right order.
|
||||
const receipt = thread.getReadReceiptForUserId(userId);
|
||||
expect(receipt?.eventId).toEqual(message.getId());
|
||||
});
|
||||
});
|
||||
|
||||
async function createThreadAndEvent(
|
||||
client: MatrixClient,
|
||||
rootTs: number,
|
||||
eventTs: number,
|
||||
userId: string,
|
||||
): Promise<{ thread: Thread; message: MatrixEvent }> {
|
||||
const room = new Room("room1", client, userId);
|
||||
|
||||
// Given a thread
|
||||
const { thread } = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: userId,
|
||||
participantUserIds: [],
|
||||
ts: rootTs,
|
||||
});
|
||||
// Sanity: the current receipt is for the thread root
|
||||
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId());
|
||||
|
||||
const awaitTimelineEvent = new Promise<void>((res) => thread.on(RoomEvent.Timeline, () => res()));
|
||||
|
||||
// When we add a message that is before the latest receipt
|
||||
const message = makeThreadEvent({
|
||||
event: true,
|
||||
rootEventId: thread.id,
|
||||
replyToEventId: thread.id,
|
||||
user: userId,
|
||||
room: room.roomId,
|
||||
ts: eventTs,
|
||||
});
|
||||
await thread.addEvent(message, false, true);
|
||||
await awaitTimelineEvent;
|
||||
|
||||
return { thread, message };
|
||||
}
|
||||
|
||||
function createClientWithEventMapper(canSupport: Map<Feature, ServerSupport> = new Map()): MatrixClient {
|
||||
const client = mock(MatrixClient, "MatrixClient");
|
||||
client.reEmitter = mock(ReEmitter, "ReEmitter");
|
||||
client.canSupport = canSupport;
|
||||
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
|
||||
mocked(client.supportsThreads).mockReturnValue(true);
|
||||
return client;
|
||||
}
|
||||
});
|
||||
|
||||
describe("Editing events", () => {
|
||||
describe("Given server support for threads", () => {
|
||||
let previousThreadHasServerSideSupport: FeatureSupport;
|
||||
|
||||
beforeAll(() => {
|
||||
previousThreadHasServerSideSupport = Thread.hasServerSideSupport;
|
||||
Thread.hasServerSideSupport = FeatureSupport.Stable;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Thread.hasServerSideSupport = previousThreadHasServerSideSupport;
|
||||
});
|
||||
|
||||
it("Adds edits from sync to the thread timeline and applies them", async () => {
|
||||
// Given a thread
|
||||
const client = createClient();
|
||||
const user = "@alice:matrix.org";
|
||||
const room = "!room:z";
|
||||
const thread = await createThread(client, user, room);
|
||||
|
||||
// When a message and an edit are added to the thread
|
||||
const messageToEdit = createThreadMessage(thread.id, user, room, "Thread reply");
|
||||
const editEvent = mkEdit(messageToEdit, client, user, room, "edit");
|
||||
await thread.addEvent(messageToEdit, false);
|
||||
await thread.addEvent(editEvent, false);
|
||||
|
||||
// Then both events end up in the timeline
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
const secondLastEvent = thread.timeline.at(-2)!;
|
||||
expect(lastEvent).toBe(editEvent);
|
||||
expect(secondLastEvent).toBe(messageToEdit);
|
||||
|
||||
// And the first message has been edited
|
||||
expect(secondLastEvent.getContent().body).toEqual("edit");
|
||||
});
|
||||
|
||||
it("Adds edits fetched on demand to the thread timeline and applies them", async () => {
|
||||
// Given we don't support recursive relations
|
||||
const client = createClient(new Map([[Feature.RelationsRecursion, ServerSupport.Unsupported]]));
|
||||
// And we have a thread
|
||||
const user = "@alice:matrix.org";
|
||||
const room = "!room:z";
|
||||
const thread = await createThread(client, user, room);
|
||||
|
||||
// When a message is added to the thread, and an edit to it is provided on demand
|
||||
const messageToEdit = createThreadMessage(thread.id, user, room, "Thread reply");
|
||||
// (fetchEditsWhereNeeded only applies to encrypted messages for some reason)
|
||||
messageToEdit.event.type = EventType.RoomMessageEncrypted;
|
||||
const editEvent = mkEdit(messageToEdit, client, user, room, "edit");
|
||||
mocked(client.relations).mockImplementation(async (_roomId, eventId) => {
|
||||
if (eventId === messageToEdit.getId()) {
|
||||
return { events: [editEvent] };
|
||||
} else {
|
||||
return { events: [] };
|
||||
}
|
||||
});
|
||||
await thread.addEvent(messageToEdit, false);
|
||||
|
||||
// Then both events end up in the timeline
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
const secondLastEvent = thread.timeline.at(-2)!;
|
||||
expect(lastEvent).toBe(editEvent);
|
||||
expect(secondLastEvent).toBe(messageToEdit);
|
||||
|
||||
// And the first message has been edited
|
||||
expect(secondLastEvent.getContent().body).toEqual("edit");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a message event that lives in a thread
|
||||
*/
|
||||
function createThreadMessage(threadId: string, user: string, room: string, msg: string): MatrixEvent {
|
||||
return makeThreadEvent({
|
||||
event: true,
|
||||
user,
|
||||
room,
|
||||
msg,
|
||||
rootEventId: threadId,
|
||||
replyToEventId: threadId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a thread and wait for it to be properly initialised (so you can safely
|
||||
* add events to it and expect them to appear in the timeline.
|
||||
*/
|
||||
async function createThread(client: MatrixClient, user: string, roomId: string): Promise<Thread> {
|
||||
const root = mkMessage({ event: true, user, room: roomId, msg: "Thread root" });
|
||||
const room = new Room(roomId, client, "@roomcreator:x");
|
||||
|
||||
// Ensure the root is in the room timeline
|
||||
root.setThreadId(root.getId());
|
||||
await room.addLiveEvents([root]);
|
||||
|
||||
// Create the thread and wait for it to be initialised
|
||||
const thread = room.createThread(root.getId()!, root, [], false);
|
||||
await new Promise<void>((res) => thread.once(RoomEvent.TimelineReset, () => res()));
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MatrixClient that supports threads and has all the methods used when
|
||||
* creating a thread that call out to HTTP endpoints mocked out.
|
||||
*/
|
||||
function createClient(canSupport = new Map()): MatrixClient {
|
||||
const client = mock(MatrixClient, "MatrixClient");
|
||||
client.reEmitter = mock(ReEmitter, "ReEmitter");
|
||||
client.canSupport = canSupport;
|
||||
|
||||
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
|
||||
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
|
||||
|
||||
// Mock methods that call out to HTTP endpoints
|
||||
jest.spyOn(client, "paginateEventTimeline").mockResolvedValue(true);
|
||||
jest.spyOn(client, "relations").mockResolvedValue({ events: [] });
|
||||
jest.spyOn(client, "fetchRoomEvent").mockResolvedValue({});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -97,51 +97,6 @@ describe("NotificationService", function () {
|
||||
pattern: "foo*bar",
|
||||
rule_id: "foobar",
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
"notify",
|
||||
{
|
||||
set_tweak: "sound",
|
||||
value: "default",
|
||||
},
|
||||
{
|
||||
set_tweak: "highlight",
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
pattern: "p[io]ng",
|
||||
rule_id: "pingpong",
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
"notify",
|
||||
{
|
||||
set_tweak: "sound",
|
||||
value: "default",
|
||||
},
|
||||
{
|
||||
set_tweak: "highlight",
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
pattern: "I ate [0-9] pies",
|
||||
rule_id: "pies",
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
"notify",
|
||||
{
|
||||
set_tweak: "sound",
|
||||
value: "default",
|
||||
},
|
||||
{
|
||||
set_tweak: "highlight",
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
pattern: "b[!ai]ke",
|
||||
rule_id: "bakebike",
|
||||
},
|
||||
],
|
||||
override: [
|
||||
{
|
||||
@@ -289,39 +244,6 @@ describe("NotificationService", function () {
|
||||
expect(actions.tweaks.highlight).toEqual(true);
|
||||
});
|
||||
|
||||
// TODO: This is not spec compliant behaviour.
|
||||
//
|
||||
// See https://spec.matrix.org/v1.5/client-server-api/#conditions-1 which
|
||||
// describes pattern should glob:
|
||||
//
|
||||
// 1. * matches 0 or more characters;
|
||||
// 2. ? matches exactly one character
|
||||
it("should bing on character group ([abc]) bing words.", function () {
|
||||
testEvent.event.content!.body = "Ping!";
|
||||
let actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(true);
|
||||
testEvent.event.content!.body = "Pong!";
|
||||
actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(true);
|
||||
});
|
||||
|
||||
// TODO: This is not spec compliant behaviour. (See above.)
|
||||
it("should bing on character range ([a-z]) bing words.", function () {
|
||||
testEvent.event.content!.body = "I ate 6 pies";
|
||||
const actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(true);
|
||||
});
|
||||
|
||||
// TODO: This is not spec compliant behaviour. (See above.)
|
||||
it("should bing on character negation ([!a]) bing words.", function () {
|
||||
testEvent.event.content!.body = "boke";
|
||||
let actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(true);
|
||||
testEvent.event.content!.body = "bake";
|
||||
actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(false);
|
||||
});
|
||||
|
||||
it("should not bing on room server ACL changes", function () {
|
||||
testEvent = utils.mkEvent({
|
||||
type: EventType.RoomServerAcl,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
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 { ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { ReceiptAccumulator } from "../../src/receipt-accumulator";
|
||||
|
||||
const roomId = "!foo:bar";
|
||||
|
||||
describe("ReceiptAccumulator", function () {
|
||||
/*
|
||||
* Note: at the time of writing, the ReceiptAccumulator uses the order of
|
||||
* events from sync as the determinant of which one is most recent. This
|
||||
* is correct, but inconsistent with other areas of the code, since we
|
||||
* don't persist this order, so in other places we are forced to use the
|
||||
* timestamp of events and hope that this matches up.
|
||||
*
|
||||
* The tests in this file provide ts values that are consistent with the
|
||||
* sync order, so they should still pass if we change to using ts to
|
||||
* determine order.
|
||||
*
|
||||
* Ideally, we would keep track of sync order so we can use it everywhere.
|
||||
* This would mean we are consistent with how the homeserver sees receipts
|
||||
* and notifications.
|
||||
*/
|
||||
|
||||
it("Discards previous unthreaded receipts for the same user", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1);
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2);
|
||||
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
expect(newEvent).toEqual(newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2));
|
||||
});
|
||||
|
||||
it("Discards previous threaded receipts for the same user in the same thread", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread1");
|
||||
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
expect(newEvent).toEqual(newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread1"));
|
||||
});
|
||||
|
||||
it("Collects multiple receipts for the same user if they are in different threads", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread2");
|
||||
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
expect(newEvent).toEqual(
|
||||
newMultiReceipt([
|
||||
["$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1"],
|
||||
["$event2", ReceiptType.Read, "@alice:localhost", 2, "thread2"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Collects multiple receipts for different users", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@bobby:localhost", 2, "thread1");
|
||||
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
expect(newEvent).toEqual(
|
||||
newMultiReceipt([
|
||||
["$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1"],
|
||||
["$event2", ReceiptType.Read, "@bobby:localhost", 2, "thread1"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Collects last receipt for various users and threads", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
// Below, if ts=1, this receipt is going to be superceded by another
|
||||
// with ts=2
|
||||
const receipts = [
|
||||
newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1),
|
||||
newReceipt("$event2", ReceiptType.Read, "@bobby:localhost", 1, "thread1"),
|
||||
newReceipt("$event3", ReceiptType.Read, "@alice:localhost", 1, "thread1"),
|
||||
newReceipt("$event4", ReceiptType.Read, "@bobby:localhost", 1),
|
||||
newReceipt("$event5", ReceiptType.Read, "@bobby:localhost", 2),
|
||||
newReceipt("$event6", ReceiptType.Read, "@alice:localhost", 2),
|
||||
newReceipt("$event7", ReceiptType.Read, "@bobby:localhost", 2, "thread1"),
|
||||
newReceipt("$event8", ReceiptType.Read, "@bobby:localhost", 1, "thread2"),
|
||||
newReceipt("$event9", ReceiptType.Read, "@bobby:localhost", 2, "thread2"),
|
||||
newReceipt("$eventA", ReceiptType.Read, "@alice:localhost", 2, "thread1"),
|
||||
newReceipt("$eventB", ReceiptType.Read, "@alice:localhost", 1, "thread2"),
|
||||
newReceipt("$eventC", ReceiptType.Read, "@alice:localhost", 2, "thread2"),
|
||||
];
|
||||
|
||||
acc.consumeEphemeralEvents(receipts);
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
|
||||
// Only the ts=2 receipts make it through
|
||||
expect(newEvent).toEqual(
|
||||
newMultiReceipt([
|
||||
["$event5", ReceiptType.Read, "@bobby:localhost", 2, undefined],
|
||||
["$event6", ReceiptType.Read, "@alice:localhost", 2, undefined],
|
||||
["$event7", ReceiptType.Read, "@bobby:localhost", 2, "thread1"],
|
||||
["$event9", ReceiptType.Read, "@bobby:localhost", 2, "thread2"],
|
||||
["$eventA", ReceiptType.Read, "@alice:localhost", 2, "thread1"],
|
||||
["$eventC", ReceiptType.Read, "@alice:localhost", 2, "thread2"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Keeps main thread receipts even when an unthreaded receipt came later", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
// Given receipts for the special thread "main" and also unthreaded
|
||||
// receipts (which have no thread id).
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "main");
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2);
|
||||
|
||||
// When we collect them
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
|
||||
// We preserve both: thread:main and unthreaded receipts are different
|
||||
// things, with different meanings.
|
||||
expect(newEvent).toEqual(
|
||||
newMultiReceipt([
|
||||
["$event1", ReceiptType.Read, "@alice:localhost", 1, "main"],
|
||||
["$event2", ReceiptType.Read, "@alice:localhost", 2, undefined],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Keeps unthreaded receipts even when a main thread receipt came later", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
// Given receipts for the special thread "main" and also unthreaded
|
||||
// receipts (which have no thread id).
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1);
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "main");
|
||||
|
||||
// When we collect them
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
|
||||
// We preserve both: thread:main and unthreaded receipts are different
|
||||
// things, with different meanings.
|
||||
expect(newEvent).toEqual(
|
||||
newMultiReceipt([
|
||||
["$event1", ReceiptType.Read, "@alice:localhost", 1, undefined],
|
||||
["$event2", ReceiptType.Read, "@alice:localhost", 2, "main"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const newReceipt = (
|
||||
eventId: string,
|
||||
receiptType: ReceiptType,
|
||||
userId: string,
|
||||
ts: number,
|
||||
threadId: string | undefined = undefined,
|
||||
) => {
|
||||
return {
|
||||
type: "m.receipt",
|
||||
room_id: roomId,
|
||||
content: {
|
||||
[eventId]: {
|
||||
[receiptType]: {
|
||||
[userId]: { ts, thread_id: threadId },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const newMultiReceipt = (infoArray: Array<[string, ReceiptType, string, number, string | undefined]>) => {
|
||||
return {
|
||||
type: "m.receipt",
|
||||
room_id: roomId,
|
||||
content: Object.fromEntries(
|
||||
infoArray.map(([eventId, receiptType, userId, ts, threadId]) => {
|
||||
return [
|
||||
eventId,
|
||||
{
|
||||
[receiptType]: {
|
||||
[userId]: { ts, thread_id: threadId },
|
||||
},
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
};
|
||||
};
|
||||
+352
-224
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
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 { Mocked } from "jest-mock";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { CrossSigningIdentity } from "../../../src/rust-crypto/CrossSigningIdentity";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
|
||||
describe("CrossSigningIdentity", () => {
|
||||
describe("bootstrapCrossSigning", () => {
|
||||
/** the CrossSigningIdentity implementation under test */
|
||||
let crossSigning: CrossSigningIdentity;
|
||||
|
||||
/** a mocked-up OlmMachine which crossSigning is connected to */
|
||||
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
/** A mock OutgoingRequestProcessor which crossSigning is connected to */
|
||||
let outgoingRequestProcessor: Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
|
||||
olmMachine = {
|
||||
crossSigningStatus: jest.fn(),
|
||||
bootstrapCrossSigning: jest.fn(),
|
||||
close: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
outgoingRequestProcessor = {
|
||||
makeOutgoingRequest: jest.fn(),
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor);
|
||||
});
|
||||
|
||||
it("should do nothing if keys are present on-device and in secret storage", async () => {
|
||||
olmMachine.crossSigningStatus.mockResolvedValue({
|
||||
hasMaster: true,
|
||||
hasSelfSigning: true,
|
||||
hasUserSigning: true,
|
||||
});
|
||||
// TODO: secret storage
|
||||
await crossSigning.bootstrapCrossSigning({});
|
||||
expect(olmMachine.bootstrapCrossSigning).not.toHaveBeenCalled();
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call bootstrapCrossSigning if a reset is forced", async () => {
|
||||
olmMachine.bootstrapCrossSigning.mockResolvedValue([]);
|
||||
await crossSigning.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
||||
expect(olmMachine.bootstrapCrossSigning).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should call bootstrapCrossSigning if we need new keys", async () => {
|
||||
olmMachine.crossSigningStatus.mockResolvedValue({
|
||||
hasMaster: false,
|
||||
hasSelfSigning: false,
|
||||
hasUserSigning: false,
|
||||
});
|
||||
olmMachine.bootstrapCrossSigning.mockResolvedValue([]);
|
||||
await crossSigning.bootstrapCrossSigning({});
|
||||
expect(olmMachine.bootstrapCrossSigning).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,11 +24,12 @@ import {
|
||||
KeysUploadRequest,
|
||||
RoomMessageRequest,
|
||||
SignatureUploadRequest,
|
||||
SigningKeysUploadRequest,
|
||||
ToDeviceRequest,
|
||||
} from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
|
||||
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi } from "../../../src";
|
||||
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi, UIAuthCallback } from "../../../src";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
|
||||
describe("OutgoingRequestProcessor", () => {
|
||||
@@ -80,6 +81,12 @@ describe("OutgoingRequestProcessor", () => {
|
||||
"https://example.com/_matrix/client/v3/keys/signatures/upload",
|
||||
],
|
||||
["KeysBackupRequest", KeysBackupRequest, "PUT", "https://example.com/_matrix/client/v3/room_keys/keys"],
|
||||
[
|
||||
"SigningKeysUploadRequest",
|
||||
SigningKeysUploadRequest,
|
||||
"POST",
|
||||
"https://example.com/_matrix/client/v3/keys/device_signing/upload",
|
||||
],
|
||||
];
|
||||
|
||||
test.each(tests)(`should handle %ss`, async (_, RequestClass, expectedMethod, expectedPath) => {
|
||||
@@ -171,6 +178,40 @@ describe("OutgoingRequestProcessor", () => {
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
it("should handle SigningKeysUploadRequests with UIA", async () => {
|
||||
// first, mock up a request as we might expect to receive it from the Rust layer ...
|
||||
const testReq = { foo: "bar" };
|
||||
const outgoingRequest = new SigningKeysUploadRequest("1234", JSON.stringify(testReq));
|
||||
|
||||
// also create a UIA callback
|
||||
const authCallback: UIAuthCallback<Object> = async (makeRequest) => {
|
||||
return await makeRequest({ type: "test" });
|
||||
};
|
||||
|
||||
// ... then poke the request into the OutgoingRequestProcessor under test
|
||||
const reqProm = processor.makeOutgoingRequest(outgoingRequest, authCallback);
|
||||
|
||||
// Now: check that it makes a matching HTTP request ...
|
||||
const testResponse = '{"result":1}';
|
||||
httpBackend
|
||||
.when("POST", "/_matrix")
|
||||
.check((req) => {
|
||||
expect(req.path).toEqual("https://example.com/_matrix/client/v3/keys/device_signing/upload");
|
||||
expect(JSON.parse(req.rawData)).toEqual({ foo: "bar", auth: { type: "test" } });
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
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 { DeviceKeys, DeviceVerification } from "../../../src";
|
||||
import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
|
||||
|
||||
describe("device-converter", () => {
|
||||
const userId = "@alice:example.com";
|
||||
const deviceId = "xcvf";
|
||||
|
||||
// All parameters for QueryDevice initialization
|
||||
const keys = {
|
||||
[`ed25519:${deviceId}`]: "key1",
|
||||
[`curve25519:${deviceId}`]: "key2",
|
||||
};
|
||||
const algorithms = ["algo1", "algo2"];
|
||||
const signatures = { [userId]: { [deviceId]: "sign1" } };
|
||||
const displayName = "display name";
|
||||
const unsigned = {
|
||||
device_display_name: displayName,
|
||||
};
|
||||
|
||||
describe("downloadDeviceToJsDevice", () => {
|
||||
it("should convert a QueryDevice to a Device", () => {
|
||||
const queryDevice: DeviceKeys[keyof DeviceKeys] = {
|
||||
keys,
|
||||
algorithms,
|
||||
device_id: deviceId,
|
||||
user_id: userId,
|
||||
signatures,
|
||||
unsigned,
|
||||
};
|
||||
const device = downloadDeviceToJsDevice(queryDevice);
|
||||
|
||||
expect(device.deviceId).toBe(deviceId);
|
||||
expect(device.userId).toBe(userId);
|
||||
expect(device.verified).toBe(DeviceVerification.Unverified);
|
||||
expect(device.getIdentityKey()).toBe(keys[`curve25519:${deviceId}`]);
|
||||
expect(device.getFingerprint()).toBe(keys[`ed25519:${deviceId}`]);
|
||||
expect(device.displayName).toBe(displayName);
|
||||
});
|
||||
|
||||
it("should add empty signatures", () => {
|
||||
const queryDevice: DeviceKeys[keyof DeviceKeys] = {
|
||||
keys,
|
||||
algorithms,
|
||||
device_id: deviceId,
|
||||
user_id: userId,
|
||||
};
|
||||
const device = downloadDeviceToJsDevice(queryDevice);
|
||||
|
||||
expect(device.signatures.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -22,11 +22,12 @@ import { Mocked } from "jest-mock";
|
||||
|
||||
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
|
||||
import { initRustCrypto } from "../../../src/rust-crypto";
|
||||
import { IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../../src";
|
||||
import { IHttpOpts, 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";
|
||||
import { ServerSideSecretStorage } from "../../../src/secret-storage";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -35,16 +36,15 @@ afterEach(() => {
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
describe("RustCrypto", () => {
|
||||
const TEST_USER = "@alice:example.com";
|
||||
const TEST_DEVICE_ID = "TEST_DEVICE";
|
||||
const TEST_USER = "@alice:example.com";
|
||||
const TEST_DEVICE_ID = "TEST_DEVICE";
|
||||
|
||||
describe("RustCrypto", () => {
|
||||
describe(".exportRoomKeys", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockHttpApi = {} as MatrixClient["http"];
|
||||
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
|
||||
rustCrypto = await makeTestRustCrypto();
|
||||
});
|
||||
|
||||
it("should return a list", async () => {
|
||||
@@ -57,8 +57,7 @@ describe("RustCrypto", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockHttpApi = {} as MatrixClient["http"];
|
||||
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
|
||||
rustCrypto = await makeTestRustCrypto();
|
||||
});
|
||||
|
||||
it("should pass through unencrypted to-device messages", async () => {
|
||||
@@ -94,6 +93,32 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("isCrossSigningReady", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await expect(rustCrypto.isCrossSigningReady()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("getCrossSigningKeyId", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await expect(rustCrypto.getCrossSigningKeyId()).resolves.toBe(null);
|
||||
});
|
||||
|
||||
it("bootstrapCrossSigning delegates to CrossSigningIdentity", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
const mockCrossSigningIdentity = {
|
||||
bootstrapCrossSigning: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
// @ts-ignore private property
|
||||
rustCrypto.crossSigningIdentity = mockCrossSigningIdentity;
|
||||
await rustCrypto.bootstrapCrossSigning({});
|
||||
expect(mockCrossSigningIdentity.bootstrapCrossSigning).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("isSecretStorageReady", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
describe("outgoing requests", () => {
|
||||
/** the RustCrypto implementation under test */
|
||||
let rustCrypto: RustCrypto;
|
||||
@@ -141,7 +166,13 @@ describe("RustCrypto", () => {
|
||||
makeOutgoingRequest: jest.fn(),
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
rustCrypto = new RustCrypto(olmMachine, {} as MatrixHttpApi<any>, TEST_USER, TEST_DEVICE_ID);
|
||||
rustCrypto = new RustCrypto(
|
||||
olmMachine,
|
||||
{} as MatrixHttpApi<any>,
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
);
|
||||
rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor;
|
||||
});
|
||||
|
||||
@@ -206,8 +237,7 @@ describe("RustCrypto", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockHttpApi = {} as MatrixClient["http"];
|
||||
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
|
||||
rustCrypto = await makeTestRustCrypto();
|
||||
});
|
||||
|
||||
it("should handle unencrypted events", () => {
|
||||
@@ -230,4 +260,74 @@ describe("RustCrypto", () => {
|
||||
expect(res.encrypted).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("get|setTrustCrossSignedDevices", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
rustCrypto = await makeTestRustCrypto();
|
||||
});
|
||||
|
||||
it("should be true by default", () => {
|
||||
expect(rustCrypto.getTrustCrossSignedDevices()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be easily turn-off-and-on-able", () => {
|
||||
rustCrypto.setTrustCrossSignedDevices(false);
|
||||
expect(rustCrypto.getTrustCrossSignedDevices()).toBe(false);
|
||||
rustCrypto.setTrustCrossSignedDevices(true);
|
||||
expect(rustCrypto.getTrustCrossSignedDevices()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDeviceVerificationStatus", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
beforeEach(() => {
|
||||
olmMachine = {
|
||||
getDevice: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
rustCrypto = new RustCrypto(
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call getDevice", async () => {
|
||||
olmMachine.getDevice.mockResolvedValue({
|
||||
isCrossSigningTrusted: jest.fn().mockReturnValue(false),
|
||||
isLocallyTrusted: jest.fn().mockReturnValue(false),
|
||||
isCrossSignedByOwner: jest.fn().mockReturnValue(false),
|
||||
} as unknown as RustSdkCryptoJs.Device);
|
||||
const res = await rustCrypto.getDeviceVerificationStatus("@user:domain", "device");
|
||||
expect(olmMachine.getDevice.mock.calls[0][0].toString()).toEqual("@user:domain");
|
||||
expect(olmMachine.getDevice.mock.calls[0][1].toString()).toEqual("device");
|
||||
expect(res?.crossSigningVerified).toBe(false);
|
||||
expect(res?.localVerified).toBe(false);
|
||||
expect(res?.signedByOwner).toBe(false);
|
||||
});
|
||||
|
||||
it("should return null for unknown device", async () => {
|
||||
olmMachine.getDevice.mockResolvedValue(undefined);
|
||||
const res = await rustCrypto.getDeviceVerificationStatus("@user:domain", "device");
|
||||
expect(res).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** build a basic RustCrypto instance for testing
|
||||
*
|
||||
* just provides default arguments for initRustCrypto()
|
||||
*/
|
||||
async function makeTestRustCrypto(
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }> = {} as MatrixClient["http"],
|
||||
userId: string = TEST_USER,
|
||||
deviceId: string = TEST_DEVICE_ID,
|
||||
secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage,
|
||||
): Promise<RustCrypto> {
|
||||
return await initRustCrypto(http, userId, deviceId, secretStorage);
|
||||
}
|
||||
|
||||
@@ -254,4 +254,31 @@ describe("IndexedDBStore", () => {
|
||||
});
|
||||
await expect(store.startup()).rejects.toThrow("Test");
|
||||
});
|
||||
|
||||
it("remote worker should terminate upon destroy call", async () => {
|
||||
const terminate = jest.fn();
|
||||
const worker = new (class MockWorker {
|
||||
private onmessage!: (data: any) => void;
|
||||
postMessage(data: any) {
|
||||
this.onmessage({
|
||||
data: {
|
||||
command: "cmd_success",
|
||||
seq: data.seq,
|
||||
result: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
public terminate = terminate;
|
||||
})() as unknown as Worker;
|
||||
|
||||
const store = new IndexedDBStore({
|
||||
indexedDB: indexedDB,
|
||||
dbName: "database",
|
||||
localStorage,
|
||||
workerFactory: () => worker,
|
||||
});
|
||||
await store.startup();
|
||||
await expect(store.destroy()).resolves;
|
||||
expect(terminate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -356,6 +356,77 @@ describe("SyncAccumulator", function () {
|
||||
});
|
||||
});
|
||||
|
||||
it("can handle large numbers of identical receipts", () => {
|
||||
const testSize = 1000; // Make this big to check performance (e.g. 10 million ~= 10s)
|
||||
|
||||
const newReceipt = (ts: number) => {
|
||||
return {
|
||||
type: "m.receipt",
|
||||
room_id: "!foo:bar",
|
||||
content: {
|
||||
"$event1:localhost": {
|
||||
[ReceiptType.Read]: {
|
||||
"@alice:localhost": { ts },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const receipts = [];
|
||||
for (let i = 0; i < testSize; i++) {
|
||||
receipts.push(newReceipt(testSize - i));
|
||||
}
|
||||
|
||||
sa.accumulate(
|
||||
syncSkeleton({
|
||||
ephemeral: {
|
||||
events: receipts,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const events = sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events;
|
||||
expect(events.length).toEqual(1);
|
||||
expect(events[0]).toEqual(newReceipt(1));
|
||||
});
|
||||
|
||||
it("can handle large numbers of receipts for different users and events", () => {
|
||||
const testSize = 100; // Make this big to check performance (e.g. 1 million ~= 10s)
|
||||
|
||||
const newReceipt = (ts: number) => {
|
||||
return {
|
||||
type: "m.receipt",
|
||||
room_id: "!foo:bar",
|
||||
content: {
|
||||
[`$event${ts}:localhost`]: {
|
||||
[ReceiptType.Read]: {
|
||||
[`@alice${ts}:localhost`]: { ts },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const receipts = [];
|
||||
for (let i = 0; i < testSize; i++) {
|
||||
receipts.push(newReceipt(testSize - i));
|
||||
}
|
||||
|
||||
sa.accumulate(
|
||||
syncSkeleton({
|
||||
ephemeral: {
|
||||
events: receipts,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const events = sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events;
|
||||
expect(events.length).toEqual(1);
|
||||
expect(events[0]["content"]["$event1:localhost"]).toEqual({ "m.read": { "@alice1:localhost": { ts: 1 } } });
|
||||
expect(Object.keys(events[0]["content"]).length).toEqual(testSize);
|
||||
});
|
||||
|
||||
it("should accumulate threaded read receipts", () => {
|
||||
const receipt1 = {
|
||||
type: "m.receipt",
|
||||
@@ -474,6 +545,25 @@ describe("SyncAccumulator", function () {
|
||||
expect(summary["m.heroes"]).toEqual(["@bob:bar"]);
|
||||
});
|
||||
|
||||
it("should correctly update summary properties to zero", function () {
|
||||
// When we receive updates of a summary property, the last of which is 0
|
||||
sa.accumulate(
|
||||
createSyncResponseWithSummary({
|
||||
"m.heroes": ["@alice:bar"],
|
||||
"m.invited_member_count": 2,
|
||||
}),
|
||||
);
|
||||
sa.accumulate(
|
||||
createSyncResponseWithSummary({
|
||||
"m.heroes": ["@alice:bar"],
|
||||
"m.invited_member_count": 0,
|
||||
}),
|
||||
);
|
||||
const summary = sa.getJSON().roomsData.join["!foo:bar"].summary;
|
||||
// Then we give an answer of 0
|
||||
expect(summary["m.invited_member_count"]).toEqual(0);
|
||||
});
|
||||
|
||||
it("should return correctly adjusted age attributes", () => {
|
||||
const delta = 1000;
|
||||
const startingTs = 1000;
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
sortEventsByLatestContentTimestamp,
|
||||
safeSet,
|
||||
MapWithDefault,
|
||||
globToRegexp,
|
||||
escapeRegExp,
|
||||
} from "../../src/utils";
|
||||
import { logger } from "../../src/logger";
|
||||
import { mkMessage } from "../test-utils/test-utils";
|
||||
@@ -725,4 +727,19 @@ describe("utils", function () {
|
||||
await utils.immediate();
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeRegExp", () => {
|
||||
it("should escape XYZ", () => {
|
||||
expect(escapeRegExp("[FIT-Connect Zustelldienst \\(Testumgebung\\)]")).toMatchInlineSnapshot(
|
||||
`"\\[FIT-Connect Zustelldienst \\\\\\(Testumgebung\\\\\\)\\]"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("globToRegexp", () => {
|
||||
it("should not explode when given regexes as globs", () => {
|
||||
const result = globToRegexp("[FIT-Connect Zustelldienst \\(Testumgebung\\)]");
|
||||
expect(result).toMatchInlineSnapshot(`"\\[FIT-Connect Zustelldienst \\\\\\(Testumgebung\\\\\\)\\]"`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
CallType,
|
||||
CallState,
|
||||
CallParty,
|
||||
CallDirection,
|
||||
} from "../../../src/webrtc/call";
|
||||
import {
|
||||
MCallAnswer,
|
||||
@@ -1053,14 +1054,7 @@ describe("Call", function () {
|
||||
|
||||
mockSendEvent.mockReset();
|
||||
|
||||
let caught = false;
|
||||
try {
|
||||
call.reject();
|
||||
} catch (e) {
|
||||
caught = true;
|
||||
}
|
||||
|
||||
expect(caught).toEqual(true);
|
||||
expect(() => call.reject()).toThrow();
|
||||
expect(client.client.sendEvent).not.toHaveBeenCalled();
|
||||
|
||||
call.hangup(CallErrorCode.UserHangup, true);
|
||||
@@ -1652,12 +1646,18 @@ describe("Call", function () {
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(call, "hangup");
|
||||
|
||||
await fakeIncomingCall(client, call, "1");
|
||||
|
||||
mockPeerConn = call.peerConn as unknown as MockRTCPeerConnection;
|
||||
|
||||
mockPeerConn.iceConnectionState = "disconnected";
|
||||
mockPeerConn.iceConnectionStateChangeListener!();
|
||||
jest.spyOn(mockPeerConn, "restartIce");
|
||||
});
|
||||
|
||||
it("should restart ICE gathering after being disconnected for 2 seconds", () => {
|
||||
jest.advanceTimersByTime(3 * 1000);
|
||||
expect(mockPeerConn.restartIce).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should hang up after being disconnected for 30 seconds", () => {
|
||||
@@ -1665,6 +1665,20 @@ describe("Call", function () {
|
||||
expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false);
|
||||
});
|
||||
|
||||
it("should restart ICE gathering once again after ICE being failed", () => {
|
||||
mockPeerConn.iceConnectionState = "failed";
|
||||
mockPeerConn.iceConnectionStateChangeListener!();
|
||||
expect(mockPeerConn.restartIce).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call hangup after ICE being failed and if there not exists a restartIce method", () => {
|
||||
// @ts-ignore
|
||||
mockPeerConn.restartIce = null;
|
||||
mockPeerConn.iceConnectionState = "failed";
|
||||
mockPeerConn.iceConnectionStateChangeListener!();
|
||||
expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false);
|
||||
});
|
||||
|
||||
it("should not hangup if we've managed to re-connect", () => {
|
||||
mockPeerConn.iceConnectionState = "connected";
|
||||
mockPeerConn.iceConnectionStateChangeListener!();
|
||||
@@ -1692,4 +1706,110 @@ describe("Call", function () {
|
||||
expect(onReplace).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe("should handle glare in negotiation process", () => {
|
||||
beforeEach(async () => {
|
||||
// cut methods not want to test
|
||||
call.hangup = () => null;
|
||||
call.isLocalOnHold = () => true;
|
||||
// @ts-ignore
|
||||
call.updateRemoteSDPStreamMetadata = jest.fn();
|
||||
// @ts-ignore
|
||||
call.getRidOfRTXCodecs = jest.fn();
|
||||
// @ts-ignore
|
||||
call.createAnswer = jest.fn().mockResolvedValue({});
|
||||
// @ts-ignore
|
||||
call.sendVoipEvent = jest.fn();
|
||||
});
|
||||
|
||||
it("and reject remote offer if not polite and have pending local offer", async () => {
|
||||
// not polite user == CallDirection.Outbound
|
||||
call.direction = CallDirection.Outbound;
|
||||
// have already a local offer
|
||||
// @ts-ignore
|
||||
call.makingOffer = true;
|
||||
const offerEvent = makeMockEvent("@test:foo", {
|
||||
description: {
|
||||
type: "offer",
|
||||
sdp: DUMMY_SDP,
|
||||
},
|
||||
});
|
||||
// @ts-ignore
|
||||
call.peerConn = {
|
||||
signalingState: "have-local-offer",
|
||||
setRemoteDescription: jest.fn(),
|
||||
};
|
||||
await call.onNegotiateReceived(offerEvent);
|
||||
expect(call.peerConn?.setRemoteDescription).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("and not reject remote offer if not polite and do have pending answer", async () => {
|
||||
// not polite user == CallDirection.Outbound
|
||||
call.direction = CallDirection.Outbound;
|
||||
// have not a local offer
|
||||
// @ts-ignore
|
||||
call.makingOffer = false;
|
||||
|
||||
// If we have a setRemoteDescription() answer operation pending, then
|
||||
// we will be "stable" by the time the next setRemoteDescription() is
|
||||
// executed, so we count this being readyForOffer when deciding whether to
|
||||
// ignore the offer.
|
||||
// @ts-ignore
|
||||
call.isSettingRemoteAnswerPending = true;
|
||||
const offerEvent = makeMockEvent("@test:foo", {
|
||||
description: {
|
||||
type: "offer",
|
||||
sdp: DUMMY_SDP,
|
||||
},
|
||||
});
|
||||
// @ts-ignore
|
||||
call.peerConn = {
|
||||
signalingState: "have-local-offer",
|
||||
setRemoteDescription: jest.fn(),
|
||||
};
|
||||
await call.onNegotiateReceived(offerEvent);
|
||||
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("and not reject remote offer if not polite and do not have pending local offer", async () => {
|
||||
// not polite user == CallDirection.Outbound
|
||||
call.direction = CallDirection.Outbound;
|
||||
// have no local offer
|
||||
// @ts-ignore
|
||||
call.makingOffer = false;
|
||||
const offerEvent = makeMockEvent("@test:foo", {
|
||||
description: {
|
||||
type: "offer",
|
||||
sdp: DUMMY_SDP,
|
||||
},
|
||||
});
|
||||
// @ts-ignore
|
||||
call.peerConn = {
|
||||
signalingState: "stable",
|
||||
setRemoteDescription: jest.fn(),
|
||||
};
|
||||
await call.onNegotiateReceived(offerEvent);
|
||||
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("and if polite do rollback pending local offer", async () => {
|
||||
// polite user == CallDirection.Inbound
|
||||
call.direction = CallDirection.Inbound;
|
||||
// have already a local offer
|
||||
// @ts-ignore
|
||||
call.makingOffer = true;
|
||||
const offerEvent = makeMockEvent("@test:foo", {
|
||||
description: {
|
||||
type: "offer",
|
||||
sdp: DUMMY_SDP,
|
||||
},
|
||||
});
|
||||
// @ts-ignore
|
||||
call.peerConn = {
|
||||
signalingState: "have-local-offer",
|
||||
setRemoteDescription: jest.fn(),
|
||||
};
|
||||
await call.onNegotiateReceived(offerEvent);
|
||||
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,10 +186,7 @@ describe("Group Call", function () {
|
||||
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) {}
|
||||
|
||||
await expect(groupCall.initLocalCallFeed()).rejects.toBeTruthy();
|
||||
expect(groupCall.state).toBe(GroupCallState.LocalCallFeedUninitialized);
|
||||
});
|
||||
|
||||
@@ -388,6 +385,10 @@ describe("Group Call", function () {
|
||||
call = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
||||
|
||||
await groupCall.create();
|
||||
|
||||
const deviceCallMap = new Map<string, MatrixCall>();
|
||||
deviceCallMap.set(FAKE_DEVICE_ID_1, call.typed());
|
||||
(groupCall as any).calls.set(FAKE_USER_ID_1, deviceCallMap);
|
||||
});
|
||||
|
||||
it("ignores changes, if we can't get user id of opponent", async () => {
|
||||
@@ -513,8 +514,7 @@ describe("Group Call", function () {
|
||||
await groupCall.setMicrophoneMuted(false);
|
||||
expect(groupCall.isMicrophoneMuted()).toEqual(false);
|
||||
|
||||
jest.advanceTimersByTime(groupCall.pttMaxTransmitTime + 100);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(groupCall.pttMaxTransmitTime + 100);
|
||||
expect(groupCall.isMicrophoneMuted()).toEqual(true);
|
||||
});
|
||||
|
||||
@@ -581,7 +581,15 @@ describe("Group Call", function () {
|
||||
});
|
||||
mockCall.sendMetadataUpdate = jest.fn().mockReturnValue(metadataUpdatePromise);
|
||||
|
||||
const getUserMediaStreamFlush = Promise.resolve("stream");
|
||||
// @ts-ignore
|
||||
mockCall.cleint = {
|
||||
getMediaHandler: {
|
||||
getUserMediaStream: jest.fn().mockReturnValue(getUserMediaStreamFlush),
|
||||
},
|
||||
};
|
||||
const mutePromise = groupCall.setMicrophoneMuted(true);
|
||||
await getUserMediaStreamFlush;
|
||||
// we should be muted at this point, before the metadata update has been sent
|
||||
expect(groupCall.isMicrophoneMuted()).toEqual(true);
|
||||
expect(mockCall.localUsermediaFeed.setAudioVideoMuted).toHaveBeenCalled();
|
||||
@@ -888,14 +896,34 @@ describe("Group Call", function () {
|
||||
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no permission for audio stream", async () => {
|
||||
it("returns false when no permission for audio stream and localCallFeed do not have an audio track", async () => {
|
||||
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
||||
// @ts-ignore
|
||||
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(false);
|
||||
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValueOnce(
|
||||
new Error("No Permission"),
|
||||
);
|
||||
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when user media stream null", async () => {
|
||||
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
||||
// @ts-ignore
|
||||
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(false);
|
||||
// @ts-ignore
|
||||
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockResolvedValue({} as MediaStream);
|
||||
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when no permission for audio stream but localCallFeed has a audio track already", async () => {
|
||||
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
||||
// @ts-ignore
|
||||
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(true);
|
||||
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream");
|
||||
expect(mockClient.getMediaHandler().getUserMediaStream).not.toHaveBeenCalled();
|
||||
expect(await groupCall.setMicrophoneMuted(false)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when unmuting video with no video device", async () => {
|
||||
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
||||
jest.spyOn(mockClient.getMediaHandler(), "hasVideoDevice").mockResolvedValue(false);
|
||||
@@ -1625,4 +1653,77 @@ describe("Group Call", function () {
|
||||
expect(room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, FAKE_USER_ID_2)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collection stats", () => {
|
||||
let groupCall: GroupCall;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(0);
|
||||
});
|
||||
|
||||
afterAll(() => jest.useRealTimers());
|
||||
|
||||
beforeEach(async () => {
|
||||
const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1);
|
||||
const mockClient = typedMockClient.typed();
|
||||
const room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
|
||||
groupCall = new GroupCall(
|
||||
mockClient,
|
||||
room,
|
||||
GroupCallType.Video,
|
||||
false,
|
||||
GroupCallIntent.Prompt,
|
||||
FAKE_CONF_ID,
|
||||
);
|
||||
});
|
||||
it("should be undefined if not get stats", async () => {
|
||||
// @ts-ignore
|
||||
const stats = groupCall.stats;
|
||||
expect(stats).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should be defined after first access", async () => {
|
||||
groupCall.getGroupCallStats();
|
||||
// @ts-ignore
|
||||
const stats = groupCall.stats;
|
||||
expect(stats).toBeDefined();
|
||||
});
|
||||
|
||||
it("with every number should do nothing if no stats exists.", async () => {
|
||||
groupCall.setGroupCallStatsInterval(0);
|
||||
// @ts-ignore
|
||||
let stats = groupCall.stats;
|
||||
expect(stats).toBeUndefined();
|
||||
|
||||
groupCall.setGroupCallStatsInterval(10000);
|
||||
// @ts-ignore
|
||||
stats = groupCall.stats;
|
||||
expect(stats).toBeUndefined();
|
||||
});
|
||||
|
||||
it("with number should stop existing stats", async () => {
|
||||
const stats = groupCall.getGroupCallStats();
|
||||
// @ts-ignore
|
||||
const stop = jest.spyOn(stats, "stop");
|
||||
// @ts-ignore
|
||||
const start = jest.spyOn(stats, "start");
|
||||
groupCall.setGroupCallStatsInterval(0);
|
||||
|
||||
expect(stop).toHaveBeenCalled();
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("with number should restart existing stats", async () => {
|
||||
const stats = groupCall.getGroupCallStats();
|
||||
// @ts-ignore
|
||||
const stop = jest.spyOn(stats, "stop");
|
||||
// @ts-ignore
|
||||
const start = jest.spyOn(stats, "start");
|
||||
groupCall.setGroupCallStatsInterval(10000);
|
||||
|
||||
expect(stop).toHaveBeenCalled();
|
||||
expect(start).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
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 { CallStatsReportGatherer } from "../../../../src/webrtc/stats/callStatsReportGatherer";
|
||||
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
|
||||
import { MediaSsrcHandler } from "../../../../src/webrtc/stats/media/mediaSsrcHandler";
|
||||
|
||||
const CALL_ID = "CALL_ID";
|
||||
const USER_ID = "USER_ID";
|
||||
|
||||
describe("CallStatsReportGatherer", () => {
|
||||
let collector: CallStatsReportGatherer;
|
||||
let rtcSpy: RTCPeerConnection;
|
||||
let emitter: StatsReportEmitter;
|
||||
beforeEach(() => {
|
||||
rtcSpy = { getStats: () => new Promise<RTCStatsReport>(() => null) } as RTCPeerConnection;
|
||||
rtcSpy.addEventListener = jest.fn();
|
||||
emitter = new StatsReportEmitter();
|
||||
collector = new CallStatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
|
||||
});
|
||||
|
||||
describe("on process stats", () => {
|
||||
it("if active calculate stats reports", async () => {
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
const report = {} as RTCStatsReport;
|
||||
report.forEach = jest.fn().mockReturnValue([]);
|
||||
getStats.mockResolvedValue(report);
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(actual).toEqual({
|
||||
isFirstCollection: true,
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
expect(collector.getActive()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("if not active do not calculate stats reports", async () => {
|
||||
collector.setActive(false);
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(getStats).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("if get reports fails, the collector becomes inactive", async () => {
|
||||
expect(collector.getActive()).toBeTruthy();
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
getStats.mockRejectedValue(new Error("unknown"));
|
||||
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(collector.getActive()).toBeFalsy();
|
||||
});
|
||||
|
||||
it("if active and getStats returns not an RTCStatsReport inside a promise the collector fails and becomes inactive", async () => {
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
// @ts-ignore
|
||||
getStats.mockReturnValue({});
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(actual).toEqual({
|
||||
isFirstCollection: true,
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(collector.getActive()).toBeFalsy();
|
||||
});
|
||||
|
||||
it("if active and the collector runs not the first time the Summery Stats is marked as not fits collection", async () => {
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
// @ts-ignore
|
||||
collector.previousStatsReport = {} as RTCStatsReport;
|
||||
const report = {} as RTCStatsReport;
|
||||
report.forEach = jest.fn().mockReturnValue([]);
|
||||
getStats.mockResolvedValue(report);
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(actual).toEqual({
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
expect(collector.getActive()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("on signal state change event", () => {
|
||||
let events: { [key: string]: any };
|
||||
beforeEach(() => {
|
||||
events = [];
|
||||
// Define the addEventListener method with a Jest mock function
|
||||
rtcSpy.addEventListener = jest.fn((event: any, callback: any) => {
|
||||
events[event] = callback;
|
||||
});
|
||||
|
||||
collector = new CallStatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
|
||||
});
|
||||
it("in case of stable, parse remote and local description", () => {
|
||||
// @ts-ignore
|
||||
const mediaSsrcHandler = {
|
||||
parse: jest.fn(),
|
||||
ssrcToMid: jest.fn(),
|
||||
findMidBySsrc: jest.fn(),
|
||||
getSsrcToMidMap: jest.fn(),
|
||||
} as MediaSsrcHandler;
|
||||
|
||||
const remoteSDP = "sdp";
|
||||
const localSDP = "sdp";
|
||||
|
||||
// @ts-ignore
|
||||
rtcSpy.signalingState = "stable";
|
||||
|
||||
// @ts-ignore
|
||||
rtcSpy.currentRemoteDescription = <RTCSessionDescription>{ sdp: remoteSDP };
|
||||
// @ts-ignore
|
||||
rtcSpy.currentLocalDescription = <RTCSessionDescription>{ sdp: localSDP };
|
||||
|
||||
// @ts-ignore
|
||||
collector.trackStats.mediaSsrcHandler = mediaSsrcHandler;
|
||||
|
||||
events["signalingstatechange"]();
|
||||
expect(mediaSsrcHandler.parse).toHaveBeenCalledWith(remoteSDP, "remote");
|
||||
expect(mediaSsrcHandler.parse).toHaveBeenCalledWith(localSDP, "local");
|
||||
});
|
||||
});
|
||||
});
|
||||
+10
-2
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import { TrackID } from "../../../../src/webrtc/stats/statsReport";
|
||||
import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats";
|
||||
import { StatsReportBuilder } from "../../../../src/webrtc/stats/statsReportBuilder";
|
||||
import { ConnectionStatsReportBuilder } from "../../../../src/webrtc/stats/connectionStatsReportBuilder";
|
||||
|
||||
describe("StatsReportBuilder", () => {
|
||||
const LOCAL_VIDEO_TRACK_ID = "LOCAL_VIDEO_TRACK_ID";
|
||||
@@ -39,7 +39,7 @@ describe("StatsReportBuilder", () => {
|
||||
|
||||
describe("should build stats", () => {
|
||||
it("by media track stats.", async () => {
|
||||
expect(StatsReportBuilder.build(stats)).toEqual({
|
||||
expect(ConnectionStatsReportBuilder.build(stats)).toEqual({
|
||||
bitrate: {
|
||||
audio: {
|
||||
download: 4000,
|
||||
@@ -91,6 +91,13 @@ describe("StatsReportBuilder", () => {
|
||||
["REMOTE_AUDIO_TRACK_ID", 0.1],
|
||||
["REMOTE_VIDEO_TRACK_ID", 50],
|
||||
]),
|
||||
audioConcealment: new Map([
|
||||
["REMOTE_AUDIO_TRACK_ID", { concealedAudio: 3000, totalAudioDuration: 3000 * 20 }],
|
||||
]),
|
||||
totalAudioConcealment: {
|
||||
concealedAudio: 3000,
|
||||
totalAudioDuration: (1 / 0.05) * 3000,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -104,6 +111,7 @@ describe("StatsReportBuilder", () => {
|
||||
remoteAudioTrack.setLoss({ packetsTotal: 20, packetsLost: 0, isDownloadStream: true });
|
||||
remoteAudioTrack.setBitrate({ download: 4000, upload: 0 });
|
||||
remoteAudioTrack.setJitter(0.1);
|
||||
remoteAudioTrack.setAudioConcealment(3000, 3000 * 20);
|
||||
|
||||
localVideoTrack.setCodec("v8");
|
||||
localVideoTrack.setLoss({ packetsTotal: 30, packetsLost: 6, isDownloadStream: false });
|
||||
@@ -13,7 +13,7 @@ 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 { ConnectionStatsReporter } from "../../../../src/webrtc/stats/connectionStatsReporter";
|
||||
import { ConnectionStatsBuilder } from "../../../../src/webrtc/stats/connectionStatsBuilder";
|
||||
|
||||
describe("ConnectionStatsReporter", () => {
|
||||
describe("should on bandwidth stats", () => {
|
||||
@@ -22,11 +22,11 @@ describe("ConnectionStatsReporter", () => {
|
||||
availableIncomingBitrate: 1000,
|
||||
availableOutgoingBitrate: 2000,
|
||||
} as RTCIceCandidatePairStats;
|
||||
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
|
||||
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
|
||||
});
|
||||
it("build empty bandwidth report if chromium starts attributes not available", () => {
|
||||
const stats = {} as RTCIceCandidatePairStats;
|
||||
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
|
||||
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,11 +36,11 @@ describe("ConnectionStatsReporter", () => {
|
||||
availableIncomingBitrate: 1000,
|
||||
availableOutgoingBitrate: 2000,
|
||||
} as RTCIceCandidatePairStats;
|
||||
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
|
||||
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
|
||||
});
|
||||
it("build empty bandwidth report if chromium starts attributes not available", () => {
|
||||
const stats = {} as RTCIceCandidatePairStats;
|
||||
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
|
||||
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { GroupCallStats } from "../../../../src/webrtc/stats/groupCallStats";
|
||||
import { SummaryStats } from "../../../../src/webrtc/stats/summaryStats";
|
||||
import { CallStatsReportSummary } from "../../../../src/webrtc/stats/callStatsReportSummary";
|
||||
|
||||
const GROUP_CALL_ID = "GROUP_ID";
|
||||
const LOCAL_USER_ID = "LOCAL_USER_ID";
|
||||
@@ -68,7 +68,7 @@ describe("GroupCallStats", () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("starting processing as well without stats collectors", async () => {
|
||||
it("starting processing stats as well without stats collectors", async () => {
|
||||
// @ts-ignore
|
||||
stats.processStats = jest.fn();
|
||||
stats.start();
|
||||
@@ -77,17 +77,42 @@ describe("GroupCallStats", () => {
|
||||
expect(stats.processStats).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("not starting processing stats if interval 0", async () => {
|
||||
const statsDisabled = new GroupCallStats(GROUP_CALL_ID, LOCAL_USER_ID, 0);
|
||||
// @ts-ignore
|
||||
statsDisabled.processStats = jest.fn();
|
||||
statsDisabled.start();
|
||||
jest.advanceTimersByTime(TIME_INTERVAL);
|
||||
// @ts-ignore
|
||||
expect(statsDisabled.processStats).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starting processing and calling the collectors", async () => {
|
||||
stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection());
|
||||
const collector = stats.getStatsReportGatherer("CALL_ID");
|
||||
stats.reports.emitSummaryStatsReport = jest.fn();
|
||||
const summaryStats = {
|
||||
isFirstCollection: true,
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: { count: 0, muted: 0 },
|
||||
videoTrackSummary: { count: 0, muted: 0 },
|
||||
} as SummaryStats;
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
} as CallStatsReportSummary;
|
||||
let processStatsSpy;
|
||||
if (collector) {
|
||||
processStatsSpy = jest.spyOn(collector, "processStats").mockResolvedValue(summaryStats);
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
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 { StatsReportGatherer } from "../../../../src/webrtc/stats/statsReportGatherer";
|
||||
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
|
||||
|
||||
const CALL_ID = "CALL_ID";
|
||||
const USER_ID = "USER_ID";
|
||||
|
||||
describe("StatsReportGatherer", () => {
|
||||
let collector: StatsReportGatherer;
|
||||
let rtcSpy: RTCPeerConnection;
|
||||
let emitter: StatsReportEmitter;
|
||||
beforeEach(() => {
|
||||
rtcSpy = { getStats: () => new Promise<RTCStatsReport>(() => null) } as RTCPeerConnection;
|
||||
rtcSpy.addEventListener = jest.fn();
|
||||
emitter = new StatsReportEmitter();
|
||||
collector = new StatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
|
||||
});
|
||||
|
||||
describe("on process stats", () => {
|
||||
it("if active calculate stats reports", async () => {
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
const report = {} as RTCStatsReport;
|
||||
report.forEach = jest.fn().mockReturnValue([]);
|
||||
getStats.mockResolvedValue(report);
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(actual).toEqual({
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: { count: 0, muted: 0 },
|
||||
videoTrackSummary: { count: 0, muted: 0 },
|
||||
});
|
||||
expect(collector.getActive()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("if not active do not calculate stats reports", async () => {
|
||||
collector.setActive(false);
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(getStats).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("if get reports fails, the collector becomes inactive", async () => {
|
||||
expect(collector.getActive()).toBeTruthy();
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
getStats.mockRejectedValue(new Error("unknown"));
|
||||
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(collector.getActive()).toBeFalsy();
|
||||
});
|
||||
|
||||
it("if active an RTCStatsReport not a promise the collector becomes inactive", async () => {
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
// @ts-ignore
|
||||
getStats.mockReturnValue({});
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(actual).toEqual({
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: { count: 0, muted: 0 },
|
||||
videoTrackSummary: { count: 0, muted: 0 },
|
||||
});
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(collector.getActive()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,592 @@
|
||||
/*
|
||||
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 { SummaryStatsReportGatherer } from "../../../../src/webrtc/stats/summaryStatsReportGatherer";
|
||||
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
|
||||
|
||||
describe("SummaryStatsReportGatherer", () => {
|
||||
let reporter: SummaryStatsReportGatherer;
|
||||
let emitter: StatsReportEmitter;
|
||||
beforeEach(() => {
|
||||
emitter = new StatsReportEmitter();
|
||||
emitter.emitSummaryStatsReport = jest.fn();
|
||||
reporter = new SummaryStatsReportGatherer(emitter);
|
||||
});
|
||||
|
||||
describe("build Summary Stats Report", () => {
|
||||
it("should do nothing if summary list empty", async () => {
|
||||
reporter.build([]);
|
||||
expect(emitter.emitSummaryStatsReport).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should do nothing if a summary stats element collection the is first time", async () => {
|
||||
reporter.build([
|
||||
{
|
||||
isFirstCollection: true,
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 4,
|
||||
receivedVideoMedia: 6,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 100,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(emitter.emitSummaryStatsReport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should trigger new summary report", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 4,
|
||||
receivedVideoMedia: 6,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 100,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 13,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 13,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 5,
|
||||
totalAudio: 100,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 10,
|
||||
totalAudio: 100,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 15,
|
||||
receivedAudioMedia: 6,
|
||||
receivedVideoMedia: 9,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 100,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 0.5,
|
||||
percentageReceivedAudioMedia: 0.5,
|
||||
percentageReceivedVideoMedia: 0.75,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
peerConnections: 4,
|
||||
percentageConcealedAudio: 0.0375,
|
||||
});
|
||||
});
|
||||
|
||||
it("as received video Media, although video was not received, but because video muted", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 10,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
peerConnections: 1,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("as received no video Media, because only on video was muted", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 10,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 2,
|
||||
muted: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 0,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
peerConnections: 1,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("as received no audio Media, although audio not received and audio muted", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 100,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 100,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 0,
|
||||
percentageReceivedAudioMedia: 0,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
peerConnections: 1,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("should find max jitter and max packet loss", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 20,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 40,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 20,
|
||||
maxPacketLoss: 40,
|
||||
peerConnections: 4,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("as received video Media, if no audio track received should count as received Media", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 10,
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
peerConnections: 1,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("as received audio Media, if no video track received should count as received Media", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 22,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
peerConnections: 1,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("as received no media at all, as received Media", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
peerConnections: 1,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter the first time summery stats", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: true,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 20,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 40,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 40,
|
||||
peerConnections: 3,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
/*
|
||||
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 { SummaryStatsReporter } from "../../../../src/webrtc/stats/summaryStatsReporter";
|
||||
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
|
||||
|
||||
describe("SummaryStatsReporter", () => {
|
||||
let reporter: SummaryStatsReporter;
|
||||
let emitter: StatsReportEmitter;
|
||||
beforeEach(() => {
|
||||
emitter = new StatsReportEmitter();
|
||||
emitter.emitSummaryStatsReport = jest.fn();
|
||||
reporter = new SummaryStatsReporter(emitter);
|
||||
});
|
||||
|
||||
describe("build Summary Stats Report", () => {
|
||||
it("should do nothing if summary list empty", async () => {
|
||||
reporter.build([]);
|
||||
expect(emitter.emitSummaryStatsReport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should trigger new summary report", async () => {
|
||||
const summary = [
|
||||
{
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 4,
|
||||
receivedVideoMedia: 6,
|
||||
audioTrackSummary: { count: 1, muted: 0 },
|
||||
videoTrackSummary: { count: 1, muted: 0 },
|
||||
},
|
||||
{
|
||||
receivedMedia: 13,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 13,
|
||||
audioTrackSummary: { count: 1, muted: 0 },
|
||||
videoTrackSummary: { count: 1, muted: 0 },
|
||||
},
|
||||
{
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: { count: 1, muted: 0 },
|
||||
videoTrackSummary: { count: 1, muted: 0 },
|
||||
},
|
||||
{
|
||||
receivedMedia: 15,
|
||||
receivedAudioMedia: 6,
|
||||
receivedVideoMedia: 9,
|
||||
audioTrackSummary: { count: 1, muted: 0 },
|
||||
videoTrackSummary: { count: 1, muted: 0 },
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 0.5,
|
||||
percentageReceivedAudioMedia: 0.5,
|
||||
percentageReceivedVideoMedia: 0.75,
|
||||
});
|
||||
});
|
||||
|
||||
it("as received video Media, although video was not received, but because video muted", async () => {
|
||||
const summary = [
|
||||
{
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 10,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: { count: 1, muted: 0 },
|
||||
videoTrackSummary: { count: 1, muted: 1 },
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("as received no video Media, because only on video was muted", async () => {
|
||||
const summary = [
|
||||
{
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 10,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: { count: 1, muted: 0 },
|
||||
videoTrackSummary: { count: 2, muted: 1 },
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 0,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("as received no audio Media, although audio not received and audio muted", async () => {
|
||||
const summary = [
|
||||
{
|
||||
receivedMedia: 100,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 100,
|
||||
audioTrackSummary: { count: 1, muted: 1 },
|
||||
videoTrackSummary: { count: 1, muted: 0 },
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 0,
|
||||
percentageReceivedAudioMedia: 0,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+155
-25
@@ -13,20 +13,20 @@ 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 { TrackStatsReporter } from "../../../../src/webrtc/stats/trackStatsReporter";
|
||||
import { TrackStatsBuilder } from "../../../../src/webrtc/stats/trackStatsBuilder";
|
||||
import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats";
|
||||
|
||||
describe("TrackStatsReporter", () => {
|
||||
describe("TrackStatsBuilder", () => {
|
||||
describe("should on frame and resolution stats", () => {
|
||||
it("creating empty frame and resolution report, if no data available.", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "local", "video");
|
||||
TrackStatsReporter.buildFramerateResolution(trackStats, {});
|
||||
TrackStatsBuilder.buildFramerateResolution(trackStats, {});
|
||||
expect(trackStats.getFramerate()).toEqual(0);
|
||||
expect(trackStats.getResolution()).toEqual({ width: -1, height: -1 });
|
||||
});
|
||||
it("creating empty frame and resolution report.", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildFramerateResolution(trackStats, {
|
||||
TrackStatsBuilder.buildFramerateResolution(trackStats, {
|
||||
framesPerSecond: 22.2,
|
||||
frameHeight: 180,
|
||||
frameWidth: 360,
|
||||
@@ -39,7 +39,7 @@ describe("TrackStatsReporter", () => {
|
||||
describe("should on simulcast", () => {
|
||||
it("creating simulcast framerate.", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "local", "video");
|
||||
TrackStatsReporter.calculateSimulcastFramerate(
|
||||
TrackStatsBuilder.calculateSimulcastFramerate(
|
||||
trackStats,
|
||||
{
|
||||
framesSent: 100,
|
||||
@@ -58,7 +58,7 @@ describe("TrackStatsReporter", () => {
|
||||
describe("should on bytes received stats", () => {
|
||||
it("creating build bitrate received report.", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildBitrateReceived(
|
||||
TrackStatsBuilder.buildBitrateReceived(
|
||||
trackStats,
|
||||
{
|
||||
bytesReceived: 2001000,
|
||||
@@ -73,7 +73,7 @@ describe("TrackStatsReporter", () => {
|
||||
describe("should on bytes send stats", () => {
|
||||
it("creating build bitrate send report.", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "local", "video");
|
||||
TrackStatsReporter.buildBitrateSend(
|
||||
TrackStatsBuilder.buildBitrateSend(
|
||||
trackStats,
|
||||
{
|
||||
bytesSent: 2001000,
|
||||
@@ -90,7 +90,7 @@ describe("TrackStatsReporter", () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
const remote = {} as RTCStatsReport;
|
||||
remote.get = jest.fn().mockReturnValue({ mimeType: "video/v8" });
|
||||
TrackStatsReporter.buildCodec(remote, trackStats, { codecId: "codecID" });
|
||||
TrackStatsBuilder.buildCodec(remote, trackStats, { codecId: "codecID" });
|
||||
expect(trackStats.getCodec()).toEqual("v8");
|
||||
});
|
||||
});
|
||||
@@ -98,7 +98,7 @@ describe("TrackStatsReporter", () => {
|
||||
describe("should on package lost stats", () => {
|
||||
it("creating build package lost on send report.", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "local", "video");
|
||||
TrackStatsReporter.buildPacketsLost(
|
||||
TrackStatsBuilder.buildPacketsLost(
|
||||
trackStats,
|
||||
{
|
||||
type: "outbound-rtp",
|
||||
@@ -114,7 +114,7 @@ describe("TrackStatsReporter", () => {
|
||||
});
|
||||
it("creating build package lost on received report.", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildPacketsLost(
|
||||
TrackStatsBuilder.buildPacketsLost(
|
||||
trackStats,
|
||||
{
|
||||
type: "inbound-rtp",
|
||||
@@ -133,7 +133,7 @@ describe("TrackStatsReporter", () => {
|
||||
describe("should set state of a TrackStats", () => {
|
||||
it("to not alive if Transceiver undefined", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, undefined);
|
||||
TrackStatsBuilder.setTrackStatsState(trackStats, undefined);
|
||||
expect(trackStats.alive).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -145,7 +145,7 @@ describe("TrackStatsReporter", () => {
|
||||
} as RTCRtpSender,
|
||||
} as RTCRtpTransceiver;
|
||||
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, ts);
|
||||
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
|
||||
expect(trackStats.alive).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("TrackStatsReporter", () => {
|
||||
} as RTCRtpReceiver,
|
||||
} as RTCRtpTransceiver;
|
||||
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, ts);
|
||||
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
|
||||
expect(trackStats.alive).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -179,7 +179,7 @@ describe("TrackStatsReporter", () => {
|
||||
} as RTCRtpSender,
|
||||
} as RTCRtpTransceiver;
|
||||
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, ts);
|
||||
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
|
||||
expect(trackStats.alive).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -195,7 +195,7 @@ describe("TrackStatsReporter", () => {
|
||||
} as RTCRtpReceiver,
|
||||
} as RTCRtpTransceiver;
|
||||
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, ts);
|
||||
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
|
||||
expect(trackStats.alive).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -211,7 +211,7 @@ describe("TrackStatsReporter", () => {
|
||||
} as RTCRtpReceiver,
|
||||
} as RTCRtpTransceiver;
|
||||
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, ts);
|
||||
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
|
||||
expect(trackStats.alive).toBeTruthy();
|
||||
expect(trackStats.muted).toBeTruthy();
|
||||
});
|
||||
@@ -219,29 +219,134 @@ describe("TrackStatsReporter", () => {
|
||||
|
||||
describe("should build Track Summary", () => {
|
||||
it("and returns empty summary if stats list empty", async () => {
|
||||
const summary = TrackStatsReporter.buildTrackSummary([]);
|
||||
const summary = TrackStatsBuilder.buildTrackSummary([]);
|
||||
expect(summary).toEqual({
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("and returns summary if stats list not empty", async () => {
|
||||
const summary = TrackStatsReporter.buildTrackSummary([]);
|
||||
it("and returns summary if stats list not empty and ignore local summery", async () => {
|
||||
const trackStatsList = buildMockTrackStatsList();
|
||||
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
|
||||
expect(summary).toEqual({
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
count: 2,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
count: 3,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("and returns summary and count muted if alive", async () => {
|
||||
const trackStatsList = buildMockTrackStatsList();
|
||||
trackStatsList[1].muted = true;
|
||||
trackStatsList[5].muted = true;
|
||||
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
|
||||
expect(summary).toEqual({
|
||||
audioTrackSummary: {
|
||||
count: 2,
|
||||
muted: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 3,
|
||||
muted: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("and returns summary and ignore muted if not alive", async () => {
|
||||
const trackStatsList = buildMockTrackStatsList();
|
||||
trackStatsList[1].muted = true;
|
||||
trackStatsList[1].alive = false;
|
||||
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
|
||||
expect(summary).toEqual({
|
||||
audioTrackSummary: {
|
||||
count: 2,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 3,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("and returns summary and build max jitter, packet loss and audio conealment", async () => {
|
||||
const trackStatsList = buildMockTrackStatsList();
|
||||
// video remote
|
||||
trackStatsList[1].setJitter(12);
|
||||
trackStatsList[4].setJitter(66);
|
||||
trackStatsList[6].setJitter(1);
|
||||
trackStatsList[1].setLoss({ packetsLost: 55, packetsTotal: 0, isDownloadStream: true });
|
||||
trackStatsList[4].setLoss({ packetsLost: 0, packetsTotal: 0, isDownloadStream: true });
|
||||
trackStatsList[6].setLoss({ packetsLost: 1, packetsTotal: 0, isDownloadStream: true });
|
||||
// audio remote
|
||||
trackStatsList[2].setJitter(1);
|
||||
trackStatsList[5].setJitter(15);
|
||||
trackStatsList[2].setLoss({ packetsLost: 5, packetsTotal: 0, isDownloadStream: true });
|
||||
trackStatsList[5].setLoss({ packetsLost: 0, packetsTotal: 0, isDownloadStream: true });
|
||||
trackStatsList[2].setAudioConcealment(220, 2000);
|
||||
trackStatsList[5].setAudioConcealment(180, 2000);
|
||||
|
||||
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
|
||||
expect(summary).toEqual({
|
||||
audioTrackSummary: {
|
||||
count: 2,
|
||||
muted: 0,
|
||||
maxJitter: 15,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 400,
|
||||
totalAudio: 4000,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 3,
|
||||
muted: 0,
|
||||
maxJitter: 66,
|
||||
maxPacketLoss: 55,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -250,26 +355,51 @@ describe("TrackStatsReporter", () => {
|
||||
describe("should build jitter value in Track Stats", () => {
|
||||
it("and returns track stats without jitter if report not 'inbound-rtp'", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildJitter(trackStats, { jitter: 0.01 });
|
||||
TrackStatsBuilder.buildJitter(trackStats, { jitter: 0.01 });
|
||||
expect(trackStats.getJitter()).toEqual(0);
|
||||
});
|
||||
|
||||
it("and returns track stats with jitter", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp", jitter: 0.01 });
|
||||
TrackStatsBuilder.buildJitter(trackStats, { type: "inbound-rtp", jitter: 0.01 });
|
||||
expect(trackStats.getJitter()).toEqual(10);
|
||||
});
|
||||
|
||||
it("and returns negative jitter if stats has no jitter value", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp" });
|
||||
TrackStatsBuilder.buildJitter(trackStats, { type: "inbound-rtp" });
|
||||
expect(trackStats.getJitter()).toEqual(-1);
|
||||
});
|
||||
|
||||
it("and returns jitter as number", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp", jitter: "0.5" });
|
||||
TrackStatsBuilder.buildJitter(trackStats, { type: "inbound-rtp", jitter: "0.5" });
|
||||
expect(trackStats.getJitter()).toEqual(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildMockTrackStatsList(): MediaTrackStats[] {
|
||||
const trackStats1 = new MediaTrackStats("1", "local", "video");
|
||||
trackStats1.muted = false;
|
||||
trackStats1.alive = true;
|
||||
const trackStats2 = new MediaTrackStats("1", "remote", "video");
|
||||
trackStats2.muted = false;
|
||||
trackStats2.alive = true;
|
||||
const trackStats3 = new MediaTrackStats("1", "remote", "audio");
|
||||
trackStats3.muted = false;
|
||||
trackStats3.alive = true;
|
||||
const trackStats4 = new MediaTrackStats("1", "local", "audio");
|
||||
trackStats4.muted = false;
|
||||
trackStats4.alive = true;
|
||||
const trackStats5 = new MediaTrackStats("1", "remote", "video");
|
||||
trackStats5.muted = false;
|
||||
trackStats5.alive = true;
|
||||
const trackStats6 = new MediaTrackStats("1", "remote", "audio");
|
||||
trackStats6.muted = false;
|
||||
trackStats6.alive = true;
|
||||
const trackStats7 = new MediaTrackStats("1", "remote", "video");
|
||||
trackStats7.muted = false;
|
||||
trackStats7.alive = true;
|
||||
return [trackStats1, trackStats2, trackStats3, trackStats4, trackStats5, trackStats6, trackStats7];
|
||||
}
|
||||
+6
-6
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { TransportStatsReporter } from "../../../../src/webrtc/stats/transportStatsReporter";
|
||||
import { TransportStatsBuilder } from "../../../../src/webrtc/stats/transportStatsBuilder";
|
||||
import { TransportStats } from "../../../../src/webrtc/stats/transportStats";
|
||||
|
||||
describe("TransportStatsReporter", () => {
|
||||
@@ -35,7 +35,7 @@ describe("TransportStatsReporter", () => {
|
||||
it("build new transport stats if all properties there", () => {
|
||||
const { report, stats } = mockStatsReport(isFocus, 0);
|
||||
const conferenceStatsTransport: TransportStats[] = [];
|
||||
const transportStats = TransportStatsReporter.buildReport(report, stats, conferenceStatsTransport, isFocus);
|
||||
const transportStats = TransportStatsBuilder.buildReport(report, stats, conferenceStatsTransport, isFocus);
|
||||
expect(transportStats).toEqual([
|
||||
{
|
||||
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
|
||||
@@ -54,8 +54,8 @@ describe("TransportStatsReporter", () => {
|
||||
const mock1 = mockStatsReport(isFocus, 0);
|
||||
const mock2 = mockStatsReport(isFocus, 1);
|
||||
let transportStats: TransportStats[] = [];
|
||||
transportStats = TransportStatsReporter.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
|
||||
transportStats = TransportStatsReporter.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
|
||||
transportStats = TransportStatsBuilder.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
|
||||
transportStats = TransportStatsBuilder.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
|
||||
expect(transportStats).toEqual([
|
||||
{
|
||||
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
|
||||
@@ -84,8 +84,8 @@ describe("TransportStatsReporter", () => {
|
||||
const mock1 = mockStatsReport(isFocus, 0);
|
||||
const mock2 = mockStatsReport(isFocus, 0);
|
||||
let transportStats: TransportStats[] = [];
|
||||
transportStats = TransportStatsReporter.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
|
||||
transportStats = TransportStatsReporter.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
|
||||
transportStats = TransportStatsBuilder.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
|
||||
transportStats = TransportStatsBuilder.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
|
||||
expect(transportStats).toEqual([
|
||||
{
|
||||
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
|
||||
+7
-7
@@ -13,16 +13,16 @@ 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 { StatsValueFormatter } from "../../../../src/webrtc/stats/statsValueFormatter";
|
||||
import { ValueFormatter } from "../../../../src/webrtc/stats/valueFormatter";
|
||||
|
||||
describe("StatsValueFormatter", () => {
|
||||
describe("ValueFormatter", () => {
|
||||
describe("on get non negative values", () => {
|
||||
it("formatter shod return number", async () => {
|
||||
expect(StatsValueFormatter.getNonNegativeValue("2")).toEqual(2);
|
||||
expect(StatsValueFormatter.getNonNegativeValue(0)).toEqual(0);
|
||||
expect(StatsValueFormatter.getNonNegativeValue("-2")).toEqual(0);
|
||||
expect(StatsValueFormatter.getNonNegativeValue("")).toEqual(0);
|
||||
expect(StatsValueFormatter.getNonNegativeValue(NaN)).toEqual(0);
|
||||
expect(ValueFormatter.getNonNegativeValue("2")).toEqual(2);
|
||||
expect(ValueFormatter.getNonNegativeValue(0)).toEqual(0);
|
||||
expect(ValueFormatter.getNonNegativeValue("-2")).toEqual(0);
|
||||
expect(ValueFormatter.getNonNegativeValue("")).toEqual(0);
|
||||
expect(ValueFormatter.getNonNegativeValue(NaN)).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -235,6 +235,13 @@ export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue(
|
||||
"org.matrix.msc3890.local_notification_settings",
|
||||
);
|
||||
|
||||
/**
|
||||
* https://github.com/matrix-org/matrix-doc/pull/4023
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export const UNSIGNED_THREAD_ID_FIELD = new UnstableValue("thread_id", "org.matrix.msc4023.thread_id");
|
||||
|
||||
export interface IEncryptedFile {
|
||||
url: string;
|
||||
mimetype?: string;
|
||||
|
||||
@@ -186,6 +186,7 @@ export interface IRelationsRequestOpts {
|
||||
to?: string;
|
||||
limit?: number;
|
||||
dir?: Direction;
|
||||
recurse?: boolean; // MSC3981 Relations Recursion https://github.com/matrix-org/matrix-spec-proposals/pull/3981
|
||||
}
|
||||
|
||||
export interface IRelationsResponse {
|
||||
|
||||
+2
-2
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IAuthData } from "../interactive-auth";
|
||||
import { IAuthDict, IAuthData } from "../interactive-auth";
|
||||
|
||||
/**
|
||||
* Helper type to represent HTTP request body for a UIA enabled endpoint
|
||||
*/
|
||||
export type UIARequest<T> = T & {
|
||||
auth?: IAuthData;
|
||||
auth?: IAuthDict;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+3
-1
@@ -25,7 +25,7 @@ export class ReEmitter {
|
||||
public constructor(private readonly target: EventEmitter) {}
|
||||
|
||||
// Map from emitter to event name to re-emitter
|
||||
private reEmitters = new Map<EventEmitter, Map<string, (...args: any[]) => void>>();
|
||||
private reEmitters = new WeakMap<EventEmitter, Map<string, (...args: any[]) => void>>();
|
||||
|
||||
public reEmit(source: EventEmitter, eventNames: string[]): void {
|
||||
let reEmittersByEvent = this.reEmitters.get(source);
|
||||
@@ -35,6 +35,8 @@ export class ReEmitter {
|
||||
}
|
||||
|
||||
for (const eventName of eventNames) {
|
||||
if (reEmittersByEvent.has(eventName)) continue;
|
||||
|
||||
// We include the source as the last argument for event handlers which may need it,
|
||||
// such as read receipt listeners on the client class which won't have the context
|
||||
// of the room.
|
||||
|
||||
@@ -142,7 +142,7 @@ export class AutoDiscovery {
|
||||
},
|
||||
};
|
||||
|
||||
if (!wellknown || !wellknown["m.homeserver"]) {
|
||||
if (!wellknown?.["m.homeserver"]) {
|
||||
logger.error("No m.homeserver key in config");
|
||||
|
||||
clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT;
|
||||
@@ -171,7 +171,7 @@ export class AutoDiscovery {
|
||||
|
||||
// Step 3: Make sure the homeserver URL points to a homeserver.
|
||||
const hsVersions = await this.fetchWellKnownObject(`${hsUrl}/_matrix/client/versions`);
|
||||
if (!hsVersions || !hsVersions.raw?.["versions"]) {
|
||||
if (!hsVersions?.raw?.["versions"]) {
|
||||
logger.error("Invalid /versions response");
|
||||
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER;
|
||||
|
||||
@@ -345,7 +345,7 @@ export class AutoDiscovery {
|
||||
|
||||
const response = await this.fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`);
|
||||
if (!response) return {};
|
||||
return response.raw || {};
|
||||
return response.raw ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+105
-74
@@ -36,7 +36,11 @@ import { StubStore } from "./store/stub";
|
||||
import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call";
|
||||
import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter";
|
||||
import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from "./webrtc/callEventHandler";
|
||||
import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from "./webrtc/groupCallEventHandler";
|
||||
import {
|
||||
GroupCallEventHandler,
|
||||
GroupCallEventHandlerEvent,
|
||||
GroupCallEventHandlerEventHandlerMap,
|
||||
} from "./webrtc/groupCallEventHandler";
|
||||
import * as utils from "./utils";
|
||||
import { replaceParam, QueryDict, sleep, noUnsafeEventProps, safeSet } from "./utils";
|
||||
import { Direction, EventTimeline } from "./models/event-timeline";
|
||||
@@ -74,7 +78,6 @@ import {
|
||||
CryptoEventHandlerMap,
|
||||
fixBackupKey,
|
||||
ICryptoCallbacks,
|
||||
IBootstrapCrossSigningOpts,
|
||||
ICheckOwnCrossSigningTrustOpts,
|
||||
isCryptoAvailable,
|
||||
VerificationMethod,
|
||||
@@ -181,7 +184,6 @@ import { IThreepid } from "./@types/threepids";
|
||||
import { CryptoStore, OutgoingRoomKeyRequest } from "./crypto/store/base";
|
||||
import { GroupCall, IGroupCallDataChannelOptions, GroupCallIntent, GroupCallType } from "./webrtc/groupCall";
|
||||
import { MediaHandler } from "./webrtc/mediaHandler";
|
||||
import { GroupCallEventHandler } from "./webrtc/groupCallEventHandler";
|
||||
import { LoginTokenPostResponse, ILoginFlowsResponse, IRefreshTokenResponse, SSOAction } from "./@types/auth";
|
||||
import { TypedEventEmitter } from "./models/typed-event-emitter";
|
||||
import { MAIN_ROOM_TIMELINE, ReceiptType } from "./@types/read_receipts";
|
||||
@@ -205,7 +207,7 @@ import { LocalNotificationSettings } from "./@types/local_notifications";
|
||||
import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature";
|
||||
import { CryptoBackend } from "./common-crypto/CryptoBackend";
|
||||
import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants";
|
||||
import { CryptoApi } from "./crypto-api";
|
||||
import { BootstrapCrossSigningOpts, CryptoApi } from "./crypto-api";
|
||||
import { DeviceInfoMap } from "./crypto/DeviceList";
|
||||
import {
|
||||
AddSecretStorageKeyOpts,
|
||||
@@ -635,7 +637,7 @@ interface IJoinRequestBody {
|
||||
|
||||
interface ITagMetadata {
|
||||
[key: string]: any;
|
||||
order: number;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
interface IMessagesResponse {
|
||||
@@ -715,7 +717,7 @@ interface IJoinedMembersResponse {
|
||||
}
|
||||
|
||||
export interface IRegisterRequestParams {
|
||||
auth?: IAuthData;
|
||||
auth?: IAuthDict;
|
||||
username?: string;
|
||||
password?: string;
|
||||
refresh_token?: boolean;
|
||||
@@ -2228,7 +2230,7 @@ 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");
|
||||
const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId);
|
||||
const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId, this.secretStorage);
|
||||
this.cryptoBackend = rustCrypto;
|
||||
|
||||
// attach the event listeners needed by RustCrypto
|
||||
@@ -2295,6 +2297,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param forceDownload - Always download the keys even if cached.
|
||||
*
|
||||
* @returns A promise which resolves to a map userId-\>deviceId-\>`DeviceInfo`
|
||||
*
|
||||
* @deprecated Prefer {@link CryptoApi.getUserDeviceInfo}
|
||||
*/
|
||||
public downloadKeys(userIds: string[], forceDownload?: boolean): Promise<DeviceInfoMap> {
|
||||
if (!this.crypto) {
|
||||
@@ -2309,6 +2313,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param userId - the user to list keys for.
|
||||
*
|
||||
* @returns list of devices
|
||||
* @deprecated Prefer {@link CryptoApi.getUserDeviceInfo}
|
||||
*/
|
||||
public getStoredDevicesForUser(userId: string): DeviceInfo[] {
|
||||
if (!this.crypto) {
|
||||
@@ -2324,6 +2329,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param deviceId - unique identifier for the device
|
||||
*
|
||||
* @returns device or null
|
||||
* @deprecated Prefer {@link CryptoApi.getUserDeviceInfo}
|
||||
*/
|
||||
public getStoredDevice(userId: string, deviceId: string): DeviceInfo | null {
|
||||
if (!this.crypto) {
|
||||
@@ -2434,10 +2440,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns the VerificationRequest that is in progress, if any
|
||||
*/
|
||||
public findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.findVerificationRequestDMInProgress(roomId);
|
||||
return this.cryptoBackend.findVerificationRequestDMInProgress(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2568,14 +2574,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's cross-signing key ID.
|
||||
*
|
||||
* The cross-signing API is currently UNSTABLE and may change without notice.
|
||||
* Get the ID of one of the user's cross-signing keys
|
||||
*
|
||||
* @param type - The type of key to get the ID of. One of
|
||||
* "master", "self_signing", or "user_signing". Defaults to "master".
|
||||
*
|
||||
* @returns the key ID
|
||||
* @deprecated prefer {@link Crypto.CryptoApi#getCrossSigningKeyId}
|
||||
*/
|
||||
public getCrossSigningId(type: CrossSigningKey | string = CrossSigningKey.Master): string | null {
|
||||
if (!this.crypto) {
|
||||
@@ -2594,10 +2599,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns the cross signing information for the user.
|
||||
*/
|
||||
public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.getStoredCrossSigningForUser(userId);
|
||||
return this.cryptoBackend.getStoredCrossSigningForUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2621,12 +2626,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* @param userId - The ID of the user whose devices is to be checked.
|
||||
* @param deviceId - The ID of the device to check
|
||||
*
|
||||
* @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus | `CryptoApi.getDeviceVerificationStatus`}
|
||||
*/
|
||||
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
|
||||
if (!this.cryptoBackend) {
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.cryptoBackend.checkDeviceTrust(userId, deviceId);
|
||||
return this.crypto.checkDeviceTrust(userId, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2730,12 +2737,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* bootstrapCrossSigning() completes successfully, this function should
|
||||
* return true.
|
||||
* @returns True if cross-signing is ready to be used on this device
|
||||
* @deprecated Prefer {@link CryptoApi.isCrossSigningReady | `CryptoApi.isCrossSigningReady`}:
|
||||
*/
|
||||
public isCrossSigningReady(): Promise<boolean> {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.isCrossSigningReady();
|
||||
return this.cryptoBackend.isCrossSigningReady();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2745,15 +2753,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* This function:
|
||||
* - creates new cross-signing keys if they are not found locally cached nor in
|
||||
* secret storage (if it has been setup)
|
||||
* secret storage (if it has been set up)
|
||||
*
|
||||
* The cross-signing API is currently UNSTABLE and may change without notice.
|
||||
* @deprecated Prefer {@link CryptoApi.bootstrapCrossSigning | `CryptoApi.bootstrapCrossSigning`}.
|
||||
*/
|
||||
public bootstrapCrossSigning(opts: IBootstrapCrossSigningOpts): Promise<void> {
|
||||
if (!this.crypto) {
|
||||
public bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void> {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.bootstrapCrossSigning(opts);
|
||||
return this.cryptoBackend.bootstrapCrossSigning(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2764,24 +2772,28 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* Default: true
|
||||
*
|
||||
* @returns True if trusting cross-signed devices
|
||||
*
|
||||
* @deprecated Prefer {@link CryptoApi.getTrustCrossSignedDevices | `CryptoApi.getTrustCrossSignedDevices`}.
|
||||
*/
|
||||
public getCryptoTrustCrossSignedDevices(): boolean {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.getCryptoTrustCrossSignedDevices();
|
||||
return this.cryptoBackend.getTrustCrossSignedDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* See getCryptoTrustCrossSignedDevices
|
||||
*
|
||||
* @param val - True to trust cross-signed devices
|
||||
*
|
||||
* @deprecated Prefer {@link CryptoApi.setTrustCrossSignedDevices | `CryptoApi.setTrustCrossSignedDevices`}.
|
||||
*/
|
||||
public setCryptoTrustCrossSignedDevices(val: boolean): void {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
this.crypto.setCryptoTrustCrossSignedDevices(val);
|
||||
this.cryptoBackend.setTrustCrossSignedDevices(val);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2838,15 +2850,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* bootstrapSecretStorage() completes successfully, this function should
|
||||
* return true.
|
||||
*
|
||||
* The Secure Secret Storage API is currently UNSTABLE and may change without notice.
|
||||
*
|
||||
* @returns True if secret storage is ready to be used on this device
|
||||
* @deprecated Prefer {@link CryptoApi.isSecretStorageReady | `CryptoApi.isSecretStorageReady`}:
|
||||
*/
|
||||
public isSecretStorageReady(): Promise<boolean> {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.isSecretStorageReady();
|
||||
return this.cryptoBackend.isSecretStorageReady();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4079,27 +4090,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
queryString["server_name"] = opts.viaServers;
|
||||
}
|
||||
|
||||
try {
|
||||
const data: IJoinRequestBody = {};
|
||||
const signedInviteObj = await signPromise;
|
||||
if (signedInviteObj) {
|
||||
data.third_party_signed = signedInviteObj;
|
||||
}
|
||||
|
||||
const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias });
|
||||
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
|
||||
|
||||
const roomId = res.room_id;
|
||||
const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
|
||||
const room = syncApi.createRoom(roomId);
|
||||
if (opts.syncRoom) {
|
||||
// v2 will do this for us
|
||||
// return syncApi.syncRoom(room);
|
||||
}
|
||||
return room;
|
||||
} catch (e) {
|
||||
throw e; // rethrow for reject
|
||||
const data: IJoinRequestBody = {};
|
||||
const signedInviteObj = await signPromise;
|
||||
if (signedInviteObj) {
|
||||
data.third_party_signed = signedInviteObj;
|
||||
}
|
||||
|
||||
const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias });
|
||||
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
|
||||
|
||||
const roomId = res.room_id;
|
||||
const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
|
||||
const syncRoom = syncApi.createRoom(roomId);
|
||||
if (opts.syncRoom) {
|
||||
// v2 will do this for us
|
||||
// return syncApi.syncRoom(room);
|
||||
}
|
||||
return syncRoom;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4179,7 +4186,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: to an empty object
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public setRoomTag(roomId: string, tagName: string, metadata: ITagMetadata): Promise<{}> {
|
||||
public setRoomTag(roomId: string, tagName: string, metadata: ITagMetadata = {}): Promise<{}> {
|
||||
const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
|
||||
$userId: this.credentials.userId!,
|
||||
$roomId: roomId,
|
||||
@@ -4997,7 +5004,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
rpEvent?: MatrixEvent,
|
||||
): Promise<{}> {
|
||||
const room = this.getRoom(roomId);
|
||||
if (room && room.hasPendingEvent(rmEventId)) {
|
||||
if (room?.hasPendingEvent(rmEventId)) {
|
||||
throw new Error(`Cannot set read marker to a pending event (${rmEventId})`);
|
||||
}
|
||||
|
||||
@@ -5050,9 +5057,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const key = ts + "_" + url;
|
||||
|
||||
// If there's already a request in flight (or we've handled it), return that instead.
|
||||
const cachedPreview = this.urlPreviewCache[key];
|
||||
if (cachedPreview) {
|
||||
return cachedPreview;
|
||||
if (key in this.urlPreviewCache) {
|
||||
return this.urlPreviewCache[key];
|
||||
}
|
||||
|
||||
const resp = this.http.authedRequest<IPreviewUrlResponse>(
|
||||
@@ -5567,11 +5573,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
room.currentState.setUnknownStateEvents(stateEvents);
|
||||
}
|
||||
|
||||
const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents);
|
||||
const [timelineEvents, threadedEvents, unknownRelations] =
|
||||
room.partitionThreadedEvents(matrixEvents);
|
||||
|
||||
this.processAggregatedTimelineEvents(room, timelineEvents);
|
||||
room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
|
||||
this.processThreadEvents(room, threadedEvents, true);
|
||||
unknownRelations.forEach((event) => room.relations.aggregateChildEvent(event));
|
||||
|
||||
room.oldState.paginationToken = res.end ?? null;
|
||||
if (res.chunk.length === 0) {
|
||||
@@ -5680,11 +5688,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
timeline.getState(EventTimeline.FORWARDS)!.paginationToken = res.end;
|
||||
}
|
||||
|
||||
const [timelineEvents, threadedEvents] = timelineSet.room.partitionThreadedEvents(events);
|
||||
const [timelineEvents, threadedEvents, unknownRelations] = timelineSet.room.partitionThreadedEvents(events);
|
||||
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.processAggregatedTimelineEvents(timelineSet.room, timelineEvents);
|
||||
unknownRelations.forEach((event) => timelineSet.relations.aggregateChildEvent(event));
|
||||
|
||||
// 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
|
||||
@@ -5730,6 +5739,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const recurse = this.canSupport.get(Feature.RelationsRecursion) !== ServerSupport.Unsupported;
|
||||
if (Thread.hasServerSideSupport) {
|
||||
if (Thread.hasServerSideFwdPaginationSupport) {
|
||||
if (!timelineSet.thread) {
|
||||
@@ -5742,14 +5752,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
thread.id,
|
||||
THREAD_RELATION_TYPE.name,
|
||||
null,
|
||||
{ dir: Direction.Backward, from: res.start },
|
||||
{ dir: Direction.Backward, from: res.start, recurse: recurse || undefined },
|
||||
);
|
||||
const resNewer: IRelationsResponse = await this.fetchRelations(
|
||||
timelineSet.room.roomId,
|
||||
thread.id,
|
||||
THREAD_RELATION_TYPE.name,
|
||||
null,
|
||||
{ dir: Direction.Forward, from: res.end },
|
||||
{ dir: Direction.Forward, from: res.end, recurse: recurse || undefined },
|
||||
);
|
||||
const events = [
|
||||
// Order events from most recent to oldest (reverse-chronological).
|
||||
@@ -5797,7 +5807,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
thread.id,
|
||||
THREAD_RELATION_TYPE.name,
|
||||
null,
|
||||
{ dir: Direction.Backward, from: res.start },
|
||||
{ dir: Direction.Backward, from: res.start, recurse: recurse || undefined },
|
||||
);
|
||||
const eventsNewer: IEvent[] = [];
|
||||
let nextBatch: Optional<string> = res.end;
|
||||
@@ -5807,7 +5817,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
thread.id,
|
||||
THREAD_RELATION_TYPE.name,
|
||||
null,
|
||||
{ dir: Direction.Forward, from: nextBatch },
|
||||
{ dir: Direction.Forward, from: nextBatch, recurse: recurse || undefined },
|
||||
);
|
||||
nextBatch = resNewer.next_batch ?? null;
|
||||
eventsNewer.push(...resNewer.chunk);
|
||||
@@ -5878,12 +5888,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
);
|
||||
event = res.chunk?.[0];
|
||||
} else if (timelineSet.thread && Thread.hasServerSideSupport) {
|
||||
const recurse = this.canSupport.get(Feature.RelationsRecursion) !== ServerSupport.Unsupported;
|
||||
const res = await this.fetchRelations(
|
||||
timelineSet.room.roomId,
|
||||
timelineSet.thread.id,
|
||||
THREAD_RELATION_TYPE.name,
|
||||
null,
|
||||
{ dir: Direction.Backward, limit: 1 },
|
||||
{ dir: Direction.Backward, limit: 1, recurse: recurse || undefined },
|
||||
);
|
||||
event = res.chunk?.[0];
|
||||
} else {
|
||||
@@ -6158,10 +6169,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
throw new Error("Unknown room " + eventTimeline.getRoomId());
|
||||
}
|
||||
|
||||
const recurse = this.canSupport.get(Feature.RelationsRecursion) !== ServerSupport.Unsupported;
|
||||
promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, THREAD_RELATION_TYPE.name, null, {
|
||||
dir,
|
||||
limit: opts.limit,
|
||||
from: token ?? undefined,
|
||||
recurse: recurse || undefined,
|
||||
})
|
||||
.then(async (res) => {
|
||||
const mapper = this.getEventMapper();
|
||||
@@ -6220,7 +6233,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(this.getEventMapper());
|
||||
|
||||
const timelineSet = eventTimeline.getTimelineSet();
|
||||
const [timelineEvents] = room.partitionThreadedEvents(matrixEvents);
|
||||
const [timelineEvents, , unknownRelations] = room.partitionThreadedEvents(matrixEvents);
|
||||
timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
|
||||
this.processAggregatedTimelineEvents(room, timelineEvents);
|
||||
this.processThreadRoots(
|
||||
@@ -6228,6 +6241,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
timelineEvents.filter((it) => it.getServerAggregatedRelation(THREAD_RELATION_TYPE.name)),
|
||||
false,
|
||||
);
|
||||
unknownRelations.forEach((event) => room.relations.aggregateChildEvent(event));
|
||||
|
||||
const atEnd = res.end === undefined || res.end === res.start;
|
||||
|
||||
@@ -7851,7 +7865,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: On success, the token response
|
||||
* or UIA auth data.
|
||||
*/
|
||||
public async requestLoginToken(auth?: IAuthData): Promise<UIAResponse<LoginTokenPostResponse>> {
|
||||
public async requestLoginToken(auth?: IAuthDict): Promise<UIAResponse<LoginTokenPostResponse>> {
|
||||
// use capabilities to determine which revision of the MSC is being used
|
||||
const capabilities = await this.getCapabilities();
|
||||
// use r1 endpoint if capability is exposed otherwise use old r0 endpoint
|
||||
@@ -7950,6 +7964,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (Thread.hasServerSideFwdPaginationSupport === FeatureSupport.Experimental) {
|
||||
params = replaceParam("dir", "org.matrix.msc3715.dir", params);
|
||||
}
|
||||
if (this.canSupport.get(Feature.RelationsRecursion) === ServerSupport.Unstable) {
|
||||
params = replaceParam("recurse", "org.matrix.msc3981.recurse", params);
|
||||
}
|
||||
const queryString = utils.encodeParams(params);
|
||||
|
||||
let templatedUrl = "/rooms/$roomId/relations/$eventId";
|
||||
@@ -8230,7 +8247,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*/
|
||||
public getRoomIdForAlias(alias: string): Promise<{ room_id: string; servers: string[] }> {
|
||||
// eslint-disable-line camelcase
|
||||
// TODO: deprecate this or resolveRoomAlias
|
||||
const path = utils.encodeUri("/directory/room/$alias", {
|
||||
$alias: alias,
|
||||
});
|
||||
@@ -8240,10 +8256,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* @returns Promise which resolves: Object with room_id and servers.
|
||||
* @returns Rejects: with an error response.
|
||||
* @deprecated use `getRoomIdForAlias` instead
|
||||
*/
|
||||
// eslint-disable-next-line camelcase
|
||||
public resolveRoomAlias(roomAlias: string): Promise<{ room_id: string; servers: string[] }> {
|
||||
// TODO: deprecate this or getRoomIdForAlias
|
||||
const path = utils.encodeUri("/directory/room/$alias", { $alias: roomAlias });
|
||||
return this.http.request(Method.Get, path);
|
||||
}
|
||||
@@ -8615,6 +8631,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http.authedRequest(Method.Post, path, undefined, pusher);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an existing pusher
|
||||
* @param pushKey - pushkey of pusher to remove
|
||||
* @param appId - app_id of pusher to remove
|
||||
* @returns Promise which resolves: Empty json object on success
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public removePusher(pushKey: string, appId: string): Promise<{}> {
|
||||
const path = "/pushers/set";
|
||||
const body = {
|
||||
pushkey: pushKey,
|
||||
app_id: appId,
|
||||
kind: null, // marks pusher for removal
|
||||
};
|
||||
return this.http.authedRequest(Method.Post, path, undefined, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists local notification settings
|
||||
* @returns Promise which resolves: an empty object
|
||||
@@ -8837,7 +8870,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http.authedRequest(Method.Get, "/keys/changes", qps);
|
||||
}
|
||||
|
||||
public uploadDeviceSigningKeys(auth?: IAuthData, keys?: CrossSigningKeys): Promise<{}> {
|
||||
public uploadDeviceSigningKeys(auth?: IAuthDict, keys?: CrossSigningKeys): Promise<{}> {
|
||||
// API returns empty object
|
||||
const data = Object.assign({}, keys);
|
||||
if (auth) Object.assign(data, { auth });
|
||||
@@ -8986,7 +9019,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param identityAccessToken - The `access_token` field of the Identity
|
||||
* Server `/account/register` response (see {@link registerWithIdentityServer}).
|
||||
*
|
||||
* @returns Promise which resolves: Object, currently with no parameters.
|
||||
* @returns Promise which resolves: Object, containing success boolean.
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws Error if No identity server is set
|
||||
*/
|
||||
@@ -8995,8 +9028,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
clientSecret: string,
|
||||
msisdnToken: string,
|
||||
identityAccessToken: string,
|
||||
): Promise<any> {
|
||||
// TODO: Types
|
||||
): Promise<{ success: boolean }> {
|
||||
const params = {
|
||||
sid: sid,
|
||||
client_secret: clientSecret,
|
||||
@@ -9027,7 +9059,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* This must be the same value submitted in the requestToken call.
|
||||
* @param msisdnToken - The MSISDN token, as enetered by the user.
|
||||
*
|
||||
* @returns Promise which resolves: Object, currently with no parameters.
|
||||
* @returns Promise which resolves: Object, containing success boolean.
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public submitMsisdnTokenOtherUrl(
|
||||
@@ -9035,8 +9067,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
sid: string,
|
||||
clientSecret: string,
|
||||
msisdnToken: string,
|
||||
): Promise<any> {
|
||||
// TODO: Types
|
||||
): Promise<{ success: boolean }> {
|
||||
const params = {
|
||||
sid: sid,
|
||||
client_secret: clientSecret,
|
||||
|
||||
@@ -18,9 +18,10 @@ import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { Room } from "../models/room";
|
||||
import { CryptoApi } from "../crypto-api";
|
||||
import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
|
||||
import { CrossSigningInfo, UserTrustLevel } from "../crypto/CrossSigning";
|
||||
import { IEncryptedEventInfo } from "../crypto/api";
|
||||
import { IEventDecryptionResult } from "../@types/crypto";
|
||||
import { VerificationRequest } from "../crypto/verification/request/VerificationRequest";
|
||||
|
||||
/**
|
||||
* Common interface for the crypto implementations
|
||||
@@ -51,16 +52,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
*/
|
||||
checkUserTrust(userId: string): UserTrustLevel;
|
||||
|
||||
/**
|
||||
* Get the verification level for a given device
|
||||
*
|
||||
* TODO: define this better
|
||||
*
|
||||
* @param userId - user to be checked
|
||||
* @param deviceId - device to be checked
|
||||
*/
|
||||
checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel;
|
||||
|
||||
/**
|
||||
* Encrypt an event according to the configuration of the room.
|
||||
*
|
||||
@@ -87,6 +78,26 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
* @param event - event to be checked
|
||||
*/
|
||||
getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo;
|
||||
|
||||
/**
|
||||
* Finds a DM verification request that is already in progress for the given room id
|
||||
*
|
||||
* @param roomId - the room to use for verification
|
||||
*
|
||||
* @returns the VerificationRequest that is in progress, if any
|
||||
*/
|
||||
findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined;
|
||||
|
||||
/**
|
||||
* Get the cross signing information for a given user.
|
||||
*
|
||||
* The cross-signing API is currently UNSTABLE and may change without notice.
|
||||
*
|
||||
* @param userId - the user ID to get the cross-signing info for.
|
||||
*
|
||||
* @returns the cross signing information for the user.
|
||||
*/
|
||||
getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null;
|
||||
}
|
||||
|
||||
/** The methods which crypto implementations should expose to the Sync api */
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as utils from "./utils";
|
||||
import { encodeParams } from "./utils";
|
||||
|
||||
/**
|
||||
* Get the HTTP URL for an MXC URI.
|
||||
@@ -74,6 +74,6 @@ export function getHttpUriForMxc(
|
||||
serverAndMediaId = serverAndMediaId.slice(0, fragmentOffset);
|
||||
}
|
||||
|
||||
const urlParams = Object.keys(params).length === 0 ? "" : "?" + utils.encodeParams(params);
|
||||
const urlParams = Object.keys(params).length === 0 ? "" : "?" + encodeParams(params);
|
||||
return baseUrl + prefix + serverAndMediaId + urlParams + fragment;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,15 @@ limitations under the License.
|
||||
|
||||
import type { IMegolmSessionData } from "./@types/crypto";
|
||||
import { Room } from "./models/room";
|
||||
import { DeviceMap } from "./models/device";
|
||||
import { UIAuthCallback } from "./interactive-auth";
|
||||
|
||||
/** Types of cross-signing key */
|
||||
export enum CrossSigningKey {
|
||||
Master = "master",
|
||||
SelfSigning = "self_signing",
|
||||
UserSigning = "user_signing",
|
||||
}
|
||||
|
||||
/**
|
||||
* Public interface to the cryptography parts of the js-sdk
|
||||
@@ -72,4 +81,185 @@ export interface CryptoApi {
|
||||
* session export objects
|
||||
*/
|
||||
exportRoomKeys(): Promise<IMegolmSessionData[]>;
|
||||
|
||||
/**
|
||||
* Get the device information for the given list of users.
|
||||
*
|
||||
* For any users whose device lists are cached (due to sharing an encrypted room with the user), the
|
||||
* cached device data is returned.
|
||||
*
|
||||
* If there are uncached users, and the `downloadUncached` parameter is set to `true`,
|
||||
* a `/keys/query` request is made to the server to retrieve these devices.
|
||||
*
|
||||
* @param userIds - The users to fetch.
|
||||
* @param downloadUncached - If true, download the device list for users whose device list we are not
|
||||
* currently tracking. Defaults to false, in which case such users will not appear at all in the result map.
|
||||
*
|
||||
* @returns A map `{@link DeviceMap}`.
|
||||
*/
|
||||
getUserDeviceInfo(userIds: string[], downloadUncached?: boolean): Promise<DeviceMap>;
|
||||
|
||||
/**
|
||||
* Set whether to trust other user's signatures of their devices.
|
||||
*
|
||||
* If false, devices will only be considered 'verified' if we have
|
||||
* verified that device individually (effectively disabling cross-signing).
|
||||
*
|
||||
* `true` by default.
|
||||
*
|
||||
* @param val - the new value
|
||||
*/
|
||||
setTrustCrossSignedDevices(val: boolean): void;
|
||||
|
||||
/**
|
||||
* Return whether we trust other user's signatures of their devices.
|
||||
*
|
||||
* @see {@link Crypto.CryptoApi#setTrustCrossSignedDevices}
|
||||
*
|
||||
* @returns `true` if we trust cross-signed devices, otherwise `false`.
|
||||
*/
|
||||
getTrustCrossSignedDevices(): boolean;
|
||||
|
||||
/**
|
||||
* Get the verification status of a given device.
|
||||
*
|
||||
* @param userId - The ID of the user whose device is to be checked.
|
||||
* @param deviceId - The ID of the device to check
|
||||
*
|
||||
* @returns `null` if the device is unknown, or has not published any encryption keys (implying it does not support
|
||||
* encryption); otherwise the verification status of the device.
|
||||
*/
|
||||
getDeviceVerificationStatus(userId: string, deviceId: string): Promise<DeviceVerificationStatus | null>;
|
||||
|
||||
/**
|
||||
* Checks whether cross signing:
|
||||
* - is enabled on this account and trusted by this device
|
||||
* - has private keys either cached locally or stored in secret storage
|
||||
*
|
||||
* If this function returns false, bootstrapCrossSigning() can be used
|
||||
* to fix things such that it returns true. That is to say, after
|
||||
* bootstrapCrossSigning() completes successfully, this function should
|
||||
* return true.
|
||||
*
|
||||
* @returns True if cross-signing is ready to be used on this device
|
||||
*/
|
||||
isCrossSigningReady(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get the ID of one of the user's cross-signing keys.
|
||||
*
|
||||
* @param type - The type of key to get the ID of. One of `CrossSigningKey.Master`, `CrossSigningKey.SelfSigning`,
|
||||
* or `CrossSigningKey.UserSigning`. Defaults to `CrossSigningKey.Master`.
|
||||
*
|
||||
* @returns If cross-signing has been initialised on this device, the ID of the given key. Otherwise, null
|
||||
*/
|
||||
getCrossSigningKeyId(type?: CrossSigningKey): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Bootstrap cross-signing by creating keys if needed.
|
||||
*
|
||||
* If everything is already set up, then no changes are made, so this is safe to run to ensure
|
||||
* cross-signing is ready for use.
|
||||
*
|
||||
* This function:
|
||||
* - creates new cross-signing keys if they are not found locally cached nor in
|
||||
* secret storage (if it has been set up)
|
||||
* - publishes the public keys to the server if they are not already published
|
||||
* - stores the private keys in secret storage if secret storage is set up.
|
||||
*
|
||||
* @param opts - options object
|
||||
*/
|
||||
bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void>;
|
||||
|
||||
/**
|
||||
* Checks whether secret storage:
|
||||
* - is enabled on this account
|
||||
* - is storing cross-signing private keys
|
||||
* - is storing session backup key (if enabled)
|
||||
*
|
||||
* If this function returns false, bootstrapSecretStorage() can be used
|
||||
* to fix things such that it returns true. That is to say, after
|
||||
* bootstrapSecretStorage() completes successfully, this function should
|
||||
* return true.
|
||||
*
|
||||
* @returns True if secret storage is ready to be used on this device
|
||||
*/
|
||||
isSecretStorageReady(): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options object for `CryptoApi.bootstrapCrossSigning`.
|
||||
*/
|
||||
export interface BootstrapCrossSigningOpts {
|
||||
/** Optional. Reset the cross-signing keys even if keys already exist. */
|
||||
setupNewCrossSigning?: boolean;
|
||||
|
||||
/**
|
||||
* An application callback to collect the authentication data for uploading the keys. If not given, the keys
|
||||
* will not be uploaded to the server (which seems like a bad thing?).
|
||||
*/
|
||||
authUploadDeviceSigningKeys?: UIAuthCallback<void>;
|
||||
}
|
||||
|
||||
export class DeviceVerificationStatus {
|
||||
/**
|
||||
* True if this device has been signed by its owner (and that signature verified).
|
||||
*
|
||||
* This doesn't necessarily mean that we have verified the device, since we may not have verified the
|
||||
* owner's cross-signing key.
|
||||
*/
|
||||
public readonly signedByOwner: boolean;
|
||||
|
||||
/**
|
||||
* True if this device has been verified via cross signing.
|
||||
*
|
||||
* This does *not* take into account `trustCrossSignedDevices`.
|
||||
*/
|
||||
public readonly crossSigningVerified: boolean;
|
||||
|
||||
/**
|
||||
* TODO: tofu magic wtf does this do?
|
||||
*/
|
||||
public readonly tofu: boolean;
|
||||
|
||||
/**
|
||||
* True if the device has been marked as locally verified.
|
||||
*/
|
||||
public readonly localVerified: boolean;
|
||||
|
||||
/**
|
||||
* True if the client has been configured to trust cross-signed devices via {@link CryptoApi#setTrustCrossSignedDevices}.
|
||||
*/
|
||||
private readonly trustCrossSignedDevices: boolean;
|
||||
|
||||
public constructor(
|
||||
opts: Partial<DeviceVerificationStatus> & {
|
||||
/**
|
||||
* True if cross-signed devices should be considered verified for {@link DeviceVerificationStatus#isVerified}.
|
||||
*/
|
||||
trustCrossSignedDevices?: boolean;
|
||||
},
|
||||
) {
|
||||
this.signedByOwner = opts.signedByOwner ?? false;
|
||||
this.crossSigningVerified = opts.crossSigningVerified ?? false;
|
||||
this.tofu = opts.tofu ?? false;
|
||||
this.localVerified = opts.localVerified ?? false;
|
||||
this.trustCrossSignedDevices = opts.trustCrossSignedDevices ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should consider this device "verified".
|
||||
*
|
||||
* A device is "verified" if either:
|
||||
* * it has been manually marked as such via {@link MatrixClient#setDeviceVerified}.
|
||||
* * it has been cross-signed with a verified signing key, **and** the client has been configured to trust
|
||||
* cross-signed devices via {@link Crypto.CryptoApi#setTrustCrossSignedDevices}.
|
||||
*
|
||||
* @returns true if this device is verified via any means.
|
||||
*/
|
||||
public isVerified(): boolean {
|
||||
return this.localVerified || (this.trustCrossSignedDevices && this.crossSigningVerified);
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./crypto-api/verification";
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
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 { MatrixEvent } from "../models/event";
|
||||
|
||||
/** Events emitted by `Verifier`. */
|
||||
export enum VerifierEvent {
|
||||
/**
|
||||
* The verification has been cancelled, by us or the other side.
|
||||
*
|
||||
* The payload is either an {@link Error}, or an (incoming or outgoing) {@link MatrixEvent}, depending on
|
||||
* unspecified reasons.
|
||||
*/
|
||||
Cancel = "cancel",
|
||||
|
||||
/**
|
||||
* SAS data has been exchanged and should be displayed to the user.
|
||||
*
|
||||
* The payload is the {@link ShowQrCodeCallbacks} object.
|
||||
*/
|
||||
ShowSas = "show_sas",
|
||||
|
||||
/**
|
||||
* QR code data should be displayed to the user.
|
||||
*
|
||||
* The payload is the {@link ShowQrCodeCallbacks} object.
|
||||
*/
|
||||
ShowReciprocateQr = "show_reciprocate_qr",
|
||||
}
|
||||
|
||||
/** Listener type map for {@link VerifierEvent}s. */
|
||||
export type VerifierEventHandlerMap = {
|
||||
[VerifierEvent.Cancel]: (e: Error | MatrixEvent) => void;
|
||||
[VerifierEvent.ShowSas]: (sas: ShowSasCallbacks) => void;
|
||||
[VerifierEvent.ShowReciprocateQr]: (qr: ShowQrCodeCallbacks) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callbacks for user actions while a QR code is displayed.
|
||||
*
|
||||
* This is exposed as the payload of a `VerifierEvent.ShowReciprocateQr` event, or can be retrieved directly from the
|
||||
* verifier as `reciprocateQREvent`.
|
||||
*/
|
||||
export interface ShowQrCodeCallbacks {
|
||||
/** The user confirms that the verification data matches */
|
||||
confirm(): void;
|
||||
|
||||
/** Cancel the verification flow */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callbacks for user actions while a SAS is displayed.
|
||||
*
|
||||
* This is exposed as the payload of a `VerifierEvent.ShowSas` event, or directly from the verifier as `sasEvent`.
|
||||
*/
|
||||
export interface ShowSasCallbacks {
|
||||
/** The generated SAS to be shown to the user */
|
||||
sas: GeneratedSas;
|
||||
|
||||
/** Function to call if the user confirms that the SAS matches.
|
||||
*
|
||||
* @returns A Promise that completes once the m.key.verification.mac is queued.
|
||||
*/
|
||||
confirm(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to call if the user finds the SAS does not match.
|
||||
*
|
||||
* Sends an `m.key.verification.cancel` event with a `m.mismatched_sas` error code.
|
||||
*/
|
||||
mismatch(): void;
|
||||
|
||||
/** Cancel the verification flow */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/** A generated SAS to be shown to the user, in alternative formats */
|
||||
export interface GeneratedSas {
|
||||
/**
|
||||
* The SAS as three numbers between 0 and 8191.
|
||||
*
|
||||
* Only populated if the `decimal` SAS method was negotiated.
|
||||
*/
|
||||
decimal?: [number, number, number];
|
||||
|
||||
/**
|
||||
* The SAS as seven emojis.
|
||||
*
|
||||
* Only populated if the `emoji` SAS method was negotiated.
|
||||
*/
|
||||
emoji?: EmojiMapping[];
|
||||
}
|
||||
|
||||
/**
|
||||
* An emoji for the generated SAS. A tuple `[emoji, name]` where `emoji` is the emoji itself and `name` is the
|
||||
* English name.
|
||||
*/
|
||||
export type EmojiMapping = [emoji: string, name: string];
|
||||
+16
-16
@@ -31,6 +31,7 @@ import { ICryptoCallbacks } from ".";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { CryptoStore, SecretStorePrivateKeys } from "./store/base";
|
||||
import { ServerSideSecretStorage, SecretStorageKeyDescription } from "../secret-storage";
|
||||
import { DeviceVerificationStatus } from "../crypto-api";
|
||||
|
||||
const KEY_REQUEST_TIMEOUT_MS = 1000 * 60;
|
||||
|
||||
@@ -628,15 +629,20 @@ export class UserTrustLevel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the ways in which we trust a device
|
||||
* Represents the ways in which we trust a device.
|
||||
*
|
||||
* @deprecated Use {@link DeviceVerificationStatus}.
|
||||
*/
|
||||
export class DeviceTrustLevel {
|
||||
export class DeviceTrustLevel extends DeviceVerificationStatus {
|
||||
public constructor(
|
||||
public readonly crossSigningVerified: boolean,
|
||||
public readonly tofu: boolean,
|
||||
private readonly localVerified: boolean,
|
||||
private readonly trustCrossSignedDevices: boolean,
|
||||
) {}
|
||||
crossSigningVerified: boolean,
|
||||
tofu: boolean,
|
||||
localVerified: boolean,
|
||||
trustCrossSignedDevices: boolean,
|
||||
signedByOwner = false,
|
||||
) {
|
||||
super({ crossSigningVerified, tofu, localVerified, trustCrossSignedDevices, signedByOwner });
|
||||
}
|
||||
|
||||
public static fromUserTrustLevel(
|
||||
userTrustLevel: UserTrustLevel,
|
||||
@@ -648,16 +654,10 @@ export class DeviceTrustLevel {
|
||||
userTrustLevel.isTofu(),
|
||||
localVerified,
|
||||
trustCrossSignedDevices,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if this device is verified via any means
|
||||
*/
|
||||
public isVerified(): boolean {
|
||||
return Boolean(this.isLocallyVerified() || (this.trustCrossSignedDevices && this.isCrossSigningVerified()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if this device is verified via cross signing
|
||||
*/
|
||||
@@ -688,7 +688,7 @@ export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: O
|
||||
_expectedPublicKey: string,
|
||||
): Promise<Uint8Array> {
|
||||
const key = await new Promise<any>((resolve) => {
|
||||
return store.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
|
||||
store.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
|
||||
store.getSecretStorePrivateKey(txn, resolve, type);
|
||||
});
|
||||
});
|
||||
@@ -790,7 +790,7 @@ export async function requestKeysDuringVerification(
|
||||
})();
|
||||
|
||||
// We call getCrossSigningKey() for its side-effects
|
||||
return Promise.race<KeysDuringVerification | void>([
|
||||
Promise.race<KeysDuringVerification | void>([
|
||||
Promise.all([
|
||||
crossSigning.getCrossSigningKey("master"),
|
||||
crossSigning.getCrossSigningKey("self_signing"),
|
||||
|
||||
@@ -116,7 +116,7 @@ export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHand
|
||||
public async load(): Promise<void> {
|
||||
await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => {
|
||||
this.cryptoStore.getEndToEndDeviceData(txn, (deviceData) => {
|
||||
this.hasFetched = Boolean(deviceData && deviceData.devices);
|
||||
this.hasFetched = Boolean(deviceData?.devices);
|
||||
this.devices = deviceData ? deviceData.devices : {};
|
||||
this.crossSigningInfo = deviceData ? deviceData.crossSigningInfo || {} : {};
|
||||
this.deviceTrackingStatus = deviceData ? deviceData.trackingStatus : {};
|
||||
|
||||
@@ -19,7 +19,7 @@ import { MatrixEvent } from "../models/event";
|
||||
import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning";
|
||||
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
|
||||
import { Method, ClientPrefix } from "../http-api";
|
||||
import { Crypto, ICryptoCallbacks, IBootstrapCrossSigningOpts } from "./index";
|
||||
import { Crypto, ICryptoCallbacks } from "./index";
|
||||
import {
|
||||
ClientEvent,
|
||||
ClientEventHandlerMap,
|
||||
@@ -31,9 +31,10 @@ import {
|
||||
import { IKeyBackupInfo } from "./keybackup";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { AccountDataClient, SecretStorageKeyDescription } from "../secret-storage";
|
||||
import { BootstrapCrossSigningOpts } from "../crypto-api";
|
||||
|
||||
interface ICrossSigningKeys {
|
||||
authUpload: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"];
|
||||
authUpload: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"];
|
||||
keys: Record<"master" | "self_signing" | "user_signing", ICrossSigningKey>;
|
||||
}
|
||||
|
||||
|
||||
@@ -192,12 +192,23 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
});
|
||||
}
|
||||
|
||||
// check that the device that encrypted the event belongs to the user
|
||||
// that the event claims it's from. We need to make sure that our
|
||||
// device list is up-to-date. If the device is unknown, we can only
|
||||
// assume that the device logged out. Some event handlers, such as
|
||||
// secret sharing, may be more strict and reject events that come from
|
||||
// unknown devices.
|
||||
// check that the device that encrypted the event belongs to the user that the event claims it's from.
|
||||
//
|
||||
// To do this, we need to make sure that our device list is up-to-date. If the device is unknown, we can only
|
||||
// assume that the device logged out and accept it anyway. Some event handlers, such as secret sharing, may be
|
||||
// more strict and reject events that come from unknown devices.
|
||||
//
|
||||
// This is a defence against the following scenario:
|
||||
//
|
||||
// * Alice has verified Bob and Mallory.
|
||||
// * Mallory gets control of Alice's server, and sends a megolm session to Alice using her (Mallory's)
|
||||
// senderkey, but claiming to be from Bob.
|
||||
// * Mallory sends more events using that session, claiming to be from Bob.
|
||||
// * Alice sees that the senderkey is verified (since she verified Mallory) so marks events those
|
||||
// events as verified even though the sender is forged.
|
||||
//
|
||||
// In practice, it's not clear that the js-sdk would behave that way, so this may be only a defence in depth.
|
||||
|
||||
await this.crypto.deviceList.downloadKeys([event.getSender()!], false);
|
||||
const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
|
||||
if (senderKeyUser !== event.getSender() && senderKeyUser != undefined) {
|
||||
|
||||
+1
-6
@@ -19,6 +19,7 @@ import { IKeyBackupInfo } from "./keybackup";
|
||||
import type { AddSecretStorageKeyOpts } from "../secret-storage";
|
||||
|
||||
/* re-exports for backwards compatibility. */
|
||||
export { CrossSigningKey } from "../crypto-api";
|
||||
export type {
|
||||
AddSecretStorageKeyOpts as IAddSecretStorageKeyOpts,
|
||||
PassphraseInfo as IPassphraseInfo,
|
||||
@@ -27,12 +28,6 @@ export type {
|
||||
|
||||
// TODO: Merge this with crypto.js once converted
|
||||
|
||||
export enum CrossSigningKey {
|
||||
Master = "master",
|
||||
SelfSigning = "self_signing",
|
||||
UserSigning = "user_signing",
|
||||
}
|
||||
|
||||
export interface IEncryptedEventInfo {
|
||||
/**
|
||||
* whether the event is encrypted (if not encrypted, some of the other properties may not be set)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
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 { Device } from "../models/device";
|
||||
import { DeviceInfo } from "./deviceinfo";
|
||||
|
||||
/**
|
||||
* Convert a {@link DeviceInfo} to a {@link Device}.
|
||||
* @param deviceInfo - deviceInfo to convert
|
||||
* @param userId - id of the user that owns the device.
|
||||
*/
|
||||
export function deviceInfoToDevice(deviceInfo: DeviceInfo, userId: string): Device {
|
||||
const keys = new Map<string, string>(Object.entries(deviceInfo.keys));
|
||||
const displayName = deviceInfo.getDisplayName() || undefined;
|
||||
|
||||
const signatures = new Map<string, Map<string, string>>();
|
||||
if (deviceInfo.signatures) {
|
||||
for (const userId in deviceInfo.signatures) {
|
||||
signatures.set(userId, new Map(Object.entries(deviceInfo.signatures[userId])));
|
||||
}
|
||||
}
|
||||
|
||||
return new Device({
|
||||
deviceId: deviceInfo.deviceId,
|
||||
userId: userId,
|
||||
keys,
|
||||
algorithms: deviceInfo.algorithms,
|
||||
verified: deviceInfo.verified,
|
||||
signatures,
|
||||
displayName,
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { DeviceVerification } from "../models/device";
|
||||
|
||||
export interface IDevice {
|
||||
keys: Record<string, string>;
|
||||
@@ -25,12 +26,6 @@ export interface IDevice {
|
||||
signatures?: ISignatures;
|
||||
}
|
||||
|
||||
enum DeviceVerification {
|
||||
Blocked = -1,
|
||||
Unverified = 0,
|
||||
Verified = 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a user's device
|
||||
*/
|
||||
|
||||
+104
-26
@@ -35,7 +35,13 @@ import * as algorithms from "./algorithms";
|
||||
import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from "./CrossSigning";
|
||||
import { EncryptionSetupBuilder } from "./EncryptionSetup";
|
||||
import { SecretStorage as LegacySecretStorage } from "./SecretStorage";
|
||||
import { ICreateSecretStorageOpts, IEncryptedEventInfo, IImportRoomKeysOpts, IRecoveryKey } from "./api";
|
||||
import {
|
||||
CrossSigningKey,
|
||||
ICreateSecretStorageOpts,
|
||||
IEncryptedEventInfo,
|
||||
IImportRoomKeysOpts,
|
||||
IRecoveryKey,
|
||||
} from "./api";
|
||||
import { OutgoingRoomKeyRequestManager } from "./OutgoingRoomKeyRequestManager";
|
||||
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
|
||||
import { VerificationBase } from "./verification/Base";
|
||||
@@ -45,7 +51,7 @@ import { keyFromPassphrase } from "./key_passphrase";
|
||||
import { decodeRecoveryKey, encodeRecoveryKey } from "./recoverykey";
|
||||
import { VerificationRequest } from "./verification/request/VerificationRequest";
|
||||
import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel";
|
||||
import { ToDeviceChannel, ToDeviceRequests, Request } from "./verification/request/ToDeviceChannel";
|
||||
import { Request, ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel";
|
||||
import { IllegalMethod } from "./verification/IllegalMethod";
|
||||
import { KeySignatureUploadError } from "../errors";
|
||||
import { calculateKeyCheck, decryptAES, encryptAES } from "./aes";
|
||||
@@ -54,7 +60,7 @@ import { BackupManager } from "./backup";
|
||||
import { IStore } from "../store";
|
||||
import { Room, RoomEvent } from "../models/room";
|
||||
import { RoomMember, RoomMemberEvent } from "../models/room-member";
|
||||
import { EventStatus, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event";
|
||||
import { EventStatus, IContent, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event";
|
||||
import { ToDeviceBatch } from "../models/ToDeviceMessage";
|
||||
import {
|
||||
ClientEvent,
|
||||
@@ -70,7 +76,6 @@ import { ISyncStateData } from "../sync";
|
||||
import { CryptoStore } from "./store/base";
|
||||
import { IVerificationChannel } from "./verification/request/Channel";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { IContent } from "../models/event";
|
||||
import { IDeviceLists, ISyncResponse, IToDeviceEvent } from "../sync-accumulator";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { IMessage } from "./algorithms/olm";
|
||||
@@ -80,14 +85,20 @@ import { MapWithDefault, recursiveMapToObject } from "../utils";
|
||||
import {
|
||||
AccountDataClient,
|
||||
AddSecretStorageKeyOpts,
|
||||
SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
SecretStorageCallbacks,
|
||||
SecretStorageKeyDescription,
|
||||
SecretStorageKeyObject,
|
||||
SecretStorageKeyTuple,
|
||||
SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
SecretStorageCallbacks,
|
||||
ServerSideSecretStorageImpl,
|
||||
} from "../secret-storage";
|
||||
import { ISecretRequest } from "./SecretSharing";
|
||||
import { BootstrapCrossSigningOpts, DeviceVerificationStatus } from "../crypto-api";
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
import { deviceInfoToDevice } from "./device-converter";
|
||||
|
||||
/* re-exports for backwards compatibility */
|
||||
export type { BootstrapCrossSigningOpts as IBootstrapCrossSigningOpts } from "../crypto-api";
|
||||
|
||||
const DeviceVerification = DeviceInfo.DeviceVerification;
|
||||
|
||||
@@ -124,16 +135,6 @@ interface IInitOpts {
|
||||
pickleKey?: string;
|
||||
}
|
||||
|
||||
export interface IBootstrapCrossSigningOpts {
|
||||
/** Optional. Reset even if keys already exist. */
|
||||
setupNewCrossSigning?: boolean;
|
||||
/**
|
||||
* A function that makes the request requiring auth. Receives the auth data as an object.
|
||||
* Can be called multiple times, first with an empty authDict, to obtain the flows.
|
||||
*/
|
||||
authUploadDeviceSigningKeys?(makeRequest: (authData: any) => Promise<{}>): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ICryptoCallbacks extends SecretStorageCallbacks {
|
||||
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
|
||||
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
|
||||
@@ -605,18 +606,23 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @returns True if trusting cross-signed devices
|
||||
*/
|
||||
public getTrustCrossSignedDevices(): boolean {
|
||||
return this.trustCrossSignedDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link Crypto.CryptoApi#getTrustCrossSignedDevices}.
|
||||
*/
|
||||
public getCryptoTrustCrossSignedDevices(): boolean {
|
||||
return this.trustCrossSignedDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
* See getCryptoTrustCrossSignedDevices
|
||||
|
||||
* This may be set before initCrypto() is called to ensure no races occur.
|
||||
*
|
||||
* @param val - True to trust cross-signed devices
|
||||
*/
|
||||
public setCryptoTrustCrossSignedDevices(val: boolean): void {
|
||||
public setTrustCrossSignedDevices(val: boolean): void {
|
||||
this.trustCrossSignedDevices = val;
|
||||
|
||||
for (const userId of this.deviceList.getKnownUserIds()) {
|
||||
@@ -634,6 +640,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link Crypto.CryptoApi#setTrustCrossSignedDevices}.
|
||||
*/
|
||||
public setCryptoTrustCrossSignedDevices(val: boolean): void {
|
||||
this.setTrustCrossSignedDevices(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a recovery key from a user-supplied passphrase.
|
||||
*
|
||||
@@ -754,7 +767,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
public async bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys,
|
||||
setupNewCrossSigning,
|
||||
}: IBootstrapCrossSigningOpts = {}): Promise<void> {
|
||||
}: BootstrapCrossSigningOpts = {}): Promise<void> {
|
||||
logger.log("Bootstrapping cross-signing");
|
||||
|
||||
const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks;
|
||||
@@ -1407,6 +1420,11 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @returns the key ID
|
||||
*/
|
||||
public getCrossSigningKeyId(type: CrossSigningKey = CrossSigningKey.Master): Promise<string | null> {
|
||||
return Promise.resolve(this.getCrossSigningId(type));
|
||||
}
|
||||
|
||||
// old name, for backwards compatibility
|
||||
public getCrossSigningId(type: string): string | null {
|
||||
return this.crossSigningInfo.getId(type);
|
||||
}
|
||||
@@ -1440,10 +1458,22 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
/**
|
||||
* Check whether a given device is trusted.
|
||||
*
|
||||
* @param userId - The ID of the user whose devices is to be checked.
|
||||
* @param userId - The ID of the user whose device is to be checked.
|
||||
* @param deviceId - The ID of the device to check
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
public async getDeviceVerificationStatus(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
): Promise<DeviceVerificationStatus | null> {
|
||||
const device = this.deviceList.getStoredDevice(userId, deviceId);
|
||||
if (!device) {
|
||||
return null;
|
||||
}
|
||||
return this.checkDeviceInfoTrust(userId, device);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus}.
|
||||
*/
|
||||
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
|
||||
const device = this.deviceList.getStoredDevice(userId, deviceId);
|
||||
@@ -1456,7 +1486,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
* @param userId - The ID of the user whose devices is to be checked.
|
||||
* @param device - The device info object to check
|
||||
*
|
||||
* @returns
|
||||
* @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus}.
|
||||
*/
|
||||
public checkDeviceInfoTrust(userId: string, device?: DeviceInfo): DeviceTrustLevel {
|
||||
const trustedLocally = !!device?.isVerified();
|
||||
@@ -1805,7 +1835,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @param value - whether to blacklist all unverified devices by default
|
||||
*
|
||||
* @deprecated Set {@link CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
|
||||
* @deprecated Set {@link Crypto.CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
|
||||
*/
|
||||
public setGlobalBlacklistUnverifiedDevices(value: boolean): void {
|
||||
this.globalBlacklistUnverifiedDevices = value;
|
||||
@@ -1814,7 +1844,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
/**
|
||||
* @returns whether to blacklist all unverified devices by default
|
||||
*
|
||||
* @deprecated Reference {@link CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
|
||||
* @deprecated Reference {@link Crypto.CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
|
||||
*/
|
||||
public getGlobalBlacklistUnverifiedDevices(): boolean {
|
||||
return this.globalBlacklistUnverifiedDevices;
|
||||
@@ -2038,6 +2068,54 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return this.deviceList.getStoredDevicesForUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device information for the given list of users.
|
||||
*
|
||||
* @param userIds - The users to fetch.
|
||||
* @param downloadUncached - If true, download the device list for users whose device list we are not
|
||||
* currently tracking. Defaults to false, in which case such users will not appear at all in the result map.
|
||||
*
|
||||
* @returns A map `{@link DeviceMap}`.
|
||||
*/
|
||||
public async getUserDeviceInfo(userIds: string[], downloadUncached = false): Promise<DeviceMap> {
|
||||
const deviceMapByUserId = new Map<string, Map<string, Device>>();
|
||||
// Keep the users without device to download theirs keys
|
||||
const usersWithoutDeviceInfo: string[] = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
const deviceInfos = await this.getStoredDevicesForUser(userId);
|
||||
// If there are device infos for a userId, we transform it into a map
|
||||
// Else, the keys will be downloaded after
|
||||
if (deviceInfos) {
|
||||
const deviceMap = new Map(
|
||||
// Convert DeviceInfo to Device
|
||||
deviceInfos.map((deviceInfo) => [deviceInfo.deviceId, deviceInfoToDevice(deviceInfo, userId)]),
|
||||
);
|
||||
deviceMapByUserId.set(userId, deviceMap);
|
||||
} else {
|
||||
usersWithoutDeviceInfo.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Download device info for users without device infos
|
||||
if (downloadUncached && usersWithoutDeviceInfo.length > 0) {
|
||||
const newDeviceInfoMap = await this.downloadKeys(usersWithoutDeviceInfo);
|
||||
|
||||
newDeviceInfoMap.forEach((deviceInfoMap, userId) => {
|
||||
const deviceMap = new Map<string, Device>();
|
||||
// Convert DeviceInfo to Device
|
||||
deviceInfoMap.forEach((deviceInfo, deviceId) =>
|
||||
deviceMap.set(deviceId, deviceInfoToDevice(deviceInfo, userId)),
|
||||
);
|
||||
|
||||
// Put the new device infos into the returned map
|
||||
deviceMapByUserId.set(userId, deviceMap);
|
||||
});
|
||||
}
|
||||
|
||||
return deviceMapByUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stored keys for a single device
|
||||
*
|
||||
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger, PrefixedLogger } from "../../logger";
|
||||
import * as utils from "../../utils";
|
||||
import { deepCompare } from "../../utils";
|
||||
import {
|
||||
CryptoStore,
|
||||
IDeviceData,
|
||||
@@ -158,7 +158,7 @@ export class Backend implements CryptoStore {
|
||||
|
||||
const existing = cursor.value;
|
||||
|
||||
if (utils.deepCompare(existing.requestBody, requestBody)) {
|
||||
if (deepCompare(existing.requestBody, requestBody)) {
|
||||
// got a match
|
||||
callback(existing);
|
||||
return;
|
||||
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger } from "../../logger";
|
||||
import * as utils from "../../utils";
|
||||
import { safeSet, deepCompare, promiseTry } from "../../utils";
|
||||
import {
|
||||
CryptoStore,
|
||||
IDeviceData,
|
||||
@@ -33,7 +33,6 @@ import { ICrossSigningKey } from "../../client";
|
||||
import { IOlmDevice } from "../algorithms/megolm";
|
||||
import { IRoomEncryption } from "../RoomList";
|
||||
import { InboundGroupSessionData } from "../OlmDevice";
|
||||
import { safeSet } from "../../utils";
|
||||
|
||||
/**
|
||||
* Internal module. in-memory storage for e2e.
|
||||
@@ -90,7 +89,7 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
|
||||
const requestBody = request.requestBody;
|
||||
|
||||
return utils.promiseTry(() => {
|
||||
return promiseTry(() => {
|
||||
// first see if we already have an entry for this request.
|
||||
const existing = this._getOutgoingRoomKeyRequest(requestBody);
|
||||
|
||||
@@ -138,7 +137,7 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
private _getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): OutgoingRoomKeyRequest | null {
|
||||
for (const existing of this.outgoingRoomKeyRequests) {
|
||||
if (utils.deepCompare(existing.requestBody, requestBody)) {
|
||||
if (deepCompare(existing.requestBody, requestBody)) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ import { KeysDuringVerification, requestKeysDuringVerification } from "../CrossS
|
||||
import { IVerificationChannel } from "./request/Channel";
|
||||
import { MatrixClient } from "../../client";
|
||||
import { VerificationRequest } from "./request/VerificationRequest";
|
||||
import { ListenerMap, TypedEventEmitter } from "../../models/typed-event-emitter";
|
||||
import { TypedEventEmitter } from "../../models/typed-event-emitter";
|
||||
import { VerifierEvent, VerifierEventHandlerMap } from "../../crypto-api/verification";
|
||||
|
||||
const timeoutException = new Error("Verification timed out");
|
||||
|
||||
@@ -40,18 +41,24 @@ export class SwitchStartEventError extends Error {
|
||||
|
||||
export type KeyVerifier = (keyId: string, device: DeviceInfo, keyInfo: string) => void;
|
||||
|
||||
export enum VerificationEvent {
|
||||
Cancel = "cancel",
|
||||
}
|
||||
/** @deprecated use VerifierEvent */
|
||||
export type VerificationEvent = VerifierEvent;
|
||||
/** @deprecated use VerifierEvent */
|
||||
export const VerificationEvent = VerifierEvent;
|
||||
|
||||
/** @deprecated use VerifierEventHandlerMap */
|
||||
export type VerificationEventHandlerMap = {
|
||||
[VerificationEvent.Cancel]: (e: Error | MatrixEvent) => void;
|
||||
};
|
||||
|
||||
// The type parameters of VerificationBase are no longer used, but we need some placeholders to maintain
|
||||
// backwards compatibility with applications that reference the class.
|
||||
export class VerificationBase<
|
||||
Events extends string,
|
||||
Arguments extends ListenerMap<Events | VerificationEvent>,
|
||||
> extends TypedEventEmitter<Events | VerificationEvent, Arguments, VerificationEventHandlerMap> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
Events extends string = VerifierEvent,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
Arguments = VerifierEventHandlerMap,
|
||||
> extends TypedEventEmitter<VerifierEvent, VerifierEventHandlerMap> {
|
||||
private cancelled = false;
|
||||
private _done = false;
|
||||
private promise: Promise<void> | null = null;
|
||||
|
||||
@@ -18,7 +18,7 @@ limitations under the License.
|
||||
* QR code key verification.
|
||||
*/
|
||||
|
||||
import { VerificationBase as Base, VerificationEventHandlerMap } from "./Base";
|
||||
import { VerificationBase as Base } from "./Base";
|
||||
import { newKeyMismatchError, newUserCancelledError } from "./Error";
|
||||
import { decodeBase64, encodeUnpaddedBase64 } from "../olmlib";
|
||||
import { logger } from "../../logger";
|
||||
@@ -26,25 +26,18 @@ import { VerificationRequest } from "./request/VerificationRequest";
|
||||
import { MatrixClient } from "../../client";
|
||||
import { IVerificationChannel } from "./request/Channel";
|
||||
import { MatrixEvent } from "../../models/event";
|
||||
import { ShowQrCodeCallbacks, VerifierEvent } from "../../crypto-api/verification";
|
||||
|
||||
export const SHOW_QR_CODE_METHOD = "m.qr_code.show.v1";
|
||||
export const SCAN_QR_CODE_METHOD = "m.qr_code.scan.v1";
|
||||
|
||||
interface IReciprocateQr {
|
||||
confirm(): void;
|
||||
cancel(): void;
|
||||
}
|
||||
/** @deprecated use VerifierEvent */
|
||||
export type QrCodeEvent = VerifierEvent;
|
||||
/** @deprecated use VerifierEvent */
|
||||
export const QrCodeEvent = VerifierEvent;
|
||||
|
||||
export enum QrCodeEvent {
|
||||
ShowReciprocateQr = "show_reciprocate_qr",
|
||||
}
|
||||
|
||||
type EventHandlerMap = {
|
||||
[QrCodeEvent.ShowReciprocateQr]: (qr: IReciprocateQr) => void;
|
||||
} & VerificationEventHandlerMap;
|
||||
|
||||
export class ReciprocateQRCode extends Base<QrCodeEvent, EventHandlerMap> {
|
||||
public reciprocateQREvent?: IReciprocateQr;
|
||||
export class ReciprocateQRCode extends Base {
|
||||
public reciprocateQREvent?: ShowQrCodeCallbacks;
|
||||
|
||||
public static factory(
|
||||
channel: IVerificationChannel,
|
||||
|
||||
@@ -21,7 +21,7 @@ limitations under the License.
|
||||
import anotherjson from "another-json";
|
||||
import { Utility, SAS as OlmSAS } from "@matrix-org/olm";
|
||||
|
||||
import { VerificationBase as Base, SwitchStartEventError, VerificationEventHandlerMap } from "./Base";
|
||||
import { VerificationBase as Base, SwitchStartEventError } from "./Base";
|
||||
import {
|
||||
errorFactory,
|
||||
newInvalidMessageError,
|
||||
@@ -33,6 +33,14 @@ import { logger } from "../../logger";
|
||||
import { IContent, MatrixEvent } from "../../models/event";
|
||||
import { generateDecimalSas } from "./SASDecimal";
|
||||
import { EventType } from "../../@types/event";
|
||||
import { EmojiMapping, GeneratedSas, ShowSasCallbacks, VerifierEvent } from "../../crypto-api/verification";
|
||||
|
||||
// backwards-compatibility exports
|
||||
export type {
|
||||
ShowSasCallbacks as ISasEvent,
|
||||
GeneratedSas as IGeneratedSas,
|
||||
EmojiMapping,
|
||||
} from "../../crypto-api/verification";
|
||||
|
||||
const START_TYPE = EventType.KeyVerificationStart;
|
||||
|
||||
@@ -44,8 +52,6 @@ const newMismatchedSASError = errorFactory("m.mismatched_sas", "Mismatched short
|
||||
|
||||
const newMismatchedCommitmentError = errorFactory("m.mismatched_commitment", "Mismatched commitment");
|
||||
|
||||
type EmojiMapping = [emoji: string, name: string];
|
||||
|
||||
const emojiMapping: EmojiMapping[] = [
|
||||
["🐶", "dog"], // 0
|
||||
["🐱", "cat"], // 1
|
||||
@@ -133,20 +139,8 @@ const sasGenerators = {
|
||||
emoji: generateEmojiSas,
|
||||
} as const;
|
||||
|
||||
export interface IGeneratedSas {
|
||||
decimal?: [number, number, number];
|
||||
emoji?: EmojiMapping[];
|
||||
}
|
||||
|
||||
export interface ISasEvent {
|
||||
sas: IGeneratedSas;
|
||||
confirm(): Promise<void>;
|
||||
cancel(): void;
|
||||
mismatch(): void;
|
||||
}
|
||||
|
||||
function generateSas(sasBytes: Uint8Array, methods: string[]): IGeneratedSas {
|
||||
const sas: IGeneratedSas = {};
|
||||
function generateSas(sasBytes: Uint8Array, methods: string[]): GeneratedSas {
|
||||
const sas: GeneratedSas = {};
|
||||
for (const method of methods) {
|
||||
if (method in sasGenerators) {
|
||||
// @ts-ignore - ts doesn't like us mixing types like this
|
||||
@@ -220,19 +214,16 @@ function intersection<T>(anArray: T[], aSet: Set<T>): T[] {
|
||||
return Array.isArray(anArray) ? anArray.filter((x) => aSet.has(x)) : [];
|
||||
}
|
||||
|
||||
export enum SasEvent {
|
||||
ShowSas = "show_sas",
|
||||
}
|
||||
/** @deprecated use VerifierEvent */
|
||||
export type SasEvent = VerifierEvent;
|
||||
/** @deprecated use VerifierEvent */
|
||||
export const SasEvent = VerifierEvent;
|
||||
|
||||
type EventHandlerMap = {
|
||||
[SasEvent.ShowSas]: (sas: ISasEvent) => void;
|
||||
} & VerificationEventHandlerMap;
|
||||
|
||||
export class SAS extends Base<SasEvent, EventHandlerMap> {
|
||||
export class SAS extends Base {
|
||||
private waitingForAccept?: boolean;
|
||||
public ourSASPubKey?: string;
|
||||
public theirSASPubKey?: string;
|
||||
public sasEvent?: ISasEvent;
|
||||
public sasEvent?: ShowSasCallbacks;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public static get NAME(): string {
|
||||
|
||||
@@ -125,7 +125,7 @@ export class InRoomChannel implements IVerificationChannel {
|
||||
// part of a verification request, so be noisy when rejecting something
|
||||
if (type === REQUEST_TYPE) {
|
||||
if (!content || typeof content.to !== "string" || !content.to.length) {
|
||||
logger.log("InRoomChannel: validateEvent: " + "no valid to " + (content && content.to));
|
||||
logger.log("InRoomChannel: validateEvent: " + "no valid to " + content.to);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export class InRoomChannel implements IVerificationChannel {
|
||||
logger.log(
|
||||
"InRoomChannel: validateEvent: " +
|
||||
`not directed to or sent by me: ${event.getSender()}` +
|
||||
`, ${content && content.to}`,
|
||||
`, ${content.to}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -208,10 +208,17 @@ export class InRoomChannel implements IVerificationChannel {
|
||||
this.requestEventId = InRoomChannel.getTransactionId(event);
|
||||
}
|
||||
|
||||
// With pendingEventOrdering: "chronological", we will see events that have been sent but not yet reflected
|
||||
// back via /sync. These are "local echoes" and are identifiable by their txnId
|
||||
const isLocalEcho = !!event.getTxnId();
|
||||
|
||||
// Alternatively, we may see an event that we sent that is reflected back via /sync. These are "remote echoes"
|
||||
// and have a transaction ID in the "unsigned" data
|
||||
const isRemoteEcho = !!event.getUnsigned().transaction_id;
|
||||
|
||||
const isSentByUs = event.getSender() === this.client.getUserId();
|
||||
|
||||
return request.handleEvent(type, event, isLiveEvent, isRemoteEcho, isSentByUs);
|
||||
return request.handleEvent(type, event, isLiveEvent, isLocalEcho || isRemoteEcho, isSentByUs);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-2
@@ -25,14 +25,13 @@ import {
|
||||
ISendEventFromWidgetResponseData,
|
||||
} from "matrix-widget-api";
|
||||
|
||||
import { IEvent, IContent, EventStatus } from "./models/event";
|
||||
import { MatrixEvent, IEvent, IContent, EventStatus } from "./models/event";
|
||||
import { ISendEventResponse } from "./@types/requests";
|
||||
import { EventType } from "./@types/event";
|
||||
import { logger } from "./logger";
|
||||
import { MatrixClient, ClientEvent, IMatrixClientCreateOpts, IStartClientOpts, SendToDeviceContentMap } from "./client";
|
||||
import { SyncApi, SyncState } from "./sync";
|
||||
import { SlidingSyncSdk } from "./sliding-sync-sdk";
|
||||
import { MatrixEvent } from "./models/event";
|
||||
import { User } from "./models/user";
|
||||
import { Room } from "./models/room";
|
||||
import { ToDeviceBatch, ToDevicePayload } from "./models/ToDeviceMessage";
|
||||
|
||||
@@ -31,6 +31,7 @@ export enum Feature {
|
||||
LoginTokenRequest = "LoginTokenRequest",
|
||||
RelationBasedRedactions = "RelationBasedRedactions",
|
||||
AccountDataDeletion = "AccountDataDeletion",
|
||||
RelationsRecursion = "RelationsRecursion",
|
||||
}
|
||||
|
||||
type FeatureSupportCondition = {
|
||||
@@ -56,6 +57,9 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
|
||||
[Feature.AccountDataDeletion]: {
|
||||
unstablePrefixes: ["org.matrix.msc3391"],
|
||||
},
|
||||
[Feature.RelationsRecursion]: {
|
||||
unstablePrefixes: ["org.matrix.msc3981"],
|
||||
},
|
||||
};
|
||||
|
||||
export async function buildFeatureSupportMap(versions: IServerVersions): Promise<Map<Feature, ServerSupport>> {
|
||||
|
||||
@@ -18,7 +18,7 @@ limitations under the License.
|
||||
* This is an internal module. See {@link MatrixHttpApi} for the public class.
|
||||
*/
|
||||
|
||||
import * as utils from "../utils";
|
||||
import { checkObjectHasKeys, encodeParams } from "../utils";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { Method } from "./method";
|
||||
import { ConnectionError, MatrixError } from "./errors";
|
||||
@@ -45,7 +45,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
private eventEmitter: TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>,
|
||||
public readonly opts: O,
|
||||
) {
|
||||
utils.checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
|
||||
checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
|
||||
opts.onlyData = !!opts.onlyData;
|
||||
opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true;
|
||||
}
|
||||
@@ -304,7 +304,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
public getUrl(path: string, queryParams?: QueryDict, prefix?: string, baseUrl?: string): URL {
|
||||
const url = new URL((baseUrl ?? this.opts.baseUrl) + (prefix ?? this.opts.prefix) + path);
|
||||
if (queryParams) {
|
||||
utils.encodeParams(queryParams, url.searchParams);
|
||||
encodeParams(queryParams, url.searchParams);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
+13
-13
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import { FetchHttpApi } from "./fetch";
|
||||
import { FileType, IContentUri, IHttpOpts, Upload, UploadOpts, UploadResponse } from "./interface";
|
||||
import { MediaPrefix } from "./prefix";
|
||||
import * as utils from "../utils";
|
||||
import { defer, QueryDict, removeElement } from "../utils";
|
||||
import * as callbacks from "../realtime-callbacks";
|
||||
import { Method } from "./method";
|
||||
import { ConnectionError } from "./errors";
|
||||
@@ -58,14 +58,14 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
total: 0,
|
||||
abortController,
|
||||
} as Upload;
|
||||
const defer = utils.defer<UploadResponse>();
|
||||
const deferred = defer<UploadResponse>();
|
||||
|
||||
if (global.XMLHttpRequest) {
|
||||
const xhr = new global.XMLHttpRequest();
|
||||
|
||||
const timeoutFn = function (): void {
|
||||
xhr.abort();
|
||||
defer.reject(new Error("Timeout"));
|
||||
deferred.reject(new Error("Timeout"));
|
||||
};
|
||||
|
||||
// set an initial timeout of 30s; we'll advance it each time we get a progress notification
|
||||
@@ -84,16 +84,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
}
|
||||
|
||||
if (xhr.status >= 400) {
|
||||
defer.reject(parseErrorResponse(xhr, xhr.responseText));
|
||||
deferred.reject(parseErrorResponse(xhr, xhr.responseText));
|
||||
} else {
|
||||
defer.resolve(JSON.parse(xhr.responseText));
|
||||
deferred.resolve(JSON.parse(xhr.responseText));
|
||||
}
|
||||
} catch (err) {
|
||||
if ((<Error>err).name === "AbortError") {
|
||||
defer.reject(err);
|
||||
deferred.reject(err);
|
||||
return;
|
||||
}
|
||||
defer.reject(new ConnectionError("request failed", <Error>err));
|
||||
deferred.reject(new ConnectionError("request failed", <Error>err));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -131,7 +131,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
xhr.abort();
|
||||
});
|
||||
} else {
|
||||
const queryParams: utils.QueryDict = {};
|
||||
const queryParams: QueryDict = {};
|
||||
if (includeFilename && fileName) {
|
||||
queryParams.filename = fileName;
|
||||
}
|
||||
@@ -146,16 +146,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
.then((response) => {
|
||||
return this.opts.onlyData ? <UploadResponse>response : response.json();
|
||||
})
|
||||
.then(defer.resolve, defer.reject);
|
||||
.then(deferred.resolve, deferred.reject);
|
||||
}
|
||||
|
||||
// remove the upload from the list on completion
|
||||
upload.promise = defer.promise.finally(() => {
|
||||
utils.removeElement(this.uploads, (elem) => elem === upload);
|
||||
upload.promise = deferred.promise.finally(() => {
|
||||
removeElement(this.uploads, (elem) => elem === upload);
|
||||
});
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
utils.removeElement(this.uploads, (elem) => elem === upload);
|
||||
defer.reject(new DOMException("Aborted", "AbortError"));
|
||||
removeElement(this.uploads, (elem) => elem === upload);
|
||||
deferred.reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
this.uploads.push(upload);
|
||||
return upload.promise;
|
||||
|
||||
+54
-6
@@ -20,12 +20,13 @@ import { logger } from "./logger";
|
||||
import { MatrixClient } from "./client";
|
||||
import { defer, IDeferred } from "./utils";
|
||||
import { MatrixError } from "./http-api";
|
||||
import { UIAResponse } from "./@types/uia";
|
||||
|
||||
const EMAIL_STAGE_TYPE = "m.login.email.identity";
|
||||
const MSISDN_STAGE_TYPE = "m.login.msisdn";
|
||||
|
||||
export interface UIAFlow {
|
||||
stages: AuthType[];
|
||||
stages: Array<AuthType | string>;
|
||||
}
|
||||
|
||||
export interface IInputs {
|
||||
@@ -44,7 +45,14 @@ export interface IStageStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data returned in the body of a 401 response from a UIA endpoint.
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.6/client-server-api/#user-interactive-api-in-the-rest-api
|
||||
*/
|
||||
export interface IAuthData {
|
||||
// XXX: many of the fields here (`type`, `available_flows`, `required_stages`, etc) look like they
|
||||
// shouldn't be here. They aren't in the spec and it's unclear what they are supposed to do. Be wary of using them.
|
||||
session?: string;
|
||||
type?: string;
|
||||
completed?: string[];
|
||||
@@ -77,6 +85,11 @@ export enum AuthType {
|
||||
UnstableRegistrationToken = "org.matrix.msc3231.login.registration_token",
|
||||
}
|
||||
|
||||
/**
|
||||
* The parameters which are submitted as the `auth` dict in a UIA request
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.6/client-server-api/#authentication-types
|
||||
*/
|
||||
export interface IAuthDict {
|
||||
// [key: string]: any;
|
||||
type?: string;
|
||||
@@ -106,6 +119,16 @@ export class NoAuthFlowFoundError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of an application callback to perform the user-interactive bit of UIA.
|
||||
*
|
||||
* It is called with a single parameter, `makeRequest`, which is a function which takes the UIA parameters and
|
||||
* makes the HTTP request.
|
||||
*
|
||||
* The generic parameter `T` is the type of the response of the endpoint, once it is eventually successful.
|
||||
*/
|
||||
export type UIAuthCallback<T> = (makeRequest: (authData: IAuthDict) => Promise<UIAResponse<T>>) => Promise<T>;
|
||||
|
||||
interface IOpts {
|
||||
/**
|
||||
* A matrix client to use for the auth process
|
||||
@@ -133,13 +156,21 @@ interface IOpts {
|
||||
*/
|
||||
emailSid?: string;
|
||||
|
||||
/**
|
||||
* If specified, will prefer flows which entirely consist of listed stages.
|
||||
* These should normally be of type AuthTypes but can be string when supporting custom auth stages.
|
||||
*
|
||||
* This can be used to avoid needing the fallback mechanism.
|
||||
*/
|
||||
supportedStages?: Array<AuthType | string>;
|
||||
|
||||
/**
|
||||
* Called with the new auth dict to submit the request.
|
||||
* Also passes a second deprecated arg which is a flag set to true if this request is a background request.
|
||||
* The busyChanged callback should be used instead of the background flag.
|
||||
* Should return a promise which resolves to the successful response or rejects with a MatrixError.
|
||||
*/
|
||||
doRequest(auth: IAuthData | null, background: boolean): Promise<IAuthData>;
|
||||
doRequest(auth: IAuthDict | null, background: boolean): Promise<IAuthData>;
|
||||
/**
|
||||
* Called when the status of the UI auth changes,
|
||||
* ie. when the state of an auth stage changes of when the auth flow moves to a new stage.
|
||||
@@ -153,7 +184,7 @@ interface IOpts {
|
||||
* m.login.email.identity:
|
||||
* * emailSid: string, the sid of the active email auth session
|
||||
*/
|
||||
stateUpdated(nextStage: AuthType, status: IStageStatus): void;
|
||||
stateUpdated(nextStage: AuthType | string, status: IStageStatus): void;
|
||||
|
||||
/**
|
||||
* A function that takes the email address (string), clientSecret (string), attempt number (int) and
|
||||
@@ -193,6 +224,7 @@ export class InteractiveAuth {
|
||||
private readonly busyChangedCallback?: IOpts["busyChanged"];
|
||||
private readonly stateUpdatedCallback: IOpts["stateUpdated"];
|
||||
private readonly requestEmailTokenCallback: IOpts["requestEmailToken"];
|
||||
private readonly supportedStages?: Set<string>;
|
||||
|
||||
private data: IAuthData;
|
||||
private emailSid?: string;
|
||||
@@ -220,6 +252,7 @@ export class InteractiveAuth {
|
||||
if (opts.sessionId) this.data.session = opts.sessionId;
|
||||
this.clientSecret = opts.clientSecret || this.matrixClient.generateClientSecret();
|
||||
this.emailSid = opts.emailSid;
|
||||
if (opts.supportedStages !== undefined) this.supportedStages = new Set(opts.supportedStages);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -441,7 +474,7 @@ export class InteractiveAuth {
|
||||
* This can be set to true for requests that just poll to see if auth has
|
||||
* been completed elsewhere.
|
||||
*/
|
||||
private async doRequest(auth: IAuthData | null, background = false): Promise<void> {
|
||||
private async doRequest(auth: IAuthDict | null, background = false): Promise<void> {
|
||||
try {
|
||||
const result = await this.requestCallback(auth, background);
|
||||
this.attemptAuthDeferred!.resolve(result);
|
||||
@@ -548,7 +581,7 @@ export class InteractiveAuth {
|
||||
* @returns login type
|
||||
* @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found
|
||||
*/
|
||||
private chooseStage(): AuthType | undefined {
|
||||
private chooseStage(): AuthType | string | undefined {
|
||||
if (this.chosenFlow === null) {
|
||||
this.chosenFlow = this.chooseFlow();
|
||||
}
|
||||
@@ -558,6 +591,17 @@ export class InteractiveAuth {
|
||||
return nextStage;
|
||||
}
|
||||
|
||||
// Returns a low number for flows we consider best. Counts increase for longer flows and even more so
|
||||
// for flows which contain stages not listed in `supportedStages`.
|
||||
private scoreFlow(flow: UIAFlow): number {
|
||||
let score = flow.stages.length;
|
||||
if (this.supportedStages !== undefined) {
|
||||
// Add 10 points to the score for each unsupported stage in the flow.
|
||||
score += flow.stages.filter((stage) => !this.supportedStages!.has(stage)).length * 10;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick one of the flows from the returned list
|
||||
* If a flow using all of the inputs is found, it will
|
||||
@@ -580,6 +624,10 @@ export class InteractiveAuth {
|
||||
const haveEmail = Boolean(this.inputs.emailAddress) || Boolean(this.emailSid);
|
||||
const haveMsisdn = Boolean(this.inputs.phoneCountry) && Boolean(this.inputs.phoneNumber);
|
||||
|
||||
// Flows are not represented in a significant order, so we can choose any we support best
|
||||
// Sort flows based on how many unsupported stages they contain ascending
|
||||
flows.sort((a, b) => this.scoreFlow(a) - this.scoreFlow(b));
|
||||
|
||||
for (const flow of flows) {
|
||||
let flowHasEmail = false;
|
||||
let flowHasMsisdn = false;
|
||||
@@ -610,7 +658,7 @@ export class InteractiveAuth {
|
||||
* @internal
|
||||
* @returns login type
|
||||
*/
|
||||
private firstUncompletedStage(flow: UIAFlow): AuthType | undefined {
|
||||
private firstUncompletedStage(flow: UIAFlow): AuthType | string | undefined {
|
||||
const completed = this.data.completed || [];
|
||||
return flow.stages.find((stageType) => !completed.includes(stageType));
|
||||
}
|
||||
|
||||
+24
-1
@@ -37,7 +37,9 @@ export * from "./models/event-timeline-set";
|
||||
export * from "./models/poll";
|
||||
export * from "./models/room-member";
|
||||
export * from "./models/room-state";
|
||||
export * from "./models/typed-event-emitter";
|
||||
export * from "./models/user";
|
||||
export * from "./models/device";
|
||||
export * from "./scheduler";
|
||||
export * from "./filter";
|
||||
export * from "./timeline-window";
|
||||
@@ -62,9 +64,30 @@ export { createNewMatrixCall } from "./webrtc/call";
|
||||
export type { MatrixCall } from "./webrtc/call";
|
||||
export { GroupCallEvent, GroupCallIntent, GroupCallState, GroupCallType } from "./webrtc/groupCall";
|
||||
export type { GroupCall } from "./webrtc/groupCall";
|
||||
export type { CryptoApi } from "./crypto-api";
|
||||
export { CryptoEvent } from "./crypto";
|
||||
|
||||
/**
|
||||
* Types supporting cryptography.
|
||||
*
|
||||
* The most important is {@link Crypto.CryptoApi}, an instance of which can be retrieved via
|
||||
* {@link MatrixClient.getCrypto}.
|
||||
*/
|
||||
export * as Crypto from "./crypto-api";
|
||||
|
||||
/**
|
||||
* Backwards compatibility re-export
|
||||
* @internal
|
||||
* @deprecated use {@link Crypto.CryptoApi}
|
||||
*/
|
||||
export type { CryptoApi } from "./crypto-api";
|
||||
|
||||
/**
|
||||
* Backwards compatibility re-export
|
||||
* @internal
|
||||
* @deprecated use {@link Crypto.DeviceVerificationStatus}
|
||||
*/
|
||||
export { DeviceVerificationStatus } from "./crypto-api";
|
||||
|
||||
let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore();
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/** State of the verification of the device. */
|
||||
export enum DeviceVerification {
|
||||
Blocked = -1,
|
||||
Unverified = 0,
|
||||
Verified = 1,
|
||||
}
|
||||
|
||||
/** A map from user ID to device ID to Device */
|
||||
export type DeviceMap = Map<string, Map<string, Device>>;
|
||||
|
||||
type DeviceParameters = Pick<Device, "deviceId" | "userId" | "algorithms" | "keys"> & Partial<Device>;
|
||||
|
||||
/**
|
||||
* Information on a user's device, as returned by {@link Crypto.CryptoApi.getUserDeviceInfo}.
|
||||
*/
|
||||
export class Device {
|
||||
/** id of the device */
|
||||
public readonly deviceId: string;
|
||||
|
||||
/** id of the user that owns the device */
|
||||
public readonly userId: string;
|
||||
|
||||
/** list of algorithms supported by this device */
|
||||
public readonly algorithms: string[];
|
||||
|
||||
/** a map from `<key type>:<id> -> <base64-encoded key>` */
|
||||
public readonly keys: Map<string, string>;
|
||||
|
||||
/** whether the device has been verified/blocked by the user */
|
||||
public readonly verified: DeviceVerification;
|
||||
|
||||
/** a map `<userId, map<algorithm:device_id, signature>>` */
|
||||
public readonly signatures: Map<string, Map<string, string>>;
|
||||
|
||||
/** display name of the device */
|
||||
public readonly displayName?: string;
|
||||
|
||||
public constructor(opts: DeviceParameters) {
|
||||
this.deviceId = opts.deviceId;
|
||||
this.userId = opts.userId;
|
||||
this.algorithms = opts.algorithms;
|
||||
this.keys = opts.keys;
|
||||
this.verified = opts.verified || DeviceVerification.Unverified;
|
||||
this.signatures = opts.signatures || new Map();
|
||||
this.displayName = opts.displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fingerprint for this device (ie, the Ed25519 key)
|
||||
*
|
||||
* @returns base64-encoded fingerprint of this device
|
||||
*/
|
||||
public getFingerprint(): string | undefined {
|
||||
return this.keys.get(`ed25519:${this.deviceId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the identity key for this device (ie, the Curve25519 key)
|
||||
*
|
||||
* @returns base64-encoded identity key of this device
|
||||
*/
|
||||
public getIdentityKey(): string | undefined {
|
||||
return this.keys.get(`curve25519:${this.deviceId}`);
|
||||
}
|
||||
}
|
||||
@@ -756,6 +756,94 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
this.emit(RoomEvent.Timeline, event, this.room, Boolean(toStartOfTimeline), false, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert event to the given timeline, and emit Room.timeline. Assumes
|
||||
* we have already checked we don't know about this event.
|
||||
*
|
||||
* TEMPORARY: until we have recursive relations, we need this function
|
||||
* to exist to allow us to insert events in timeline order, which is our
|
||||
* best guess for Sync Order.
|
||||
* This is a copy of addEventToTimeline above, modified to insert the event
|
||||
* after the event it relates to, and before any event with a later
|
||||
* timestamp. This is our best guess at Sync Order.
|
||||
*
|
||||
* Will fire "Room.timeline" for each event added.
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @param options - addEventToTimeline options
|
||||
*
|
||||
* @remarks
|
||||
* Fires {@link RoomEvent.Timeline}
|
||||
*/
|
||||
public insertEventIntoTimeline(event: MatrixEvent, timeline: EventTimeline, roomState: RoomState): void {
|
||||
if (timeline.getTimelineSet() !== this) {
|
||||
throw new Error(`EventTimelineSet.addEventToTimeline: Timeline=${timeline.toString()} does not belong " +
|
||||
"in timelineSet(threadId=${this.thread?.id})`);
|
||||
}
|
||||
|
||||
// Make sure events don't get mixed in timelines they shouldn't be in (e.g. a
|
||||
// threaded message should not be in the main timeline).
|
||||
//
|
||||
// We can only run this check for timelines with a `room` because `canContain`
|
||||
// requires it
|
||||
if (this.room && !this.canContain(event)) {
|
||||
let eventDebugString = `event=${event.getId()}`;
|
||||
if (event.threadRootId) {
|
||||
eventDebugString += `(belongs to thread=${event.threadRootId})`;
|
||||
}
|
||||
logger.warn(
|
||||
`EventTimelineSet.addEventToTimeline: Ignoring ${eventDebugString} that does not belong ` +
|
||||
`in timeline=${timeline.toString()} timelineSet(threadId=${this.thread?.id})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the event that this event is related to - the "parent"
|
||||
const parentEventId = event.relationEventId;
|
||||
if (!parentEventId) {
|
||||
// Not related to anything - we just append
|
||||
this.addEventToTimeline(event, timeline, {
|
||||
toStartOfTimeline: false,
|
||||
fromCache: false,
|
||||
timelineWasEmpty: false,
|
||||
roomState,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parentEvent = this.findEventById(parentEventId);
|
||||
|
||||
const timelineEvents = timeline.getEvents();
|
||||
|
||||
// Start searching from the parent event, or if it's not loaded, start
|
||||
// at the beginning and insert purely using timestamp order.
|
||||
const parentIndex = parentEvent !== undefined ? timelineEvents.indexOf(parentEvent) : 0;
|
||||
let insertIndex = parentIndex;
|
||||
for (; insertIndex < timelineEvents.length; insertIndex++) {
|
||||
const nextEvent = timelineEvents[insertIndex];
|
||||
if (nextEvent.getTs() > event.getTs()) {
|
||||
// We found an event later than ours, so insert before that.
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If we got to the end of the loop, insertIndex points at the end of
|
||||
// the list.
|
||||
|
||||
const eventId = event.getId()!;
|
||||
timeline.insertEvent(event, insertIndex, roomState);
|
||||
this._eventIdToTimeline.set(eventId, timeline);
|
||||
|
||||
this.relations.aggregateParentEvent(event);
|
||||
this.relations.aggregateChildEvent(event, this);
|
||||
|
||||
const data: IRoomTimelineData = {
|
||||
timeline: timeline,
|
||||
liveEvent: timeline == this.liveTimeline,
|
||||
};
|
||||
this.emit(RoomEvent.Timeline, event, this.room, false, false, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces event with ID oldEventId with one with newEventId, if oldEventId is
|
||||
* recognised. Otherwise, add to the live timeline. Used to handle remote echos.
|
||||
|
||||
@@ -427,6 +427,45 @@ export class EventTimeline {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new event into the timeline, and update the state.
|
||||
*
|
||||
* TEMPORARY: until we have recursive relations, we need this function
|
||||
* to exist to allow us to insert events in timeline order, which is our
|
||||
* best guess for Sync Order.
|
||||
* This is a copy of addEvent above, modified to allow inserting an event at
|
||||
* a specific index.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public insertEvent(event: MatrixEvent, insertIndex: number, roomState: RoomState): void {
|
||||
const timelineSet = this.getTimelineSet();
|
||||
|
||||
if (timelineSet.room) {
|
||||
EventTimeline.setEventMetadata(event, roomState, false);
|
||||
|
||||
// modify state but only on unfiltered timelineSets
|
||||
if (event.isState() && timelineSet.room.getUnfilteredTimelineSet() === timelineSet) {
|
||||
roomState.setStateEvents([event], {});
|
||||
// it is possible that the act of setting the state event means we
|
||||
// can set more metadata (specifically sender/target props), so try
|
||||
// it again if the prop wasn't previously set. It may also mean that
|
||||
// the sender/target is updated (if the event set was a room member event)
|
||||
// so we want to use the *updated* member (new avatar/name) instead.
|
||||
//
|
||||
// However, we do NOT want to do this on member events if we're going
|
||||
// back in time, else we'll set the .sender value for BEFORE the given
|
||||
// member event, whereas we want to set the .sender value for the ACTUAL
|
||||
// member event itself.
|
||||
if (!event.sender || event.getType() === EventType.RoomMember) {
|
||||
EventTimeline.setEventMetadata(event, roomState, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.events.splice(insertIndex, 0, event); // insert element
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an event from the timeline
|
||||
*
|
||||
|
||||
+8
-1
@@ -24,7 +24,13 @@ import { ExtensibleEvent, ExtensibleEvents, Optional } from "matrix-events-sdk";
|
||||
import type { IEventDecryptionResult } from "../@types/crypto";
|
||||
import { logger } from "../logger";
|
||||
import { VerificationRequest } from "../crypto/verification/request/VerificationRequest";
|
||||
import { EVENT_VISIBILITY_CHANGE_TYPE, EventType, MsgType, RelationType } from "../@types/event";
|
||||
import {
|
||||
EVENT_VISIBILITY_CHANGE_TYPE,
|
||||
EventType,
|
||||
MsgType,
|
||||
RelationType,
|
||||
UNSIGNED_THREAD_ID_FIELD,
|
||||
} from "../@types/event";
|
||||
import { Crypto } from "../crypto";
|
||||
import { deepSortedObjectEntries, internaliseString } from "../utils";
|
||||
import { RoomMember } from "./room-member";
|
||||
@@ -63,6 +69,7 @@ export interface IUnsigned {
|
||||
"transaction_id"?: string;
|
||||
"invite_room_state"?: StrippedState[];
|
||||
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
|
||||
[UNSIGNED_THREAD_ID_FIELD.name]?: string;
|
||||
}
|
||||
|
||||
export interface IThreadBundledRelationship {
|
||||
|
||||
@@ -186,7 +186,7 @@ export class IgnoredInvites {
|
||||
}
|
||||
let regexp: RegExp;
|
||||
try {
|
||||
regexp = new RegExp(globToRegexp(glob, false));
|
||||
regexp = new RegExp(globToRegexp(glob));
|
||||
} catch (ex) {
|
||||
// Assume invalid event.
|
||||
continue;
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
WrappedReceipt,
|
||||
} from "../@types/read_receipts";
|
||||
import { ListenerMap, TypedEventEmitter } from "./typed-event-emitter";
|
||||
import * as utils from "../utils";
|
||||
import { isSupportedReceiptType } from "../utils";
|
||||
import { MatrixEvent } from "./event";
|
||||
import { EventType } from "../@types/event";
|
||||
import { EventTimelineSet } from "./event-timeline-set";
|
||||
@@ -267,7 +267,7 @@ export abstract class ReadReceipt<
|
||||
public getUsersReadUpTo(event: MatrixEvent): string[] {
|
||||
return this.getReceiptsForEvent(event)
|
||||
.filter(function (receipt) {
|
||||
return utils.isSupportedReceiptType(receipt.type);
|
||||
return isSupportedReceiptType(receipt.type);
|
||||
})
|
||||
.map(function (receipt) {
|
||||
return receipt.userId;
|
||||
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { getHttpUriForMxc } from "../content-repo";
|
||||
import * as utils from "../utils";
|
||||
import { removeDirectionOverrideChars, removeHiddenChars } from "../utils";
|
||||
import { User } from "./user";
|
||||
import { MatrixEvent } from "./event";
|
||||
import { RoomState } from "./room-state";
|
||||
@@ -206,8 +206,8 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
|
||||
|
||||
// not quite raw: we strip direction override chars so it can safely be inserted into
|
||||
// blocks of text without breaking the text direction
|
||||
this.rawDisplayName = utils.removeDirectionOverrideChars(event.getDirectionalContent().displayname ?? "");
|
||||
if (!this.rawDisplayName || !utils.removeHiddenChars(this.rawDisplayName)) {
|
||||
this.rawDisplayName = removeDirectionOverrideChars(event.getDirectionalContent().displayname ?? "");
|
||||
if (!this.rawDisplayName || !removeHiddenChars(this.rawDisplayName)) {
|
||||
this.rawDisplayName = this.userId;
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@ function shouldDisambiguate(selfUserId: string, displayName?: string, roomState?
|
||||
|
||||
// First check if the displayname is something we consider truthy
|
||||
// after stripping it of zero width characters and padding spaces
|
||||
if (!utils.removeHiddenChars(displayName)) return false;
|
||||
if (!removeHiddenChars(displayName)) return false;
|
||||
|
||||
if (!roomState) return false;
|
||||
|
||||
@@ -432,11 +432,11 @@ function shouldDisambiguate(selfUserId: string, displayName?: string, roomState?
|
||||
function calculateDisplayName(selfUserId: string, displayName: string | undefined, disambiguate: boolean): string {
|
||||
if (!displayName || displayName === selfUserId) return selfUserId;
|
||||
|
||||
if (disambiguate) return utils.removeDirectionOverrideChars(displayName) + " (" + selfUserId + ")";
|
||||
if (disambiguate) return removeDirectionOverrideChars(displayName) + " (" + selfUserId + ")";
|
||||
|
||||
// First check if the displayname is something we consider truthy
|
||||
// after stripping it of zero width characters and padding spaces
|
||||
if (!utils.removeHiddenChars(displayName)) return selfUserId;
|
||||
if (!removeHiddenChars(displayName)) return selfUserId;
|
||||
|
||||
// We always strip the direction override characters (LRO and RLO).
|
||||
// These override the text direction for all subsequent characters
|
||||
@@ -449,5 +449,5 @@ function calculateDisplayName(selfUserId: string, displayName: string | undefine
|
||||
// names should flip into the correct direction automatically based on
|
||||
// the characters, and you can still embed rtl in ltr or vice versa
|
||||
// with the embed chars or marker chars.
|
||||
return utils.removeDirectionOverrideChars(displayName);
|
||||
return removeDirectionOverrideChars(displayName);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import { RoomMember } from "./room-member";
|
||||
import { logger } from "../logger";
|
||||
import * as utils from "../utils";
|
||||
import { isNumber, removeHiddenChars } from "../utils";
|
||||
import { EventType, UNSTABLE_MSC2716_MARKER } from "../@types/event";
|
||||
import { IEvent, MatrixEvent, MatrixEventEvent } from "./event";
|
||||
import { MatrixClient } from "../client";
|
||||
@@ -759,7 +759,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
* @returns An array of user IDs or an empty array.
|
||||
*/
|
||||
public getUserIdsWithDisplayName(displayName: string): string[] {
|
||||
return this.displayNameToUserIds.get(utils.removeHiddenChars(displayName)) ?? [];
|
||||
return this.displayNameToUserIds.get(removeHiddenChars(displayName)) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -798,7 +798,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
}
|
||||
|
||||
let requiredLevel = 50;
|
||||
if (utils.isNumber(powerLevels[action])) {
|
||||
if (isNumber(powerLevels[action])) {
|
||||
requiredLevel = powerLevels[action]!;
|
||||
}
|
||||
|
||||
@@ -928,7 +928,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
powerLevelsEvent &&
|
||||
powerLevelsEvent.getContent() &&
|
||||
powerLevelsEvent.getContent().notifications &&
|
||||
utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey])
|
||||
isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey])
|
||||
) {
|
||||
notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey];
|
||||
}
|
||||
@@ -1058,7 +1058,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
// We clobber the user_id > name lookup but the name -> [user_id] lookup
|
||||
// means we need to remove that user ID from that array rather than nuking
|
||||
// the lot.
|
||||
const strippedOldName = utils.removeHiddenChars(oldName);
|
||||
const strippedOldName = removeHiddenChars(oldName);
|
||||
|
||||
const existingUserIds = this.displayNameToUserIds.get(strippedOldName);
|
||||
if (existingUserIds) {
|
||||
@@ -1070,7 +1070,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
|
||||
this.userIdsToDisplayNames[userId] = displayName;
|
||||
|
||||
const strippedDisplayname = displayName && utils.removeHiddenChars(displayName);
|
||||
const strippedDisplayname = displayName && removeHiddenChars(displayName);
|
||||
// an empty stripped displayname (undefined/'') will be set to MXID in room-member.js
|
||||
if (strippedDisplayname) {
|
||||
const arr = this.displayNameToUserIds.get(strippedDisplayname) ?? [];
|
||||
|
||||
+146
-31
@@ -24,7 +24,7 @@ import {
|
||||
} from "./event-timeline-set";
|
||||
import { Direction, EventTimeline } from "./event-timeline";
|
||||
import { getHttpUriForMxc } from "../content-repo";
|
||||
import * as utils from "../utils";
|
||||
import { compare, removeElement } from "../utils";
|
||||
import { normalize, noUnsafeEventProps } from "../utils";
|
||||
import { IEvent, IThreadBundledRelationship, MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from "./event";
|
||||
import { EventStatus } from "./event-status";
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
|
||||
EVENT_VISIBILITY_CHANGE_TYPE,
|
||||
RelationType,
|
||||
UNSIGNED_THREAD_ID_FIELD,
|
||||
} from "../@types/event";
|
||||
import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client";
|
||||
import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
|
||||
@@ -72,8 +73,8 @@ import { isPollEvent, Poll, PollEvent } from "./poll";
|
||||
// room versions which are considered okay for people to run without being asked
|
||||
// to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers
|
||||
// return an m.room_versions capability.
|
||||
export const KNOWN_SAFE_ROOM_VERSION = "9";
|
||||
const SAFE_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
|
||||
export const KNOWN_SAFE_ROOM_VERSION = "10";
|
||||
const SAFE_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"];
|
||||
|
||||
interface IOpts {
|
||||
/**
|
||||
@@ -368,22 +369,25 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* The room summary.
|
||||
*/
|
||||
public summary: RoomSummary | null = null;
|
||||
// legacy fields
|
||||
/**
|
||||
* The live event timeline for this room, with the oldest event at index 0.
|
||||
* Present for backwards compatibility - prefer getLiveTimeline().getEvents()
|
||||
*
|
||||
* @deprecated Present for backwards compatibility.
|
||||
* Use getLiveTimeline().getEvents() instead
|
||||
*/
|
||||
public timeline!: MatrixEvent[];
|
||||
/**
|
||||
* oldState The state of the room at the time of the oldest
|
||||
* event in the live timeline. Present for backwards compatibility -
|
||||
* prefer getLiveTimeline().getState(EventTimeline.BACKWARDS).
|
||||
* oldState The state of the room at the time of the oldest event in the live timeline.
|
||||
*
|
||||
* @deprecated Present for backwards compatibility.
|
||||
* Use getLiveTimeline().getState(EventTimeline.BACKWARDS) instead
|
||||
*/
|
||||
public oldState!: RoomState;
|
||||
/**
|
||||
* currentState The state of the room at the time of the
|
||||
* newest event in the timeline. Present for backwards compatibility -
|
||||
* prefer getLiveTimeline().getState(EventTimeline.FORWARDS).
|
||||
* currentState The state of the room at the time of the newest event in the timeline.
|
||||
*
|
||||
* @deprecated Present for backwards compatibility.
|
||||
* Use getLiveTimeline().getState(EventTimeline.FORWARDS) instead.
|
||||
*/
|
||||
public currentState!: RoomState;
|
||||
public readonly relations = new RelationsContainer(this.client, this);
|
||||
@@ -393,6 +397,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* This is not a comprehensive list of the threads that exist in this room
|
||||
*/
|
||||
private threads = new Map<string, Thread>();
|
||||
|
||||
/**
|
||||
* @deprecated This value is unreliable. It may not contain the last thread.
|
||||
* Use {@link Room.getLastThread} instead.
|
||||
*/
|
||||
public lastThread?: Thread;
|
||||
|
||||
/**
|
||||
@@ -725,7 +734,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
);
|
||||
}
|
||||
|
||||
const removed = utils.removeElement(
|
||||
const removed = removeElement(
|
||||
this.pendingEventList,
|
||||
function (ev) {
|
||||
return ev.getId() == eventId;
|
||||
@@ -782,6 +791,54 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last live event of this room.
|
||||
* "last" means latest timestamp.
|
||||
* Instead of using timestamps, it would be better to do the comparison based on the order of the homeserver DAG.
|
||||
* Unfortunately, this information is currently not available in the client.
|
||||
* See {@link https://github.com/matrix-org/matrix-js-sdk/issues/3325}.
|
||||
* "live of this room" means from all live timelines: the room and the threads.
|
||||
*
|
||||
* @returns MatrixEvent if there is a last event; else undefined.
|
||||
*/
|
||||
public getLastLiveEvent(): MatrixEvent | undefined {
|
||||
const roomEvents = this.getLiveTimeline().getEvents();
|
||||
const lastRoomEvent = roomEvents[roomEvents.length - 1] as MatrixEvent | undefined;
|
||||
const lastThread = this.getLastThread();
|
||||
|
||||
if (!lastThread) return lastRoomEvent;
|
||||
|
||||
const lastThreadEvent = lastThread.events[lastThread.events.length - 1];
|
||||
|
||||
return (lastRoomEvent?.getTs() ?? 0) > (lastThreadEvent?.getTs() ?? 0) ? lastRoomEvent : lastThreadEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last thread of this room.
|
||||
* "last" means latest timestamp of the last thread event.
|
||||
* Instead of using timestamps, it would be better to do the comparison based on the order of the homeserver DAG.
|
||||
* Unfortunately, this information is currently not available in the client.
|
||||
* See {@link https://github.com/matrix-org/matrix-js-sdk/issues/3325}.
|
||||
*
|
||||
* @returns the thread with the most recent event in its live time line. undefined if there is no thread.
|
||||
*/
|
||||
public getLastThread(): Thread | undefined {
|
||||
return this.getThreads().reduce<Thread | undefined>((lastThread: Thread | undefined, thread: Thread) => {
|
||||
if (!lastThread) return thread;
|
||||
|
||||
const threadEvent = thread.events[thread.events.length - 1];
|
||||
const lastThreadEvent = lastThread.events[lastThread.events.length - 1];
|
||||
|
||||
if ((threadEvent?.getTs() ?? 0) >= (lastThreadEvent?.getTs() ?? 0)) {
|
||||
// Last message of current thread is newer → new last thread.
|
||||
// Equal also means newer, because it was added to the thread map later.
|
||||
return thread;
|
||||
}
|
||||
|
||||
return lastThread;
|
||||
}, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the membership type (join | leave | invite) for the logged in user
|
||||
*/
|
||||
@@ -2076,6 +2133,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
return this.eventShouldLiveIn(parentEvent, events, roots);
|
||||
}
|
||||
|
||||
if (!event.isRelation()) {
|
||||
return {
|
||||
shouldLiveInRoom: true,
|
||||
shouldLiveInThread: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Edge case where we know the event is a relation but don't have the parentEvent
|
||||
if (roots?.has(event.relationEventId!)) {
|
||||
return {
|
||||
@@ -2085,9 +2149,20 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
};
|
||||
}
|
||||
|
||||
// We've exhausted all scenarios, can safely assume that this event should live in the room timeline only
|
||||
const unsigned = event.getUnsigned();
|
||||
if (typeof unsigned[UNSIGNED_THREAD_ID_FIELD.name] === "string") {
|
||||
return {
|
||||
shouldLiveInRoom: false,
|
||||
shouldLiveInThread: true,
|
||||
threadId: unsigned[UNSIGNED_THREAD_ID_FIELD.name],
|
||||
};
|
||||
}
|
||||
|
||||
// We've exhausted all scenarios,
|
||||
// we cannot assume that it lives in the main timeline as this may be a relation for an unknown thread
|
||||
// adding the event in the wrong timeline causes stuck notifications and can break ability to send read receipts
|
||||
return {
|
||||
shouldLiveInRoom: true,
|
||||
shouldLiveInRoom: false,
|
||||
shouldLiveInThread: false,
|
||||
};
|
||||
}
|
||||
@@ -2100,14 +2175,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
|
||||
private addThreadedEvents(threadId: string, events: MatrixEvent[], toStartOfTimeline = false): void {
|
||||
let thread = this.getThread(threadId);
|
||||
|
||||
if (!thread) {
|
||||
const thread = this.getThread(threadId);
|
||||
if (thread) {
|
||||
thread.addEvents(events, toStartOfTimeline);
|
||||
} else {
|
||||
const rootEvent = this.findEventById(threadId) ?? events.find((e) => e.getId() === threadId);
|
||||
thread = this.createThread(threadId, rootEvent, events, toStartOfTimeline);
|
||||
this.createThread(threadId, rootEvent, events, toStartOfTimeline);
|
||||
}
|
||||
|
||||
thread.addEvents(events, toStartOfTimeline);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2644,16 +2718,20 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* @param addLiveEventOptions - addLiveEvent options
|
||||
* @throws If `duplicateStrategy` is not falsey, 'replace' or 'ignore'.
|
||||
*/
|
||||
public addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): void;
|
||||
public async addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): Promise<void>;
|
||||
/**
|
||||
* @deprecated In favor of the overload with `IAddLiveEventOptions`
|
||||
*/
|
||||
public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache?: boolean): void;
|
||||
public addLiveEvents(
|
||||
public async addLiveEvents(
|
||||
events: MatrixEvent[],
|
||||
duplicateStrategy?: DuplicateStrategy,
|
||||
fromCache?: boolean,
|
||||
): Promise<void>;
|
||||
public async addLiveEvents(
|
||||
events: MatrixEvent[],
|
||||
duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions,
|
||||
fromCache = false,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
let duplicateStrategy: DuplicateStrategy | undefined = duplicateStrategyOrOpts as DuplicateStrategy;
|
||||
let timelineWasEmpty: boolean | undefined = false;
|
||||
if (typeof duplicateStrategyOrOpts === "object") {
|
||||
@@ -2704,6 +2782,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
timelineWasEmpty,
|
||||
};
|
||||
|
||||
// List of extra events to check for being parents of any relations encountered
|
||||
const neighbouringEvents = [...events];
|
||||
|
||||
for (const event of events) {
|
||||
// TODO: We should have a filter to say "only add state event types X Y Z to the timeline".
|
||||
this.processLiveEvent(event);
|
||||
@@ -2717,12 +2798,35 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
}
|
||||
|
||||
const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
|
||||
let { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
|
||||
event,
|
||||
events,
|
||||
neighbouringEvents,
|
||||
threadRoots,
|
||||
);
|
||||
|
||||
if (!shouldLiveInThread && !shouldLiveInRoom && event.isRelation()) {
|
||||
try {
|
||||
const parentEvent = new MatrixEvent(
|
||||
await this.client.fetchRoomEvent(this.roomId, event.relationEventId!),
|
||||
);
|
||||
neighbouringEvents.push(parentEvent);
|
||||
if (parentEvent.threadRootId) {
|
||||
threadRoots.add(parentEvent.threadRootId);
|
||||
const unsigned = event.getUnsigned();
|
||||
unsigned[UNSIGNED_THREAD_ID_FIELD.name] = parentEvent.threadRootId;
|
||||
event.setUnsigned(unsigned);
|
||||
}
|
||||
|
||||
({ shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
|
||||
event,
|
||||
neighbouringEvents,
|
||||
threadRoots,
|
||||
));
|
||||
} catch (e) {
|
||||
logger.error("Failed to load parent event of unhandled relation", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) {
|
||||
eventsByThread[threadId ?? ""] = [];
|
||||
}
|
||||
@@ -2730,6 +2834,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
|
||||
if (shouldLiveInRoom) {
|
||||
this.addLiveEvent(event, options);
|
||||
} else if (!shouldLiveInThread && event.isRelation()) {
|
||||
this.relations.aggregateChildEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2740,13 +2846,14 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
|
||||
public partitionThreadedEvents(
|
||||
events: MatrixEvent[],
|
||||
): [timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[]] {
|
||||
): [timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[], unknownRelations: MatrixEvent[]] {
|
||||
// Indices to the events array, for readability
|
||||
const ROOM = 0;
|
||||
const THREAD = 1;
|
||||
const UNKNOWN_RELATION = 2;
|
||||
if (this.client.supportsThreads()) {
|
||||
const threadRoots = this.findThreadRoots(events);
|
||||
return events.reduce(
|
||||
return events.reduce<[MatrixEvent[], MatrixEvent[], MatrixEvent[]]>(
|
||||
(memo, event: MatrixEvent) => {
|
||||
const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
|
||||
event,
|
||||
@@ -2763,13 +2870,17 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
memo[THREAD].push(event);
|
||||
}
|
||||
|
||||
if (!shouldLiveInThread && !shouldLiveInRoom) {
|
||||
memo[UNKNOWN_RELATION].push(event);
|
||||
}
|
||||
|
||||
return memo;
|
||||
},
|
||||
[[] as MatrixEvent[], [] as MatrixEvent[]],
|
||||
[[], [], []],
|
||||
);
|
||||
} else {
|
||||
// When `experimentalThreadSupport` is disabled treat all events as timelineEvents
|
||||
return [events as MatrixEvent[], [] as MatrixEvent[]];
|
||||
return [events as MatrixEvent[], [] as MatrixEvent[], [] as MatrixEvent[]];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2782,6 +2893,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
|
||||
threadRoots.add(event.relationEventId ?? "");
|
||||
}
|
||||
const unsigned = event.getUnsigned();
|
||||
if (typeof unsigned[UNSIGNED_THREAD_ID_FIELD.name] === "string") {
|
||||
threadRoots.add(unsigned[UNSIGNED_THREAD_ID_FIELD.name]!);
|
||||
}
|
||||
}
|
||||
return threadRoots;
|
||||
}
|
||||
@@ -3211,7 +3326,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
return true;
|
||||
});
|
||||
// make sure members have stable order
|
||||
otherMembers.sort((a, b) => utils.compare(a.userId, b.userId));
|
||||
otherMembers.sort((a, b) => compare(a.userId, b.userId));
|
||||
// only 5 first members, immitate summaryHeroes
|
||||
otherMembers = otherMembers.slice(0, 5);
|
||||
otherNames = otherMembers.map((m) => m.name);
|
||||
|
||||
+94
-20
@@ -28,6 +28,7 @@ import { ServerControlledNamespacedValue } from "../NamespacedValue";
|
||||
import { logger } from "../logger";
|
||||
import { ReadReceipt } from "./read-receipt";
|
||||
import { CachedReceiptStructure, ReceiptType } from "../@types/read_receipts";
|
||||
import { Feature, ServerSupport } from "../feature";
|
||||
|
||||
export enum ThreadEvent {
|
||||
New = "Thread.new",
|
||||
@@ -202,11 +203,33 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
): void => {
|
||||
// Add a synthesized receipt when paginating forward in the timeline
|
||||
if (!toStartOfTimeline) {
|
||||
room!.addLocalEchoReceipt(event.getSender()!, event, ReceiptType.Read);
|
||||
const sender = event.getSender();
|
||||
if (sender && room && this.shouldSendLocalEchoReceipt(sender, event)) {
|
||||
room.addLocalEchoReceipt(sender, event, ReceiptType.Read);
|
||||
}
|
||||
}
|
||||
this.onEcho(event, toStartOfTimeline ?? false);
|
||||
};
|
||||
|
||||
private shouldSendLocalEchoReceipt(sender: string, event: MatrixEvent): boolean {
|
||||
const recursionSupport = this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
|
||||
|
||||
if (recursionSupport === ServerSupport.Unsupported) {
|
||||
// Normally we add a local receipt, but if we don't have
|
||||
// recursion support, then events may arrive out of order, so we
|
||||
// only create a receipt if it's after our existing receipt.
|
||||
const oldReceiptEventId = this.getReadReceiptForUserId(sender)?.eventId;
|
||||
if (oldReceiptEventId) {
|
||||
const receiptEvent = this.findEventById(oldReceiptEventId);
|
||||
if (receiptEvent && receiptEvent.getTs() > event.getTs()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private onLocalEcho = (event: MatrixEvent): void => {
|
||||
this.onEcho(event, false);
|
||||
};
|
||||
@@ -235,6 +258,34 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TEMPORARY. Only call this when MSC3981 is not available, and we have some
|
||||
* late-arriving events to insert, because we recursively found them as part
|
||||
* of populating a thread. When we have MSC3981 we won't need it, because
|
||||
* they will all be supplied by the homeserver in one request, and they will
|
||||
* already be in the right order in that response.
|
||||
* This is a copy of addEventToTimeline above, modified to call
|
||||
* insertEventIntoTimeline so this event is inserted into our best guess of
|
||||
* the right place based on timestamp. (We should be using Sync Order but we
|
||||
* don't have it.)
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public insertEventIntoTimeline(event: MatrixEvent): void {
|
||||
const eventId = event.getId();
|
||||
if (!eventId) {
|
||||
return;
|
||||
}
|
||||
// If the event is already in this thread, bail out
|
||||
if (this.findEventById(eventId)) {
|
||||
return;
|
||||
}
|
||||
this.timelineSet.insertEventIntoTimeline(event, this.liveTimeline, this.roomState);
|
||||
|
||||
// As far as we know, timeline should always be the same as events
|
||||
this.timeline = this.events;
|
||||
}
|
||||
|
||||
public addEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
|
||||
events.forEach((ev) => this.addEvent(ev, toStartOfTimeline, false));
|
||||
this.updateThreadMetadata();
|
||||
@@ -280,7 +331,14 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
*/
|
||||
this.replayEvents?.push(event);
|
||||
} else {
|
||||
this.addEventToTimeline(event, toStartOfTimeline);
|
||||
const recursionSupport =
|
||||
this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
|
||||
|
||||
if (recursionSupport === ServerSupport.Unsupported) {
|
||||
this.insertEventIntoTimeline(event);
|
||||
} else {
|
||||
this.addEventToTimeline(event, toStartOfTimeline);
|
||||
}
|
||||
}
|
||||
// Apply annotations and replace relations to the relations of the timeline only
|
||||
this.timelineSet.relations?.aggregateParentEvent(event);
|
||||
@@ -458,25 +516,31 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
|
||||
// XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084
|
||||
private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise<unknown> {
|
||||
return Promise.all(
|
||||
events
|
||||
.filter((e) => e.isEncrypted())
|
||||
.map((event: MatrixEvent) => {
|
||||
if (event.isRelation()) return; // skip - relations don't get edits
|
||||
return this.client
|
||||
.relations(this.roomId, event.getId()!, RelationType.Replace, event.getType(), {
|
||||
limit: 1,
|
||||
})
|
||||
.then((relations) => {
|
||||
if (relations.events.length) {
|
||||
event.makeReplaced(relations.events[0]);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("Failed to load edits for encrypted thread event", e);
|
||||
});
|
||||
const recursionSupport = this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
|
||||
if (recursionSupport === ServerSupport.Unsupported) {
|
||||
return Promise.all(
|
||||
events.filter(isAnEncryptedThreadMessage).map(async (event: MatrixEvent) => {
|
||||
try {
|
||||
const relations = await this.client.relations(
|
||||
this.roomId,
|
||||
event.getId()!,
|
||||
RelationType.Replace,
|
||||
event.getType(),
|
||||
{
|
||||
limit: 1,
|
||||
},
|
||||
);
|
||||
if (relations.events.length) {
|
||||
const editEvent = relations.events[0];
|
||||
event.makeReplaced(editEvent);
|
||||
this.insertEventIntoTimeline(editEvent);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("Failed to load edits for encrypted thread event", e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public setEventMetadata(event: Optional<MatrixEvent>): void {
|
||||
@@ -644,6 +708,16 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an event deserves to have its potential edits fetched.
|
||||
*
|
||||
* @returns true if this event is encrypted and is a message that is part of a
|
||||
* thread - either inside it, or a root.
|
||||
*/
|
||||
function isAnEncryptedThreadMessage(event: MatrixEvent): boolean {
|
||||
return event.isEncrypted() && (event.isRelation(THREAD_RELATION_TYPE.name) || event.isThreadRoot);
|
||||
}
|
||||
|
||||
export const FILTER_RELATED_BY_SENDERS = new ServerControlledNamespacedValue(
|
||||
"related_by_senders",
|
||||
"io.element.relation_senders",
|
||||
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
/** Events emitted by EventEmitter itself */
|
||||
export enum EventEmitterEvents {
|
||||
NewListener = "newListener",
|
||||
RemoveListener = "removeListener",
|
||||
@@ -24,10 +25,22 @@ export enum EventEmitterEvents {
|
||||
}
|
||||
|
||||
type AnyListener = (...args: any) => any;
|
||||
|
||||
/** Base class for types mapping from event name to the type of listeners to that event */
|
||||
export type ListenerMap<E extends string> = { [eventName in E]: AnyListener };
|
||||
|
||||
type EventEmitterEventListener = (eventName: string, listener: AnyListener) => void;
|
||||
type EventEmitterErrorListener = (error: Error) => void;
|
||||
|
||||
/**
|
||||
* The expected type of a listener function for a particular event.
|
||||
*
|
||||
* Type parameters:
|
||||
* * `E` - List of all events emitted by the `TypedEventEmitter`. Normally an enum type.
|
||||
* * `A` - A type providing mappings from event names to listener types.
|
||||
* * `T` - The name of the actual event that this listener is for. Normally one of the types in `E` or
|
||||
* {@link EventEmitterEvents}.
|
||||
*/
|
||||
export type Listener<E extends string, A extends ListenerMap<E>, T extends E | EventEmitterEvents> = T extends E
|
||||
? A[T]
|
||||
: T extends EventEmitterEvents
|
||||
@@ -40,12 +53,20 @@ export type Listener<E extends string, A extends ListenerMap<E>, T extends E | E
|
||||
* This makes it much easier for us to distinguish between events, as we now need
|
||||
* to properly type this, so that our events are not stringly-based and prone
|
||||
* to silly typos.
|
||||
*
|
||||
* Type parameters:
|
||||
* * `Events` - List of all events emitted by this `TypedEventEmitter`. Normally an enum type.
|
||||
* * `Arguments` - A {@link ListenerMap} type providing mappings from event names to listener types.
|
||||
* * `SuperclassArguments` - TODO: not really sure. Alternative listener mappings, I think? But only honoured for `.emit`?
|
||||
*/
|
||||
export class TypedEventEmitter<
|
||||
Events extends string,
|
||||
Arguments extends ListenerMap<Events>,
|
||||
SuperclassArguments extends ListenerMap<any> = Arguments,
|
||||
> extends EventEmitter {
|
||||
/**
|
||||
* Alias for {@link TypedEventEmitter#on}.
|
||||
*/
|
||||
public addListener<T extends Events | EventEmitterEvents>(
|
||||
event: T,
|
||||
listener: Listener<Events, Arguments, T>,
|
||||
@@ -53,32 +74,97 @@ export class TypedEventEmitter<
|
||||
return super.addListener(event, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously calls each of the listeners registered for the event named
|
||||
* `event`, in the order they were registered, passing the supplied arguments
|
||||
* to each.
|
||||
*
|
||||
* @param event - The name of the event to emit
|
||||
* @param args - Arguments to pass to the listener
|
||||
* @returns `true` if the event had listeners, `false` otherwise.
|
||||
*/
|
||||
public emit<T extends Events>(event: T, ...args: Parameters<SuperclassArguments[T]>): boolean;
|
||||
public emit<T extends Events>(event: T, ...args: Parameters<Arguments[T]>): boolean;
|
||||
public emit<T extends Events>(event: T, ...args: any[]): boolean {
|
||||
return super.emit(event, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of listeners listening to the event named `event`.
|
||||
*
|
||||
* @param event - The name of the event being listened for
|
||||
*/
|
||||
public listenerCount(event: Events | EventEmitterEvents): number {
|
||||
return super.listenerCount(event);
|
||||
}
|
||||
|
||||
public listeners(event: Events | EventEmitterEvents): ReturnType<EventEmitter["listeners"]> {
|
||||
/**
|
||||
* Returns a copy of the array of listeners for the event named `event`.
|
||||
*/
|
||||
public listeners(event: Events | EventEmitterEvents): Function[] {
|
||||
return super.listeners(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for {@link TypedEventEmitter#removeListener}
|
||||
*/
|
||||
public off<T extends Events | EventEmitterEvents>(event: T, listener: Listener<Events, Arguments, T>): this {
|
||||
return super.off(event, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the `listener` function to the end of the listeners array for the
|
||||
* event named `event`.
|
||||
*
|
||||
* No checks are made to see if the `listener` has already been added. Multiple calls
|
||||
* passing the same combination of `event` and `listener` will result in the `listener`
|
||||
* being added, and called, multiple times.
|
||||
*
|
||||
* By default, event listeners are invoked in the order they are added. The
|
||||
* {@link TypedEventEmitter#prependListener} method can be used as an alternative to add the
|
||||
* event listener to the beginning of the listeners array.
|
||||
*
|
||||
* @param event - The name of the event.
|
||||
* @param listener - The callback function
|
||||
*
|
||||
* @returns a reference to the `EventEmitter`, so that calls can be chained.
|
||||
*/
|
||||
public on<T extends Events | EventEmitterEvents>(event: T, listener: Listener<Events, Arguments, T>): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a **one-time** `listener` function for the event named `event`. The
|
||||
* next time `event` is triggered, this listener is removed and then invoked.
|
||||
*
|
||||
* Returns a reference to the `EventEmitter`, so that calls can be chained.
|
||||
*
|
||||
* By default, event listeners are invoked in the order they are added.
|
||||
* The {@link TypedEventEmitter#prependOnceListener} method can be used as an alternative to add the
|
||||
* event listener to the beginning of the listeners array.
|
||||
*
|
||||
* @param event - The name of the event.
|
||||
* @param listener - The callback function
|
||||
*
|
||||
* @returns a reference to the `EventEmitter`, so that calls can be chained.
|
||||
*/
|
||||
public once<T extends Events | EventEmitterEvents>(event: T, listener: Listener<Events, Arguments, T>): this {
|
||||
return super.once(event, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the `listener` function to the _beginning_ of the listeners array for the
|
||||
* event named `event`.
|
||||
*
|
||||
* No checks are made to see if the `listener` has already been added. Multiple calls
|
||||
* passing the same combination of `event` and `listener` will result in the `listener`
|
||||
* being added, and called, multiple times.
|
||||
*
|
||||
* @param event - The name of the event.
|
||||
* @param listener - The callback function
|
||||
*
|
||||
* @returns a reference to the `EventEmitter`, so that calls can be chained.
|
||||
*/
|
||||
public prependListener<T extends Events | EventEmitterEvents>(
|
||||
event: T,
|
||||
listener: Listener<Events, Arguments, T>,
|
||||
@@ -86,6 +172,15 @@ export class TypedEventEmitter<
|
||||
return super.prependListener(event, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a **one-time**`listener` function for the event named `event` to the _beginning_ of the listeners array.
|
||||
* The next time `event` is triggered, this listener is removed, and then invoked.
|
||||
*
|
||||
* @param event - The name of the event.
|
||||
* @param listener - The callback function
|
||||
*
|
||||
* @returns a reference to the `EventEmitter`, so that calls can be chained.
|
||||
*/
|
||||
public prependOnceListener<T extends Events | EventEmitterEvents>(
|
||||
event: T,
|
||||
listener: Listener<Events, Arguments, T>,
|
||||
@@ -93,10 +188,25 @@ export class TypedEventEmitter<
|
||||
return super.prependOnceListener(event, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all listeners, or those of the specified `event`.
|
||||
*
|
||||
* It is bad practice to remove listeners added elsewhere in the code,
|
||||
* particularly when the `EventEmitter` instance was created by some other
|
||||
* component or module (e.g. sockets or file streams).
|
||||
*
|
||||
* @param event - The name of the event. If undefined, all listeners everywhere are removed.
|
||||
* @returns a reference to the `EventEmitter`, so that calls can be chained.
|
||||
*/
|
||||
public removeAllListeners(event?: Events | EventEmitterEvents): this {
|
||||
return super.removeAllListeners(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the specified `listener` from the listener array for the event named `event`.
|
||||
*
|
||||
* @returns a reference to the `EventEmitter`, so that calls can be chained.
|
||||
*/
|
||||
public removeListener<T extends Events | EventEmitterEvents>(
|
||||
event: T,
|
||||
listener: Listener<Events, Arguments, T>,
|
||||
@@ -104,7 +214,11 @@ export class TypedEventEmitter<
|
||||
return super.removeListener(event, listener);
|
||||
}
|
||||
|
||||
public rawListeners(event: Events | EventEmitterEvents): ReturnType<EventEmitter["rawListeners"]> {
|
||||
/**
|
||||
* Returns a copy of the array of listeners for the event named `eventName`,
|
||||
* including any wrappers (such as those created by `.once()`).
|
||||
*/
|
||||
public rawListeners(event: Events | EventEmitterEvents): Function[] {
|
||||
return super.rawListeners(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
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 { IMinimalEvent } from "./sync-accumulator";
|
||||
import { EventType } from "./@types/event";
|
||||
import { isSupportedReceiptType, MapWithDefault, recursiveMapToObject } from "./utils";
|
||||
import { IContent } from "./models/event";
|
||||
import { ReceiptContent, ReceiptType } from "./@types/read_receipts";
|
||||
|
||||
interface AccumulatedReceipt {
|
||||
data: IMinimalEvent;
|
||||
type: ReceiptType;
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarises the read receipts within a room. Used by the sync accumulator.
|
||||
*
|
||||
* Given receipts for users, picks the most recently-received one and provides
|
||||
* the results in a new fake receipt event returned from
|
||||
* buildAccumulatedReceiptEvent().
|
||||
*
|
||||
* Handles unthreaded receipts and receipts in each thread separately, so the
|
||||
* returned event contains the most recently received unthreaded receipt, and
|
||||
* the most recently received receipt in each thread.
|
||||
*/
|
||||
export class ReceiptAccumulator {
|
||||
/** user_id -\> most-recently-received unthreaded receipt */
|
||||
private unthreadedReadReceipts: Map<string, AccumulatedReceipt> = new Map();
|
||||
|
||||
/** thread_id -\> user_id -\> most-recently-received receipt for this thread */
|
||||
private threadedReadReceipts: MapWithDefault<string, Map<string, AccumulatedReceipt>> = new MapWithDefault(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Provide an unthreaded receipt for this user. Overwrites any other
|
||||
* unthreaded receipt we have for this user.
|
||||
*/
|
||||
private setUnthreaded(userId: string, receipt: AccumulatedReceipt): void {
|
||||
this.unthreadedReadReceipts.set(userId, receipt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a receipt for this user in this thread. Overwrites any other
|
||||
* receipt we have for this user in this thread.
|
||||
*/
|
||||
private setThreaded(threadId: string, userId: string, receipt: AccumulatedReceipt): void {
|
||||
this.threadedReadReceipts.getOrCreate(threadId).set(userId, receipt);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns an iterator of pairs of [userId, AccumulatedReceipt] - all the
|
||||
* most recently-received unthreaded receipts for each user.
|
||||
*/
|
||||
private allUnthreaded(): IterableIterator<[string, AccumulatedReceipt]> {
|
||||
return this.unthreadedReadReceipts.entries();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns an iterator of pairs of [userId, AccumulatedReceipt] - all the
|
||||
* most recently-received threaded receipts for each user, in all
|
||||
* threads.
|
||||
*/
|
||||
private *allThreaded(): IterableIterator<[string, AccumulatedReceipt]> {
|
||||
for (const receiptsForThread of this.threadedReadReceipts.values()) {
|
||||
for (const e of receiptsForThread.entries()) {
|
||||
yield e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of ephemeral events, find the receipts and store the
|
||||
* relevant ones to be returned later from buildAccumulatedReceiptEvent().
|
||||
*/
|
||||
public consumeEphemeralEvents(events: IMinimalEvent[] | undefined): void {
|
||||
events?.forEach((e) => {
|
||||
if (e.type !== EventType.Receipt || !e.content) {
|
||||
// This means we'll drop unknown ephemeral events but that
|
||||
// seems okay.
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle m.receipt events. They clobber based on:
|
||||
// (user_id, receipt_type)
|
||||
// but they are keyed in the event as:
|
||||
// content:{ $event_id: { $receipt_type: { $user_id: {json} }}}
|
||||
// so store them in the former so we can accumulate receipt deltas
|
||||
// quickly and efficiently (we expect a lot of them). Fold the
|
||||
// receipt type into the key name since we only have 1 at the
|
||||
// moment (m.read) and nested JSON objects are slower and more
|
||||
// of a hassle to work with. We'll inflate this back out when
|
||||
// getJSON() is called.
|
||||
Object.keys(e.content).forEach((eventId) => {
|
||||
Object.entries<ReceiptContent>(e.content[eventId]).forEach(([key, value]) => {
|
||||
if (!isSupportedReceiptType(key)) return;
|
||||
|
||||
for (const userId of Object.keys(value)) {
|
||||
const data = e.content[eventId][key][userId];
|
||||
|
||||
const receipt = {
|
||||
data: e.content[eventId][key][userId],
|
||||
type: key as ReceiptType,
|
||||
eventId,
|
||||
};
|
||||
|
||||
// In a world that supports threads, read receipts normally have
|
||||
// a `thread_id` which is either the thread they belong in or
|
||||
// `MAIN_ROOM_TIMELINE`, so we normally use `setThreaded(...)`
|
||||
// here. The `MAIN_ROOM_TIMELINE` is just treated as another
|
||||
// thread.
|
||||
//
|
||||
// We still encounter read receipts that are "unthreaded"
|
||||
// (missing the `thread_id` property). These come from clients
|
||||
// that don't support threads, and from threaded clients that
|
||||
// are doing a "Mark room as read" operation. Unthreaded
|
||||
// receipts mark everything "before" them as read, in all
|
||||
// threads, where "before" means in Sync Order i.e. the order
|
||||
// the events were received from the homeserver in a sync.
|
||||
// [Note: we have some bugs where we use timestamp order instead
|
||||
// of Sync Order, because we don't correctly remember the Sync
|
||||
// Order. See #3325.]
|
||||
//
|
||||
// Calling the wrong method will cause incorrect behavior like
|
||||
// messages re-appearing as "new" when you already read them
|
||||
// previously.
|
||||
if (!data.thread_id) {
|
||||
this.setUnthreaded(userId, receipt);
|
||||
} else {
|
||||
this.setThreaded(data.thread_id, userId, receipt);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a receipt event that contains all relevant information for this
|
||||
* room, taking the most recently received receipt for each user in an
|
||||
* unthreaded context, and in each thread.
|
||||
*/
|
||||
public buildAccumulatedReceiptEvent(roomId: string): IMinimalEvent | null {
|
||||
const receiptEvent: IMinimalEvent = {
|
||||
type: EventType.Receipt,
|
||||
room_id: roomId,
|
||||
content: {
|
||||
// $event_id: { "m.read": { $user_id: $json } }
|
||||
} as IContent,
|
||||
};
|
||||
|
||||
const receiptEventContent: MapWithDefault<
|
||||
string,
|
||||
MapWithDefault<ReceiptType, Map<string, object>>
|
||||
> = new MapWithDefault(() => new MapWithDefault(() => new Map()));
|
||||
|
||||
for (const [userId, receiptData] of this.allUnthreaded()) {
|
||||
receiptEventContent
|
||||
.getOrCreate(receiptData.eventId)
|
||||
.getOrCreate(receiptData.type)
|
||||
.set(userId, receiptData.data);
|
||||
}
|
||||
|
||||
for (const [userId, receiptData] of this.allThreaded()) {
|
||||
receiptEventContent
|
||||
.getOrCreate(receiptData.eventId)
|
||||
.getOrCreate(receiptData.type)
|
||||
.set(userId, receiptData.data);
|
||||
}
|
||||
|
||||
receiptEvent.content = recursiveMapToObject(receiptEventContent);
|
||||
|
||||
return receiptEventContent.size > 0 ? receiptEvent : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
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, CrossSigningStatus } from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { BootstrapCrossSigningOpts } from "../crypto-api";
|
||||
import { logger } from "../logger";
|
||||
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { UIAuthCallback } from "../interactive-auth";
|
||||
|
||||
/** Manages the cross-signing keys for our own user.
|
||||
*/
|
||||
export class CrossSigningIdentity {
|
||||
public constructor(
|
||||
private readonly olmMachine: OlmMachine,
|
||||
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initialise our cross-signing keys by creating new keys if they do not exist, and uploading to the server
|
||||
*/
|
||||
public async bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void> {
|
||||
if (opts.setupNewCrossSigning) {
|
||||
await this.resetCrossSigning(opts.authUploadDeviceSigningKeys);
|
||||
return;
|
||||
}
|
||||
|
||||
const olmDeviceStatus: CrossSigningStatus = await this.olmMachine.crossSigningStatus();
|
||||
const privateKeysInSecretStorage = false; // TODO
|
||||
const olmDeviceHasKeys =
|
||||
olmDeviceStatus.hasMaster && olmDeviceStatus.hasUserSigning && olmDeviceStatus.hasSelfSigning;
|
||||
|
||||
// Log all relevant state for easier parsing of debug logs.
|
||||
logger.log("bootStrapCrossSigning: starting", {
|
||||
setupNewCrossSigning: opts.setupNewCrossSigning,
|
||||
olmDeviceHasMaster: olmDeviceStatus.hasMaster,
|
||||
olmDeviceHasUserSigning: olmDeviceStatus.hasUserSigning,
|
||||
olmDeviceHasSelfSigning: olmDeviceStatus.hasSelfSigning,
|
||||
privateKeysInSecretStorage,
|
||||
});
|
||||
|
||||
if (!olmDeviceHasKeys && !privateKeysInSecretStorage) {
|
||||
logger.log(
|
||||
"bootStrapCrossSigning: Cross-signing private keys not found locally or in secret storage, creating new keys",
|
||||
);
|
||||
await this.resetCrossSigning(opts.authUploadDeviceSigningKeys);
|
||||
} else if (olmDeviceHasKeys) {
|
||||
logger.log("bootStrapCrossSigning: Olm device has private keys: exporting to secret storage");
|
||||
await this.exportCrossSigningKeysToStorage();
|
||||
} else if (privateKeysInSecretStorage) {
|
||||
logger.log(
|
||||
"bootStrapCrossSigning: Cross-signing private keys not found locally, but they are available " +
|
||||
"in secret storage, reading storage and caching locally",
|
||||
);
|
||||
throw new Error("TODO");
|
||||
}
|
||||
|
||||
// TODO: we might previously have bootstrapped cross-signing but not completed uploading the keys to the
|
||||
// server -- in which case we should call OlmDevice.bootstrap_cross_signing. How do we know?
|
||||
logger.log("bootStrapCrossSigning: complete");
|
||||
}
|
||||
|
||||
/** Reset our cross-signing keys
|
||||
*
|
||||
* This method will:
|
||||
* * Tell the OlmMachine to create new keys
|
||||
* * Upload the new public keys and the device signature to the server
|
||||
* * Upload the private keys to SSSS, if it is set up
|
||||
*/
|
||||
private async resetCrossSigning(authUploadDeviceSigningKeys?: UIAuthCallback<void>): Promise<void> {
|
||||
const outgoingRequests: Array<OutgoingRequest> = await this.olmMachine.bootstrapCrossSigning(true);
|
||||
|
||||
logger.log("bootStrapCrossSigning: publishing keys to server");
|
||||
for (const req of outgoingRequests) {
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(req, authUploadDeviceSigningKeys);
|
||||
}
|
||||
await this.exportCrossSigningKeysToStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the cross-signing keys from the olm machine and save them to secret storage, if it is configured
|
||||
*
|
||||
* (If secret storage is *not* configured, we assume that the export will happen when it is set up)
|
||||
*/
|
||||
private async exportCrossSigningKeysToStorage(): Promise<void> {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,14 @@ import {
|
||||
RoomMessageRequest,
|
||||
SignatureUploadRequest,
|
||||
ToDeviceRequest,
|
||||
SigningKeysUploadRequest,
|
||||
} from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { logger } from "../logger";
|
||||
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { QueryDict } from "../utils";
|
||||
import { IAuthDict, UIAuthCallback } from "../interactive-auth";
|
||||
import { UIAResponse } from "../@types/uia";
|
||||
|
||||
/**
|
||||
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
|
||||
@@ -53,7 +56,7 @@ export class OutgoingRequestProcessor {
|
||||
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
) {}
|
||||
|
||||
public async makeOutgoingRequest(msg: OutgoingRequest): Promise<void> {
|
||||
public async makeOutgoingRequest<T>(msg: OutgoingRequest, uiaCallback?: UIAuthCallback<T>): Promise<void> {
|
||||
let resp: string;
|
||||
|
||||
/* refer https://docs.rs/matrix-sdk-crypto/0.6.0/matrix_sdk_crypto/requests/enum.OutgoingRequests.html
|
||||
@@ -79,6 +82,14 @@ export class OutgoingRequestProcessor {
|
||||
`/_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 if (msg instanceof SigningKeysUploadRequest) {
|
||||
resp = await this.makeRequestWithUIA(
|
||||
Method.Post,
|
||||
"/_matrix/client/v3/keys/device_signing/upload",
|
||||
{},
|
||||
msg.body,
|
||||
uiaCallback,
|
||||
);
|
||||
} else {
|
||||
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
|
||||
resp = "";
|
||||
@@ -89,6 +100,31 @@ export class OutgoingRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private async makeRequestWithUIA<T>(
|
||||
method: Method,
|
||||
path: string,
|
||||
queryParams: QueryDict,
|
||||
body: string,
|
||||
uiaCallback: UIAuthCallback<T> | undefined,
|
||||
): Promise<string> {
|
||||
if (!uiaCallback) {
|
||||
return await this.rawJsonRequest(method, path, queryParams, body);
|
||||
}
|
||||
|
||||
const parsedBody = JSON.parse(body);
|
||||
const makeRequest = async (auth: IAuthDict): Promise<UIAResponse<T>> => {
|
||||
const newBody = {
|
||||
...parsedBody,
|
||||
auth,
|
||||
};
|
||||
const resp = await this.rawJsonRequest(method, path, queryParams, JSON.stringify(newBody));
|
||||
return JSON.parse(resp) as T;
|
||||
};
|
||||
|
||||
const resp = await uiaCallback(makeRequest);
|
||||
return JSON.stringify(resp);
|
||||
}
|
||||
|
||||
private async rawJsonRequest(method: Method, path: string, queryParams: QueryDict, body: string): Promise<string> {
|
||||
const opts = {
|
||||
// inhibit the JSON stringification and parsing within HttpApi.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
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 { Device, DeviceVerification } from "../models/device";
|
||||
import { DeviceKeys } from "../client";
|
||||
|
||||
/**
|
||||
* Convert a {@link RustSdkCryptoJs.Device} to a {@link Device}
|
||||
* @param device - Rust Sdk device
|
||||
* @param userId - owner of the device
|
||||
*/
|
||||
export function rustDeviceToJsDevice(device: RustSdkCryptoJs.Device, userId: RustSdkCryptoJs.UserId): Device {
|
||||
// Copy rust device keys to Device.keys
|
||||
const keys = new Map<string, string>();
|
||||
for (const [keyId, key] of device.keys.entries()) {
|
||||
keys.set(keyId.toString(), key.toBase64());
|
||||
}
|
||||
|
||||
// Compute verified from device state
|
||||
let verified: DeviceVerification = DeviceVerification.Unverified;
|
||||
if (device.isBlacklisted()) {
|
||||
verified = DeviceVerification.Blocked;
|
||||
} else if (device.isVerified()) {
|
||||
verified = DeviceVerification.Verified;
|
||||
}
|
||||
|
||||
// Convert rust signatures to Device.signatures
|
||||
const signatures = new Map<string, Map<string, string>>();
|
||||
const mayBeSignatureMap: Map<string, RustSdkCryptoJs.MaybeSignature> | undefined = device.signatures.get(userId);
|
||||
if (mayBeSignatureMap) {
|
||||
const convertedSignatures = new Map<string, string>();
|
||||
// Convert maybeSignatures map to a Map<string, string>
|
||||
for (const [key, value] of mayBeSignatureMap.entries()) {
|
||||
if (value.isValid() && value.signature) {
|
||||
convertedSignatures.set(key, value.signature.toBase64());
|
||||
}
|
||||
}
|
||||
|
||||
signatures.set(userId.toString(), convertedSignatures);
|
||||
}
|
||||
|
||||
// Convert rust algorithms to algorithms
|
||||
const rustAlgorithms: RustSdkCryptoJs.EncryptionAlgorithm[] = device.algorithms;
|
||||
// Use set to ensure that algorithms are not duplicated
|
||||
const algorithms = new Set<string>();
|
||||
rustAlgorithms.forEach((algorithm) => {
|
||||
switch (algorithm) {
|
||||
case RustSdkCryptoJs.EncryptionAlgorithm.MegolmV1AesSha2:
|
||||
algorithms.add("m.megolm.v1.aes-sha2");
|
||||
break;
|
||||
case RustSdkCryptoJs.EncryptionAlgorithm.OlmV1Curve25519AesSha2:
|
||||
default:
|
||||
algorithms.add("m.olm.v1.curve25519-aes-sha2");
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return new Device({
|
||||
deviceId: device.deviceId.toString(),
|
||||
userId: userId.toString(),
|
||||
keys,
|
||||
algorithms: Array.from(algorithms),
|
||||
verified,
|
||||
signatures,
|
||||
displayName: device.displayName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert {@link DeviceKeys} from `/keys/query` request to a `Map<string, Device>`
|
||||
* @param deviceKeys - Device keys object to convert
|
||||
*/
|
||||
export function deviceKeysToDeviceMap(deviceKeys: DeviceKeys): Map<string, Device> {
|
||||
return new Map(
|
||||
Object.entries(deviceKeys).map(([deviceId, device]) => [deviceId, downloadDeviceToJsDevice(device)]),
|
||||
);
|
||||
}
|
||||
|
||||
// Device from `/keys/query` request
|
||||
type QueryDevice = DeviceKeys[keyof DeviceKeys];
|
||||
|
||||
/**
|
||||
* Convert `/keys/query` {@link QueryDevice} device to {@link Device}
|
||||
* @param device - Device from `/keys/query` request
|
||||
*/
|
||||
export function downloadDeviceToJsDevice(device: QueryDevice): Device {
|
||||
const keys = new Map(Object.entries(device.keys));
|
||||
const displayName = device.unsigned?.device_display_name;
|
||||
|
||||
const signatures = new Map<string, Map<string, string>>();
|
||||
if (device.signatures) {
|
||||
for (const userId in device.signatures) {
|
||||
signatures.set(userId, new Map(Object.entries(device.signatures[userId])));
|
||||
}
|
||||
}
|
||||
|
||||
return new Device({
|
||||
deviceId: device.device_id,
|
||||
userId: device.user_id,
|
||||
keys,
|
||||
algorithms: device.algorithms,
|
||||
verified: DeviceVerification.Unverified,
|
||||
signatures,
|
||||
displayName,
|
||||
});
|
||||
}
|
||||
@@ -20,11 +20,22 @@ import { RustCrypto } from "./rust-crypto";
|
||||
import { logger } from "../logger";
|
||||
import { RUST_SDK_STORE_PREFIX } from "./constants";
|
||||
import { IHttpOpts, MatrixHttpApi } from "../http-api";
|
||||
import { ServerSideSecretStorage } from "../secret-storage";
|
||||
|
||||
/**
|
||||
* Create a new `RustCrypto` implementation
|
||||
*
|
||||
* @param http - Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
|
||||
* We expect it to set the access token, etc.
|
||||
* @param userId - The local user's User ID.
|
||||
* @param deviceId - The local user's Device ID.
|
||||
* @param secretStorage - Interface to server-side secret storage.
|
||||
*/
|
||||
export async function initRustCrypto(
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
secretStorage: ServerSideSecretStorage,
|
||||
): Promise<RustCrypto> {
|
||||
// initialise the rust matrix-sdk-crypto-js, if it hasn't already been done
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
@@ -38,7 +49,7 @@ export async function initRustCrypto(
|
||||
|
||||
// TODO: use the pickle key for the passphrase
|
||||
const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(u, d, RUST_SDK_STORE_PREFIX, "test pass");
|
||||
const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId);
|
||||
const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId, secretStorage);
|
||||
await olmMachine.registerRoomKeyUpdatedCallback((sessions: RustSdkCryptoJs.RoomKeyInfo[]) =>
|
||||
rustCrypto.onRoomKeysUpdated(sessions),
|
||||
);
|
||||
|
||||
@@ -24,18 +24,26 @@ 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 } from "../http-api";
|
||||
import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
|
||||
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { UserTrustLevel } from "../crypto/CrossSigning";
|
||||
import { RoomEncryptor } from "./RoomEncryptor";
|
||||
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { KeyClaimManager } from "./KeyClaimManager";
|
||||
import { MapWithDefault } from "../utils";
|
||||
import { BootstrapCrossSigningOpts, DeviceVerificationStatus } from "../crypto-api";
|
||||
import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter";
|
||||
import { IDownloadKeyResult, IQueryKeysRequest } from "../client";
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
import { ServerSideSecretStorage } from "../secret-storage";
|
||||
import { CrossSigningKey } from "../crypto/api";
|
||||
import { CrossSigningIdentity } from "./CrossSigningIdentity";
|
||||
|
||||
/**
|
||||
* An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
|
||||
*/
|
||||
export class RustCrypto implements CryptoBackend {
|
||||
public globalErrorOnUnknownDevices = false;
|
||||
private _trustCrossSignedDevices = true;
|
||||
|
||||
/** whether {@link stop} has been called */
|
||||
private stopped = false;
|
||||
@@ -49,16 +57,32 @@ export class RustCrypto implements CryptoBackend {
|
||||
private eventDecryptor: EventDecryptor;
|
||||
private keyClaimManager: KeyClaimManager;
|
||||
private outgoingRequestProcessor: OutgoingRequestProcessor;
|
||||
private crossSigningIdentity: CrossSigningIdentity;
|
||||
|
||||
public constructor(
|
||||
/** The `OlmMachine` from the underlying rust crypto sdk. */
|
||||
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
|
||||
/**
|
||||
* Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
|
||||
*
|
||||
* We expect it to set the access token, etc.
|
||||
*/
|
||||
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
|
||||
/** The local user's User ID. */
|
||||
_userId: string,
|
||||
|
||||
/** The local user's Device ID. */
|
||||
_deviceId: string,
|
||||
|
||||
/** Interface to server-side secret storage */
|
||||
_secretStorage: ServerSideSecretStorage,
|
||||
) {
|
||||
this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http);
|
||||
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
|
||||
this.eventDecryptor = new EventDecryptor(olmMachine);
|
||||
this.crossSigningIdentity = new CrossSigningIdentity(olmMachine, this.outgoingRequestProcessor);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -130,9 +154,30 @@ export class RustCrypto implements CryptoBackend {
|
||||
return new UserTrustLevel(false, false, false);
|
||||
}
|
||||
|
||||
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
|
||||
/**
|
||||
* Finds a DM verification request that is already in progress for the given room id
|
||||
*
|
||||
* @param roomId - the room to use for verification
|
||||
*
|
||||
* @returns the VerificationRequest that is in progress, if any
|
||||
*/
|
||||
public findVerificationRequestDMInProgress(roomId: string): undefined {
|
||||
// TODO
|
||||
return new DeviceTrustLevel(false, false, false, false);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cross signing information for a given user.
|
||||
*
|
||||
* The cross-signing API is currently UNSTABLE and may change without notice.
|
||||
*
|
||||
* @param userId - the user ID to get the cross-signing info for.
|
||||
*
|
||||
* @returns the cross signing information for the user.
|
||||
*/
|
||||
public getStoredCrossSigningForUser(userId: string): null {
|
||||
// TODO
|
||||
return null;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -165,6 +210,146 @@ export class RustCrypto implements CryptoBackend {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device information for the given list of users.
|
||||
*
|
||||
* @param userIds - The users to fetch.
|
||||
* @param downloadUncached - If true, download the device list for users whose device list we are not
|
||||
* currently tracking. Defaults to false, in which case such users will not appear at all in the result map.
|
||||
*
|
||||
* @returns A map `{@link DeviceMap}`.
|
||||
*/
|
||||
public async getUserDeviceInfo(userIds: string[], downloadUncached = false): Promise<DeviceMap> {
|
||||
const deviceMapByUserId = new Map<string, Map<string, Device>>();
|
||||
const rustTrackedUsers: Set<RustSdkCryptoJs.UserId> = await this.olmMachine.trackedUsers();
|
||||
|
||||
// Convert RustSdkCryptoJs.UserId to a `Set<string>`
|
||||
const trackedUsers = new Set<string>();
|
||||
rustTrackedUsers.forEach((rustUserId) => trackedUsers.add(rustUserId.toString()));
|
||||
|
||||
// Keep untracked user to download their keys after
|
||||
const untrackedUsers: Set<string> = new Set();
|
||||
|
||||
for (const userId of userIds) {
|
||||
// if this is a tracked user, we can just fetch the device list from the rust-sdk
|
||||
// (NB: this is probably ok even if we race with a leave event such that we stop tracking the user's
|
||||
// devices: the rust-sdk will return the last-known device list, which will be good enough.)
|
||||
if (trackedUsers.has(userId)) {
|
||||
deviceMapByUserId.set(userId, await this.getUserDevices(userId));
|
||||
} else {
|
||||
untrackedUsers.add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
// for any users whose device lists we are not tracking, fall back to downloading the device list
|
||||
// over HTTP.
|
||||
if (downloadUncached && untrackedUsers.size >= 1) {
|
||||
const queryResult = await this.downloadDeviceList(untrackedUsers);
|
||||
Object.entries(queryResult.device_keys).forEach(([userId, deviceKeys]) =>
|
||||
deviceMapByUserId.set(userId, deviceKeysToDeviceMap(deviceKeys)),
|
||||
);
|
||||
}
|
||||
|
||||
return deviceMapByUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device list for the given user from the olm machine
|
||||
* @param userId - Rust SDK UserId
|
||||
*/
|
||||
private async getUserDevices(userId: string): Promise<Map<string, Device>> {
|
||||
const rustUserId = new RustSdkCryptoJs.UserId(userId);
|
||||
const devices: RustSdkCryptoJs.UserDevices = await this.olmMachine.getUserDevices(rustUserId);
|
||||
return new Map(
|
||||
devices
|
||||
.devices()
|
||||
.map((device: RustSdkCryptoJs.Device) => [
|
||||
device.deviceId.toString(),
|
||||
rustDeviceToJsDevice(device, rustUserId),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the given user keys by calling `/keys/query` request
|
||||
* @param untrackedUsers - download keys of these users
|
||||
*/
|
||||
private async downloadDeviceList(untrackedUsers: Set<string>): Promise<IDownloadKeyResult> {
|
||||
const queryBody: IQueryKeysRequest = { device_keys: {} };
|
||||
untrackedUsers.forEach((user) => (queryBody.device_keys[user] = []));
|
||||
|
||||
return await this.http.authedRequest(Method.Post, "/_matrix/client/v3/keys/query", undefined, queryBody, {
|
||||
prefix: "",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getTrustCrossSignedDevices}.
|
||||
*/
|
||||
public getTrustCrossSignedDevices(): boolean {
|
||||
return this._trustCrossSignedDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#setTrustCrossSignedDevices}.
|
||||
*/
|
||||
public setTrustCrossSignedDevices(val: boolean): void {
|
||||
this._trustCrossSignedDevices = val;
|
||||
// TODO: legacy crypto goes through the list of known devices and emits DeviceVerificationChanged
|
||||
// events. Maybe we need to do the same?
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getDeviceVerificationStatus}.
|
||||
*/
|
||||
public async getDeviceVerificationStatus(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
): Promise<DeviceVerificationStatus | null> {
|
||||
const device: RustSdkCryptoJs.Device | undefined = await this.olmMachine.getDevice(
|
||||
new RustSdkCryptoJs.UserId(userId),
|
||||
new RustSdkCryptoJs.DeviceId(deviceId),
|
||||
);
|
||||
|
||||
if (!device) return null;
|
||||
|
||||
return new DeviceVerificationStatus({
|
||||
signedByOwner: device.isCrossSignedByOwner(),
|
||||
crossSigningVerified: device.isCrossSigningTrusted(),
|
||||
localVerified: device.isLocallyTrusted(),
|
||||
trustCrossSignedDevices: this._trustCrossSignedDevices,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#isCrossSigningReady}
|
||||
*/
|
||||
public async isCrossSigningReady(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getCrossSigningKeyId}
|
||||
*/
|
||||
public async getCrossSigningKeyId(type: CrossSigningKey = CrossSigningKey.Master): Promise<string | null> {
|
||||
// TODO
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#boostrapCrossSigning}
|
||||
*/
|
||||
public async bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void> {
|
||||
await this.crossSigningIdentity.bootstrapCrossSigning(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#isSecretStorageReady}
|
||||
*/
|
||||
public async isSecretStorageReady(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// SyncCryptoCallbacks implementation
|
||||
@@ -182,7 +367,7 @@ export class RustCrypto implements CryptoBackend {
|
||||
private async receiveSyncChanges({
|
||||
events,
|
||||
oneTimeKeysCounts = new Map<string, number>(),
|
||||
unusedFallbackKeys = new Set<string>(),
|
||||
unusedFallbackKeys,
|
||||
devices = new RustSdkCryptoJs.DeviceLists(),
|
||||
}: {
|
||||
events?: IToDeviceEvent[];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user