Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd379d3d4c | |||
| 796135c7ce | |||
| 533b40922c | |||
| b69a19ce7c | |||
| 8703acb533 | |||
| b59603d748 | |||
| b0cbe22f64 | |||
| 977d0322da | |||
| dd7394c14c | |||
| f2d082064e | |||
| 2731e20893 | |||
| 502a513b5b | |||
| 3ac47e71cd | |||
| 7c1e25e713 | |||
| 2d90ad95f1 | |||
| cd9794471f | |||
| b2d3ab8bc1 | |||
| d8b70ef83b | |||
| a67fb1fb8d | |||
| ddd6e77cde | |||
| 2e9f5b6033 | |||
| fd949fe486 | |||
| 7b3aed8a47 | |||
| b84a73c7cc | |||
| a03cf054a8 | |||
| b3d217717a | |||
| 3e6fe5f914 | |||
| a213d177f9 | |||
| e885ecf08d | |||
| d1d9aba745 | |||
| 52bcc2c955 | |||
| 1994806c72 | |||
| c4d1fd2c67 | |||
| 7ad8288525 | |||
| 31a42964e6 | |||
| 2b1d37813c | |||
| e6fd0c58ff | |||
| 41d70d0b5d | |||
| a08a2737e1 | |||
| dbe441de33 | |||
| 9f3ca71495 | |||
| 7f74fcc9f7 | |||
| e39644ad08 | |||
| df0f0074b4 | |||
| 29fbed5603 | |||
| 5ee6fc196b | |||
| 961e32a3bb | |||
| 25f0418fce | |||
| b7b3588cb8 | |||
| 72846c713d | |||
| 7c5229b4c8 | |||
| dd08388397 | |||
| ec1ccebcca | |||
| e9b45cc504 | |||
| 5d4df65c09 |
@@ -22,7 +22,7 @@ runs:
|
||||
|
||||
- name: Upload tarball signature
|
||||
if: ${{ inputs.upload-url }}
|
||||
uses: shogo82148/actions-upload-release-asset@e6cd4579c50a5d0eb50ca05d0d597daafca8cebb # v1
|
||||
uses: shogo82148/actions-upload-release-asset@59cbc563d11314e48122193f8fe5cdda62ea6cf9 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ env.VERSION }}.tar.gz.asc
|
||||
|
||||
@@ -29,13 +29,13 @@ runs:
|
||||
|
||||
- name: Upload asset signatures
|
||||
if: inputs.gpg-fingerprint
|
||||
uses: shogo82148/actions-upload-release-asset@e6cd4579c50a5d0eb50ca05d0d597daafca8cebb # v1
|
||||
uses: shogo82148/actions-upload-release-asset@59cbc563d11314e48122193f8fe5cdda62ea6cf9 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}.asc
|
||||
|
||||
- name: Upload assets
|
||||
uses: shogo82148/actions-upload-release-asset@e6cd4579c50a5d0eb50ca05d0d597daafca8cebb # v1
|
||||
uses: shogo82148/actions-upload-release-asset@59cbc563d11314e48122193f8fe5cdda62ea6cf9 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
|
||||
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
|
||||
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
repository: ${{ matrix.repo }}
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Add notice
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
|
||||
with:
|
||||
script: |
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check membership
|
||||
if: github.event.pull_request.user.login != 'renovate[bot]'
|
||||
if: github.event.pull_request.user.login != 'renovate[bot]' && github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
uses: tspascoal/get-user-teams-membership@57e9f42acd78f4d0f496b3be4368fc5f62696662 # v3
|
||||
id: teams
|
||||
with:
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
|
||||
- name: Add label
|
||||
if: steps.teams.outputs.isTeamMember == 'false'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.addLabels({
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name != github.repository
|
||||
steps:
|
||||
- name: Close pull request
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Check for X-Release-Blocker label on any open issues or PRs
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
REPO: ${{ inputs.repository }}
|
||||
with:
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
ref: staging
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: "yarn"
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Ingest upstream changes
|
||||
if: inputs.include-changes
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
sparse-checkout: |
|
||||
scripts/release
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
run: echo "VERSION=$(echo $VERSION | cut -d- -f1)" >> $GITHUB_ENV
|
||||
|
||||
- name: Check version number not in use
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
const { VERSION } = process.env;
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
git config --global user.email "releases@riot.im"
|
||||
git config --global user.name "RiotRobot"
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -218,7 +218,7 @@ jobs:
|
||||
|
||||
- name: Validate release has expected assets
|
||||
if: inputs.expected-asset-count
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
|
||||
EXPECTED_ASSET_COUNT: ${{ inputs.expected-asset-count }}
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
git push origin master
|
||||
|
||||
- name: Publish release
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
|
||||
FINAL: ${{ inputs.final }}
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
ref: staging
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
ref: staging
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
steps:
|
||||
# We create the status here and then update it to success/failure in the `report` stage
|
||||
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
|
||||
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: pending
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
revision: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
|
||||
if: always()
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
- name: Build Types
|
||||
run: "yarn build:types"
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "npm"
|
||||
node-version-file: "examples/node/package.json"
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
with:
|
||||
repository: element-hq/element-web
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- name: Setup Node
|
||||
id: setupNode
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: ${{ matrix.node }}
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
steps:
|
||||
- name: Skip SonarCloud on merge queues
|
||||
if: env.ENABLE_COVERAGE == 'false'
|
||||
uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10
|
||||
with:
|
||||
operations-per-run: 250
|
||||
days-before-issue-stale: -1
|
||||
|
||||
@@ -1,3 +1,47 @@
|
||||
Changes in [39.1.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.1.1) (2025-11-04)
|
||||
==================================================================================================
|
||||
Re-release of v39.1.0 to fix npm publishing workflow
|
||||
|
||||
Changes in [39.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.1.0) (2025-11-04)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* [MatrixRTC] Sticky Events support (MSC4354) ([#5017](https://github.com/matrix-org/matrix-js-sdk/pull/5017)). Contributed by @toger5.
|
||||
* Add `CryptoApi.getSecretStorageStatus` ([#5054](https://github.com/matrix-org/matrix-js-sdk/pull/5054)). Contributed by @richvdh.
|
||||
* Add parseCallNotificationContent ([#5015](https://github.com/matrix-org/matrix-js-sdk/pull/5015)). Contributed by @toger5.
|
||||
* MSC4140: support filters on delayed event lookup ([#5038](https://github.com/matrix-org/matrix-js-sdk/pull/5038)). Contributed by @AndrewFerr.
|
||||
|
||||
|
||||
Changes in [39.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.0.0) (2025-10-21)
|
||||
==================================================================================================
|
||||
## 🚨 BREAKING CHANGES
|
||||
|
||||
* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5.
|
||||
* Implement Sticky Events MSC4354 ([#5028](https://github.com/matrix-org/matrix-js-sdk/pull/5028)). Contributed by @Half-Shot.
|
||||
* feat(client): allow disabling VoIP support ([#5021](https://github.com/matrix-org/matrix-js-sdk/pull/5021)). Contributed by @pkuzco.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Only use the first 3 viaServers specified ([#5034](https://github.com/matrix-org/matrix-js-sdk/pull/5034)). Contributed by @t3chguy.
|
||||
* Fetch the user's device info before processing a verification request ([#5030](https://github.com/matrix-org/matrix-js-sdk/pull/5030)). Contributed by @andybalaam.
|
||||
|
||||
|
||||
Changes in [38.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.4.0) (2025-10-07)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Add call intent to RTC call notifications ([#5010](https://github.com/matrix-org/matrix-js-sdk/pull/5010)). Contributed by @Half-Shot.
|
||||
* Implement experimental encrypted state events. ([#4994](https://github.com/matrix-org/matrix-js-sdk/pull/4994)). Contributed by @kaylendog.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Exclude cancelled requests from in-progress lists ([#5016](https://github.com/matrix-org/matrix-js-sdk/pull/5016)). Contributed by @andybalaam.
|
||||
|
||||
|
||||
Changes in [38.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.3.0) (2025-09-23)
|
||||
==================================================================================================
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
@@ -23,6 +23,7 @@ const config: Config = {
|
||||
collectCoverageFrom: ["<rootDir>/src/**/*.{js,ts}"],
|
||||
coverageReporters: ["text-summary", "lcov"],
|
||||
testResultsProcessor: "@casualbot/jest-sonar-reporter",
|
||||
transformIgnorePatterns: ["/node_modules/(?!(uuid|p-retry|is-network-error)).+$"],
|
||||
|
||||
// Always print out a summary if there are any failing tests. Normally
|
||||
// a summary is only printed if there are more than 20 test *suites*.
|
||||
|
||||
@@ -12,10 +12,6 @@ export default {
|
||||
"src/utils.ts", // not really an entrypoint but we have deprecated `defer` there
|
||||
"scripts/**",
|
||||
"spec/**",
|
||||
// XXX: these look entirely unused
|
||||
"src/crypto/aes.ts",
|
||||
"src/crypto/crypto.ts",
|
||||
"src/crypto/recoverykey.ts",
|
||||
// XXX: these should be re-exported by one of the supported exports
|
||||
"src/matrixrtc/index.ts",
|
||||
"src/sliding-sync.ts",
|
||||
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "38.3.0",
|
||||
"version": "39.1.1",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
@@ -49,7 +49,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^15.2.0",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^15.3.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^6.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -58,10 +58,10 @@
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-widget-api": "^1.10.0",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
"p-retry": "4",
|
||||
"p-retry": "7",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"unhomoglyph": "^1.0.6",
|
||||
"uuid": "11"
|
||||
"uuid": "13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@action-validator/cli": "^0.6.0",
|
||||
@@ -97,9 +97,9 @@
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-import-resolver-typescript": "^4.0.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^28.0.0",
|
||||
"eslint-plugin-jsdoc": "^50.0.0",
|
||||
"eslint-plugin-matrix-org": "2.1.0",
|
||||
"eslint-plugin-jest": "^29.0.0",
|
||||
"eslint-plugin-jsdoc": "^61.0.0",
|
||||
"eslint-plugin-matrix-org": "^3.0.0",
|
||||
"eslint-plugin-n": "^14.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.4.0",
|
||||
"eslint-plugin-unicorn": "^56.0.0",
|
||||
|
||||
@@ -88,7 +88,10 @@ import {
|
||||
encryptMegolmEventRawPlainText,
|
||||
establishOlmSession,
|
||||
getTestOlmAccountKeys,
|
||||
} from "./olm-utils";
|
||||
expectSendRoomKey,
|
||||
expectSendMegolmMessageEvent,
|
||||
expectEncryptedSendMessageEvent,
|
||||
} from "./olm-utils.ts";
|
||||
import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator";
|
||||
import { UNSIGNED_MEMBERSHIP_FIELD } from "../../../src/@types/event";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
@@ -104,107 +107,6 @@ afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
/**
|
||||
* Expect that the client shares keys with the given recipient
|
||||
*
|
||||
* Waits for an HTTP request to send the encrypted m.room_key to-device message; decrypts it and uses it
|
||||
* to establish an Olm InboundGroupSession.
|
||||
*
|
||||
* @param recipientUserID - the user id of the expected recipient
|
||||
*
|
||||
* @param recipientOlmAccount - Olm.Account for the recipient
|
||||
*
|
||||
* @param recipientOlmSession - an Olm.Session for the recipient, which must already have exchanged pre-key
|
||||
* messages with the sender. Alternatively, null, in which case we will expect a pre-key message.
|
||||
*
|
||||
* @returns the established inbound group session
|
||||
*/
|
||||
async function expectSendRoomKey(
|
||||
recipientUserID: string,
|
||||
recipientOlmAccount: Olm.Account,
|
||||
recipientOlmSession: Olm.Session | null = null,
|
||||
): Promise<Olm.InboundGroupSession> {
|
||||
const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"];
|
||||
|
||||
function onSendRoomKey(content: any): Olm.InboundGroupSession {
|
||||
const m = content.messages[recipientUserID].DEVICE_ID;
|
||||
const ct = m.ciphertext[testRecipientKey];
|
||||
|
||||
if (!recipientOlmSession) {
|
||||
expect(ct.type).toEqual(0); // pre-key message
|
||||
recipientOlmSession = new Olm.Session();
|
||||
recipientOlmSession.create_inbound(recipientOlmAccount, ct.body);
|
||||
} else {
|
||||
expect(ct.type).toEqual(1); // regular message
|
||||
}
|
||||
|
||||
const decrypted = JSON.parse(recipientOlmSession.decrypt(ct.type, ct.body));
|
||||
expect(decrypted.type).toEqual("m.room_key");
|
||||
const inboundGroupSession = new Olm.InboundGroupSession();
|
||||
inboundGroupSession.create(decrypted.content.session_key);
|
||||
return inboundGroupSession;
|
||||
}
|
||||
return await new Promise<Olm.InboundGroupSession>((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp("/sendToDevice/m.room.encrypted/"),
|
||||
(url: string, opts: RequestInit): FetchMock.MockResponse => {
|
||||
const content = JSON.parse(opts.body as string);
|
||||
resolve(onSendRoomKey(content));
|
||||
return {};
|
||||
},
|
||||
{
|
||||
// append to the list of intercepts on this path (since we have some tests that call
|
||||
// this function multiple times)
|
||||
overwriteRoutes: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the event received on rooms/{roomId}/send/m.room.encrypted endpoint.
|
||||
* See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid
|
||||
* @returns the content of the encrypted event
|
||||
*/
|
||||
function expectEncryptedSendMessage() {
|
||||
return new Promise<IContent>((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp("/send/m.room.encrypted/"),
|
||||
(url, request) => {
|
||||
const content = JSON.parse(request.body as string);
|
||||
resolve(content);
|
||||
return { event_id: "$event_id" };
|
||||
},
|
||||
// append to the list of intercepts on this path (since we have some tests that call
|
||||
// this function multiple times)
|
||||
{ overwriteRoutes: false },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Expect that the client sends an encrypted event
|
||||
*
|
||||
* Waits for an HTTP request to send an encrypted message in the test room.
|
||||
*
|
||||
* @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will
|
||||
* be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed.
|
||||
*
|
||||
* @returns The content of the successfully-decrypted event
|
||||
*/
|
||||
async function expectSendMegolmMessage(
|
||||
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
|
||||
): Promise<Partial<IEvent>> {
|
||||
const encryptedMessageContent = await expectEncryptedSendMessage();
|
||||
|
||||
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
|
||||
const inboundGroupSession = await inboundGroupSessionPromise;
|
||||
|
||||
const r: any = inboundGroupSession.decrypt(encryptedMessageContent!.ciphertext);
|
||||
logger.log("Decrypted received megolm message", r);
|
||||
return JSON.parse(r.plaintext);
|
||||
}
|
||||
|
||||
describe("crypto", () => {
|
||||
let testOlmAccount = {} as unknown as Olm.Account;
|
||||
let testSenderKey = "";
|
||||
@@ -991,7 +893,7 @@ describe("crypto", () => {
|
||||
// Finally, send the message, and expect to get an `m.room.encrypted` event that we can decrypt.
|
||||
await Promise.all([
|
||||
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
||||
expectSendMegolmMessage(inboundGroupSessionPromise),
|
||||
expectSendMegolmMessageEvent(inboundGroupSessionPromise),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1018,7 +920,7 @@ describe("crypto", () => {
|
||||
// Send the first message, and check we can decrypt it.
|
||||
await Promise.all([
|
||||
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
||||
expectSendMegolmMessage(inboundGroupSessionPromise),
|
||||
expectSendMegolmMessageEvent(inboundGroupSessionPromise),
|
||||
]);
|
||||
|
||||
// Finally the interesting part: discard the session.
|
||||
@@ -1026,7 +928,7 @@ describe("crypto", () => {
|
||||
|
||||
// Now when we send the next message, we should get a *new* megolm session.
|
||||
const inboundGroupSessionPromise2 = expectSendRoomKey("@bob:xyz", testOlmAccount);
|
||||
const p2 = expectSendMegolmMessage(inboundGroupSessionPromise2);
|
||||
const p2 = expectSendMegolmMessageEvent(inboundGroupSessionPromise2);
|
||||
await Promise.all([aliceClient.sendTextMessage(ROOM_ID, "test2"), p2]);
|
||||
});
|
||||
|
||||
@@ -1037,7 +939,7 @@ describe("crypto", () => {
|
||||
*/
|
||||
async function sendEncryptedMessage(): Promise<IContent> {
|
||||
const [encryptedMessage] = await Promise.all([
|
||||
expectEncryptedSendMessage(),
|
||||
expectEncryptedSendMessageEvent(),
|
||||
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
||||
]);
|
||||
return encryptedMessage;
|
||||
@@ -1159,7 +1061,7 @@ describe("crypto", () => {
|
||||
let [, , encryptedMessage] = await Promise.all([
|
||||
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
||||
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
|
||||
expectEncryptedSendMessage(),
|
||||
expectEncryptedSendMessageEvent(),
|
||||
]);
|
||||
|
||||
// Check that the session id exists
|
||||
@@ -1187,7 +1089,7 @@ describe("crypto", () => {
|
||||
[, , encryptedMessage] = await Promise.all([
|
||||
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
||||
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
|
||||
expectEncryptedSendMessage(),
|
||||
expectEncryptedSendMessageEvent(),
|
||||
]);
|
||||
|
||||
// Check that the new session id exists
|
||||
@@ -1385,7 +1287,7 @@ describe("crypto", () => {
|
||||
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession);
|
||||
|
||||
// and finally the megolm message
|
||||
const megolmMessagePromise = expectSendMegolmMessage(inboundGroupSessionPromise);
|
||||
const megolmMessagePromise = expectSendMegolmMessageEvent(inboundGroupSessionPromise);
|
||||
|
||||
// kick it off
|
||||
const sendPromise = aliceClient.sendTextMessage(ROOM_ID, "test");
|
||||
@@ -1408,7 +1310,7 @@ describe("crypto", () => {
|
||||
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession);
|
||||
|
||||
// and finally the megolm message
|
||||
const megolmMessagePromise = expectSendMegolmMessage(inboundGroupSessionPromise);
|
||||
const megolmMessagePromise = expectSendMegolmMessageEvent(inboundGroupSessionPromise);
|
||||
|
||||
// kick it off
|
||||
const sendPromise = aliceClient.sendTextMessage(ROOM_ID, "test");
|
||||
@@ -2300,7 +2202,7 @@ describe("crypto", () => {
|
||||
await syncPromise(client1);
|
||||
|
||||
// Send a message, and expect to get an `m.room.encrypted` event.
|
||||
await Promise.all([client1.sendTextMessage(ROOM_ID, "test"), expectEncryptedSendMessage()]);
|
||||
await Promise.all([client1.sendTextMessage(ROOM_ID, "test"), expectEncryptedSendMessageEvent()]);
|
||||
|
||||
// We now replace the client, and allow the new one to resync, *without* the encryption event.
|
||||
client2 = await replaceClient(client1);
|
||||
@@ -2321,7 +2223,7 @@ describe("crypto", () => {
|
||||
// Send a message, and expect to get an `m.room.encrypted` event.
|
||||
const [, msg1Content] = await Promise.all([
|
||||
client1.sendTextMessage(ROOM_ID, "test1"),
|
||||
expectEncryptedSendMessage(),
|
||||
expectEncryptedSendMessageEvent(),
|
||||
]);
|
||||
|
||||
// Replace the state with one which bumps the rotation period. This should be ignored, though it's not
|
||||
@@ -2340,12 +2242,12 @@ describe("crypto", () => {
|
||||
// use a different one.
|
||||
const [, msg2Content] = await Promise.all([
|
||||
client1.sendTextMessage(ROOM_ID, "test2"),
|
||||
expectEncryptedSendMessage(),
|
||||
expectEncryptedSendMessageEvent(),
|
||||
]);
|
||||
expect(msg2Content.session_id).toEqual(msg1Content.session_id);
|
||||
const [, msg3Content] = await Promise.all([
|
||||
client1.sendTextMessage(ROOM_ID, "test3"),
|
||||
expectEncryptedSendMessage(),
|
||||
expectEncryptedSendMessageEvent(),
|
||||
]);
|
||||
expect(msg3Content.session_id).not.toEqual(msg1Content.session_id);
|
||||
});
|
||||
@@ -2357,7 +2259,7 @@ describe("crypto", () => {
|
||||
await syncPromise(client1);
|
||||
|
||||
// Send a message, and expect to get an `m.room.encrypted` event.
|
||||
await Promise.all([client1.sendTextMessage(ROOM_ID, "test1"), expectEncryptedSendMessage()]);
|
||||
await Promise.all([client1.sendTextMessage(ROOM_ID, "test1"), expectEncryptedSendMessageEvent()]);
|
||||
|
||||
// We now replace the client, and allow the new one to resync with a *different* encryption event.
|
||||
client2 = await replaceClient(client1);
|
||||
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
|
||||
import Olm from "@matrix-org/olm";
|
||||
import anotherjson from "another-json";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import {
|
||||
type IContent,
|
||||
@@ -30,6 +31,8 @@ import { type IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { type ISyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { syncPromise } from "../../test-utils/test-utils";
|
||||
import { type KeyBackupInfo } from "../../../src/crypto-api";
|
||||
import { logger } from "../../../src/logger";
|
||||
import type FetchMock from "fetch-mock";
|
||||
|
||||
/**
|
||||
* @module
|
||||
@@ -302,6 +305,7 @@ export function encryptMegolmEventRawPlainText(opts: {
|
||||
},
|
||||
type: "m.room.encrypted",
|
||||
unsigned: {},
|
||||
state_key: opts.plaintext.state_key ? `${opts.plaintext.type}:${opts.plaintext.state_key}` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -414,3 +418,148 @@ export async function establishOlmSession(
|
||||
await syncPromise(testClient);
|
||||
return p2pSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expect that the client shares keys with the given recipient
|
||||
*
|
||||
* Waits for an HTTP request to send the encrypted m.room_key to-device message; decrypts it and uses it
|
||||
* to establish an Olm InboundGroupSession.
|
||||
*
|
||||
* @param recipientUserID - the user id of the expected recipient
|
||||
*
|
||||
* @param recipientOlmAccount - Olm.Account for the recipient
|
||||
*
|
||||
* @param recipientOlmSession - an Olm.Session for the recipient, which must already have exchanged pre-key
|
||||
* messages with the sender. Alternatively, null, in which case we will expect a pre-key message.
|
||||
*
|
||||
* @returns the established inbound group session
|
||||
*/
|
||||
export async function expectSendRoomKey(
|
||||
recipientUserID: string,
|
||||
recipientOlmAccount: Olm.Account,
|
||||
recipientOlmSession: Olm.Session | null = null,
|
||||
): Promise<Olm.InboundGroupSession> {
|
||||
const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"];
|
||||
|
||||
function onSendRoomKey(content: any): Olm.InboundGroupSession {
|
||||
const m = content.messages[recipientUserID].DEVICE_ID;
|
||||
const ct = m.ciphertext[testRecipientKey];
|
||||
|
||||
if (!recipientOlmSession) {
|
||||
expect(ct.type).toEqual(0); // pre-key message
|
||||
recipientOlmSession = new Olm.Session();
|
||||
recipientOlmSession.create_inbound(recipientOlmAccount, ct.body);
|
||||
} else {
|
||||
expect(ct.type).toEqual(1); // regular message
|
||||
}
|
||||
|
||||
const decrypted = JSON.parse(recipientOlmSession.decrypt(ct.type, ct.body));
|
||||
expect(decrypted.type).toEqual("m.room_key");
|
||||
const inboundGroupSession = new Olm.InboundGroupSession();
|
||||
inboundGroupSession.create(decrypted.content.session_key);
|
||||
return inboundGroupSession;
|
||||
}
|
||||
return await new Promise<Olm.InboundGroupSession>((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp("/sendToDevice/m.room.encrypted/"),
|
||||
(url: string, opts: RequestInit): FetchMock.MockResponse => {
|
||||
const content = JSON.parse(opts.body as string);
|
||||
resolve(onSendRoomKey(content));
|
||||
return {};
|
||||
},
|
||||
{
|
||||
// append to the list of intercepts on this path (since we have some tests that call
|
||||
// this function multiple times)
|
||||
overwriteRoutes: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the event received on rooms/{roomId}/send/m.room.encrypted endpoint.
|
||||
* See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid
|
||||
* @returns the content of the encrypted event
|
||||
*/
|
||||
export function expectEncryptedSendMessageEvent() {
|
||||
return new Promise<IContent>((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp("/send/m.room.encrypted/"),
|
||||
(url, request) => {
|
||||
const content = JSON.parse(request.body as string);
|
||||
resolve(content);
|
||||
return { event_id: "$event_id" };
|
||||
},
|
||||
// append to the list of intercepts on this path (since we have some tests that call
|
||||
// this function multiple times)
|
||||
{ overwriteRoutes: false },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the event received on rooms/{roomId}/state/m.room.encrypted/{stateKey} endpoint.
|
||||
* See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey
|
||||
* @returns the content of the encrypted event
|
||||
*/
|
||||
function expectEncryptedSendStateEvent() {
|
||||
return new Promise<IContent>((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp("/state/m.room.encrypted/"),
|
||||
(url, request) => {
|
||||
const content = JSON.parse(request.body as string);
|
||||
resolve(content);
|
||||
return { event_id: "$event_id" };
|
||||
},
|
||||
// append to the list of intercepts on this path (since we have some tests that call
|
||||
// this function multiple times)
|
||||
{ overwriteRoutes: false },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Expect that the client sends an encrypted message event
|
||||
*
|
||||
* Waits for an HTTP request to send an encrypted message in the test room.
|
||||
*
|
||||
* @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will
|
||||
* be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed.
|
||||
*
|
||||
* @returns The content of the successfully-decrypted event
|
||||
*/
|
||||
export async function expectSendMegolmMessageEvent(
|
||||
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
|
||||
): Promise<Partial<IEvent>> {
|
||||
const encryptedMessageContent = await expectEncryptedSendMessageEvent();
|
||||
|
||||
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
|
||||
const inboundGroupSession = await inboundGroupSessionPromise;
|
||||
|
||||
const r: any = inboundGroupSession.decrypt(encryptedMessageContent!.ciphertext);
|
||||
logger.log("Decrypted received megolm message", r);
|
||||
return JSON.parse(r.plaintext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expect that the client sends an encrypted state event
|
||||
*
|
||||
* Waits for an HTTP request to send an encrypted state event in the test room.
|
||||
*
|
||||
* @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will
|
||||
* be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed.
|
||||
*
|
||||
* @returns The content of the successfully-decrypted state event
|
||||
*/
|
||||
export async function expectSendMegolmStateEvent(
|
||||
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
|
||||
): Promise<Partial<IEvent>> {
|
||||
const encryptedStateContent = await expectEncryptedSendStateEvent();
|
||||
|
||||
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
|
||||
const inboundGroupSession = await inboundGroupSessionPromise;
|
||||
|
||||
const r: any = inboundGroupSession.decrypt(encryptedStateContent!.ciphertext);
|
||||
logger.log("Decrypted received megolm state event", r);
|
||||
return JSON.parse(r.plaintext);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
Copyright 2025 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 anotherjson from "another-json";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import Olm from "@matrix-org/olm";
|
||||
|
||||
import * as testUtils from "../../test-utils/test-utils";
|
||||
import { getSyncResponse, syncPromise } from "../../test-utils/test-utils";
|
||||
import { TEST_ROOM_ID as ROOM_ID } from "../../test-utils/test-data";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { createClient, PendingEventOrdering, type IStartClientOpts, type MatrixClient } from "../../../src/matrix";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { type ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import {
|
||||
createOlmAccount,
|
||||
createOlmSession,
|
||||
encryptGroupSessionKey,
|
||||
encryptMegolmEvent,
|
||||
getTestOlmAccountKeys,
|
||||
expectSendRoomKey,
|
||||
expectSendMegolmStateEvent,
|
||||
} from "./olm-utils";
|
||||
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
|
||||
|
||||
describe("Encrypted State Events", () => {
|
||||
let testOlmAccount = {} as unknown as Olm.Account;
|
||||
let testSenderKey = "";
|
||||
|
||||
/** the MatrixClient under test */
|
||||
let aliceClient: MatrixClient;
|
||||
|
||||
/** an object which intercepts `/keys/upload` requests from {@link #aliceClient} to catch the uploaded keys */
|
||||
let keyReceiver: E2EKeyReceiver;
|
||||
|
||||
/** an object which intercepts `/sync` requests from {@link #aliceClient} */
|
||||
let syncResponder: ISyncResponder;
|
||||
|
||||
async function startClientAndAwaitFirstSync(opts: IStartClientOpts = {}): Promise<void> {
|
||||
logger.log(aliceClient.getUserId() + ": starting");
|
||||
|
||||
mockInitialApiRequests(aliceClient.getHomeserverUrl());
|
||||
|
||||
// we let the client do a very basic initial sync, which it needs before
|
||||
// it will upload one-time keys.
|
||||
syncResponder.sendOrQueueSyncResponse({ next_batch: 1 });
|
||||
|
||||
aliceClient.startClient({
|
||||
// set this so that we can get hold of failed events
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
...opts,
|
||||
});
|
||||
|
||||
await syncPromise(aliceClient);
|
||||
logger.log(aliceClient.getUserId() + ": started");
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
|
||||
const homeserverUrl = "https://alice-server.com";
|
||||
aliceClient = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
userId: "@alice:localhost",
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: "xzcvb",
|
||||
logger: logger.getChild("aliceClient"),
|
||||
enableEncryptedStateEvents: true,
|
||||
});
|
||||
|
||||
keyReceiver = new E2EKeyReceiver(homeserverUrl);
|
||||
syncResponder = new SyncResponder(homeserverUrl);
|
||||
|
||||
await aliceClient.initRustCrypto();
|
||||
|
||||
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
|
||||
testOlmAccount = await createOlmAccount();
|
||||
const testE2eKeys = JSON.parse(testOlmAccount.identity_keys());
|
||||
testSenderKey = testE2eKeys.curve25519;
|
||||
}, 10000);
|
||||
|
||||
afterEach(async () => {
|
||||
await aliceClient.stopClient();
|
||||
await jest.runAllTimersAsync();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
function expectAliceKeyQuery(response: any) {
|
||||
fetchMock.postOnce(new RegExp("/keys/query"), (url: string, opts: RequestInit) => response, {
|
||||
overwriteRoutes: false,
|
||||
});
|
||||
}
|
||||
|
||||
function expectAliceKeyClaim(response: any) {
|
||||
fetchMock.postOnce(new RegExp("/keys/claim"), response);
|
||||
}
|
||||
|
||||
function getTestKeysClaimResponse(userId: string) {
|
||||
testOlmAccount.generate_one_time_keys(1);
|
||||
const testOneTimeKeys = JSON.parse(testOlmAccount.one_time_keys());
|
||||
testOlmAccount.mark_keys_as_published();
|
||||
|
||||
const keyId = Object.keys(testOneTimeKeys.curve25519)[0];
|
||||
const oneTimeKey: string = testOneTimeKeys.curve25519[keyId];
|
||||
const unsignedKeyResult = { key: oneTimeKey };
|
||||
const j = anotherjson.stringify(unsignedKeyResult);
|
||||
const sig = testOlmAccount.sign(j);
|
||||
const keyResult = {
|
||||
...unsignedKeyResult,
|
||||
signatures: { [userId]: { "ed25519:DEVICE_ID": sig } },
|
||||
};
|
||||
|
||||
return {
|
||||
one_time_keys: { [userId]: { DEVICE_ID: { ["signed_curve25519:" + keyId]: keyResult } } },
|
||||
failures: {},
|
||||
};
|
||||
}
|
||||
|
||||
it("Should receive an encrypted state event", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const p2pSession = await createOlmSession(testOlmAccount, keyReceiver);
|
||||
const groupSession = new Olm.OutboundGroupSession();
|
||||
groupSession.create();
|
||||
|
||||
// make the room_key event
|
||||
const roomKeyEncrypted = encryptGroupSessionKey({
|
||||
recipient: aliceClient.getUserId()!,
|
||||
recipientCurve25519Key: keyReceiver.getDeviceKey(),
|
||||
recipientEd25519Key: keyReceiver.getSigningKey(),
|
||||
olmAccount: testOlmAccount,
|
||||
p2pSession: p2pSession,
|
||||
groupSession: groupSession,
|
||||
room_id: ROOM_ID,
|
||||
});
|
||||
|
||||
// encrypt a state event with the group session
|
||||
const eventEncrypted = encryptMegolmEvent({
|
||||
senderKey: testSenderKey,
|
||||
groupSession: groupSession,
|
||||
room_id: ROOM_ID,
|
||||
plaintext: {
|
||||
type: "m.room.topic",
|
||||
state_key: "",
|
||||
content: {
|
||||
topic: "Secret!",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Alice gets both the events in a single sync
|
||||
const syncResponse = {
|
||||
next_batch: 1,
|
||||
to_device: {
|
||||
events: [roomKeyEncrypted],
|
||||
},
|
||||
rooms: {
|
||||
join: {
|
||||
[ROOM_ID]: { timeline: { events: [eventEncrypted] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||
const event = room.getLiveTimeline().getEvents()[0];
|
||||
expect(event.isEncrypted()).toBe(true);
|
||||
|
||||
// it probably won't be decrypted yet, because it takes a while to process the olm keys
|
||||
const decryptedEvent = await testUtils.awaitDecryption(event, { waitOnDecryptionFailure: true });
|
||||
expect(decryptedEvent.getContent().topic).toEqual("Secret!");
|
||||
});
|
||||
|
||||
it("Should send an encrypted state event", async () => {
|
||||
const homeserverUrl = aliceClient.getHomeserverUrl();
|
||||
const keyResponder = new E2EKeyResponder(homeserverUrl);
|
||||
keyResponder.addKeyReceiver("@alice:localhost", keyReceiver);
|
||||
|
||||
const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID");
|
||||
keyResponder.addDeviceKeys(testDeviceKeys);
|
||||
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
// Alice shares a room with Bob
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"], ROOM_ID, true));
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// ... and claim one of Bob's OTKs ...
|
||||
expectAliceKeyClaim(getTestKeysClaimResponse("@bob:xyz"));
|
||||
|
||||
// ... and send an m.room.topic message
|
||||
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount);
|
||||
|
||||
// Finally, send the message, and expect to get an `m.room.encrypted` event that we can decrypt.
|
||||
await Promise.all([
|
||||
aliceClient.setRoomTopic(ROOM_ID, "Secret!"),
|
||||
expectSendMegolmStateEvent(inboundGroupSessionPromise),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -46,8 +46,8 @@ import {
|
||||
type Verifier,
|
||||
VerifierEvent,
|
||||
} from "../../../src/crypto-api/verification";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { awaitDecryption, emitPromise, getSyncResponse, syncPromise } from "../../test-utils/test-utils";
|
||||
import { escapeRegExp, sleep } from "../../../src/utils";
|
||||
import { awaitDecryption, emitPromise, getSyncResponse, syncPromise, waitFor } from "../../test-utils/test-utils";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import {
|
||||
BACKUP_DECRYPTION_KEY_BASE64,
|
||||
@@ -79,11 +79,6 @@ import {
|
||||
import { type KeyBackupInfo, CryptoEvent } from "../../../src/crypto-api";
|
||||
import { encodeBase64 } from "../../../src/base64";
|
||||
|
||||
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
|
||||
// to ensure that we don't end up with dangling timeouts.
|
||||
// But the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
|
||||
beforeAll(async () => {
|
||||
// we use the libolm primitives in the test, so init the Olm library
|
||||
await Olm.init();
|
||||
@@ -96,6 +91,13 @@ beforeAll(async () => {
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
}, 10000);
|
||||
|
||||
beforeEach(() => {
|
||||
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
|
||||
// to ensure that we don't end up with dangling timeouts.
|
||||
// But the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
});
|
||||
|
||||
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
|
||||
@@ -735,6 +737,35 @@ describe("verification", () => {
|
||||
expect(request.cancellingUserId).toEqual("@alice:localhost");
|
||||
});
|
||||
|
||||
it("does not include cancelled requests in the list of requests", async () => {
|
||||
// Given Alice started a verification request
|
||||
const [, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
|
||||
]);
|
||||
const transactionId = request.transactionId!;
|
||||
|
||||
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"]));
|
||||
await waitForVerificationRequestChanged(request);
|
||||
|
||||
// Sanity: the request is listed
|
||||
const requestsBeforeCancel = aliceClient
|
||||
.getCrypto()!
|
||||
.getVerificationRequestsToDeviceInProgress(TEST_USER_ID);
|
||||
|
||||
expect(requestsBeforeCancel).toHaveLength(1);
|
||||
|
||||
// When Alice cancels it
|
||||
await Promise.all([expectSendToDeviceMessage("m.key.verification.cancel"), request.cancel()]);
|
||||
|
||||
// Then it is no longer listed as in progress
|
||||
const requestsAfterCancel = aliceClient
|
||||
.getCrypto()!
|
||||
.getVerificationRequestsToDeviceInProgress(TEST_USER_ID);
|
||||
|
||||
expect(requestsAfterCancel).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("can cancel during the SAS phase", async () => {
|
||||
// have alice initiate a verification. She should send a m.key.verification.request
|
||||
const [, request] = await Promise.all([
|
||||
@@ -1051,6 +1082,13 @@ describe("verification", () => {
|
||||
});
|
||||
|
||||
it("ignores old verification requests", async () => {
|
||||
const debug = jest.fn();
|
||||
const info = jest.fn();
|
||||
const warn = jest.fn();
|
||||
|
||||
// @ts-ignore overriding RustCrypto's logger
|
||||
aliceClient.getCrypto()!.logger = { debug, info, warn };
|
||||
|
||||
const eventHandler = jest.fn();
|
||||
aliceClient.on(CryptoEvent.VerificationRequestReceived, eventHandler);
|
||||
|
||||
@@ -1065,6 +1103,16 @@ describe("verification", () => {
|
||||
const matrixEvent = room.getLiveTimeline().getEvents()[0];
|
||||
expect(matrixEvent.getId()).toEqual(verificationRequestEvent.event_id);
|
||||
|
||||
// Wait until the request has been processed. We use a real sleep()
|
||||
// here to make sure any background async tasks are completed.
|
||||
jest.useRealTimers();
|
||||
await waitFor(async () => {
|
||||
expect(info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^Ignoring just-received verification request/),
|
||||
);
|
||||
sleep(100);
|
||||
});
|
||||
|
||||
// check that an event has not been raised, and that the request is not found
|
||||
expect(eventHandler).not.toHaveBeenCalled();
|
||||
expect(
|
||||
@@ -1072,6 +1120,29 @@ describe("verification", () => {
|
||||
).not.toBeDefined();
|
||||
});
|
||||
|
||||
it("ignores cancelled verification requests", async () => {
|
||||
// Given a verification request exists
|
||||
const event = createVerificationRequestEvent();
|
||||
returnRoomMessageFromSync(TEST_ROOM_ID, event);
|
||||
|
||||
// Wait for the request to be received
|
||||
await emitPromise(aliceClient, CryptoEvent.VerificationRequestReceived);
|
||||
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
|
||||
// When I cancel it
|
||||
fetchMock.put("express:/_matrix/client/v3/rooms/:roomId/send/m.key.verification.cancel/:id", {
|
||||
event_id: event.event_id,
|
||||
});
|
||||
await request!.cancel();
|
||||
expect(request!.phase).toEqual(VerificationPhase.Cancelled);
|
||||
|
||||
// Then it is no longer found
|
||||
expect(
|
||||
aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"),
|
||||
).not.toBeDefined();
|
||||
});
|
||||
|
||||
it("Plaintext verification request from Bob to Alice", async () => {
|
||||
// Add verification request from Bob to Alice in the DM between them
|
||||
returnRoomMessageFromSync(TEST_ROOM_ID, createVerificationRequestEvent());
|
||||
|
||||
@@ -63,7 +63,11 @@ export function syncPromise(client: MatrixClient, count = 1): Promise<void> {
|
||||
*
|
||||
* @returns the sync response
|
||||
*/
|
||||
export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): ISyncResponse {
|
||||
export function getSyncResponse(
|
||||
roomMembers: string[],
|
||||
roomId = TEST_ROOM_ID,
|
||||
encryptStateEvents = false,
|
||||
): ISyncResponse {
|
||||
const roomResponse: IJoinedRoom = {
|
||||
summary: {
|
||||
"m.heroes": [],
|
||||
@@ -77,7 +81,8 @@ export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): I
|
||||
type: "m.room.encryption",
|
||||
state_key: "",
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"io.element.msc3414.encrypt_state_events": encryptStateEvents,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -1053,17 +1053,28 @@ describe("MatrixClient", function () {
|
||||
);
|
||||
});
|
||||
|
||||
it("can look up delayed events", async () => {
|
||||
httpLookups = [
|
||||
{
|
||||
method: "GET",
|
||||
prefix: unstableMSC4140Prefix,
|
||||
path: "/delayed_events",
|
||||
data: [],
|
||||
},
|
||||
];
|
||||
describe("lookups", () => {
|
||||
const statuses = [undefined, "scheduled" as const, "finalised" as const];
|
||||
const delayIds = [undefined, "dxyz", ["d123"], ["d456", "d789"]];
|
||||
const inputs = statuses.flatMap((status) =>
|
||||
delayIds.map((delayId) => [status, delayId] as [(typeof statuses)[0], (typeof delayIds)[0]]),
|
||||
);
|
||||
it.each(inputs)("can look up delayed events (status = %s, delayId = %s)", async (status, delayId) => {
|
||||
httpLookups = [
|
||||
{
|
||||
method: "GET",
|
||||
prefix: unstableMSC4140Prefix,
|
||||
path: "/delayed_events",
|
||||
expectQueryParams: {
|
||||
status,
|
||||
delay_id: delayId,
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
];
|
||||
|
||||
await client._unstable_getDelayedEvents();
|
||||
await client._unstable_getDelayedEvents(status, delayId);
|
||||
});
|
||||
});
|
||||
|
||||
it("can update delayed events", async () => {
|
||||
@@ -2366,6 +2377,61 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("disableVoip option", () => {
|
||||
const baseUrl = "https://alice-server.com";
|
||||
const userId = "@alice:bar";
|
||||
const accessToken = "sometoken";
|
||||
|
||||
beforeEach(() => {
|
||||
mocked(supportsMatrixCall).mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mocked(supportsMatrixCall).mockReset();
|
||||
});
|
||||
|
||||
it("should not call /voip/turnServer when disableVoip = true", () => {
|
||||
fetchMock.getOnce(`${baseUrl}/_matrix/client/unstable/voip/turnServer`, 200);
|
||||
|
||||
const client = createClient({
|
||||
baseUrl,
|
||||
accessToken,
|
||||
userId,
|
||||
disableVoip: true,
|
||||
});
|
||||
|
||||
// Only check createCall / supportsVoip, avoid startClient
|
||||
expect(client.createCall("!roomId:example.com")).toBeNull();
|
||||
expect(client.supportsVoip?.()).toBe(false);
|
||||
});
|
||||
|
||||
it("should call /voip/turnServer when disableVoip is not set", () => {
|
||||
fetchMock.getOnce(`${baseUrl}/_matrix/client/unstable/voip/turnServer`, {
|
||||
uris: ["turn:turn.example.org"],
|
||||
});
|
||||
|
||||
createClient({
|
||||
baseUrl,
|
||||
accessToken,
|
||||
userId,
|
||||
});
|
||||
|
||||
// The call will trigger the request if VoIP is supported
|
||||
expect(fetchMock.called(`${baseUrl}/_matrix/client/unstable/voip/turnServer`)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return null from createCall when disableVoip = true", () => {
|
||||
const client = createClient({
|
||||
baseUrl,
|
||||
accessToken,
|
||||
userId,
|
||||
disableVoip: true,
|
||||
});
|
||||
|
||||
expect(client.createCall("!roomId:example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("support for ignoring invites", () => {
|
||||
beforeEach(() => {
|
||||
// Mockup `getAccountData`/`setAccountData`.
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CallMembership,
|
||||
type SessionMembershipData,
|
||||
DEFAULT_EXPIRE_DURATION,
|
||||
type RtcMembershipData,
|
||||
} from "../../../src/matrixrtc/CallMembership";
|
||||
import { membershipTemplate } from "./mocks";
|
||||
|
||||
@@ -26,6 +27,7 @@ function makeMockEvent(originTs = 0): MatrixEvent {
|
||||
return {
|
||||
getTs: jest.fn().mockReturnValue(originTs),
|
||||
getSender: jest.fn().mockReturnValue("@alice:example.org"),
|
||||
getId: jest.fn().mockReturnValue("$eventid"),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
@@ -40,12 +42,13 @@ describe("CallMembership", () => {
|
||||
});
|
||||
|
||||
const membershipTemplate: SessionMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
focus_active: { type: "livekit" },
|
||||
foci_preferred: [{ type: "livekit" }],
|
||||
"call_id": "",
|
||||
"scope": "m.room",
|
||||
"application": "m.call",
|
||||
"device_id": "AAAAAAA",
|
||||
"focus_active": { type: "livekit", focus_selection: "oldest_membership" },
|
||||
"foci_preferred": [{ type: "livekit" }],
|
||||
"m.call.intent": "voice",
|
||||
};
|
||||
|
||||
it("rejects membership with no device_id", () => {
|
||||
@@ -94,11 +97,271 @@ describe("CallMembership", () => {
|
||||
it("returns preferred foci", () => {
|
||||
const fakeEvent = makeMockEvent();
|
||||
const mockFocus = { type: "this_is_a_mock_focus" };
|
||||
const membership = new CallMembership(
|
||||
fakeEvent,
|
||||
Object.assign({}, membershipTemplate, { foci_preferred: [mockFocus] }),
|
||||
);
|
||||
expect(membership.getPreferredFoci()).toEqual([mockFocus]);
|
||||
const membership = new CallMembership(fakeEvent, { ...membershipTemplate, foci_preferred: [mockFocus] });
|
||||
expect(membership.transports).toEqual([mockFocus]);
|
||||
});
|
||||
|
||||
describe("getTransport", () => {
|
||||
const mockFocus = { type: "this_is_a_mock_focus" };
|
||||
const oldestMembership = new CallMembership(makeMockEvent(), membershipTemplate);
|
||||
it("gets the correct active transport with oldest_membership", () => {
|
||||
const membership = new CallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
foci_preferred: [mockFocus],
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
});
|
||||
|
||||
// if we are the oldest member we use our focus.
|
||||
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
|
||||
|
||||
// If there is an older member we use its focus.
|
||||
expect(membership.getTransport(oldestMembership)).toBe(membershipTemplate.foci_preferred[0]);
|
||||
});
|
||||
|
||||
it("gets the correct active transport with multi_sfu", () => {
|
||||
const membership = new CallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
foci_preferred: [mockFocus],
|
||||
focus_active: { type: "livekit", focus_selection: "multi_sfu" },
|
||||
});
|
||||
|
||||
// if we are the oldest member we use our focus.
|
||||
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
|
||||
|
||||
// If there is an older member we still use our own focus in multi sfu.
|
||||
expect(membership.getTransport(oldestMembership)).toBe(mockFocus);
|
||||
});
|
||||
it("does not provide focus if the selection method is unknown", () => {
|
||||
const membership = new CallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
foci_preferred: [mockFocus],
|
||||
focus_active: { type: "livekit", focus_selection: "unknown" },
|
||||
});
|
||||
|
||||
// if we are the oldest member we use our focus.
|
||||
expect(membership.getTransport(membership)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe("correct values from computed fields", () => {
|
||||
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
|
||||
it("returns correct sender", () => {
|
||||
expect(membership.sender).toBe("@alice:example.org");
|
||||
});
|
||||
it("returns correct eventId", () => {
|
||||
expect(membership.eventId).toBe("$eventid");
|
||||
});
|
||||
it("returns correct slot_id", () => {
|
||||
expect(membership.slotId).toBe("m.call#");
|
||||
expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" });
|
||||
});
|
||||
it("returns correct deviceId", () => {
|
||||
expect(membership.deviceId).toBe("AAAAAAA");
|
||||
});
|
||||
it("returns correct call intent", () => {
|
||||
expect(membership.callIntent).toBe("voice");
|
||||
});
|
||||
it("returns correct application", () => {
|
||||
expect(membership.application).toStrictEqual("m.call");
|
||||
});
|
||||
it("returns correct applicationData", () => {
|
||||
expect(membership.applicationData).toStrictEqual({ "type": "m.call", "m.call.intent": "voice" });
|
||||
});
|
||||
it("returns correct scope", () => {
|
||||
expect(membership.scope).toBe("m.room");
|
||||
});
|
||||
it("returns correct membershipID", () => {
|
||||
expect(membership.membershipID).toBe("0");
|
||||
});
|
||||
it("returns correct unused fields", () => {
|
||||
expect(membership.getAbsoluteExpiry()).toBe(DEFAULT_EXPIRE_DURATION);
|
||||
expect(membership.getMsUntilExpiry()).toBe(DEFAULT_EXPIRE_DURATION - Date.now());
|
||||
expect(membership.isExpired()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("RtcMembershipData", () => {
|
||||
const membershipTemplate: RtcMembershipData = {
|
||||
slot_id: "m.call#",
|
||||
application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" },
|
||||
member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" },
|
||||
rtc_transports: [{ type: "livekit" }],
|
||||
versions: [],
|
||||
msc4354_sticky_key: "abc123",
|
||||
};
|
||||
|
||||
it("rejects membership with no slot_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined });
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects membership with invalid slot_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
|
||||
}).toThrow();
|
||||
});
|
||||
it("accepts membership with valid slot_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with no application", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with incorrect application", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
application: { wrong_type_key: "unknown" },
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with no member", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with incorrect member", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } });
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
member: { id: "test", device_id: "test", user_id_wrong: "test" },
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" },
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
member: { id: "test", device_id: "test", user_id: "@@test" },
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
member: { id: "test", device_id: "test", user_id: "@test-wrong-user:user.id" },
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects membership with incorrect sticky_key", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), membershipTemplate);
|
||||
}).not.toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
sticky_key: 1,
|
||||
msc4354_sticky_key: undefined,
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
sticky_key: "1",
|
||||
msc4354_sticky_key: undefined,
|
||||
});
|
||||
}).not.toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined });
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
msc4354_sticky_key: 1,
|
||||
sticky_key: "valid",
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
msc4354_sticky_key: "valid",
|
||||
sticky_key: "valid",
|
||||
});
|
||||
}).not.toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
msc4354_sticky_key: "valid_but_different",
|
||||
sticky_key: "valid",
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("considers memberships unexpired if local age low enough", () => {
|
||||
// TODO link prev event
|
||||
});
|
||||
|
||||
it("considers memberships expired if local age large enough", () => {
|
||||
// TODO link prev event
|
||||
});
|
||||
|
||||
describe("getTransport", () => {
|
||||
it("gets the correct active transport with oldest_membership", () => {
|
||||
const oldestMembership = new CallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
rtc_transports: [{ type: "oldest_transport" }],
|
||||
});
|
||||
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
|
||||
|
||||
// if we are the oldest member we use our focus.
|
||||
expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" });
|
||||
|
||||
// If there is an older member we use our own focus focus. (RtcMembershipData always uses multi sfu)
|
||||
expect(membership.getTransport(oldestMembership)).toStrictEqual({ type: "livekit" });
|
||||
});
|
||||
});
|
||||
describe("correct values from computed fields", () => {
|
||||
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
|
||||
it("returns correct sender", () => {
|
||||
expect(membership.sender).toBe("@alice:example.org");
|
||||
});
|
||||
it("returns correct eventId", () => {
|
||||
expect(membership.eventId).toBe("$eventid");
|
||||
});
|
||||
it("returns correct slot_id", () => {
|
||||
expect(membership.slotId).toBe("m.call#");
|
||||
expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" });
|
||||
});
|
||||
it("returns correct deviceId", () => {
|
||||
expect(membership.deviceId).toBe("AAAAAAA");
|
||||
});
|
||||
it("returns correct call intent", () => {
|
||||
expect(membership.callIntent).toBe("voice");
|
||||
});
|
||||
it("returns correct application", () => {
|
||||
expect(membership.application).toStrictEqual("m.call");
|
||||
});
|
||||
it("returns correct applicationData", () => {
|
||||
expect(membership.applicationData).toStrictEqual({
|
||||
"type": "m.call",
|
||||
"m.call.id": "",
|
||||
"m.call.intent": "voice",
|
||||
});
|
||||
});
|
||||
it("returns correct scope", () => {
|
||||
expect(membership.scope).toBe(undefined);
|
||||
});
|
||||
it("returns correct membershipID", () => {
|
||||
expect(membership.membershipID).toBe("xyzHASHxyz");
|
||||
});
|
||||
it("returns correct unused fields", () => {
|
||||
expect(membership.getAbsoluteExpiry()).toBe(undefined);
|
||||
expect(membership.getMsUntilExpiry()).toBe(undefined);
|
||||
expect(membership.isExpired()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+17
-13
@@ -14,47 +14,51 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus";
|
||||
import {
|
||||
isLivekitTransport,
|
||||
isLivekitFocusSelection,
|
||||
isLivekitTransportConfig,
|
||||
} from "../../../src/matrixrtc/LivekitTransport";
|
||||
|
||||
describe("LivekitFocus", () => {
|
||||
it("isLivekitFocus", () => {
|
||||
expect(
|
||||
isLivekitFocus({
|
||||
isLivekitTransport({
|
||||
type: "livekit",
|
||||
livekit_service_url: "http://test.com",
|
||||
livekit_alias: "test",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(isLivekitFocus({ type: "livekit" })).toBeFalsy();
|
||||
expect(isLivekitTransport({ type: "livekit" })).toBeFalsy();
|
||||
expect(
|
||||
isLivekitFocus({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
|
||||
isLivekitTransport({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
isLivekitFocus({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
|
||||
isLivekitTransport({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
isLivekitFocus({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
|
||||
isLivekitTransport({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
|
||||
).toBeFalsy();
|
||||
});
|
||||
it("isLivekitFocusActive", () => {
|
||||
expect(
|
||||
isLivekitFocusActive({
|
||||
isLivekitFocusSelection({
|
||||
type: "livekit",
|
||||
focus_selection: "oldest_membership",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(isLivekitFocusActive({ type: "livekit" })).toBeFalsy();
|
||||
expect(isLivekitFocusActive({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
|
||||
expect(isLivekitFocusSelection({ type: "livekit" })).toBeFalsy();
|
||||
expect(isLivekitFocusSelection({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
|
||||
});
|
||||
it("isLivekitFocusConfig", () => {
|
||||
expect(
|
||||
isLivekitFocusConfig({
|
||||
isLivekitTransportConfig({
|
||||
type: "livekit",
|
||||
livekit_service_url: "http://test.com",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(isLivekitFocusConfig({ type: "livekit" })).toBeFalsy();
|
||||
expect(isLivekitFocusConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
|
||||
expect(isLivekitFocusConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
|
||||
expect(isLivekitTransportConfig({ type: "livekit" })).toBeFalsy();
|
||||
expect(isLivekitTransportConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
|
||||
expect(isLivekitTransportConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -14,13 +14,30 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEvent, type Room } from "../../../src";
|
||||
import {
|
||||
encodeBase64,
|
||||
type EventTimeline,
|
||||
EventType,
|
||||
MatrixClient,
|
||||
type MatrixError,
|
||||
type MatrixEvent,
|
||||
type Room,
|
||||
} from "../../../src";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
||||
import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
|
||||
import { secureRandomString } from "../../../src/randomstring";
|
||||
import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey, type MembershipData, mockRoomState } from "./mocks";
|
||||
import {
|
||||
makeMockEvent,
|
||||
makeMockRoom,
|
||||
membershipTemplate,
|
||||
makeKey,
|
||||
type MembershipData,
|
||||
mockRoomState,
|
||||
mockRTCEvent,
|
||||
} from "./mocks";
|
||||
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
|
||||
import { RoomStickyEventsEvent, type StickyMatrixEvent } from "../../../src/models/room-sticky-events.ts";
|
||||
import { StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
|
||||
|
||||
const mockFocus = { type: "mock" };
|
||||
|
||||
@@ -47,162 +64,362 @@ describe("MatrixRTCSession", () => {
|
||||
sess = undefined;
|
||||
});
|
||||
|
||||
describe("roomSessionForRoom", () => {
|
||||
it("creates a room-scoped session from room state", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
describe.each([
|
||||
{
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
testCreateSticky: false,
|
||||
createWithDefaults: true, // Create MatrixRTCSession with defaults
|
||||
},
|
||||
{
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
testCreateSticky: false,
|
||||
},
|
||||
{
|
||||
listenForStickyEvents: false,
|
||||
listenForMemberStateEvents: true,
|
||||
testCreateSticky: false,
|
||||
},
|
||||
{
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
testCreateSticky: true,
|
||||
},
|
||||
{
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: false,
|
||||
testCreateSticky: true,
|
||||
},
|
||||
])(
|
||||
"roomSessionForRoom listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky",
|
||||
(testConfig) => {
|
||||
it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
|
||||
mockRoom.getLiveTimeline.mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue(undefined),
|
||||
} as unknown as EventTimeline);
|
||||
if (testConfig.listenForMemberStateEvents) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(() => {
|
||||
MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig);
|
||||
}).toThrow();
|
||||
} else {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(() => {
|
||||
MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig);
|
||||
}).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
it("creates a room-scoped session from room state", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
|
||||
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].slotDescription.id).toEqual("");
|
||||
expect(sess?.memberships[0].scope).toEqual("m.room");
|
||||
expect(sess?.memberships[0].application).toEqual("m.call");
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
expect(sess?.memberships[0].isExpired()).toEqual(false);
|
||||
expect(sess?.slotDescription.id).toEqual("");
|
||||
});
|
||||
|
||||
it("ignores memberships where application is not m.call", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate, {
|
||||
application: "not-m.call",
|
||||
});
|
||||
const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky);
|
||||
const sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships where callId is not empty", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate, {
|
||||
call_id: "not-empty",
|
||||
scope: "m.room",
|
||||
});
|
||||
const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky);
|
||||
const sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores expired memberships events", () => {
|
||||
jest.useFakeTimers();
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.expires = 1000;
|
||||
expiredMembership.device_id = "EXPIRED";
|
||||
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], testConfig.testCreateSticky);
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("ignores memberships events of members not in the room", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
|
||||
mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("ignores memberships events with no sender", () => {
|
||||
// Force the sender to be undefined.
|
||||
const mockRoom = makeMockRoom([{ ...membershipTemplate, user_id: "" }], testConfig.testCreateSticky);
|
||||
mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("honours created_ts", () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(500);
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.created_ts = 500;
|
||||
expiredMembership.expires = 1000;
|
||||
const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns empty session if no membership events are present", () => {
|
||||
const mockRoom = makeMockRoom([], testConfig.testCreateSticky);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("safely ignores events with no memberships section", () => {
|
||||
const event = {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
};
|
||||
const mockRoom = makeMockRoom([]);
|
||||
mockRoom.getLiveTimeline.mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
getStateEvents: (_type: string, _stateKey: string) => [event],
|
||||
events: new Map([
|
||||
[
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
} as unknown as EventTimeline);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("safely ignores events with junk memberships section", () => {
|
||||
const event = {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
};
|
||||
const mockRoom = makeMockRoom([]);
|
||||
mockRoom.getLiveTimeline.mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
getStateEvents: (_type: string, _stateKey: string) => [event],
|
||||
events: new Map([
|
||||
[
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
} as unknown as EventTimeline);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships with no device_id", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.device_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships with no call_id", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.call_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("roomSessionForRoom combined state", () => {
|
||||
it("perfers sticky events when both membership and sticky events appear for the same user", () => {
|
||||
// Create a room with identical member state and sticky state for the same user.
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
|
||||
const ev = mockRTCEvent(
|
||||
{
|
||||
...membershipTemplate,
|
||||
msc4354_sticky_key: `_${membershipTemplate.user_id}_${membershipTemplate.device_id}`,
|
||||
},
|
||||
mockRoom.roomId,
|
||||
);
|
||||
return [ev as StickyMatrixEvent];
|
||||
});
|
||||
|
||||
// Expect for there to be one membership as the state has been merged down.
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
});
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].sessionDescription.id).toEqual("");
|
||||
expect(sess?.memberships[0].slotDescription.id).toEqual("");
|
||||
expect(sess?.memberships[0].scope).toEqual("m.room");
|
||||
expect(sess?.memberships[0].application).toEqual("m.call");
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
expect(sess?.memberships[0].isExpired()).toEqual(false);
|
||||
expect(sess?.sessionDescription.id).toEqual("");
|
||||
expect(sess?.slotDescription.id).toEqual("");
|
||||
});
|
||||
|
||||
it("ignores memberships where application is not m.call", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate, {
|
||||
application: "not-m.call",
|
||||
});
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships where callId is not empty", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate, {
|
||||
call_id: "not-empty",
|
||||
scope: "m.room",
|
||||
});
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores expired memberships events", () => {
|
||||
jest.useFakeTimers();
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.expires = 1000;
|
||||
expiredMembership.device_id = "EXPIRED";
|
||||
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]);
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("ignores memberships events of members not in the room", () => {
|
||||
it("combines sticky and membership events when both exist", () => {
|
||||
// Create a room with identical member state and sticky state for the same user.
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
mockRoom.hasMembershipState = (state) => state === KnownMembership.Join;
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships.length).toEqual(0);
|
||||
});
|
||||
const stickyUserId = "@stickyev:user.example";
|
||||
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
|
||||
const ev = mockRTCEvent(
|
||||
{
|
||||
...membershipTemplate,
|
||||
user_id: stickyUserId,
|
||||
msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`,
|
||||
},
|
||||
mockRoom.roomId,
|
||||
15000,
|
||||
Date.now() - 1000, // Sticky event comes first.
|
||||
);
|
||||
return [ev as StickyMatrixEvent];
|
||||
});
|
||||
|
||||
it("honours created_ts", () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(500);
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.created_ts = 500;
|
||||
expiredMembership.expires = 1000;
|
||||
const mockRoom = makeMockRoom([expiredMembership]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
});
|
||||
|
||||
it("returns empty session if no membership events are present", () => {
|
||||
const mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
const memberships = sess.memberships;
|
||||
expect(memberships.length).toEqual(2);
|
||||
expect(memberships[0].sender).toEqual(stickyUserId);
|
||||
expect(memberships[0].slotDescription.id).toEqual("");
|
||||
expect(memberships[0].scope).toEqual("m.room");
|
||||
expect(memberships[0].application).toEqual("m.call");
|
||||
expect(memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
expect(memberships[0].isExpired()).toEqual(false);
|
||||
|
||||
it("safely ignores events with no memberships section", () => {
|
||||
const roomId = secureRandomString(8);
|
||||
const event = {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
};
|
||||
const mockRoom = {
|
||||
...makeMockRoom([]),
|
||||
roomId,
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
getStateEvents: (_type: string, _stateKey: string) => [event],
|
||||
events: new Map([
|
||||
[
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
// Then state
|
||||
expect(memberships[1].sender).toEqual(membershipTemplate.user_id);
|
||||
|
||||
it("safely ignores events with junk memberships section", () => {
|
||||
const roomId = secureRandomString(8);
|
||||
const event = {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
};
|
||||
const mockRoom = {
|
||||
...makeMockRoom([]),
|
||||
roomId,
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
getStateEvents: (_type: string, _stateKey: string) => [event],
|
||||
events: new Map([
|
||||
[
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
expect(sess?.slotDescription.id).toEqual("");
|
||||
});
|
||||
it("handles an incoming sticky event to an existing session", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
const stickyUserId = "@stickyev:user.example";
|
||||
|
||||
it("ignores memberships with no device_id", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.device_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships with no call_id", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.call_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
});
|
||||
expect(sess.memberships.length).toEqual(1);
|
||||
const stickyEv = mockRTCEvent(
|
||||
{
|
||||
...membershipTemplate,
|
||||
user_id: stickyUserId,
|
||||
msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`,
|
||||
},
|
||||
mockRoom.roomId,
|
||||
15000,
|
||||
Date.now() - 1000, // Sticky event comes first.
|
||||
) as StickyMatrixEvent;
|
||||
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
|
||||
return [stickyEv];
|
||||
});
|
||||
mockRoom.emit(RoomStickyEventsEvent.Update, [stickyEv], [], []);
|
||||
expect(sess.memberships.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,6 +439,27 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConsensusCallIntent", () => {
|
||||
it.each([
|
||||
[undefined, undefined, undefined],
|
||||
["audio", undefined, "audio"],
|
||||
[undefined, "audio", "audio"],
|
||||
["audio", "audio", "audio"],
|
||||
["audio", "video", undefined],
|
||||
])("gets correct consensus for %s + %s = %s", (intentA, intentB, result) => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(4000);
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, { "m.call.intent": intentA }),
|
||||
Object.assign({}, membershipTemplate, { "m.call.intent": intentB }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess.getConsensusCallIntent()).toEqual(result);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getsActiveFocus", () => {
|
||||
const firstPreferredFocus = {
|
||||
type: "livekit",
|
||||
@@ -247,7 +485,6 @@ describe("MatrixRTCSession", () => {
|
||||
type: "livekit",
|
||||
focus_selection: "oldest_membership",
|
||||
});
|
||||
expect(sess.getActiveFocus()).toBe(firstPreferredFocus);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
it("does not provide focus if the selection method is unknown", () => {
|
||||
@@ -267,7 +504,7 @@ describe("MatrixRTCSession", () => {
|
||||
type: "livekit",
|
||||
focus_selection: "unknown",
|
||||
});
|
||||
expect(sess.getActiveFocus()).toBe(undefined);
|
||||
expect(sess.memberships.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -309,6 +546,12 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess!.isJoined()).toEqual(true);
|
||||
});
|
||||
|
||||
it("uses the sticky events membership manager implementation", () => {
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { unstableSendStickyEvents: true });
|
||||
expect(sess!.isJoined()).toEqual(true);
|
||||
expect(sess!["membershipManager"] instanceof StickyEventMembershipManager).toEqual(true);
|
||||
});
|
||||
|
||||
it("sends a notification when starting a call and emit DidSendCallNotification", async () => {
|
||||
// Simulate a join, including the update to the room state
|
||||
// Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them
|
||||
@@ -370,6 +613,79 @@ describe("MatrixRTCSession", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a notification with a intent when starting a call and emits DidSendCallNotification", async () => {
|
||||
// Simulate a join, including the update to the room state
|
||||
// Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them
|
||||
sendEventMock
|
||||
.mockResolvedValueOnce({ event_id: "legacy-evt" })
|
||||
.mockResolvedValueOnce({ event_id: "new-evt" });
|
||||
const didSendEventFn = jest.fn();
|
||||
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, didSendEventFn);
|
||||
// Create an additional listener to create a promise that resolves after the emission.
|
||||
const didSendNotification = new Promise((resolve) => {
|
||||
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, resolve);
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring", callIntent: "audio" });
|
||||
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
|
||||
|
||||
mockRoomState(mockRoom, [
|
||||
{
|
||||
...membershipTemplate,
|
||||
"user_id": client.getUserId()!,
|
||||
// This is what triggers the intent type on the notification event.
|
||||
"m.call.intent": "audio",
|
||||
},
|
||||
]);
|
||||
|
||||
sess!.onRTCSessionMemberUpdate();
|
||||
const ownMembershipId = sess?.memberships[0].eventId;
|
||||
expect(sess!.getConsensusCallIntent()).toEqual("audio");
|
||||
|
||||
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, {
|
||||
"m.mentions": { user_ids: [], room: true },
|
||||
"notification_type": "ring",
|
||||
"m.call.intent": "audio",
|
||||
"m.relates_to": {
|
||||
event_id: ownMembershipId,
|
||||
rel_type: "m.reference",
|
||||
},
|
||||
"lifetime": 30000,
|
||||
"sender_ts": expect.any(Number),
|
||||
});
|
||||
|
||||
// Check if deprecated notify event is also sent.
|
||||
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.CallNotify, {
|
||||
"application": "m.call",
|
||||
"m.mentions": { user_ids: [], room: true },
|
||||
"notify_type": "ring",
|
||||
"call_id": "",
|
||||
});
|
||||
await didSendNotification;
|
||||
// And ensure we emitted the DidSendCallNotification event with both payloads
|
||||
expect(didSendEventFn).toHaveBeenCalledWith(
|
||||
{
|
||||
"event_id": "new-evt",
|
||||
"lifetime": 30000,
|
||||
"m.mentions": { room: true, user_ids: [] },
|
||||
"m.relates_to": {
|
||||
event_id: expect.any(String),
|
||||
rel_type: "m.reference",
|
||||
},
|
||||
"notification_type": "ring",
|
||||
"m.call.intent": "audio",
|
||||
"sender_ts": expect.any(Number),
|
||||
},
|
||||
{
|
||||
"application": "m.call",
|
||||
"call_id": "",
|
||||
"event_id": "legacy-evt",
|
||||
"m.mentions": { room: true, user_ids: [] },
|
||||
"notify_type": "ring",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("doesn't send a notification when joining an existing call", async () => {
|
||||
// Add another member to the call so that it is considered an existing call
|
||||
mockRoomState(mockRoom, [membershipTemplate]);
|
||||
|
||||
@@ -14,136 +14,145 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
|
||||
import { ClientEvent, EventTimeline, MatrixClient, type Room } from "../../../src";
|
||||
import { RoomStateEvent } from "../../../src/models/room-state";
|
||||
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks";
|
||||
import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks";
|
||||
import { logger } from "../../../src/logger";
|
||||
|
||||
describe("MatrixRTCSessionManager", () => {
|
||||
let client: MatrixClient;
|
||||
describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
"MatrixRTCSessionManager ($eventKind)",
|
||||
({ eventKind }) => {
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.matrixRTC.start();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
});
|
||||
|
||||
it("Fires event when session starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
function sendLeaveMembership(room: Room, membershipData: MembershipData[]): void {
|
||||
if (eventKind === "memberState") {
|
||||
mockRoomState(room, [{ user_id: membershipTemplate.user_id }]);
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
} else {
|
||||
membershipData.splice(0, 1, { user_id: membershipTemplate.user_id });
|
||||
client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("Doesn't fire event if unrelated sessions starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
beforeEach(() => {
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.matrixRTC.start();
|
||||
});
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
});
|
||||
|
||||
it("Fires event when session starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
}
|
||||
});
|
||||
|
||||
it("Doesn't fire event if unrelated sessions starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }], eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
}
|
||||
});
|
||||
|
||||
it("Fires event when session ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const membershipData: MembershipData[] = [membershipTemplate];
|
||||
const room1 = makeMockRoom(membershipData, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
}
|
||||
});
|
||||
|
||||
it("Fires event when session ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
sendLeaveMembership(room1, membershipData);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
|
||||
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
|
||||
it("Fires correctly with custom sessionDescription", () => {
|
||||
const onStarted = jest.fn();
|
||||
const onEnded = jest.fn();
|
||||
// create a session manager with a custom session description
|
||||
const sessionManager = new MatrixRTCSessionManager(logger, client, {
|
||||
id: "test",
|
||||
application: "m.notCall",
|
||||
});
|
||||
|
||||
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
// manually start the session manager (its not the default one started by the client)
|
||||
sessionManager.start();
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
try {
|
||||
// Create a session for applicaation m.other, we ignore this session ecause it lacks a call_id
|
||||
const room1MembershipData: MembershipData[] = [{ ...membershipTemplate, application: "m.other" }];
|
||||
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
it("Fires correctly with for with custom sessionDescription", () => {
|
||||
const onStarted = jest.fn();
|
||||
const onEnded = jest.fn();
|
||||
// create a session manager with a custom session description
|
||||
const sessionManager = new MatrixRTCSessionManager(logger, client, { id: "test", application: "m.notCall" });
|
||||
// Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id
|
||||
const room2MembershipData: MembershipData[] = [
|
||||
{ ...membershipTemplate, application: "m.notCall", call_id: "test" },
|
||||
];
|
||||
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
|
||||
client.emit(ClientEvent.Room, room2);
|
||||
expect(onStarted).toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
// manually start the session manager (its not the default one started by the client)
|
||||
sessionManager.start();
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
// Stop room1's RTC session. Tracked.
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room2);
|
||||
sendLeaveMembership(room2, room2MembershipData);
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
onEnded.mockClear();
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
|
||||
// Stop room1's RTC session. Not tracked.
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
sendLeaveMembership(room1, room1MembershipData);
|
||||
expect(onEnded).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
}
|
||||
});
|
||||
|
||||
it("Doesn't fire event if unrelated sessions ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }];
|
||||
const room1 = makeMockRoom(membership, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
const room2 = makeMockRoom([{ ...membershipTemplate, application: "m.notCall", call_id: "test" }]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
|
||||
|
||||
client.emit(ClientEvent.Room, room2);
|
||||
expect(onStarted).toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
mockRoomState(room2, [{ user_id: membershipTemplate.user_id }]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room2);
|
||||
|
||||
const roomState = room2.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
onEnded.mockClear();
|
||||
|
||||
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
const roomStateOther = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEventOther = roomStateOther.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEventOther, roomStateOther, null);
|
||||
expect(onEnded).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
}
|
||||
});
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
|
||||
it("Doesn't fire event if unrelated sessions ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other_app" }]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
sendLeaveMembership(room1, membership);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
|
||||
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
|
||||
|
||||
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
|
||||
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
});
|
||||
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -23,17 +23,17 @@ import {
|
||||
MatrixError,
|
||||
UnsupportedDelayedEventsEndpointError,
|
||||
type Room,
|
||||
MAX_STICKY_DURATION_MS,
|
||||
} from "../../../src";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
Status,
|
||||
type Focus,
|
||||
type LivekitFocusActive,
|
||||
type Transport,
|
||||
type SessionMembershipData,
|
||||
type LivekitFocusSelection,
|
||||
} from "../../../src/matrixrtc";
|
||||
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
|
||||
import { MembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
|
||||
|
||||
/**
|
||||
* Create a promise that will resolve once a mocked method is called.
|
||||
@@ -76,11 +76,11 @@ const callSession = { id: "", application: "m.call" };
|
||||
describe("MembershipManager", () => {
|
||||
let client: MockClient;
|
||||
let room: Room;
|
||||
const focusActive: LivekitFocusActive = {
|
||||
const focusActive: LivekitFocusSelection = {
|
||||
focus_selection: "oldest_membership",
|
||||
type: "livekit",
|
||||
};
|
||||
const focus: Focus = {
|
||||
const focus: Transport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://active.url",
|
||||
livekit_alias: "!active:active.url",
|
||||
@@ -94,7 +94,9 @@ describe("MembershipManager", () => {
|
||||
// Provide a default mock that is like the default "non error" server behaviour.
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
(client.sendStateEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
(client._unstable_sendStickyEvent as Mock<any>).mockResolvedValue({ event_id: "id" });
|
||||
(client._unstable_sendStickyDelayedEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
|
||||
(client.sendStateEvent as Mock<any>).mockResolvedValue({ event_id: "id" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -104,12 +106,12 @@ describe("MembershipManager", () => {
|
||||
|
||||
describe("isActivated()", () => {
|
||||
it("defaults to false", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
expect(manager.isActivated()).toEqual(false);
|
||||
});
|
||||
|
||||
it("returns true after join()", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([]);
|
||||
expect(manager.isActivated()).toEqual(true);
|
||||
});
|
||||
@@ -123,8 +125,8 @@ describe("MembershipManager", () => {
|
||||
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
|
||||
|
||||
// Test
|
||||
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
|
||||
memberManager.join([focus], focusActive);
|
||||
const memberManager = new MembershipManager(undefined, room, client, callSession);
|
||||
memberManager.join([focus], undefined);
|
||||
// expects
|
||||
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
@@ -153,7 +155,7 @@ describe("MembershipManager", () => {
|
||||
});
|
||||
|
||||
it("reschedules delayed leave event if sending state cancels it", async () => {
|
||||
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
|
||||
const memberManager = new MembershipManager(undefined, room, client, callSession);
|
||||
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
||||
const waitForUpdateDelaye = waitForMockCallOnce(
|
||||
client._unstable_updateDelayedEvent,
|
||||
@@ -228,10 +230,9 @@ describe("MembershipManager", () => {
|
||||
},
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
manager.join([focus]);
|
||||
|
||||
await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches
|
||||
await sendDelayedStateAttempt;
|
||||
@@ -286,8 +287,8 @@ describe("MembershipManager", () => {
|
||||
describe("delayed leave event", () => {
|
||||
it("does not try again to schedule a delayed leave event if not supported", () => {
|
||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus]);
|
||||
delayedHandle.reject?.(
|
||||
new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
@@ -298,21 +299,15 @@ describe("MembershipManager", () => {
|
||||
});
|
||||
it("does try to schedule a delayed leave event again if rate limited", async () => {
|
||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus]);
|
||||
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("uses delayedLeaveEventDelayMs from config", () => {
|
||||
const manager = new MembershipManager(
|
||||
{ delayedLeaveEventDelayMs: 123456 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
const manager = new MembershipManager({ delayedLeaveEventDelayMs: 123456 }, room, client, callSession);
|
||||
manager.join([focus]);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
{ delay: 123456 },
|
||||
@@ -329,11 +324,11 @@ describe("MembershipManager", () => {
|
||||
{ delayedLeaveEventRestartMs: RESTART_DELAY },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
|
||||
callSession,
|
||||
);
|
||||
// Join with the membership manager
|
||||
manager.join([focus], focusActive);
|
||||
manager.join([focus]);
|
||||
expect(manager.status).toBe(Status.Connecting);
|
||||
// Let the scheduler run one iteration so that we can send the join state event
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
@@ -367,11 +362,11 @@ describe("MembershipManager", () => {
|
||||
{ membershipEventExpiryMs: 1234567 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
|
||||
callSession,
|
||||
);
|
||||
|
||||
manager.join([focus], focusActive);
|
||||
manager.join([focus]);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
@@ -393,11 +388,11 @@ describe("MembershipManager", () => {
|
||||
});
|
||||
|
||||
it("does nothing if join called when already joined", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus]);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
manager.join([focus], focusActive);
|
||||
manager.join([focus]);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -405,16 +400,16 @@ describe("MembershipManager", () => {
|
||||
describe("leave()", () => {
|
||||
// TODO add rate limit cases.
|
||||
it("resolves delayed leave event when leave is called", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus]);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
await manager.leave();
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send");
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
});
|
||||
it("send leave event when leave is called and resolving delayed leave fails", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus]);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
|
||||
await manager.leave();
|
||||
@@ -428,60 +423,16 @@ describe("MembershipManager", () => {
|
||||
);
|
||||
});
|
||||
it("does nothing if not joined", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
expect(async () => await manager.leave()).not.toThrow();
|
||||
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getsActiveFocus", () => {
|
||||
it("gets the correct active focus with oldest_membership", () => {
|
||||
const getOldestMembership = jest.fn();
|
||||
const manager = new MembershipManager({}, room, client, getOldestMembership, callSession);
|
||||
// Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession)
|
||||
expect(manager.getActiveFocus()).toBe(undefined);
|
||||
manager.join([focus], focusActive);
|
||||
// After joining we want our own focus to be the one we select.
|
||||
getOldestMembership.mockReturnValue(
|
||||
mockCallMembership(
|
||||
{
|
||||
...membershipTemplate,
|
||||
foci_preferred: [
|
||||
{
|
||||
livekit_alias: "!active:active.url",
|
||||
livekit_service_url: "https://active.url",
|
||||
type: "livekit",
|
||||
},
|
||||
],
|
||||
user_id: client.getUserId()!,
|
||||
device_id: client.getDeviceId()!,
|
||||
created_ts: 1000,
|
||||
},
|
||||
room.roomId,
|
||||
),
|
||||
);
|
||||
expect(manager.getActiveFocus()).toStrictEqual(focus);
|
||||
getOldestMembership.mockReturnValue(
|
||||
mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
|
||||
room.roomId,
|
||||
),
|
||||
);
|
||||
// If there is an older member we use its focus.
|
||||
expect(manager.getActiveFocus()).toBe(membershipTemplate.foci_preferred[0]);
|
||||
});
|
||||
|
||||
it("does not provide focus if the selection method is unknown", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], Object.assign(focusActive, { type: "unknown_type" }));
|
||||
expect(manager.getActiveFocus()).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onRTCSessionMemberUpdate()", () => {
|
||||
it("does nothing if not joined", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
@@ -489,7 +440,7 @@ describe("MembershipManager", () => {
|
||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does nothing if own membership still present", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
|
||||
@@ -513,7 +464,7 @@ describe("MembershipManager", () => {
|
||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("recreates membership if it is missing", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
@@ -531,7 +482,7 @@ describe("MembershipManager", () => {
|
||||
});
|
||||
|
||||
it("updates the UpdateExpiry entry in the action scheduler", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
@@ -564,7 +515,6 @@ describe("MembershipManager", () => {
|
||||
{ delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
{ id: "", application: "m.call" },
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
@@ -596,7 +546,7 @@ describe("MembershipManager", () => {
|
||||
{ membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
|
||||
{ id: "", application: "m.call" },
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
@@ -621,14 +571,14 @@ describe("MembershipManager", () => {
|
||||
|
||||
describe("status updates", () => {
|
||||
it("starts 'Disconnected'", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
});
|
||||
it("emits 'Connection' and 'Connected' after join", async () => {
|
||||
const handleDelayedEvent = createAsyncHandle<void>(client._unstable_sendDelayedStateEvent);
|
||||
const handleStateEvent = createAsyncHandle<void>(client.sendStateEvent);
|
||||
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
@@ -642,7 +592,7 @@ describe("MembershipManager", () => {
|
||||
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
|
||||
});
|
||||
it("emits 'Disconnecting' and 'Disconnected' after leave", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
manager.join([focus], focusActive);
|
||||
@@ -658,7 +608,7 @@ describe("MembershipManager", () => {
|
||||
it("sends retry if call membership event is still valid at time of retry", async () => {
|
||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -685,7 +635,7 @@ describe("MembershipManager", () => {
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
|
||||
// RateLimit error.
|
||||
manager.join([focus], focusActive);
|
||||
@@ -705,7 +655,7 @@ describe("MembershipManager", () => {
|
||||
it("abandons retry loop if leave() was called before sending state event", async () => {
|
||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
handle.reject?.(
|
||||
new MatrixError(
|
||||
@@ -740,7 +690,7 @@ describe("MembershipManager", () => {
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
|
||||
// Hit rate limit
|
||||
@@ -773,7 +723,7 @@ describe("MembershipManager", () => {
|
||||
new Headers({ "Retry-After": "2" }),
|
||||
),
|
||||
);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive, delayEventSendError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -793,7 +743,7 @@ describe("MembershipManager", () => {
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive, delayEventRestartError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -804,7 +754,7 @@ describe("MembershipManager", () => {
|
||||
it("falls back to using pure state events when some error occurs while sending delayed events", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(unrecoverableError).not.toHaveBeenCalledWith();
|
||||
@@ -817,7 +767,6 @@ describe("MembershipManager", () => {
|
||||
{ networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
@@ -836,7 +785,7 @@ describe("MembershipManager", () => {
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
|
||||
);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
@@ -850,7 +799,7 @@ describe("MembershipManager", () => {
|
||||
{ delayedLeaveEventDelayMs: 10000 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
|
||||
callSession,
|
||||
);
|
||||
const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers<EmptyObject>();
|
||||
@@ -901,13 +850,107 @@ describe("MembershipManager", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateCallIntent()", () => {
|
||||
it("should fail if the user has not joined the call", async () => {
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
// After joining we want our own focus to be the one we select.
|
||||
try {
|
||||
await manager.updateCallIntent("video");
|
||||
throw Error("Should have thrown");
|
||||
} catch {}
|
||||
});
|
||||
|
||||
it("can adjust the intent", async () => {
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([]);
|
||||
expect(manager.isActivated()).toEqual(true);
|
||||
const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId);
|
||||
await manager.onRTCSessionMemberUpdate([membership]);
|
||||
await manager.updateCallIntent("video");
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
|
||||
const eventContent = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData;
|
||||
expect(eventContent["created_ts"]).toEqual(membership.createdTs());
|
||||
expect(eventContent["m.call.intent"]).toEqual("video");
|
||||
});
|
||||
|
||||
it("does nothing if the intent doesn't change", async () => {
|
||||
const manager = new MembershipManager({ callIntent: "video" }, room, client, callSession);
|
||||
manager.join([]);
|
||||
expect(manager.isActivated()).toEqual(true);
|
||||
const membership = mockCallMembership(
|
||||
{ ...membershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" },
|
||||
room.roomId,
|
||||
);
|
||||
await manager.onRTCSessionMemberUpdate([membership]);
|
||||
await manager.updateCallIntent("video");
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StickyEventMembershipManager", () => {
|
||||
beforeEach(() => {
|
||||
// Provide a default mock that is like the default "non error" server behaviour.
|
||||
(client._unstable_sendStickyDelayedEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
|
||||
(client._unstable_sendStickyEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("join()", () => {
|
||||
describe("sends an rtc membership event", () => {
|
||||
it("sends a membership event and schedules delayed leave when joining a call", async () => {
|
||||
const updateDelayedEventHandle = createAsyncHandle<void>(
|
||||
client._unstable_updateDelayedEvent as Mock,
|
||||
);
|
||||
const memberManager = new StickyEventMembershipManager(undefined, room, client, callSession);
|
||||
|
||||
memberManager.join([], focus);
|
||||
|
||||
await waitForMockCall(client._unstable_sendStickyEvent, Promise.resolve({ event_id: "id" }));
|
||||
// Test we sent the initial join
|
||||
expect(client._unstable_sendStickyEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
3600000,
|
||||
null,
|
||||
"org.matrix.msc4143.rtc.member",
|
||||
{
|
||||
application: { type: "m.call" },
|
||||
member: {
|
||||
user_id: "@alice:example.org",
|
||||
id: "_@alice:example.org_AAAAAAA_m.call",
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
slot_id: "m.call#",
|
||||
rtc_transports: [focus],
|
||||
versions: [],
|
||||
msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call",
|
||||
},
|
||||
);
|
||||
updateDelayedEventHandle.resolve?.();
|
||||
|
||||
// Ensure we have sent the delayed disconnect event.
|
||||
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
MAX_STICKY_DURATION_MS,
|
||||
{ delay: 8000 },
|
||||
null,
|
||||
"org.matrix.msc4143.rtc.member",
|
||||
{
|
||||
msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call",
|
||||
},
|
||||
);
|
||||
// ..once
|
||||
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Should prefix log with MembershipManager used", () => {
|
||||
const client = makeMockClient("@alice:example.org", "AAAAAAA");
|
||||
const room = makeMockRoom([membershipTemplate]);
|
||||
|
||||
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, callSession, logger);
|
||||
const membershipManager = new MembershipManager(undefined, room, client, callSession);
|
||||
|
||||
const spy = jest.spyOn(console, "error");
|
||||
// Double join
|
||||
|
||||
@@ -22,9 +22,7 @@ import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTra
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
|
||||
import { decodeBase64, TypedEventEmitter } from "../../../src";
|
||||
import { RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
|
||||
import { type RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
|
||||
import { logger, type Logger } from "../../../src/logger.ts";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
import { getParticipantId } from "../../../src/matrixrtc/utils.ts";
|
||||
|
||||
describe("RTCEncryptionManager", () => {
|
||||
@@ -782,86 +780,6 @@ describe("RTCEncryptionManager", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Should re-distribute key on transport switch", async () => {
|
||||
const toDeviceEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
const mockToDeviceTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: toDeviceEmitter.on.bind(toDeviceEmitter),
|
||||
off: toDeviceEmitter.off.bind(toDeviceEmitter),
|
||||
emit: toDeviceEmitter.emit.bind(toDeviceEmitter),
|
||||
setParentLogger: jest.fn(),
|
||||
} as unknown as Mocked<ToDeviceKeyTransport>;
|
||||
|
||||
const roomEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
const mockRoomTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: roomEmitter.on.bind(roomEmitter),
|
||||
off: roomEmitter.off.bind(roomEmitter),
|
||||
emit: roomEmitter.emit.bind(roomEmitter),
|
||||
setParentLogger: jest.fn(),
|
||||
} as unknown as Mocked<RoomKeyTransport>;
|
||||
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
|
||||
const transport = new RoomAndToDeviceTransport(mockToDeviceTransport, mockRoomTransport, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
"@alice:example.org",
|
||||
"DEVICE01",
|
||||
getMembershipMock,
|
||||
transport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
);
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Should have sent the key to the toDevice transport
|
||||
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockRoomTransport.sendKey).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate receiving a key by room transport
|
||||
roomEmitter.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE",
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
// The key should have been re-distributed to the room transport
|
||||
expect(mockRoomTransport.sendKey).toHaveBeenCalled();
|
||||
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key re-distributed
|
||||
0,
|
||||
// to all the members
|
||||
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
});
|
||||
|
||||
function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
|
||||
return mockCallMembership(
|
||||
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 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 { type Mocked } from "jest-mock";
|
||||
|
||||
import { makeKey, makeMockEvent, makeMockRoom } from "./mocks";
|
||||
import { EventType, type IRoomTimelineData, type Room, RoomEvent, type MatrixClient } from "../../../src";
|
||||
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import {
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsEvents,
|
||||
mockClientMethodsUser,
|
||||
} from "../../test-utils/client.ts";
|
||||
import { type ParticipantDeviceInfo, type Statistics } from "../../../src/matrixrtc";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { type Logger } from "../../../src/logger.ts";
|
||||
import { RoomAndToDeviceEvents, RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
|
||||
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
|
||||
|
||||
describe("RoomAndToDeviceTransport", () => {
|
||||
const roomId = "!room:id";
|
||||
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
let statistics: Statistics;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
let transport: RoomAndToDeviceTransport;
|
||||
let mockRoom: Room;
|
||||
let sendEventMock: jest.Mock;
|
||||
let roomKeyTransport: RoomKeyTransport;
|
||||
let toDeviceKeyTransport: ToDeviceKeyTransport;
|
||||
let toDeviceSendKeySpy: jest.SpyInstance;
|
||||
let roomSendKeySpy: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
sendEventMock = jest.fn();
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
encryptAndSendToDevice: jest.fn(),
|
||||
getDeviceId: jest.fn().mockReturnValue("MYDEVICE"),
|
||||
...mockClientMethodsEvents(),
|
||||
...mockClientMethodsUser("@alice:example.org"),
|
||||
sendEvent: sendEventMock,
|
||||
});
|
||||
mockRoom = makeMockRoom([]);
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
getChild: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
mockLogger.getChild.mockReturnValue(mockLogger);
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
roomKeyTransport = new RoomKeyTransport(mockRoom, mockClient, statistics);
|
||||
toDeviceKeyTransport = new ToDeviceKeyTransport(
|
||||
"@alice:example.org",
|
||||
"MYDEVICE",
|
||||
mockRoom.roomId,
|
||||
mockClient,
|
||||
statistics,
|
||||
);
|
||||
transport = new RoomAndToDeviceTransport(toDeviceKeyTransport, roomKeyTransport, mockLogger);
|
||||
toDeviceSendKeySpy = jest.spyOn(toDeviceKeyTransport, "sendKey");
|
||||
roomSendKeySpy = jest.spyOn(roomKeyTransport, "sendKey");
|
||||
});
|
||||
|
||||
it("should enable to device transport when starting", () => {
|
||||
transport.start();
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
});
|
||||
it("only sends to device keys when sending a key", async () => {
|
||||
transport.start();
|
||||
await transport.sendKey("1235", 0, [
|
||||
{ userId: "@alice:example.org", deviceId: "ALICEDEVICE", membershipTs: 1234 },
|
||||
]);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
});
|
||||
|
||||
it("enables room transport and disables to device transport when receiving a room key", async () => {
|
||||
transport.start();
|
||||
const onNewKeyFromTransport = jest.fn();
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
mockRoom.emit(
|
||||
RoomEvent.Timeline,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", roomId, {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(transport.enabled.room).toBeTruthy();
|
||||
expect(transport.enabled.toDevice).toBeFalsy();
|
||||
|
||||
await transport.sendKey("1235", 0, [
|
||||
{ userId: "@alice:example.org", deviceId: "AlICEDEV", membershipTs: 1234 },
|
||||
]);
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
|
||||
});
|
||||
|
||||
it("enables room transport and disables to device transport on widget driver error", async () => {
|
||||
mockClient.encryptAndSendToDevice.mockRejectedValue({
|
||||
message:
|
||||
"unknown variant `send_to_device`, expected one of `supported_api_versions`, `content_loaded`, `get_openid`, `org.matrix.msc2876.read_events`, `send_event`, `org.matrix.msc4157.update_delayed_event` at line 1 column 22",
|
||||
});
|
||||
|
||||
transport.start();
|
||||
const membership: ParticipantDeviceInfo = {
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "ALICEDEVICE",
|
||||
membershipTs: 1234,
|
||||
};
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
|
||||
// We start with toDevice transport enabled
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
|
||||
await transport.sendKey("1235", 0, [membership]);
|
||||
|
||||
// We switched transport, now room transport is enabled
|
||||
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
|
||||
expect(transport.enabled.room).toBeTruthy();
|
||||
expect(transport.enabled.toDevice).toBeFalsy();
|
||||
|
||||
// sanity check that we called the failang to device send key.
|
||||
expect(toDeviceKeyTransport.sendKey).toHaveBeenCalledWith("1235", 0, [membership]);
|
||||
expect(toDeviceKeyTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
// We re-sent the key via the room transport
|
||||
expect(roomKeyTransport.sendKey).toHaveBeenCalledWith("1235", 0, [membership]);
|
||||
expect(roomKeyTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
|
||||
mockClient.encryptAndSendToDevice.mockRestore();
|
||||
});
|
||||
|
||||
it("does log that it did nothing when disabled", () => {
|
||||
transport.start();
|
||||
const onNewKeyFromTransport = jest.fn();
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
|
||||
transport.setEnabled({ toDevice: false, room: false });
|
||||
const dateNow = Date.now();
|
||||
roomKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "roomKey", 0, dateNow);
|
||||
toDeviceKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "toDeviceKey", 0, Date.now());
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith("To Device transport is disabled, ignoring received keys");
|
||||
// for room key transport we will never get a disabled message because its will always just turn on
|
||||
expect(onTransportEnabled).toHaveBeenNthCalledWith(1, { toDevice: false, room: false });
|
||||
expect(onTransportEnabled).toHaveBeenNthCalledWith(2, { toDevice: false, room: true });
|
||||
expect(onNewKeyFromTransport).toHaveBeenCalledTimes(1);
|
||||
expect(onNewKeyFromTransport).toHaveBeenCalledWith("user", "device", "roomKey", 0, dateNow);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "stream";
|
||||
import { type Mocked } from "jest-mock";
|
||||
|
||||
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
|
||||
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
@@ -51,6 +52,8 @@ export type MockClient = Pick<
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "_unstable_sendStickyEvent"
|
||||
| "_unstable_sendStickyDelayedEvent"
|
||||
| "cancelPendingEvent"
|
||||
>;
|
||||
/**
|
||||
@@ -65,15 +68,19 @@ export function makeMockClient(userId: string, deviceId: string): MockClient {
|
||||
cancelPendingEvent: jest.fn(),
|
||||
_unstable_updateDelayedEvent: jest.fn(),
|
||||
_unstable_sendDelayedStateEvent: jest.fn(),
|
||||
_unstable_sendStickyEvent: jest.fn(),
|
||||
_unstable_sendStickyDelayedEvent: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
export function makeMockRoom(
|
||||
membershipData: MembershipData[],
|
||||
): Room & { emitTimelineEvent: (event: MatrixEvent) => void } {
|
||||
useStickyEvents = false,
|
||||
): Mocked<Room & { emitTimelineEvent: (event: MatrixEvent) => void }> {
|
||||
const roomId = secureRandomString(8);
|
||||
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
|
||||
const roomState = makeMockRoomState(membershipData, roomId);
|
||||
const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId);
|
||||
const ts = Date.now();
|
||||
const room = Object.assign(new EventEmitter(), {
|
||||
roomId: roomId,
|
||||
hasMembershipState: jest.fn().mockReturnValue(true),
|
||||
@@ -81,11 +88,16 @@ export function makeMockRoom(
|
||||
getState: jest.fn().mockReturnValue(roomState),
|
||||
}),
|
||||
getVersion: jest.fn().mockReturnValue("default"),
|
||||
}) as unknown as Room;
|
||||
_unstable_getStickyEvents: jest
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, 10000, ts)) : [],
|
||||
) as any,
|
||||
});
|
||||
return Object.assign(room, {
|
||||
emitTimelineEvent: (event: MatrixEvent) =>
|
||||
room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any),
|
||||
});
|
||||
}) as unknown as Mocked<Room & { emitTimelineEvent: (event: MatrixEvent) => void }>;
|
||||
}
|
||||
|
||||
function makeMockRoomState(membershipData: MembershipData[], roomId: string) {
|
||||
@@ -129,6 +141,7 @@ export function makeMockEvent(
|
||||
roomId: string | undefined,
|
||||
content: any,
|
||||
timestamp?: number,
|
||||
stateKey?: string,
|
||||
): MatrixEvent {
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(type),
|
||||
@@ -137,12 +150,28 @@ export function makeMockEvent(
|
||||
getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()),
|
||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||
getId: jest.fn().mockReturnValue(secureRandomString(8)),
|
||||
getStateKey: jest.fn().mockReturnValue(stateKey),
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(false),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipData, roomId: string): MatrixEvent {
|
||||
return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData);
|
||||
export function mockRTCEvent(
|
||||
{ user_id: sender, ...membershipData }: MembershipData,
|
||||
roomId: string,
|
||||
stickyDuration?: number,
|
||||
timestamp?: number,
|
||||
): MatrixEvent {
|
||||
return {
|
||||
...makeMockEvent(
|
||||
stickyDuration !== undefined ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||
sender,
|
||||
roomId,
|
||||
membershipData,
|
||||
timestamp,
|
||||
!stickyDuration && "device_id" in membershipData ? `_${sender}_${membershipData.device_id}` : "",
|
||||
),
|
||||
unstableStickyExpiresAt: stickyDuration,
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
export function mockCallMembership(membershipData: MembershipData, roomId: string): CallMembership {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
Copyright 2025 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 { type CallMembership } from "../../../src/matrixrtc";
|
||||
import { isMyMembership, parseCallNotificationContent } from "../../../src/matrixrtc/types";
|
||||
|
||||
describe("types", () => {
|
||||
describe("isMyMembership", () => {
|
||||
it("returns false if userId is different", () => {
|
||||
expect(
|
||||
isMyMembership(
|
||||
{ sender: "@alice:example.org", deviceId: "DEVICE" } as CallMembership,
|
||||
"@bob:example.org",
|
||||
"DEVICE",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("returns true if userId and device is the same", () => {
|
||||
expect(
|
||||
isMyMembership(
|
||||
{ sender: "@alice:example.org", deviceId: "DEVICE" } as CallMembership,
|
||||
"@alice:example.org",
|
||||
"DEVICE",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("IRTCNotificationContent", () => {
|
||||
const validBase = Object.freeze({
|
||||
"m.mentions": { user_ids: [], room: true },
|
||||
"notification_type": "notification",
|
||||
"sender_ts": 123,
|
||||
"lifetime": 1000,
|
||||
});
|
||||
|
||||
it("parses valid content", () => {
|
||||
const res = parseCallNotificationContent({ ...validBase });
|
||||
expect(res).toMatchObject(validBase);
|
||||
});
|
||||
|
||||
it("caps lifetime to 90000ms", () => {
|
||||
const res = parseCallNotificationContent({ ...validBase, lifetime: 130000 });
|
||||
expect(res.lifetime).toBe(90000);
|
||||
});
|
||||
|
||||
it("throws on malformed m.mentions", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
"m.mentions": "not an object",
|
||||
} as any),
|
||||
).toThrow("malformed m.mentions");
|
||||
});
|
||||
|
||||
it("throws on missing or invalid notification_type", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
notification_type: undefined,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid notification_type");
|
||||
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
notification_type: 123 as any,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid notification_type");
|
||||
});
|
||||
|
||||
it("throws on missing or invalid sender_ts", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
sender_ts: undefined,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid sender_ts");
|
||||
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
sender_ts: "123" as any,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid sender_ts");
|
||||
});
|
||||
|
||||
it("throws on missing or invalid lifetime", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
lifetime: undefined,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid lifetime");
|
||||
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
lifetime: "1000" as any,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid lifetime");
|
||||
});
|
||||
|
||||
it("accepts valid relation (m.reference)", () => {
|
||||
// Note: parseCallNotificationContent currently checks `relation.rel_type` rather than `m.relates_to`.
|
||||
const res = parseCallNotificationContent({
|
||||
...validBase,
|
||||
relation: { rel_type: "m.reference", event_id: "$ev" },
|
||||
} as any);
|
||||
expect(res).toBeTruthy();
|
||||
});
|
||||
|
||||
it("throws on invalid relation rel_type", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
relation: { rel_type: "m.annotation", event_id: "$ev" },
|
||||
} as any),
|
||||
).toThrow("Invalid relation");
|
||||
});
|
||||
});
|
||||
@@ -16,10 +16,11 @@ limitations under the License.
|
||||
|
||||
import { type MockedObject } from "jest-mock";
|
||||
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
|
||||
import { type IContent, MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import {
|
||||
type IAnnotatedPushRule,
|
||||
type IStickyEvent,
|
||||
type MatrixClient,
|
||||
PushRuleActionName,
|
||||
Room,
|
||||
@@ -335,6 +336,31 @@ describe("MatrixEvent", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("state key packing", () => {
|
||||
it("should pack the state key during encryption", () => {
|
||||
const ev = createStateEvent("$event1:server", "m.room.topic", "", { topic: "" });
|
||||
expect(ev.getStateKey()).toStrictEqual("");
|
||||
ev.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", "");
|
||||
expect(ev.getStateKey()).toStrictEqual("");
|
||||
expect(ev.getWireStateKey()).toStrictEqual("m.room.topic:");
|
||||
|
||||
const keyedEv = createStateEvent("$event2:server", "m.beacon_info", "@alice:server", {});
|
||||
expect(keyedEv.getStateKey()).toStrictEqual("@alice:server");
|
||||
keyedEv.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", "");
|
||||
expect(keyedEv.getStateKey()).toStrictEqual("@alice:server");
|
||||
expect(keyedEv.getWireStateKey()).toStrictEqual("m.beacon_info:@alice:server");
|
||||
});
|
||||
|
||||
function createStateEvent(eventId: string, type: string, stateKey: string, content?: IContent): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type,
|
||||
state_key: stateKey,
|
||||
content,
|
||||
event_id: eventId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("applyVisibilityEvent", () => {
|
||||
it("should emit VisibilityChange if a change was made", async () => {
|
||||
const ev = new MatrixEvent({
|
||||
@@ -573,6 +599,39 @@ describe("MatrixEvent", () => {
|
||||
expect(stateEvent.isState()).toBeTruthy();
|
||||
expect(stateEvent.threadRootId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should calculate sticky duration correctly", async () => {
|
||||
const evData: IStickyEvent = {
|
||||
event_id: "$event_id",
|
||||
type: "some_state_event",
|
||||
content: {},
|
||||
sender: "@alice:example.org",
|
||||
origin_server_ts: 50,
|
||||
msc4354_sticky: {
|
||||
duration_ms: 1000,
|
||||
},
|
||||
unsigned: {
|
||||
msc4354_sticky_duration_ttl_ms: 5000,
|
||||
},
|
||||
};
|
||||
try {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(50);
|
||||
// Prefer unsigned
|
||||
expect(new MatrixEvent({ ...evData } satisfies IStickyEvent).unstableStickyExpiresAt).toEqual(5050);
|
||||
// Fall back to `duration_ms`
|
||||
expect(
|
||||
new MatrixEvent({ ...evData, unsigned: undefined } satisfies IStickyEvent).unstableStickyExpiresAt,
|
||||
).toEqual(1050);
|
||||
// Prefer current time if `origin_server_ts` is more recent.
|
||||
expect(
|
||||
new MatrixEvent({ ...evData, unsigned: undefined, origin_server_ts: 5000 } satisfies IStickyEvent)
|
||||
.unstableStickyExpiresAt,
|
||||
).toEqual(1050);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function mainTimelineLiveEventIds(room: Room): Array<string> {
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import { type IStickyEvent, MatrixEvent } from "../../../src";
|
||||
import { RoomStickyEventsStore, RoomStickyEventsEvent } from "../../../src/models/room-sticky-events";
|
||||
|
||||
describe("RoomStickyEvents", () => {
|
||||
let stickyEvents: RoomStickyEventsStore;
|
||||
const emitSpy: jest.Mock = jest.fn();
|
||||
const stickyEvent: IStickyEvent = {
|
||||
event_id: "$foo:bar",
|
||||
room_id: "!roomId",
|
||||
type: "org.example.any_type",
|
||||
msc4354_sticky: {
|
||||
duration_ms: 15000,
|
||||
},
|
||||
content: {
|
||||
msc4354_sticky_key: "foobar",
|
||||
},
|
||||
sender: "@alice:example.org",
|
||||
origin_server_ts: Date.now(),
|
||||
unsigned: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
emitSpy.mockReset();
|
||||
stickyEvents = new RoomStickyEventsStore();
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stickyEvents?.clear();
|
||||
});
|
||||
|
||||
describe("addStickyEvents", () => {
|
||||
it("should allow adding an event without a msc4354_sticky_key", () => {
|
||||
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, content: {} })]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toHaveLength(1);
|
||||
});
|
||||
it("should not allow adding an event without a msc4354_sticky property", () => {
|
||||
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, msc4354_sticky: undefined })]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
|
||||
stickyEvents.addStickyEvents([
|
||||
new MatrixEvent({ ...stickyEvent, msc4354_sticky: { duration_ms: undefined } as any }),
|
||||
]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
|
||||
});
|
||||
it("should not allow adding an event without a sender", () => {
|
||||
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: undefined })]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
|
||||
});
|
||||
it("should not allow adding an event with an invalid sender", () => {
|
||||
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: "not_a_real_sender" })]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
|
||||
});
|
||||
it("should ignore old events", () => {
|
||||
stickyEvents.addStickyEvents([
|
||||
new MatrixEvent({ ...stickyEvent, origin_server_ts: 0, msc4354_sticky: { duration_ms: 1 } }),
|
||||
]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
|
||||
});
|
||||
it("should be able to just add an event", () => {
|
||||
const originalEv = new MatrixEvent({ ...stickyEvent });
|
||||
stickyEvents.addStickyEvents([originalEv]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
|
||||
});
|
||||
it("should not replace events on ID tie break", () => {
|
||||
const originalEv = new MatrixEvent({ ...stickyEvent });
|
||||
stickyEvents.addStickyEvents([originalEv]);
|
||||
stickyEvents.addStickyEvents([
|
||||
new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$abc:bar",
|
||||
}),
|
||||
]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
|
||||
});
|
||||
it("should not replace a newer event with an older event", () => {
|
||||
const originalEv = new MatrixEvent({ ...stickyEvent });
|
||||
stickyEvents.addStickyEvents([originalEv]);
|
||||
stickyEvents.addStickyEvents([
|
||||
new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: 1,
|
||||
}),
|
||||
]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
|
||||
});
|
||||
it("should replace an older event with a newer event", () => {
|
||||
const originalEv = new MatrixEvent({ ...stickyEvent, event_id: "$old" });
|
||||
const newerEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$new",
|
||||
origin_server_ts: Date.now() + 2000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([originalEv]);
|
||||
stickyEvents.addStickyEvents([newerEv]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([newerEv]);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [{ current: newerEv, previous: originalEv }], []);
|
||||
});
|
||||
it("should allow multiple events with the same sticky key for different event types", () => {
|
||||
const originalEv = new MatrixEvent({ ...stickyEvent });
|
||||
const anotherEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
type: "org.example.another_type",
|
||||
});
|
||||
stickyEvents.addStickyEvents([originalEv, anotherEv]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv, anotherEv]);
|
||||
});
|
||||
|
||||
it("should emit when a new sticky event is added", () => {
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
|
||||
expect(emitSpy).toHaveBeenCalledWith([ev], [], []);
|
||||
});
|
||||
it("should emit when a new unkeyed sticky event is added", () => {
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
content: {},
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
|
||||
expect(emitSpy).toHaveBeenCalledWith([ev], [], []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStickyEvents", () => {
|
||||
it("should have zero sticky events", () => {
|
||||
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
|
||||
});
|
||||
it("should contain a sticky event", () => {
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
|
||||
});
|
||||
it("should contain two sticky events", () => {
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
});
|
||||
const ev2 = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
sender: "@fibble:bobble",
|
||||
content: {
|
||||
msc4354_sticky_key: "bibble",
|
||||
},
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, ev2]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([ev, ev2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKeyedStickyEvent", () => {
|
||||
it("should have zero sticky events", () => {
|
||||
expect(
|
||||
stickyEvents.getKeyedStickyEvent(
|
||||
stickyEvent.sender,
|
||||
stickyEvent.type,
|
||||
stickyEvent.content.msc4354_sticky_key!,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
it("should return a sticky event", () => {
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
expect(
|
||||
stickyEvents.getKeyedStickyEvent(
|
||||
stickyEvent.sender,
|
||||
stickyEvent.type,
|
||||
stickyEvent.content.msc4354_sticky_key!,
|
||||
),
|
||||
).toEqual(ev);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUnkeyedStickyEvent", () => {
|
||||
it("should have zero sticky events", () => {
|
||||
expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([]);
|
||||
});
|
||||
it("should return a sticky event", () => {
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
content: {
|
||||
msc4354_sticky_key: undefined,
|
||||
},
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([ev]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanExpiredStickyEvents", () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should emit when a sticky event expires", () => {
|
||||
jest.setSystemTime(1000);
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: 0,
|
||||
});
|
||||
const evLater = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$baz:bar",
|
||||
sender: "@bob:example.org",
|
||||
origin_server_ts: 1000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, evLater]);
|
||||
const emitSpy = jest.fn();
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
jest.advanceTimersByTime(15000);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
|
||||
// Then expire the next event
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [evLater]);
|
||||
});
|
||||
it("should emit two events when both expire at the same time", () => {
|
||||
const emitSpy = jest.fn();
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
jest.setSystemTime(0);
|
||||
const ev1 = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$eventA",
|
||||
origin_server_ts: 0,
|
||||
});
|
||||
const ev2 = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$eventB",
|
||||
content: {
|
||||
msc4354_sticky_key: "key_2",
|
||||
},
|
||||
origin_server_ts: 0,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev1, ev2]);
|
||||
expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], [], []);
|
||||
jest.advanceTimersByTime(15000);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [ev1, ev2]);
|
||||
});
|
||||
it("should emit when a unkeyed sticky event expires", () => {
|
||||
const emitSpy = jest.fn();
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
jest.setSystemTime(0);
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
content: {},
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
jest.advanceTimersByTime(15000);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleRedaction", () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
it("should not emit if the event does not exist in the map", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
content: {},
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
stickyEvents.handleRedaction("$123456");
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should emit a remove when the event exists in the map without a predecessor", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
stickyEvents.handleRedaction(stickyEvent.event_id);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
|
||||
});
|
||||
it("should emit a remove when the event has no sticky key", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
content: {},
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
stickyEvents.handleRedaction(stickyEvent.event_id);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
|
||||
});
|
||||
it("should emit an update when the event exists in the map with a predecessor", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
|
||||
const newerEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newer-ev",
|
||||
origin_server_ts: Date.now() + 1000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, newerEv]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
stickyEvents.handleRedaction(newerEv.getId()!);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newerEv }], []);
|
||||
});
|
||||
it("should emit a remove if the previous event has expired", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
|
||||
const newerEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newer-ev",
|
||||
origin_server_ts: Date.now() + 1000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, newerEv]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
// Expire the older event.
|
||||
jest.advanceTimersByTime(stickyEvent.msc4354_sticky.duration_ms);
|
||||
// Redact the newer event
|
||||
stickyEvents.handleRedaction(newerEv.getId()!);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [newerEv]);
|
||||
});
|
||||
it("should recurse the chain of events if the previous event has been redacted", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
|
||||
const middleEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newer-ev",
|
||||
origin_server_ts: Date.now() + 1000,
|
||||
});
|
||||
jest.advanceTimersByTime(1000);
|
||||
const newestEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newest-ev",
|
||||
origin_server_ts: Date.now() + 2000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, middleEv, newestEv]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
// Mark the middle event as redacted.
|
||||
middleEv.setUnsigned({
|
||||
redacted_because: {
|
||||
event_id: "$foo",
|
||||
} as any,
|
||||
});
|
||||
// Redact the newer event
|
||||
stickyEvents.handleRedaction(newestEv.getId()!);
|
||||
// expect immediate transition from newestEv -> ev and skipping middleEv
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newestEv }], []);
|
||||
});
|
||||
it("should revert to the most recent valid event regardless of insertion order", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
|
||||
const middleEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newer-ev",
|
||||
origin_server_ts: Date.now() + 1000,
|
||||
});
|
||||
jest.advanceTimersByTime(1000);
|
||||
const newestEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newest-ev",
|
||||
origin_server_ts: Date.now() + 2000,
|
||||
});
|
||||
// Invert in reverse order, to make sure we retain the older events.
|
||||
stickyEvents.addStickyEvents([newestEv, middleEv, ev]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
// Mark the middle event as redacted.
|
||||
middleEv.setUnsigned({
|
||||
redacted_because: {
|
||||
event_id: "$foo",
|
||||
} as any,
|
||||
});
|
||||
// Redact the newer event
|
||||
stickyEvents.handleRedaction(newestEv.getId()!);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newestEv }], []);
|
||||
});
|
||||
it("should handle redaction when using `handleRedaction` with a `MatrixEvent` parameter", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
|
||||
const newerEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newer-ev",
|
||||
origin_server_ts: Date.now() + 1000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, newerEv]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
stickyEvents.handleRedaction(newerEv);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newerEv }], []);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,7 @@ describe("RoomEncryptor", () => {
|
||||
body: text,
|
||||
msgtype: "m.text",
|
||||
}),
|
||||
isState: () => false,
|
||||
makeEncrypted: jest.fn().mockReturnValue(undefined),
|
||||
} as unknown as Mocked<MatrixEvent>;
|
||||
}
|
||||
|
||||
@@ -830,6 +830,7 @@ describe("RustCrypto", () => {
|
||||
TEST_DEVICE_ID,
|
||||
secretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
false,
|
||||
);
|
||||
|
||||
async function createSecretStorageKey() {
|
||||
@@ -853,9 +854,27 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("getSecretStorageStatus", async () => {
|
||||
const mockSecretStorage = {
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("blah"),
|
||||
isStored: jest.fn().mockResolvedValue({ blah: {} }),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage);
|
||||
await expect(rustCrypto.getSecretStorageStatus()).resolves.toEqual({
|
||||
defaultKeyId: "blah",
|
||||
ready: true,
|
||||
secretStorageKeyValidityMap: {
|
||||
"m.cross_signing.master": true,
|
||||
"m.cross_signing.self_signing": true,
|
||||
"m.cross_signing.user_signing": true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("isSecretStorageReady", async () => {
|
||||
const mockSecretStorage = {
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue(null),
|
||||
isStored: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage);
|
||||
await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false);
|
||||
@@ -2418,6 +2437,135 @@ describe("RustCrypto", () => {
|
||||
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][1]).toEqual(new TextEncoder().encode("asdfghjkl"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Verification requests", () => {
|
||||
it("fetches device details before room verification requests", async () => {
|
||||
// Given a RustCrypto
|
||||
const olmMachine = mockedOlmMachine();
|
||||
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
|
||||
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
|
||||
|
||||
// When we receive a room verification request
|
||||
const event = mockedEvent("!r:s.co", "@u:s.co", "m.room.message", "m.key.verification.request");
|
||||
await rustCrypto.onLiveEventFromSync(event);
|
||||
|
||||
// Then we first fetch device details
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalled();
|
||||
|
||||
// And we handle the verification event as normal
|
||||
expect(olmMachine.receiveVerificationEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fetch device details before other verification events", async () => {
|
||||
// Given a RustCrypto
|
||||
const olmMachine = mockedOlmMachine();
|
||||
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
|
||||
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
|
||||
|
||||
// When we receive some verification event that is not a room request
|
||||
const event = mockedEvent("!r:s.co", "@u:s.co", "m.key.verification.start");
|
||||
await rustCrypto.onLiveEventFromSync(event);
|
||||
|
||||
// Then we do not fetch device details
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
|
||||
|
||||
// And we handle the verification event as normal
|
||||
expect(olmMachine.receiveVerificationEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws an error if sender is missing", async () => {
|
||||
// Given a RustCrypto
|
||||
const olmMachine = mockedOlmMachine();
|
||||
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
|
||||
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
|
||||
|
||||
// When we receive a verification event without a sender
|
||||
// Then we throw
|
||||
const event = mockedEvent("!r:s.co", null, "m.key.verification.start");
|
||||
|
||||
await expect(async () => await rustCrypto.onLiveEventFromSync(event)).rejects.toThrow(
|
||||
"missing sender in the event",
|
||||
);
|
||||
|
||||
// And we do not fetch device details or handle the event
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
|
||||
expect(olmMachine.receiveVerificationEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws an error if room is missing", async () => {
|
||||
// Given a RustCrypto
|
||||
const olmMachine = mockedOlmMachine();
|
||||
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
|
||||
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
|
||||
|
||||
// When we receive a verification event without a sender
|
||||
// Then we throw
|
||||
const event = mockedEvent(null, "@u:s.co", "m.key.verification.start");
|
||||
|
||||
await expect(async () => await rustCrypto.onLiveEventFromSync(event)).rejects.toThrow(
|
||||
"missing roomId in the event",
|
||||
);
|
||||
|
||||
// And we do not fetch device details or handle the event
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
|
||||
expect(olmMachine.receiveVerificationEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function mockedOlmMachine(): Mocked<OlmMachine> {
|
||||
return {
|
||||
queryKeysForUsers: jest.fn(),
|
||||
getVerificationRequest: jest.fn(),
|
||||
receiveVerificationEvent: jest.fn(),
|
||||
} as unknown as Mocked<OlmMachine>;
|
||||
}
|
||||
|
||||
function makeRustCrypto(
|
||||
olmMachine: OlmMachine,
|
||||
outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||
): RustCrypto {
|
||||
const rustCrypto = new RustCrypto(
|
||||
new DebugLogger(debug("test Verification requests")),
|
||||
olmMachine,
|
||||
{} as unknown as MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
);
|
||||
|
||||
// @ts-ignore mocking outgoingRequestProcessor
|
||||
rustCrypto.outgoingRequestProcessor = outgoingRequestProcessor;
|
||||
|
||||
return rustCrypto;
|
||||
}
|
||||
|
||||
function mockedOutgoingRequestProcessor(): OutgoingRequestProcessor {
|
||||
return {
|
||||
makeOutgoingRequest: jest.fn(),
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
}
|
||||
|
||||
function mockedEvent(
|
||||
roomId: string | null,
|
||||
senderId: string | null,
|
||||
eventType: string,
|
||||
msgtype?: string | undefined,
|
||||
): MatrixEvent {
|
||||
return {
|
||||
isState: jest.fn().mockReturnValue(false),
|
||||
getUnsigned: jest.fn().mockReturnValue({}),
|
||||
isDecryptionFailure: jest.fn(),
|
||||
isEncrypted: jest.fn(),
|
||||
getType: jest.fn().mockReturnValue(eventType),
|
||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||
getSender: jest.fn().mockReturnValue(senderId),
|
||||
getId: jest.fn(),
|
||||
getStateKey: jest.fn(),
|
||||
getContent: jest.fn().mockReturnValue({ msgtype: msgtype }),
|
||||
getTs: jest.fn(),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** Build a MatrixHttpApi instance */
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type ILeftRoom,
|
||||
type IRoomEvent,
|
||||
type IStateEvent,
|
||||
type IStickyEvent,
|
||||
type IStrippedState,
|
||||
type ISyncResponse,
|
||||
SyncAccumulator,
|
||||
@@ -1067,6 +1068,67 @@ describe("SyncAccumulator", function () {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MSC4354 sticky events", () => {
|
||||
function stickyEvent(ts = 0): IStickyEvent {
|
||||
const msgData = msg("test", "test text");
|
||||
return {
|
||||
...msgData,
|
||||
msc4354_sticky: {
|
||||
duration_ms: 1000,
|
||||
},
|
||||
origin_server_ts: ts,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should accumulate sticky events", () => {
|
||||
jest.setSystemTime(0);
|
||||
const ev = stickyEvent();
|
||||
sa.accumulate(
|
||||
syncSkeleton({
|
||||
msc4354_sticky: {
|
||||
events: [ev],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]);
|
||||
});
|
||||
it("should clear stale sticky events", () => {
|
||||
jest.setSystemTime(1000);
|
||||
const ev = stickyEvent(1000);
|
||||
sa.accumulate(
|
||||
syncSkeleton({
|
||||
msc4354_sticky: {
|
||||
events: [ev],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]);
|
||||
jest.setSystemTime(2000); // Expire the event
|
||||
sa.accumulate(syncSkeleton({}));
|
||||
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears stale sticky events that pretend to be from the distant future", () => {
|
||||
jest.setSystemTime(0);
|
||||
const eventFarInTheFuture = stickyEvent(999999999999);
|
||||
sa.accumulate(syncSkeleton({ msc4354_sticky: { events: [eventFarInTheFuture] } }));
|
||||
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([
|
||||
eventFarInTheFuture,
|
||||
]);
|
||||
jest.setSystemTime(1000); // Expire the event
|
||||
sa.accumulate(syncSkeleton({}));
|
||||
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function syncSkeleton(
|
||||
|
||||
+4
-2
@@ -58,7 +58,7 @@ import {
|
||||
type ICallNotifyContent,
|
||||
} from "../matrixrtc/types.ts";
|
||||
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts";
|
||||
import { type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
|
||||
import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
|
||||
import { type LocalNotificationSettings } from "./local_notifications.ts";
|
||||
import { type IPushRules } from "./PushRules.ts";
|
||||
import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
|
||||
@@ -151,6 +151,7 @@ export enum EventType {
|
||||
GroupCallMemberPrefix = "org.matrix.msc3401.call.member",
|
||||
|
||||
// MatrixRTC events
|
||||
RTCMembership = "org.matrix.msc4143.rtc.member",
|
||||
CallNotify = "org.matrix.msc4075.call.notify",
|
||||
RTCNotification = "org.matrix.msc4075.rtc.notification",
|
||||
RTCDecline = "org.matrix.msc4310.rtc.decline",
|
||||
@@ -337,6 +338,7 @@ export interface TimelineEvents {
|
||||
[M_BEACON.name]: MBeaconEventContent;
|
||||
[M_POLL_START.name]: PollStartEventContent;
|
||||
[M_POLL_END.name]: PollEndEventContent;
|
||||
[EventType.RTCMembership]: RtcMembershipData | { msc4354_sticky_key: string }; // An object containing just the sticky key is empty.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -369,7 +371,7 @@ export interface StateEvents {
|
||||
// MSC3401
|
||||
[EventType.GroupCallPrefix]: IGroupCallRoomState;
|
||||
[EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject;
|
||||
|
||||
[EventType.RTCMembership]: RtcMembershipData | EmptyObject;
|
||||
// MSC3089
|
||||
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
|
||||
|
||||
|
||||
+31
-17
@@ -20,6 +20,7 @@ import { type IEventWithRoomId, type SearchKey } from "./search.ts";
|
||||
import { type IRoomEventFilter } from "../filter.ts";
|
||||
import { type Direction } from "../models/event-timeline.ts";
|
||||
import { type PushRuleAction } from "./PushRules.ts";
|
||||
import { type MatrixError } from "../matrix.ts";
|
||||
import { type IRoomEvent } from "../sync-accumulator.ts";
|
||||
import { type EventType, type RelationType, type RoomType } from "./event.ts";
|
||||
|
||||
@@ -34,6 +35,7 @@ export interface IJoinRoomOpts {
|
||||
|
||||
/**
|
||||
* The server names to try and join through in addition to those that are automatically chosen.
|
||||
* Only the first 3 are actually used in the request, to avoid HTTP 414 Request-URI Too Long responses.
|
||||
*/
|
||||
viaServers?: string[];
|
||||
|
||||
@@ -71,6 +73,7 @@ export interface KnockRoomOpts {
|
||||
|
||||
/**
|
||||
* The server names to try and knock through in addition to those that are automatically chosen.
|
||||
* Only the first 3 are actually used in the request, to avoid HTTP 414 Request-URI Too Long responses.
|
||||
*/
|
||||
viaServers?: string | string[];
|
||||
}
|
||||
@@ -94,19 +97,20 @@ export interface ISendEventResponse {
|
||||
event_id: string;
|
||||
}
|
||||
|
||||
export type TimeoutDelay = {
|
||||
delay: number;
|
||||
};
|
||||
|
||||
export type ParentDelayId = {
|
||||
parent_delay_id: string;
|
||||
};
|
||||
|
||||
export type SendTimeoutDelayedEventRequestOpts = TimeoutDelay & Partial<ParentDelayId>;
|
||||
export type SendActionDelayedEventRequestOpts = ParentDelayId;
|
||||
|
||||
export type SendDelayedEventRequestOpts = SendTimeoutDelayedEventRequestOpts | SendActionDelayedEventRequestOpts;
|
||||
export type SendDelayedEventRequestOpts = { parent_delay_id: string } | { delay: number; parent_delay_id?: string };
|
||||
|
||||
export function isSendDelayedEventRequestOpts(opts: object): opts is SendDelayedEventRequestOpts {
|
||||
if ("parent_delay_id" in opts && typeof opts.parent_delay_id !== "string") {
|
||||
// Invalid type, reject
|
||||
return false;
|
||||
}
|
||||
if ("delay" in opts && typeof opts.delay !== "number") {
|
||||
// Invalid type, reject.
|
||||
return true;
|
||||
}
|
||||
// At least one of these fields must be specified.
|
||||
return "delay" in opts || "parent_delay_id" in opts;
|
||||
}
|
||||
export type SendDelayedEventResponse = {
|
||||
delay_id: string;
|
||||
};
|
||||
@@ -133,12 +137,22 @@ type DelayedPartialStateEvent = DelayedPartialTimelineEvent & {
|
||||
|
||||
type DelayedPartialEvent = DelayedPartialTimelineEvent | DelayedPartialStateEvent;
|
||||
|
||||
export type DelayedEventInfoItem = DelayedPartialEvent &
|
||||
SendDelayedEventResponse &
|
||||
SendDelayedEventRequestOpts & {
|
||||
running_since: number;
|
||||
};
|
||||
|
||||
export type DelayedEventInfo = {
|
||||
delayed_events: (DelayedPartialEvent &
|
||||
SendDelayedEventResponse &
|
||||
SendDelayedEventRequestOpts & {
|
||||
running_since: number;
|
||||
})[];
|
||||
scheduled?: DelayedEventInfoItem[];
|
||||
finalised?: {
|
||||
delayed_event: DelayedEventInfoItem;
|
||||
outcome: "send" | "cancel";
|
||||
reason: "error" | "action" | "delay";
|
||||
error?: MatrixError["data"];
|
||||
event_id?: string;
|
||||
origin_server_ts?: number;
|
||||
}[];
|
||||
next_batch?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -105,9 +105,10 @@ export interface RoomPinnedEventsEventContent {
|
||||
}
|
||||
|
||||
export interface RoomEncryptionEventContent {
|
||||
algorithm: "m.megolm.v1.aes-sha2";
|
||||
rotation_period_ms?: number;
|
||||
rotation_period_msgs?: number;
|
||||
"algorithm": "m.megolm.v1.aes-sha2";
|
||||
"io.element.msc3414.encrypt_state_events"?: boolean;
|
||||
"rotation_period_ms"?: number;
|
||||
"rotation_period_msgs"?: number;
|
||||
}
|
||||
|
||||
export interface RoomHistoryVisibilityEventContent {
|
||||
|
||||
+262
-71
@@ -105,6 +105,7 @@ import {
|
||||
import { RoomMemberEvent, type RoomMemberEventHandlerMap } from "./models/room-member.ts";
|
||||
import { type IPowerLevelsContent, type RoomStateEvent, type RoomStateEventHandlerMap } from "./models/room-state.ts";
|
||||
import {
|
||||
isSendDelayedEventRequestOpts,
|
||||
type DelayedEventInfo,
|
||||
type IAddThreePidOnlyBody,
|
||||
type IBindThreePidBody,
|
||||
@@ -246,7 +247,7 @@ import {
|
||||
validateAuthMetadataAndKeys,
|
||||
} from "./oidc/index.ts";
|
||||
import { type EmptyObject } from "./@types/common.ts";
|
||||
import { UnsupportedDelayedEventsEndpointError } from "./errors.ts";
|
||||
import { UnsupportedDelayedEventsEndpointError, UnsupportedStickyEventsEndpointError } from "./errors.ts";
|
||||
|
||||
export type Store = IStore;
|
||||
|
||||
@@ -425,6 +426,11 @@ export interface ICreateClientOpts {
|
||||
*/
|
||||
cryptoCallbacks?: CryptoCallbacks;
|
||||
|
||||
/**
|
||||
* Enable encrypted state events.
|
||||
*/
|
||||
enableEncryptedStateEvents?: boolean;
|
||||
|
||||
/**
|
||||
* Method to generate room names for empty rooms and rooms names based on membership.
|
||||
* Defaults to a built-in English handler with basic pluralisation.
|
||||
@@ -438,6 +444,12 @@ export interface ICreateClientOpts {
|
||||
*/
|
||||
isVoipWithNoMediaAllowed?: boolean;
|
||||
|
||||
/**
|
||||
* Disable VoIP support (prevents fetching TURN servers, etc.)
|
||||
* Default: false (VoIP enabled)
|
||||
*/
|
||||
disableVoip?: boolean;
|
||||
|
||||
/**
|
||||
* If true, group calls will not establish media connectivity and only create the signaling events,
|
||||
* so that livekit media can be used in the application layer (js-sdk contains no livekit code).
|
||||
@@ -540,6 +552,7 @@ export const UNSTABLE_MSC2666_MUTUAL_ROOMS = "uk.half-shot.msc2666.mutual_rooms"
|
||||
export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_mutual_rooms";
|
||||
|
||||
export const UNSTABLE_MSC4140_DELAYED_EVENTS = "org.matrix.msc4140";
|
||||
export const UNSTABLE_MSC4354_STICKY_EVENTS = "org.matrix.msc4354";
|
||||
|
||||
export const UNSTABLE_MSC4133_EXTENDED_PROFILES = "uk.tcpip.msc4133";
|
||||
export const STABLE_MSC4133_EXTENDED_PROFILES = "uk.tcpip.msc4133.stable";
|
||||
@@ -1205,6 +1218,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public http: MatrixHttpApi<IHttpOpts & { onlyData: true }>; // XXX: Intended private, used in code.
|
||||
|
||||
private cryptoBackend?: CryptoBackend; // one of crypto or rustCrypto
|
||||
private readonly enableEncryptedStateEvents: boolean;
|
||||
public cryptoCallbacks: CryptoCallbacks; // XXX: Intended private, used in code.
|
||||
public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code.
|
||||
public groupCallEventHandler?: GroupCallEventHandler;
|
||||
@@ -1214,6 +1228,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public idBaseUrl?: string;
|
||||
public baseUrl: string;
|
||||
public readonly isVoipWithNoMediaAllowed;
|
||||
public disableVoip: boolean;
|
||||
|
||||
public useLivekitForGroupCalls: boolean;
|
||||
|
||||
@@ -1340,7 +1355,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
});
|
||||
}
|
||||
|
||||
if (supportsMatrixCall()) {
|
||||
this.disableVoip = opts.disableVoip ?? false;
|
||||
|
||||
if (!this.disableVoip && supportsMatrixCall()) {
|
||||
this.callEventHandler = new CallEventHandler(this);
|
||||
this.groupCallEventHandler = new GroupCallEventHandler(this);
|
||||
this.canSupportVoip = true;
|
||||
@@ -1363,6 +1380,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.legacyCryptoStore = opts.cryptoStore;
|
||||
this.verificationMethods = opts.verificationMethods;
|
||||
this.cryptoCallbacks = opts.cryptoCallbacks || {};
|
||||
this.enableEncryptedStateEvents = opts.enableEncryptedStateEvents ?? false;
|
||||
|
||||
this.forceTURN = opts.forceTURN || false;
|
||||
this.iceCandidatePoolSize = opts.iceCandidatePoolSize === undefined ? 0 : opts.iceCandidatePoolSize;
|
||||
@@ -1426,7 +1444,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
// periodically poll for turn servers if we support voip
|
||||
if (this.canSupportVoip) {
|
||||
if (this.supportsVoip()) {
|
||||
this.checkTurnServersIntervalID = setInterval(() => {
|
||||
this.checkTurnServers();
|
||||
}, TURN_CHECK_INTERVAL);
|
||||
@@ -1663,7 +1681,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns True if VoIP is supported.
|
||||
*/
|
||||
public supportsVoip(): boolean {
|
||||
return this.canSupportVoip;
|
||||
return !this.disableVoip && this.canSupportVoip;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1979,6 +1997,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
legacyMigrationProgressListener: (progress: number, total: number): void => {
|
||||
this.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, progress, total);
|
||||
},
|
||||
|
||||
enableEncryptedStateEvents: this.enableEncryptedStateEvents,
|
||||
});
|
||||
|
||||
rustCrypto.setSupportedVerificationMethods(this.verificationMethods);
|
||||
@@ -2373,8 +2393,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const queryParams: QueryDict = {};
|
||||
if (opts.viaServers) {
|
||||
// server_name has been deprecated in favour of via with Matrix >1.11 (MSC4156)
|
||||
queryParams.server_name = opts.viaServers;
|
||||
queryParams.via = opts.viaServers;
|
||||
// We only use the first 3 servers, to avoid URI length issues.
|
||||
queryParams.via = queryParams.server_name = opts.viaServers.slice(0, 3);
|
||||
}
|
||||
|
||||
const data: IJoinRequestBody = {};
|
||||
@@ -2418,9 +2438,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
const queryParams: QueryDict = {};
|
||||
if (opts.viaServers) {
|
||||
// We only use the first 3 servers, to avoid URI length issues.
|
||||
const viaServers = Array.isArray(opts.viaServers) ? opts.viaServers.slice(0, 3) : [opts.viaServers];
|
||||
// server_name has been deprecated in favour of via with Matrix >1.11 (MSC4156)
|
||||
queryParams.server_name = opts.viaServers;
|
||||
queryParams.via = opts.viaServers;
|
||||
queryParams.server_name = viaServers;
|
||||
queryParams.via = viaServers;
|
||||
}
|
||||
|
||||
const body: Record<string, string> = {};
|
||||
@@ -2663,7 +2685,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
this.addThreadRelationIfNeeded(content, threadId, roomId);
|
||||
return this.sendCompleteEvent(roomId, threadId, { type: eventType, content }, txnId);
|
||||
return this.sendCompleteEvent({ roomId, threadId, eventObject: { type: eventType, content }, txnId });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2700,12 +2722,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: to an empty object `{}`
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
private sendCompleteEvent(
|
||||
roomId: string,
|
||||
threadId: string | null,
|
||||
eventObject: Partial<IEvent>,
|
||||
txnId?: string,
|
||||
): Promise<ISendEventResponse>;
|
||||
private sendCompleteEvent(params: {
|
||||
roomId: string;
|
||||
threadId: string | null;
|
||||
eventObject: Partial<IEvent>;
|
||||
queryDict?: QueryDict;
|
||||
txnId?: string;
|
||||
}): Promise<ISendEventResponse>;
|
||||
/**
|
||||
* Sends a delayed event (MSC4140).
|
||||
* @param eventObject - An object with the partial structure of an event, to which event_id, user_id, room_id and origin_server_ts will be added.
|
||||
@@ -2714,29 +2737,29 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: to an empty object `{}`
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
private sendCompleteEvent(
|
||||
roomId: string,
|
||||
threadId: string | null,
|
||||
eventObject: Partial<IEvent>,
|
||||
delayOpts: SendDelayedEventRequestOpts,
|
||||
txnId?: string,
|
||||
): Promise<SendDelayedEventResponse>;
|
||||
private sendCompleteEvent(
|
||||
roomId: string,
|
||||
threadId: string | null,
|
||||
eventObject: Partial<IEvent>,
|
||||
delayOptsOrTxnId?: SendDelayedEventRequestOpts | string,
|
||||
txnIdOrVoid?: string,
|
||||
): Promise<ISendEventResponse | SendDelayedEventResponse> {
|
||||
let delayOpts: SendDelayedEventRequestOpts | undefined;
|
||||
let txnId: string | undefined;
|
||||
if (typeof delayOptsOrTxnId === "string") {
|
||||
txnId = delayOptsOrTxnId;
|
||||
} else {
|
||||
delayOpts = delayOptsOrTxnId;
|
||||
txnId = txnIdOrVoid;
|
||||
}
|
||||
|
||||
private sendCompleteEvent(params: {
|
||||
roomId: string;
|
||||
threadId: string | null;
|
||||
eventObject: Partial<IEvent>;
|
||||
delayOpts: SendDelayedEventRequestOpts;
|
||||
queryDict?: QueryDict;
|
||||
txnId?: string;
|
||||
}): Promise<SendDelayedEventResponse>;
|
||||
private sendCompleteEvent({
|
||||
roomId,
|
||||
threadId,
|
||||
eventObject,
|
||||
delayOpts,
|
||||
queryDict,
|
||||
txnId,
|
||||
}: {
|
||||
roomId: string;
|
||||
threadId: string | null;
|
||||
eventObject: Partial<IEvent>;
|
||||
delayOpts?: SendDelayedEventRequestOpts;
|
||||
queryDict?: QueryDict;
|
||||
txnId?: string;
|
||||
}): Promise<SendDelayedEventResponse | ISendEventResponse> {
|
||||
if (!txnId) {
|
||||
txnId = this.makeTxnId();
|
||||
}
|
||||
@@ -2779,7 +2802,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
const type = localEvent.getType();
|
||||
this.logger.debug(
|
||||
`sendEvent of type ${type} in ${roomId} with txnId ${txnId}${delayOpts ? " (delayed event)" : ""}`,
|
||||
`sendEvent of type ${type} in ${roomId} with txnId ${txnId}${delayOpts ? " (delayed event)" : ""}${queryDict ? " query params: " + JSON.stringify(queryDict) : ""}`,
|
||||
);
|
||||
|
||||
localEvent.setTxnId(txnId);
|
||||
@@ -2797,9 +2820,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return Promise.reject(new Error("Event blocked by other events not yet sent"));
|
||||
}
|
||||
|
||||
return this.encryptAndSendEvent(room, localEvent);
|
||||
return this.encryptAndSendEvent(room, localEvent, queryDict);
|
||||
} else {
|
||||
return this.encryptAndSendEvent(room, localEvent, delayOpts);
|
||||
return this.encryptAndSendEvent(room, localEvent, delayOpts, queryDict);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2807,7 +2830,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* encrypts the event if necessary; adds the event to the queue, or sends it; marks the event as sent/unsent
|
||||
* @returns returns a promise which resolves with the result of the send request
|
||||
*/
|
||||
protected async encryptAndSendEvent(room: Room | null, event: MatrixEvent): Promise<ISendEventResponse>;
|
||||
protected async encryptAndSendEvent(
|
||||
room: Room | null,
|
||||
event: MatrixEvent,
|
||||
queryDict?: QueryDict,
|
||||
): Promise<ISendEventResponse>;
|
||||
/**
|
||||
* Simply sends a delayed event without encrypting it.
|
||||
* TODO: Allow encrypted delayed events, and encrypt them properly
|
||||
@@ -2818,16 +2845,20 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
room: Room | null,
|
||||
event: MatrixEvent,
|
||||
delayOpts: SendDelayedEventRequestOpts,
|
||||
): Promise<SendDelayedEventResponse>;
|
||||
queryDict?: QueryDict,
|
||||
): Promise<ISendEventResponse>;
|
||||
protected async encryptAndSendEvent(
|
||||
room: Room | null,
|
||||
event: MatrixEvent,
|
||||
delayOpts?: SendDelayedEventRequestOpts,
|
||||
delayOptsOrQuery?: SendDelayedEventRequestOpts | QueryDict,
|
||||
queryDict?: QueryDict,
|
||||
): Promise<ISendEventResponse | SendDelayedEventResponse> {
|
||||
if (delayOpts) {
|
||||
return this.sendEventHttpRequest(event, delayOpts);
|
||||
let queryOpts = queryDict;
|
||||
if (delayOptsOrQuery && isSendDelayedEventRequestOpts(delayOptsOrQuery)) {
|
||||
return this.sendEventHttpRequest(event, delayOptsOrQuery, queryOpts);
|
||||
} else if (!queryOpts) {
|
||||
queryOpts = delayOptsOrQuery;
|
||||
}
|
||||
|
||||
try {
|
||||
let cancelled: boolean;
|
||||
this.eventsBeingEncrypted.add(event.getId()!);
|
||||
@@ -2863,7 +2894,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
if (!promise) {
|
||||
promise = this.sendEventHttpRequest(event);
|
||||
promise = this.sendEventHttpRequest(event, queryOpts);
|
||||
if (room) {
|
||||
promise = promise.then((res) => {
|
||||
room.updatePendingEvent(event, EventStatus.SENT, res["event_id"]);
|
||||
@@ -2978,14 +3009,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
}
|
||||
|
||||
private sendEventHttpRequest(event: MatrixEvent): Promise<ISendEventResponse>;
|
||||
private sendEventHttpRequest(event: MatrixEvent, queryDict?: QueryDict): Promise<ISendEventResponse>;
|
||||
private sendEventHttpRequest(
|
||||
event: MatrixEvent,
|
||||
delayOpts: SendDelayedEventRequestOpts,
|
||||
queryDict?: QueryDict,
|
||||
): Promise<SendDelayedEventResponse>;
|
||||
private sendEventHttpRequest(
|
||||
event: MatrixEvent,
|
||||
delayOpts?: SendDelayedEventRequestOpts,
|
||||
queryOrDelayOpts?: SendDelayedEventRequestOpts | QueryDict,
|
||||
queryDict?: QueryDict,
|
||||
): Promise<ISendEventResponse | SendDelayedEventResponse> {
|
||||
let txnId = event.getTxnId();
|
||||
if (!txnId) {
|
||||
@@ -3018,19 +3051,22 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
path = utils.encodeUri("/rooms/$roomId/send/$eventType/$txnId", pathParams);
|
||||
}
|
||||
|
||||
const delayOpts =
|
||||
queryOrDelayOpts && isSendDelayedEventRequestOpts(queryOrDelayOpts) ? queryOrDelayOpts : undefined;
|
||||
const queryOpts = !delayOpts ? queryOrDelayOpts : queryDict;
|
||||
const content = event.getWireContent();
|
||||
if (!delayOpts) {
|
||||
return this.http.authedRequest<ISendEventResponse>(Method.Put, path, undefined, content).then((res) => {
|
||||
this.logger.debug(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`);
|
||||
return res;
|
||||
});
|
||||
} else {
|
||||
if (delayOpts) {
|
||||
return this.http.authedRequest<SendDelayedEventResponse>(
|
||||
Method.Put,
|
||||
path,
|
||||
getUnstableDelayQueryOpts(delayOpts),
|
||||
{ ...getUnstableDelayQueryOpts(delayOpts), ...queryOpts },
|
||||
content,
|
||||
);
|
||||
} else {
|
||||
return this.http.authedRequest<ISendEventResponse>(Method.Put, path, queryOpts, content).then((res) => {
|
||||
this.logger.debug(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3087,16 +3123,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
content[withRelTypesPropName] = opts.with_rel_types;
|
||||
}
|
||||
|
||||
return this.sendCompleteEvent(
|
||||
return this.sendCompleteEvent({
|
||||
roomId,
|
||||
threadId,
|
||||
{
|
||||
eventObject: {
|
||||
type: EventType.RoomRedaction,
|
||||
content,
|
||||
redacts: eventId,
|
||||
},
|
||||
txnId as string,
|
||||
);
|
||||
txnId: txnId as string,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3384,7 +3420,54 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
this.addThreadRelationIfNeeded(content, threadId, roomId);
|
||||
return this.sendCompleteEvent(roomId, threadId, { type: eventType, content }, delayOpts, txnId);
|
||||
return this.sendCompleteEvent({
|
||||
roomId,
|
||||
threadId,
|
||||
eventObject: { type: eventType, content },
|
||||
delayOpts,
|
||||
txnId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a delayed sticky timeline event.
|
||||
*
|
||||
* Note: This endpoint is unstable, and can throw an `Error`.
|
||||
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) and
|
||||
* [MSC4354](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) for more details.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public async _unstable_sendStickyDelayedEvent<K extends keyof TimelineEvents>(
|
||||
roomId: string,
|
||||
stickDuration: number,
|
||||
delayOpts: SendDelayedEventRequestOpts,
|
||||
threadId: string | null,
|
||||
eventType: K,
|
||||
content: TimelineEvents[K] & { msc4354_sticky_key: string },
|
||||
txnId?: string,
|
||||
): Promise<SendDelayedEventResponse> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"getDelayedEvents",
|
||||
);
|
||||
}
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS))) {
|
||||
throw new UnsupportedStickyEventsEndpointError(
|
||||
"Server does not support the sticky events",
|
||||
"sendStickyEvent",
|
||||
);
|
||||
}
|
||||
|
||||
this.addThreadRelationIfNeeded(content, threadId, roomId);
|
||||
return this.sendCompleteEvent({
|
||||
roomId,
|
||||
threadId,
|
||||
eventObject: { type: eventType, content },
|
||||
queryDict: { "org.matrix.msc4354.sticky_duration_ms": stickDuration },
|
||||
delayOpts,
|
||||
txnId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3422,13 +3505,49 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending delayed events for the calling user.
|
||||
* Send a sticky timeline event.
|
||||
*
|
||||
* Note: This endpoint is unstable, and can throw an `Error`.
|
||||
* Check progress on [MSC4354](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) for more details.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public async _unstable_sendStickyEvent<K extends keyof TimelineEvents>(
|
||||
roomId: string,
|
||||
stickDuration: number,
|
||||
threadId: string | null,
|
||||
eventType: K,
|
||||
content: TimelineEvents[K] & { msc4354_sticky_key: string },
|
||||
txnId?: string,
|
||||
): Promise<ISendEventResponse> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS))) {
|
||||
throw new UnsupportedStickyEventsEndpointError(
|
||||
"Server does not support the sticky events",
|
||||
"sendStickyEvent",
|
||||
);
|
||||
}
|
||||
|
||||
this.addThreadRelationIfNeeded(content, threadId, roomId);
|
||||
return this.sendCompleteEvent({
|
||||
roomId,
|
||||
threadId,
|
||||
eventObject: { type: eventType, content },
|
||||
queryDict: { "org.matrix.msc4354.sticky_duration_ms": stickDuration },
|
||||
txnId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about delayed events owned by the requesting user.
|
||||
*
|
||||
* Note: This endpoint is unstable, and can throw an `Error`.
|
||||
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public async _unstable_getDelayedEvents(fromToken?: string): Promise<DelayedEventInfo> {
|
||||
public async _unstable_getDelayedEvents(
|
||||
status?: "scheduled" | "finalised",
|
||||
delayId?: string | string[],
|
||||
fromToken?: string,
|
||||
): Promise<DelayedEventInfo> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
@@ -3436,7 +3555,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
);
|
||||
}
|
||||
|
||||
const queryDict = fromToken ? { from: fromToken } : undefined;
|
||||
const queryDict = {
|
||||
from: fromToken,
|
||||
status,
|
||||
delay_id: delayId,
|
||||
};
|
||||
return await this.http.authedRequest(Method.Get, "/delayed_events", queryDict, undefined, {
|
||||
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
|
||||
});
|
||||
@@ -5613,7 +5736,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
// XXX: Intended private, used in code.
|
||||
public async checkTurnServers(): Promise<boolean | undefined> {
|
||||
if (!this.canSupportVoip) {
|
||||
if (!this.supportsVoip()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6034,6 +6157,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns A decryption promise
|
||||
*/
|
||||
public decryptEventIfNeeded(event: MatrixEvent, options?: IDecryptOptions): Promise<void> {
|
||||
if (event.isState() && !this.enableEncryptedStateEvents) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (event.shouldAttemptDecryption() && this.getCrypto()) {
|
||||
event.attemptDecryption(this.cryptoBackend!, options);
|
||||
}
|
||||
@@ -6618,23 +6745,83 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: TODO
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public sendStateEvent<K extends keyof StateEvents>(
|
||||
public async sendStateEvent<K extends keyof StateEvents>(
|
||||
roomId: string,
|
||||
eventType: K,
|
||||
content: StateEvents[K],
|
||||
stateKey = "",
|
||||
opts: IRequestOpts = {},
|
||||
): Promise<ISendEventResponse> {
|
||||
const room = this.getRoom(roomId);
|
||||
const event = new MatrixEvent({
|
||||
room_id: roomId,
|
||||
type: eventType,
|
||||
state_key: stateKey,
|
||||
// Cast safety: StateEvents[K] is a stronger bound than IContent, which has [key: string]: any
|
||||
content: content as IContent,
|
||||
});
|
||||
|
||||
await this.encryptStateEventIfNeeded(event, room ?? undefined);
|
||||
|
||||
const pathParams = {
|
||||
$roomId: roomId,
|
||||
$eventType: eventType,
|
||||
$stateKey: stateKey,
|
||||
$eventType: event.getWireType(),
|
||||
$stateKey: event.getWireStateKey(),
|
||||
};
|
||||
let path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams);
|
||||
if (stateKey !== undefined) {
|
||||
path = utils.encodeUri(path + "/$stateKey", pathParams);
|
||||
}
|
||||
return this.http.authedRequest(Method.Put, path, undefined, content as Body, opts);
|
||||
return this.http.authedRequest(Method.Put, path, undefined, event.getWireContent(), opts);
|
||||
}
|
||||
|
||||
private async encryptStateEventIfNeeded(event: MatrixEvent, room?: Room): Promise<void> {
|
||||
if (!this.enableEncryptedStateEvents) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the room is unknown, we cannot encrypt for it
|
||||
if (!room) return;
|
||||
|
||||
if (!this.cryptoBackend && this.usingExternalCrypto) {
|
||||
// The client has opted to allow sending messages to encrypted
|
||||
// rooms even if the room is encrypted, and we haven't set up
|
||||
// crypto. This is useful for users of matrix-org/pantalaimon
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("This room is configured to use encryption, but your client does not support encryption.");
|
||||
}
|
||||
|
||||
// Check regular encryption conditions.
|
||||
if (!(await this.shouldEncryptEventForRoom(event, room))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the crypto impl thinks we shouldn't encrypt, then we shouldn't.
|
||||
// Safety: we checked the crypto impl exists above.
|
||||
if (!(await this.cryptoBackend!.isStateEncryptionEnabledInRoom(room.roomId))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the event is excluded under MSC3414
|
||||
if (
|
||||
[
|
||||
"m.room.create",
|
||||
"m.room.member",
|
||||
"m.room.join_rules",
|
||||
"m.room.power_levels",
|
||||
"m.room.third_party_invite",
|
||||
"m.room.history_visibility",
|
||||
"m.room.guest_access",
|
||||
"m.room.encryption",
|
||||
].includes(event.getType())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.cryptoBackend.encryptEvent(event, room);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6898,6 +7085,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*/
|
||||
public async doesServerSupportExtendedProfiles(): Promise<boolean> {
|
||||
return (
|
||||
(await this.isVersionSupported("v1.16")) ||
|
||||
(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4133_EXTENDED_PROFILES)) ||
|
||||
(await this.doesServerSupportUnstableFeature(STABLE_MSC4133_EXTENDED_PROFILES))
|
||||
);
|
||||
@@ -6909,7 +7097,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns The prefix for use with `authedRequest`
|
||||
*/
|
||||
private async getExtendedProfileRequestPrefix(): Promise<string> {
|
||||
if (await this.doesServerSupportUnstableFeature("uk.tcpip.msc4133.stable")) {
|
||||
if (
|
||||
(await this.isVersionSupported("v1.16")) ||
|
||||
(await this.doesServerSupportUnstableFeature("uk.tcpip.msc4133.stable"))
|
||||
) {
|
||||
return ClientPrefix.V3;
|
||||
}
|
||||
return "/_matrix/client/unstable/uk.tcpip.msc4133";
|
||||
|
||||
+35
-1
@@ -20,7 +20,7 @@ import type { ToDeviceBatch, ToDevicePayload } from "../models/ToDeviceMessage.t
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type DeviceMap } from "../models/device.ts";
|
||||
import { type UIAuthCallback } from "../interactive-auth.ts";
|
||||
import { type PassphraseInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
|
||||
import { type PassphraseInfo, type SecretStorageKey, type SecretStorageKeyDescription } from "../secret-storage.ts";
|
||||
import { type VerificationRequest } from "./verification.ts";
|
||||
import {
|
||||
type BackupTrustInfo,
|
||||
@@ -118,6 +118,11 @@ export interface CryptoApi {
|
||||
*/
|
||||
isEncryptionEnabledInRoom(roomId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Check if we believe the given room supports encrypted state events.
|
||||
*/
|
||||
isStateEncryptionEnabledInRoom(roomId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Perform any background tasks that can be done before a message is ready to
|
||||
* send, in order to speed up sending of the message.
|
||||
@@ -364,6 +369,11 @@ export interface CryptoApi {
|
||||
*/
|
||||
isSecretStorageReady(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Inspect the status of secret storage, in more detail than {@link isSecretStorageReady}.
|
||||
*/
|
||||
getSecretStorageStatus(): Promise<SecretStorageStatus>;
|
||||
|
||||
/**
|
||||
* Bootstrap [secret storage](https://spec.matrix.org/v1.12/client-server-api/#storage).
|
||||
*
|
||||
@@ -1143,6 +1153,30 @@ export interface CryptoCallbacks {
|
||||
cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of a call to {@link CryptoApi.getSecretStorageStatus}.
|
||||
*/
|
||||
export interface SecretStorageStatus {
|
||||
/** Whether secret storage is fully populated. The same as {@link CryptoApi.isSecretStorageReady}. */
|
||||
ready: boolean;
|
||||
|
||||
/** The ID of the current default secret storage key. */
|
||||
defaultKeyId: string | null;
|
||||
|
||||
/**
|
||||
* For each secret that we checked whether it is correctly stored in secret storage with the default secret storage key.
|
||||
*
|
||||
* Note that we will only check that the key backup key is stored if key backup is currently enabled (i.e. that
|
||||
* {@link CryptoApi.getActiveSessionBackupVersion} returns non-null). `m.megolm_backup.v1` will only be present in that case.
|
||||
*
|
||||
* (This is an object rather than a `Map` so that it JSON.stringify()s nicely, since its main purpose is to end up
|
||||
* in logs.)
|
||||
*/
|
||||
secretStorageKeyValidityMap: {
|
||||
[P in SecretStorageKey]?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter of {@link CryptoApi#bootstrapSecretStorage}
|
||||
*/
|
||||
|
||||
+14
-1
@@ -54,7 +54,7 @@ export class ClientStoppedError extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* This error is thrown when the Homeserver does not support the delayed events enpdpoints.
|
||||
* This error is thrown when the Homeserver does not support the delayed events endpoints.
|
||||
*/
|
||||
export class UnsupportedDelayedEventsEndpointError extends Error {
|
||||
public constructor(
|
||||
@@ -65,3 +65,16 @@ export class UnsupportedDelayedEventsEndpointError extends Error {
|
||||
this.name = "UnsupportedDelayedEventsEndpointError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This error is thrown when the Homeserver does not support the sticky events endpoints.
|
||||
*/
|
||||
export class UnsupportedStickyEventsEndpointError extends Error {
|
||||
public constructor(
|
||||
message: string,
|
||||
public clientEndpoint: "sendStickyEvent" | "sendStickyStateEvent",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "UnsupportedStickyEventsEndpointError";
|
||||
}
|
||||
}
|
||||
|
||||
+372
-66
@@ -14,11 +14,14 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type MatrixEvent } from "../matrix.ts";
|
||||
import { MXID_PATTERN } from "../models/room-member.ts";
|
||||
import { deepCompare } from "../utils.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type SessionDescription } from "./MatrixRTCSession.ts";
|
||||
import { type LivekitFocusSelection } from "./LivekitTransport.ts";
|
||||
import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts";
|
||||
import type { RTCCallIntent, Transport } from "./types.ts";
|
||||
import { type IContent, type MatrixEvent } from "../models/event.ts";
|
||||
import { type RelationType } from "../@types/event.ts";
|
||||
import { logger } from "../logger.ts";
|
||||
|
||||
/**
|
||||
* The default duration in milliseconds that a membership is considered valid for.
|
||||
@@ -28,16 +31,116 @@ import { type SessionDescription } from "./MatrixRTCSession.ts";
|
||||
export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4;
|
||||
|
||||
type CallScope = "m.room" | "m.user";
|
||||
type Member = { user_id: string; device_id: string; id: string };
|
||||
|
||||
export interface RtcMembershipData {
|
||||
"slot_id": string;
|
||||
"member": Member;
|
||||
"m.relates_to"?: {
|
||||
event_id: string;
|
||||
rel_type: RelationType.Reference;
|
||||
};
|
||||
"application": {
|
||||
type: string;
|
||||
// other application specific keys
|
||||
[key: string]: unknown;
|
||||
};
|
||||
"rtc_transports": Transport[];
|
||||
"versions": string[];
|
||||
"msc4354_sticky_key"?: string;
|
||||
"sticky_key"?: string;
|
||||
}
|
||||
|
||||
const checkRtcMembershipData = (
|
||||
data: IContent,
|
||||
errors: string[],
|
||||
referenceUserId: string,
|
||||
): data is RtcMembershipData => {
|
||||
const prefix = " - ";
|
||||
|
||||
// required fields
|
||||
if (typeof data.slot_id !== "string") {
|
||||
errors.push(prefix + "slot_id must be string");
|
||||
} else {
|
||||
if (data.slot_id.split("#").length !== 2) errors.push(prefix + 'slot_id must include exactly one "#"');
|
||||
}
|
||||
if (typeof data.member !== "object" || data.member === null) {
|
||||
errors.push(prefix + "member must be an object");
|
||||
} else {
|
||||
if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string");
|
||||
else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid");
|
||||
// This is not what the spec enforces but there currently are no rules what power levels are required to
|
||||
// send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there
|
||||
// is a proper definition when this is allowed.
|
||||
else if (data.member.user_id !== referenceUserId) errors.push(prefix + "member.user_id must match the sender");
|
||||
if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string");
|
||||
if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string");
|
||||
}
|
||||
if (typeof data.application !== "object" || data.application === null) {
|
||||
errors.push(prefix + "application must be an object");
|
||||
} else {
|
||||
if (typeof data.application.type !== "string") {
|
||||
errors.push(prefix + "application.type must be a string");
|
||||
} else {
|
||||
if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"');
|
||||
}
|
||||
}
|
||||
if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) {
|
||||
errors.push(prefix + "rtc_transports must be an array");
|
||||
} else {
|
||||
// validate that each transport has at least a string 'type'
|
||||
for (const t of data.rtc_transports) {
|
||||
if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") {
|
||||
errors.push(prefix + "rtc_transports entries must be objects with a string type");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.versions === undefined || !Array.isArray(data.versions)) {
|
||||
errors.push(prefix + "versions must be an array");
|
||||
} else if (!data.versions.every((v) => typeof v === "string")) {
|
||||
errors.push(prefix + "versions must be an array of strings");
|
||||
}
|
||||
|
||||
// optional fields
|
||||
if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) {
|
||||
errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined");
|
||||
}
|
||||
if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") {
|
||||
errors.push(prefix + "sticky_key must be a string");
|
||||
}
|
||||
if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") {
|
||||
errors.push(prefix + "msc4354_sticky_key must be a string");
|
||||
}
|
||||
if (
|
||||
data.sticky_key !== undefined &&
|
||||
data.msc4354_sticky_key !== undefined &&
|
||||
data.sticky_key !== data.msc4354_sticky_key
|
||||
) {
|
||||
errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined");
|
||||
}
|
||||
if (data["m.relates_to"] !== undefined) {
|
||||
const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"];
|
||||
if (typeof rel !== "object" || rel === null) {
|
||||
errors.push(prefix + "m.relates_to must be an object if provided");
|
||||
} else {
|
||||
if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
|
||||
if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
|
||||
}
|
||||
}
|
||||
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* MSC4143 (MatrixRTC) session membership data.
|
||||
* Represents an entry in the memberships section of an m.call.member event as it is on the wire.
|
||||
* Represents the `session` in the memberships section of an m.call.member event as it is on the wire.
|
||||
**/
|
||||
export type SessionMembershipData = {
|
||||
/**
|
||||
* The RTC application defines the type of the RTC session.
|
||||
*/
|
||||
application: string;
|
||||
"application": string;
|
||||
|
||||
/**
|
||||
* The id of this session.
|
||||
@@ -45,23 +148,23 @@ export type SessionMembershipData = {
|
||||
* multiple session in one room. A room wide session that is not associated with a user,
|
||||
* and therefore immune to creation race conflicts, uses the `call_id: ""`.
|
||||
*/
|
||||
call_id: string;
|
||||
"call_id": string;
|
||||
|
||||
/**
|
||||
* The Matrix device ID of this session. A single user can have multiple sessions on different devices.
|
||||
*/
|
||||
device_id: string;
|
||||
"device_id": string;
|
||||
|
||||
/**
|
||||
* The focus selection system this user/membership is using.
|
||||
*/
|
||||
focus_active: Focus;
|
||||
"focus_active": LivekitFocusSelection;
|
||||
|
||||
/**
|
||||
* A list of possible foci this uses knows about. One of them might be used based on the focus_active
|
||||
* A list of possible foci this user knows about. One of them might be used based on the focus_active
|
||||
* selection system.
|
||||
*/
|
||||
foci_preferred: Focus[];
|
||||
"foci_preferred": Transport[];
|
||||
|
||||
/**
|
||||
* Optional field that contains the creation of the session. If it is undefined the creation
|
||||
@@ -70,138 +173,341 @@ export type SessionMembershipData = {
|
||||
* - If it is undefined it can be interpreted as a "Join".
|
||||
* - If it is defined it can be interpreted as an "Update"
|
||||
*/
|
||||
created_ts?: number;
|
||||
"created_ts"?: number;
|
||||
|
||||
// Application specific data
|
||||
|
||||
/**
|
||||
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
|
||||
* There can always be one room scroped call but multiple user owned calls (breakout sessions)
|
||||
* There can always be one room scoped call but multiple user owned calls (breakout sessions)
|
||||
*/
|
||||
scope?: CallScope;
|
||||
"scope"?: CallScope;
|
||||
|
||||
/**
|
||||
* Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid.
|
||||
* This should be set to multiple hours. The only reason it exist is to deal with failed delayed events.
|
||||
* (for example caused by a homeserver crashes)
|
||||
**/
|
||||
expires?: number;
|
||||
"expires"?: number;
|
||||
|
||||
/**
|
||||
* The intent of the call from the perspective of this user. This may be an audio call, video call or
|
||||
* something else.
|
||||
*/
|
||||
"m.call.intent"?: RTCCallIntent;
|
||||
/**
|
||||
* The sticky key in case of a sticky event. This string encodes the application + device_id indicating the used slot + device.
|
||||
*/
|
||||
"msc4354_sticky_key"?: string;
|
||||
};
|
||||
|
||||
const checkSessionsMembershipData = (data: any, errors: string[]): data is SessionMembershipData => {
|
||||
const prefix = "Malformed session membership event: ";
|
||||
const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => {
|
||||
const prefix = " - ";
|
||||
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
|
||||
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
|
||||
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
|
||||
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
|
||||
if (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array");
|
||||
if (data.focus_active === undefined) {
|
||||
errors.push(prefix + "focus_active has an invalid type");
|
||||
}
|
||||
if (
|
||||
data.foci_preferred !== undefined &&
|
||||
!(
|
||||
Array.isArray(data.foci_preferred) &&
|
||||
data.foci_preferred.every(
|
||||
(f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string",
|
||||
)
|
||||
)
|
||||
) {
|
||||
errors.push(prefix + "foci_preferred must be an array of transport objects");
|
||||
}
|
||||
// optional parameters
|
||||
if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number");
|
||||
if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
|
||||
errors.push(prefix + "created_ts must be number");
|
||||
}
|
||||
|
||||
// application specific data (we first need to check if they exist)
|
||||
if (data.scope && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
|
||||
if (data.scope !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
|
||||
|
||||
if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") {
|
||||
errors.push(prefix + "m.call.intent must be a string");
|
||||
}
|
||||
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData };
|
||||
// TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file.
|
||||
export class CallMembership {
|
||||
public static equal(a: CallMembership, b: CallMembership): boolean {
|
||||
return deepCompare(a.membershipData, b.membershipData);
|
||||
public static equal(a?: CallMembership, b?: CallMembership): boolean {
|
||||
return deepCompare(a?.membershipData, b?.membershipData);
|
||||
}
|
||||
private membershipData: SessionMembershipData;
|
||||
|
||||
private membershipData: MembershipData;
|
||||
|
||||
/** The parsed data from the Matrix event.
|
||||
* To access checked eventId and sender from the matrixEvent.
|
||||
* Class construction will fail if these values cannot get obtained. */
|
||||
private readonly matrixEventData: { eventId: string; sender: string };
|
||||
public constructor(
|
||||
private parentEvent: MatrixEvent,
|
||||
data: any,
|
||||
/** The Matrix event that this membership is based on */
|
||||
private readonly matrixEvent: MatrixEvent,
|
||||
data: IContent,
|
||||
) {
|
||||
const eventId = matrixEvent.getId();
|
||||
const sender = matrixEvent.getSender();
|
||||
|
||||
if (eventId === undefined) throw new Error("parentEvent is missing eventId field");
|
||||
if (sender === undefined) throw new Error("parentEvent is missing sender field");
|
||||
|
||||
const sessionErrors: string[] = [];
|
||||
if (!checkSessionsMembershipData(data, sessionErrors)) {
|
||||
throw Error(
|
||||
`unknown CallMembership data. Does not match MSC4143 call.member (${sessionErrors.join(" & ")}) events this could be a legacy membership event: (${data})`,
|
||||
);
|
||||
const rtcErrors: string[] = [];
|
||||
if (checkSessionsMembershipData(data, sessionErrors)) {
|
||||
this.membershipData = { kind: "session", data };
|
||||
} else if (checkRtcMembershipData(data, rtcErrors, sender)) {
|
||||
this.membershipData = { kind: "rtc", data };
|
||||
} else {
|
||||
this.membershipData = data;
|
||||
const details =
|
||||
sessionErrors.length < rtcErrors.length
|
||||
? `Does not match MSC4143 m.call.member:\n${sessionErrors.join("\n")}\n\n`
|
||||
: `Does not match MSC4143 m.rtc.member:\n${rtcErrors.join("\n")}\n\n`;
|
||||
const json = "\nevent:\n" + JSON.stringify(data).replaceAll('"', "'");
|
||||
throw Error(`unknown CallMembership data.\n` + details + json);
|
||||
}
|
||||
this.matrixEventData = { eventId, sender };
|
||||
}
|
||||
|
||||
/** @deprecated use userId instead */
|
||||
public get sender(): string {
|
||||
return this.userId;
|
||||
}
|
||||
public get userId(): string {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.member.user_id;
|
||||
case "session":
|
||||
default:
|
||||
return this.matrixEventData.sender;
|
||||
}
|
||||
}
|
||||
|
||||
public get sender(): string | undefined {
|
||||
return this.parentEvent.getSender();
|
||||
}
|
||||
|
||||
public get eventId(): string | undefined {
|
||||
return this.parentEvent.getId();
|
||||
public get eventId(): string {
|
||||
return this.matrixEventData.eventId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use sessionDescription.id instead.
|
||||
* The ID of the MatrixRTC slot that this membership belongs to (format `{application}#{id}`).
|
||||
* This is computed in case SessionMembershipData is used.
|
||||
*/
|
||||
public get callId(): string {
|
||||
return this.membershipData.call_id;
|
||||
public get slotId(): string {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.slot_id;
|
||||
case "session":
|
||||
default:
|
||||
return slotDescriptionToId({ application: this.application, id: data.call_id });
|
||||
}
|
||||
}
|
||||
|
||||
public get deviceId(): string {
|
||||
return this.membershipData.device_id;
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.member.device_id;
|
||||
case "session":
|
||||
default:
|
||||
return data.device_id;
|
||||
}
|
||||
}
|
||||
|
||||
public get sessionDescription(): SessionDescription {
|
||||
return {
|
||||
application: this.membershipData.application,
|
||||
id: this.membershipData.call_id,
|
||||
};
|
||||
public get callIntent(): RTCCallIntent | undefined {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc": {
|
||||
const intent = data.application["m.call.intent"];
|
||||
if (typeof intent === "string") {
|
||||
return intent;
|
||||
}
|
||||
logger.warn("RTC membership has invalid m.call.intent");
|
||||
return undefined;
|
||||
}
|
||||
case "session":
|
||||
default:
|
||||
return data["m.call.intent"];
|
||||
}
|
||||
}
|
||||
|
||||
public get application(): string | undefined {
|
||||
return this.membershipData.application;
|
||||
/**
|
||||
* Parsed `slot_id` (format `{application}#{id}`) into its components (application and id).
|
||||
*/
|
||||
public get slotDescription(): SlotDescription {
|
||||
return slotIdToDescription(this.slotId);
|
||||
}
|
||||
|
||||
public get application(): string {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.application.type;
|
||||
case "session":
|
||||
default:
|
||||
return data.application;
|
||||
}
|
||||
}
|
||||
public get applicationData(): { type: string; [key: string]: unknown } {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.application;
|
||||
case "session":
|
||||
default:
|
||||
return { "type": data.application, "m.call.intent": data["m.call.intent"] };
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated scope is not used and will be removed in future versions. replaced by application specific types.*/
|
||||
public get scope(): CallScope | undefined {
|
||||
return this.membershipData.scope;
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return undefined;
|
||||
case "session":
|
||||
default:
|
||||
return data.scope;
|
||||
}
|
||||
}
|
||||
|
||||
public get membershipID(): string {
|
||||
// the createdTs behaves equivalent to the membershipID.
|
||||
// we only need the field for the legacy member envents where we needed to update them
|
||||
// we only need the field for the legacy member events where we needed to update them
|
||||
// synapse ignores sending state events if they have the same content.
|
||||
return this.createdTs().toString();
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.member.id;
|
||||
case "session":
|
||||
default:
|
||||
return (this.createdTs() ?? "").toString();
|
||||
}
|
||||
}
|
||||
|
||||
public createdTs(): number {
|
||||
return this.membershipData.created_ts ?? this.parentEvent.getTs();
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
// TODO we need to read the referenced (relation) event if available to get the real created_ts
|
||||
return this.matrixEvent.getTs();
|
||||
case "session":
|
||||
default:
|
||||
return data.created_ts ?? this.matrixEvent.getTs();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the absolute expiry timestamp of the membership.
|
||||
* @returns The absolute expiry time of the membership as a unix timestamp in milliseconds or undefined if not applicable
|
||||
*/
|
||||
public getAbsoluteExpiry(): number {
|
||||
// TODO: calculate this from the MatrixRTCSession join configuration directly
|
||||
return this.createdTs() + (this.membershipData.expires ?? DEFAULT_EXPIRE_DURATION);
|
||||
public getAbsoluteExpiry(): number | undefined {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return undefined;
|
||||
case "session":
|
||||
default:
|
||||
// TODO: calculate this from the MatrixRTCSession join configuration directly
|
||||
return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The number of milliseconds until the membership expires or undefined if applicable
|
||||
*/
|
||||
public getMsUntilExpiry(): number {
|
||||
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
|
||||
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
|
||||
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
|
||||
return this.getAbsoluteExpiry() - Date.now();
|
||||
public getMsUntilExpiry(): number | undefined {
|
||||
const { kind } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return undefined;
|
||||
case "session":
|
||||
default:
|
||||
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
|
||||
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
|
||||
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
|
||||
return this.getAbsoluteExpiry()! - Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the membership has expired, otherwise false
|
||||
*/
|
||||
public isExpired(): boolean {
|
||||
return this.getMsUntilExpiry() <= 0;
|
||||
const { kind } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return false;
|
||||
case "session":
|
||||
default:
|
||||
return this.getMsUntilExpiry()! <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
public getPreferredFoci(): Focus[] {
|
||||
return this.membershipData.foci_preferred;
|
||||
/**
|
||||
* ## RTC Membership
|
||||
* Gets the primary transport to use for this RTC membership (m.rtc.member).
|
||||
* This will return the primary transport that is used by this call membership to publish their media.
|
||||
* Directly relates to the `rtc_transports` field.
|
||||
*
|
||||
* ## Legacy session membership
|
||||
* In case of a legacy session membership (m.call.member) this will return the selected transport where
|
||||
* media is published. How this selection happens depends on the `focus_active` field of the session membership.
|
||||
* If the `focus_selection` is `oldest_membership` this will return the transport of the oldest membership
|
||||
* in the room (based on the `created_ts` field of the session membership).
|
||||
* If the `focus_selection` is `multi_sfu` it will return the first transport of the `foci_preferred` list.
|
||||
* (`multi_sfu` is equivalent to how `m.rtc.member` `rtc_transports` work).
|
||||
* @param oldestMembership For backwards compatibility with session membership (legacy). Unused in case of RTC membership.
|
||||
* Always required to make the consumer not care if it deals with RTC or session memberships.
|
||||
* @returns The transport this membership uses to publish media or undefined if no transport is available.
|
||||
*/
|
||||
public getTransport(oldestMembership: CallMembership): Transport | undefined {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.rtc_transports[0];
|
||||
case "session":
|
||||
switch (data.focus_active.focus_selection) {
|
||||
case "multi_sfu":
|
||||
return data.foci_preferred[0];
|
||||
case "oldest_membership":
|
||||
if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0];
|
||||
if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public getFocusSelection(): string | undefined {
|
||||
const focusActive = this.membershipData.focus_active;
|
||||
if (isLivekitFocusActive(focusActive)) {
|
||||
return focusActive.focus_selection;
|
||||
/**
|
||||
* The focus_active filed of the session membership (m.call.member).
|
||||
* @deprecated focus_active is not used and will be removed in future versions.
|
||||
*/
|
||||
public getFocusActive(): LivekitFocusSelection | undefined {
|
||||
const { kind, data } = this.membershipData;
|
||||
if (kind === "session") return data.focus_active;
|
||||
return undefined;
|
||||
}
|
||||
/**
|
||||
* The value of the `rtc_transports` field for RTC memberships (m.rtc.member).
|
||||
* Or the value of the `foci_preferred` field for legacy session memberships (m.call.member).
|
||||
*/
|
||||
public get transports(): Transport[] {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.rtc_transports;
|
||||
case "session":
|
||||
default:
|
||||
return data.foci_preferred;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,6 @@ import { type CallMembership } from "./CallMembership.ts";
|
||||
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import { isMyMembership, type ParticipantId, type Statistics } from "./types.ts";
|
||||
import { getParticipantId } from "./utils.ts";
|
||||
import {
|
||||
type EnabledTransports,
|
||||
RoomAndToDeviceEvents,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
|
||||
/**
|
||||
* This interface is for testing and for making it possible to interchange the encryption manager.
|
||||
@@ -119,10 +114,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
|
||||
|
||||
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
// Deprecate RoomKeyTransport: this can get removed.
|
||||
if (this.transport instanceof RoomAndToDeviceTransport) {
|
||||
this.transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, this.onTransportChanged);
|
||||
}
|
||||
|
||||
this.transport.start();
|
||||
if (this.joinConfig?.manageMediaKeys) {
|
||||
this.makeNewSenderKey();
|
||||
@@ -315,10 +307,6 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
};
|
||||
|
||||
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
|
||||
this.requestSendCurrentKey();
|
||||
};
|
||||
|
||||
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
this.logger.debug(`Received key over key transport ${userId}:${deviceId} at index ${index}`);
|
||||
this.setEncryptionKey(userId, deviceId, index, keyBase64Encoded, timestamp);
|
||||
|
||||
@@ -15,8 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
import type { Focus } from "./focus.ts";
|
||||
import type { Status } from "./types.ts";
|
||||
import type { RTCCallIntent, Status, Transport } from "./types.ts";
|
||||
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
|
||||
export enum MembershipManagerEvent {
|
||||
@@ -80,10 +79,13 @@ export interface IMembershipManager
|
||||
/**
|
||||
* Start sending all necessary events to make this user participate in the RTC session.
|
||||
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||
* @param fociActive the active focus to use in the joined RTC membership event.
|
||||
* If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously.
|
||||
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
|
||||
* membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership`
|
||||
* transport selection will be used instead.
|
||||
* @throws can throw if it exceeds a configured maximum retry.
|
||||
*/
|
||||
join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void;
|
||||
join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void;
|
||||
/**
|
||||
* Send all necessary events to make this user leave the RTC session.
|
||||
* @param timeout the maximum duration in ms until the promise is forced to resolve.
|
||||
@@ -95,9 +97,10 @@ export interface IMembershipManager
|
||||
* Call this if the MatrixRTC session members have changed.
|
||||
*/
|
||||
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* The used active focus in the currently joined session.
|
||||
* @returns the used active focus in the currently joined session or undefined if not joined.
|
||||
* Update the intent of a membership on the call (e.g. user is now providing a video feed)
|
||||
* @param callIntent The new intent to set.
|
||||
*/
|
||||
getActiveFocus(): Focus | undefined;
|
||||
updateCallIntent(callIntent: RTCCallIntent): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
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 { type Focus } from "./focus.ts";
|
||||
|
||||
export interface LivekitFocusConfig extends Focus {
|
||||
type: "livekit";
|
||||
livekit_service_url: string;
|
||||
}
|
||||
|
||||
export const isLivekitFocusConfig = (object: any): object is LivekitFocusConfig =>
|
||||
object.type === "livekit" && "livekit_service_url" in object;
|
||||
|
||||
export interface LivekitFocus extends LivekitFocusConfig {
|
||||
livekit_alias: string;
|
||||
}
|
||||
|
||||
export const isLivekitFocus = (object: any): object is LivekitFocus =>
|
||||
isLivekitFocusConfig(object) && "livekit_alias" in object;
|
||||
|
||||
export interface LivekitFocusActive extends Focus {
|
||||
type: "livekit";
|
||||
focus_selection: "oldest_membership";
|
||||
}
|
||||
export const isLivekitFocusActive = (object: any): object is LivekitFocusActive =>
|
||||
object.type === "livekit" && "focus_selection" in object;
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd
|
||||
|
||||
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 { type Transport } from "./types.ts";
|
||||
|
||||
export interface LivekitTransportConfig extends Transport {
|
||||
type: "livekit";
|
||||
livekit_service_url: string;
|
||||
}
|
||||
|
||||
export const isLivekitTransportConfig = (object: any): object is LivekitTransportConfig =>
|
||||
object.type === "livekit" && "livekit_service_url" in object;
|
||||
|
||||
export interface LivekitTransport extends LivekitTransportConfig {
|
||||
livekit_alias: string;
|
||||
}
|
||||
|
||||
export const isLivekitTransport = (object: any): object is LivekitTransport =>
|
||||
isLivekitTransportConfig(object) && "livekit_alias" in object;
|
||||
|
||||
/**
|
||||
* @deprecated, this is just needed for the old focus active / focus fields of a call membership.
|
||||
* Not needed for new implementations.
|
||||
*/
|
||||
export interface LivekitFocusSelection extends Transport {
|
||||
type: "livekit";
|
||||
focus_selection: "oldest_membership" | "multi_sfu";
|
||||
}
|
||||
/**
|
||||
* @deprecated see LivekitFocusSelection
|
||||
*/
|
||||
export const isLivekitFocusSelection = (object: any): object is LivekitFocusSelection =>
|
||||
object.type === "livekit" && "focus_selection" in object;
|
||||
+248
-106
@@ -24,31 +24,29 @@ import { KnownMembership } from "../@types/membership.ts";
|
||||
import { type ISendEventResponse } from "../@types/requests.ts";
|
||||
import { CallMembership } from "./CallMembership.ts";
|
||||
import { RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { MembershipManager } from "./MembershipManager.ts";
|
||||
import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { deepCompare, logDurationSync } from "../utils.ts";
|
||||
import {
|
||||
type Statistics,
|
||||
type RTCNotificationType,
|
||||
type Status,
|
||||
type IRTCNotificationContent,
|
||||
type ICallNotifyContent,
|
||||
import type {
|
||||
Statistics,
|
||||
RTCNotificationType,
|
||||
Status,
|
||||
IRTCNotificationContent,
|
||||
ICallNotifyContent,
|
||||
RTCCallIntent,
|
||||
Transport,
|
||||
} from "./types.ts";
|
||||
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
type MembershipManagerEventHandlerMap,
|
||||
type IMembershipManager,
|
||||
} from "./IMembershipManager.ts";
|
||||
import { RTCEncryptionManager } from "./RTCEncryptionManager.ts";
|
||||
import {
|
||||
RoomAndToDeviceEvents,
|
||||
type RoomAndToDeviceEventsHandlerMap,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
import { TypedReEmitter } from "../ReEmitter.ts";
|
||||
import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
||||
import { TypedReEmitter } from "../ReEmitter.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts";
|
||||
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
|
||||
/**
|
||||
* Events emitted by MatrixRTCSession
|
||||
@@ -92,15 +90,27 @@ export interface SessionConfig {
|
||||
* @default `undefined` (no notification)
|
||||
*/
|
||||
notificationType?: RTCNotificationType;
|
||||
|
||||
/**
|
||||
* Determines the kind of call this will be.
|
||||
*/
|
||||
callIntent?: RTCCallIntent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session description is used to identify a session. Used in the state event.
|
||||
*/
|
||||
export interface SessionDescription {
|
||||
export interface SlotDescription {
|
||||
id: string;
|
||||
application: string;
|
||||
}
|
||||
export function slotIdToDescription(slotId: string): SlotDescription {
|
||||
const [application, id] = slotId.split("#");
|
||||
return { application, id };
|
||||
}
|
||||
export function slotDescriptionToId(slotDescription: SlotDescription): string {
|
||||
return `${slotDescription.application}#${slotDescription.id}`;
|
||||
}
|
||||
|
||||
// The names follow these principles:
|
||||
// - we use the technical term delay if the option is related to delayed events.
|
||||
@@ -110,14 +120,6 @@ export interface SessionDescription {
|
||||
// - we use a `Ms` postfix if the option is a duration to avoid using words like:
|
||||
// `time`, `duration`, `delay`, `timeout`... that might be mistaken/confused with technical terms.
|
||||
export interface MembershipConfig {
|
||||
/**
|
||||
* Use the new Manager.
|
||||
*
|
||||
* Default: `false`.
|
||||
* @deprecated does nothing anymore we always default to the new membership manager.
|
||||
*/
|
||||
useNewMembershipManager?: boolean;
|
||||
|
||||
/**
|
||||
* The timeout (in milliseconds) after we joined the call, that our membership should expire
|
||||
* unless we have explicitly updated it.
|
||||
@@ -179,6 +181,14 @@ export interface MembershipConfig {
|
||||
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
|
||||
*/
|
||||
delayedLeaveEventRestartLocalTimeoutMs?: number;
|
||||
|
||||
/**
|
||||
* Send membership using sticky events rather than state events.
|
||||
* This also make the client use the new m.rtc.member MSC4354 event format. (instead of m.call.member)
|
||||
*
|
||||
* **WARNING**: This is an unstable feature and not all clients will support it.
|
||||
*/
|
||||
unstableSendStickyEvents?: boolean;
|
||||
}
|
||||
|
||||
export interface EncryptionConfig {
|
||||
@@ -224,18 +234,29 @@ export interface EncryptionConfig {
|
||||
}
|
||||
export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig;
|
||||
|
||||
interface SessionMembershipsForRoomOpts {
|
||||
/**
|
||||
* Listen for incoming sticky member events. If disabled, this session will
|
||||
* ignore any incoming sticky events.
|
||||
*/
|
||||
listenForStickyEvents: boolean;
|
||||
/**
|
||||
* Listen for incoming member state events (legacy). If disabled, this session will
|
||||
* ignore any incoming state events.
|
||||
*/
|
||||
listenForMemberStateEvents: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
|
||||
* This class doesn't deal with media at all, just membership & properties of a session.
|
||||
*/
|
||||
export class MatrixRTCSession extends TypedEventEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap
|
||||
MatrixRTCSessionEvent | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap & MembershipManagerEventHandlerMap
|
||||
> {
|
||||
private membershipManager?: IMembershipManager;
|
||||
private encryptionManager?: IEncryptionManager;
|
||||
// The session Id of the call, this is the call_id of the call Member event.
|
||||
private _callId: string | undefined;
|
||||
private joinConfig?: SessionConfig;
|
||||
private logger: Logger;
|
||||
|
||||
@@ -273,53 +294,101 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
*
|
||||
* It can be undefined since the callId is only known once the first membership joins.
|
||||
* The callId is the property that, per definition, groups memberships into one call.
|
||||
* @deprecated use `slotId` instead.
|
||||
*/
|
||||
public get callId(): string | undefined {
|
||||
return this._callId;
|
||||
return this.slotDescription?.id;
|
||||
}
|
||||
/**
|
||||
* The slotId of the call.
|
||||
* `{application}#{appSpecificId}`
|
||||
* It can be undefined since the slotId is only known once the first membership joins.
|
||||
* The slotId is the property that, per definition, groups memberships into one call.
|
||||
*/
|
||||
public get slotId(): string | undefined {
|
||||
return slotDescriptionToId(this.slotDescription);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
||||
* oldest first.
|
||||
*
|
||||
* @deprecated Use `MatrixRTCSession.sessionMembershipsForRoom` instead.
|
||||
* @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead.
|
||||
*/
|
||||
public static callMembershipsForRoom(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
|
||||
): CallMembership[] {
|
||||
return MatrixRTCSession.sessionMembershipsForRoom(room, {
|
||||
return MatrixRTCSession.sessionMembershipsForSlot(room, {
|
||||
id: "",
|
||||
application: "m.call",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
||||
* oldest first.
|
||||
* @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead.
|
||||
*/
|
||||
public static sessionMembershipsForRoom(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||
sessionDescription: SessionDescription,
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
|
||||
sessionDescription: SlotDescription,
|
||||
): CallMembership[] {
|
||||
return this.sessionMembershipsForSlot(room, sessionDescription);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
||||
* oldest first.
|
||||
*
|
||||
* By default, this will return *both* sticky and member state events.
|
||||
*/
|
||||
public static sessionMembershipsForSlot(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
|
||||
slotDescription: SlotDescription,
|
||||
// default both true this implied we combine sticky and state events for the final call state
|
||||
// (prefer sticky events in case of a duplicate)
|
||||
{ listenForStickyEvents, listenForMemberStateEvents }: SessionMembershipsForRoomOpts = {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
},
|
||||
): CallMembership[] {
|
||||
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!roomState) {
|
||||
logger.warn("Couldn't get state for room " + room.roomId);
|
||||
throw new Error("Could't get state for room " + room.roomId);
|
||||
let callMemberEvents = [] as MatrixEvent[];
|
||||
if (listenForStickyEvents) {
|
||||
// prefill with sticky events
|
||||
callMemberEvents = [...room._unstable_getStickyEvents()].filter(
|
||||
(e) => e.getType() === EventType.RTCMembership,
|
||||
);
|
||||
}
|
||||
if (listenForMemberStateEvents) {
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!roomState) {
|
||||
logger.warn("Couldn't get state for room " + room.roomId);
|
||||
throw new Error("Could't get state for room " + room.roomId);
|
||||
}
|
||||
const callMemberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
|
||||
callMemberEvents = callMemberEvents.concat(
|
||||
callMemberStateEvents.filter(
|
||||
(callMemberStateEvent) =>
|
||||
!callMemberEvents.some(
|
||||
// only care about state events which have keys which we have not yet seen in the sticky events.
|
||||
(stickyEvent) =>
|
||||
stickyEvent.getContent().msc4354_sticky_key === callMemberStateEvent.getStateKey(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
const callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
|
||||
|
||||
const callMemberships: CallMembership[] = [];
|
||||
for (const memberEvent of callMemberEvents) {
|
||||
const content = memberEvent.getContent();
|
||||
const eventKeysCount = Object.keys(content).length;
|
||||
// Ignore sticky keys for the count
|
||||
const eventKeysCount = Object.keys(content).filter((k) => k !== "msc4354_sticky_key").length;
|
||||
// Dont even bother about empty events (saves us from costly type/"key in" checks in bigger rooms)
|
||||
if (eventKeysCount === 0) continue;
|
||||
|
||||
const membershipContents: any[] = [];
|
||||
|
||||
// We first decide if its a MSC4143 event (per device state key)
|
||||
if (eventKeysCount > 1 && "focus_active" in content) {
|
||||
if (eventKeysCount > 1 && "application" in content) {
|
||||
// We have a MSC4143 event membership event
|
||||
membershipContents.push(content);
|
||||
} else if (eventKeysCount === 1 && "memberships" in content) {
|
||||
@@ -329,12 +398,16 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
if (membershipContents.length === 0) continue;
|
||||
|
||||
for (const membershipData of membershipContents) {
|
||||
if (!("application" in membershipData)) {
|
||||
// This is a left membership event, ignore it here to not log warnings.
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const membership = new CallMembership(memberEvent, membershipData);
|
||||
|
||||
if (!deepCompare(membership.sessionDescription, sessionDescription)) {
|
||||
if (!deepCompare(membership.slotDescription, slotDescription)) {
|
||||
logger.info(
|
||||
`Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.sessionDescription)}`,
|
||||
`Ignoring membership of user ${membership.sender} for a different slot: ${JSON.stringify(membership.slotDescription)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -373,26 +446,41 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* This method is an alias for `MatrixRTCSession.sessionForRoom` with
|
||||
* sessionDescription `{ id: "", application: "m.call" }`.
|
||||
*
|
||||
* @deprecated Use `MatrixRTCSession.sessionForRoom` with sessionDescription `{ id: "", application: "m.call" }` instead.
|
||||
* @deprecated Use `MatrixRTCSession.sessionForSlot` with sessionDescription `{ id: "", application: "m.call" }` instead.
|
||||
*/
|
||||
public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession {
|
||||
const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, { id: "", application: "m.call" });
|
||||
public static roomSessionForRoom(
|
||||
client: MatrixClient,
|
||||
room: Room,
|
||||
opts?: SessionMembershipsForRoomOpts,
|
||||
): MatrixRTCSession {
|
||||
const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(
|
||||
room,
|
||||
{ id: "", application: "m.call" },
|
||||
opts,
|
||||
);
|
||||
return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" });
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `MatrixRTCSession.sessionForSlot` instead.
|
||||
*/
|
||||
public static sessionForRoom(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession {
|
||||
return this.sessionForSlot(client, room, slotDescription);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the MatrixRTC session for the room.
|
||||
* This returned session can be used to find out if there are active sessions
|
||||
* for the requested room and `sessionDescription`.
|
||||
* for the requested room and `slotDescription`.
|
||||
*/
|
||||
public static sessionForRoom(
|
||||
public static sessionForSlot(
|
||||
client: MatrixClient,
|
||||
room: Room,
|
||||
sessionDescription: SessionDescription,
|
||||
slotDescription: SlotDescription,
|
||||
opts?: SessionMembershipsForRoomOpts,
|
||||
): MatrixRTCSession {
|
||||
const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription);
|
||||
|
||||
return new MatrixRTCSession(client, room, callMemberships, sessionDescription);
|
||||
const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription, opts);
|
||||
return new MatrixRTCSession(client, room, callMemberships, slotDescription);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -422,10 +510,12 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
MatrixClient,
|
||||
| "getUserId"
|
||||
| "getDeviceId"
|
||||
| "sendEvent"
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "sendEvent"
|
||||
| "_unstable_sendStickyEvent"
|
||||
| "_unstable_sendStickyDelayedEvent"
|
||||
| "cancelPendingEvent"
|
||||
| "encryptAndSendToDevice"
|
||||
| "off"
|
||||
@@ -438,20 +528,21 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
>,
|
||||
public memberships: CallMembership[],
|
||||
/**
|
||||
* The session description is used to define the exact session this object is tracking.
|
||||
* A session is distinct from another session if one of those properties differ: `roomSubset.roomId`, `sessionDescription.application`, `sessionDescription.id`.
|
||||
* The slot description is a virtual address where participants are allowed to meet.
|
||||
* This session will only manage memberships that match this slot description.
|
||||
* Sessions are distinct if any of those properties are distinct: `roomSubset.roomId`, `slotDescription.application`, `slotDescription.id`.
|
||||
*/
|
||||
public readonly sessionDescription: SessionDescription,
|
||||
public readonly slotDescription: SlotDescription,
|
||||
) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
|
||||
this._callId = memberships[0]?.sessionDescription.id;
|
||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
|
||||
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
this.roomSubset.on(RoomStickyEventsEvent.Update, this.onStickyEventUpdate);
|
||||
|
||||
this.setExpiryTimer();
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns true if we intend to be participating in the MatrixRTC session.
|
||||
* This is determined by checking if the relativeExpiry has been set.
|
||||
@@ -471,10 +562,12 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
}
|
||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
this.roomSubset.off(RoomStickyEventsEvent.Update, this.onStickyEventUpdate);
|
||||
}
|
||||
|
||||
private reEmitter = new TypedReEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap
|
||||
MatrixRTCSessionEvent | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap & MembershipManagerEventHandlerMap
|
||||
>(this);
|
||||
|
||||
/**
|
||||
@@ -484,28 +577,32 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* This will not subscribe to updates: remember to call subscribe() separately if
|
||||
* desired.
|
||||
* This method will return immediately and the session will be joined in the background.
|
||||
*
|
||||
* @param fociActive - The object representing the active focus. (This depends on the focus type.)
|
||||
* @param fociPreferred - The list of preferred foci this member proposes to use/knows/has access to.
|
||||
* For the livekit case this is a list of foci generated from the homeserver well-known, the current rtc session,
|
||||
* or optionally other room members homeserver well known.
|
||||
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||
* If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously.
|
||||
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
|
||||
* membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership`
|
||||
* transport selection will be used instead.
|
||||
* @param joinConfig - Additional configuration for the joined session.
|
||||
*/
|
||||
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
|
||||
public joinRoomSession(
|
||||
fociPreferred: Transport[],
|
||||
multiSfuFocus?: Transport,
|
||||
joinConfig?: JoinSessionConfig,
|
||||
): void {
|
||||
if (this.isJoined()) {
|
||||
this.logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
|
||||
return;
|
||||
} else {
|
||||
// Create MembershipManager and pass the RTCSession logger (with room id info)
|
||||
|
||||
this.membershipManager = new MembershipManager(
|
||||
joinConfig,
|
||||
this.roomSubset,
|
||||
this.client,
|
||||
() => this.getOldestMembership(),
|
||||
this.sessionDescription,
|
||||
this.logger,
|
||||
);
|
||||
this.membershipManager = joinConfig?.unstableSendStickyEvents
|
||||
? new StickyEventMembershipManager(
|
||||
joinConfig,
|
||||
this.roomSubset,
|
||||
this.client,
|
||||
this.slotDescription,
|
||||
this.logger,
|
||||
)
|
||||
: new MembershipManager(joinConfig, this.roomSubset, this.client, this.slotDescription, this.logger);
|
||||
|
||||
this.reEmitter.reEmit(this.membershipManager!, [
|
||||
MembershipManagerEvent.ProbablyLeft,
|
||||
@@ -518,13 +615,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
this.logger.info("Using to-device with room fallback transport for encryption keys");
|
||||
const [uId, dId] = [this.client.getUserId()!, this.client.getDeviceId()!];
|
||||
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics];
|
||||
// Deprecate RoomKeyTransport: only ToDeviceKeyTransport is needed once deprecated
|
||||
const roomKeyTransport = new RoomKeyTransport(room, client, statistics);
|
||||
const toDeviceTransport = new ToDeviceKeyTransport(uId, dId, room.roomId, client, statistics);
|
||||
transport = new RoomAndToDeviceTransport(toDeviceTransport, roomKeyTransport, this.logger);
|
||||
|
||||
// Expose the changes so the ui can display the currently used transport.
|
||||
this.reEmitter.reEmit(transport, [RoomAndToDeviceEvents.EnabledTransportsChanged]);
|
||||
const transport = new ToDeviceKeyTransport(uId, dId, room.roomId, client, statistics);
|
||||
this.encryptionManager = new RTCEncryptionManager(
|
||||
this.client.getUserId()!,
|
||||
this.client.getDeviceId()!,
|
||||
@@ -565,7 +656,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
this.pendingNotificationToSend = this.joinConfig?.notificationType;
|
||||
|
||||
// Join!
|
||||
this.membershipManager!.join(fociPreferred, fociActive, (e) => {
|
||||
this.membershipManager!.join(fociPreferred, multiSfuFocus, (e) => {
|
||||
this.logger.error("MembershipManager encountered an unrecoverable error: ", e);
|
||||
this.emit(MatrixRTCSessionEvent.MembershipManagerError, e);
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, this.isJoined());
|
||||
@@ -600,32 +691,51 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
|
||||
return await leavePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active focus from the current CallMemberState event
|
||||
* @returns The focus that is currently in use to connect to this session. This is undefined
|
||||
* if the client is not connected to this session.
|
||||
* This returns the focus in use by the oldest membership.
|
||||
* Do not use since this might be just the focus for the oldest membership. others might use a different focus.
|
||||
* @deprecated use `member.getTransport(session.getOldestMembership())` instead for the specific member you want to get the focus for.
|
||||
*/
|
||||
public getActiveFocus(): Focus | undefined {
|
||||
return this.membershipManager?.getActiveFocus();
|
||||
public getFocusInUse(): Transport | undefined {
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getTransport(oldestMembership);
|
||||
}
|
||||
|
||||
/**
|
||||
* The used focusActive of the oldest membership (to find out the selection type multi-sfu or oldest membership active focus)
|
||||
* @deprecated does not work with m.rtc.member. Do not rely on it.
|
||||
*/
|
||||
public getActiveFocus(): Transport | undefined {
|
||||
return this.getOldestMembership()?.getFocusActive();
|
||||
}
|
||||
public getOldestMembership(): CallMembership | undefined {
|
||||
return this.memberships[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is used when the user is not yet connected to the Session but wants to know what focus
|
||||
* the users in the session are using to make a decision how it wants/should connect.
|
||||
* Get the call intent for the current call, based on what members are advertising. If one or more
|
||||
* members disagree on the current call intent, or nobody specifies one then `undefined` is returned.
|
||||
*
|
||||
* See also `getActiveFocus`
|
||||
* @returns The focus which should be used when joining this session.
|
||||
* If all members that specify a call intent agree, that value is returned.
|
||||
* @returns A call intent, or `undefined` if no consensus or not given.
|
||||
*/
|
||||
public getFocusInUse(): Focus | undefined {
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
if (oldestMembership?.getFocusSelection() === "oldest_membership") {
|
||||
return oldestMembership.getPreferredFoci()[0];
|
||||
public getConsensusCallIntent(): RTCCallIntent | undefined {
|
||||
const getFirstCallIntent = this.memberships.find((m) => !!m.callIntent)?.callIntent;
|
||||
if (!getFirstCallIntent) {
|
||||
return undefined;
|
||||
}
|
||||
if (this.memberships.every((m) => !m.callIntent || m.callIntent === getFirstCallIntent)) {
|
||||
return getFirstCallIntent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async updateCallIntent(callIntent: RTCCallIntent): Promise<void> {
|
||||
const myMembership = this.membershipManager?.ownMembership;
|
||||
if (!myMembership) {
|
||||
throw Error("Not connected yet");
|
||||
}
|
||||
await this.membershipManager?.updateCallIntent(callIntent);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -665,9 +775,17 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a notification corresponding to the configured notify type.
|
||||
* Sends notification events to indiciate the call has started.
|
||||
* Note: This does not return a promise, instead scheduling the notification events to be sent.
|
||||
* @param parentEventId Event id linking to your RTC call membership event.
|
||||
* @param notificationType The type of notification to send
|
||||
* @param callIntent The type of call this is (e.g. "audio").
|
||||
*/
|
||||
private sendCallNotify(parentEventId: string, notificationType: RTCNotificationType): void {
|
||||
private sendCallNotify(
|
||||
parentEventId: string,
|
||||
notificationType: RTCNotificationType,
|
||||
callIntent?: RTCCallIntent,
|
||||
): void {
|
||||
const sendLegacyNotificationEvent = async (): Promise<{
|
||||
response: ISendEventResponse;
|
||||
content: ICallNotifyContent;
|
||||
@@ -695,6 +813,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
"sender_ts": Date.now(),
|
||||
"lifetime": 30_000, // 30 seconds
|
||||
};
|
||||
if (callIntent) {
|
||||
content["m.call.intent"] = callIntent;
|
||||
}
|
||||
const response = await this.client.sendEvent(this.roomSubset.roomId, EventType.RTCNotification, content);
|
||||
return { response, content };
|
||||
};
|
||||
@@ -714,10 +835,27 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
/**
|
||||
* Call this when the Matrix room members have changed.
|
||||
*/
|
||||
public onRoomMemberUpdate = (): void => {
|
||||
private readonly onRoomMemberUpdate = (): void => {
|
||||
this.recalculateSessionMembers();
|
||||
};
|
||||
|
||||
/**
|
||||
* Call this when a sticky event update has occured.
|
||||
*/
|
||||
private readonly onStickyEventUpdate: RoomStickyEventsMap[RoomStickyEventsEvent.Update] = (
|
||||
added,
|
||||
updated,
|
||||
removed,
|
||||
): void => {
|
||||
if (
|
||||
[...added, ...removed, ...updated.flatMap((v) => [v.current, v.previous])].some(
|
||||
(e) => e.getType() === EventType.RTCMembership,
|
||||
)
|
||||
) {
|
||||
this.recalculateSessionMembers();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Call this when something changed that may impacts the current MatrixRTC members in this session.
|
||||
*/
|
||||
@@ -734,9 +872,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
*/
|
||||
private recalculateSessionMembers = (): void => {
|
||||
const oldMemberships = this.memberships;
|
||||
this.memberships = MatrixRTCSession.sessionMembershipsForRoom(this.room, this.sessionDescription);
|
||||
|
||||
this._callId = this._callId ?? this.memberships[0]?.sessionDescription.id;
|
||||
this.memberships = MatrixRTCSession.sessionMembershipsForSlot(this.room, this.slotDescription);
|
||||
|
||||
const changed =
|
||||
oldMemberships.length != this.memberships.length ||
|
||||
@@ -757,7 +893,11 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
// If we're the first member in the call, we're responsible for
|
||||
// sending the notification event
|
||||
if (ownMembership.eventId && this.joinConfig?.notificationType) {
|
||||
this.sendCallNotify(ownMembership.eventId, this.joinConfig.notificationType);
|
||||
this.sendCallNotify(
|
||||
ownMembership.eventId,
|
||||
this.joinConfig.notificationType,
|
||||
ownMembership.callIntent,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn("Own membership eventId is undefined, cannot send call notification");
|
||||
}
|
||||
@@ -765,6 +905,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
// If anyone else joins the session it is no longer our responsibility to send the notification.
|
||||
// (If we were the joiner we already did sent the notification in the block above.)
|
||||
if (this.memberships.length > 0) this.pendingNotificationToSend = undefined;
|
||||
} else {
|
||||
this.logger.debug(`No membership changes detected for room ${this.roomSubset.roomId}`);
|
||||
}
|
||||
// This also needs to be done if `changed` = false
|
||||
// A member might have updated their fingerprint (created_ts)
|
||||
|
||||
@@ -18,9 +18,9 @@ import { type Logger } from "../logger.ts";
|
||||
import { type MatrixClient, ClientEvent } from "../client.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type RoomState, RoomStateEvent } from "../models/room-state.ts";
|
||||
import { RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { MatrixRTCSession, type SessionDescription } from "./MatrixRTCSession.ts";
|
||||
import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
|
||||
export enum MatrixRTCSessionManagerEvents {
|
||||
@@ -56,7 +56,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
public constructor(
|
||||
rootLogger: Logger,
|
||||
private client: MatrixClient,
|
||||
private readonly sessionDescription: SessionDescription = { id: "", application: "m.call" }, // Default to the Matrix Call application
|
||||
private readonly slotDescription: SlotDescription = { application: "m.call", id: "" }, // Default to the Matrix Call application
|
||||
) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
|
||||
@@ -66,13 +66,14 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
// We shouldn't need to null-check here, but matrix-client.spec.ts mocks getRooms
|
||||
// returning nothing, and breaks tests if you change it to return an empty array :'(
|
||||
for (const room of this.client.getRooms() ?? []) {
|
||||
const session = MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription);
|
||||
const session = MatrixRTCSession.sessionForRoom(this.client, room, this.slotDescription);
|
||||
if (session.memberships.length > 0) {
|
||||
this.roomSessions.set(room.roomId, session);
|
||||
}
|
||||
}
|
||||
|
||||
this.client.on(ClientEvent.Room, this.onRoom);
|
||||
this.client.on(ClientEvent.Event, this.onEvent);
|
||||
this.client.on(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
@@ -83,6 +84,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
this.roomSessions.clear();
|
||||
|
||||
this.client.off(ClientEvent.Room, this.onRoom);
|
||||
this.client.off(ClientEvent.Event, this.onEvent);
|
||||
this.client.off(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
@@ -102,7 +104,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
if (!this.roomSessions.has(room.roomId)) {
|
||||
this.roomSessions.set(
|
||||
room.roomId,
|
||||
MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription),
|
||||
MatrixRTCSession.sessionForRoom(this.client, room, this.slotDescription),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,16 +115,28 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
this.refreshRoom(room);
|
||||
};
|
||||
|
||||
private onRoomState = (event: MatrixEvent, _state: RoomState): void => {
|
||||
private readonly onEvent = (event: MatrixEvent): void => {
|
||||
if (!event.unstableStickyExpiresAt) return; // Not sticky, not interested.
|
||||
|
||||
if (event.getType() !== EventType.RTCMembership) return;
|
||||
|
||||
const room = this.client.getRoom(event.getRoomId());
|
||||
if (!room) return;
|
||||
|
||||
this.refreshRoom(room);
|
||||
};
|
||||
|
||||
private readonly onRoomState = (event: MatrixEvent): void => {
|
||||
if (event.getType() !== EventType.GroupCallMemberPrefix) {
|
||||
return;
|
||||
}
|
||||
const room = this.client.getRoom(event.getRoomId());
|
||||
if (!room) {
|
||||
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.getType() == EventType.GroupCallMemberPrefix) {
|
||||
this.refreshRoom(room);
|
||||
}
|
||||
this.refreshRoom(room);
|
||||
};
|
||||
|
||||
private refreshRoom(room: Room): void {
|
||||
|
||||
+205
-104
@@ -15,20 +15,33 @@ limitations under the License.
|
||||
*/
|
||||
import { AbortError } from "p-retry";
|
||||
|
||||
import { EventType } from "../@types/event.ts";
|
||||
import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
||||
import { type MatrixClient } from "../client.ts";
|
||||
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
|
||||
import { EventType, RelationType } from "../@types/event.ts";
|
||||
import {
|
||||
type ISendEventResponse,
|
||||
type SendDelayedEventResponse,
|
||||
UpdateDelayedEventAction,
|
||||
} from "../@types/requests.ts";
|
||||
import { type EmptyObject } from "../@types/common.ts";
|
||||
import type { MatrixClient } from "../client.ts";
|
||||
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { isMyMembership, Status } from "./types.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type SessionDescription, type MembershipConfig } from "./MatrixRTCSession.ts";
|
||||
import {
|
||||
type CallMembership,
|
||||
DEFAULT_EXPIRE_DURATION,
|
||||
type RtcMembershipData,
|
||||
type SessionMembershipData,
|
||||
} from "./CallMembership.ts";
|
||||
import { type Transport, isMyMembership, type RTCCallIntent, Status } from "./types.ts";
|
||||
import {
|
||||
type SlotDescription,
|
||||
type MembershipConfig,
|
||||
type SessionConfig,
|
||||
slotDescriptionToId,
|
||||
} from "./MatrixRTCSession.ts";
|
||||
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
type IMembershipManager,
|
||||
@@ -36,12 +49,11 @@ import {
|
||||
} from "./IMembershipManager.ts";
|
||||
|
||||
/* MembershipActionTypes:
|
||||
|
||||
On Join: ───────────────┐ ┌───────────────(1)───────────┐
|
||||
▼ ▼ │
|
||||
┌────────────────┐ │
|
||||
│SendDelayedEvent│ ──────(2)───┐ │
|
||||
└────────────────┘ │ │
|
||||
└────────────────┘ │ │
|
||||
│(3) │ │
|
||||
▼ │ │
|
||||
┌─────────────┐ │ │
|
||||
@@ -52,9 +64,9 @@ On Join: ───────────────┐ ┌─────
|
||||
┌────────────┐ │ │ ┌───────────────────┐ │
|
||||
│UpdateExpiry│ (s) (s)|RestartDelayedEvent│ │
|
||||
└────────────┘ │ │ └───────────────────┘ │
|
||||
│ │ │ │ │ │
|
||||
└─────┘ └──────┘ └───────┘
|
||||
|
||||
│ │ │ │ │ │
|
||||
└─────┘ └──────┘ └───────┘
|
||||
|
||||
On Leave: ───────── STOP ALL ABOVE
|
||||
▼
|
||||
┌────────────────────────────────┐
|
||||
@@ -77,6 +89,12 @@ On Leave: ───────── STOP ALL ABOVE
|
||||
(s) Successful restart/resend
|
||||
*/
|
||||
|
||||
/**
|
||||
* Call membership should always remain sticky for this amount
|
||||
* of time.
|
||||
*/
|
||||
const MEMBERSHIP_STICKY_DURATION_MS = 60 * 60 * 1000; // 60 minutes
|
||||
|
||||
/**
|
||||
* The different types of actions the MembershipManager can take.
|
||||
* @internal
|
||||
@@ -137,6 +155,23 @@ export interface MembershipManagerState {
|
||||
probablyLeft: boolean;
|
||||
}
|
||||
|
||||
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
insert: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
};
|
||||
}
|
||||
|
||||
function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
replace: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
};
|
||||
}
|
||||
|
||||
type MembershipManagerClient = Pick<
|
||||
MatrixClient,
|
||||
"getUserId" | "getDeviceId" | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent"
|
||||
>;
|
||||
|
||||
/**
|
||||
* This class is responsible for sending all events relating to the own membership of a matrixRTC call.
|
||||
* It has the following tasks:
|
||||
@@ -155,7 +190,8 @@ export class MembershipManager
|
||||
implements IMembershipManager
|
||||
{
|
||||
private activated = false;
|
||||
private logger: Logger;
|
||||
private readonly logger: Logger;
|
||||
protected callIntent: RTCCallIntent | undefined;
|
||||
|
||||
public isActivated(): boolean {
|
||||
return this.activated;
|
||||
@@ -168,18 +204,21 @@ export class MembershipManager
|
||||
/**
|
||||
* Puts the MembershipManager in a state where it tries to be joined.
|
||||
* It will send delayed events and membership events
|
||||
* @param fociPreferred
|
||||
* @param focusActive
|
||||
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||
* If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously.
|
||||
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
|
||||
* membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership`
|
||||
* transport selection will be used instead.
|
||||
* @param onError This will be called once the membership manager encounters an unrecoverable error.
|
||||
* This should bubble up the the frontend to communicate that the call does not work in the current environment.
|
||||
*/
|
||||
public join(fociPreferred: Focus[], focusActive?: Focus, onError?: (error: unknown) => void): void {
|
||||
public join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void {
|
||||
if (this.scheduler.running) {
|
||||
this.logger.error("MembershipManager is already running. Ignoring join request.");
|
||||
return;
|
||||
}
|
||||
this.fociPreferred = fociPreferred;
|
||||
this.focusActive = focusActive;
|
||||
this.rtcTransport = multiSfuFocus;
|
||||
this.leavePromiseResolvers = undefined;
|
||||
this.activated = true;
|
||||
this.oldStatus = this.status;
|
||||
@@ -230,7 +269,10 @@ export class MembershipManager
|
||||
|
||||
private leavePromiseResolvers?: PromiseWithResolvers<boolean>;
|
||||
|
||||
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
|
||||
public onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
|
||||
if (!this.isActivated()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const userId = this.client.getUserId();
|
||||
const deviceId = this.client.getDeviceId();
|
||||
if (!userId || !deviceId) {
|
||||
@@ -239,7 +281,7 @@ export class MembershipManager
|
||||
}
|
||||
this._ownMembership = memberships.find((m) => isMyMembership(m, userId, deviceId));
|
||||
|
||||
if (this.isActivated() && !this._ownMembership) {
|
||||
if (!this._ownMembership) {
|
||||
// If one of these actions are scheduled or are getting inserted in the next iteration, we should already
|
||||
// take care of our missing membership.
|
||||
const sendingMembershipActions = [
|
||||
@@ -262,23 +304,16 @@ export class MembershipManager
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public getActiveFocus(): Focus | undefined {
|
||||
if (this.focusActive) {
|
||||
// A livekit active focus
|
||||
if (isLivekitFocusActive(this.focusActive)) {
|
||||
if (this.focusActive.focus_selection === "oldest_membership") {
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
} else {
|
||||
this.logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
|
||||
}
|
||||
} else {
|
||||
// We do not understand the membership format (could be legacy). We default to oldestMembership
|
||||
// Once there are other methods this is a hard error!
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
public async updateCallIntent(callIntent: RTCCallIntent): Promise<void> {
|
||||
if (!this.activated || !this.ownMembership) {
|
||||
throw Error("You cannot update your intent before joining the call");
|
||||
}
|
||||
if (this.ownMembership.callIntent === callIntent) {
|
||||
return; // No-op
|
||||
}
|
||||
this.callIntent = callIntent;
|
||||
// Kick off a new membership event as a result.
|
||||
await this.sendJoinEvent();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -286,21 +321,12 @@ export class MembershipManager
|
||||
* @param joinConfig
|
||||
* @param room
|
||||
* @param client
|
||||
* @param getOldestMembership
|
||||
*/
|
||||
public constructor(
|
||||
private joinConfig: MembershipConfig | undefined,
|
||||
private room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
|
||||
private client: Pick<
|
||||
MatrixClient,
|
||||
| "getUserId"
|
||||
| "getDeviceId"
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
>,
|
||||
private getOldestMembership: () => CallMembership | undefined,
|
||||
public readonly sessionDescription: SessionDescription,
|
||||
private readonly joinConfig: (SessionConfig & MembershipConfig) | undefined,
|
||||
protected readonly room: Pick<Room, "roomId" | "getVersion">,
|
||||
protected readonly client: MembershipManagerClient,
|
||||
public readonly slotDescription: SlotDescription,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
@@ -309,8 +335,11 @@ export class MembershipManager
|
||||
if (userId === null) throw Error("Missing userId in client");
|
||||
if (deviceId === null) throw Error("Missing deviceId in client");
|
||||
this.deviceId = deviceId;
|
||||
this.stateKey = this.makeMembershipStateKey(userId, deviceId);
|
||||
// this needs to become a uuid so that consecutive join/leaves result in a key rotation.
|
||||
// we keep it as a string for now for backwards compatibility.
|
||||
this.memberId = this.makeMembershipStateKey(userId, deviceId);
|
||||
this.state = MembershipManager.defaultState;
|
||||
this.callIntent = joinConfig?.callIntent;
|
||||
this.scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
|
||||
if (this.oldStatus) {
|
||||
// we put this at the beginning of the actions scheduler loop handle callback since it is a loop this
|
||||
@@ -353,10 +382,11 @@ export class MembershipManager
|
||||
};
|
||||
}
|
||||
// Membership Event static parameters:
|
||||
private deviceId: string;
|
||||
private stateKey: string;
|
||||
private fociPreferred?: Focus[];
|
||||
private focusActive?: Focus;
|
||||
protected deviceId: string;
|
||||
protected memberId: string;
|
||||
protected rtcTransport?: Transport;
|
||||
/** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */
|
||||
private fociPreferred?: Transport[];
|
||||
|
||||
// Config:
|
||||
private delayedLeaveEventDelayMsOverride?: number;
|
||||
@@ -371,9 +401,13 @@ export class MembershipManager
|
||||
return this.joinConfig?.membershipEventExpiryHeadroomMs ?? 5_000;
|
||||
}
|
||||
private computeNextExpiryActionTs(iteration: number): number {
|
||||
return this.state.startTime + this.membershipEventExpiryMs * iteration - this.membershipEventExpiryHeadroomMs;
|
||||
return (
|
||||
this.state.startTime +
|
||||
Math.min(this.membershipEventExpiryMs, MEMBERSHIP_STICKY_DURATION_MS) * iteration -
|
||||
this.membershipEventExpiryHeadroomMs
|
||||
);
|
||||
}
|
||||
private get delayedLeaveEventDelayMs(): number {
|
||||
protected get delayedLeaveEventDelayMs(): number {
|
||||
return this.delayedLeaveEventDelayMsOverride ?? this.joinConfig?.delayedLeaveEventDelayMs ?? 8_000;
|
||||
}
|
||||
private get delayedLeaveEventRestartMs(): number {
|
||||
@@ -385,10 +419,10 @@ export class MembershipManager
|
||||
private get maximumNetworkErrorRetryCount(): number {
|
||||
return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10;
|
||||
}
|
||||
|
||||
private get delayedLeaveEventRestartLocalTimeoutMs(): number {
|
||||
return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000;
|
||||
}
|
||||
|
||||
// LOOP HANDLER:
|
||||
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
|
||||
switch (type) {
|
||||
@@ -443,22 +477,23 @@ export class MembershipManager
|
||||
}
|
||||
}
|
||||
|
||||
// an abstraction to switch between sending state or a sticky event
|
||||
protected clientSendDelayedDisconnectMembership: () => Promise<SendDelayedEventResponse> = () =>
|
||||
this.client._unstable_sendDelayedStateEvent(
|
||||
this.room.roomId,
|
||||
{ delay: this.delayedLeaveEventDelayMs },
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{},
|
||||
this.memberId,
|
||||
);
|
||||
|
||||
// HANDLERS (used in the membershipLoopHandler)
|
||||
private async sendOrResendDelayedLeaveEvent(): Promise<ActionUpdate> {
|
||||
// We can reach this at the start of a call (where we do not yet have a membership: state.hasMemberStateEvent=false)
|
||||
// or during a call if the state event canceled our delayed event or caused by an unexpected error that removed our delayed event.
|
||||
// (Another client could have canceled it, the homeserver might have removed/lost it due to a restart, ...)
|
||||
// In the `then` and `catch` block we treat both cases differently. "if (this.state.hasMemberStateEvent) {} else {}"
|
||||
return await this.client
|
||||
._unstable_sendDelayedStateEvent(
|
||||
this.room.roomId,
|
||||
{
|
||||
delay: this.delayedLeaveEventDelayMs,
|
||||
},
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{}, // leave event
|
||||
this.stateKey,
|
||||
)
|
||||
return await this.clientSendDelayedDisconnectMembership()
|
||||
.then((response) => {
|
||||
this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs;
|
||||
this.setAndEmitProbablyLeft(false);
|
||||
@@ -482,7 +517,7 @@ export class MembershipManager
|
||||
if (this.manageMaxDelayExceededSituation(e)) {
|
||||
return createInsertActionUpdate(repeatActionType);
|
||||
}
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "sendDelayedStateEvent");
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "_unstable_sendDelayedStateEvent");
|
||||
if (update) return update;
|
||||
|
||||
if (this.state.hasMemberStateEvent) {
|
||||
@@ -638,14 +673,19 @@ export class MembershipManager
|
||||
});
|
||||
}
|
||||
|
||||
protected clientSendMembership: (
|
||||
myMembership: RtcMembershipData | SessionMembershipData | EmptyObject,
|
||||
) => Promise<ISendEventResponse> = (myMembership) => {
|
||||
return this.client.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
myMembership as EmptyObject | SessionMembershipData,
|
||||
this.memberId,
|
||||
);
|
||||
};
|
||||
|
||||
private async sendJoinEvent(): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
this.makeMyMembership(this.membershipEventExpiryMs),
|
||||
this.stateKey,
|
||||
)
|
||||
return await this.clientSendMembership(this.makeMyMembership(this.membershipEventExpiryMs))
|
||||
.then(() => {
|
||||
this.setAndEmitProbablyLeft(false);
|
||||
this.state.startTime = Date.now();
|
||||
@@ -685,13 +725,9 @@ export class MembershipManager
|
||||
|
||||
private async updateExpiryOnJoinedEvent(): Promise<ActionUpdate> {
|
||||
const nextExpireUpdateIteration = this.state.expireUpdateIterations + 1;
|
||||
return await this.client
|
||||
.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
|
||||
this.stateKey,
|
||||
)
|
||||
return await this.clientSendMembership(
|
||||
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
|
||||
)
|
||||
.then(() => {
|
||||
// Success, we reset retries and schedule update.
|
||||
this.resetRateLimitCounter(MembershipActionType.UpdateExpiry);
|
||||
@@ -713,8 +749,7 @@ export class MembershipManager
|
||||
});
|
||||
}
|
||||
private async sendFallbackLeaveEvent(): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.stateKey)
|
||||
return await this.clientSendMembership({})
|
||||
.then(() => {
|
||||
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
|
||||
this.state.hasMemberStateEvent = false;
|
||||
@@ -729,7 +764,7 @@ export class MembershipManager
|
||||
|
||||
// HELPERS
|
||||
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
|
||||
const stateKey = `${localUserId}_${localDeviceId}_${this.sessionDescription.application}${this.sessionDescription.id}`;
|
||||
const stateKey = `${localUserId}_${localDeviceId}_${this.slotDescription.application}${this.slotDescription.id}`;
|
||||
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
|
||||
return stateKey;
|
||||
} else {
|
||||
@@ -740,16 +775,28 @@ export class MembershipManager
|
||||
/**
|
||||
* Constructs our own membership
|
||||
*/
|
||||
private makeMyMembership(expires: number): SessionMembershipData {
|
||||
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
|
||||
const ownMembership = this.ownMembership;
|
||||
|
||||
const focusObjects =
|
||||
this.rtcTransport === undefined
|
||||
? {
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const,
|
||||
foci_preferred: this.fociPreferred ?? [],
|
||||
}
|
||||
: {
|
||||
focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const,
|
||||
foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])],
|
||||
};
|
||||
return {
|
||||
// TODO: use the new format for m.rtc.member events where call_id becomes session.id
|
||||
application: this.sessionDescription.application,
|
||||
call_id: this.sessionDescription.id,
|
||||
scope: "m.room",
|
||||
device_id: this.deviceId,
|
||||
"application": this.slotDescription.application,
|
||||
"call_id": this.slotDescription.id,
|
||||
"scope": "m.room",
|
||||
"device_id": this.deviceId,
|
||||
expires,
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
foci_preferred: this.fociPreferred ?? [],
|
||||
"m.call.intent": this.callIntent,
|
||||
...focusObjects,
|
||||
...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -785,7 +832,7 @@ export class MembershipManager
|
||||
return false;
|
||||
}
|
||||
|
||||
private actionUpdateFromErrors(
|
||||
protected actionUpdateFromErrors(
|
||||
error: unknown,
|
||||
type: MembershipActionType,
|
||||
method: string,
|
||||
@@ -833,7 +880,7 @@ export class MembershipManager
|
||||
return createInsertActionUpdate(type, resendDelay);
|
||||
}
|
||||
|
||||
throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + "): " + (error as Error));
|
||||
throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + ")", { cause: error });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -977,14 +1024,68 @@ export class MembershipManager
|
||||
}
|
||||
}
|
||||
|
||||
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
insert: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Implementation of the Membership manager that uses sticky events
|
||||
* rather than state events.
|
||||
*/
|
||||
export class StickyEventMembershipManager extends MembershipManager {
|
||||
public constructor(
|
||||
joinConfig: (SessionConfig & MembershipConfig) | undefined,
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
|
||||
private readonly clientWithSticky: MembershipManagerClient &
|
||||
Pick<MatrixClient, "_unstable_sendStickyEvent" | "_unstable_sendStickyDelayedEvent">,
|
||||
sessionDescription: SlotDescription,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super(joinConfig, room, clientWithSticky, sessionDescription, parentLogger);
|
||||
}
|
||||
|
||||
function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
replace: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
protected clientSendDelayedDisconnectMembership: () => Promise<SendDelayedEventResponse> = () =>
|
||||
this.clientWithSticky._unstable_sendStickyDelayedEvent(
|
||||
this.room.roomId,
|
||||
MEMBERSHIP_STICKY_DURATION_MS,
|
||||
{ delay: this.delayedLeaveEventDelayMs },
|
||||
null,
|
||||
EventType.RTCMembership,
|
||||
{ msc4354_sticky_key: this.memberId },
|
||||
);
|
||||
|
||||
protected clientSendMembership: (
|
||||
myMembership: RtcMembershipData | SessionMembershipData | EmptyObject,
|
||||
) => Promise<ISendEventResponse> = (myMembership) => {
|
||||
return this.clientWithSticky._unstable_sendStickyEvent(
|
||||
this.room.roomId,
|
||||
MEMBERSHIP_STICKY_DURATION_MS,
|
||||
null,
|
||||
EventType.RTCMembership,
|
||||
{ ...myMembership, msc4354_sticky_key: this.memberId },
|
||||
);
|
||||
};
|
||||
|
||||
private static nameMap = new Map([
|
||||
["sendStateEvent", "_unstable_sendStickyEvent"],
|
||||
["sendDelayedStateEvent", "_unstable_sendStickyDelayedEvent"],
|
||||
]);
|
||||
protected actionUpdateFromErrors(e: unknown, t: MembershipActionType, m: string): ActionUpdate | undefined {
|
||||
return super.actionUpdateFromErrors(e, t, StickyEventMembershipManager.nameMap.get(m) ?? "unknown");
|
||||
}
|
||||
|
||||
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
|
||||
const ownMembership = this.ownMembership;
|
||||
|
||||
const relationObject = ownMembership?.eventId
|
||||
? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } }
|
||||
: {};
|
||||
return {
|
||||
application: {
|
||||
type: this.slotDescription.application,
|
||||
...(this.callIntent ? { "m.call.intent": this.callIntent } : {}),
|
||||
},
|
||||
slot_id: slotDescriptionToId(this.slotDescription),
|
||||
rtc_transports: this.rtcTransport ? [this.rtcTransport] : [],
|
||||
member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId },
|
||||
versions: [],
|
||||
...relationObject,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,6 @@ import type {
|
||||
Statistics,
|
||||
} from "./types.ts";
|
||||
import { getParticipantId, OutdatedKeyFilter } from "./utils.ts";
|
||||
import {
|
||||
type EnabledTransports,
|
||||
RoomAndToDeviceEvents,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
|
||||
/**
|
||||
* RTCEncryptionManager is used to manage the encryption keys for a call.
|
||||
@@ -137,10 +132,6 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
this.useKeyDelay = joinConfig?.useKeyDelay ?? 1000;
|
||||
this.keyRotationGracePeriodMs = joinConfig?.keyRotationGracePeriodMs ?? 10_000;
|
||||
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
// Deprecate RoomKeyTransport: this can get removed.
|
||||
if (this.transport instanceof RoomAndToDeviceTransport) {
|
||||
this.transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, this.onTransportChanged);
|
||||
}
|
||||
|
||||
this.transport.start();
|
||||
}
|
||||
@@ -151,29 +142,6 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
this.participantKeyRings.clear();
|
||||
}
|
||||
|
||||
// Temporary for backwards compatibility
|
||||
// TODO: Remove this in the future
|
||||
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
|
||||
this.logger?.info("Transport change detected, restarting key distribution");
|
||||
if (this.currentKeyDistributionPromise) {
|
||||
this.currentKeyDistributionPromise
|
||||
.then(() => {
|
||||
if (this.outboundSession) {
|
||||
this.outboundSession.sharedWith = [];
|
||||
this.ensureKeyDistribution();
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger?.error("Failed to restart key distribution", e);
|
||||
});
|
||||
} else {
|
||||
if (this.outboundSession) {
|
||||
this.outboundSession.sharedWith = [];
|
||||
this.ensureKeyDistribution();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Will ensure that a new key is distributed and used to encrypt our media.
|
||||
* If there is already a key distribution in progress, it will schedule a new distribution round just after the current one is completed.
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger, type Logger } from "../logger.ts";
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import type { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import { NotSupportedError, type ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type ParticipantDeviceInfo } from "./types.ts";
|
||||
|
||||
// Deprecate RoomAndToDeviceTransport: This whole class is only a stop gap until we remove RoomKeyTransport.
|
||||
export interface EnabledTransports {
|
||||
toDevice: boolean;
|
||||
room: boolean;
|
||||
}
|
||||
|
||||
export enum RoomAndToDeviceEvents {
|
||||
EnabledTransportsChanged = "enabled_transports_changed",
|
||||
}
|
||||
export type RoomAndToDeviceEventsHandlerMap = {
|
||||
[RoomAndToDeviceEvents.EnabledTransportsChanged]: (enabledTransports: EnabledTransports) => void;
|
||||
};
|
||||
/**
|
||||
* A custom transport that subscribes to room key events (via `RoomKeyTransport`) and to device key events (via: `ToDeviceKeyTransport`)
|
||||
* The public setEnabled method allows to turn one or the other on or off on the fly.
|
||||
* It will emit `RoomAndToDeviceEvents.EnabledTransportsChanged` if the enabled transport changes to allow comminitcating this to
|
||||
* the user in the ui.
|
||||
*
|
||||
* Since it will always subscribe to both (room and to device) but only emit for the enabled ones, it can detect
|
||||
* if a room key event was received and autoenable it.
|
||||
*/
|
||||
export class RoomAndToDeviceTransport
|
||||
extends TypedEventEmitter<
|
||||
KeyTransportEvents | RoomAndToDeviceEvents,
|
||||
KeyTransportEventsHandlerMap & RoomAndToDeviceEventsHandlerMap
|
||||
>
|
||||
implements IKeyTransport
|
||||
{
|
||||
private readonly logger: Logger;
|
||||
private _enabled: EnabledTransports = { toDevice: true, room: false };
|
||||
public constructor(
|
||||
private toDeviceTransport: ToDeviceKeyTransport,
|
||||
private roomKeyTransport: RoomKeyTransport,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[RoomAndToDeviceTransport]`);
|
||||
// update parent loggers for the sub transports so filtering for `RoomAndToDeviceTransport` contains their logs too
|
||||
this.toDeviceTransport.setParentLogger(this.logger);
|
||||
this.roomKeyTransport.setParentLogger(this.logger);
|
||||
|
||||
this.roomKeyTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
|
||||
// Turn on the room transport if we receive a roomKey from another participant
|
||||
// and disable the toDevice transport.
|
||||
if (!this._enabled.room) {
|
||||
this.logger.debug("Received room key, enabling room key transport, disabling toDevice transport");
|
||||
this.setEnabled({ toDevice: false, room: true });
|
||||
}
|
||||
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
|
||||
});
|
||||
this.toDeviceTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
|
||||
if (this._enabled.toDevice) {
|
||||
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
|
||||
} else {
|
||||
this.logger.debug("To Device transport is disabled, ignoring received keys");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Set which transport type should be used to send and receive keys.*/
|
||||
public setEnabled(enabled: { toDevice: boolean; room: boolean }): void {
|
||||
if (this.enabled.toDevice !== enabled.toDevice || this.enabled.room !== enabled.room) {
|
||||
this._enabled = enabled;
|
||||
this.emit(RoomAndToDeviceEvents.EnabledTransportsChanged, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/** The currently enabled transports that are used to send and receive keys.*/
|
||||
public get enabled(): EnabledTransports {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
// always start the underlying transport since we need to enable room transport
|
||||
// when someone else sends us a room key. (we need to listen to roomKeyTransport)
|
||||
this.roomKeyTransport.start();
|
||||
this.toDeviceTransport.start();
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
// always stop since it is always running
|
||||
this.roomKeyTransport.stop();
|
||||
this.toDeviceTransport.stop();
|
||||
}
|
||||
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Sending key with index ${index} to call members (count=${members.length}) via:` +
|
||||
(this._enabled.room ? "room transport" : "") +
|
||||
(this._enabled.room && this._enabled.toDevice ? "and" : "") +
|
||||
(this._enabled.toDevice ? "to device transport" : ""),
|
||||
);
|
||||
if (this._enabled.room) await this.roomKeyTransport.sendKey(keyBase64Encoded, index, members);
|
||||
if (this._enabled.toDevice) {
|
||||
try {
|
||||
await this.toDeviceTransport.sendKey(keyBase64Encoded, index, members);
|
||||
} catch (error) {
|
||||
if (error instanceof NotSupportedError && !this._enabled.room) {
|
||||
this.logger.warn(
|
||||
"To device is not supported enabling room key transport, disabling toDevice transport",
|
||||
);
|
||||
this.setEnabled({ toDevice: false, room: true });
|
||||
await this.sendKey(keyBase64Encoded, index, members);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Information about a MatrixRTC conference focus. The only attribute that
|
||||
* the js-sdk (currently) knows about is the type: applications can extend
|
||||
* this class for different types of focus.
|
||||
*/
|
||||
export interface Focus {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -15,8 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
export * from "./CallMembership.ts";
|
||||
export type * from "./focus.ts";
|
||||
export * from "./LivekitFocus.ts";
|
||||
export * from "./LivekitTransport.ts";
|
||||
export * from "./MatrixRTCSession.ts";
|
||||
export * from "./MatrixRTCSessionManager.ts";
|
||||
export type * from "./types.ts";
|
||||
|
||||
+57
-3
@@ -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 type { IMentions } from "../matrix.ts";
|
||||
import type { IContent, IMentions } from "../matrix.ts";
|
||||
import type { RelationEvent } from "../types.ts";
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
|
||||
@@ -96,10 +96,56 @@ export interface ICallNotifyContent {
|
||||
}
|
||||
|
||||
export type RTCNotificationType = "ring" | "notification";
|
||||
|
||||
/**
|
||||
* Represents the intention of the call from the perspective of the sending user.
|
||||
* May be any string, although `"audio"` and `"video"` are commonly accepted values.
|
||||
*/
|
||||
export type RTCCallIntent = "audio" | "video" | string;
|
||||
|
||||
/**
|
||||
* This will check if the content has all the expected fields to be a valid IRTCNotificationContent.
|
||||
* It will also cap the lifetime to 90000ms (1.5 min) if a higher value is provided.
|
||||
* @param content
|
||||
* @throws if the content is invalid
|
||||
* @returns a parsed IRTCNotificationContent
|
||||
*/
|
||||
export function parseCallNotificationContent(content: IContent): IRTCNotificationContent {
|
||||
if (content["m.mentions"] && typeof content["m.mentions"] !== "object") {
|
||||
throw new Error("malformed m.mentions");
|
||||
}
|
||||
if (typeof content["notification_type"] !== "string") {
|
||||
throw new Error("Missing or invalid notification_type");
|
||||
}
|
||||
if (typeof content["sender_ts"] !== "number") {
|
||||
throw new Error("Missing or invalid sender_ts");
|
||||
}
|
||||
if (typeof content["lifetime"] !== "number") {
|
||||
throw new Error("Missing or invalid lifetime");
|
||||
}
|
||||
|
||||
if (content["relation"] && content["relation"]["rel_type"] !== "m.reference") {
|
||||
throw new Error("Invalid relation");
|
||||
}
|
||||
if (content["m.call.intent"] && typeof content["m.call.intent"] !== "string") {
|
||||
throw new Error("Invalid m.call.intent");
|
||||
}
|
||||
|
||||
const cappedLifetime = content["lifetime"] >= 90000 ? 90000 : content["lifetime"];
|
||||
return { ...content, lifetime: cappedLifetime } as IRTCNotificationContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for `org.matrix.msc4075.rtc.notification` events.
|
||||
* Don't cast event content to this directly. Use `parseCallNotificationContent` instead to validate the content first.
|
||||
*/
|
||||
export interface IRTCNotificationContent extends RelationEvent {
|
||||
"m.mentions": IMentions;
|
||||
"decline_reason"?: string;
|
||||
"m.mentions"?: IMentions;
|
||||
"notification_type": RTCNotificationType;
|
||||
/**
|
||||
* The initial intent of the calling user.
|
||||
*/
|
||||
"m.call.intent"?: RTCCallIntent;
|
||||
"sender_ts": number;
|
||||
"lifetime": number;
|
||||
}
|
||||
@@ -146,3 +192,11 @@ export type Statistics = {
|
||||
|
||||
export const isMyMembership = (m: CallMembership, userId: string, deviceId: string): boolean =>
|
||||
m.sender === userId && m.deviceId === deviceId;
|
||||
|
||||
/**
|
||||
* A RTC transport is a JSON object that describes how to connect to a RTC member.
|
||||
*/
|
||||
export interface Transport {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import promiseRetry from "p-retry";
|
||||
|
||||
import { type MatrixClient } from "../client.ts";
|
||||
import { EventType, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../@types/event.ts";
|
||||
import { type Room } from "./room.ts";
|
||||
@@ -31,7 +29,7 @@ import {
|
||||
} from "../utils.ts";
|
||||
import { MSC3089Branch } from "./MSC3089Branch.ts";
|
||||
import { type ISendEventResponse } from "../@types/requests.ts";
|
||||
import { type FileType } from "../http-api/index.ts";
|
||||
import { type FileType, MatrixError } from "../http-api/index.ts";
|
||||
import { KnownMembership } from "../@types/membership.ts";
|
||||
import { type RoomPowerLevelsEventContent, type SpaceChildEventContent } from "../@types/state_events.ts";
|
||||
import type { EncryptedFile, FileContent } from "../@types/media.ts";
|
||||
@@ -146,16 +144,17 @@ export class MSC3089TreeSpace {
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
private retryInvite(userId: string): Promise<void> {
|
||||
return simpleRetryOperation(async () => {
|
||||
await this.client.invite(this.roomId, userId).catch((e) => {
|
||||
private async retryInvite(userId: string): Promise<void> {
|
||||
await simpleRetryOperation(
|
||||
() => this.client.invite(this.roomId, userId),
|
||||
(e) => {
|
||||
// We don't want to retry permission errors forever...
|
||||
if (e?.errcode === "M_FORBIDDEN") {
|
||||
throw new promiseRetry.AbortError(e);
|
||||
if (e instanceof MatrixError && e.errcode === "M_FORBIDDEN") {
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+66
-4
@@ -75,6 +75,7 @@ export interface IUnsigned {
|
||||
"transaction_id"?: string;
|
||||
"invite_room_state"?: StrippedState[];
|
||||
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
|
||||
"msc4354_sticky_duration_ttl_ms"?: number;
|
||||
[UNSIGNED_THREAD_ID_FIELD.name]?: string;
|
||||
}
|
||||
|
||||
@@ -96,6 +97,7 @@ export interface IEvent {
|
||||
membership?: Membership;
|
||||
unsigned: IUnsigned;
|
||||
redacts?: string;
|
||||
msc4354_sticky?: { duration_ms: number };
|
||||
}
|
||||
|
||||
export interface IAggregatedRelation {
|
||||
@@ -158,6 +160,7 @@ export interface IMarkedUnreadEvent {
|
||||
export interface IClearEvent {
|
||||
room_id?: string;
|
||||
type: string;
|
||||
state_key?: string;
|
||||
content: Omit<IContent, "membership" | "avatar_url" | "displayname" | "m.relates_to">;
|
||||
unsigned?: IUnsigned;
|
||||
}
|
||||
@@ -212,6 +215,7 @@ export interface IMessageVisibilityHidden {
|
||||
}
|
||||
// A singleton implementing `IMessageVisibilityVisible`.
|
||||
const MESSAGE_VISIBLE: IMessageVisibilityVisible = Object.freeze({ visible: true });
|
||||
export const MAX_STICKY_DURATION_MS = 3600000;
|
||||
|
||||
export enum MatrixEventEvent {
|
||||
/**
|
||||
@@ -407,6 +411,17 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
|
||||
private readonly reEmitter: TypedReEmitter<MatrixEventEmittedEvents, MatrixEventHandlerMap>;
|
||||
|
||||
/**
|
||||
* The timestamp for when this event should expire, in milliseconds.
|
||||
* Prefers using the server-provided value, but will fall back to local calculation.
|
||||
*
|
||||
* This value is **safe** to use, as malicious start time and duration are appropriately capped.
|
||||
*
|
||||
* If the event is not a sticky event (or not supported by the server),
|
||||
* then this returns `undefined`.
|
||||
*/
|
||||
public readonly unstableStickyExpiresAt: number | undefined;
|
||||
|
||||
/**
|
||||
* Construct a Matrix Event object
|
||||
*
|
||||
@@ -446,8 +461,17 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
// The fallback in these cases will be to use the origin_server_ts.
|
||||
// For EDUs, the origin_server_ts also is not defined so we use Date.now().
|
||||
const age = this.getAge();
|
||||
this.localTimestamp = age !== undefined ? Date.now() - age : (this.getTs() ?? Date.now());
|
||||
const now = Date.now();
|
||||
this.localTimestamp = age !== undefined ? now - age : (this.getTs() ?? now);
|
||||
this.reEmitter = new TypedReEmitter(this);
|
||||
if (this.unstableStickyInfo) {
|
||||
if (this.unstableStickyInfo.duration_ttl_ms) {
|
||||
this.unstableStickyExpiresAt = now + this.unstableStickyInfo.duration_ttl_ms;
|
||||
} else {
|
||||
// Bound the timestamp so it doesn't come from the future.
|
||||
this.unstableStickyExpiresAt = Math.min(now, this.getTs()) + this.unstableStickyInfo.duration_ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -728,11 +752,25 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the event state_key if it has one. This will return <code>undefined
|
||||
* </code> for message events.
|
||||
* Get the event state_key if it has one. If necessary, this will perform
|
||||
* string-unpacking on the state key, as per MSC3414. This will return
|
||||
* <code>undefined</code> for message events.
|
||||
* @returns The event's `state_key`.
|
||||
*/
|
||||
public getStateKey(): string | undefined {
|
||||
if (this.clearEvent) {
|
||||
return this.clearEvent.state_key;
|
||||
}
|
||||
return this.event.state_key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw event state_key if it has one. This may be string-packed as per
|
||||
* MSC3414 if the state event is encrypted. This will return <code>undefined
|
||||
* </code> for message events.
|
||||
* @returns The event's `state_key`.
|
||||
*/
|
||||
public getWireStateKey(): string | undefined {
|
||||
return this.event.state_key;
|
||||
}
|
||||
|
||||
@@ -785,11 +823,17 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
this.clearEvent = {
|
||||
type: this.event.type!,
|
||||
content: this.event.content!,
|
||||
state_key: this.event.state_key,
|
||||
};
|
||||
this.event.type = cryptoType;
|
||||
this.event.content = cryptoContent;
|
||||
this.senderCurve25519Key = senderCurve25519Key;
|
||||
this.claimedEd25519Key = claimedEd25519Key;
|
||||
|
||||
// if this is a state event, pack cleartext type and statekey
|
||||
if (this.isState()) {
|
||||
this.event.state_key = `${this.clearEvent!.type}:${this.clearEvent!.state_key}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1025,7 +1069,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* @returns True if this event is encrypted.
|
||||
*/
|
||||
public isEncrypted(): boolean {
|
||||
return !this.isState() && this.event.type === EventType.RoomMessageEncrypted;
|
||||
return this.event.type === EventType.RoomMessageEncrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1718,6 +1762,24 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
public setThreadId(threadId?: string): void {
|
||||
this.threadId = threadId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unstable getter to try and get the sticky information for the event.
|
||||
* If the event is not a sticky event (or not supported by the server),
|
||||
* then this returns `undefined`.
|
||||
*
|
||||
* `duration_ms` is safely bounded to a hour.
|
||||
*/
|
||||
public get unstableStickyInfo(): { duration_ms: number; duration_ttl_ms?: number } | undefined {
|
||||
if (!this.event.msc4354_sticky?.duration_ms) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
duration_ms: Math.min(MAX_STICKY_DURATION_MS, this.event.msc4354_sticky.duration_ms),
|
||||
// This is assumed to be bounded server-side.
|
||||
duration_ttl_ms: this.event.unsigned?.msc4354_sticky_duration_ttl_ms,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* REDACT_KEEP_KEYS gives the keys we keep when an event is redacted
|
||||
|
||||
@@ -1,8 +1,33 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The timezone the user is currently in. The value of this property should
|
||||
* match a timezone provided in https://www.iana.org/time-zones.
|
||||
*
|
||||
* This key was introduced in Matrix v1.16.
|
||||
*/
|
||||
export const ProfileKeyTimezone = "m.tz";
|
||||
|
||||
/**
|
||||
* The timezone the user is currently in. The value of this property should
|
||||
* match a timezone provided in https://www.iana.org/time-zones.
|
||||
*
|
||||
* @see https://github.com/matrix-org/matrix-spec-proposals/blob/clokep/profile-tz/proposals/4175-profile-field-time-zone.md
|
||||
* @experimental
|
||||
* @deprecated Unstable MSC field - Use `ProfileKeyTimezone`
|
||||
*/
|
||||
export const ProfileKeyMSC4175Timezone = "us.cloke.msc4175.tz";
|
||||
|
||||
@@ -388,7 +388,7 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
|
||||
}
|
||||
}
|
||||
|
||||
const MXID_PATTERN = /@.+:.+/;
|
||||
export const MXID_PATTERN = /@.+:.+/;
|
||||
const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/;
|
||||
|
||||
function shouldDisambiguate(selfUserId: string, displayName?: string, roomState?: RoomState): boolean {
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { logger as loggerInstance } from "../logger.ts";
|
||||
import { type MatrixEvent } from "./event.ts";
|
||||
import { TypedEventEmitter } from "./typed-event-emitter.ts";
|
||||
|
||||
const logger = loggerInstance.getChild("RoomStickyEvents");
|
||||
|
||||
export enum RoomStickyEventsEvent {
|
||||
Update = "RoomStickyEvents.Update",
|
||||
}
|
||||
|
||||
export type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number };
|
||||
|
||||
export type RoomStickyEventsMap = {
|
||||
/**
|
||||
* Fires when any sticky event changes happen in a room.
|
||||
* @param added Any new sticky events with no predecessor events (matching sender, type, and sticky_key)
|
||||
* @param updated Any sticky events that supersede an existing event (matching sender, type, and sticky_key)
|
||||
* @param removed The events that were removed from the map due to expiry.
|
||||
*/
|
||||
[RoomStickyEventsEvent.Update]: (
|
||||
added: StickyMatrixEvent[],
|
||||
updated: { current: StickyMatrixEvent; previous: StickyMatrixEvent }[],
|
||||
removed: StickyMatrixEvent[],
|
||||
) => void;
|
||||
};
|
||||
|
||||
type UserId = `@${string}`;
|
||||
|
||||
function assertIsUserId(value: unknown): asserts value is UserId {
|
||||
if (typeof value !== "string") throw new Error("Not a string");
|
||||
if (!value.startsWith("@")) throw new Error("Not a userId");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks sticky events on behalf of one room, and fires an event
|
||||
* whenever a sticky event is updated or replaced.
|
||||
*/
|
||||
export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEvent, RoomStickyEventsMap> {
|
||||
/**
|
||||
* Sticky event map is a nested map of:
|
||||
* eventType -> `content.sticky_key sender` -> StickyMatrixEvent[]
|
||||
*
|
||||
* The events are ordered in latest to earliest expiry, so that the first event
|
||||
* in the array will always be the "current" one.
|
||||
*/
|
||||
private readonly stickyEventsMap = new Map<string, Map<string, StickyMatrixEvent[]>>();
|
||||
/**
|
||||
* These are sticky events that have no sticky key and therefore exist outside the tuple
|
||||
* system above. They are just held in this Set until they expire.
|
||||
*/
|
||||
private readonly unkeyedStickyEvents = new Set<StickyMatrixEvent>();
|
||||
|
||||
private stickyEventTimer?: ReturnType<typeof setTimeout>;
|
||||
private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
/**
|
||||
* Sort two sticky events by order of expiry. This assumes the sticky events have the same
|
||||
* `type`, `sticky_key` and `sender`.
|
||||
* @returns A positive value if event A will expire sooner, or a negative value if event B will expire sooner.
|
||||
*/
|
||||
private static sortStickyEvent(eventA: StickyMatrixEvent, eventB: StickyMatrixEvent): number {
|
||||
// Sticky events with the same key have to use the same expiration duration.
|
||||
// Hence, comparing via `origin_server_ts` yields the exact same result as comparing their expiration time.
|
||||
if (eventB.getTs() !== eventA.getTs()) {
|
||||
return eventB.getTs() - eventA.getTs();
|
||||
}
|
||||
|
||||
if ((eventB.getId() ?? "") > (eventA.getId() ?? "")) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// This should fail as we've got corruption in our sticky array.
|
||||
throw Error("Comparing two sticky events with the same event ID is not allowed.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the correct key for an event to be found in the inner maps of `stickyEventsMap`.
|
||||
* @param stickyKey The sticky key of an event.
|
||||
* @param sender The sender of the event.
|
||||
*/
|
||||
private static stickyMapKey(stickyKey: string, sender: UserId): string {
|
||||
return `${stickyKey}${sender}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sticky events that are currently active.
|
||||
* @returns An iterable set of events.
|
||||
*/
|
||||
public *getStickyEvents(): Iterable<StickyMatrixEvent> {
|
||||
yield* this.unkeyedStickyEvents;
|
||||
for (const innerMap of this.stickyEventsMap.values()) {
|
||||
// Inner map contains a map of sender+stickykeys => all sticky events
|
||||
for (const events of innerMap.values()) {
|
||||
// The first sticky event is the "current" one in the sticky map.
|
||||
yield events[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an active sticky event that match the given `type`, `sender`, and `stickyKey`
|
||||
* @param type The event `type`.
|
||||
* @param sender The sender of the sticky event.
|
||||
* @param stickyKey The sticky key used by the event.
|
||||
* @returns A matching active sticky event, or undefined.
|
||||
*/
|
||||
public getKeyedStickyEvent(sender: string, type: string, stickyKey: string): StickyMatrixEvent | undefined {
|
||||
assertIsUserId(sender);
|
||||
return this.stickyEventsMap.get(type)?.get(RoomStickyEventsStore.stickyMapKey(stickyKey, sender))?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active sticky events without a sticky key that match the given `type` and `sender`.
|
||||
* @param type The event `type`.
|
||||
* @param sender The sender of the sticky event.
|
||||
* @returns An array of matching sticky events.
|
||||
*/
|
||||
public getUnkeyedStickyEvent(sender: string, type: string): StickyMatrixEvent[] {
|
||||
return [...this.unkeyedStickyEvents].filter((ev) => ev.getType() === type && ev.getSender() === sender);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a sticky event into the local sticky event map.
|
||||
*
|
||||
* NOTE: This will not cause `RoomEvent.StickyEvents` to be emitted.
|
||||
*
|
||||
* @throws If the `event` does not contain valid sticky data.
|
||||
* @param event The MatrixEvent that contains sticky data.
|
||||
* @returns An object describing whether the event was added to the map,
|
||||
* and the previous event it may have replaced.
|
||||
*/
|
||||
private addStickyEvent(event: MatrixEvent): { added: true; prevEvent?: StickyMatrixEvent } | { added: false } {
|
||||
const stickyKey = event.getContent().msc4354_sticky_key;
|
||||
if (typeof stickyKey !== "string" && stickyKey !== undefined) {
|
||||
throw new Error(`${event.getId()} is missing msc4354_sticky_key`);
|
||||
}
|
||||
|
||||
// With this we have the guarantee, that all events in stickyEventsMap are correctly formatted
|
||||
if (event.unstableStickyExpiresAt === undefined) {
|
||||
throw new Error(`${event.getId()} is missing msc4354_sticky.duration_ms`);
|
||||
}
|
||||
const sender = event.getSender();
|
||||
const type = event.getType();
|
||||
assertIsUserId(sender);
|
||||
if (event.unstableStickyExpiresAt <= Date.now()) {
|
||||
logger.info("ignored sticky event with older expiration time than current time", stickyKey);
|
||||
return { added: false };
|
||||
}
|
||||
|
||||
// While we fully expect the server to always provide the correct value,
|
||||
// this is just insurance to protect against attacks on our Map.
|
||||
if (!sender.startsWith("@")) {
|
||||
throw new Error("Expected sender to start with @");
|
||||
}
|
||||
|
||||
const stickyEvent = event as StickyMatrixEvent;
|
||||
|
||||
if (stickyKey === undefined) {
|
||||
this.unkeyedStickyEvents.add(stickyEvent);
|
||||
// Recalculate the next expiry time.
|
||||
this.nextStickyEventExpiryTs = Math.min(event.unstableStickyExpiresAt, this.nextStickyEventExpiryTs);
|
||||
|
||||
this.scheduleStickyTimer();
|
||||
return { added: true };
|
||||
}
|
||||
|
||||
// Why this is safe:
|
||||
// A type may contain anything but the *sender* is tightly
|
||||
// constrained so that a key will always end with a @<user_id>
|
||||
// E.g. Where a malicious event type might be "rtc.member.event@foo:bar" the key becomes:
|
||||
// "rtc.member.event.@foo:bar@bar:baz"
|
||||
const innerMapKey = RoomStickyEventsStore.stickyMapKey(stickyKey, sender);
|
||||
const currentEventSet = [stickyEvent, ...(this.stickyEventsMap.get(type)?.get(innerMapKey) ?? [])].sort(
|
||||
RoomStickyEventsStore.sortStickyEvent,
|
||||
);
|
||||
if (!this.stickyEventsMap.has(type)) {
|
||||
this.stickyEventsMap.set(type, new Map());
|
||||
}
|
||||
this.stickyEventsMap.get(type)?.set(innerMapKey, currentEventSet);
|
||||
|
||||
// Recalculate the next expiry time.
|
||||
this.nextStickyEventExpiryTs = Math.min(stickyEvent.unstableStickyExpiresAt, this.nextStickyEventExpiryTs);
|
||||
|
||||
this.scheduleStickyTimer();
|
||||
return {
|
||||
added: currentEventSet[0] === stickyEvent,
|
||||
prevEvent: currentEventSet?.[1],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any
|
||||
* changes were made.
|
||||
* @param events A set of new sticky events.
|
||||
*/
|
||||
public addStickyEvents(events: MatrixEvent[]): void {
|
||||
const added: StickyMatrixEvent[] = [];
|
||||
const updated: { current: StickyMatrixEvent; previous: StickyMatrixEvent }[] = [];
|
||||
for (const event of events) {
|
||||
try {
|
||||
const result = this.addStickyEvent(event);
|
||||
if (result.added) {
|
||||
if (result.prevEvent) {
|
||||
// e is validated as a StickyMatrixEvent by virtue of `addStickyEvent` returning added: true.
|
||||
updated.push({ current: event as StickyMatrixEvent, previous: result.prevEvent });
|
||||
} else {
|
||||
added.push(event as StickyMatrixEvent);
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
logger.warn("ignored invalid sticky event", ex);
|
||||
}
|
||||
}
|
||||
if (added.length || updated.length) this.emit(RoomStickyEventsEvent.Update, added, updated, []);
|
||||
this.scheduleStickyTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the sticky event expiry timer. The timer will
|
||||
* run immediately if an event has already expired.
|
||||
*/
|
||||
private scheduleStickyTimer(): void {
|
||||
if (this.stickyEventTimer) {
|
||||
clearTimeout(this.stickyEventTimer);
|
||||
this.stickyEventTimer = undefined;
|
||||
}
|
||||
if (this.nextStickyEventExpiryTs === Number.MAX_SAFE_INTEGER) {
|
||||
// We have no events due to expire.
|
||||
return;
|
||||
} // otherwise, schedule in the future
|
||||
this.stickyEventTimer = setTimeout(this.cleanExpiredStickyEvents, this.nextStickyEventExpiryTs - Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean out any expired sticky events.
|
||||
*/
|
||||
private readonly cleanExpiredStickyEvents = (): void => {
|
||||
const now = Date.now();
|
||||
const removedEvents: StickyMatrixEvent[] = [];
|
||||
|
||||
// We will recalculate this as we check all events.
|
||||
this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER;
|
||||
for (const [eventType, innerEvents] of this.stickyEventsMap.entries()) {
|
||||
for (const [innerMapKey, [currentEvent, ...previousEvents]] of innerEvents) {
|
||||
// we only added items with `sticky` into this map so we can assert non-null here
|
||||
if (now >= currentEvent.unstableStickyExpiresAt) {
|
||||
logger.debug("Expiring sticky event", currentEvent.getId());
|
||||
removedEvents.push(currentEvent);
|
||||
this.stickyEventsMap.get(eventType)!.delete(innerMapKey);
|
||||
} else {
|
||||
// Ensure we remove any previous events which have now expired, to avoid unbounded memory consumption.
|
||||
this.stickyEventsMap
|
||||
.get(eventType)!
|
||||
.set(innerMapKey, [
|
||||
currentEvent,
|
||||
...previousEvents.filter((e) => e.unstableStickyExpiresAt <= now),
|
||||
]);
|
||||
// If not removing the event, check to see if it's the next lowest expiry.
|
||||
this.nextStickyEventExpiryTs = Math.min(
|
||||
this.nextStickyEventExpiryTs,
|
||||
currentEvent.unstableStickyExpiresAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Clean up map after use.
|
||||
if (this.stickyEventsMap.get(eventType)?.size === 0) {
|
||||
this.stickyEventsMap.delete(eventType);
|
||||
}
|
||||
}
|
||||
for (const event of this.unkeyedStickyEvents) {
|
||||
if (now >= event.unstableStickyExpiresAt) {
|
||||
logger.debug("Expiring sticky event", event.getId());
|
||||
this.unkeyedStickyEvents.delete(event);
|
||||
removedEvents.push(event);
|
||||
} else {
|
||||
// If not removing the event, check to see if it's the next lowest expiry.
|
||||
this.nextStickyEventExpiryTs = Math.min(this.nextStickyEventExpiryTs, event.unstableStickyExpiresAt);
|
||||
}
|
||||
}
|
||||
if (removedEvents.length) {
|
||||
this.emit(RoomStickyEventsEvent.Update, [], [], removedEvents);
|
||||
}
|
||||
// Finally, schedule the next run.
|
||||
this.scheduleStickyTimer();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles incoming event redactions. Checks the sticky map
|
||||
* for any active sticky events being redacted.
|
||||
* @param redactedEvent The MatrixEvent OR event ID of the event being redacted. MAY not be a sticky event.
|
||||
*/
|
||||
public handleRedaction(redactedEvent: MatrixEvent | string): void {
|
||||
// Note, we do not adjust`nextStickyEventExpiryTs` here.
|
||||
// If this event happens to be the most recent expiring event
|
||||
// then we may do one extra iteration of cleanExpiredStickyEvents
|
||||
// but this saves us having to iterate over all events here to calculate
|
||||
// the next expiry time.
|
||||
|
||||
// Note, as soon as we find a positive match on an event in this function
|
||||
// we can return. There is no need to continue iterating on a positive match
|
||||
// as an event can only appear in one map.
|
||||
|
||||
// Handle unkeyedStickyEvents first since it's *quick*.
|
||||
const redactEventId = typeof redactedEvent === "string" ? redactedEvent : redactedEvent.getId();
|
||||
for (const event of this.unkeyedStickyEvents) {
|
||||
if (event.getId() === redactEventId) {
|
||||
this.unkeyedStickyEvents.delete(event);
|
||||
this.emit(RoomStickyEventsEvent.Update, [], [], [event]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Faster method of finding the event since we have the event cached.
|
||||
if (typeof redactedEvent !== "string" && !redactedEvent.isRedacted()) {
|
||||
const stickyKey = redactedEvent.getContent().msc4354_sticky_key;
|
||||
if (typeof stickyKey !== "string" && stickyKey !== undefined) {
|
||||
return; // Not a sticky event.
|
||||
}
|
||||
const eventType = redactedEvent.getType();
|
||||
const sender = redactedEvent.getSender();
|
||||
assertIsUserId(sender);
|
||||
const innerMap = this.stickyEventsMap.get(eventType);
|
||||
if (!innerMap) {
|
||||
return;
|
||||
}
|
||||
const mapKey = RoomStickyEventsStore.stickyMapKey(stickyKey, sender);
|
||||
const [currentEvent, ...previousEvents] = innerMap.get(mapKey) ?? [];
|
||||
if (!currentEvent) {
|
||||
// No event current in the map so ignore.
|
||||
return;
|
||||
}
|
||||
logger.debug(`Redaction for ${redactEventId} under sticky key ${stickyKey}`);
|
||||
// Revert to previous state, taking care to skip any other redacted events.
|
||||
const newEvents = previousEvents.filter((e) => !e.isRedacted()).sort(RoomStickyEventsStore.sortStickyEvent);
|
||||
this.stickyEventsMap.get(eventType)?.set(mapKey, newEvents);
|
||||
if (newEvents.length) {
|
||||
this.emit(
|
||||
RoomStickyEventsEvent.Update,
|
||||
[],
|
||||
[
|
||||
{
|
||||
// This looks confusing. This emits that the newer event
|
||||
// has been redacted and the previous event has taken it's place.
|
||||
previous: currentEvent,
|
||||
current: newEvents[0],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
} else {
|
||||
// We did not find a previous event, so just expire.
|
||||
innerMap.delete(mapKey);
|
||||
if (innerMap.size === 0) {
|
||||
this.stickyEventsMap.delete(eventType);
|
||||
}
|
||||
this.emit(RoomStickyEventsEvent.Update, [], [], [currentEvent]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// We only know the event ID of the redacted event, so we need to
|
||||
// traverse the map to find our event.
|
||||
for (const innerMap of this.stickyEventsMap.values()) {
|
||||
for (const [currentEvent] of innerMap.values()) {
|
||||
if (currentEvent.getId() !== redactEventId) {
|
||||
continue;
|
||||
}
|
||||
// Found the event.
|
||||
return this.handleRedaction(currentEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all events and stop the timer from firing.
|
||||
*/
|
||||
public clear(): void {
|
||||
this.stickyEventsMap.clear();
|
||||
// Unschedule timer.
|
||||
this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER;
|
||||
this.scheduleStickyTimer();
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ import { compareEventOrdering } from "./compare-event-ordering.ts";
|
||||
import { KnownMembership, type Membership } from "../@types/membership.ts";
|
||||
import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts";
|
||||
import { type MSC4186Hero } from "../sliding-sync.ts";
|
||||
import { RoomStickyEventsStore, RoomStickyEventsEvent, type RoomStickyEventsMap } from "./room-sticky-events.ts";
|
||||
|
||||
// These constants are used as sane defaults when the homeserver doesn't support
|
||||
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
|
||||
@@ -167,6 +168,7 @@ export type RoomEmittedEvents =
|
||||
| RoomStateEvent.NewMember
|
||||
| RoomStateEvent.Update
|
||||
| RoomStateEvent.Marker
|
||||
| RoomStickyEventsEvent.Update
|
||||
| ThreadEvent.New
|
||||
| ThreadEvent.Update
|
||||
| ThreadEvent.NewReply
|
||||
@@ -320,6 +322,7 @@ export type RoomEventHandlerMap = {
|
||||
} & Pick<ThreadHandlerMap, ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete> &
|
||||
EventTimelineSetHandlerMap &
|
||||
Pick<MatrixEventHandlerMap, MatrixEventEvent.BeforeRedaction> &
|
||||
Pick<RoomStickyEventsMap, RoomStickyEventsEvent.Update> &
|
||||
Pick<
|
||||
RoomStateEventHandlerMap,
|
||||
| RoomStateEvent.Events
|
||||
@@ -446,6 +449,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
*/
|
||||
private roomReceipts = new RoomReceipts(this);
|
||||
|
||||
/**
|
||||
* Stores and tracks sticky events
|
||||
*/
|
||||
private stickyEvents = new RoomStickyEventsStore();
|
||||
|
||||
/**
|
||||
* Construct a new Room.
|
||||
*
|
||||
@@ -492,6 +500,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// Listen to our own receipt event as a more modular way of processing our own
|
||||
// receipts. No need to remove the listener: it's on ourself anyway.
|
||||
this.on(RoomEvent.Receipt, this.onReceipt);
|
||||
this.reEmitter.reEmit(this.stickyEvents, [RoomStickyEventsEvent.Update]);
|
||||
|
||||
// all our per-room timeline sets. the first one is the unfiltered ones;
|
||||
// the subsequent ones are the filtered ones in no particular order.
|
||||
@@ -2624,6 +2633,14 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
|
||||
// if we know about this event, redact its contents now.
|
||||
const redactedEvent = redactId ? this.findEventById(redactId) : undefined;
|
||||
if (redactId) {
|
||||
try {
|
||||
this.stickyEvents.handleRedaction(redactedEvent || redactId);
|
||||
} catch (ex) {
|
||||
// Non-critical failure, but we should warn.
|
||||
logger.error("Failed to handle redaction for sticky event", ex);
|
||||
}
|
||||
}
|
||||
if (redactedEvent) {
|
||||
this.applyEventAsRedaction(event, redactedEvent);
|
||||
}
|
||||
@@ -3414,6 +3431,55 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
return this.accountData.get(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an iterator of currently active sticky events.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public _unstable_getStickyEvents(): ReturnType<RoomStickyEventsStore["getStickyEvents"]> {
|
||||
return this.stickyEvents.getStickyEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a sticky event that match the given `type`, `sender`, and `stickyKey`
|
||||
* @param type The event `type`.
|
||||
* @param sender The sender of the sticky event.
|
||||
* @param stickyKey The sticky key used by the event.
|
||||
* @returns A matching active sticky event, or undefined.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public _unstable_getKeyedStickyEvent(
|
||||
sender: string,
|
||||
type: string,
|
||||
stickyKey: string,
|
||||
): ReturnType<RoomStickyEventsStore["getKeyedStickyEvent"]> {
|
||||
return this.stickyEvents.getKeyedStickyEvent(sender, type, stickyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active sticky events without a sticky key that match the given `type` and `sender`.
|
||||
* @param type The event `type`.
|
||||
* @param sender The sender of the sticky event.
|
||||
* @returns An array of matching sticky events.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public _unstable_getUnkeyedStickyEvent(
|
||||
sender: string,
|
||||
type: string,
|
||||
): ReturnType<RoomStickyEventsStore["getUnkeyedStickyEvent"]> {
|
||||
return this.stickyEvents.getUnkeyedStickyEvent(sender, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any
|
||||
* changes were made.
|
||||
* @param events A set of new sticky events.
|
||||
* @internal
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public _unstable_addStickyEvents(events: MatrixEvent[]): ReturnType<RoomStickyEventsStore["addStickyEvents"]> {
|
||||
return this.stickyEvents.addStickyEvents(events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the syncing user has permission to send a message in the room
|
||||
* @returns true if the user should be permitted to send
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { EventType } from "../@types/event.ts";
|
||||
import { type IContent, type MatrixEvent } from "../models/event.ts";
|
||||
import { type MatrixEvent, type IContent } from "../models/event.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type Logger, LogSpan } from "../logger.ts";
|
||||
import { type KeyClaimManager } from "./KeyClaimManager.ts";
|
||||
@@ -314,11 +314,23 @@ export class RoomEncryptor {
|
||||
|
||||
private async encryptEventInner(logger: LogSpan, event: MatrixEvent): Promise<void> {
|
||||
logger.debug("Encrypting actual message content");
|
||||
const encryptedContent = await this.olmMachine.encryptRoomEvent(
|
||||
new RoomId(this.room.roomId),
|
||||
event.getType(),
|
||||
JSON.stringify(event.getContent()),
|
||||
);
|
||||
|
||||
const room = new RoomId(this.room.roomId);
|
||||
const type = event.getType();
|
||||
const content = JSON.stringify(event.getContent());
|
||||
|
||||
let encryptedContent;
|
||||
if (event.isState()) {
|
||||
encryptedContent = await this.olmMachine.encryptStateEvent(
|
||||
room,
|
||||
type,
|
||||
// Safety: we've already checked above that this is a state event, so the state key must exist.
|
||||
event.getStateKey()!,
|
||||
content,
|
||||
);
|
||||
} else {
|
||||
encryptedContent = await this.olmMachine.encryptRoomEvent(room, type, content);
|
||||
}
|
||||
|
||||
event.makeEncrypted(
|
||||
EventType.RoomMessageEncrypted,
|
||||
|
||||
@@ -91,6 +91,11 @@ export async function initRustCrypto(args: {
|
||||
* Called with (-1, -1) to mark the end of migration.
|
||||
*/
|
||||
legacyMigrationProgressListener?: (progress: number, total: number) => void;
|
||||
|
||||
/**
|
||||
* Whether to enable support for encrypting state events.
|
||||
*/
|
||||
enableEncryptedStateEvents?: boolean;
|
||||
}): Promise<RustCrypto> {
|
||||
const { logger } = args;
|
||||
|
||||
@@ -128,6 +133,7 @@ export async function initRustCrypto(args: {
|
||||
args.cryptoCallbacks,
|
||||
storeHandle,
|
||||
args.legacyCryptoStore,
|
||||
args.enableEncryptedStateEvents,
|
||||
);
|
||||
|
||||
storeHandle.free();
|
||||
@@ -145,6 +151,7 @@ async function initOlmMachine(
|
||||
cryptoCallbacks: CryptoCallbacks,
|
||||
storeHandle: StoreHandle,
|
||||
legacyCryptoStore?: CryptoStore,
|
||||
enableEncryptedStateEvents?: boolean,
|
||||
): Promise<RustCrypto> {
|
||||
logger.debug("Init OlmMachine");
|
||||
|
||||
@@ -167,7 +174,16 @@ async function initOlmMachine(
|
||||
// Disable room key requests, per https://github.com/vector-im/element-web/issues/26524.
|
||||
olmMachine.roomKeyRequestsEnabled = false;
|
||||
|
||||
const rustCrypto = new RustCrypto(logger, olmMachine, http, userId, deviceId, secretStorage, cryptoCallbacks);
|
||||
const rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
olmMachine,
|
||||
http,
|
||||
userId,
|
||||
deviceId,
|
||||
secretStorage,
|
||||
cryptoCallbacks,
|
||||
enableEncryptedStateEvents,
|
||||
);
|
||||
|
||||
await olmMachine.registerRoomKeyUpdatedCallback((sessions: RustSdkCryptoJs.RoomKeyInfo[]) =>
|
||||
rustCrypto.onRoomKeysUpdated(sessions),
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
type KeyBackupRestoreOpts,
|
||||
type KeyBackupRestoreResult,
|
||||
type OwnDeviceKeys,
|
||||
type SecretStorageStatus,
|
||||
type StartDehydrationOpts,
|
||||
UserVerificationStatus,
|
||||
type VerificationRequest,
|
||||
@@ -78,7 +79,7 @@ import {
|
||||
type ServerSideSecretStorage,
|
||||
} from "../secret-storage.ts";
|
||||
import { CrossSigningIdentity } from "./CrossSigningIdentity.ts";
|
||||
import { secretStorageCanAccessSecrets, secretStorageContainsCrossSigningKeys } from "./secret-storage.ts";
|
||||
import { secretStorageContainsCrossSigningKeys } from "./secret-storage.ts";
|
||||
import { isVerificationEvent, RustVerificationRequest, verificationMethodIdentifierToMethod } from "./verification.ts";
|
||||
import { EventType, MsgType } from "../@types/event.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
@@ -162,6 +163,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
|
||||
/** Crypto callbacks provided by the application */
|
||||
private readonly cryptoCallbacks: CryptoCallbacks,
|
||||
|
||||
/** Enable support for encrypted state events under MSC3414. */
|
||||
private readonly enableEncryptedStateEvents: boolean = false,
|
||||
) {
|
||||
super();
|
||||
this.outgoingRequestProcessor = new OutgoingRequestProcessor(logger, olmMachine, http);
|
||||
@@ -421,6 +425,16 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
return Boolean(roomSettings?.algorithm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#isStateEncryptionEnabledInRoom}.
|
||||
*/
|
||||
public async isStateEncryptionEnabledInRoom(roomId: string): Promise<boolean> {
|
||||
const roomSettings: RustSdkCryptoJs.RoomSettings | undefined = await this.olmMachine.getRoomSettings(
|
||||
new RustSdkCryptoJs.RoomId(roomId),
|
||||
);
|
||||
return Boolean(roomSettings?.encryptStateEvents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getOwnDeviceKeys}.
|
||||
*/
|
||||
@@ -814,6 +828,13 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
* Implementation of {@link CryptoApi#isSecretStorageReady}
|
||||
*/
|
||||
public async isSecretStorageReady(): Promise<boolean> {
|
||||
return (await this.getSecretStorageStatus()).ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getSecretStorageStatus}
|
||||
*/
|
||||
public async getSecretStorageStatus(): Promise<SecretStorageStatus> {
|
||||
// make sure that the cross-signing keys are stored
|
||||
const secretsToCheck: SecretStorageKey[] = [
|
||||
"m.cross_signing.master",
|
||||
@@ -821,13 +842,32 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
"m.cross_signing.self_signing",
|
||||
];
|
||||
|
||||
// if key backup is active, we also need to check that the backup decryption key is stored
|
||||
// If key backup is active, we also need to check that the backup decryption key is stored
|
||||
const keyBackupEnabled = (await this.backupManager.getActiveBackupVersion()) != null;
|
||||
if (keyBackupEnabled) {
|
||||
secretsToCheck.push("m.megolm_backup.v1");
|
||||
}
|
||||
|
||||
return secretStorageCanAccessSecrets(this.secretStorage, secretsToCheck);
|
||||
const defaultKeyId = await this.secretStorage.getDefaultKeyId();
|
||||
|
||||
const result: SecretStorageStatus = {
|
||||
// Assume we have all secrets until proven otherwise
|
||||
ready: true,
|
||||
defaultKeyId,
|
||||
secretStorageKeyValidityMap: {},
|
||||
};
|
||||
|
||||
for (const secretName of secretsToCheck) {
|
||||
// Check which keys this particular secret is encrypted with
|
||||
const record = (await this.secretStorage.isStored(secretName)) || {};
|
||||
|
||||
// If it's encrypted with the right key, it is valid
|
||||
const secretStored = !!defaultKeyId && defaultKeyId in record;
|
||||
result.secretStorageKeyValidityMap[secretName] = secretStored;
|
||||
result.ready = result.ready && secretStored;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1040,7 +1080,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
new RustSdkCryptoJs.UserId(userId),
|
||||
);
|
||||
return requests
|
||||
.filter((request) => request.roomId === undefined)
|
||||
.filter((request) => request.roomId === undefined && !request.isCancelled())
|
||||
.map((request) => this.makeVerificationRequest(request));
|
||||
}
|
||||
|
||||
@@ -1063,7 +1103,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
);
|
||||
|
||||
// Search for the verification request for the given room id
|
||||
const request = requests.find((request) => request.roomId?.toString() === roomId);
|
||||
const request = requests.find((request) => request.roomId?.toString() === roomId && !request.isCancelled());
|
||||
|
||||
if (request) {
|
||||
return this.makeVerificationRequest(request);
|
||||
@@ -1717,7 +1757,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
await this.receiveSyncChanges({ devices });
|
||||
}
|
||||
|
||||
/** called by the sync loop on m.room.encrypted events
|
||||
/** called by the sync loop on m.room.encryption events
|
||||
*
|
||||
* @param room - in which the event was received
|
||||
* @param event - encryption event to be processed
|
||||
@@ -1734,6 +1774,11 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
return;
|
||||
}
|
||||
|
||||
if (config["io.element.msc3414.encrypt_state_events"] && this.enableEncryptedStateEvents) {
|
||||
this.logger.info("crypto Enabling state event encryption...");
|
||||
settings.encryptStateEvents = true;
|
||||
}
|
||||
|
||||
try {
|
||||
settings.sessionRotationPeriodMs = config.rotation_period_ms;
|
||||
settings.sessionRotationPeriodMessages = config.rotation_period_msgs;
|
||||
@@ -2035,20 +2080,38 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
*/
|
||||
private async onKeyVerificationEvent(event: MatrixEvent): Promise<void> {
|
||||
const roomId = event.getRoomId();
|
||||
const senderId = event.getSender();
|
||||
|
||||
if (!roomId) {
|
||||
throw new Error("missing roomId in the event");
|
||||
}
|
||||
|
||||
if (!senderId) {
|
||||
throw new Error("missing sender in the event");
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Incoming verification event ${event.getId()} type ${event.getType()} from ${event.getSender()}`,
|
||||
);
|
||||
|
||||
await this.olmMachine.receiveVerificationEvent(
|
||||
const isRoomVerificationRequest =
|
||||
event.getType() === EventType.RoomMessage && event.getContent().msgtype === MsgType.KeyVerificationRequest;
|
||||
|
||||
if (isRoomVerificationRequest) {
|
||||
// Before processing an in-room verification request, we need to
|
||||
// make sure we have the sender's device information - otherwise we
|
||||
// will immediately abort verification. So we explicitly fetch it
|
||||
// from /keys/query and wait for that request to complete before we
|
||||
// call receiveVerificationEvent.
|
||||
const req = this.getOlmMachineOrThrow().queryKeysForUsers([new RustSdkCryptoJs.UserId(senderId)]);
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(req);
|
||||
}
|
||||
|
||||
await this.getOlmMachineOrThrow().receiveVerificationEvent(
|
||||
JSON.stringify({
|
||||
event_id: event.getId(),
|
||||
type: event.getType(),
|
||||
sender: event.getSender(),
|
||||
sender: senderId,
|
||||
state_key: event.getStateKey(),
|
||||
content: event.getContent(),
|
||||
origin_server_ts: event.getTs(),
|
||||
@@ -2056,11 +2119,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
new RustSdkCryptoJs.RoomId(roomId),
|
||||
);
|
||||
|
||||
if (
|
||||
event.getType() === EventType.RoomMessage &&
|
||||
event.getContent().msgtype === MsgType.KeyVerificationRequest
|
||||
) {
|
||||
this.onIncomingKeyVerificationRequest(event.getSender()!, event.getId()!);
|
||||
if (isRoomVerificationRequest) {
|
||||
this.onIncomingKeyVerificationRequest(senderId, event.getId()!);
|
||||
}
|
||||
|
||||
// that may have caused us to queue up outgoing requests, so make sure we send them.
|
||||
|
||||
@@ -64,6 +64,10 @@ export interface Capabilities {
|
||||
"m.set_displayname"?: ISetDisplayNameCapability;
|
||||
"m.set_avatar_url"?: ISetAvatarUrlCapability;
|
||||
"uk.tcpip.msc4133.profile_fields"?: IProfileFieldsCapability;
|
||||
/**
|
||||
* Since Matrix v1.16
|
||||
*/
|
||||
"m.profile_fields"?: IProfileFieldsCapability;
|
||||
}
|
||||
|
||||
type CapabilitiesResponse = {
|
||||
|
||||
+51
-1
@@ -20,7 +20,7 @@ limitations under the License.
|
||||
|
||||
import { logger } from "./logger.ts";
|
||||
import { deepCopy } from "./utils.ts";
|
||||
import { type IContent, type IUnsigned } from "./models/event.ts";
|
||||
import { MAX_STICKY_DURATION_MS, type IContent, type IUnsigned } from "./models/event.ts";
|
||||
import { type IRoomSummary } from "./models/room-summary.ts";
|
||||
import { type EventType } from "./@types/event.ts";
|
||||
import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync.ts";
|
||||
@@ -76,11 +76,25 @@ export interface ITimeline {
|
||||
prev_batch: string | null;
|
||||
}
|
||||
|
||||
type StickyEventFields = {
|
||||
msc4354_sticky: { duration_ms: number };
|
||||
content: { msc4354_sticky_key?: string };
|
||||
};
|
||||
|
||||
export type IStickyEvent = IRoomEvent & StickyEventFields;
|
||||
|
||||
export type IStickyStateEvent = IStateEvent & StickyEventFields;
|
||||
|
||||
export interface ISticky {
|
||||
events: Array<IStickyEvent | IStickyStateEvent>;
|
||||
}
|
||||
|
||||
export interface IJoinedRoom {
|
||||
"summary": IRoomSummary;
|
||||
// One of `state` or `state_after` is required.
|
||||
"state"?: IState;
|
||||
"org.matrix.msc4222.state_after"?: IState; // https://github.com/matrix-org/matrix-spec-proposals/pull/4222
|
||||
"msc4354_sticky"?: ISticky; // https://github.com/matrix-org/matrix-spec-proposals/pull/4354
|
||||
"timeline": ITimeline;
|
||||
"ephemeral": IEphemeral;
|
||||
"account_data": IAccountData;
|
||||
@@ -201,6 +215,14 @@ interface IRoom {
|
||||
_unreadNotifications: Partial<UnreadNotificationCounts>;
|
||||
_unreadThreadNotifications?: Record<string, Partial<UnreadNotificationCounts>>;
|
||||
_receipts: ReceiptAccumulator;
|
||||
_stickyEvents: {
|
||||
readonly event: IStickyEvent | IStickyStateEvent;
|
||||
/**
|
||||
* This is the timestamp at which point it is safe to remove this event from the store.
|
||||
* This value is immutable
|
||||
*/
|
||||
readonly expiresTs: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ISyncData {
|
||||
@@ -411,6 +433,7 @@ export class SyncAccumulator {
|
||||
|
||||
// Accumulate timeline and state events in a room.
|
||||
private accumulateJoinState(roomId: string, data: IJoinedRoom, fromDatabase = false): void {
|
||||
const now = Date.now();
|
||||
// We expect this function to be called a lot (every /sync) so we want
|
||||
// this to be fast. /sync stores events in an array but we often want
|
||||
// to clobber based on type/state_key. Rather than convert arrays to
|
||||
@@ -457,6 +480,7 @@ export class SyncAccumulator {
|
||||
_unreadThreadNotifications: {},
|
||||
_summary: {},
|
||||
_receipts: new ReceiptAccumulator(),
|
||||
_stickyEvents: [],
|
||||
};
|
||||
}
|
||||
const currentData = this.joinRooms[roomId];
|
||||
@@ -540,6 +564,27 @@ export class SyncAccumulator {
|
||||
});
|
||||
});
|
||||
|
||||
// Prune out any events in our stores that have since expired, do this before we
|
||||
// insert new events.
|
||||
currentData._stickyEvents = currentData._stickyEvents.filter(({ expiresTs }) => expiresTs > now);
|
||||
|
||||
// We want this to be fast, so don't worry about duplicate events here. The RoomStickyEventsStore will
|
||||
// process these events into the correct mapped order.
|
||||
if (data.msc4354_sticky?.events) {
|
||||
currentData._stickyEvents = currentData._stickyEvents.concat(
|
||||
data.msc4354_sticky.events.map((event) => {
|
||||
// If `duration_ms` exceeds the spec limit of a hour, we cap it.
|
||||
const cappedDuration = Math.min(event.msc4354_sticky.duration_ms, MAX_STICKY_DURATION_MS);
|
||||
// If `origin_server_ts` claims to have been from the future, we still bound it to now.
|
||||
const createdTs = Math.min(event.origin_server_ts, now);
|
||||
return {
|
||||
event,
|
||||
expiresTs: cappedDuration + createdTs,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// attempt to prune the timeline by jumping between events which have
|
||||
// pagination tokens.
|
||||
if (currentData._timeline.length > this.opts.maxTimelineEntries!) {
|
||||
@@ -611,6 +656,11 @@ export class SyncAccumulator {
|
||||
"unread_notifications": roomData._unreadNotifications,
|
||||
"unread_thread_notifications": roomData._unreadThreadNotifications,
|
||||
"summary": roomData._summary as IRoomSummary,
|
||||
"msc4354_sticky": roomData._stickyEvents?.length
|
||||
? {
|
||||
events: roomData._stickyEvents.map((e) => e.event),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
// Add account data
|
||||
Object.keys(roomData._accountData).forEach((evType) => {
|
||||
|
||||
+33
-1
@@ -1082,6 +1082,8 @@ export class SyncApi {
|
||||
// highlight_count: 0,
|
||||
// notification_count: 0,
|
||||
// }
|
||||
// "org.matrix.msc4222.state_after": { events: [] }, // only if "org.matrix.msc4222.use_state_after" is true
|
||||
// msc4354_sticky: { events: [] }, // only if "org.matrix.msc4354.sticky" is true
|
||||
// }
|
||||
// },
|
||||
// leave: {
|
||||
@@ -1219,6 +1221,7 @@ export class SyncApi {
|
||||
const timelineEvents = this.mapSyncEventsFormat(joinObj.timeline, room, false);
|
||||
const ephemeralEvents = this.mapSyncEventsFormat(joinObj.ephemeral);
|
||||
const accountDataEvents = this.mapSyncEventsFormat(joinObj.account_data);
|
||||
const stickyEvents = this.mapSyncEventsFormat(joinObj.msc4354_sticky);
|
||||
|
||||
// If state_after is present, this is the events that form the state at the end of the timeline block and
|
||||
// regular timeline events do *not* count towards state. If it's not present, then the state is formed by
|
||||
@@ -1366,6 +1369,13 @@ export class SyncApi {
|
||||
}
|
||||
}
|
||||
|
||||
// Proactively decrypt state events: normally we decrypt on demand, but for state
|
||||
// events we need them immediately, so we handle them here. Specifically, consumers
|
||||
// (e.g. Element Web) expect state events to be unencrypted upon receipt.
|
||||
for (const ev of timelineEvents.filter((ev) => ev.isState())) {
|
||||
await this.client.decryptEventIfNeeded(ev);
|
||||
}
|
||||
|
||||
try {
|
||||
if ("org.matrix.msc4222.state_after" in joinObj) {
|
||||
await this.injectRoomEvents(
|
||||
@@ -1395,6 +1405,18 @@ export class SyncApi {
|
||||
// we deliberately don't add accountData to the timeline
|
||||
room.addAccountData(accountDataEvents);
|
||||
|
||||
// Sticky events primarily come via the `timeline` field, with the
|
||||
// sticky info field marking them as sticky.
|
||||
// If the sync is "gappy" (meaning it is skipping events to catch up) then
|
||||
// sticky events will instead come down the sticky section.
|
||||
// This ensures we collect sticky events from both places.
|
||||
const stickyEventsAndStickyEventsFromTheTimeline = stickyEvents.concat(
|
||||
timelineEvents.filter((e) => e.unstableStickyInfo !== undefined),
|
||||
);
|
||||
// Note: We calculate sticky events before emitting `.Room` as it's nice to have
|
||||
// sticky events calculated and ready to go.
|
||||
room._unstable_addStickyEvents(stickyEventsAndStickyEventsFromTheTimeline);
|
||||
|
||||
room.recalculate();
|
||||
if (joinObj.isBrandNewRoom) {
|
||||
client.store.storeRoom(room);
|
||||
@@ -1404,11 +1426,21 @@ export class SyncApi {
|
||||
this.processEventsForNotifs(room, timelineEvents);
|
||||
|
||||
const emitEvent = (e: MatrixEvent): boolean => client.emit(ClientEvent.Event, e);
|
||||
// this fires a couple of times for some events. (eg state events are in the timeline and the state)
|
||||
// should this get a sync section as an additional event emission param (e, syncSection))?
|
||||
stateEvents.forEach(emitEvent);
|
||||
timelineEvents.forEach(emitEvent);
|
||||
ephemeralEvents.forEach(emitEvent);
|
||||
accountDataEvents.forEach(emitEvent);
|
||||
|
||||
stickyEvents
|
||||
.filter(
|
||||
(stickyEvent) =>
|
||||
// This is highly unlikey, but in the case where a sticky event
|
||||
// has appeared in the timeline AND the sticky section, we only
|
||||
// want to emit the event once.
|
||||
!timelineEvents.some((timelineEvent) => timelineEvent.getId() === stickyEvent.getId()),
|
||||
)
|
||||
.forEach(emitEvent);
|
||||
// Decrypt only the last message in all rooms to make sure we can generate a preview
|
||||
// And decrypt all events after the recorded read receipt to ensure an accurate
|
||||
// notification count
|
||||
|
||||
+8
-2
@@ -465,15 +465,21 @@ export async function chunkPromises<T>(fns: (() => Promise<T>)[], chunkSize: num
|
||||
* should always return a new promise.
|
||||
* @param promiseFn - The function to call to get a fresh promise instance. Takes an
|
||||
* attempt count as an argument, for logging/debugging purposes.
|
||||
* @param shouldRetry - Optional function which is called with the error the latest rejection from promiseFn,
|
||||
* retrying will ba aborted if this return false.
|
||||
* @returns The promise for the retried operation.
|
||||
*/
|
||||
export function simpleRetryOperation<T>(promiseFn: (attempt: number) => Promise<T>): Promise<T> {
|
||||
export function simpleRetryOperation<T>(
|
||||
promiseFn: (attempt: number) => Promise<T>,
|
||||
shouldRetry?: (e: unknown) => boolean,
|
||||
): Promise<T> {
|
||||
return promiseRetry(
|
||||
(attempt: number) => {
|
||||
return promiseFn(attempt);
|
||||
},
|
||||
{
|
||||
forever: true,
|
||||
retries: Infinity,
|
||||
shouldRetry: shouldRetry ? ({ error }): boolean => shouldRetry(error) : undefined,
|
||||
factor: 2,
|
||||
minTimeout: 3000, // ms
|
||||
maxTimeout: 15000, // ms
|
||||
|
||||
Reference in New Issue
Block a user