Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25555ec431 | |||
| 455b614008 | |||
| 93f4f40202 | |||
| aeade9ce58 | |||
| 4b89fb23c5 | |||
| 174439c2f0 | |||
| 43f3e10f05 | |||
| 97fcdb2830 | |||
| 31e2d8eb20 | |||
| 633a5a8848 | |||
| a5086a09b9 | |||
| c251be9ae5 | |||
| ec137cb5fb | |||
| ab4e24f115 | |||
| 2218ec4e31 | |||
| 319a8309c5 | |||
| 5af046f54f | |||
| f97a9d9762 | |||
| dc1a57a9f2 | |||
| 2d6111a04b | |||
| 710fd7859d | |||
| e340a4ceaf | |||
| 3fa44e076e | |||
| 3f9fb9c936 | |||
| 8db347a75e | |||
| 8db3343280 | |||
| 25c5a5b4ff | |||
| a696e77652 | |||
| 582a76d87c | |||
| fdfddde55a | |||
| 0ecfef2352 | |||
| 4fdece6c1c | |||
| 6f0bce8708 | |||
| d3bdeb73f5 | |||
| 942fdf5bee | |||
| dd2635dbe6 | |||
| 3d1bcb73c1 | |||
| a960e686b3 | |||
| 946774c3fb | |||
| 15edbc8067 | |||
| 1398ac24a2 | |||
| c76df4cd8f | |||
| a5e4dbf2d3 | |||
| 3768187395 | |||
| 08d0ce25f1 | |||
| 23241f18e2 | |||
| 90da67aa95 | |||
| 0bf2702149 | |||
| c7a75c8824 | |||
| 98b2b9745d | |||
| 65d5b3172c | |||
| 2f72f9e889 | |||
| 18f500a1f8 | |||
| b1df58796a | |||
| 761b3771d6 | |||
| df88edfda0 | |||
| 1dee1ba581 | |||
| b274c74a30 | |||
| b489bb15cf | |||
| dff4922a42 | |||
| dc6ad0b54c | |||
| 9769c05dc5 | |||
| dd379d3d4c | |||
| 1b884a3e52 | |||
| ddb164490e | |||
| 4cc4c01dd8 | |||
| 0ae483ce27 | |||
| dbc1fa87ed | |||
| 0a3675b971 | |||
| ab3f529d29 | |||
| 607b712a07 | |||
| b6d9e49277 | |||
| 731d5943e2 | |||
| 01e7a43593 |
@@ -41,3 +41,6 @@
|
||||
- name: "Z-Flaky-Test"
|
||||
description: "A test is raising false alarms"
|
||||
color: "ededed"
|
||||
- name: "Z-Skip-Coverage"
|
||||
description: "Skip SonarQube coverage for this PR"
|
||||
color: "ededed"
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
deployments: write
|
||||
steps:
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
@@ -73,6 +73,8 @@ jobs:
|
||||
close-if-fork-develop:
|
||||
name: Forbid develop branch fork contributions
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
if: >
|
||||
github.event.action == 'opened' &&
|
||||
github.event.pull_request.head.ref == 'develop' &&
|
||||
|
||||
@@ -16,12 +16,12 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: "yarn"
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
disable-autolabeler: true
|
||||
|
||||
- name: Get actions scripts
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
repository: matrix-org/matrix-js-sdk
|
||||
persist-credentials: false
|
||||
|
||||
@@ -18,14 +18,14 @@ jobs:
|
||||
merge:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
# We will be pushing to this branch and want the CI to run after we do so we cannot use the GITHUB_TOKEN
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get actions scripts
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
repository: matrix-org/matrix-js-sdk
|
||||
persist-credentials: false
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
sparse-checkout: |
|
||||
scripts/release
|
||||
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
|
||||
@@ -4,8 +4,6 @@ on:
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN:
|
||||
required: true
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
GPG_PASSPHRASE:
|
||||
required: false
|
||||
GPG_PRIVATE_KEY:
|
||||
@@ -73,7 +71,7 @@ jobs:
|
||||
draft: true
|
||||
latest: true
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
ref: staging
|
||||
# We will be pushing to this branch and want the CI to run after we do so we cannot use the GITHUB_TOKEN
|
||||
@@ -81,7 +79,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get actions scripts
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
repository: matrix-org/matrix-js-sdk
|
||||
persist-credentials: false
|
||||
@@ -125,7 +123,7 @@ jobs:
|
||||
git config --global user.email "releases@riot.im"
|
||||
git config --global user.name "RiotRobot"
|
||||
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -282,8 +280,6 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
secrets:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
post-release:
|
||||
name: Post release steps
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
name: Publish to npm
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: true
|
||||
outputs:
|
||||
id:
|
||||
description: "The npm package@version string we published"
|
||||
@@ -20,32 +17,29 @@ jobs:
|
||||
id: ${{ steps.npm-publish.outputs.id }}
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
ref: staging
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
node-version-file: package.json
|
||||
|
||||
# Ensure npm 11.5.1 or later is installed
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: 🔨 Install dependencies
|
||||
run: "yarn install --frozen-lockfile"
|
||||
|
||||
- name: 🚀 Publish to npm
|
||||
id: npm-publish
|
||||
run: |
|
||||
npm publish --provenance --access public --tag next
|
||||
npm publish --provenance --access public --tag "$TAG"
|
||||
release=$(jq -r '"\(.name)@\(.version)"' package.json)
|
||||
echo "id=$release" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: 🎖️ Add `latest` dist-tag to final releases
|
||||
if: steps.npm-publish.outputs.id && !contains(steps.npm-publish.outputs.id, '-rc.')
|
||||
run: npm dist-tag add "$release" latest
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
release: ${{ steps.npm-publish.outputs.id }}
|
||||
TAG: ${{ contains(steps.npm-publish.outputs.id, '-rc.') && 'next' || 'latest' }}
|
||||
|
||||
@@ -44,13 +44,13 @@ jobs:
|
||||
repo:
|
||||
- element-hq/element-web
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
repository: ${{ matrix.repo }}
|
||||
ref: staging
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
@@ -73,10 +73,10 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
|
||||
@@ -36,14 +36,14 @@ jobs:
|
||||
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
- name: "🧮 Checkout code"
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
repository: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
||||
if: ${{ !inputs.sharded }}
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
name: coverage
|
||||
path: coverage
|
||||
- name: 📥 Download sharded artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
||||
if: inputs.sharded
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -75,13 +75,14 @@ jobs:
|
||||
|
||||
- name: "🩻 SonarCloud Scan"
|
||||
id: sonarcloud
|
||||
uses: matrix-org/sonarcloud-workflow-action@820f7c2e9e94ba9e35add0f739691e5c7e23fa25 # v4.0
|
||||
uses: matrix-org/sonarcloud-workflow-action@9d9692c97a426ea43f5b578c6bc8f952a867cd49
|
||||
# workflow_run fails report against the develop commit always, we don't want that for PRs
|
||||
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
|
||||
with:
|
||||
skip_checkout: true
|
||||
repository: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
is_pr: ${{ github.event.workflow_run.event == 'pull_request' }}
|
||||
skip_coverage_label: Z-Skip-Coverage
|
||||
version_cmd: "cat package.json | jq -r .version"
|
||||
branch: ${{ github.event.workflow_run.head_branch }}
|
||||
revision: ${{ github.event.workflow_run.head_sha }}
|
||||
|
||||
@@ -14,9 +14,9 @@ jobs:
|
||||
name: "Typescript Syntax Check"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -31,9 +31,9 @@ jobs:
|
||||
name: "ESLint"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -48,9 +48,9 @@ jobs:
|
||||
name: "Node.js example"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # 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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
cache: "npm"
|
||||
node-version-file: "examples/node/package.json"
|
||||
@@ -83,9 +83,9 @@ jobs:
|
||||
name: "Workflow Lint"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -100,9 +100,9 @@ jobs:
|
||||
name: "JSDoc Checker"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
run: "yarn run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: docs
|
||||
path: _docs
|
||||
@@ -125,9 +125,9 @@ jobs:
|
||||
name: "Analyse Dead Code"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -143,11 +143,11 @@ jobs:
|
||||
if: github.event_name == 'merge_group'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
repository: element-hq/element-web
|
||||
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
@@ -22,11 +22,11 @@ jobs:
|
||||
node: ["lts/*", 22]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Setup Node
|
||||
id: setupNode
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: ${{ matrix.node }}
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
- name: Upload Artifact
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
|
||||
path: |
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
|
||||
with:
|
||||
operations-per-run: 250
|
||||
days-before-issue-stale: -1
|
||||
|
||||
@@ -1,3 +1,69 @@
|
||||
Changes in [40.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v40.0.0) (2026-01-13)
|
||||
==================================================================================================
|
||||
## 🚨 BREAKING CHANGES
|
||||
|
||||
* MatrixRTC Pseudonymous livekit identities ([#5110](https://github.com/matrix-org/matrix-js-sdk/pull/5110)). Contributed by @toger5.
|
||||
|
||||
## 🦖 Deprecations
|
||||
|
||||
* Mark `forwardingCurve25519KeyChain` as deprecated ([#5111](https://github.com/matrix-org/matrix-js-sdk/pull/5111)). Contributed by @richvdh.
|
||||
* Mark `IEventDecryptionResult` as deprecated ([#5112](https://github.com/matrix-org/matrix-js-sdk/pull/5112)). Contributed by @richvdh.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Implement MSC4387: M\_SAFETY error ([#5107](https://github.com/matrix-org/matrix-js-sdk/pull/5107)). Contributed by @Half-Shot.
|
||||
* Implement \_unstable\_getRTCTransports for MSC4143 ([#5104](https://github.com/matrix-org/matrix-js-sdk/pull/5104)). Contributed by @Half-Shot.
|
||||
* Use `membershipID` for session events ([#5105](https://github.com/matrix-org/matrix-js-sdk/pull/5105)). Contributed by @toger5.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Make MatrixRTC encryption key types narrower for TS 5.9 compatibility ([#5117](https://github.com/matrix-org/matrix-js-sdk/pull/5117)). Contributed by @robintown.
|
||||
* Re-check outgoing requests after processing them ([#5109](https://github.com/matrix-org/matrix-js-sdk/pull/5109)). Contributed by @andybalaam.
|
||||
* Make token refresher init itself lazily ([#5106](https://github.com/matrix-org/matrix-js-sdk/pull/5106)). Contributed by @dbkr.
|
||||
|
||||
|
||||
Changes in [39.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.4.0) (2025-12-16)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Import room key bundles received after invite. ([#5080](https://github.com/matrix-org/matrix-js-sdk/pull/5080)). Contributed by @kaylendog.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Allow msc4354\_sticky\_key to be optional on sticky events. ([#5073](https://github.com/matrix-org/matrix-js-sdk/pull/5073)). Contributed by @Half-Shot.
|
||||
* Handle all response fields from /context API being optional ([#5089](https://github.com/matrix-org/matrix-js-sdk/pull/5089)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [39.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.3.0) (2025-12-02)
|
||||
==================================================================================================
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Re-add truthy check on room name/avatar/alias events ([#5081](https://github.com/matrix-org/matrix-js-sdk/pull/5081)). Contributed by @t3chguy.
|
||||
* Fix invalid state events corrupting room objects ([#5078](https://github.com/matrix-org/matrix-js-sdk/pull/5078)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [39.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.2.0) (2025-11-18)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Delayed event management: split endpoints, no auth ([#5066](https://github.com/matrix-org/matrix-js-sdk/pull/5066)). Contributed by @AndrewFerr.
|
||||
* do not set cache in authenticated fetch ([#5020](https://github.com/matrix-org/matrix-js-sdk/pull/5020)). Contributed by @pkuzco.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix media switching during legacy calls ([#5069](https://github.com/matrix-org/matrix-js-sdk/pull/5069)). Contributed by @langleyd.
|
||||
|
||||
|
||||
Changes in [39.1.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.1.2) (2025-11-04)
|
||||
==================================================================================================
|
||||
Re-release of v39.1.0 to fix npm publishing workflow
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
+3
-4
@@ -71,7 +71,7 @@ Unless otherwise specified, the following applies to all code:
|
||||
11. If a variable is not receiving a value on declaration, its type must be defined.
|
||||
|
||||
```typescript
|
||||
let errorMessage: Optional<string>;
|
||||
let errorMessage: string;
|
||||
```
|
||||
|
||||
12. Objects can use shorthand declarations, including mixing of types.
|
||||
@@ -150,8 +150,7 @@ Unless otherwise specified, the following applies to all code:
|
||||
1. When using `any`, a comment explaining why must be present.
|
||||
27. `import` should be used instead of `require`, as `require` does not have types.
|
||||
28. Export only what can be reused.
|
||||
29. Prefer a type like `Optional<X>` (`type Optional<T> = T | null | undefined`) instead
|
||||
of truly optional parameters.
|
||||
29. Prefer a type like `X | null` instead of truly optional parameters.
|
||||
1. A notable exception is when the likelihood of a bug is minimal, such as when a function
|
||||
takes an argument that is more often not required than required. An example where the
|
||||
`?` operator is inappropriate is when taking a room ID: typically the caller should
|
||||
@@ -161,7 +160,7 @@ Unless otherwise specified, the following applies to all code:
|
||||
```typescript
|
||||
function doThingWithRoom(
|
||||
thing: string,
|
||||
room: Optional<string>, // require the caller to specify
|
||||
room: string | null, // require the caller to specify
|
||||
) {
|
||||
// ...
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ export default {
|
||||
"ts-node",
|
||||
// Used by `@babel/plugin-transform-runtime`
|
||||
"@babel/runtime",
|
||||
// Transitive dep of jest
|
||||
"jsdom",
|
||||
],
|
||||
ignoreBinaries: [
|
||||
// Used when available by reusable workflow `.github/workflows/release-make.yml`
|
||||
|
||||
+12
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "39.1.0",
|
||||
"version": "40.0.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
@@ -49,17 +49,17 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^15.3.0",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^16.0.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^6.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-widget-api": "^1.10.0",
|
||||
"matrix-widget-api": "^1.14.0",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
"p-retry": "7",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"sdp-transform": "^3.0.0",
|
||||
"unhomoglyph": "^1.0.6",
|
||||
"uuid": "13"
|
||||
},
|
||||
@@ -78,18 +78,18 @@
|
||||
"@babel/plugin-transform-runtime": "^7.12.10",
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||
"@casualbot/jest-sonar-reporter": "2.5.0",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@peculiar/webcrypto": "^1.4.5",
|
||||
"@stylistic/eslint-plugin": "^5.0.0",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/jest": "^29.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "18",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"babel-jest": "^29.0.0",
|
||||
"babel-jest": "^30.0.0",
|
||||
"babel-plugin-search-and-replace": "^1.1.1",
|
||||
"debug": "^4.3.4",
|
||||
"eslint": "8.57.1",
|
||||
@@ -101,21 +101,21 @@
|
||||
"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-tsdoc": "^0.5.0",
|
||||
"eslint-plugin-unicorn": "^56.0.0",
|
||||
"fake-indexeddb": "^5.0.2",
|
||||
"fetch-mock": "11.1.5",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"husky": "^9.0.0",
|
||||
"jest": "^29.0.0",
|
||||
"jest-environment-jsdom": "^29.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"jest-environment-jsdom": "^30.0.0",
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-mock": "^29.0.0",
|
||||
"jest-mock": "^30.0.0",
|
||||
"knip": "^5.0.0",
|
||||
"lint-staged": "^16.0.0",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"prettier": "3.6.2",
|
||||
"prettier": "3.7.0",
|
||||
"rimraf": "^6.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typedoc": "^0.28.1",
|
||||
|
||||
@@ -403,7 +403,7 @@ describe("cross-signing", () => {
|
||||
const isCrossSigningReady = await aliceClient.getCrypto()!.isCrossSigningReady();
|
||||
|
||||
expect(isCrossSigningReady).toBeFalsy();
|
||||
});
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe("getCrossSigningKeyId", () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver.ts";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder.ts";
|
||||
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints.ts";
|
||||
import { getSyncResponse, mkEventCustom, syncPromise } from "../../test-utils/test-utils.ts";
|
||||
import { getSyncResponse, mkEventCustom, syncPromise, waitFor } from "../../test-utils/test-utils.ts";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder.ts";
|
||||
import { flushPromises } from "../../test-utils/flushPromises.ts";
|
||||
import { E2EOTKClaimResponder } from "../../test-utils/E2EOTKClaimResponder.ts";
|
||||
@@ -80,6 +80,9 @@ describe("History Sharing", () => {
|
||||
let bobSyncResponder: SyncResponder;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset mocks.
|
||||
fetchMock.reset();
|
||||
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
@@ -201,6 +204,104 @@ describe("History Sharing", () => {
|
||||
expect(event.getContent().body).toEqual("Hi!");
|
||||
});
|
||||
|
||||
test("Room keys are imported correctly if invite is accepted before the bundle arrives", async () => {
|
||||
// Alice is in an encrypted room
|
||||
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], ROOM_ID);
|
||||
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// ... and she sends an event
|
||||
const msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
|
||||
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, { msgtype: MsgType.Text, body: "Hello!" });
|
||||
const sentMessage = await msgProm;
|
||||
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
|
||||
|
||||
// Now, Alice invites Bob
|
||||
const uploadProm = new Promise<Uint8Array>((resolve) => {
|
||||
fetchMock.postOnce(new URL("/_matrix/media/v3/upload", ALICE_HOMESERVER_URL).toString(), (url, request) => {
|
||||
const body = request.body as Uint8Array;
|
||||
debug(`Alice uploaded blob of length ${body.length}`);
|
||||
resolve(body);
|
||||
return { content_uri: "mxc://alice-server/here" };
|
||||
});
|
||||
});
|
||||
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
|
||||
// POST https://alice-server.com/_matrix/client/v3/rooms/!room%3Aexample.com/invite
|
||||
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
|
||||
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
|
||||
const uploadedBlob = await uploadProm;
|
||||
const sentToDeviceRequest = await toDeviceMessageProm;
|
||||
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
|
||||
const bobToDeviceMessage = sentToDeviceRequest[bobClient.getSafeUserId()][bobClient.deviceId!];
|
||||
expect(bobToDeviceMessage).toBeDefined();
|
||||
|
||||
// Bob receives the room invite, but not the room key bundle
|
||||
const inviteEvent = mkEventCustom({
|
||||
type: "m.room.member",
|
||||
sender: aliceClient.getSafeUserId(),
|
||||
state_key: bobClient.getSafeUserId(),
|
||||
content: { membership: KnownMembership.Invite },
|
||||
});
|
||||
bobSyncResponder.sendOrQueueSyncResponse({
|
||||
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [inviteEvent] } } } },
|
||||
});
|
||||
await syncPromise(bobClient);
|
||||
|
||||
const room = bobClient.getRoom(ROOM_ID);
|
||||
expect(room).toBeTruthy();
|
||||
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
|
||||
|
||||
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
|
||||
room_id: ROOM_ID,
|
||||
});
|
||||
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
|
||||
|
||||
// Bob receives and attempts to decrypt the megolm message, but should not be able to (yet).
|
||||
const bobSyncResponse = getSyncResponse([aliceClient.getSafeUserId(), bobClient.getSafeUserId()], ROOM_ID);
|
||||
bobSyncResponse.rooms.join[ROOM_ID].timeline.events.push(
|
||||
mkEventCustom({
|
||||
type: "m.room.encrypted",
|
||||
sender: aliceClient.getSafeUserId(),
|
||||
content: sentMessage,
|
||||
event_id: "$event_id",
|
||||
}) as any,
|
||||
);
|
||||
bobSyncResponder.sendOrQueueSyncResponse(bobSyncResponse);
|
||||
await syncPromise(bobClient);
|
||||
const bobRoom = bobClient.getRoom(ROOM_ID);
|
||||
const event = bobRoom!.getLastLiveEvent()!;
|
||||
expect(event.getId()).toEqual("$event_id");
|
||||
await event.getDecryptionPromise();
|
||||
expect(event.isDecryptionFailure()).toBeTruthy();
|
||||
|
||||
// Now the room key bundle message arrives
|
||||
fetchMock.getOnce(
|
||||
`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`,
|
||||
{ body: uploadedBlob },
|
||||
{ sendAsJson: false },
|
||||
);
|
||||
bobSyncResponder.sendOrQueueSyncResponse({
|
||||
to_device: {
|
||||
events: [
|
||||
{
|
||||
type: "m.room.encrypted",
|
||||
sender: aliceClient.getSafeUserId(),
|
||||
content: bobToDeviceMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await syncPromise(bobClient);
|
||||
|
||||
// Once the room key bundle finishes downloading, we should be able to decrypt the message.
|
||||
await waitFor(async () => {
|
||||
await event.getDecryptionPromise();
|
||||
expect(event.isDecryptionFailure()).toBeFalsy();
|
||||
expect(event.getType()).toEqual("m.room.message");
|
||||
expect(event.getContent().body).toEqual("Hello!");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
bobClient.stopClient();
|
||||
aliceClient.stopClient();
|
||||
|
||||
@@ -305,7 +305,9 @@ export function encryptMegolmEventRawPlainText(opts: {
|
||||
},
|
||||
type: "m.room.encrypted",
|
||||
unsigned: {},
|
||||
state_key: opts.plaintext.state_key ? `${opts.plaintext.type}:${opts.plaintext.state_key}` : undefined,
|
||||
state_key: opts.plaintext.hasOwnProperty("state_key")
|
||||
? `${opts.plaintext.type}:${opts.plaintext.state_key}`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -672,7 +672,7 @@ describe("MatrixClient event timelines", function () {
|
||||
expect(timeline!.getEvents().find((e) => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return undefined when event is not in the thread that the given timelineSet is representing", () => {
|
||||
it("should return null when event is not in the thread that the given timelineSet is representing", () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
@@ -696,12 +696,12 @@ describe("MatrixClient event timelines", function () {
|
||||
});
|
||||
|
||||
return Promise.all([
|
||||
expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id!)).resolves.toBeUndefined(),
|
||||
expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id!)).resolves.toBeNull(),
|
||||
httpBackend.flushAllExpected(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return undefined when event is within a thread but timelineSet is not", () => {
|
||||
it("should return null when event is within a thread but timelineSet is not", () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
@@ -723,7 +723,7 @@ describe("MatrixClient event timelines", function () {
|
||||
});
|
||||
|
||||
return Promise.all([
|
||||
expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!)).resolves.toBeUndefined(),
|
||||
expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!)).resolves.toBeNull(),
|
||||
httpBackend.flushAllExpected(),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ describe("MatrixClient opts", function () {
|
||||
|
||||
await expect(
|
||||
Promise.all([client.sendTextMessage("!foo:bar", "a body", "txn1"), httpBackend.flush("/txn1", 1)]),
|
||||
).rejects.toThrow("MatrixError: [500] Unknown message");
|
||||
).rejects.toThrow("MatrixError: [500] Ruh roh");
|
||||
});
|
||||
|
||||
it("shouldn't queue events", async () => {
|
||||
|
||||
@@ -82,7 +82,7 @@ export function getSyncResponse(
|
||||
state_key: "",
|
||||
content: {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"io.element.msc3414.encrypt_state_events": encryptStateEvents,
|
||||
"io.element.msc4362.encrypt_state_events": encryptStateEvents,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
+51
-10
@@ -86,7 +86,9 @@ class MockWidgetApi extends EventEmitter {
|
||||
? { event_id: `$${Math.random()}` }
|
||||
: { delay_id: `id-${Math.random()}` },
|
||||
);
|
||||
public updateDelayedEvent = jest.fn().mockResolvedValue(undefined);
|
||||
public cancelScheduledDelayedEvent = jest.fn().mockResolvedValue(undefined);
|
||||
public restartScheduledDelayedEvent = jest.fn().mockResolvedValue(undefined);
|
||||
public sendScheduledDelayedEvent = jest.fn().mockResolvedValue(undefined);
|
||||
public sendToDevice = jest.fn().mockResolvedValue(undefined);
|
||||
public requestOpenIDConnectToken = jest.fn(async () => {
|
||||
return testOIDCToken;
|
||||
@@ -531,17 +533,49 @@ describe("RoomWidgetClient", () => {
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("updates delayed events", async () => {
|
||||
it.each([UpdateDelayedEventAction.Cancel, UpdateDelayedEventAction.Restart, UpdateDelayedEventAction.Send])(
|
||||
"can %s scheduled delayed events (action in parameter)",
|
||||
async (action: UpdateDelayedEventAction) => {
|
||||
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
|
||||
expect(widgetApi.requestCapability).toHaveBeenCalledWith(
|
||||
MatrixCapabilities.MSC4157UpdateDelayedEvent,
|
||||
);
|
||||
await client._unstable_updateDelayedEvent("id", action);
|
||||
let updateDelayedEvent: (delayId: string) => Promise<unknown>;
|
||||
switch (action) {
|
||||
case UpdateDelayedEventAction.Cancel:
|
||||
updateDelayedEvent = widgetApi.cancelScheduledDelayedEvent;
|
||||
break;
|
||||
case UpdateDelayedEventAction.Restart:
|
||||
updateDelayedEvent = widgetApi.cancelScheduledDelayedEvent;
|
||||
break;
|
||||
case UpdateDelayedEventAction.Send:
|
||||
updateDelayedEvent = widgetApi.sendScheduledDelayedEvent;
|
||||
break;
|
||||
}
|
||||
expect(updateDelayedEvent).toHaveBeenCalledWith("id");
|
||||
},
|
||||
);
|
||||
|
||||
it("can cancel scheduled delayed events (action in method)", async () => {
|
||||
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
|
||||
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent);
|
||||
for (const action of [
|
||||
UpdateDelayedEventAction.Cancel,
|
||||
UpdateDelayedEventAction.Restart,
|
||||
UpdateDelayedEventAction.Send,
|
||||
]) {
|
||||
await client._unstable_updateDelayedEvent("id", action);
|
||||
expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith("id", action);
|
||||
}
|
||||
await client._unstable_cancelScheduledDelayedEvent("id");
|
||||
expect(widgetApi.cancelScheduledDelayedEvent).toHaveBeenCalledWith("id");
|
||||
});
|
||||
|
||||
it("can restart scheduled delayed events (action in method)", async () => {
|
||||
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
|
||||
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent);
|
||||
await client._unstable_restartScheduledDelayedEvent("id");
|
||||
expect(widgetApi.restartScheduledDelayedEvent).toHaveBeenCalledWith("id");
|
||||
});
|
||||
|
||||
it("can send scheduled delayed events (action in method)", async () => {
|
||||
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
|
||||
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent);
|
||||
await client._unstable_sendScheduledDelayedEvent("id");
|
||||
expect(widgetApi.sendScheduledDelayedEvent).toHaveBeenCalledWith("id");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -583,6 +617,13 @@ describe("RoomWidgetClient", () => {
|
||||
"Server does not support",
|
||||
);
|
||||
}
|
||||
for (const updateDelayedEvent of [
|
||||
client._unstable_cancelScheduledDelayedEvent,
|
||||
client._unstable_restartScheduledDelayedEvent,
|
||||
client._unstable_sendScheduledDelayedEvent,
|
||||
]) {
|
||||
await expect(updateDelayedEvent.call(client, "id")).rejects.toThrow("Server does not support");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`MatrixHttpApi should return expected object from \`getContentUri\` 1`] = `
|
||||
{
|
||||
|
||||
@@ -375,7 +375,7 @@ describe("FetchHttpApi", () => {
|
||||
refreshToken,
|
||||
onlyData: true,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toEqual(
|
||||
unknownTokenErr,
|
||||
);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
@@ -397,7 +397,7 @@ describe("FetchHttpApi", () => {
|
||||
refreshToken,
|
||||
onlyData: true,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toEqual(
|
||||
unknownTokenErr,
|
||||
);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
ConnectionError,
|
||||
HTTPError,
|
||||
MatrixError,
|
||||
MatrixSafetyError,
|
||||
MatrixSafetyErrorCode,
|
||||
parseErrorResponse,
|
||||
retryNetworkOperation,
|
||||
timeoutSignal,
|
||||
@@ -185,6 +187,35 @@ describe("parseErrorResponse", () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
errcode: MatrixSafetyErrorCode.name,
|
||||
error: "Spammy",
|
||||
},
|
||||
{
|
||||
errcode: MatrixSafetyErrorCode.name,
|
||||
error: "Spammy",
|
||||
expiry: 5000,
|
||||
},
|
||||
{
|
||||
errcode: MatrixSafetyErrorCode.name,
|
||||
error: "Spammy",
|
||||
harms: ["m.spam", "org.example.additional-harm"],
|
||||
expiry: 5000,
|
||||
},
|
||||
])("should resolve MatrixSafetyErrors from fetch", (errContent) => {
|
||||
headers.set("Content-Type", "application/json");
|
||||
const value = parseErrorResponse(
|
||||
{
|
||||
headers,
|
||||
status: 400,
|
||||
} as Response,
|
||||
JSON.stringify(errContent),
|
||||
) as MatrixSafetyError;
|
||||
expect(value).toBeInstanceOf(MatrixSafetyError);
|
||||
expect(value.harms.size).toEqual(errContent.harms?.length ?? 0);
|
||||
expect(value.expiry?.getTime()).toEqual(errContent.expiry);
|
||||
});
|
||||
|
||||
describe("with HTTP headers", () => {
|
||||
function addHeaders(headers: Headers) {
|
||||
|
||||
@@ -84,6 +84,7 @@ import { mockOpenIdConfiguration } from "../test-utils/oidc.ts";
|
||||
import { type CryptoBackend } from "../../src/common-crypto/CryptoBackend";
|
||||
import { SyncResponder } from "../test-utils/SyncResponder.ts";
|
||||
import { mockInitialApiRequests } from "../test-utils/mockEndpoints.ts";
|
||||
import { type Transport } from "src/matrixrtc/index.ts";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
@@ -801,6 +802,10 @@ describe("MatrixClient", function () {
|
||||
await expect(
|
||||
client._unstable_updateDelayedEvent("anyDelayId", UpdateDelayedEventAction.Send),
|
||||
).rejects.toThrow(errorMessage);
|
||||
|
||||
await expect(client._unstable_cancelScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage);
|
||||
await expect(client._unstable_restartScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage);
|
||||
await expect(client._unstable_sendScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage);
|
||||
});
|
||||
|
||||
it("works with null threadId", async () => {
|
||||
@@ -1077,21 +1082,169 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
});
|
||||
|
||||
it("can update delayed events", async () => {
|
||||
it.each([UpdateDelayedEventAction.Cancel, UpdateDelayedEventAction.Restart, UpdateDelayedEventAction.Send])(
|
||||
"can %s scheduled delayed events (action in request body)",
|
||||
async (action: UpdateDelayedEventAction) => {
|
||||
const delayId = "id";
|
||||
httpLookups = [
|
||||
{
|
||||
method: "POST",
|
||||
prefix: unstableMSC4140Prefix,
|
||||
path: `/delayed_events/${encodeURIComponent(delayId)}`,
|
||||
data: {
|
||||
action,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await client._unstable_updateDelayedEvent(delayId, action);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([UpdateDelayedEventAction.Cancel, UpdateDelayedEventAction.Restart, UpdateDelayedEventAction.Send])(
|
||||
"can %s scheduled delayed events (action in request body fallback when auth required)",
|
||||
async (action: UpdateDelayedEventAction) => {
|
||||
const delayId = "id";
|
||||
const baseLookup = {
|
||||
method: "POST",
|
||||
prefix: unstableMSC4140Prefix,
|
||||
path: `/delayed_events/${encodeURIComponent(delayId)}`,
|
||||
};
|
||||
httpLookups = [
|
||||
{
|
||||
...baseLookup,
|
||||
error: {
|
||||
httpStatus: 401,
|
||||
errcode: "M_MISSING_TOKEN",
|
||||
},
|
||||
},
|
||||
{
|
||||
...baseLookup,
|
||||
data: {
|
||||
action,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await client._unstable_updateDelayedEvent(delayId, action);
|
||||
},
|
||||
);
|
||||
|
||||
it("can cancel scheduled delayed events (action in request path)", async () => {
|
||||
const delayId = "id";
|
||||
const action = UpdateDelayedEventAction.Restart;
|
||||
httpLookups = [
|
||||
{
|
||||
method: "POST",
|
||||
prefix: unstableMSC4140Prefix,
|
||||
path: `/delayed_events/${encodeURIComponent(delayId)}/cancel`,
|
||||
},
|
||||
];
|
||||
|
||||
await client._unstable_cancelScheduledDelayedEvent(delayId);
|
||||
});
|
||||
|
||||
it("can restart scheduled delayed events (action in request path)", async () => {
|
||||
const delayId = "id";
|
||||
httpLookups = [
|
||||
{
|
||||
method: "POST",
|
||||
prefix: unstableMSC4140Prefix,
|
||||
path: `/delayed_events/${encodeURIComponent(delayId)}/restart`,
|
||||
},
|
||||
];
|
||||
|
||||
await client._unstable_restartScheduledDelayedEvent(delayId);
|
||||
});
|
||||
|
||||
it("can send scheduled delayed events (action in request path)", async () => {
|
||||
const delayId = "id";
|
||||
httpLookups = [
|
||||
{
|
||||
method: "POST",
|
||||
prefix: unstableMSC4140Prefix,
|
||||
path: `/delayed_events/${encodeURIComponent(delayId)}/send`,
|
||||
},
|
||||
];
|
||||
|
||||
await client._unstable_sendScheduledDelayedEvent(delayId);
|
||||
});
|
||||
|
||||
it("can cancel scheduled delayed events (action in request path fallback when unsupported)", async () => {
|
||||
const delayId = "id";
|
||||
httpLookups = [
|
||||
{
|
||||
method: "POST",
|
||||
prefix: unstableMSC4140Prefix,
|
||||
path: `/delayed_events/${encodeURIComponent(delayId)}/cancel`,
|
||||
error: {
|
||||
httpStatus: 400,
|
||||
errcode: "M_UNRECOGNIZED",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
prefix: unstableMSC4140Prefix,
|
||||
path: `/delayed_events/${encodeURIComponent(delayId)}`,
|
||||
data: {
|
||||
action,
|
||||
action: UpdateDelayedEventAction.Cancel,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await client._unstable_updateDelayedEvent(delayId, action);
|
||||
await client._unstable_cancelScheduledDelayedEvent(delayId);
|
||||
expect(httpLookups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("can restart scheduled delayed events (action in request path fallback when unsupported)", async () => {
|
||||
const delayId = "id";
|
||||
httpLookups = [
|
||||
{
|
||||
method: "POST",
|
||||
prefix: unstableMSC4140Prefix,
|
||||
path: `/delayed_events/${encodeURIComponent(delayId)}/restart`,
|
||||
error: {
|
||||
httpStatus: 400,
|
||||
errcode: "M_UNRECOGNIZED",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
prefix: unstableMSC4140Prefix,
|
||||
path: `/delayed_events/${encodeURIComponent(delayId)}`,
|
||||
data: {
|
||||
action: UpdateDelayedEventAction.Restart,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await client._unstable_restartScheduledDelayedEvent(delayId);
|
||||
expect(httpLookups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("can send scheduled delayed events (action in request path fallback when unsupported)", async () => {
|
||||
const delayId = "id";
|
||||
httpLookups = [
|
||||
{
|
||||
method: "POST",
|
||||
prefix: unstableMSC4140Prefix,
|
||||
path: `/delayed_events/${encodeURIComponent(delayId)}/send`,
|
||||
error: {
|
||||
httpStatus: 400,
|
||||
errcode: "M_UNRECOGNIZED",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
prefix: unstableMSC4140Prefix,
|
||||
path: `/delayed_events/${encodeURIComponent(delayId)}`,
|
||||
data: {
|
||||
action: UpdateDelayedEventAction.Send,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await client._unstable_sendScheduledDelayedEvent(delayId);
|
||||
expect(httpLookups).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3783,4 +3936,23 @@ describe("MatrixClient", function () {
|
||||
expect(lookupResult[0]).toEqual({ address: "bob@email.dummy", mxid: "@bob:homeserver.dummy" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("_unstable_getRTCTransports", () => {
|
||||
it("makes a well-formed request", async () => {
|
||||
httpLookups = [
|
||||
{
|
||||
method: "GET",
|
||||
path: `/rtc/transports`,
|
||||
data: { rtc_transports: [{ type: "livekit", extra_field: "foobar" }] satisfies Transport[] },
|
||||
prefix: "/_matrix/client/unstable/org.matrix.msc4143",
|
||||
},
|
||||
];
|
||||
expect(await client._unstable_getRTCTransports()).toEqual([
|
||||
{
|
||||
type: "livekit",
|
||||
extra_field: "foobar",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,9 +28,16 @@ function makeMockEvent(originTs = 0): MatrixEvent {
|
||||
getTs: jest.fn().mockReturnValue(originTs),
|
||||
getSender: jest.fn().mockReturnValue("@alice:example.org"),
|
||||
getId: jest.fn().mockReturnValue("$eventid"),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
function createCallMembership(ev: MatrixEvent, content: unknown): CallMembership {
|
||||
(ev.getContent as jest.Mock).mockReturnValue(content);
|
||||
const data = CallMembership.membershipDataFromMatrixEvent(ev);
|
||||
return new CallMembership(ev, data, "xx");
|
||||
}
|
||||
|
||||
describe("CallMembership", () => {
|
||||
describe("SessionMembershipData", () => {
|
||||
beforeEach(() => {
|
||||
@@ -53,29 +60,29 @@ describe("CallMembership", () => {
|
||||
|
||||
it("rejects membership with no device_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
|
||||
createCallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with no call_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
|
||||
createCallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("allow membership with no scope", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
|
||||
createCallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("uses event timestamp if no created_ts", () => {
|
||||
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
|
||||
const membership = createCallMembership(makeMockEvent(12345), membershipTemplate);
|
||||
expect(membership.createdTs()).toEqual(12345);
|
||||
});
|
||||
|
||||
it("uses created_ts if present", () => {
|
||||
const membership = new CallMembership(
|
||||
const membership = createCallMembership(
|
||||
makeMockEvent(12345),
|
||||
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
|
||||
);
|
||||
@@ -85,27 +92,27 @@ describe("CallMembership", () => {
|
||||
it("considers memberships unexpired if local age low enough", () => {
|
||||
const fakeEvent = makeMockEvent(1000);
|
||||
fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION - 1));
|
||||
expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(false);
|
||||
expect(createCallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(false);
|
||||
});
|
||||
|
||||
it("considers memberships expired if local age large enough", () => {
|
||||
const fakeEvent = makeMockEvent(1000);
|
||||
fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION + 1));
|
||||
expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(true);
|
||||
expect(createCallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(true);
|
||||
});
|
||||
|
||||
it("returns preferred foci", () => {
|
||||
const fakeEvent = makeMockEvent();
|
||||
const mockFocus = { type: "this_is_a_mock_focus" };
|
||||
const membership = new CallMembership(fakeEvent, { ...membershipTemplate, foci_preferred: [mockFocus] });
|
||||
const membership = createCallMembership(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);
|
||||
const oldestMembership = createCallMembership(makeMockEvent(), membershipTemplate);
|
||||
it("gets the correct active transport with oldest_membership", () => {
|
||||
const membership = new CallMembership(makeMockEvent(), {
|
||||
const membership = createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
foci_preferred: [mockFocus],
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
@@ -119,7 +126,7 @@ describe("CallMembership", () => {
|
||||
});
|
||||
|
||||
it("gets the correct active transport with multi_sfu", () => {
|
||||
const membership = new CallMembership(makeMockEvent(), {
|
||||
const membership = createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
foci_preferred: [mockFocus],
|
||||
focus_active: { type: "livekit", focus_selection: "multi_sfu" },
|
||||
@@ -132,7 +139,7 @@ describe("CallMembership", () => {
|
||||
expect(membership.getTransport(oldestMembership)).toBe(mockFocus);
|
||||
});
|
||||
it("does not provide focus if the selection method is unknown", () => {
|
||||
const membership = new CallMembership(makeMockEvent(), {
|
||||
const membership = createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
foci_preferred: [mockFocus],
|
||||
focus_active: { type: "livekit", focus_selection: "unknown" },
|
||||
@@ -143,7 +150,7 @@ describe("CallMembership", () => {
|
||||
});
|
||||
});
|
||||
describe("correct values from computed fields", () => {
|
||||
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
|
||||
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
|
||||
it("returns correct sender", () => {
|
||||
expect(membership.sender).toBe("@alice:example.org");
|
||||
});
|
||||
@@ -170,7 +177,7 @@ describe("CallMembership", () => {
|
||||
expect(membership.scope).toBe("m.room");
|
||||
});
|
||||
it("returns correct membershipID", () => {
|
||||
expect(membership.membershipID).toBe("0");
|
||||
expect(membership.membershipID).toBe("@alice:example.org:AAAAAAA");
|
||||
});
|
||||
it("returns correct unused fields", () => {
|
||||
expect(membership.getAbsoluteExpiry()).toBe(DEFAULT_EXPIRE_DURATION);
|
||||
@@ -192,29 +199,29 @@ describe("CallMembership", () => {
|
||||
|
||||
it("rejects membership with no slot_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined });
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined });
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects membership with invalid slot_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
|
||||
}).toThrow();
|
||||
});
|
||||
it("accepts membership with valid slot_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with no application", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined });
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with incorrect application", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
application: { wrong_type_key: "unknown" },
|
||||
});
|
||||
@@ -223,34 +230,34 @@ describe("CallMembership", () => {
|
||||
|
||||
it("rejects membership with no member", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined });
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with incorrect member", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } });
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } });
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
member: { id: "test", device_id: "test", user_id_wrong: "test" },
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" },
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
member: { id: "test", device_id: "test", user_id: "@@test" },
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
member: { id: "test", device_id: "test", user_id: "@test-wrong-user:user.id" },
|
||||
});
|
||||
@@ -258,41 +265,41 @@ describe("CallMembership", () => {
|
||||
});
|
||||
it("rejects membership with incorrect sticky_key", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), membershipTemplate);
|
||||
createCallMembership(makeMockEvent(), membershipTemplate);
|
||||
}).not.toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
sticky_key: 1,
|
||||
msc4354_sticky_key: undefined,
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
sticky_key: "1",
|
||||
msc4354_sticky_key: undefined,
|
||||
});
|
||||
}).not.toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined });
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined });
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
msc4354_sticky_key: 1,
|
||||
sticky_key: "valid",
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
msc4354_sticky_key: "valid",
|
||||
sticky_key: "valid",
|
||||
});
|
||||
}).not.toThrow();
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
msc4354_sticky_key: "valid_but_different",
|
||||
sticky_key: "valid",
|
||||
@@ -310,11 +317,11 @@ describe("CallMembership", () => {
|
||||
|
||||
describe("getTransport", () => {
|
||||
it("gets the correct active transport with oldest_membership", () => {
|
||||
const oldestMembership = new CallMembership(makeMockEvent(), {
|
||||
const oldestMembership = createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
rtc_transports: [{ type: "oldest_transport" }],
|
||||
});
|
||||
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
|
||||
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
|
||||
|
||||
// if we are the oldest member we use our focus.
|
||||
expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" });
|
||||
@@ -324,7 +331,7 @@ describe("CallMembership", () => {
|
||||
});
|
||||
});
|
||||
describe("correct values from computed fields", () => {
|
||||
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
|
||||
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
|
||||
it("returns correct sender", () => {
|
||||
expect(membership.sender).toBe("@alice:example.org");
|
||||
});
|
||||
@@ -372,7 +379,7 @@ describe("CallMembership", () => {
|
||||
beforeEach(() => {
|
||||
// server origin timestamp for this event is 1000
|
||||
fakeEvent = makeMockEvent(1000);
|
||||
membership = new CallMembership(fakeEvent!, membershipTemplate);
|
||||
membership = createCallMembership(fakeEvent!, membershipTemplate);
|
||||
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
@@ -34,10 +34,13 @@ import {
|
||||
type MembershipData,
|
||||
mockRoomState,
|
||||
mockRTCEvent,
|
||||
owmMemberIdentity,
|
||||
} 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";
|
||||
import { type CallMembershipIdentityParts } from "../../../src/matrixrtc/EncryptionManager.ts";
|
||||
import { flushPromises } from "../../test-utils/flushPromises.ts";
|
||||
|
||||
const mockFocus = { type: "mock" };
|
||||
|
||||
@@ -92,27 +95,32 @@ describe("MatrixRTCSession", () => {
|
||||
testCreateSticky: true,
|
||||
},
|
||||
])(
|
||||
"roomSessionForRoom listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky",
|
||||
"roomsessionForSlot listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky",
|
||||
(testConfig) => {
|
||||
it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, () => {
|
||||
it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
|
||||
mockRoom.getLiveTimeline.mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue(undefined),
|
||||
} as unknown as EventTimeline);
|
||||
|
||||
const warnLogSpy = jest.spyOn(console, "warn");
|
||||
warnLogSpy.mockClear();
|
||||
const stateWarningWasLogged = () =>
|
||||
warnLogSpy.mock.calls.find((call) => (call[1] as string).includes("Couldn't get state for room"));
|
||||
|
||||
MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig);
|
||||
await flushPromises();
|
||||
|
||||
if (testConfig.listenForMemberStateEvents) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(() => {
|
||||
MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig);
|
||||
}).toThrow();
|
||||
expect(stateWarningWasLogged()).toBeTruthy();
|
||||
} else {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(() => {
|
||||
MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig);
|
||||
}).not.toThrow();
|
||||
expect(stateWarningWasLogged()).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a room-scoped session from room state", () => {
|
||||
it("creates a room-scoped session from room state", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
|
||||
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
@@ -121,6 +129,7 @@ describe("MatrixRTCSession", () => {
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
await flushPromises();
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].slotDescription.id).toEqual("");
|
||||
expect(sess?.memberships[0].scope).toEqual("m.room");
|
||||
@@ -159,7 +168,7 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores expired memberships events", () => {
|
||||
it("ignores expired memberships events", async () => {
|
||||
jest.useFakeTimers();
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.expires = 1000;
|
||||
@@ -173,6 +182,7 @@ describe("MatrixRTCSession", () => {
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
await flushPromises();
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
jest.useRealTimers();
|
||||
@@ -203,7 +213,7 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess?.memberships.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("honours created_ts", () => {
|
||||
it("honours created_ts", async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(500);
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
@@ -216,6 +226,7 @@ describe("MatrixRTCSession", () => {
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
await flushPromises();
|
||||
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
@@ -331,8 +342,8 @@ describe("MatrixRTCSession", () => {
|
||||
},
|
||||
);
|
||||
|
||||
describe("roomSessionForRoom combined state", () => {
|
||||
it("perfers sticky events when both membership and sticky events appear for the same user", () => {
|
||||
describe("roomsessionForSlot combined state", () => {
|
||||
it("perfers sticky events when both membership and sticky events appear for the same user", async () => {
|
||||
// Create a room with identical member state and sticky state for the same user.
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
|
||||
@@ -351,6 +362,7 @@ describe("MatrixRTCSession", () => {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
});
|
||||
await flushPromises();
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].slotDescription.id).toEqual("");
|
||||
expect(sess?.memberships[0].scope).toEqual("m.room");
|
||||
@@ -359,7 +371,7 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess?.memberships[0].isExpired()).toEqual(false);
|
||||
expect(sess?.slotDescription.id).toEqual("");
|
||||
});
|
||||
it("combines sticky and membership events when both exist", () => {
|
||||
it("combines sticky and membership events when both exist", async () => {
|
||||
// Create a room with identical member state and sticky state for the same user.
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
const stickyUserId = "@stickyev:user.example";
|
||||
@@ -381,6 +393,7 @@ describe("MatrixRTCSession", () => {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const memberships = sess.memberships;
|
||||
expect(memberships.length).toEqual(2);
|
||||
@@ -396,7 +409,7 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
expect(sess?.slotDescription.id).toEqual("");
|
||||
});
|
||||
it("handles an incoming sticky event to an existing session", () => {
|
||||
it("handles an incoming sticky event to an existing session", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
const stickyUserId = "@stickyev:user.example";
|
||||
|
||||
@@ -404,6 +417,7 @@ describe("MatrixRTCSession", () => {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
});
|
||||
await flushPromises();
|
||||
expect(sess.memberships.length).toEqual(1);
|
||||
const stickyEv = mockRTCEvent(
|
||||
{
|
||||
@@ -419,12 +433,13 @@ describe("MatrixRTCSession", () => {
|
||||
return [stickyEv];
|
||||
});
|
||||
mockRoom.emit(RoomStickyEventsEvent.Update, [stickyEv], [], []);
|
||||
await flushPromises();
|
||||
expect(sess.memberships.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOldestMembership", () => {
|
||||
it("returns the oldest membership event", () => {
|
||||
it("returns the oldest membership event", async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(4000);
|
||||
const mockRoom = makeMockRoom([
|
||||
@@ -433,7 +448,8 @@ describe("MatrixRTCSession", () => {
|
||||
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
await flushPromises();
|
||||
expect(sess.getOldestMembership()!.deviceId).toEqual("old");
|
||||
jest.useRealTimers();
|
||||
});
|
||||
@@ -446,7 +462,7 @@ describe("MatrixRTCSession", () => {
|
||||
[undefined, "audio", "audio"],
|
||||
["audio", "audio", "audio"],
|
||||
["audio", "video", undefined],
|
||||
])("gets correct consensus for %s + %s = %s", (intentA, intentB, result) => {
|
||||
])("gets correct consensus for %s + %s = %s", async (intentA, intentB, result) => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(4000);
|
||||
const mockRoom = makeMockRoom([
|
||||
@@ -454,7 +470,8 @@ describe("MatrixRTCSession", () => {
|
||||
Object.assign({}, membershipTemplate, { "m.call.intent": intentB }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
await flushPromises();
|
||||
expect(sess.getConsensusCallIntent()).toEqual(result);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
@@ -479,9 +496,9 @@ describe("MatrixRTCSession", () => {
|
||||
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
|
||||
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
|
||||
sess.joinRTCSession(owmMemberIdentity, [{ type: "livekit", livekit_service_url: "htts://test.org" }], {
|
||||
type: "livekit",
|
||||
focus_selection: "oldest_membership",
|
||||
});
|
||||
@@ -498,9 +515,9 @@ describe("MatrixRTCSession", () => {
|
||||
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
|
||||
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
|
||||
sess.joinRTCSession(owmMemberIdentity, [{ type: "livekit", livekit_service_url: "htts://test.org" }], {
|
||||
type: "livekit",
|
||||
focus_selection: "unknown",
|
||||
});
|
||||
@@ -514,7 +531,7 @@ describe("MatrixRTCSession", () => {
|
||||
let sendStateEventMock: jest.Mock;
|
||||
|
||||
let sentStateEvent: Promise<void>;
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
sentStateEvent = new Promise((resolve) => {
|
||||
sendStateEventMock = jest.fn(resolve);
|
||||
});
|
||||
@@ -523,9 +540,13 @@ describe("MatrixRTCSession", () => {
|
||||
client.sendEvent = sendEventMock;
|
||||
|
||||
client._unstable_updateDelayedEvent = jest.fn();
|
||||
client._unstable_cancelScheduledDelayedEvent = jest.fn();
|
||||
client._unstable_restartScheduledDelayedEvent = jest.fn();
|
||||
client._unstable_sendScheduledDelayedEvent = jest.fn();
|
||||
|
||||
mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -542,12 +563,12 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("shows joined once join is called", () => {
|
||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus);
|
||||
expect(sess!.isJoined()).toEqual(true);
|
||||
});
|
||||
|
||||
it("uses the sticky events membership manager implementation", () => {
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { unstableSendStickyEvents: true });
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { unstableSendStickyEvents: true });
|
||||
expect(sess!.isJoined()).toEqual(true);
|
||||
expect(sess!["membershipManager"] instanceof StickyEventMembershipManager).toEqual(true);
|
||||
});
|
||||
@@ -565,10 +586,10 @@ describe("MatrixRTCSession", () => {
|
||||
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, resolve);
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { notificationType: "ring" });
|
||||
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
|
||||
mockRoomState(mockRoom, [{ ...membershipTemplate, user_id: client.getUserId()! }]);
|
||||
sess!.onRTCSessionMemberUpdate();
|
||||
await sess!._onRTCSessionMemberUpdate();
|
||||
const ownMembershipId = sess?.memberships[0].eventId;
|
||||
|
||||
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, {
|
||||
@@ -626,7 +647,10 @@ describe("MatrixRTCSession", () => {
|
||||
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, resolve);
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring", callIntent: "audio" });
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, {
|
||||
notificationType: "ring",
|
||||
callIntent: "audio",
|
||||
});
|
||||
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
|
||||
|
||||
mockRoomState(mockRoom, [
|
||||
@@ -638,7 +662,7 @@ describe("MatrixRTCSession", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
sess!.onRTCSessionMemberUpdate();
|
||||
await sess!._onRTCSessionMemberUpdate();
|
||||
const ownMembershipId = sess?.memberships[0].eventId;
|
||||
expect(sess!.getConsensusCallIntent()).toEqual("audio");
|
||||
|
||||
@@ -689,27 +713,27 @@ describe("MatrixRTCSession", () => {
|
||||
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]);
|
||||
sess!.onRTCSessionMemberUpdate();
|
||||
await sess!._onRTCSessionMemberUpdate();
|
||||
|
||||
// Simulate a join, including the update to the room state
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { notificationType: "ring" });
|
||||
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
|
||||
mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]);
|
||||
sess!.onRTCSessionMemberUpdate();
|
||||
await sess!._onRTCSessionMemberUpdate();
|
||||
|
||||
expect(client.sendEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("doesn't send a notification when someone else starts the call faster than us", async () => {
|
||||
// Simulate a join, including the update to the room state
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { notificationType: "ring" });
|
||||
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
|
||||
// But this time we want to simulate a race condition in which we receive a state event
|
||||
// from someone else, starting the call before our own state event has been sent
|
||||
mockRoomState(mockRoom, [membershipTemplate]);
|
||||
sess!.onRTCSessionMemberUpdate();
|
||||
await sess!._onRTCSessionMemberUpdate();
|
||||
mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]);
|
||||
sess!.onRTCSessionMemberUpdate();
|
||||
await sess!._onRTCSessionMemberUpdate();
|
||||
|
||||
// We assume that the responsibility to send a notification, if any, lies with the other
|
||||
// participant that won the race
|
||||
@@ -718,26 +742,26 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
describe("onMembershipsChanged", () => {
|
||||
it("does not emit if no membership changes", () => {
|
||||
it("does not emit if no membership changes", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
await flushPromises();
|
||||
const onMembershipsChanged = jest.fn();
|
||||
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
await sess._onRTCSessionMemberUpdate();
|
||||
|
||||
expect(onMembershipsChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits on membership changes", () => {
|
||||
it("emits on membership changes", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
|
||||
const onMembershipsChanged = jest.fn();
|
||||
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
|
||||
|
||||
mockRoomState(mockRoom, []);
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
await sess._onRTCSessionMemberUpdate();
|
||||
|
||||
expect(onMembershipsChanged).toHaveBeenCalled();
|
||||
});
|
||||
@@ -750,7 +774,7 @@ describe("MatrixRTCSession", () => {
|
||||
// const membership = Object.assign({}, membershipTemplate);
|
||||
// const mockRoom = makeMockRoom([membership]);
|
||||
|
||||
// sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
// sess = MatrixRTCSession.roomsessionForSlot(client, mockRoom);
|
||||
// const membershipObject = sess.memberships[0];
|
||||
|
||||
// const onMembershipsChanged = jest.fn();
|
||||
@@ -786,7 +810,8 @@ describe("MatrixRTCSession", () => {
|
||||
client.encryptAndSendToDevice = sendToDeviceMock;
|
||||
|
||||
mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
// await new Promise((resolve) => sess!.once(MatrixRTCSessionEvent.MembershipsChanged, resolve));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -795,7 +820,7 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("creates a key when joining", () => {
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess?.reemitEncryptionKeys();
|
||||
@@ -803,6 +828,11 @@ describe("MatrixRTCSession", () => {
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array),
|
||||
0,
|
||||
{
|
||||
deviceId: "AAAAAAA",
|
||||
memberId: "@alice:example.org:AAAAAAA",
|
||||
userId: "@alice:example.org",
|
||||
},
|
||||
"@alice:example.org:AAAAAAA",
|
||||
);
|
||||
});
|
||||
@@ -814,7 +844,7 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
|
||||
await eventSentPromise;
|
||||
|
||||
@@ -839,13 +869,13 @@ describe("MatrixRTCSession", () => {
|
||||
sendStateEventMock = jest.fn(resolve);
|
||||
});
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
await sentStateEvent;
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
expect(client.sendEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1);
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
expect(client.sendEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1);
|
||||
@@ -869,7 +899,7 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
// wait for the encryption event to get sent
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
await eventSentPromise;
|
||||
@@ -892,7 +922,7 @@ describe("MatrixRTCSession", () => {
|
||||
throw e;
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
|
||||
expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel);
|
||||
});
|
||||
@@ -905,13 +935,13 @@ describe("MatrixRTCSession", () => {
|
||||
device_id: "BBBBBBB",
|
||||
});
|
||||
const mockRoom = makeMockRoom([membershipTemplate, member2]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
await flushPromises();
|
||||
// joining will trigger an initial key send
|
||||
const keysSentPromise1 = new Promise<EncryptionKeysEventContent>((resolve) => {
|
||||
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
|
||||
});
|
||||
sess.joinRoomSession([mockFocus], mockFocus, {
|
||||
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, {
|
||||
manageMediaKeys: true,
|
||||
updateEncryptionKeyThrottle: 1000,
|
||||
makeKeyDelay: 3000,
|
||||
@@ -921,14 +951,14 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
// member2 leaves triggering key rotation
|
||||
mockRoomState(mockRoom, [membershipTemplate]);
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
await sess._onRTCSessionMemberUpdate();
|
||||
|
||||
// member2 re-joins which should trigger an immediate re-send
|
||||
const keysSentPromise2 = new Promise<EncryptionKeysEventContent>((resolve) => {
|
||||
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
|
||||
});
|
||||
mockRoomState(mockRoom, [membershipTemplate, member2]);
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
await sess._onRTCSessionMemberUpdate();
|
||||
// but, that immediate resend is throttled so we need to wait a bit
|
||||
jest.advanceTimersByTime(1000);
|
||||
const { keys } = await keysSentPromise2;
|
||||
@@ -954,13 +984,13 @@ describe("MatrixRTCSession", () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
|
||||
const keysSentPromise1 = new Promise<EncryptionKeysEventContent>((resolve) => {
|
||||
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
await keysSentPromise1;
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1);
|
||||
|
||||
@@ -979,7 +1009,7 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
mockRoomState(mockRoom, [membershipTemplate, member2]);
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
await sess._onRTCSessionMemberUpdate();
|
||||
|
||||
await keysSentPromise2;
|
||||
|
||||
@@ -1005,8 +1035,8 @@ describe("MatrixRTCSession", () => {
|
||||
const mockRoom = makeMockRoom([member1, member2]);
|
||||
mockRoomState(mockRoom, [member1, member2]);
|
||||
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
|
||||
await keysSentPromise1;
|
||||
|
||||
@@ -1026,7 +1056,7 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockClear();
|
||||
|
||||
// these should be a no-op:
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
await sess._onRTCSessionMemberUpdate();
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(0);
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1);
|
||||
} finally {
|
||||
@@ -1050,8 +1080,9 @@ describe("MatrixRTCSession", () => {
|
||||
};
|
||||
|
||||
const mockRoom = makeMockRoom([member1, member2]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
await flushPromises();
|
||||
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
|
||||
await keysSentPromise1;
|
||||
|
||||
@@ -1071,7 +1102,7 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockClear();
|
||||
|
||||
// this should be a no-op:
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
await sess._onRTCSessionMemberUpdate();
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// advance time to avoid key throttling
|
||||
@@ -1086,7 +1117,7 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
// this should re-send the key
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
await sess._onRTCSessionMemberUpdate();
|
||||
|
||||
await keysSentPromise2;
|
||||
|
||||
@@ -1114,13 +1145,16 @@ describe("MatrixRTCSession", () => {
|
||||
device_id: "BBBBBBB",
|
||||
});
|
||||
const mockRoom = makeMockRoom([membershipTemplate, member2]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
await flushPromises();
|
||||
const onMyEncryptionKeyChanged = jest.fn();
|
||||
sess.on(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
(_key: Uint8Array, _idx: number, participantId: string) => {
|
||||
if (participantId === `${client.getUserId()}:${client.getDeviceId()}`) {
|
||||
(_key: Uint8Array, _idx: number, membership: CallMembershipIdentityParts) => {
|
||||
if (
|
||||
membership.userId === client.getUserId() &&
|
||||
membership.deviceId === client.getDeviceId()
|
||||
) {
|
||||
onMyEncryptionKeyChanged();
|
||||
}
|
||||
},
|
||||
@@ -1130,7 +1164,10 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true, makeKeyDelay: KEY_DELAY });
|
||||
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, {
|
||||
manageMediaKeys: true,
|
||||
makeKeyDelay: KEY_DELAY,
|
||||
});
|
||||
const sendKeySpy = jest.spyOn((sess as unknown as any).encryptionManager.transport, "sendKey");
|
||||
const firstKeysPayload = await keysSentPromise1;
|
||||
expect(firstKeysPayload.keys).toHaveLength(1);
|
||||
@@ -1144,7 +1181,7 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
mockRoomState(mockRoom, [membershipTemplate]);
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
await sess._onRTCSessionMemberUpdate();
|
||||
|
||||
jest.advanceTimersByTime(KEY_DELAY);
|
||||
expect(sendKeySpy).toHaveBeenCalledTimes(1);
|
||||
@@ -1204,14 +1241,15 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
if (i === 0) {
|
||||
// if first time around then set up the session
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
await flushPromises();
|
||||
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
} else {
|
||||
// otherwise update the state reducing the membership each time in order to trigger key rotation
|
||||
mockRoomState(mockRoom, members.slice(0, membersToTest - i));
|
||||
}
|
||||
|
||||
sess!.onRTCSessionMemberUpdate();
|
||||
await sess!._onRTCSessionMemberUpdate();
|
||||
|
||||
// advance time to avoid key throttling
|
||||
jest.advanceTimersByTime(10000);
|
||||
@@ -1230,13 +1268,14 @@ describe("MatrixRTCSession", () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
await flushPromises();
|
||||
|
||||
const keysSentPromise1 = new Promise((resolve) => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
await keysSentPromise1;
|
||||
|
||||
sendEventMock.mockClear();
|
||||
@@ -1250,7 +1289,7 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
mockRoomState(mockRoom, [membershipTemplate, member2]);
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
await sess._onRTCSessionMemberUpdate();
|
||||
|
||||
await new Promise((resolve) => {
|
||||
realSetTimeout(resolve);
|
||||
@@ -1271,13 +1310,13 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, {
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
await flushPromises();
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, {
|
||||
manageMediaKeys: true,
|
||||
useExperimentalToDeviceTransport: true,
|
||||
});
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
await sess._onRTCSessionMemberUpdate();
|
||||
|
||||
await keySentPromise;
|
||||
|
||||
@@ -1294,8 +1333,9 @@ describe("MatrixRTCSession", () => {
|
||||
describe("receiving", () => {
|
||||
it("collects keys from encryption events", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
await flushPromises();
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
@@ -1312,6 +1352,11 @@ describe("MatrixRTCSession", () => {
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
0,
|
||||
{
|
||||
deviceId: "bobsphone",
|
||||
memberId: "@bob:example.org:bobsphone",
|
||||
userId: "@bob:example.org",
|
||||
},
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
|
||||
@@ -1319,8 +1364,9 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
it("collects keys at non-zero indices", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
await flushPromises();
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
@@ -1337,6 +1383,11 @@ describe("MatrixRTCSession", () => {
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
4,
|
||||
{
|
||||
deviceId: "bobsphone",
|
||||
memberId: "@bob:example.org:bobsphone",
|
||||
userId: "@bob:example.org",
|
||||
},
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
@@ -1345,8 +1396,8 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
it("collects keys by merging", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
@@ -1363,6 +1414,11 @@ describe("MatrixRTCSession", () => {
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
0,
|
||||
{
|
||||
deviceId: "bobsphone",
|
||||
memberId: "@bob:example.org:bobsphone",
|
||||
userId: "@bob:example.org",
|
||||
},
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
@@ -1383,11 +1439,21 @@ describe("MatrixRTCSession", () => {
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
0,
|
||||
{
|
||||
deviceId: "bobsphone",
|
||||
memberId: "@bob:example.org:bobsphone",
|
||||
userId: "@bob:example.org",
|
||||
},
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
4,
|
||||
{
|
||||
deviceId: "bobsphone",
|
||||
memberId: "@bob:example.org:bobsphone",
|
||||
userId: "@bob:example.org",
|
||||
},
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
@@ -1396,8 +1462,8 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
it("ignores older keys at same index", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
@@ -1447,6 +1513,11 @@ describe("MatrixRTCSession", () => {
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("newer key"),
|
||||
0,
|
||||
{
|
||||
deviceId: "bobsphone",
|
||||
memberId: "@bob:example.org:bobsphone",
|
||||
userId: "@bob:example.org",
|
||||
},
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
@@ -1455,8 +1526,8 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
it("key timestamps are treated as monotonic", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
@@ -1493,15 +1564,20 @@ describe("MatrixRTCSession", () => {
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("second key"),
|
||||
0,
|
||||
{
|
||||
deviceId: "bobsphone",
|
||||
memberId: "@bob:example.org:bobsphone",
|
||||
userId: "@bob:example.org",
|
||||
},
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores keys event for the local participant", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", client.getUserId()!, "1234roomId", {
|
||||
device_id: client.getDeviceId(),
|
||||
@@ -1522,11 +1598,11 @@ describe("MatrixRTCSession", () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
|
||||
// defaults to getTs()
|
||||
jest.setSystemTime(1000);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
@@ -1578,10 +1654,10 @@ describe("MatrixRTCSession", () => {
|
||||
describe("read status", () => {
|
||||
it("returns the correct probablyLeft status", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
expect(sess!.probablyLeft).toBe(undefined);
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
expect(sess!.probablyLeft).toBe(false);
|
||||
|
||||
// Simulate the membership manager believing the user has left
|
||||
@@ -1592,12 +1668,12 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess!.probablyLeft).toBe(true);
|
||||
});
|
||||
|
||||
it("returns membershipStatus once joinRoomSession got called", () => {
|
||||
it("returns membershipStatus once joinRTCSession got called", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
||||
expect(sess!.membershipStatus).toBe(undefined);
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
expect(sess!.membershipStatus).toBe(Status.Connecting);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,18 +14,18 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ClientEvent, EventTimeline, MatrixClient, type Room } from "../../../src";
|
||||
import { RoomStateEvent } from "../../../src/models/room-state";
|
||||
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { ClientEvent, EventTimeline, MatrixClient, type Room, RoomStateEvent } from "../../../src";
|
||||
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc";
|
||||
import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
|
||||
describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
"MatrixRTCSessionManager ($eventKind)",
|
||||
({ eventKind }) => {
|
||||
let client: MatrixClient;
|
||||
|
||||
function sendLeaveMembership(room: Room, membershipData: MembershipData[]): void {
|
||||
async function sendLeaveMembership(room: Room, membershipData: MembershipData[]): Promise<void> {
|
||||
if (eventKind === "memberState") {
|
||||
mockRoomState(room, [{ user_id: membershipTemplate.user_id }]);
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
@@ -35,6 +35,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
membershipData.splice(0, 1, { user_id: membershipTemplate.user_id });
|
||||
client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000));
|
||||
}
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -47,7 +48,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
client.matrixRTC.stop();
|
||||
});
|
||||
|
||||
it("Fires event when session starts", () => {
|
||||
it("Fires event when session starts", async () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
@@ -56,6 +57,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
await flushPromises();
|
||||
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
@@ -77,7 +79,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
}
|
||||
});
|
||||
|
||||
it("Fires event when session ends", () => {
|
||||
it("Fires event when session ends", async () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const membershipData: MembershipData[] = [membershipTemplate];
|
||||
@@ -85,13 +87,13 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
|
||||
sendLeaveMembership(room1, membershipData);
|
||||
await flushPromises();
|
||||
await sendLeaveMembership(room1, membershipData);
|
||||
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
|
||||
it("Fires correctly with custom sessionDescription", () => {
|
||||
it("Fires correctly with custom sessionDescription", async () => {
|
||||
const onStarted = jest.fn();
|
||||
const onEnded = jest.fn();
|
||||
// create a session manager with a custom session description
|
||||
@@ -111,6 +113,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
await flushPromises();
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
@@ -121,18 +124,19 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
|
||||
client.emit(ClientEvent.Room, room2);
|
||||
await flushPromises();
|
||||
expect(onStarted).toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
// Stop room1's RTC session. Tracked.
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room2);
|
||||
sendLeaveMembership(room2, room2MembershipData);
|
||||
await sendLeaveMembership(room2, room2MembershipData);
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
onEnded.mockClear();
|
||||
|
||||
// Stop room1's RTC session. Not tracked.
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
sendLeaveMembership(room1, room1MembershipData);
|
||||
await sendLeaveMembership(room1, room1MembershipData);
|
||||
expect(onEnded).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
@@ -140,7 +144,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
}
|
||||
});
|
||||
|
||||
it("Doesn't fire event if unrelated sessions ends", () => {
|
||||
it("Doesn't fire event if unrelated sessions ends", async () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }];
|
||||
@@ -150,7 +154,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
|
||||
sendLeaveMembership(room1, membership);
|
||||
await sendLeaveMembership(room1, membership);
|
||||
|
||||
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
|
||||
@@ -94,6 +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._unstable_cancelScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
(client._unstable_sendScheduledDelayedEvent 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" });
|
||||
@@ -122,7 +125,9 @@ describe("MembershipManager", () => {
|
||||
it("sends a membership event and schedules delayed leave when joining a call", async () => {
|
||||
// Spys/Mocks
|
||||
|
||||
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
|
||||
const restartScheduledDelayedEventHandle = createAsyncHandle<void>(
|
||||
client._unstable_restartScheduledDelayedEvent as Mock,
|
||||
);
|
||||
|
||||
// Test
|
||||
const memberManager = new MembershipManager(undefined, room, client, callSession);
|
||||
@@ -138,12 +143,13 @@ describe("MembershipManager", () => {
|
||||
device_id: "AAAAAAA",
|
||||
expires: 14400000,
|
||||
foci_preferred: [focus],
|
||||
membershipID: "@alice:example.org:AAAAAAA",
|
||||
focus_active: focusActive,
|
||||
scope: "m.room",
|
||||
},
|
||||
"_@alice:example.org_AAAAAAA_m.call",
|
||||
);
|
||||
updateDelayedEventHandle.resolve?.();
|
||||
restartScheduledDelayedEventHandle.resolve?.();
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
{ delay: 8000 },
|
||||
@@ -157,13 +163,13 @@ describe("MembershipManager", () => {
|
||||
it("reschedules delayed leave event if sending state cancels it", async () => {
|
||||
const memberManager = new MembershipManager(undefined, room, client, callSession);
|
||||
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
||||
const waitForUpdateDelaye = waitForMockCallOnce(
|
||||
client._unstable_updateDelayedEvent,
|
||||
const waitForRestartScheduledDelayedEvent = waitForMockCallOnce(
|
||||
client._unstable_restartScheduledDelayedEvent,
|
||||
Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })),
|
||||
);
|
||||
memberManager.join([focus], focusActive);
|
||||
await waitForSendState;
|
||||
await waitForUpdateDelaye;
|
||||
await waitForRestartScheduledDelayedEvent;
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// Once for the initial event and once because of the errcode: "M_NOT_FOUND"
|
||||
// Different to "sends a membership event and schedules delayed leave when joining a call" where its only called once (1)
|
||||
@@ -179,7 +185,7 @@ describe("MembershipManager", () => {
|
||||
if (useOwnedStateEvents) {
|
||||
room.getVersion = jest.fn().mockReturnValue("org.matrix.msc3757.default");
|
||||
}
|
||||
const updatedDelayedEvent = waitForMockCall(client._unstable_updateDelayedEvent);
|
||||
const restartScheduledDelayedEvent = waitForMockCall(client._unstable_restartScheduledDelayedEvent);
|
||||
const sentDelayedState = waitForMockCall(
|
||||
client._unstable_sendDelayedStateEvent,
|
||||
Promise.resolve({
|
||||
@@ -258,6 +264,7 @@ describe("MembershipManager", () => {
|
||||
expires: 14400000,
|
||||
device_id: "AAAAAAA",
|
||||
foci_preferred: [focus],
|
||||
membershipID: "@alice:example.org:AAAAAAA",
|
||||
focus_active: focusActive,
|
||||
} satisfies SessionMembershipData,
|
||||
userStateKey,
|
||||
@@ -265,13 +272,13 @@ describe("MembershipManager", () => {
|
||||
await sentDelayedState;
|
||||
|
||||
// should have prepared the heartbeat to keep delaying the leave event while still connected
|
||||
await updatedDelayedEvent;
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
|
||||
await restartScheduledDelayedEvent;
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
// ensures that we reach the code that schedules the timeout for the next delay update before we advance the timers.
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
// should update delayed disconnect
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2);
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
|
||||
it("sends a membership event after rate limits during delayed event setup when joining a call", async () => {
|
||||
@@ -343,7 +350,7 @@ describe("MembershipManager", () => {
|
||||
// (onRTCSessionMemberUpdate)
|
||||
// - Only then do we resolve the sending of the delayed event.
|
||||
// - We test that the manager acknowledges the leave and sends a new membership state event.
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValueOnce(
|
||||
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockRejectedValueOnce(
|
||||
new MatrixError({ errcode: "M_NOT_FOUND" }),
|
||||
);
|
||||
|
||||
@@ -378,6 +385,7 @@ describe("MembershipManager", () => {
|
||||
device_id: "AAAAAAA",
|
||||
expires: 1234567,
|
||||
foci_preferred: [focus],
|
||||
membershipID: "@alice:example.org:AAAAAAA",
|
||||
focus_active: {
|
||||
focus_selection: "oldest_membership",
|
||||
type: "livekit",
|
||||
@@ -404,17 +412,17 @@ describe("MembershipManager", () => {
|
||||
manager.join([focus]);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
await manager.leave();
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send");
|
||||
expect(client._unstable_sendScheduledDelayedEvent).toHaveBeenLastCalledWith("id");
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
});
|
||||
it("send leave event when leave is called and resolving delayed leave fails", async () => {
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus]);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
|
||||
(client._unstable_sendScheduledDelayedEvent as Mock<any>).mockRejectedValue("unknown");
|
||||
await manager.leave();
|
||||
|
||||
// We send a normal leave event since we failed using updateDelayedEvent with the "send" action.
|
||||
// We send a normal leave event since we failed using sendScheduledDelayedEvent.
|
||||
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
|
||||
room.roomId,
|
||||
"org.matrix.msc3401.call.member",
|
||||
@@ -438,6 +446,9 @@ describe("MembershipManager", () => {
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_cancelScheduledDelayedEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_restartScheduledDelayedEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_sendScheduledDelayedEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does nothing if own membership still present", async () => {
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
@@ -447,6 +458,9 @@ describe("MembershipManager", () => {
|
||||
// reset all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
(client.sendStateEvent as Mock).mockClear();
|
||||
(client._unstable_updateDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_cancelScheduledDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_restartScheduledDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_sendScheduledDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
|
||||
|
||||
await manager.onRTCSessionMemberUpdate([
|
||||
@@ -462,6 +476,9 @@ describe("MembershipManager", () => {
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_cancelScheduledDelayedEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_restartScheduledDelayedEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_sendScheduledDelayedEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("recreates membership if it is missing", async () => {
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
@@ -469,7 +486,7 @@ describe("MembershipManager", () => {
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
(client.sendStateEvent as Mock).mockClear();
|
||||
(client._unstable_updateDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_restartScheduledDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
|
||||
|
||||
// Our own membership is removed:
|
||||
@@ -478,7 +495,7 @@ describe("MembershipManager", () => {
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
|
||||
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalled();
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates the UpdateExpiry entry in the action scheduler", async () => {
|
||||
@@ -487,10 +504,10 @@ describe("MembershipManager", () => {
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
(client.sendStateEvent as Mock).mockClear();
|
||||
(client._unstable_updateDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_restartScheduledDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
|
||||
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValueOnce(
|
||||
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockRejectedValueOnce(
|
||||
new MatrixError({ errcode: "M_NOT_FOUND" }),
|
||||
);
|
||||
|
||||
@@ -503,7 +520,7 @@ describe("MembershipManager", () => {
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
|
||||
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalled();
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalled();
|
||||
expect(manager.status).toBe(Status.Connected);
|
||||
});
|
||||
});
|
||||
@@ -523,17 +540,17 @@ describe("MembershipManager", () => {
|
||||
|
||||
// The first call is from checking id the server deleted the delayed event
|
||||
// so it does not need a `advanceTimersByTime`
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
|
||||
// TODO: Check that update delayed event is called with the correct HTTP request timeout
|
||||
// expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
|
||||
// expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
|
||||
|
||||
for (let i = 2; i <= 12; i++) {
|
||||
// flush promises before advancing the timers to make sure schedulers are setup
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(i);
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(i);
|
||||
// TODO: Check that update delayed event is called with the correct HTTP request timeout
|
||||
// expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
|
||||
// expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -681,7 +698,7 @@ describe("MembershipManager", () => {
|
||||
});
|
||||
describe("retries sending update delayed leave event restart", () => {
|
||||
it("resends the initial check delayed update event", async () => {
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
|
||||
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||
429,
|
||||
@@ -695,17 +712,17 @@ describe("MembershipManager", () => {
|
||||
|
||||
// Hit rate limit
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Hit second rate limit.
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2);
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Setup resolve
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3);
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(3);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -734,7 +751,7 @@ describe("MembershipManager", () => {
|
||||
// because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries", async () => {
|
||||
const delayEventRestartError = jest.fn();
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
|
||||
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||
429,
|
||||
@@ -808,11 +825,11 @@ describe("MembershipManager", () => {
|
||||
manager.join([focus], focusActive);
|
||||
try {
|
||||
// Let the scheduler run one iteration so that we can send the join state event
|
||||
await waitForMockCall(client._unstable_updateDelayedEvent);
|
||||
await waitForMockCall(client._unstable_restartScheduledDelayedEvent);
|
||||
|
||||
// We never resolve the delayed event so that we can test the probablyLeft event.
|
||||
// This simulates the case where the server does not respond to the delayed event.
|
||||
client._unstable_updateDelayedEvent = jest.fn(() => stuckPromise);
|
||||
client._unstable_restartScheduledDelayedEvent = jest.fn(() => stuckPromise);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
expect(manager.status).toBe(Status.Connected);
|
||||
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
|
||||
@@ -822,18 +839,18 @@ describe("MembershipManager", () => {
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
// No emission after 5s
|
||||
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(4999);
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3);
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(3);
|
||||
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
|
||||
|
||||
// Reset mocks before we setup the next delayed event restart by advancing the timers 1 more ms.
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue({});
|
||||
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockResolvedValue({});
|
||||
|
||||
// Emit after 10s
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(4);
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(4);
|
||||
expect(probablyLeftEmit).toHaveBeenCalledWith(true);
|
||||
|
||||
// Mock a sync which does not include our own membership
|
||||
@@ -898,10 +915,16 @@ describe("MembershipManager", () => {
|
||||
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 restartScheduledDelayedEventHandle = createAsyncHandle<void>(
|
||||
client._unstable_restartScheduledDelayedEvent as Mock,
|
||||
);
|
||||
const memberManager = new StickyEventMembershipManager(
|
||||
undefined,
|
||||
room,
|
||||
client,
|
||||
callSession,
|
||||
"@alice:example.org:AAAAAAA_m.call",
|
||||
);
|
||||
const memberManager = new StickyEventMembershipManager(undefined, room, client, callSession);
|
||||
|
||||
memberManager.join([], focus);
|
||||
|
||||
@@ -916,16 +939,16 @@ describe("MembershipManager", () => {
|
||||
application: { type: "m.call" },
|
||||
member: {
|
||||
user_id: "@alice:example.org",
|
||||
id: "_@alice:example.org_AAAAAAA_m.call",
|
||||
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",
|
||||
msc4354_sticky_key: "@alice:example.org:AAAAAAA_m.call",
|
||||
},
|
||||
);
|
||||
updateDelayedEventHandle.resolve?.();
|
||||
restartScheduledDelayedEventHandle.resolve?.();
|
||||
|
||||
// Ensure we have sent the delayed disconnect event.
|
||||
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith(
|
||||
@@ -935,7 +958,7 @@ describe("MembershipManager", () => {
|
||||
null,
|
||||
"org.matrix.msc4143.rtc.member",
|
||||
{
|
||||
msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call",
|
||||
msc4354_sticky_key: "@alice:example.org:AAAAAAA_m.call",
|
||||
},
|
||||
);
|
||||
// ..once
|
||||
|
||||
@@ -25,9 +25,9 @@ describe("OutdatedKeyFilter Test", () => {
|
||||
const olderKey = fakeInboundSessionWithTimestamp(300);
|
||||
// Simulate receiving out of order keys
|
||||
|
||||
expect(filter.isOutdated(aKey.participantId, aKey)).toBe(false);
|
||||
expect(filter.isOutdated(aKey.membership, aKey)).toBe(false);
|
||||
// Then we receive the most recent key out of order
|
||||
const isOutdated = filter.isOutdated(aKey.participantId, olderKey);
|
||||
const isOutdated = filter.isOutdated(aKey.membership, olderKey);
|
||||
// this key is older and should be ignored even if received after
|
||||
expect(isOutdated).toBe(true);
|
||||
});
|
||||
@@ -36,7 +36,7 @@ describe("OutdatedKeyFilter Test", () => {
|
||||
return {
|
||||
keyIndex: 0,
|
||||
creationTS: ts,
|
||||
participantId: "@alice:localhost|ABCDE",
|
||||
membership: { userId: "@alice:localhost", deviceId: "ABDE", memberId: "@alice:localhost:ABCDE" },
|
||||
key: new Uint8Array(16),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../
|
||||
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
|
||||
import { decodeBase64, TypedEventEmitter } from "../../../src";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
import { getParticipantId } from "../../../src/matrixrtc/utils.ts";
|
||||
import { getEncryptionKeyMapKey } from "../../../src/matrixrtc/EncryptionManager.ts";
|
||||
import { flushPromises } from "../../test-utils/flushPromises.ts";
|
||||
|
||||
describe("RTCEncryptionManager", () => {
|
||||
// The manager being tested
|
||||
@@ -32,6 +33,7 @@ describe("RTCEncryptionManager", () => {
|
||||
let mockTransport: Mocked<ToDeviceKeyTransport>;
|
||||
let statistics: Statistics;
|
||||
let onEncryptionKeysChanged: jest.Mock;
|
||||
let rtcIdentifierProvider: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
statistics = {
|
||||
@@ -53,14 +55,18 @@ describe("RTCEncryptionManager", () => {
|
||||
off: jest.fn(),
|
||||
} as unknown as Mocked<ToDeviceKeyTransport>;
|
||||
|
||||
rtcIdentifierProvider = jest.fn().mockImplementation((userId: string, deviceId: string, memberId: string) => {
|
||||
return `MOCKSHA<${userId}|${deviceId}|${memberId}>`;
|
||||
});
|
||||
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
"@alice:example.org",
|
||||
"DEVICE01",
|
||||
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
|
||||
getMembershipMock,
|
||||
mockTransport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
logger,
|
||||
rtcIdentifierProvider,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -76,13 +82,15 @@ describe("RTCEncryptionManager", () => {
|
||||
});
|
||||
|
||||
describe("Sharing Keys", () => {
|
||||
it("Set up my key asap even if no key distribution is needed", () => {
|
||||
it("Set up my key asap even if no key distribution is needed", async () => {
|
||||
getMembershipMock.mockReturnValue([]);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
// After join it is too early, key might be lost as no one is listening yet
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await flushPromises();
|
||||
// The key should have been rolled out immediately
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalled();
|
||||
});
|
||||
@@ -90,14 +98,15 @@ describe("RTCEncryptionManager", () => {
|
||||
it("Should distribute keys to members on join", async () => {
|
||||
jest.useFakeTimers();
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aStateBaseMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
@@ -112,16 +121,22 @@ describe("RTCEncryptionManager", () => {
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
0,
|
||||
{
|
||||
deviceId: "DEVICE01",
|
||||
memberId: "@alice:example.org:DEVICE01",
|
||||
userId: "@alice:example.org",
|
||||
},
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
});
|
||||
|
||||
it("Should re-distribute keys to members whom callMemberhsip ts has changed", async () => {
|
||||
let members = [aCallMembership("@bob:example.org", "BOBDEVICE", 1000)];
|
||||
let members = [aStateBaseMembership("@bob:example.org", "BOBDEVICE", 1000)];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
@@ -143,7 +158,7 @@ describe("RTCEncryptionManager", () => {
|
||||
mockTransport.sendKey.mockClear();
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
|
||||
members = [aCallMembership("@bob:example.org", "BOBDEVICE", 2000)];
|
||||
members = [aStateBaseMembership("@bob:example.org", "BOBDEVICE", 2000)];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// There are no membership change but the callMembership ts has changed (reset?)
|
||||
@@ -170,8 +185,8 @@ describe("RTCEncryptionManager", () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
@@ -192,7 +207,7 @@ describe("RTCEncryptionManager", () => {
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
// Carl joins, within the grace period
|
||||
members.push(aCallMembership("@carl:example.org", "CARLDEVICE"));
|
||||
members.push(aStateBaseMembership("@carl:example.org", "CARLDEVICE"));
|
||||
await jest.advanceTimersByTimeAsync(gracePeriod / 2);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
@@ -220,8 +235,8 @@ describe("RTCEncryptionManager", () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
@@ -242,12 +257,12 @@ describe("RTCEncryptionManager", () => {
|
||||
await jest.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
|
||||
// A new member joins, that should trigger a key rotation.
|
||||
members.push(aCallMembership("@carl:example.org", "CARLDEVICE"));
|
||||
members.push(aStateBaseMembership("@carl:example.org", "CARLDEVICE"));
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
// A new member joins, within the grace period, but under the delay period
|
||||
members.push(aCallMembership("@david:example.org", "DAVDEVICE"));
|
||||
members.push(aStateBaseMembership("@david:example.org", "DAVDEVICE"));
|
||||
await jest.advanceTimersByTimeAsync((useKeyDelay - gracePeriod) / 2);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
@@ -272,8 +287,8 @@ describe("RTCEncryptionManager", () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
@@ -287,7 +302,7 @@ describe("RTCEncryptionManager", () => {
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
await jest.advanceTimersByTimeAsync(gracePeriod + 1000);
|
||||
members.push(aCallMembership("@carl:example.org", "CARLDEVICE"));
|
||||
members.push(aStateBaseMembership("@carl:example.org", "CARLDEVICE"));
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
@@ -314,8 +329,8 @@ describe("RTCEncryptionManager", () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
@@ -328,11 +343,11 @@ describe("RTCEncryptionManager", () => {
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
const newJoiners = [
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
aCallMembership("@dave:example.org", "DAVEDEVICE"),
|
||||
aCallMembership("@eve:example.org", "EVEDEVICE"),
|
||||
aCallMembership("@frank:example.org", "FRANKDEVICE"),
|
||||
aCallMembership("@george:example.org", "GEORGEDEVICE"),
|
||||
aStateBaseMembership("@carl:example.org", "CARLDEVICE"),
|
||||
aStateBaseMembership("@dave:example.org", "DAVEDEVICE"),
|
||||
aStateBaseMembership("@eve:example.org", "EVEDEVICE"),
|
||||
aStateBaseMembership("@frank:example.org", "FRANKDEVICE"),
|
||||
aStateBaseMembership("@george:example.org", "GEORGEDEVICE"),
|
||||
];
|
||||
|
||||
for (const newJoiner of newJoiners) {
|
||||
@@ -364,8 +379,8 @@ describe("RTCEncryptionManager", () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
@@ -394,9 +409,9 @@ describe("RTCEncryptionManager", () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aStateBaseMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
@@ -417,8 +432,8 @@ describe("RTCEncryptionManager", () => {
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
|
||||
const updatedMembers = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(updatedMembers);
|
||||
|
||||
@@ -441,6 +456,11 @@ describe("RTCEncryptionManager", () => {
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
1,
|
||||
{
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "DEVICE01",
|
||||
memberId: "@alice:example.org:DEVICE01",
|
||||
},
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
|
||||
@@ -450,9 +470,9 @@ describe("RTCEncryptionManager", () => {
|
||||
it("Should not distribute keys if encryption is disabled", async () => {
|
||||
jest.useFakeTimers();
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aStateBaseMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
@@ -477,8 +497,7 @@ describe("RTCEncryptionManager", () => {
|
||||
emit: emitter.emit.bind(emitter),
|
||||
} as unknown as Mocked<ToDeviceKeyTransport>;
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
"@alice:example.org",
|
||||
"DEVICE01",
|
||||
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
|
||||
getMembershipMock,
|
||||
mockTransport,
|
||||
statistics,
|
||||
@@ -489,7 +508,7 @@ describe("RTCEncryptionManager", () => {
|
||||
it("should not accept keys when manageMediaKeys is disabled", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [aCallMembership("@bob:example.org", "BOBDEVICE")];
|
||||
const members = [aStateBaseMembership("@bob:example.org", "BOBDEVICE")];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join({ manageMediaKeys: false });
|
||||
@@ -498,8 +517,7 @@ describe("RTCEncryptionManager", () => {
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE",
|
||||
{ userId: "@bob:example.org", deviceId: "BOBDEVICE", memberId: "@bob:example.org:BOBDEVICE" },
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
@@ -513,9 +531,9 @@ describe("RTCEncryptionManager", () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE", 1000, "rtcIDBOB1"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2", 1000, "rtcIDBOB2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE", 1000, "rtcIDCARL1"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
@@ -525,24 +543,21 @@ describe("RTCEncryptionManager", () => {
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE",
|
||||
{ userId: "@bob:example.org", deviceId: "BOBDEVICE", memberId: "@bob:example.org:BOBDEVICE" },
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE2",
|
||||
{ userId: "@bob:example.org", deviceId: "BOBDEVICE2", memberId: "@bob:example.org:BOBDEVICE2" },
|
||||
"BBBBBBBBBBB",
|
||||
4 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carl:example.org",
|
||||
"CARLDEVICE",
|
||||
{ userId: "@carl:example.org", deviceId: "CARLDEVICE", memberId: "@carl:example.org:CARLDEVICE" },
|
||||
"CCCCCCCCCC",
|
||||
8 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
@@ -552,19 +567,34 @@ describe("RTCEncryptionManager", () => {
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
decodeBase64("AAAAAAAAAAA"),
|
||||
0,
|
||||
"@bob:example.org:BOBDEVICE",
|
||||
{
|
||||
userId: "@bob:example.org",
|
||||
deviceId: "BOBDEVICE",
|
||||
memberId: "@bob:example.org:BOBDEVICE",
|
||||
},
|
||||
"rtcIDBOB1",
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
decodeBase64("BBBBBBBBBBB"),
|
||||
4,
|
||||
"@bob:example.org:BOBDEVICE2",
|
||||
{
|
||||
userId: "@bob:example.org",
|
||||
deviceId: "BOBDEVICE2",
|
||||
memberId: "@bob:example.org:BOBDEVICE2",
|
||||
},
|
||||
"rtcIDBOB2",
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
decodeBase64("CCCCCCCCCC"),
|
||||
8,
|
||||
"@carl:example.org:CARLDEVICE",
|
||||
{
|
||||
userId: "@carl:example.org",
|
||||
deviceId: "CARLDEVICE",
|
||||
memberId: "@carl:example.org:CARLDEVICE",
|
||||
},
|
||||
"rtcIDCARL1",
|
||||
);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(3);
|
||||
@@ -573,7 +603,7 @@ describe("RTCEncryptionManager", () => {
|
||||
it("Should support quick re-joiner if keys received out of order", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [aCallMembership("@carl:example.org", "CARLDEVICE")];
|
||||
const members = [aStateBaseMembership("@carol:example.org", "CAROLDEVICE")];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// Let's join
|
||||
@@ -581,13 +611,13 @@ describe("RTCEncryptionManager", () => {
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Simulate Carl leaving then joining back, and key received out of order
|
||||
// XXX This can only happen in legacy because with sticky events the rtcBackendIdentity would be different.
|
||||
const initialKey0TimeStamp = 1000;
|
||||
const newKey0TimeStamp = 2000;
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carol:example.org",
|
||||
"CAROLDEVICE",
|
||||
{ userId: "@carol:example.org", deviceId: "CAROLDEVICE", memberId: "@carol:example.org:CAROLDEVICE" },
|
||||
"BBBBBBBBBBB",
|
||||
0 /* KeyId */,
|
||||
newKey0TimeStamp,
|
||||
@@ -597,8 +627,7 @@ describe("RTCEncryptionManager", () => {
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carol:example.org",
|
||||
"CAROLDEVICE",
|
||||
{ userId: "@carol:example.org", deviceId: "CAROLDEVICE", memberId: "@carol:example.org:CAROLDEVICE" },
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
initialKey0TimeStamp,
|
||||
@@ -611,7 +640,12 @@ describe("RTCEncryptionManager", () => {
|
||||
expect(onEncryptionKeysChanged).toHaveBeenLastCalledWith(
|
||||
decodeBase64("BBBBBBBBBBB"),
|
||||
0,
|
||||
"@carol:example.org:CAROLDEVICE",
|
||||
{
|
||||
userId: "@carol:example.org",
|
||||
deviceId: "CAROLDEVICE",
|
||||
memberId: "@carol:example.org:CAROLDEVICE",
|
||||
},
|
||||
"@carol:example.org|CAROLDEVICE",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -619,9 +653,9 @@ describe("RTCEncryptionManager", () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE", 1000, "@bob:example.org|BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2", 1000, "@bob:example.org|BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE", 1000, "@carl:example.org|CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
@@ -633,8 +667,7 @@ describe("RTCEncryptionManager", () => {
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carl:example.org",
|
||||
"CARLDEVICE",
|
||||
{ userId: "@carl:example.org", deviceId: "CARLDEVICE", memberId: "@carl:example.org:CARLDEVICE" },
|
||||
"BBBBBBBBBBB",
|
||||
0 /* KeyId */,
|
||||
1000,
|
||||
@@ -642,8 +675,7 @@ describe("RTCEncryptionManager", () => {
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carl:example.org",
|
||||
"CARLDEVICE",
|
||||
{ userId: "@carl:example.org", deviceId: "CARLDEVICE", memberId: "@carl:example.org:CARLDEVICE" },
|
||||
"CCCCCCCCCCC",
|
||||
5 /* KeyId */,
|
||||
1000,
|
||||
@@ -651,8 +683,7 @@ describe("RTCEncryptionManager", () => {
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE2",
|
||||
{ userId: "@bob:example.org", deviceId: "BOBDEVICE2", memberId: "@bob:example.org:BOBDEVICE2" },
|
||||
"DDDDDDDDDDD",
|
||||
0 /* KeyId */,
|
||||
1000,
|
||||
@@ -661,7 +692,13 @@ describe("RTCEncryptionManager", () => {
|
||||
const knownKeys = encryptionManager.getEncryptionKeys();
|
||||
|
||||
// My own key should be there
|
||||
const myRing = knownKeys.get(getParticipantId("@alice:example.org", "DEVICE01"));
|
||||
const myRing = knownKeys.get(
|
||||
getEncryptionKeyMapKey({
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "DEVICE01",
|
||||
memberId: "@alice:example.org:DEVICE01",
|
||||
}),
|
||||
);
|
||||
expect(myRing).toBeDefined();
|
||||
expect(myRing).toHaveLength(1);
|
||||
expect(myRing![0]).toMatchObject(
|
||||
@@ -671,7 +708,13 @@ describe("RTCEncryptionManager", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const carlRing = knownKeys.get(getParticipantId("@carl:example.org", "CARLDEVICE"));
|
||||
const carlRing = knownKeys.get(
|
||||
getEncryptionKeyMapKey({
|
||||
userId: "@carl:example.org",
|
||||
deviceId: "CARLDEVICE",
|
||||
memberId: "@carl:example.org:CARLDEVICE",
|
||||
}),
|
||||
);
|
||||
expect(carlRing).toBeDefined();
|
||||
expect(carlRing).toHaveLength(2);
|
||||
expect(carlRing![0]).toMatchObject(
|
||||
@@ -687,7 +730,13 @@ describe("RTCEncryptionManager", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const bobRing = knownKeys.get(getParticipantId("@bob:example.org", "BOBDEVICE2"));
|
||||
const bobRing = knownKeys.get(
|
||||
getEncryptionKeyMapKey({
|
||||
userId: "@bob:example.org",
|
||||
deviceId: "BOBDEVICE2",
|
||||
memberId: "@bob:example.org:BOBDEVICE2",
|
||||
}),
|
||||
);
|
||||
expect(bobRing).toBeDefined();
|
||||
expect(bobRing).toHaveLength(1);
|
||||
expect(bobRing![0]).toMatchObject(
|
||||
@@ -697,7 +746,13 @@ describe("RTCEncryptionManager", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const bob1Ring = knownKeys.get(getParticipantId("@bob:example.org", "BOBDEVICE"));
|
||||
const bob1Ring = knownKeys.get(
|
||||
getEncryptionKeyMapKey({
|
||||
userId: "@bob:example.org",
|
||||
deviceId: "BOBDEVICE",
|
||||
memberId: "@bob:example.org:BOBDEVICE",
|
||||
}),
|
||||
);
|
||||
expect(bob1Ring).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -706,9 +761,9 @@ describe("RTCEncryptionManager", () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
let members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aStateBaseMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
@@ -721,12 +776,20 @@ describe("RTCEncryptionManager", () => {
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
0,
|
||||
{
|
||||
deviceId: "DEVICE01",
|
||||
memberId: "@alice:example.org:DEVICE01",
|
||||
userId: "@alice:example.org",
|
||||
},
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
|
||||
// Trigger a key rotation with a leaver
|
||||
members = [aCallMembership("@bob:example.org", "BOBDEVICE"), aCallMembership("@bob:example.org", "BOBDEVICE2")];
|
||||
members = [
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aStateBaseMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// This should start a new key rollout
|
||||
@@ -734,7 +797,7 @@ describe("RTCEncryptionManager", () => {
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Now simulate a new leaver
|
||||
members = [aCallMembership("@bob:example.org", "BOBDEVICE")];
|
||||
members = [aStateBaseMembership("@bob:example.org", "BOBDEVICE")];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// The key `1` rollout is in progress
|
||||
@@ -742,7 +805,7 @@ describe("RTCEncryptionManager", () => {
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// And another one ( plus a joiner)
|
||||
const lastMembership = [aCallMembership("@bob:example.org", "BOBDEVICE3")];
|
||||
const lastMembership = [aStateBaseMembership("@bob:example.org", "BOBDEVICE3")];
|
||||
getMembershipMock.mockReturnValue(lastMembership);
|
||||
// The key `1` rollout is still in progress
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
@@ -757,11 +820,21 @@ describe("RTCEncryptionManager", () => {
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
1,
|
||||
{
|
||||
deviceId: "DEVICE01",
|
||||
userId: "@alice:example.org",
|
||||
memberId: "@alice:example.org:DEVICE01",
|
||||
},
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
2,
|
||||
{
|
||||
deviceId: "DEVICE01",
|
||||
memberId: "@alice:example.org:DEVICE01",
|
||||
userId: "@alice:example.org",
|
||||
},
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
|
||||
@@ -780,10 +853,153 @@ describe("RTCEncryptionManager", () => {
|
||||
);
|
||||
});
|
||||
|
||||
function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
|
||||
describe("RTC backend pseudonymous id", () => {
|
||||
it("Should use pseudo rtcBackendIdentity if using sticky events", async () => {
|
||||
getMembershipMock.mockReturnValue([]);
|
||||
encryptionManager.join({
|
||||
manageMediaKeys: true,
|
||||
unstableSendStickyEvents: true,
|
||||
});
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
0,
|
||||
{
|
||||
deviceId: "DEVICE01",
|
||||
userId: "@alice:example.org",
|
||||
memberId: "@alice:example.org:DEVICE01",
|
||||
},
|
||||
"MOCKSHA<@alice:example.org|DEVICE01|@alice:example.org:DEVICE01>",
|
||||
);
|
||||
});
|
||||
|
||||
it("Should use legacy participant id if not using sticky event", async () => {
|
||||
getMembershipMock.mockReturnValue([]);
|
||||
encryptionManager.join({
|
||||
manageMediaKeys: true,
|
||||
unstableSendStickyEvents: false,
|
||||
});
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
0,
|
||||
{
|
||||
deviceId: "DEVICE01",
|
||||
userId: "@alice:example.org",
|
||||
memberId: "@alice:example.org:DEVICE01",
|
||||
},
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
});
|
||||
|
||||
it("Should use early keys as soon as the membership is known", async () => {
|
||||
const emitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
mockTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: emitter.on.bind(emitter),
|
||||
off: emitter.off.bind(emitter),
|
||||
emit: emitter.emit.bind(emitter),
|
||||
} as unknown as Mocked<ToDeviceKeyTransport>;
|
||||
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
|
||||
getMembershipMock,
|
||||
mockTransport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
logger,
|
||||
rtcIdentifierProvider,
|
||||
);
|
||||
|
||||
getMembershipMock.mockReturnValue([]);
|
||||
encryptionManager.join({
|
||||
manageMediaKeys: true,
|
||||
unstableSendStickyEvents: true,
|
||||
});
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await flushPromises();
|
||||
|
||||
// In 2.0 mode the participant identity is pseudo hashed and known from
|
||||
// the rtc membership itself. If a key is received before we have processed
|
||||
// the membership, we cannot pass it to the media layer yet because we don't know
|
||||
// the rtcBackendIdentity to use.
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
{ userId: "@bob:example.org", deviceId: "BOBDEVICE", memberId: "@bob:example.org:BOBDEVICE" },
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// No membership yet, cannot process the key, so should not have called the callback
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(1 /* only own key */);
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
0,
|
||||
{
|
||||
deviceId: "BOBDEVICE",
|
||||
userId: "@bob:example.org",
|
||||
memberId: "@bob:example.org:BOBDEVICE",
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
|
||||
// Now process membership
|
||||
const bobRtcId = "MOCKSHA<@bob:example.org|BOBDEVICE|@bob:example.org:BOBDEVICE>";
|
||||
const members = [aCallMembership("@bob:example.org", "BOBDEVICE", 1000, bobRtcId)];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await flushPromises();
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(2);
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
0,
|
||||
{
|
||||
deviceId: "BOBDEVICE",
|
||||
userId: "@bob:example.org",
|
||||
memberId: "@bob:example.org:BOBDEVICE",
|
||||
},
|
||||
bobRtcId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function aCallMembership(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
ts: number = 1000,
|
||||
rtcBackendIdentity: string,
|
||||
): CallMembership {
|
||||
return mockCallMembership(
|
||||
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
|
||||
"!room:id",
|
||||
rtcBackendIdentity,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a basic state membership event for the given user and device.
|
||||
* The rtcBackendIdentity is derived from userId and deviceId as `${userId}|${deviceId}`
|
||||
* @param userId
|
||||
* @param deviceId
|
||||
* @param ts
|
||||
*/
|
||||
function aStateBaseMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
|
||||
return mockCallMembership(
|
||||
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
|
||||
"!room:id",
|
||||
`${userId}|${deviceId}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -800,6 +1016,7 @@ function expectKeyAtIndexToHaveBeenSentTo(
|
||||
expect.arrayContaining([expect.objectContaining({ userId, deviceId })]),
|
||||
);
|
||||
}
|
||||
|
||||
function expectKeyAtIndexNotToHaveBeenSentTo(
|
||||
mockTransport: Mocked<ToDeviceKeyTransport>,
|
||||
index: number,
|
||||
|
||||
@@ -50,9 +50,15 @@ describe("ToDeviceKeyTransport", () => {
|
||||
},
|
||||
};
|
||||
|
||||
transport = new ToDeviceKeyTransport("@alice:example.org", "MYDEVICE", roomId, mockClient, statistics, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
transport = new ToDeviceKeyTransport(
|
||||
{ userId: "@alice:example.org", deviceId: "MYDEVICE", memberId: "@alice:example.org:MYDEVICE" },
|
||||
roomId,
|
||||
mockClient,
|
||||
statistics,
|
||||
{
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>,
|
||||
);
|
||||
});
|
||||
|
||||
it("should send my keys on via to device", async () => {
|
||||
@@ -81,6 +87,7 @@ describe("ToDeviceKeyTransport", () => {
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "MYDEVICE",
|
||||
id: "@alice:example.org:MYDEVICE",
|
||||
},
|
||||
room_id: roomId,
|
||||
session: {
|
||||
@@ -102,8 +109,13 @@ describe("ToDeviceKeyTransport", () => {
|
||||
keyBase64Encoded: string;
|
||||
index: number;
|
||||
}>();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
receivedKeyResolvers.resolve({ userId, deviceId, keyBase64Encoded, index });
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, (membership, keyBase64Encoded, index, _timestamp) => {
|
||||
receivedKeyResolvers.resolve({
|
||||
userId: membership.userId,
|
||||
deviceId: membership.deviceId,
|
||||
keyBase64Encoded,
|
||||
index,
|
||||
});
|
||||
});
|
||||
transport.start();
|
||||
|
||||
|
||||
@@ -18,11 +18,19 @@ 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";
|
||||
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc";
|
||||
import { secureRandomString } from "../../../src/randomstring";
|
||||
import { type CallMembershipIdentityParts } from "src/matrixrtc/EncryptionManager";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
export type MembershipData = (SessionMembershipData | {}) & { user_id: string };
|
||||
|
||||
export const owmMemberIdentity: CallMembershipIdentityParts = {
|
||||
deviceId: "AAAAAAA",
|
||||
memberId: "@alice:example.org:AAAAAAA",
|
||||
userId: "@alice:example.org",
|
||||
};
|
||||
|
||||
export const membershipTemplate: SessionMembershipData & { user_id: string } = {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
@@ -52,6 +60,9 @@ export type MockClient = Pick<
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "_unstable_cancelScheduledDelayedEvent"
|
||||
| "_unstable_restartScheduledDelayedEvent"
|
||||
| "_unstable_sendScheduledDelayedEvent"
|
||||
| "_unstable_sendStickyEvent"
|
||||
| "_unstable_sendStickyDelayedEvent"
|
||||
| "cancelPendingEvent"
|
||||
@@ -67,6 +78,9 @@ export function makeMockClient(userId: string, deviceId: string): MockClient {
|
||||
sendStateEvent: jest.fn(),
|
||||
cancelPendingEvent: jest.fn(),
|
||||
_unstable_updateDelayedEvent: jest.fn(),
|
||||
_unstable_cancelScheduledDelayedEvent: jest.fn(),
|
||||
_unstable_restartScheduledDelayedEvent: jest.fn(),
|
||||
_unstable_sendScheduledDelayedEvent: jest.fn(),
|
||||
_unstable_sendDelayedStateEvent: jest.fn(),
|
||||
_unstable_sendStickyEvent: jest.fn(),
|
||||
_unstable_sendStickyDelayedEvent: jest.fn(),
|
||||
@@ -174,8 +188,15 @@ export function mockRTCEvent(
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
export function mockCallMembership(membershipData: MembershipData, roomId: string): CallMembership {
|
||||
return new CallMembership(mockRTCEvent(membershipData, roomId), membershipData);
|
||||
export function mockCallMembership(
|
||||
membershipData: MembershipData,
|
||||
roomId: string,
|
||||
rtcBackendIdentity?: string,
|
||||
): CallMembership {
|
||||
const ev = mockRTCEvent(membershipData, roomId);
|
||||
(ev.getContent as jest.Mock).mockReturnValue(membershipData);
|
||||
const data = CallMembership.membershipDataFromMatrixEvent(ev);
|
||||
return new CallMembership(ev, data, rtcBackendIdentity ?? "xx", logger);
|
||||
}
|
||||
|
||||
export function makeKey(id: number, key: string): { key: string; index: number } {
|
||||
|
||||
@@ -127,15 +127,17 @@ describe("Poll", () => {
|
||||
|
||||
it("waits for existing relations request to finish when getting responses", async () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
// @ts-expect-error TS2769
|
||||
const spy = jest.spyOn(poll, "fetchResponses");
|
||||
const firstResponsePromise = poll.getResponses();
|
||||
const secondResponsePromise = poll.getResponses();
|
||||
await firstResponsePromise;
|
||||
expect(firstResponsePromise).toEqual(secondResponsePromise);
|
||||
await secondResponsePromise;
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(mockClient.relations).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("filters relations for relevent response events", async () => {
|
||||
it("filters relations for relevant response events", async () => {
|
||||
const replyEvent = makeRelatedEvent({ type: "m.room.message" });
|
||||
const stableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.stable! });
|
||||
const unstableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.unstable });
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Direction, type MatrixClient, MatrixEvent, Room } from "../../../src";
|
||||
import { Direction, EventType, type MatrixClient, MatrixEvent, Room } from "../../../src";
|
||||
import type { MockedObject } from "jest-mock";
|
||||
|
||||
const CREATOR_USER_ID = "@creator:example.org";
|
||||
@@ -185,4 +185,71 @@ describe("Room", () => {
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore invalid m.room.name events", async () => {
|
||||
const mockClient = createMockClient();
|
||||
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
|
||||
const invalidNameEvent = new MatrixEvent({
|
||||
type: EventType.RoomName,
|
||||
content: {
|
||||
name: { invalid: 123 },
|
||||
},
|
||||
state_key: "",
|
||||
event_id: "$123",
|
||||
room_id: room.roomId,
|
||||
sender: CREATOR_USER_ID,
|
||||
});
|
||||
|
||||
// Set up the room
|
||||
room.currentState.setStateEvents([invalidNameEvent]);
|
||||
room.recalculate();
|
||||
|
||||
expect(room.name).toEqual("Empty room");
|
||||
});
|
||||
|
||||
describe("getAltAliases()", () => {
|
||||
it("should ignore invalid events", async () => {
|
||||
const mockClient = createMockClient();
|
||||
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
|
||||
const invalidAliasEvent = new MatrixEvent({
|
||||
type: EventType.RoomCanonicalAlias,
|
||||
content: {
|
||||
alt_aliases: [123, "#foo:bar"],
|
||||
},
|
||||
state_key: "",
|
||||
event_id: "$123",
|
||||
room_id: room.roomId,
|
||||
sender: CREATOR_USER_ID,
|
||||
});
|
||||
|
||||
// Set up the room
|
||||
room.currentState.setStateEvents([invalidAliasEvent]);
|
||||
room.recalculate();
|
||||
|
||||
expect(room.getAltAliases()).toEqual(["#foo:bar"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateRoomName()", () => {
|
||||
it("should ignore empty m.room.name 'name' field", async () => {
|
||||
const mockClient = createMockClient();
|
||||
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
|
||||
const event = new MatrixEvent({
|
||||
type: EventType.RoomName,
|
||||
content: {
|
||||
name: "",
|
||||
},
|
||||
state_key: "",
|
||||
event_id: "$123",
|
||||
room_id: room.roomId,
|
||||
sender: CREATOR_USER_ID,
|
||||
});
|
||||
|
||||
// Set up the room
|
||||
room.currentState.setStateEvents([event]);
|
||||
room.recalculate();
|
||||
|
||||
expect(room.name).not.toEqual("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,6 @@ limitations under the License.
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { OidcTokenRefresher, TokenRefreshLogoutError } from "../../../src";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
|
||||
|
||||
describe("OidcTokenRefresher", () => {
|
||||
@@ -78,51 +77,49 @@ describe("OidcTokenRefresher", () => {
|
||||
fetchMock.resetBehavior();
|
||||
});
|
||||
|
||||
it("throws when oidc client cannot be initialised", async () => {
|
||||
jest.spyOn(logger, "error");
|
||||
fetchMock.get(
|
||||
`${config.issuer}.well-known/openid-configuration`,
|
||||
{
|
||||
ok: false,
|
||||
status: 404,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await expect(refresher.oidcClientReady).rejects.toThrow();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"Failed to initialise OIDC client.",
|
||||
// error from OidcClient
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it("initialises oidc client", async () => {
|
||||
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
|
||||
// @ts-ignore peek at private property to see we initialised the client correctly
|
||||
expect(refresher.oidcClient.settings).toEqual(
|
||||
expect.objectContaining({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
authority: authConfig.issuer,
|
||||
scope,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("doRefreshAccessToken()", () => {
|
||||
it("should throw when oidcClient has not been initialised", async () => {
|
||||
fetchMock.get(
|
||||
`${config.issuer}.well-known/openid-configuration`,
|
||||
{
|
||||
ok: false,
|
||||
status: 404,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow(
|
||||
"Cannot get new token before OIDC client is initialised.",
|
||||
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow("Failed to initialise OIDC client.");
|
||||
});
|
||||
|
||||
it("should retry initialisation", async () => {
|
||||
fetchMock.get(
|
||||
`${config.issuer}.well-known/openid-configuration`,
|
||||
{
|
||||
ok: false,
|
||||
status: 404,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow("Failed to initialise OIDC client.");
|
||||
|
||||
// put the successful mock back
|
||||
fetchMock.get(`${config.issuer}.well-known/openid-configuration`, config, { overwriteRoutes: true });
|
||||
|
||||
const result = await refresher.doRefreshAccessToken("token");
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should refresh the tokens", async () => {
|
||||
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
|
||||
const result = await refresher.doRefreshAccessToken("refresh-token");
|
||||
|
||||
@@ -140,13 +137,12 @@ describe("OidcTokenRefresher", () => {
|
||||
|
||||
it("should persist the new tokens", async () => {
|
||||
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
// spy on our stub
|
||||
jest.spyOn(refresher, "persistTokens");
|
||||
jest.spyOn(refresher as any, "persistTokens");
|
||||
|
||||
await refresher.doRefreshAccessToken("refresh-token");
|
||||
|
||||
expect(refresher.persistTokens).toHaveBeenCalledWith(
|
||||
expect((refresher as any).persistTokens).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
|
||||
@@ -19,7 +19,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START, type Optional, PollStartEvent } from "matrix-events-sdk";
|
||||
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START, PollStartEvent } from "matrix-events-sdk";
|
||||
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { emitPromise, type IMessageOpts } from "../test-utils/test-utils";
|
||||
@@ -197,8 +197,8 @@ describe("Room", function () {
|
||||
|
||||
const addRoomThreads = (
|
||||
room: Room,
|
||||
thread1EventTs: Optional<number>,
|
||||
thread2EventTs: Optional<number>,
|
||||
thread1EventTs?: number,
|
||||
thread2EventTs?: number,
|
||||
): { thread1?: Thread; thread2?: Thread } => {
|
||||
const result: { thread1?: Thread; thread2?: Thread } = {};
|
||||
|
||||
@@ -4159,7 +4159,7 @@ describe("Room", function () {
|
||||
});
|
||||
|
||||
it("when there is only one thread, it should return this one", () => {
|
||||
const { thread1 } = addRoomThreads(room, 23, null);
|
||||
const { thread1 } = addRoomThreads(room, 23);
|
||||
expect(room.getLastThread()).toBe(thread1);
|
||||
});
|
||||
|
||||
|
||||
@@ -92,9 +92,11 @@ describe("OutgoingRequestsManager", () => {
|
||||
await secondRequest;
|
||||
await thirdRequest;
|
||||
|
||||
// outgoingRequests should be called twice in total, as the second and third requests are
|
||||
// processed in the same loop.
|
||||
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(2);
|
||||
// outgoingRequests should be called three times in total:
|
||||
// 1. the first time,
|
||||
// 2. the second and third requests processed in the same loop, and
|
||||
// 3. checking that all requests are finished
|
||||
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(3);
|
||||
|
||||
expect(processor.makeOutgoingRequest).toHaveBeenCalledTimes(3);
|
||||
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request1);
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Mocked, type SpyInstance } from "jest-mock";
|
||||
import { type Mocked } from "jest-mock";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { type OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
@@ -211,14 +211,14 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
fetchMock.get(`path:/_matrix/client/v3/room_keys/keys/!roomA/sessionA1`, mockCipherKey);
|
||||
|
||||
// @ts-ignore access to private function
|
||||
const spy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
const spy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
|
||||
const expectImported = expectSessionImported("!roomA", "sessionA1");
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
|
||||
await jest.runAllTimersAsync();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy).toHaveLastReturnedWith(Promise.resolve({ ok: false, error: "MISSING_DECRYPTION_KEY" }));
|
||||
await expect(spy.mock.results[0].value).rejects.toThrow("MISSING_DECRYPTION_KEY");
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
|
||||
await jest.runAllTimersAsync();
|
||||
@@ -237,7 +237,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
});
|
||||
|
||||
// @ts-ignore access to private function
|
||||
const spy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
const spy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
|
||||
await jest.runAllTimersAsync();
|
||||
@@ -297,7 +297,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
});
|
||||
|
||||
describe("Given no usable backup available", () => {
|
||||
let getConfigSpy: SpyInstance;
|
||||
let getConfigSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(null);
|
||||
@@ -318,7 +318,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
|
||||
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
|
||||
|
||||
// isKeyBackupDownloadConfigured remains false
|
||||
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
|
||||
@@ -336,7 +336,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
|
||||
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
|
||||
|
||||
// isKeyBackupDownloadConfigured remains false
|
||||
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
|
||||
@@ -355,7 +355,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
|
||||
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
|
||||
|
||||
// isKeyBackupDownloadConfigured remains false
|
||||
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
|
||||
@@ -377,7 +377,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
|
||||
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
|
||||
|
||||
// isKeyBackupDownloadConfigured remains false
|
||||
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
|
||||
@@ -399,7 +399,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
|
||||
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
|
||||
|
||||
// isKeyBackupDownloadConfigured remains false
|
||||
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
|
||||
@@ -488,7 +488,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
const originalImplementation = downloader.queryKeyBackup.bind(downloader);
|
||||
|
||||
// @ts-ignore access to private function
|
||||
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
const keyQuerySpy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
const rateDeferred = Promise.withResolvers<void>();
|
||||
|
||||
keyQuerySpy.mockImplementation(
|
||||
@@ -542,7 +542,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
const originalImplementation = downloader.queryKeyBackup.bind(downloader);
|
||||
|
||||
// @ts-ignore
|
||||
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
const keyQuerySpy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
const errorDeferred = Promise.withResolvers<void>();
|
||||
|
||||
keyQuerySpy.mockImplementation(
|
||||
@@ -606,7 +606,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
});
|
||||
|
||||
// @ts-ignore access to private function
|
||||
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
const keyQuerySpy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`RustCrypto importing and exporting room keys should import and export keys 1`] = `
|
||||
{
|
||||
|
||||
@@ -98,7 +98,7 @@ function createLinkedTimelines(): [EventTimeline, EventTimeline] {
|
||||
describe("TimelineIndex", function () {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockClient.getEventTimeline.mockResolvedValue(undefined);
|
||||
mockClient.getEventTimeline.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("minIndex", function () {
|
||||
@@ -193,7 +193,7 @@ describe("TimelineWindow", function () {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockClient.getEventTimeline.mockResolvedValue(undefined);
|
||||
mockClient.getEventTimeline.mockResolvedValue(null);
|
||||
mockClient.paginateEventTimeline.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -187,12 +187,14 @@ describe("Group Call", function () {
|
||||
});
|
||||
|
||||
it("does not start initializing local call feed twice", () => {
|
||||
const promise1 = groupCall.initLocalCallFeed();
|
||||
// @ts-expect-error TS2769
|
||||
const spy = jest.spyOn(groupCall, "initLocalCallFeedInternal");
|
||||
groupCall.initLocalCallFeed();
|
||||
// @ts-ignore Mock
|
||||
groupCall.state = GroupCallState.LocalCallFeedUninitialized;
|
||||
const promise2 = groupCall.initLocalCallFeed();
|
||||
groupCall.initLocalCallFeed();
|
||||
|
||||
expect(promise1).toEqual(promise2);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sets state to local call feed uninitialized when getUserMedia() fails", async () => {
|
||||
|
||||
@@ -61,10 +61,10 @@ describe("Media Handler", function () {
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audio: expect.objectContaining({
|
||||
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
|
||||
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
|
||||
}),
|
||||
video: expect.objectContaining({
|
||||
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
|
||||
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -77,7 +77,7 @@ describe("Media Handler", function () {
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audio: expect.objectContaining({
|
||||
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
|
||||
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -109,7 +109,7 @@ describe("Media Handler", function () {
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
video: expect.objectContaining({
|
||||
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
|
||||
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -122,10 +122,10 @@ describe("Media Handler", function () {
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audio: expect.objectContaining({
|
||||
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
|
||||
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
|
||||
}),
|
||||
video: expect.objectContaining({
|
||||
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
|
||||
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -331,6 +331,80 @@ describe("Media Handler", function () {
|
||||
|
||||
expect(stream.getVideoTracks().length).toEqual(0);
|
||||
});
|
||||
|
||||
it("falls back to ideal deviceId when exact deviceId fails", async () => {
|
||||
// First call with exact should fail
|
||||
mockMediaDevices.getUserMedia
|
||||
.mockRejectedValueOnce(new Error("OverconstrainedError"))
|
||||
.mockImplementation((constraints: MediaStreamConstraints) => {
|
||||
const stream = new MockMediaStream("local_stream");
|
||||
if (constraints.audio) {
|
||||
const track = new MockMediaStreamTrack("audio_track", "audio");
|
||||
track.settings = { deviceId: FAKE_AUDIO_INPUT_ID };
|
||||
stream.addTrack(track);
|
||||
}
|
||||
return Promise.resolve(stream.typed());
|
||||
});
|
||||
|
||||
const stream = await mediaHandler.getUserMediaStream(true, false);
|
||||
|
||||
// Should have been called twice: once with exact, once with ideal
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledTimes(2);
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
audio: expect.objectContaining({
|
||||
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
audio: expect.objectContaining({
|
||||
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(stream).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to ideal deviceId for video when exact fails", async () => {
|
||||
// First call with exact should fail
|
||||
mockMediaDevices.getUserMedia
|
||||
.mockRejectedValueOnce(new Error("OverconstrainedError"))
|
||||
.mockImplementation((constraints: MediaStreamConstraints) => {
|
||||
const stream = new MockMediaStream("local_stream");
|
||||
if (constraints.video) {
|
||||
const track = new MockMediaStreamTrack("video_track", "video");
|
||||
track.settings = { deviceId: FAKE_VIDEO_INPUT_ID };
|
||||
stream.addTrack(track);
|
||||
}
|
||||
return Promise.resolve(stream.typed());
|
||||
});
|
||||
|
||||
const stream = await mediaHandler.getUserMediaStream(false, true);
|
||||
|
||||
// Should have been called twice: once with exact, once with ideal
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledTimes(2);
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
video: expect.objectContaining({
|
||||
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
video: expect.objectContaining({
|
||||
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(stream).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getScreensharingStream", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`CallFeedStatsReporter should builds CallFeedReport 1`] = `
|
||||
{
|
||||
|
||||
@@ -102,8 +102,7 @@ export interface IRoomMemberCountCondition extends IPushRuleCondition<ConditionK
|
||||
is: AnyMemberCountCondition;
|
||||
}
|
||||
|
||||
export interface ISenderNotificationPermissionCondition
|
||||
extends IPushRuleCondition<ConditionKind.SenderNotificationPermission> {
|
||||
export interface ISenderNotificationPermissionCondition extends IPushRuleCondition<ConditionKind.SenderNotificationPermission> {
|
||||
key: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,12 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ISignatures } from "./signed.ts";
|
||||
import type { EventDecryptionResult } from "../common-crypto/CryptoBackend.ts";
|
||||
|
||||
// Backwards compatible re-export
|
||||
export type { EventDecryptionResult as IEventDecryptionResult } from "../common-crypto/CryptoBackend.ts";
|
||||
/** @deprecated This is an internal type and should not be used. */
|
||||
type IEventDecryptionResult = EventDecryptionResult;
|
||||
export type { IEventDecryptionResult };
|
||||
|
||||
interface Extensible {
|
||||
[key: string]: any;
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type EitherAnd, NamespacedValue, type Optional, UnstableValue } from "matrix-events-sdk";
|
||||
import { type EitherAnd, NamespacedValue, UnstableValue } from "matrix-events-sdk";
|
||||
|
||||
import { isProvided } from "../extensible_events_v1/utilities.ts";
|
||||
|
||||
@@ -125,10 +125,7 @@ export type ExtensibleEventType = NamespacedValue<string, string> | string;
|
||||
* @param expected - The expected event type.
|
||||
* @returns True if the given type matches the expected type.
|
||||
*/
|
||||
export function isEventTypeSame(
|
||||
given: Optional<ExtensibleEventType>,
|
||||
expected: Optional<ExtensibleEventType>,
|
||||
): boolean {
|
||||
export function isEventTypeSame(given: ExtensibleEventType | null, expected: ExtensibleEventType | null): boolean {
|
||||
if (typeof given === "string") {
|
||||
if (typeof expected === "string") {
|
||||
return expected === given;
|
||||
|
||||
Vendored
+1
-1
@@ -62,6 +62,6 @@ declare global {
|
||||
|
||||
interface Uint8ArrayConstructor {
|
||||
// https://tc39.es/proposal-arraybuffer-base64/spec/#sec-uint8array.frombase64
|
||||
fromBase64?(base64: string, options?: Uint8ArrayFromBase64Options): Uint8Array;
|
||||
fromBase64?(base64: string, options?: Uint8ArrayFromBase64Options): Uint8Array<ArrayBuffer>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,12 +298,12 @@ export interface IRelationsResponse {
|
||||
}
|
||||
|
||||
export interface IContextResponse {
|
||||
end: string;
|
||||
start: string;
|
||||
state: IEventWithRoomId[];
|
||||
events_before: IEventWithRoomId[];
|
||||
events_after: IEventWithRoomId[];
|
||||
event: IEventWithRoomId;
|
||||
end?: string;
|
||||
start?: string;
|
||||
state?: IEventWithRoomId[];
|
||||
events_before?: IEventWithRoomId[];
|
||||
events_after?: IEventWithRoomId[];
|
||||
event?: IEventWithRoomId;
|
||||
}
|
||||
|
||||
export interface IEventsResponse {
|
||||
|
||||
@@ -106,7 +106,7 @@ export interface RoomPinnedEventsEventContent {
|
||||
|
||||
export interface RoomEncryptionEventContent {
|
||||
"algorithm": "m.megolm.v1.aes-sha2";
|
||||
"io.element.msc3414.encrypt_state_events"?: boolean;
|
||||
"io.element.msc4362.encrypt_state_events"?: boolean;
|
||||
"rotation_period_ms"?: number;
|
||||
"rotation_period_msgs"?: number;
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Optional } from "matrix-events-sdk";
|
||||
|
||||
/**
|
||||
* Represents a simple Matrix namespaced value. This will assume that if a stable prefix
|
||||
* is provided that the stable prefix should be used when representing the identifier.
|
||||
@@ -62,7 +60,7 @@ export class NamespacedValue<S extends string, U extends string> {
|
||||
|
||||
// this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class
|
||||
// so we can instantiate `NamespacedValue<string, _, _>` as a default type for that namespace.
|
||||
public findIn<T>(obj: any): Optional<T> {
|
||||
public findIn<T>(obj: any): T | undefined {
|
||||
let val: T | undefined = undefined;
|
||||
if (this.name) {
|
||||
val = obj?.[this.name];
|
||||
|
||||
+2
-2
@@ -64,7 +64,7 @@ export function encodeUnpaddedBase64Url(uint8Array: Uint8Array): string {
|
||||
return toBase64(uint8Array, { alphabet: "base64url", omitPadding: true });
|
||||
}
|
||||
|
||||
function fromBase64(base64: string, options: Uint8ArrayFromBase64Options): Uint8Array {
|
||||
function fromBase64(base64: string, options: Uint8ArrayFromBase64Options): Uint8Array<ArrayBuffer> {
|
||||
if (typeof Uint8Array.fromBase64 === "function") {
|
||||
// Currently this is only supported in Firefox,
|
||||
// but we match the options in the hope in the future we can rely on it for all environments.
|
||||
@@ -80,7 +80,7 @@ function fromBase64(base64: string, options: Uint8ArrayFromBase64Options): Uint8
|
||||
* @param base64 - The base64 to decode.
|
||||
* @returns The decoded data.
|
||||
*/
|
||||
export function decodeBase64(base64: string): Uint8Array {
|
||||
export function decodeBase64(base64: string): Uint8Array<ArrayBuffer> {
|
||||
// The function requires us to select an alphabet, but we don't know if base64url was used so we convert.
|
||||
return fromBase64(base64.replace(/-/g, "+").replace(/_/g, "/"), { alphabet: "base64", lastChunkHandling: "loose" });
|
||||
}
|
||||
|
||||
+281
-50
@@ -18,8 +18,6 @@ limitations under the License.
|
||||
* This is an internal module. See {@link MatrixClient} for the public class.
|
||||
*/
|
||||
|
||||
import { type Optional } from "matrix-events-sdk";
|
||||
|
||||
import type { IDeviceKeys, IOneTimeKey } from "./@types/crypto.ts";
|
||||
import { type ISyncStateData, type SetPresence, SyncApi, type SyncApiOptions, SyncState } from "./sync.ts";
|
||||
import {
|
||||
@@ -106,6 +104,7 @@ import { RoomMemberEvent, type RoomMemberEventHandlerMap } from "./models/room-m
|
||||
import { type IPowerLevelsContent, type RoomStateEvent, type RoomStateEventHandlerMap } from "./models/room-state.ts";
|
||||
import {
|
||||
isSendDelayedEventRequestOpts,
|
||||
UpdateDelayedEventAction,
|
||||
type DelayedEventInfo,
|
||||
type IAddThreePidOnlyBody,
|
||||
type IBindThreePidBody,
|
||||
@@ -130,7 +129,6 @@ import {
|
||||
type KnockRoomOpts,
|
||||
type SendDelayedEventRequestOpts,
|
||||
type SendDelayedEventResponse,
|
||||
type UpdateDelayedEventAction,
|
||||
} from "./@types/requests.ts";
|
||||
import {
|
||||
type AccountDataEvents,
|
||||
@@ -248,6 +246,7 @@ import {
|
||||
} from "./oidc/index.ts";
|
||||
import { type EmptyObject } from "./@types/common.ts";
|
||||
import { UnsupportedDelayedEventsEndpointError, UnsupportedStickyEventsEndpointError } from "./errors.ts";
|
||||
import { type Transport } from "./matrixrtc/index.ts";
|
||||
|
||||
export type Store = IStore;
|
||||
|
||||
@@ -1218,7 +1217,26 @@ 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;
|
||||
|
||||
/**
|
||||
* Support MSC4362: Simplified Encrypted State Events.
|
||||
*
|
||||
* The client must be recreated for changes to this setting to take effect
|
||||
* reliably.
|
||||
*
|
||||
* When this setting is true, if we find a state event that is encrypted
|
||||
* (within a room that supports encrypted state), we will attempt to decrypt
|
||||
* it as specified in MSC4362. If the user was in the room at the time an
|
||||
* encrypted state event was received (meaning we have the key), even if
|
||||
* this setting was set to false at the time it was received, recreating the
|
||||
* client with this setting set to true will allow decrypting that event.
|
||||
*
|
||||
* When this setting is false, any state event that is encrypted will not be
|
||||
* decrypted, meaning it will have no effect. This matched the behaviour of
|
||||
* a client that does not support MSC4362.
|
||||
*/
|
||||
public enableEncryptedStateEvents: boolean;
|
||||
|
||||
public cryptoCallbacks: CryptoCallbacks; // XXX: Intended private, used in code.
|
||||
public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code.
|
||||
public groupCallEventHandler?: GroupCallEventHandler;
|
||||
@@ -2408,7 +2426,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
const roomId = res.room_id;
|
||||
if (opts.acceptSharedHistory && inviter && this.cryptoBackend) {
|
||||
await this.cryptoBackend.maybeAcceptKeyBundle(roomId, inviter);
|
||||
// Try to accept the room key bundle specified in a `m.room_key_bundle` to-device message we (might have) already received.
|
||||
const bundleDownloaded = await this.cryptoBackend.maybeAcceptKeyBundle(roomId, inviter);
|
||||
// If this fails, i.e. we haven't received this message yet, we need to wait until the to-device message arrives.
|
||||
if (!bundleDownloaded) {
|
||||
this.cryptoBackend.markRoomAsPendingKeyBundle(roomId, inviter);
|
||||
}
|
||||
}
|
||||
|
||||
// In case we were originally given an alias, check the room cache again
|
||||
@@ -2458,8 +2481,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param event - The event to resend.
|
||||
* @param room - Optional. The room the event is in. Will update the
|
||||
* timeline entry if provided.
|
||||
* @returns Promise which resolves: to an ISendEventResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public resendEvent(event: MatrixEvent, room: Room): Promise<ISendEventResponse> {
|
||||
// also kick the to-device queue to retry
|
||||
@@ -2498,6 +2521,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* @returns Promise which resolves: TODO
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public setRoomName(roomId: string, name: string): Promise<ISendEventResponse> {
|
||||
return this.sendStateEvent(roomId, EventType.RoomName, { name: name });
|
||||
@@ -2509,6 +2534,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param htmlTopic - Optional.
|
||||
* @returns Promise which resolves: TODO
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public setRoomTopic(roomId: string, topic?: string, htmlTopic?: string): Promise<ISendEventResponse> {
|
||||
const content = ContentHelpers.makeTopicContent(topic, htmlTopic);
|
||||
@@ -2580,6 +2607,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param powerLevel - the numeric power level to update given users to
|
||||
* @returns Promise which resolves: to an ISendEventResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public async setPowerLevel(
|
||||
roomId: string,
|
||||
@@ -2625,6 +2654,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* Create an m.beacon_info event
|
||||
* @returns
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async unstable_createLiveBeacon(
|
||||
@@ -2639,6 +2670,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* using a specific m.beacon_info.* event variable type
|
||||
* @param roomId - string
|
||||
* @returns
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async unstable_setLiveBeacon(
|
||||
@@ -2648,6 +2681,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.sendStateEvent(roomId, M_BEACON_INFO.name, beaconInfoContent, this.getUserId()!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Matrix timeline event.
|
||||
* @param roomId The room to send to.
|
||||
* @param eventType The event type.
|
||||
* @param content The event content.
|
||||
* @param txnId An optional ID to deduplicate requests in case of repeated attempts.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public sendEvent<K extends keyof TimelineEvents>(
|
||||
roomId: string,
|
||||
eventType: K,
|
||||
@@ -3077,6 +3119,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws Error if called with `with_rel_types` (MSC3912) but the server does not support it.
|
||||
* Callers should check whether the server supports MSC3912 via `MatrixClient.canSupport`.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public redactEvent(
|
||||
roomId: string,
|
||||
@@ -3223,6 +3267,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param txnId - Optional.
|
||||
* @returns Promise which resolves: to a ISendEventResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public sendEmoteMessage(roomId: string, body: string, txnId?: string): Promise<ISendEventResponse>;
|
||||
public sendEmoteMessage(
|
||||
@@ -3249,6 +3295,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* @returns Promise which resolves: to a ISendEventResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public sendImageMessage(roomId: string, url: string, info?: ImageInfo, text?: string): Promise<ISendEventResponse>;
|
||||
public sendImageMessage(
|
||||
@@ -3283,6 +3331,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* @returns Promise which resolves: to a ISendEventResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public sendStickerMessage(
|
||||
roomId: string,
|
||||
@@ -3322,6 +3372,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* @returns Promise which resolves: to a ISendEventResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public sendHtmlMessage(roomId: string, body: string, htmlBody: string): Promise<ISendEventResponse>;
|
||||
public sendHtmlMessage(
|
||||
@@ -3348,6 +3400,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* @returns Promise which resolves: to a ISendEventResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public sendHtmlNotice(roomId: string, body: string, htmlBody: string): Promise<ISendEventResponse>;
|
||||
public sendHtmlNotice(
|
||||
@@ -3374,6 +3428,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* @returns Promise which resolves: to a ISendEventResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public sendHtmlEmote(roomId: string, body: string, htmlBody: string): Promise<ISendEventResponse>;
|
||||
public sendHtmlEmote(
|
||||
@@ -3402,6 +3458,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* 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.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public async _unstable_sendDelayedEvent<K extends keyof TimelineEvents>(
|
||||
@@ -3435,6 +3493,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* 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.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public async _unstable_sendStickyDelayedEvent<K extends keyof TimelineEvents>(
|
||||
@@ -3443,7 +3503,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
delayOpts: SendDelayedEventRequestOpts,
|
||||
threadId: string | null,
|
||||
eventType: K,
|
||||
content: TimelineEvents[K] & { msc4354_sticky_key: string },
|
||||
content: TimelineEvents[K] & { msc4354_sticky_key?: string },
|
||||
txnId?: string,
|
||||
): Promise<SendDelayedEventResponse> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
@@ -3475,6 +3535,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* 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.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public async _unstable_sendDelayedStateEvent<K extends keyof StateEvents>(
|
||||
@@ -3509,6 +3571,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* 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.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public async _unstable_sendStickyEvent<K extends keyof TimelineEvents>(
|
||||
@@ -3516,7 +3580,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
stickDuration: number,
|
||||
threadId: string | null,
|
||||
eventType: K,
|
||||
content: TimelineEvents[K] & { msc4354_sticky_key: string },
|
||||
content: TimelineEvents[K] & { msc4354_sticky_key?: string },
|
||||
txnId?: string,
|
||||
): Promise<ISendEventResponse> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS))) {
|
||||
@@ -3570,8 +3634,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @deprecated Instead use one of:
|
||||
* - {@link _unstable_cancelScheduledDelayedEvent}
|
||||
* - {@link _unstable_restartScheduledDelayedEvent}
|
||||
* - {@link _unstable_sendScheduledDelayedEvent}
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_updateDelayedEvent(
|
||||
delayId: string,
|
||||
action: UpdateDelayedEventAction,
|
||||
@@ -3583,17 +3652,125 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
"updateDelayedEvent",
|
||||
);
|
||||
}
|
||||
return await this.updateScheduledDelayedEventWithActionInBody(delayId, action, requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the scheduled delivery of the delayed event matching the provided delayId.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @throws A M_NOT_FOUND error if no matching delayed event could be found.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_cancelScheduledDelayedEvent(
|
||||
delayId: string,
|
||||
requestOptions: IRequestOpts = {},
|
||||
): Promise<EmptyObject> {
|
||||
return await this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Cancel, requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the scheduled delivery of the delayed event matching the given delayId.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @throws A M_NOT_FOUND error if no matching delayed event could be found.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_restartScheduledDelayedEvent(
|
||||
delayId: string,
|
||||
requestOptions: IRequestOpts = {},
|
||||
): Promise<EmptyObject> {
|
||||
return await this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Restart, requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately send the delayed event matching the given delayId,
|
||||
* instead of waiting for its scheduled delivery.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @throws A M_NOT_FOUND error if no matching delayed event could be found.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_sendScheduledDelayedEvent(
|
||||
delayId: string,
|
||||
requestOptions: IRequestOpts = {},
|
||||
): Promise<EmptyObject> {
|
||||
return await this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Send, requestOptions);
|
||||
}
|
||||
|
||||
private async updateScheduledDelayedEvent(
|
||||
delayId: string,
|
||||
action: UpdateDelayedEventAction,
|
||||
requestOptions: IRequestOpts = {},
|
||||
): Promise<EmptyObject> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
`${action}ScheduledDelayedEvent`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const path = utils.encodeUri("/delayed_events/$delayId/$action", {
|
||||
$delayId: delayId,
|
||||
$action: action,
|
||||
});
|
||||
return await this.http.request(Method.Post, path, undefined, undefined, {
|
||||
...requestOptions,
|
||||
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") {
|
||||
// For backwards compatibility with an older version of this endpoint
|
||||
// which put the update action in the request body instead of the path
|
||||
return await this.updateScheduledDelayedEventWithActionInBody(delayId, action, requestOptions);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Present for backwards compatibility with an older version of MSC4140
|
||||
* which had a single, authenticated endpoint for updating a delayed event, instead
|
||||
* of one unauthenticated endpoint per update action.
|
||||
*/
|
||||
private async updateScheduledDelayedEventWithActionInBody(
|
||||
delayId: string,
|
||||
action: UpdateDelayedEventAction,
|
||||
requestOptions: IRequestOpts = {},
|
||||
): Promise<EmptyObject> {
|
||||
const path = utils.encodeUri("/delayed_events/$delayId", {
|
||||
$delayId: delayId,
|
||||
});
|
||||
const data = {
|
||||
action,
|
||||
};
|
||||
return await this.http.authedRequest(Method.Post, path, undefined, data, {
|
||||
...requestOptions,
|
||||
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
|
||||
});
|
||||
try {
|
||||
return await this.http.request(Method.Post, path, undefined, data, {
|
||||
...requestOptions,
|
||||
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof MatrixError && e.errcode === "M_MISSING_TOKEN") {
|
||||
// For backwards compatibility with an older version of this endpoint
|
||||
// which required authentication
|
||||
return await this.http.authedRequest(Method.Post, path, undefined, data, {
|
||||
...requestOptions,
|
||||
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3706,6 +3883,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return await this.setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId, rpEventId);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param roomId
|
||||
* @param notificationEventId
|
||||
* @returns
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public sendRtcDecline(roomId: string, notificationEventId: string): Promise<ISendEventResponse> {
|
||||
return this.sendEvent(roomId, EventType.RTCDecline, {
|
||||
"m.relates_to": { event_id: notificationEventId, rel_type: RelationType.Reference },
|
||||
@@ -3885,6 +4070,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param opts - Optional reason object. For backwards compatibility, a string is also accepted, and will be interpreted as a reason.
|
||||
*
|
||||
* @returns An empty object.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public async invite(roomId: string, userId: string, opts: InviteOpts | string = {}): Promise<EmptyObject> {
|
||||
if (typeof opts != "object") {
|
||||
@@ -3904,6 +4091,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param email - The email address to invite.
|
||||
* @returns Promise which resolves: `{}` an empty object.
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public inviteByEmail(roomId: string, email: string): Promise<EmptyObject> {
|
||||
return this.inviteByThreePid(roomId, "email", email);
|
||||
@@ -3916,6 +4105,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param address - The address for the specified medium.
|
||||
* @returns Promise which resolves: `{}` an empty object.
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public async inviteByThreePid(roomId: string, medium: string, address: string): Promise<EmptyObject> {
|
||||
const path = utils.encodeUri("/rooms/$roomId/invite", { $roomId: roomId });
|
||||
@@ -4119,6 +4310,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param data - The JSON object to set.
|
||||
* @returns
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
// eslint-disable-next-line camelcase
|
||||
public setProfileInfo(info: "avatar_url", data: { avatar_url: string }): Promise<EmptyObject>;
|
||||
@@ -4134,6 +4327,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* @returns Promise which resolves: `{}` an empty object.
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public async setDisplayName(name: string): Promise<EmptyObject> {
|
||||
const prom = await this.setProfileInfo("displayname", { displayname: name });
|
||||
@@ -4149,6 +4344,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* @returns Promise which resolves: `{}` an empty object.
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public async setAvatarUrl(url: string): Promise<EmptyObject> {
|
||||
const prom = await this.setProfileInfo("avatar_url", { avatar_url: url });
|
||||
@@ -4336,6 +4533,46 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return eventMapperFor(this, options || {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the `/context` API for the given room ID & event ID.
|
||||
* Returns the response, with `event` asserted and all optional arrays defaulted to an empty array.
|
||||
* @param roomId - the room ID to request a context for
|
||||
* @param eventId - the event ID to request a context for
|
||||
* @throws if `event` in the response is missing
|
||||
* @private
|
||||
*/
|
||||
private async getEventContext(
|
||||
roomId: string,
|
||||
eventId: string,
|
||||
): Promise<IContextResponse & Omit<Required<IContextResponse>, "start" | "end">> {
|
||||
const path = utils.encodeUri("/rooms/$roomId/context/$eventId", {
|
||||
$roomId: roomId,
|
||||
$eventId: eventId,
|
||||
});
|
||||
|
||||
const params: Record<string, string | string[]> = {
|
||||
limit: "0",
|
||||
};
|
||||
if (this.clientOpts?.lazyLoadMembers) {
|
||||
params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER);
|
||||
}
|
||||
|
||||
// TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors.
|
||||
const res = await this.http.authedRequest<IContextResponse>(Method.Get, path, params);
|
||||
if (res.event) {
|
||||
return {
|
||||
start: res.start,
|
||||
end: res.end,
|
||||
event: res.event,
|
||||
events_after: res.events_after ?? [],
|
||||
events_before: res.events_before ?? [],
|
||||
state: res.state ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("'event' not in '/context' result - homeserver too old?");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an EventTimeline for the given event
|
||||
*
|
||||
@@ -4350,7 +4587,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves:
|
||||
* {@link EventTimeline} including the given event
|
||||
*/
|
||||
public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<Optional<EventTimeline>> {
|
||||
public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<EventTimeline | null> {
|
||||
// don't allow any timeline support unless it's been enabled.
|
||||
if (!this.timelineSupport) {
|
||||
throw new Error(
|
||||
@@ -4368,24 +4605,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
if (timelineSet.thread && this.supportsThreads()) {
|
||||
return this.getThreadTimeline(timelineSet, eventId);
|
||||
return (await this.getThreadTimeline(timelineSet, eventId)) ?? null;
|
||||
}
|
||||
|
||||
const path = utils.encodeUri("/rooms/$roomId/context/$eventId", {
|
||||
$roomId: timelineSet.room.roomId,
|
||||
$eventId: eventId,
|
||||
});
|
||||
|
||||
let params: Record<string, string | string[]> | undefined = undefined;
|
||||
if (this.clientOpts?.lazyLoadMembers) {
|
||||
params = { filter: JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER) };
|
||||
}
|
||||
|
||||
// TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors.
|
||||
const res = await this.http.authedRequest<IContextResponse>(Method.Get, path, params);
|
||||
if (!res.event) {
|
||||
throw new Error("'event' not in '/context' result - homeserver too old?");
|
||||
}
|
||||
const res = await this.getEventContext(timelineSet.room.roomId, eventId);
|
||||
|
||||
// by the time the request completes, the event might have ended up in the timeline.
|
||||
if (timelineSet.getTimelineForEvent(eventId)) {
|
||||
@@ -4396,7 +4619,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const event = mapper(res.event);
|
||||
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
|
||||
this.logger.warn("Tried loading a regular timeline at the position of a thread event");
|
||||
return undefined;
|
||||
return null;
|
||||
}
|
||||
const events = [
|
||||
// Order events from most recent to oldest (reverse-chronological).
|
||||
@@ -4414,7 +4637,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
} else {
|
||||
timeline = timelineSet.addTimeline();
|
||||
timeline.initialiseState(res.state.map(mapper));
|
||||
timeline.getState(EventTimeline.FORWARDS)!.paginationToken = res.end;
|
||||
timeline.getState(EventTimeline.FORWARDS)!.paginationToken = res.end ?? null;
|
||||
}
|
||||
|
||||
const [timelineEvents, threadedEvents, unknownRelations] = timelineSet.room.partitionThreadedEvents(events);
|
||||
@@ -4447,20 +4670,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
throw new Error("could not get thread timeline: not a thread timeline");
|
||||
}
|
||||
|
||||
const path = utils.encodeUri("/rooms/$roomId/context/$eventId", {
|
||||
$roomId: timelineSet.room.roomId,
|
||||
$eventId: eventId,
|
||||
});
|
||||
const res = await this.getEventContext(timelineSet.room.roomId, eventId);
|
||||
|
||||
const params: Record<string, string | string[]> = {
|
||||
limit: "0",
|
||||
};
|
||||
if (this.clientOpts?.lazyLoadMembers) {
|
||||
params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER);
|
||||
}
|
||||
|
||||
// TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors.
|
||||
const res = await this.http.authedRequest<IContextResponse>(Method.Get, path, params);
|
||||
const mapper = this.getEventMapper();
|
||||
const event = mapper(res.event);
|
||||
|
||||
@@ -4540,7 +4751,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
{ dir: Direction.Backward, from: res.start, recurse: recurse || undefined },
|
||||
);
|
||||
const eventsNewer: IEvent[] = [];
|
||||
let nextBatch: Optional<string> = res.end;
|
||||
let nextBatch = res.end;
|
||||
while (nextBatch) {
|
||||
const resNewer: IRelationsResponse = await this.fetchRelations(
|
||||
timelineSet.room.roomId,
|
||||
@@ -4549,7 +4760,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
null,
|
||||
{ dir: Direction.Forward, from: nextBatch, recurse: recurse || undefined },
|
||||
);
|
||||
nextBatch = resNewer.next_batch ?? null;
|
||||
nextBatch = resNewer.next_batch;
|
||||
eventsNewer.push(...resNewer.chunk);
|
||||
}
|
||||
const events = [
|
||||
@@ -4593,7 +4804,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves:
|
||||
* {@link EventTimeline} timeline with the latest events in the room
|
||||
*/
|
||||
public async getLatestTimeline(timelineSet: EventTimelineSet): Promise<Optional<EventTimeline>> {
|
||||
public async getLatestTimeline(timelineSet: EventTimelineSet): Promise<EventTimeline | null> {
|
||||
// don't allow any timeline support unless it's been enabled.
|
||||
if (!this.timelineSupport) {
|
||||
throw new Error(
|
||||
@@ -5952,6 +6163,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return rooms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a set of configured RTC transports supported by the homeserver.
|
||||
* Requires homeserver support for MSC4143.
|
||||
* @throws A M_NOT_FOUND error if not supported by the homeserver.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_getRTCTransports(): Promise<Transport[]> {
|
||||
// There is no /versions endpoint to check for support, so we just have to attempt a request.
|
||||
return (
|
||||
await this.http.authedRequest<{
|
||||
rtc_transports: Transport[];
|
||||
}>(Method.Get, "/rtc/transports", undefined, undefined, {
|
||||
prefix: `${ClientPrefix.Unstable}/org.matrix.msc4143`,
|
||||
})
|
||||
).rtc_transports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API versions supported by the server, along with any
|
||||
* unstable APIs it supports
|
||||
@@ -6805,7 +7033,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the event is excluded under MSC3414
|
||||
// Check if the event is excluded under MSC4362
|
||||
if (
|
||||
[
|
||||
"m.room.create",
|
||||
@@ -6897,9 +7125,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public rooms list from the server. Supports pagination
|
||||
* @param params - Options for this request
|
||||
* @returns Promise which resolves: IPublicRoomsResponse
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public publicRooms({
|
||||
server,
|
||||
|
||||
@@ -90,8 +90,20 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
*
|
||||
* @param inviter - The user who invited us to the room and is expected to have
|
||||
* sent the room key bundle.
|
||||
*
|
||||
* @returns `true` if the key bundle was successfuly downloaded and imported.
|
||||
*/
|
||||
maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<void>;
|
||||
maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Mark a room as pending a key bundle under MSC4268. The backend will listen for room key bundle messages, and if
|
||||
* it sees one matching the room specified, it will automatically import it as long as the message author's ID matches
|
||||
* the inviter's ID.
|
||||
*
|
||||
* @param roomId - The room we were invited to, for which we did not receive a key bundle before accepting the invite.
|
||||
* @param inviterId - The user who invited us to the room and is expected to send the room key bundle.
|
||||
*/
|
||||
markRoomAsPendingKeyBundle(roomId: string, inviterId: string): void;
|
||||
}
|
||||
|
||||
/** The methods which crypto implementations should expose to the Sync api
|
||||
@@ -188,8 +200,9 @@ export interface EventDecryptionResult {
|
||||
*/
|
||||
clearEvent: IClearEvent;
|
||||
/**
|
||||
* List of curve25519 keys involved in telling us about the senderCurve25519Key and claimedEd25519Key.
|
||||
* No longer used.
|
||||
* See {@link MatrixEvent#getForwardingCurve25519KeyChain}.
|
||||
* @deprecated
|
||||
*/
|
||||
forwardingCurve25519KeyChain?: string[];
|
||||
/**
|
||||
@@ -200,11 +213,6 @@ export interface EventDecryptionResult {
|
||||
* ed25519 key claimed by the sender of this event. See {@link MatrixEvent#getClaimedEd25519Key}.
|
||||
*/
|
||||
claimedEd25519Key?: string;
|
||||
/**
|
||||
* Whether the keys for this event have been received via an unauthenticated source (eg via key forwards, or
|
||||
* restored from backup)
|
||||
*/
|
||||
untrusted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,8 +20,10 @@ import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
/**
|
||||
* An incoming, or outgoing, request to verify a user or a device via cross-signing.
|
||||
*/
|
||||
export interface VerificationRequest
|
||||
extends TypedEventEmitter<VerificationRequestEvent, VerificationRequestEventHandlerMap> {
|
||||
export interface VerificationRequest extends TypedEventEmitter<
|
||||
VerificationRequestEvent,
|
||||
VerificationRequestEventHandlerMap
|
||||
> {
|
||||
/**
|
||||
* Unique ID for this verification request.
|
||||
*
|
||||
|
||||
+67
-3
@@ -37,7 +37,7 @@ import {
|
||||
type ISendEventResponse,
|
||||
type SendDelayedEventRequestOpts,
|
||||
type SendDelayedEventResponse,
|
||||
type UpdateDelayedEventAction,
|
||||
UpdateDelayedEventAction,
|
||||
} from "./@types/requests.ts";
|
||||
import { EventType, type StateEvents } from "./@types/event.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
@@ -459,8 +459,12 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
|
||||
/**
|
||||
* @experimental This currently relies on an unstable MSC (MSC4140).
|
||||
* @deprecated Instead use one of:
|
||||
* - {@link _unstable_cancelScheduledDelayedEvent}
|
||||
* - {@link _unstable_restartScheduledDelayedEvent}
|
||||
* - {@link _unstable_sendScheduledDelayedEvent}
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise<EmptyObject> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
@@ -469,7 +473,67 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
);
|
||||
}
|
||||
|
||||
await this.widgetApi.updateDelayedEvent(delayId, action).catch(timeoutToConnectionError);
|
||||
let updateDelayedEvent: (delayId: string) => Promise<unknown>;
|
||||
switch (action) {
|
||||
case UpdateDelayedEventAction.Cancel:
|
||||
updateDelayedEvent = this.widgetApi.cancelScheduledDelayedEvent;
|
||||
break;
|
||||
case UpdateDelayedEventAction.Restart:
|
||||
updateDelayedEvent = this.widgetApi.cancelScheduledDelayedEvent;
|
||||
break;
|
||||
case UpdateDelayedEventAction.Send:
|
||||
updateDelayedEvent = this.widgetApi.sendScheduledDelayedEvent;
|
||||
break;
|
||||
}
|
||||
await updateDelayedEvent.call(this.widgetApi, delayId).catch(timeoutToConnectionError);
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental This currently relies on an unstable MSC (MSC4140).
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_cancelScheduledDelayedEvent(delayId: string): Promise<EmptyObject> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"cancelScheduledDelayedEvent",
|
||||
);
|
||||
}
|
||||
|
||||
await this.widgetApi.cancelScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental This currently relies on an unstable MSC (MSC4140).
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_restartScheduledDelayedEvent(delayId: string): Promise<EmptyObject> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"restartScheduledDelayedEvent",
|
||||
);
|
||||
}
|
||||
|
||||
await this.widgetApi.restartScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental This currently relies on an unstable MSC (MSC4140).
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_sendScheduledDelayedEvent(delayId: string): Promise<EmptyObject> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"sendScheduledDelayedEvent",
|
||||
);
|
||||
}
|
||||
|
||||
await this.widgetApi.sendScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -59,7 +59,14 @@ export class ClientStoppedError extends Error {
|
||||
export class UnsupportedDelayedEventsEndpointError extends Error {
|
||||
public constructor(
|
||||
message: string,
|
||||
public clientEndpoint: "sendDelayedEvent" | "updateDelayedEvent" | "sendDelayedStateEvent" | "getDelayedEvents",
|
||||
public clientEndpoint:
|
||||
| "sendDelayedEvent"
|
||||
| "updateDelayedEvent"
|
||||
| "cancelScheduledDelayedEvent"
|
||||
| "restartScheduledDelayedEvent"
|
||||
| "sendScheduledDelayedEvent"
|
||||
| "sendDelayedStateEvent"
|
||||
| "getDelayedEvents",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "UnsupportedDelayedEventsEndpointError";
|
||||
|
||||
@@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Optional } from "matrix-events-sdk";
|
||||
|
||||
import { ExtensibleEvent } from "./ExtensibleEvent.ts";
|
||||
import {
|
||||
type ExtensibleEventType,
|
||||
@@ -46,7 +44,7 @@ export class MessageEvent extends ExtensibleEvent<ExtensibleAnyMessageEventConte
|
||||
/**
|
||||
* The default HTML for the event, if provided.
|
||||
*/
|
||||
public readonly html: Optional<string>;
|
||||
public readonly html?: string;
|
||||
|
||||
/**
|
||||
* All the different renderings of the message. Note that this is the same
|
||||
@@ -82,7 +80,7 @@ export class MessageEvent extends ExtensibleEvent<ExtensibleAnyMessageEventConte
|
||||
this.renderings = mmessage;
|
||||
} else if (isOptionalAString(mtext)) {
|
||||
this.text = mtext;
|
||||
this.html = mhtml;
|
||||
this.html = mhtml ?? undefined;
|
||||
this.renderings = [{ body: mtext, mimetype: "text/plain" }];
|
||||
if (this.html) {
|
||||
this.renderings.push({ body: this.html, mimetype: "text/html" });
|
||||
|
||||
@@ -18,6 +18,7 @@ import { type IMatrixApiError as IWidgetMatrixError } from "matrix-widget-api";
|
||||
|
||||
import { type IUsageLimit } from "../@types/partials.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { NamespacedValue } from "../NamespacedValue.ts";
|
||||
|
||||
interface IErrorJson extends Partial<IUsageLimit> {
|
||||
[key: string]: any; // extensible
|
||||
@@ -82,6 +83,8 @@ export class HTTPError extends Error {
|
||||
export class MatrixError extends HTTPError {
|
||||
// The Matrix 'errcode' value, e.g. "M_FORBIDDEN".
|
||||
public readonly errcode?: string;
|
||||
// The Matrix 'error' value.
|
||||
public readonly error?: string;
|
||||
// The raw Matrix error JSON used to construct this object.
|
||||
public data: IErrorJson;
|
||||
|
||||
@@ -108,6 +111,7 @@ export class MatrixError extends HTTPError {
|
||||
}
|
||||
super(`MatrixError: ${message}`, httpStatus, httpHeaders);
|
||||
this.errcode = errorJson.errcode;
|
||||
this.error = errorJson.error;
|
||||
this.name = errorJson.errcode || "Unknown error code";
|
||||
this.data = errorJson;
|
||||
}
|
||||
@@ -226,3 +230,32 @@ export class TokenRefreshLogoutError extends Error {
|
||||
return "TokenRefreshLogoutError";
|
||||
}
|
||||
}
|
||||
|
||||
export const MatrixSafetyErrorCode = new NamespacedValue(null, "ORG.MATRIX.MSC4387_SAFETY");
|
||||
|
||||
/***
|
||||
* This error is thrown when the homeserver refuses to handle an action due to a
|
||||
* safety concern.
|
||||
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/4387
|
||||
*/
|
||||
export class MatrixSafetyError extends MatrixError {
|
||||
/**
|
||||
* The kinds of harms detected by the server.
|
||||
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/4387 for a list of spec defined harms.
|
||||
*/
|
||||
public readonly harms: Set<string>;
|
||||
/**
|
||||
* The date at which a request can be reattempted.
|
||||
*/
|
||||
public readonly expiry?: Date;
|
||||
public constructor(...props: ConstructorParameters<typeof MatrixError>) {
|
||||
super(...props);
|
||||
const body = props[0];
|
||||
|
||||
this.harms = new Set(body && "harms" in body && Array.isArray(body.harms) ? body.harms : []);
|
||||
this.message = `${super.message} (${[...this.harms].join(", ")})`;
|
||||
if (body && "expiry" in body && typeof body.expiry === "number") {
|
||||
this.expiry = new Date(body.expiry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +279,13 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
|
||||
const { signal, cleanup } = anySignal(signals);
|
||||
|
||||
// Set cache mode based on presence of Authorization header.
|
||||
// Browsers/proxies do not cache responses to requests with Authorization headers.
|
||||
// So specifying "no-cache" is redundant, and actually prevents caching
|
||||
// of preflight requests in CORS scenarios. As such, we only set "no-cache"
|
||||
// when there is no Authorization header.
|
||||
const cacheMode = "Authorization" in headers ? undefined : "no-cache";
|
||||
|
||||
let res: Response;
|
||||
const start = Date.now();
|
||||
try {
|
||||
@@ -291,7 +298,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
redirect: "follow",
|
||||
referrer: "",
|
||||
referrerPolicy: "no-referrer",
|
||||
cache: "no-cache",
|
||||
cache: cacheMode,
|
||||
credentials: "omit", // we send credentials via headers
|
||||
keepalive: keepAlive,
|
||||
priority: opts.priority,
|
||||
|
||||
@@ -49,6 +49,8 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
* @param opts - options object
|
||||
*
|
||||
* @returns Promise which resolves to response object, or rejects with an error (usually a MatrixError).
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public uploadContent(file: FileType, opts: UploadOpts = {}): Promise<UploadResponse> {
|
||||
const includeFilename = opts.includeFilename ?? true;
|
||||
|
||||
+19
-2
@@ -18,7 +18,14 @@ import { parse as parseContentType, type ParsedMediaType } from "content-type";
|
||||
|
||||
import { logger } from "../logger.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
import { ConnectionError, HTTPError, MatrixError, safeGetRetryAfterMs } from "./errors.ts";
|
||||
import {
|
||||
ConnectionError,
|
||||
HTTPError,
|
||||
MatrixError,
|
||||
MatrixSafetyError,
|
||||
MatrixSafetyErrorCode,
|
||||
safeGetRetryAfterMs,
|
||||
} from "./errors.ts";
|
||||
|
||||
// Ponyfill for https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout
|
||||
export function timeoutSignal(ms: number): AbortSignal {
|
||||
@@ -92,8 +99,18 @@ export function parseErrorResponse(response: XMLHttpRequest | Response, body?: s
|
||||
return <Error>e;
|
||||
}
|
||||
if (contentType?.type === "application/json" && body) {
|
||||
const errorBody = JSON.parse(body);
|
||||
if (errorBody.errcode && MatrixSafetyErrorCode.matches(errorBody.errcode)) {
|
||||
return new MatrixSafetyError(
|
||||
errorBody,
|
||||
response.status,
|
||||
isXhr(response) ? response.responseURL : response.url,
|
||||
undefined,
|
||||
httpHeaders,
|
||||
);
|
||||
}
|
||||
return new MatrixError(
|
||||
JSON.parse(body),
|
||||
errorBody,
|
||||
response.status,
|
||||
isXhr(response) ? response.responseURL : response.url,
|
||||
undefined,
|
||||
|
||||
+149
-22
@@ -19,9 +19,11 @@ import { deepCompare } from "../utils.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 { MatrixEvent, type IContent } from "../models/event.ts";
|
||||
import { type RelationType } from "../@types/event.ts";
|
||||
import { logger } from "../logger.ts";
|
||||
import { sha256 } from "../digest.ts";
|
||||
import { encodeUnpaddedBase64Url } from "../base64.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
|
||||
/**
|
||||
* The default duration in milliseconds that a membership is considered valid for.
|
||||
@@ -31,7 +33,16 @@ import { logger } from "../logger.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 };
|
||||
type Member = {
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
/**
|
||||
* The id used on the media backend.
|
||||
* (With livekit this is the participant identity on the LK SFU)
|
||||
* This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`.
|
||||
*/
|
||||
id: string;
|
||||
};
|
||||
|
||||
export interface RtcMembershipData {
|
||||
"slot_id": string;
|
||||
@@ -199,6 +210,15 @@ export type SessionMembershipData = {
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* The id used on the media backend.
|
||||
* (With livekit this is the participant identity on the LK SFU)
|
||||
* This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`.
|
||||
*
|
||||
* It is compleatly valid to not set this field. Other clients will treat `undefined` as `${this.matrixEventData.sender}:${data.device_id}`
|
||||
*/
|
||||
"membershipID"?: string;
|
||||
};
|
||||
|
||||
const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => {
|
||||
@@ -243,38 +263,117 @@ export class CallMembership {
|
||||
return deepCompare(a?.membershipData, b?.membershipData);
|
||||
}
|
||||
|
||||
private membershipData: MembershipData;
|
||||
|
||||
private logger?: Logger;
|
||||
/** 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 };
|
||||
private readonly matrixEventData: { eventId: string; sender: string; ts: number };
|
||||
|
||||
/** Anonymized identity to use with the RTC backend. */
|
||||
public readonly rtcBackendIdentity: string;
|
||||
|
||||
/**
|
||||
* The type checked membership data {data: (content of the matrix event), kind: (type hint)}
|
||||
* @private
|
||||
*/
|
||||
private readonly membershipData: MembershipData;
|
||||
|
||||
public constructor(
|
||||
/** The Matrix event that this membership is based on */
|
||||
private readonly matrixEvent: MatrixEvent,
|
||||
data: IContent,
|
||||
/** The required parts of the Matrix event that this membership is based on */
|
||||
matrixEvent: Pick<MatrixEvent, "getId" | "getSender" | "getTs">,
|
||||
/**
|
||||
* The type checked membership data {data: (content of the matrix event), kind: (type hint)}
|
||||
* It can be a IContent just for backwards compatibility.
|
||||
* */
|
||||
membershipData: MembershipData | IContent,
|
||||
/**
|
||||
*
|
||||
* Anonymized identity to use with the RTC backend.
|
||||
*
|
||||
* The rtcBackendIdentity is optional only for backwards compatibility. If omitted, the constructor will
|
||||
* fall back to the legacy `${sender}:${deviceId}` value.
|
||||
* The rtcBackendIdentity is a hashed version of all the identity parts:
|
||||
* `sha256(${this.userId}|${this.deviceId}|${this.memberId})`
|
||||
*
|
||||
* It is used to anonymize the identity of the user in the RTC backend.
|
||||
*/
|
||||
rtcBackendIdentity?: string,
|
||||
/**
|
||||
* The constructor will automatically create a properly tagged child logger instance.
|
||||
*/
|
||||
logger?: Logger,
|
||||
) {
|
||||
const eventId = matrixEvent.getId();
|
||||
const sender = matrixEvent.getSender();
|
||||
if (membershipData.kind === "rtc" || membershipData.kind === "session") {
|
||||
this.membershipData = membershipData as MembershipData;
|
||||
if (rtcBackendIdentity == undefined) {
|
||||
throw new Error("rtcBackendIdentity must be defined when passing MembershipData");
|
||||
}
|
||||
this.rtcBackendIdentity = rtcBackendIdentity!;
|
||||
} else {
|
||||
// Backwards compatibility path for legacy code that passes raw content
|
||||
this.membershipData = CallMembership.membershipDataFromMatrixEvent(
|
||||
new MatrixEvent({
|
||||
event_id: matrixEvent.getId(),
|
||||
sender: matrixEvent.getSender(),
|
||||
content: membershipData,
|
||||
}),
|
||||
);
|
||||
this.rtcBackendIdentity = `${matrixEvent.getSender()}:${this.deviceId}`;
|
||||
}
|
||||
const [eventId, sender, ts] = [matrixEvent.getId(), matrixEvent.getSender(), matrixEvent.getTs()];
|
||||
if (eventId === undefined) throw new Error("parentEvent is missing eventId field");
|
||||
if (sender === undefined) throw new Error("parentEvent is missing sender field");
|
||||
|
||||
this.matrixEventData = { eventId, sender, ts };
|
||||
|
||||
this.logger = logger?.getChild(`[CallMembership ${sender}:${this.deviceId}]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* sha256(`${this.userId}|${this.deviceId}|${this.memberId}`) for sticky events (kind = rtc)
|
||||
* `${this.userId}:${this.deviceId}` for state events (kind = session)
|
||||
*/
|
||||
public static async computeRtcBackendIdentity(
|
||||
matrixEvent: Pick<MatrixEvent, "getSender">,
|
||||
membershipData: MembershipData,
|
||||
): Promise<string> {
|
||||
const { kind, data } = membershipData;
|
||||
switch (kind) {
|
||||
case "rtc": {
|
||||
return CallMembership.computeRtcIdentityRaw(data.member.user_id, data.member.device_id, data.member.id);
|
||||
}
|
||||
case "session":
|
||||
return `${matrixEvent.getSender()}:${data.device_id}`;
|
||||
}
|
||||
}
|
||||
|
||||
public static async computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise<string> {
|
||||
const hashInput = `${userId}|${deviceId}|${memberId}`;
|
||||
const hashBuffer = await sha256(hashInput);
|
||||
const hashedString = encodeUnpaddedBase64Url(hashBuffer);
|
||||
return hashedString;
|
||||
}
|
||||
|
||||
public static membershipDataFromMatrixEvent(matrixEvent: MatrixEvent): MembershipData {
|
||||
const [eventId, sender, content] = [matrixEvent.getId(), matrixEvent.getSender(), matrixEvent.getContent()];
|
||||
|
||||
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[] = [];
|
||||
const rtcErrors: string[] = [];
|
||||
if (checkSessionsMembershipData(data, sessionErrors)) {
|
||||
this.membershipData = { kind: "session", data };
|
||||
} else if (checkRtcMembershipData(data, rtcErrors, sender)) {
|
||||
this.membershipData = { kind: "rtc", data };
|
||||
if (checkSessionsMembershipData(content, sessionErrors)) {
|
||||
return { kind: "session", data: content };
|
||||
} else if (checkRtcMembershipData(content, rtcErrors, sender)) {
|
||||
return { kind: "rtc", data: content };
|
||||
} else {
|
||||
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('"', "'");
|
||||
const json = "\nevent:\n" + JSON.stringify(content).replaceAll('"', "'");
|
||||
throw Error(`unknown CallMembership data.\n` + details + json);
|
||||
}
|
||||
this.matrixEventData = { eventId, sender };
|
||||
}
|
||||
|
||||
/** @deprecated use userId instead */
|
||||
@@ -330,7 +429,7 @@ export class CallMembership {
|
||||
if (typeof intent === "string") {
|
||||
return intent;
|
||||
}
|
||||
logger.warn("RTC membership has invalid m.call.intent");
|
||||
this.logger?.warn("RTC membership has invalid m.call.intent");
|
||||
return undefined;
|
||||
}
|
||||
case "session":
|
||||
@@ -378,8 +477,28 @@ export class CallMembership {
|
||||
return data.scope;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated renamed to `memberId`
|
||||
*/
|
||||
public get membershipID(): string {
|
||||
return this.memberId;
|
||||
}
|
||||
|
||||
/**
|
||||
* This computes the membership ID for the membership.
|
||||
* For the sticky event based rtcSessionData this is trivial it is `member.id`.
|
||||
* This is not supposed to be used to identity on an rtc backend. This is just a nouance for
|
||||
* a generated (sha256) anonymised identity. Only send `rtcBackendIdentity` to any rtc backend service.
|
||||
*
|
||||
* For the legacy sessionMemberEvents it is a bit more complex. Here we sometimes do not have this data
|
||||
* in the event content and we expected the SFU and the client to use `${this.matrixEventData.sender}:${data.device_id}`.
|
||||
*
|
||||
* So if there is no membershipID we use the hard coded jwt id default (`${this.matrixEventData.sender}:${data.device_id}`)
|
||||
* value (used until version 0.16.0)
|
||||
*
|
||||
* It is also possible for a session event to set a custom membershipID. in that case this will be used.
|
||||
*/
|
||||
public get memberId(): string {
|
||||
// the createdTs behaves equivalent to the membershipID.
|
||||
// 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.
|
||||
@@ -389,7 +508,12 @@ export class CallMembership {
|
||||
return data.member.id;
|
||||
case "session":
|
||||
default:
|
||||
return (this.createdTs() ?? "").toString();
|
||||
return (
|
||||
// best case we have a client already publishing the right custom membershipId
|
||||
data.membershipID ??
|
||||
// alternativly we use the hard coded jwt id defuatl value (used until version 0.16.0)
|
||||
`${this.matrixEventData.sender}:${data.device_id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,10 +522,10 @@ export class CallMembership {
|
||||
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();
|
||||
return this.matrixEventData.ts;
|
||||
case "session":
|
||||
default:
|
||||
return data.created_ts ?? this.matrixEvent.getTs();
|
||||
return data.created_ts ?? this.matrixEventData.ts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,4 +634,7 @@ export class CallMembership {
|
||||
return data.foci_preferred;
|
||||
}
|
||||
}
|
||||
public get kind(): MembershipData["kind"] {
|
||||
return this.membershipData.kind;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,15 @@ import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts";
|
||||
import { safeGetRetryAfterMs } from "../http-api/errors.ts";
|
||||
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 { isMyMembership, type EncryptionKeyMapKey, type Statistics } from "./types.ts";
|
||||
|
||||
/**
|
||||
* The string used for the keys in the the encryption key map.
|
||||
* `@bob:examle.org:DEVICEID(UUIDRANDOM_MEMBERID_RANDOMUUID)`
|
||||
*/
|
||||
export function getEncryptionKeyMapKey(membership: CallMembershipIdentityParts): EncryptionKeyMapKey {
|
||||
return `${membership.userId}:${membership.deviceId}(${membership.memberId})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* This interface is for testing and for making it possible to interchange the encryption manager.
|
||||
@@ -38,9 +45,19 @@ export interface IEncryptionManager {
|
||||
*
|
||||
* @returns A map of participant IDs to their encryption keys.
|
||||
*/
|
||||
getEncryptionKeys(): ReadonlyMap<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>>;
|
||||
getEncryptionKeys(): ReadonlyMap<
|
||||
EncryptionKeyMapKey,
|
||||
ReadonlyArray<{
|
||||
key: Uint8Array<ArrayBuffer>;
|
||||
keyIndex: number;
|
||||
membership: CallMembershipIdentityParts;
|
||||
rtcBackendIdentity: string;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export type CallMembershipIdentityParts = Pick<CallMembership, "userId" | "deviceId" | "memberId">;
|
||||
|
||||
/**
|
||||
* This class implements the IEncryptionManager interface,
|
||||
* and takes care of managing the encryption keys of all rtc members:
|
||||
@@ -67,7 +84,10 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
return this.joinConfig?.useKeyDelay ?? 5_000;
|
||||
}
|
||||
|
||||
private encryptionKeys = new Map<string, Array<{ key: Uint8Array; timestamp: number }>>();
|
||||
private encryptionKeys = new Map<
|
||||
string,
|
||||
Array<{ key: Uint8Array<ArrayBuffer>; timestamp: number; membership: CallMembershipIdentityParts }>
|
||||
>();
|
||||
private lastEncryptionKeyUpdateRequest?: number;
|
||||
|
||||
// We use this to store the last membership fingerprints we saw, so we can proactively re-send encryption keys
|
||||
@@ -79,29 +99,52 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
private logger: Logger;
|
||||
|
||||
public constructor(
|
||||
private userId: string,
|
||||
private deviceId: string,
|
||||
private membership: CallMembershipIdentityParts,
|
||||
private getMemberships: () => CallMembership[],
|
||||
private transport: IKeyTransport,
|
||||
private statistics: Statistics,
|
||||
private onEncryptionKeysChanged: (
|
||||
keyBin: Uint8Array,
|
||||
keyBin: Uint8Array<ArrayBuffer>,
|
||||
encryptionKeyIndex: number,
|
||||
participantId: string,
|
||||
membership: CallMembershipIdentityParts,
|
||||
rtcBackendIdentity: string,
|
||||
) => void,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[EncryptionManager]`);
|
||||
}
|
||||
|
||||
public getEncryptionKeys(): ReadonlyMap<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>> {
|
||||
const keysMap = new Map<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>>();
|
||||
for (const [userId, userKeys] of this.encryptionKeys) {
|
||||
const keys = userKeys.map((entry, index) => ({
|
||||
private rtcBackendIdentityFromMembershipParts(membership: CallMembershipIdentityParts): string {
|
||||
// Implement logic to construct rtcBackendIdentity from membership parts
|
||||
return `${membership.userId}:${membership.deviceId}`;
|
||||
}
|
||||
|
||||
public getEncryptionKeys(): ReadonlyMap<
|
||||
EncryptionKeyMapKey,
|
||||
ReadonlyArray<{
|
||||
key: Uint8Array<ArrayBuffer>;
|
||||
keyIndex: number;
|
||||
membership: CallMembershipIdentityParts;
|
||||
rtcBackendIdentity: string;
|
||||
}>
|
||||
> {
|
||||
const keysMap = new Map<
|
||||
EncryptionKeyMapKey,
|
||||
ReadonlyArray<{
|
||||
key: Uint8Array<ArrayBuffer>;
|
||||
keyIndex: number;
|
||||
membership: CallMembershipIdentityParts;
|
||||
rtcBackendIdentity: string;
|
||||
}>
|
||||
>();
|
||||
for (const [userId, userKeyEntry] of this.encryptionKeys) {
|
||||
const keys = userKeyEntry.map((entry, index) => ({
|
||||
key: entry.key,
|
||||
membership: entry.membership,
|
||||
keyIndex: index,
|
||||
rtcBackendIdentity: this.rtcBackendIdentityFromMembershipParts(entry.membership),
|
||||
}));
|
||||
keysMap.set(userId as ParticipantId, keys);
|
||||
keysMap.set(userId as EncryptionKeyMapKey, keys);
|
||||
}
|
||||
return keysMap;
|
||||
}
|
||||
@@ -126,7 +169,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
// clear our encryption keys as we're done with them now (we'll
|
||||
// make new keys if we rejoin). We leave keys for other participants
|
||||
// as they may still be using the same ones.
|
||||
this.encryptionKeys.set(getParticipantId(this.userId, this.deviceId), []);
|
||||
this.encryptionKeys.set(getEncryptionKeyMapKey(this.membership), []);
|
||||
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
this.transport.stop();
|
||||
|
||||
@@ -147,13 +190,13 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
if (this.manageMediaKeys && this.joined) {
|
||||
const oldMembershipIds = new Set(
|
||||
oldMemberships
|
||||
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
|
||||
.map(getParticipantIdFromMembership),
|
||||
.filter((m) => !isMyMembership(m, this.membership.userId, this.membership.deviceId))
|
||||
.map(getEncryptionKeyMapKey),
|
||||
);
|
||||
const newMembershipIds = new Set(
|
||||
this.getMemberships()
|
||||
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
|
||||
.map(getParticipantIdFromMembership),
|
||||
.filter((m) => !isMyMembership(m, this.membership.userId, this.membership.deviceId))
|
||||
.map(getEncryptionKeyMapKey),
|
||||
);
|
||||
|
||||
// We can use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/symmetricDifference
|
||||
@@ -202,14 +245,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
const encryptionKey = secureRandomBase64Url(16);
|
||||
const encryptionKeyIndex = this.getNewEncryptionKeyIndex();
|
||||
this.logger.info("Generated new key at index " + encryptionKeyIndex);
|
||||
this.setEncryptionKey(
|
||||
this.userId,
|
||||
this.deviceId,
|
||||
encryptionKeyIndex,
|
||||
encryptionKey,
|
||||
Date.now(),
|
||||
delayBeforeUse,
|
||||
);
|
||||
this.setEncryptionKey(this.membership, encryptionKeyIndex, encryptionKey, Date.now(), delayBeforeUse);
|
||||
return encryptionKeyIndex;
|
||||
}
|
||||
|
||||
@@ -240,12 +276,11 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
/**
|
||||
* Get the known encryption keys for a given participant device.
|
||||
*
|
||||
* @param userId the user ID of the participant
|
||||
* @param deviceId the device ID of the participant
|
||||
* @param membership - The membership identity parts of the participant
|
||||
* @returns The encryption keys for the given participant, or undefined if they are not known.
|
||||
*/
|
||||
private getKeysForParticipant(userId: string, deviceId: string): Array<Uint8Array> | undefined {
|
||||
return this.encryptionKeys.get(getParticipantId(userId, deviceId))?.map((entry) => entry.key);
|
||||
private getKeysForParticipant(membership: CallMembershipIdentityParts): Array<Uint8Array<ArrayBuffer>> | undefined {
|
||||
return this.encryptionKeys.get(getEncryptionKeyMapKey(membership))?.map((entry) => entry.key);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,7 +295,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
|
||||
if (!this.joined) return;
|
||||
|
||||
const myKeys = this.getKeysForParticipant(this.userId, this.deviceId);
|
||||
const myKeys = this.getKeysForParticipant(this.membership);
|
||||
|
||||
if (!myKeys) {
|
||||
this.logger.warn("Tried to send encryption keys event but no keys found!");
|
||||
@@ -294,7 +329,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
});
|
||||
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, targets);
|
||||
this.logger.debug(
|
||||
`sendEncryptionKeysEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
|
||||
`sendEncryptionKeysEvent participantId=${this.membership.userId}:${this.membership.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.keysEventUpdateTimeout === undefined) {
|
||||
@@ -307,16 +342,18 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
public onNewKeyReceived: KeyTransportEventListener = (membership, keyBase64Encoded, index, timestamp) => {
|
||||
this.logger.debug(
|
||||
`Received key over key transport ${membership.userId}:${membership.deviceId} at index ${index}`,
|
||||
);
|
||||
this.setEncryptionKey(membership, index, keyBase64Encoded, timestamp);
|
||||
};
|
||||
|
||||
private storeLastMembershipFingerprints(): void {
|
||||
this.lastMembershipFingerprints = new Set(
|
||||
this.getMemberships()
|
||||
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
|
||||
.map((m) => `${getParticipantIdFromMembership(m)}:${m.createdTs()}`),
|
||||
.filter((m) => !isMyMembership(m, this.membership.userId, this.membership.deviceId))
|
||||
.map((m) => `${getEncryptionKeyMapKey(m)}:${m.createdTs()}`),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -344,28 +381,29 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
* be distributed.
|
||||
*/
|
||||
private setEncryptionKey(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
membership: CallMembershipIdentityParts,
|
||||
encryptionKeyIndex: number,
|
||||
encryptionKeyString: string,
|
||||
timestamp: number,
|
||||
delayBeforeUse = false,
|
||||
): void {
|
||||
this.logger.debug(`Setting encryption key for ${userId}:${deviceId} at index ${encryptionKeyIndex}`);
|
||||
this.logger.debug(
|
||||
`Setting encryption key for ${membership.userId}:${membership.deviceId} at index ${encryptionKeyIndex}`,
|
||||
);
|
||||
const keyBin = decodeBase64(encryptionKeyString);
|
||||
|
||||
const participantId = getParticipantId(userId, deviceId);
|
||||
if (!this.encryptionKeys.has(participantId)) {
|
||||
this.encryptionKeys.set(participantId, []);
|
||||
const mapKey = getEncryptionKeyMapKey(membership);
|
||||
if (!this.encryptionKeys.has(mapKey)) {
|
||||
this.encryptionKeys.set(mapKey, []);
|
||||
}
|
||||
const participantKeys = this.encryptionKeys.get(participantId)!;
|
||||
const participantKeys = this.encryptionKeys.get(mapKey)!;
|
||||
|
||||
const existingKeyAtIndex = participantKeys[encryptionKeyIndex];
|
||||
|
||||
if (existingKeyAtIndex) {
|
||||
if (existingKeyAtIndex.timestamp > timestamp) {
|
||||
this.logger.info(
|
||||
`Ignoring new key at index ${encryptionKeyIndex} for ${participantId} as it is older than existing known key`,
|
||||
`Ignoring new key at index ${encryptionKeyIndex} for ${mapKey} as it is older than existing known key`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -376,7 +414,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (userId === this.userId && deviceId === this.deviceId) {
|
||||
if (membership.userId === this.membership.userId && membership.deviceId === this.membership.deviceId) {
|
||||
// It is important to already update the latestGeneratedKeyIndex here
|
||||
// NOT IN THE `delayBeforeUse` `setTimeout`.
|
||||
// Even though this is where we call onEncryptionKeysChanged and set the key in EC (and livekit).
|
||||
@@ -388,18 +426,29 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
participantKeys[encryptionKeyIndex] = {
|
||||
key: keyBin,
|
||||
timestamp,
|
||||
membership: membership,
|
||||
};
|
||||
|
||||
if (delayBeforeUse) {
|
||||
const useKeyTimeout = setTimeout(() => {
|
||||
this.setNewKeyTimeouts.delete(useKeyTimeout);
|
||||
this.logger.info(`Delayed-emitting key changed event for ${participantId} index ${encryptionKeyIndex}`);
|
||||
this.logger.info(`Delayed-emitting key changed event for ${mapKey} index ${encryptionKeyIndex}`);
|
||||
|
||||
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
|
||||
this.onEncryptionKeysChanged(
|
||||
keyBin,
|
||||
encryptionKeyIndex,
|
||||
membership,
|
||||
this.rtcBackendIdentityFromMembershipParts(membership),
|
||||
);
|
||||
}, this.useKeyDelay);
|
||||
this.setNewKeyTimeouts.add(useKeyTimeout);
|
||||
} else {
|
||||
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
|
||||
this.onEncryptionKeysChanged(
|
||||
keyBin,
|
||||
encryptionKeyIndex,
|
||||
membership,
|
||||
this.rtcBackendIdentityFromMembershipParts(membership),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,5 +468,3 @@ function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolea
|
||||
if (a === b) return true;
|
||||
return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]);
|
||||
}
|
||||
|
||||
const getParticipantIdFromMembership = (m: CallMembership): string => getParticipantId(m.sender!, m.deviceId);
|
||||
|
||||
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type CallMembershipIdentityParts } from "./EncryptionManager.ts";
|
||||
import { type ParticipantDeviceInfo } from "./types.ts";
|
||||
|
||||
export enum KeyTransportEvents {
|
||||
@@ -27,8 +28,7 @@ export type KeyTransportEventsHandlerMap = {
|
||||
};
|
||||
|
||||
export type KeyTransportEventListener = (
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
membership: CallMembershipIdentityParts,
|
||||
keyBase64Encoded: string,
|
||||
index: number,
|
||||
timestamp: number,
|
||||
|
||||
@@ -26,11 +26,17 @@ export enum MembershipManagerEvent {
|
||||
* This means that the user is probably not joined anymore and the leave event was distributed to other session members.
|
||||
*/
|
||||
ProbablyLeft = "ProbablyLeft",
|
||||
/**
|
||||
* Once the membershipManger has aquired the a delay id (after sending the state event)
|
||||
* It will emit and share the delay id.
|
||||
*/
|
||||
DelayIdChanged = "DelayIdChanged",
|
||||
}
|
||||
|
||||
export type MembershipManagerEventHandlerMap = {
|
||||
[MembershipManagerEvent.StatusChanged]: (prefStatus: Status, newStatus: Status) => void;
|
||||
[MembershipManagerEvent.ProbablyLeft]: (probablyLeft: boolean) => void;
|
||||
[MembershipManagerEvent.DelayIdChanged]: (delayId: string | undefined) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -40,8 +46,10 @@ export type MembershipManagerEventHandlerMap = {
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface IMembershipManager
|
||||
extends TypedEventEmitter<MembershipManagerEvent, MembershipManagerEventHandlerMap> {
|
||||
export interface IMembershipManager extends TypedEventEmitter<
|
||||
MembershipManagerEvent,
|
||||
MembershipManagerEventHandlerMap
|
||||
> {
|
||||
/**
|
||||
* If we are trying to join, or have successfully joined the session.
|
||||
* It does not reflect if the room state is already configured to represent us being joined.
|
||||
@@ -76,6 +84,12 @@ export interface IMembershipManager
|
||||
*/
|
||||
get probablyLeft(): boolean;
|
||||
|
||||
/**
|
||||
* If the membership manager has reason to believe that the hs sent a leave event
|
||||
* and as a consequence the current user is perceived as left for other session participants.
|
||||
*/
|
||||
get delayId(): string | undefined;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
+155
-119
@@ -25,7 +25,7 @@ import { type ISendEventResponse } from "../@types/requests.ts";
|
||||
import { CallMembership } from "./CallMembership.ts";
|
||||
import { RoomStateEvent } from "../models/room-state.ts";
|
||||
import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { type CallMembershipIdentityParts, EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { deepCompare, logDurationSync } from "../utils.ts";
|
||||
import type {
|
||||
Statistics,
|
||||
@@ -73,9 +73,10 @@ export type MatrixRTCSessionEventHandlerMap = {
|
||||
) => void;
|
||||
[MatrixRTCSessionEvent.JoinStateChanged]: (isJoined: boolean) => void;
|
||||
[MatrixRTCSessionEvent.EncryptionKeyChanged]: (
|
||||
key: Uint8Array,
|
||||
key: Uint8Array<ArrayBuffer>,
|
||||
encryptionKeyIndex: number,
|
||||
participantId: string,
|
||||
membership: CallMembershipIdentityParts,
|
||||
rtcBackendIdentity: string,
|
||||
) => void;
|
||||
[MatrixRTCSessionEvent.MembershipManagerError]: (error: unknown) => void;
|
||||
[MatrixRTCSessionEvent.DidSendCallNotification]: (
|
||||
@@ -178,7 +179,8 @@ export interface MembershipConfig {
|
||||
* In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs
|
||||
* helps by keeping more delayed event reset candidates in flight,
|
||||
* improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration,
|
||||
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
|
||||
* but only applies to calls to the `_unstable_restartScheduledDelayedEvent` endpoint
|
||||
* or the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
|
||||
*/
|
||||
delayedLeaveEventRestartLocalTimeoutMs?: number;
|
||||
|
||||
@@ -234,7 +236,7 @@ export interface EncryptionConfig {
|
||||
}
|
||||
export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig;
|
||||
|
||||
interface SessionMembershipsForRoomOpts {
|
||||
interface SessionMembershipsForSlotOpts {
|
||||
/**
|
||||
* Listen for incoming sticky member events. If disabled, this session will
|
||||
* ignore any incoming sticky events.
|
||||
@@ -268,6 +270,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
*/
|
||||
private expiryTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
public memberships: CallMembership[] = [];
|
||||
|
||||
/**
|
||||
* The statistics for this session.
|
||||
*/
|
||||
@@ -284,10 +288,12 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
public get membershipStatus(): Status | undefined {
|
||||
return this.membershipManager?.status;
|
||||
}
|
||||
|
||||
public get probablyLeft(): boolean | undefined {
|
||||
return this.membershipManager?.probablyLeft;
|
||||
}
|
||||
public get delayId(): string | undefined {
|
||||
return this.membershipManager?.delayId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The callId (sessionId) of the call.
|
||||
@@ -310,28 +316,17 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
||||
* oldest first.
|
||||
*
|
||||
* @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead.
|
||||
* @returns an empty array.
|
||||
*/
|
||||
public static callMembershipsForRoom(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
|
||||
): CallMembership[] {
|
||||
return MatrixRTCSession.sessionMembershipsForSlot(room, {
|
||||
id: "",
|
||||
application: "m.call",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead.
|
||||
*/
|
||||
public static sessionMembershipsForRoom(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
|
||||
sessionDescription: SlotDescription,
|
||||
): CallMembership[] {
|
||||
return this.sessionMembershipsForSlot(room, sessionDescription);
|
||||
rootLogger.error(
|
||||
`[MatrixRTCSession ${room.roomId}] callMembershipsForRoom is deprecated. Use sessionMembershipsForSlot instead.`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,16 +335,16 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
*
|
||||
* By default, this will return *both* sticky and member state events.
|
||||
*/
|
||||
public static sessionMembershipsForSlot(
|
||||
public static async 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, listenForMemberStateEvents }: SessionMembershipsForSlotOpts = {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
},
|
||||
): CallMembership[] {
|
||||
): Promise<CallMembership[]> {
|
||||
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
|
||||
let callMemberEvents = [] as MatrixEvent[];
|
||||
if (listenForStickyEvents) {
|
||||
@@ -361,8 +356,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
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);
|
||||
logger.warn("Couldn't get state for room " + room.roomId + "using empty membership array");
|
||||
return [];
|
||||
}
|
||||
const callMemberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
|
||||
callMemberEvents = callMemberEvents.concat(
|
||||
@@ -385,45 +380,50 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
// Dont even bother about empty events (saves us from costly type/"key in" checks in bigger rooms)
|
||||
if (eventKeysCount === 0) continue;
|
||||
|
||||
const membershipContents: any[] = [];
|
||||
let membershipContent = undefined;
|
||||
|
||||
// We first decide if its a MSC4143 event (per device state key)
|
||||
if (eventKeysCount > 1 && "application" in content) {
|
||||
// We have a MSC4143 event membership event
|
||||
membershipContents.push(content);
|
||||
membershipContent = content;
|
||||
} else if (eventKeysCount === 1 && "memberships" in content) {
|
||||
logger.warn(`Legacy event found. Those are ignored, they do not contribute to the MatrixRTC session`);
|
||||
}
|
||||
|
||||
if (membershipContents.length === 0) continue;
|
||||
if (membershipContent === undefined) continue;
|
||||
|
||||
for (const membershipData of membershipContents) {
|
||||
if (!("application" in membershipData)) {
|
||||
// This is a left membership event, ignore it here to not log warnings.
|
||||
if (!("application" in membershipContent)) {
|
||||
// This is a left membership event, ignore it here to not log warnings.
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const membershipData = CallMembership.membershipDataFromMatrixEvent(memberEvent);
|
||||
|
||||
const membership = new CallMembership(
|
||||
memberEvent,
|
||||
membershipData,
|
||||
await CallMembership.computeRtcBackendIdentity(memberEvent, membershipData),
|
||||
rootLogger,
|
||||
);
|
||||
|
||||
if (!deepCompare(membership.slotDescription, slotDescription)) {
|
||||
logger.info(
|
||||
`Ignoring membership of user ${membership.sender} for a different slot: ${JSON.stringify(membership.slotDescription)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const membership = new CallMembership(memberEvent, membershipData);
|
||||
|
||||
if (!deepCompare(membership.slotDescription, slotDescription)) {
|
||||
logger.info(
|
||||
`Ignoring membership of user ${membership.sender} for a different slot: ${JSON.stringify(membership.slotDescription)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (membership.isExpired()) {
|
||||
logger.info(`Ignoring expired device membership ${membership.sender}/${membership.deviceId}`);
|
||||
continue;
|
||||
}
|
||||
if (!room.hasMembershipState(membership.sender ?? "", KnownMembership.Join)) {
|
||||
logger.info(`Ignoring membership of user ${membership.sender} who is not in the room.`);
|
||||
continue;
|
||||
}
|
||||
callMemberships.push(membership);
|
||||
} catch (e) {
|
||||
logger.warn("Couldn't construct call membership: ", e);
|
||||
if (membership.isExpired()) {
|
||||
logger.info(`Ignoring expired device membership ${membership.sender}/${membership.deviceId}`);
|
||||
continue;
|
||||
}
|
||||
if (!room.hasMembershipState(membership.sender ?? "", KnownMembership.Join)) {
|
||||
logger.info(`Ignoring membership of user ${membership.sender} who is not in the room.`);
|
||||
continue;
|
||||
}
|
||||
callMemberships.push(membership);
|
||||
} catch (e) {
|
||||
logger.warn("Couldn't construct call membership: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,43 +431,13 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
if (callMemberships.length > 1) {
|
||||
logger.debug(
|
||||
`Call memberships in room ${room.roomId}, in order: `,
|
||||
callMemberships.map((m) => [m.createdTs(), m.sender]),
|
||||
callMemberships.map((m) => [m.createdTs(), m.userId]),
|
||||
);
|
||||
}
|
||||
|
||||
return callMemberships;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the MatrixRTC session for the room.
|
||||
* This returned session can be used to find out if there are active room call sessions
|
||||
* for the requested room.
|
||||
*
|
||||
* This method is an alias for `MatrixRTCSession.sessionForRoom` with
|
||||
* sessionDescription `{ id: "", application: "m.call" }`.
|
||||
*
|
||||
* @deprecated Use `MatrixRTCSession.sessionForSlot` with sessionDescription `{ id: "", application: "m.call" }` instead.
|
||||
*/
|
||||
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
|
||||
@@ -477,10 +447,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
client: MatrixClient,
|
||||
room: Room,
|
||||
slotDescription: SlotDescription,
|
||||
opts?: SessionMembershipsForRoomOpts,
|
||||
opts?: SessionMembershipsForSlotOpts,
|
||||
): MatrixRTCSession {
|
||||
const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription, opts);
|
||||
return new MatrixRTCSession(client, room, callMemberships, slotDescription);
|
||||
return new MatrixRTCSession(client, room, slotDescription, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -503,7 +472,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
*
|
||||
* @param client A subset of the {@link MatrixClient} that lets the session interact with the Matrix room.
|
||||
* @param roomSubset The room this session is attached to. A subset of a js-sdk Room that the session needs.
|
||||
* @param memberships The list of memberships this session currently has.
|
||||
* @param slotDescription 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 constructor(
|
||||
private readonly client: Pick<
|
||||
@@ -514,6 +485,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "_unstable_cancelScheduledDelayedEvent"
|
||||
| "_unstable_restartScheduledDelayedEvent"
|
||||
| "_unstable_sendScheduledDelayedEvent"
|
||||
| "_unstable_sendStickyEvent"
|
||||
| "_unstable_sendStickyDelayedEvent"
|
||||
| "cancelPendingEvent"
|
||||
@@ -526,21 +500,19 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
Room,
|
||||
"getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off"
|
||||
>,
|
||||
public memberships: CallMembership[],
|
||||
/**
|
||||
* 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 slotDescription: SlotDescription,
|
||||
private readonly calculateMembershipsOpts?: SessionMembershipsForSlotOpts,
|
||||
) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
|
||||
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(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
this.roomSubset.on(RoomStickyEventsEvent.Update, this.onStickyEventUpdate);
|
||||
|
||||
// We can ignore this promise because `recalculateSessionMembers` will emit
|
||||
// `MatrixRTCSessionEvent.MembershipsChanged` once it has completed.
|
||||
this.ensureRecalculateSessionMembers();
|
||||
this.setExpiryTimer();
|
||||
}
|
||||
/*
|
||||
@@ -560,8 +532,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
clearTimeout(this.expiryTimeout);
|
||||
this.expiryTimeout = undefined;
|
||||
}
|
||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
|
||||
this.roomSubset.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
this.roomSubset.off(RoomStickyEventsEvent.Update, this.onStickyEventUpdate);
|
||||
}
|
||||
|
||||
@@ -577,6 +549,8 @@ 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 ownMembershipIdentity the identity of the user and device joining the session.
|
||||
* This will be put into the content.member.
|
||||
* @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
|
||||
@@ -584,7 +558,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* transport selection will be used instead.
|
||||
* @param joinConfig - Additional configuration for the joined session.
|
||||
*/
|
||||
public joinRoomSession(
|
||||
public joinRTCSession(
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
fociPreferred: Transport[],
|
||||
multiSfuFocus?: Transport,
|
||||
joinConfig?: JoinSessionConfig,
|
||||
@@ -600,6 +575,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
this.roomSubset,
|
||||
this.client,
|
||||
this.slotDescription,
|
||||
ownMembershipIdentity.memberId,
|
||||
this.logger,
|
||||
)
|
||||
: new MembershipManager(joinConfig, this.roomSubset, this.client, this.slotDescription, this.logger);
|
||||
@@ -613,39 +589,49 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
if (joinConfig?.useExperimentalToDeviceTransport) {
|
||||
this.logger.info("Using experimental to-device transport for encryption keys");
|
||||
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];
|
||||
const transport = new ToDeviceKeyTransport(uId, dId, room.roomId, client, statistics);
|
||||
const transport = new ToDeviceKeyTransport(ownMembershipIdentity, room.roomId, client, statistics);
|
||||
this.encryptionManager = new RTCEncryptionManager(
|
||||
this.client.getUserId()!,
|
||||
this.client.getDeviceId()!,
|
||||
ownMembershipIdentity,
|
||||
() => this.memberships,
|
||||
transport,
|
||||
this.statistics,
|
||||
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
|
||||
(
|
||||
keyBin: Uint8Array<ArrayBuffer>,
|
||||
encryptionKeyIndex: number,
|
||||
membership: CallMembershipIdentityParts,
|
||||
rtcBackendIdentity: string,
|
||||
) => {
|
||||
this.emit(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
keyBin,
|
||||
encryptionKeyIndex,
|
||||
participantId,
|
||||
membership,
|
||||
rtcBackendIdentity,
|
||||
);
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
} else {
|
||||
// TODO REMOVE ME!
|
||||
transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
|
||||
this.encryptionManager = new EncryptionManager(
|
||||
this.client.getUserId()!,
|
||||
this.client.getDeviceId()!,
|
||||
ownMembershipIdentity,
|
||||
() => this.memberships,
|
||||
transport,
|
||||
this.statistics,
|
||||
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
|
||||
(
|
||||
keyBin: Uint8Array<ArrayBuffer>,
|
||||
encryptionKeyIndex: number,
|
||||
membership: CallMembershipIdentityParts,
|
||||
rtcBackendIdentity: string,
|
||||
) => {
|
||||
this.emit(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
keyBin,
|
||||
encryptionKeyIndex,
|
||||
participantId,
|
||||
membership,
|
||||
rtcBackendIdentity,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -666,6 +652,24 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param fociPreferred
|
||||
* @param multiSfuFocus
|
||||
* @param joinConfig
|
||||
* @deprecated use the joinRTCSession method instead
|
||||
*/
|
||||
public joinRoomSession(
|
||||
fociPreferred: Transport[],
|
||||
multiSfuFocus?: Transport,
|
||||
joinConfig?: JoinSessionConfig,
|
||||
): void {
|
||||
const [userId, deviceId] = [this.client.getUserId()!, this.client.getDeviceId()!];
|
||||
// TODO this wants to become a UUID
|
||||
const memberId = `${userId}:${deviceId}`;
|
||||
this.joinRTCSession({ userId, deviceId, memberId }, fociPreferred, multiSfuFocus, joinConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces this user and device as having left the MatrixRTC session
|
||||
* and stops scheduled updates.
|
||||
@@ -743,9 +747,15 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* the keys.
|
||||
*/
|
||||
public reemitEncryptionKeys(): void {
|
||||
this.encryptionManager?.getEncryptionKeys().forEach((keyRing, participantId) => {
|
||||
this.encryptionManager?.getEncryptionKeys().forEach((keyRing, key) => {
|
||||
keyRing.forEach((keyInfo) => {
|
||||
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyInfo.key, keyInfo.keyIndex, participantId);
|
||||
this.emit(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
keyInfo.key,
|
||||
keyInfo.keyIndex,
|
||||
keyInfo.membership,
|
||||
keyInfo.rtcBackendIdentity,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -770,7 +780,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
}
|
||||
|
||||
if (soonestExpiry != undefined) {
|
||||
this.expiryTimeout = setTimeout(this.onRTCSessionMemberUpdate, soonestExpiry);
|
||||
this.expiryTimeout = setTimeout(this.ensureRecalculateSessionMembers.bind(this), soonestExpiry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,7 +846,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* Call this when the Matrix room members have changed.
|
||||
*/
|
||||
private readonly onRoomMemberUpdate = (): void => {
|
||||
this.recalculateSessionMembers();
|
||||
this.ensureRecalculateSessionMembers();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -852,17 +862,38 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
(e) => e.getType() === EventType.RTCMembership,
|
||||
)
|
||||
) {
|
||||
this.recalculateSessionMembers();
|
||||
this.ensureRecalculateSessionMembers();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Call this when something changed that may impacts the current MatrixRTC members in this session.
|
||||
*/
|
||||
public onRTCSessionMemberUpdate = (): void => {
|
||||
this.recalculateSessionMembers();
|
||||
// We allow this name schema since this function should only be used for testing purposes.
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public _onRTCSessionMemberUpdate = async (): Promise<void> => {
|
||||
await this.recalculateSessionMembers();
|
||||
};
|
||||
|
||||
// helper variables to make sure we do not have parallel running recalculations.
|
||||
|
||||
private recalculateSessionMembersDirty = false;
|
||||
private recalculateSessionMembersPromise: Promise<void> | undefined = undefined;
|
||||
|
||||
private ensureRecalculateSessionMembers(): void {
|
||||
if (this.recalculateSessionMembersPromise === undefined) {
|
||||
this.recalculateSessionMembersPromise = this.recalculateSessionMembers().then(() => {
|
||||
this.recalculateSessionMembersPromise = undefined;
|
||||
if (this.recalculateSessionMembersDirty) {
|
||||
this.ensureRecalculateSessionMembers();
|
||||
this.recalculateSessionMembersDirty = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.recalculateSessionMembersDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this when anything that could impact rtc memberships has changed: Room Members or RTC members.
|
||||
*
|
||||
@@ -870,9 +901,14 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
*
|
||||
* This function should be called when the room members or call memberships might have changed.
|
||||
*/
|
||||
private recalculateSessionMembers = (): void => {
|
||||
private recalculateSessionMembers = async (): Promise<void> => {
|
||||
const oldMemberships = this.memberships;
|
||||
this.memberships = MatrixRTCSession.sessionMembershipsForSlot(this.room, this.slotDescription);
|
||||
|
||||
this.memberships = await MatrixRTCSession.sessionMembershipsForSlot(
|
||||
this.room,
|
||||
this.slotDescription,
|
||||
this.calculateMembershipsOpts,
|
||||
);
|
||||
|
||||
const changed =
|
||||
oldMemberships.length != this.memberships.length ||
|
||||
|
||||
@@ -66,7 +66,7 @@ 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.slotDescription);
|
||||
const session = MatrixRTCSession.sessionForSlot(this.client, room, this.slotDescription);
|
||||
if (session.memberships.length > 0) {
|
||||
this.roomSessions.set(room.roomId, session);
|
||||
}
|
||||
@@ -104,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.slotDescription),
|
||||
MatrixRTCSession.sessionForSlot(this.client, room, this.slotDescription),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
}
|
||||
|
||||
private onRoom = (room: Room): void => {
|
||||
this.refreshRoom(room);
|
||||
void this.refreshRoom(room);
|
||||
};
|
||||
|
||||
private readonly onEvent = (event: MatrixEvent): void => {
|
||||
@@ -123,7 +123,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
const room = this.client.getRoom(event.getRoomId());
|
||||
if (!room) return;
|
||||
|
||||
this.refreshRoom(room);
|
||||
void this.refreshRoom(room);
|
||||
};
|
||||
|
||||
private readonly onRoomState = (event: MatrixEvent): void => {
|
||||
@@ -136,10 +136,10 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshRoom(room);
|
||||
void this.refreshRoom(room);
|
||||
};
|
||||
|
||||
private refreshRoom(room: Room): void {
|
||||
private async refreshRoom(room: Room): Promise<void> {
|
||||
const isNewSession = !this.roomSessions.has(room.roomId);
|
||||
const session = this.getRoomSession(room);
|
||||
|
||||
@@ -149,7 +149,11 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
// wasActiveAndKnown = session.memberships.length > 0 and
|
||||
// nowActive = session.memberships.length
|
||||
// Alternatively we would need to setup some event emission when the RTC session ended.
|
||||
session.onRTCSessionMemberUpdate();
|
||||
// TODO we want to add the emission en session end. This makes the responsibility of the session manager more clear.
|
||||
|
||||
await session._onRTCSessionMemberUpdate().catch((error) => {
|
||||
this.logger.error(`Error updating RTC session members for ${room.roomId}: ${error}`);
|
||||
});
|
||||
|
||||
const nowActive = session.memberships.length > 0;
|
||||
|
||||
|
||||
@@ -16,11 +16,7 @@ limitations under the License.
|
||||
import { AbortError } from "p-retry";
|
||||
|
||||
import { EventType, RelationType } from "../@types/event.ts";
|
||||
import {
|
||||
type ISendEventResponse,
|
||||
type SendDelayedEventResponse,
|
||||
UpdateDelayedEventAction,
|
||||
} from "../@types/requests.ts";
|
||||
import { type ISendEventResponse, type SendDelayedEventResponse } 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";
|
||||
@@ -169,7 +165,14 @@ function createReplaceActionUpdate(type: MembershipActionType, offset?: number):
|
||||
|
||||
type MembershipManagerClient = Pick<
|
||||
MatrixClient,
|
||||
"getUserId" | "getDeviceId" | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent"
|
||||
| "getUserId"
|
||||
| "getDeviceId"
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "_unstable_cancelScheduledDelayedEvent"
|
||||
| "_unstable_restartScheduledDelayedEvent"
|
||||
| "_unstable_sendScheduledDelayedEvent"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -273,13 +276,8 @@ export class MembershipManager
|
||||
if (!this.isActivated()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const userId = this.client.getUserId();
|
||||
const deviceId = this.client.getDeviceId();
|
||||
if (!userId || !deviceId) {
|
||||
this.logger.error("MembershipManager.onRTCSessionMemberUpdate called without user or device id");
|
||||
return Promise.resolve();
|
||||
}
|
||||
this._ownMembership = memberships.find((m) => isMyMembership(m, userId, deviceId));
|
||||
|
||||
this._ownMembership = memberships.find((m) => isMyMembership(m, this.userId, this.deviceId));
|
||||
|
||||
if (!this._ownMembership) {
|
||||
// If one of these actions are scheduled or are getting inserted in the next iteration, we should already
|
||||
@@ -335,9 +333,10 @@ 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.userId = userId;
|
||||
// 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.stateKey = this.makeMembershipStateKey(userId, deviceId);
|
||||
this.state = MembershipManager.defaultState;
|
||||
this.callIntent = joinConfig?.callIntent;
|
||||
this.scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
|
||||
@@ -383,7 +382,8 @@ export class MembershipManager
|
||||
}
|
||||
// Membership Event static parameters:
|
||||
protected deviceId: string;
|
||||
protected memberId: string;
|
||||
protected userId: string;
|
||||
protected stateKey: string;
|
||||
protected rtcTransport?: Transport;
|
||||
/** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */
|
||||
private fociPreferred?: Transport[];
|
||||
@@ -484,7 +484,7 @@ export class MembershipManager
|
||||
{ delay: this.delayedLeaveEventDelayMs },
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{},
|
||||
this.memberId,
|
||||
this.stateKey,
|
||||
);
|
||||
|
||||
// HANDLERS (used in the membershipLoopHandler)
|
||||
@@ -499,7 +499,7 @@ export class MembershipManager
|
||||
this.setAndEmitProbablyLeft(false);
|
||||
// On success we reset retries and set delayId.
|
||||
this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
|
||||
this.state.delayId = response.delay_id;
|
||||
this.setAndEmitDelayId(response.delay_id);
|
||||
if (this.state.hasMemberStateEvent) {
|
||||
// This action was scheduled because the previous delayed event was cancelled
|
||||
// due to lack of https://github.com/element-hq/synapse/pull/17810
|
||||
@@ -544,21 +544,21 @@ export class MembershipManager
|
||||
private async cancelKnownDelayIdBeforeSendDelayedEvent(delayId: string): Promise<ActionUpdate> {
|
||||
// Remove all running updates and restarts
|
||||
return await this.client
|
||||
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel)
|
||||
._unstable_cancelScheduledDelayedEvent(delayId)
|
||||
.then(() => {
|
||||
this.state.delayId = undefined;
|
||||
this.setAndEmitDelayId(undefined);
|
||||
this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
|
||||
return createReplaceActionUpdate(MembershipActionType.SendDelayedEvent);
|
||||
})
|
||||
.catch((e) => {
|
||||
const repeatActionType = MembershipActionType.SendDelayedEvent;
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "cancelScheduledDelayedEvent");
|
||||
if (update) return update;
|
||||
|
||||
if (this.isNotFoundError(e)) {
|
||||
// If we get a M_NOT_FOUND we know that the delayed event got already removed.
|
||||
// This means we are good and can set it to undefined and run this again.
|
||||
this.state.delayId = undefined;
|
||||
this.setAndEmitDelayId(undefined);
|
||||
return createReplaceActionUpdate(repeatActionType);
|
||||
}
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) {
|
||||
@@ -584,6 +584,13 @@ export class MembershipManager
|
||||
this.emit(MembershipManagerEvent.ProbablyLeft, this.state.probablyLeft);
|
||||
}
|
||||
|
||||
private setAndEmitDelayId(delayId?: string): void {
|
||||
if (this.state.delayId === delayId) return;
|
||||
|
||||
this.state.delayId = delayId;
|
||||
this.emit(MembershipManagerEvent.DelayIdChanged, this.state.delayId);
|
||||
}
|
||||
|
||||
private async restartDelayedEvent(delayId: string): Promise<ActionUpdate> {
|
||||
// Compute the duration until we expect the server to send the delayed leave event.
|
||||
const durationUntilServerDelayedLeave = this.state.expectedServerDelayLeaveTs
|
||||
@@ -606,10 +613,7 @@ export class MembershipManager
|
||||
|
||||
// The obvious choice here would be to use the `IRequestOpts` to set the timeout. Since this call might be forwarded
|
||||
// to the widget driver this information would get lost. That is why we mimic the AbortError using the race.
|
||||
return await Promise.race([
|
||||
this.client._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart),
|
||||
abortPromise,
|
||||
])
|
||||
return await Promise.race([this.client._unstable_restartScheduledDelayedEvent(delayId), abortPromise])
|
||||
.then(() => {
|
||||
// Whenever we successfully restart the delayed event we update the `state.expectedServerDelayLeaveTs`
|
||||
// which stores the predicted timestamp at which the server will send the delayed leave event if there wont be any further
|
||||
@@ -630,14 +634,14 @@ export class MembershipManager
|
||||
}
|
||||
const repeatActionType = MembershipActionType.RestartDelayedEvent;
|
||||
if (this.isNotFoundError(e)) {
|
||||
this.state.delayId = undefined;
|
||||
this.setAndEmitDelayId(undefined);
|
||||
return createInsertActionUpdate(MembershipActionType.SendDelayedEvent);
|
||||
}
|
||||
// If the HS does not support delayed events we wont reschedule.
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) return {};
|
||||
|
||||
// TODO this also needs a test: get rate limit while checking id delayed event is scheduled
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "restartScheduledDelayedEvent");
|
||||
if (update) return update;
|
||||
|
||||
// In other error cases we have no idea what is happening
|
||||
@@ -647,7 +651,7 @@ export class MembershipManager
|
||||
|
||||
private async sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(delayId: string): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Send)
|
||||
._unstable_sendScheduledDelayedEvent(delayId)
|
||||
.then(() => {
|
||||
this.state.hasMemberStateEvent = false;
|
||||
this.resetRateLimitCounter(MembershipActionType.SendScheduledDelayedLeaveEvent);
|
||||
@@ -658,10 +662,10 @@ export class MembershipManager
|
||||
const repeatActionType = MembershipActionType.SendLeaveEvent;
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) return {};
|
||||
if (this.isNotFoundError(e)) {
|
||||
this.state.delayId = undefined;
|
||||
this.setAndEmitDelayId(undefined);
|
||||
return createInsertActionUpdate(repeatActionType);
|
||||
}
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "sendScheduledDelayedEvent");
|
||||
if (update) return update;
|
||||
|
||||
// On any other error we fall back to SendLeaveEvent (this includes hard errors from rate limiting)
|
||||
@@ -680,7 +684,7 @@ export class MembershipManager
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
myMembership as EmptyObject | SessionMembershipData,
|
||||
this.memberId,
|
||||
this.stateKey,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -763,6 +767,10 @@ export class MembershipManager
|
||||
}
|
||||
|
||||
// HELPERS
|
||||
/**
|
||||
* this creates `${localUserId}_${localDeviceId}_${this.slotDescription.application}${this.slotDescription.id}`
|
||||
* which is not compatible with membershipID of session type member events. They have to be `${localUserId}:${localDeviceId}`
|
||||
*/
|
||||
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
|
||||
const stateKey = `${localUserId}_${localDeviceId}_${this.slotDescription.application}${this.slotDescription.id}`;
|
||||
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
|
||||
@@ -793,6 +801,10 @@ export class MembershipManager
|
||||
"call_id": this.slotDescription.id,
|
||||
"scope": "m.room",
|
||||
"device_id": this.deviceId,
|
||||
// DO NOT use this.memberId here since that is the state key (using application...)
|
||||
// But for session events we use the colon seperated userId and deviceId. The SFU will automatically
|
||||
// assign those values to the media participant for those versions.
|
||||
"membershipID": `${this.userId}:${this.deviceId}`,
|
||||
expires,
|
||||
"m.call.intent": this.callIntent,
|
||||
...focusObjects,
|
||||
@@ -1022,6 +1034,9 @@ export class MembershipManager
|
||||
public get probablyLeft(): boolean {
|
||||
return this.state.probablyLeft;
|
||||
}
|
||||
public get delayId(): string | undefined {
|
||||
return this.state.delayId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1035,6 +1050,9 @@ export class StickyEventMembershipManager extends MembershipManager {
|
||||
private readonly clientWithSticky: MembershipManagerClient &
|
||||
Pick<MatrixClient, "_unstable_sendStickyEvent" | "_unstable_sendStickyDelayedEvent">,
|
||||
sessionDescription: SlotDescription,
|
||||
// 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.
|
||||
private readonly memberId: string,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super(joinConfig, room, clientWithSticky, sessionDescription, parentLogger);
|
||||
@@ -1083,7 +1101,7 @@ export class StickyEventMembershipManager extends MembershipManager {
|
||||
},
|
||||
slot_id: slotDescriptionToId(this.slotDescription),
|
||||
rtc_transports: this.rtcTransport ? [this.rtcTransport] : [],
|
||||
member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId },
|
||||
member: { device_id: this.deviceId, user_id: this.userId, id: this.memberId },
|
||||
versions: [],
|
||||
...relationObject,
|
||||
};
|
||||
|
||||
@@ -14,21 +14,25 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { type EncryptionConfig } from "./MatrixRTCSession.ts";
|
||||
import { type CallMembership } from "./CallMembership.ts";
|
||||
import {
|
||||
type CallMembershipIdentityParts,
|
||||
getEncryptionKeyMapKey,
|
||||
type IEncryptionManager,
|
||||
} from "./EncryptionManager.ts";
|
||||
import { type EncryptionConfig, type MembershipConfig } from "./MatrixRTCSession.ts";
|
||||
import { CallMembership } from "./CallMembership.ts";
|
||||
import { decodeBase64, encodeBase64 } from "../base64.ts";
|
||||
import { type IKeyTransport, type KeyTransportEventListener, KeyTransportEvents } from "./IKeyTransport.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
import type {
|
||||
InboundEncryptionSession,
|
||||
OutboundEncryptionSession,
|
||||
ParticipantDeviceInfo,
|
||||
ParticipantId,
|
||||
Statistics,
|
||||
import {
|
||||
type EncryptionKeyMapKey,
|
||||
type InboundEncryptionSession,
|
||||
type OutboundEncryptionSession,
|
||||
type ParticipantDeviceInfo,
|
||||
type Statistics,
|
||||
} from "./types.ts";
|
||||
import { getParticipantId, OutdatedKeyFilter } from "./utils.ts";
|
||||
import { OutdatedKeyFilter } from "./utils.ts";
|
||||
|
||||
/**
|
||||
* RTCEncryptionManager is used to manage the encryption keys for a call.
|
||||
@@ -46,12 +50,23 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
// This will be done when removing the legacy EncryptionManager.
|
||||
private manageMediaKeys = false;
|
||||
|
||||
private useHashedRtcBackendIdentity = false;
|
||||
private ownRtcBackendIdentityCache: string | undefined;
|
||||
|
||||
/**
|
||||
* Store the key rings for each participant.
|
||||
* The encryption manager stores the keys because the application layer might not be ready yet to handle the keys.
|
||||
* The keys are stored and can be retrieved later when the application layer is ready {@link RTCEncryptionManager#getEncryptionKeys}.
|
||||
*/
|
||||
private participantKeyRings = new Map<ParticipantId, Array<{ key: Uint8Array; keyIndex: number }>>();
|
||||
private participantKeyRings = new Map<
|
||||
EncryptionKeyMapKey,
|
||||
Array<{
|
||||
key: Uint8Array<ArrayBuffer>;
|
||||
keyIndex: number;
|
||||
membership: CallMembershipIdentityParts;
|
||||
rtcBackendIdentity: string;
|
||||
}>
|
||||
>();
|
||||
|
||||
// The current per-sender media key for this device
|
||||
private outboundSession: OutboundEncryptionSession | null = null;
|
||||
@@ -96,43 +111,122 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
|
||||
private logger: Logger | undefined = undefined;
|
||||
|
||||
private rtcIdentityProvider: (userId: string, deviceId: string, memberId: string) => Promise<string>;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param ownMembership - our own membership info
|
||||
* @param getMemberships - function to get current memberships
|
||||
* @param transport - key transport (room or to-device)
|
||||
* @param statistics - statistics collector
|
||||
* @param onEncryptionKeysChanged - callback to notify the media layer of new keys
|
||||
* @param parentLogger - optional parent logger
|
||||
* @param rtcBackendIdProvider - A function to compute the rtc backend identity, exposed for testing purposes
|
||||
*/
|
||||
public constructor(
|
||||
private userId: string,
|
||||
private deviceId: string,
|
||||
private ownMembership: CallMembershipIdentityParts,
|
||||
private getMemberships: () => CallMembership[],
|
||||
private transport: IKeyTransport,
|
||||
private statistics: Statistics,
|
||||
// Callback to notify the media layer of new keys
|
||||
private onEncryptionKeysChanged: (
|
||||
keyBin: Uint8Array,
|
||||
keyBin: Uint8Array<ArrayBuffer>,
|
||||
encryptionKeyIndex: number,
|
||||
participantId: ParticipantId,
|
||||
membership: CallMembershipIdentityParts,
|
||||
rtcBackendIdentity: string,
|
||||
) => void,
|
||||
parentLogger?: Logger,
|
||||
rtcBackendIdProvider?: (userId: string, deviceId: string, memberId: string) => Promise<string>,
|
||||
) {
|
||||
this.logger = parentLogger?.getChild(`[EncryptionManager]`);
|
||||
this.rtcIdentityProvider = rtcBackendIdProvider ?? CallMembership.computeRtcIdentityRaw;
|
||||
}
|
||||
|
||||
public getEncryptionKeys(): ReadonlyMap<ParticipantId, ReadonlyArray<{ key: Uint8Array; keyIndex: number }>> {
|
||||
private async getOwnRtcBackendIdentity(): Promise<string> {
|
||||
if (this.ownRtcBackendIdentityCache) return this.ownRtcBackendIdentityCache;
|
||||
|
||||
if (this.useHashedRtcBackendIdentity) {
|
||||
const { userId, deviceId, memberId } = this.ownMembership;
|
||||
this.logger?.info(
|
||||
// If we see this log multiple times, we need to reconsider the precompute call of getOwnRtcBackendIdentity
|
||||
`Computing RTC backend identity for ${userId}:${deviceId}:${memberId} (SHOULD ONLY BE CALLED ONCE)`,
|
||||
);
|
||||
this.ownRtcBackendIdentityCache = await this.rtcIdentityProvider(userId, deviceId, memberId);
|
||||
} else {
|
||||
this.ownRtcBackendIdentityCache = `${this.ownMembership.userId}:${this.ownMembership.deviceId}`;
|
||||
}
|
||||
return this.ownRtcBackendIdentityCache;
|
||||
}
|
||||
|
||||
public getEncryptionKeys(): ReadonlyMap<
|
||||
EncryptionKeyMapKey,
|
||||
ReadonlyArray<{
|
||||
key: Uint8Array<ArrayBuffer>;
|
||||
keyIndex: number;
|
||||
membership: CallMembershipIdentityParts;
|
||||
rtcBackendIdentity: string;
|
||||
}>
|
||||
> {
|
||||
return new Map(this.participantKeyRings);
|
||||
}
|
||||
|
||||
private addKeyToParticipant(key: Uint8Array, keyIndex: number, participantId: ParticipantId): void {
|
||||
if (!this.participantKeyRings.has(participantId)) {
|
||||
this.participantKeyRings.set(participantId, []);
|
||||
}
|
||||
this.participantKeyRings.get(participantId)!.push({ key, keyIndex });
|
||||
this.onEncryptionKeysChanged(key, keyIndex, participantId);
|
||||
private keysWithoutMatchingRTCMembership: Array<{
|
||||
key: Uint8Array<ArrayBuffer>;
|
||||
keyIndex: number;
|
||||
membership: CallMembershipIdentityParts;
|
||||
}> = [];
|
||||
|
||||
private checkKeysWithoutMatchingRTCMembership(): void {
|
||||
const keyInfoTemp = this.keysWithoutMatchingRTCMembership;
|
||||
this.keysWithoutMatchingRTCMembership = [];
|
||||
keyInfoTemp.forEach((keyInfo) => {
|
||||
this.addKeyToParticipant(keyInfo.key, keyInfo.keyIndex, keyInfo.membership);
|
||||
});
|
||||
}
|
||||
|
||||
public join(joinConfig: EncryptionConfig | undefined): void {
|
||||
this.manageMediaKeys = joinConfig?.manageMediaKeys ?? true; // default to true
|
||||
private addKeyToParticipant(
|
||||
key: Uint8Array<ArrayBuffer>,
|
||||
keyIndex: number,
|
||||
membership: CallMembershipIdentityParts,
|
||||
): void {
|
||||
const knownRtcMembership = this.getMemberships();
|
||||
const fullMembership = knownRtcMembership.find(
|
||||
(member) => member.userId === membership.userId && member.deviceId === membership.deviceId,
|
||||
);
|
||||
if (!fullMembership) {
|
||||
this.logger?.info(
|
||||
`No matching RTC membership for key from ${membership.userId}:${membership.deviceId}, delaying key addition`,
|
||||
);
|
||||
this.keysWithoutMatchingRTCMembership.push({ key, keyIndex, membership });
|
||||
return;
|
||||
}
|
||||
this.addKeyToParticipantWithBackendIdentity(key, keyIndex, membership, fullMembership.rtcBackendIdentity);
|
||||
}
|
||||
|
||||
this.logger?.info(`Joining room`);
|
||||
private addKeyToParticipantWithBackendIdentity(
|
||||
key: Uint8Array<ArrayBuffer>,
|
||||
keyIndex: number,
|
||||
membership: CallMembershipIdentityParts,
|
||||
rtcBackendIdentity: string,
|
||||
): void {
|
||||
const mapKey = getEncryptionKeyMapKey(membership);
|
||||
if (!this.participantKeyRings.has(mapKey)) {
|
||||
this.participantKeyRings.set(mapKey, []);
|
||||
}
|
||||
this.participantKeyRings.get(mapKey)!.push({ key, keyIndex, membership, rtcBackendIdentity });
|
||||
this.onEncryptionKeysChanged(key, keyIndex, membership, rtcBackendIdentity);
|
||||
}
|
||||
|
||||
public join(joinConfig: (EncryptionConfig & MembershipConfig) | undefined): void {
|
||||
this.manageMediaKeys = joinConfig?.manageMediaKeys ?? true; // default to true
|
||||
this.useHashedRtcBackendIdentity = joinConfig?.unstableSendStickyEvents ?? false;
|
||||
this.useKeyDelay = joinConfig?.useKeyDelay ?? 1000;
|
||||
this.keyRotationGracePeriodMs = joinConfig?.keyRotationGracePeriodMs ?? 10_000;
|
||||
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
|
||||
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
void this.getOwnRtcBackendIdentity(); // precompute own identity
|
||||
|
||||
this.logger?.info(`Joining room`);
|
||||
this.transport.start();
|
||||
}
|
||||
|
||||
@@ -167,50 +261,51 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// There is a rollout in progress, but a key rotation is requested (could be caused by a membership change)
|
||||
// There is a rollout in progress, but a key rotation is requested (could be caused by a ownMembership change)
|
||||
// Remember that a new rotation is needed after the current one.
|
||||
this.logger?.debug(`Rollout in progress, a new rollout will be started after the current one`);
|
||||
this.needToEnsureKeyAgain = true;
|
||||
}
|
||||
}
|
||||
|
||||
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
public onNewKeyReceived: KeyTransportEventListener = (membership, keyBase64Encoded, index, timestamp) => {
|
||||
// `manageMediaKeys` is a stop-gap solution for now. The preferred way to handle this case would be instead
|
||||
// to create a NoOpEncryptionManager that does nothing and use it for the session.
|
||||
// This will be done when removing the legacy EncryptionManager.
|
||||
if (!this.manageMediaKeys) {
|
||||
this.logger?.warn(
|
||||
`Received key over transport ${userId}:${deviceId} at index ${index} but media keys are disabled`,
|
||||
`Received key over transport ${membership.userId}:${membership.deviceId} at index ${index} but media keys are disabled`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger?.debug(`Received key over transport ${userId}:${deviceId} at index ${index}`);
|
||||
this.logger?.debug(`Received key over transport ${membership.userId}:${membership.deviceId} at index ${index}`);
|
||||
|
||||
// We received a new key, notify the video layer of this new key so that it can decrypt the frames properly.
|
||||
const participantId = getParticipantId(userId, deviceId);
|
||||
const keyBin = decodeBase64(keyBase64Encoded);
|
||||
const candidateInboundSession: InboundEncryptionSession = {
|
||||
key: keyBin,
|
||||
participantId,
|
||||
membership,
|
||||
keyIndex: index,
|
||||
creationTS: timestamp,
|
||||
};
|
||||
|
||||
const outdated = this.keyBuffer.isOutdated(participantId, candidateInboundSession);
|
||||
const outdated = this.keyBuffer.isOutdated(membership, candidateInboundSession);
|
||||
if (!outdated) {
|
||||
this.addKeyToParticipant(
|
||||
candidateInboundSession.key,
|
||||
candidateInboundSession.keyIndex,
|
||||
candidateInboundSession.participantId,
|
||||
candidateInboundSession.membership,
|
||||
);
|
||||
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
|
||||
} else {
|
||||
this.logger?.info(`Received an out of order key for ${userId}:${deviceId}, dropping it`);
|
||||
this.logger?.info(
|
||||
`Received an out of order key for ${membership.userId}:${membership.deviceId}, dropping it`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when the membership of the call changes.
|
||||
* Called when the ownMembership of the call changes.
|
||||
* This encryption manager is very basic, it will rotate the key everytime this is called.
|
||||
* @param oldMemberships - This parameter is not used here, but it is kept for compatibility with the interface.
|
||||
*/
|
||||
@@ -220,22 +315,26 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
// Ensure the key is distributed. This will be no-op if the key is already being distributed to everyone.
|
||||
// If there is an ongoing distribution, it will be completed before a new one is started.
|
||||
this.ensureKeyDistribution();
|
||||
// ensure key emission to the rtc backend
|
||||
this.checkKeysWithoutMatchingRTCMembership();
|
||||
}
|
||||
|
||||
private async rolloutOutboundKey(): Promise<void> {
|
||||
const isFirstKey = this.outboundSession == null;
|
||||
if (isFirstKey) {
|
||||
// create the first key
|
||||
this.outboundSession = {
|
||||
const firstKey = {
|
||||
key: this.generateRandomKey(),
|
||||
creationTS: Date.now(),
|
||||
sharedWith: [],
|
||||
keyId: 0,
|
||||
};
|
||||
this.addKeyToParticipant(
|
||||
this.outboundSession.key,
|
||||
this.outboundSession.keyId,
|
||||
getParticipantId(this.userId, this.deviceId),
|
||||
this.outboundSession = firstKey;
|
||||
this.addKeyToParticipantWithBackendIdentity(
|
||||
firstKey.key,
|
||||
firstKey.keyId,
|
||||
this.ownMembership,
|
||||
await this.getOwnRtcBackendIdentity(),
|
||||
);
|
||||
}
|
||||
// get current memberships
|
||||
@@ -253,7 +352,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
|
||||
let alreadySharedWith = this.outboundSession?.sharedWith ?? [];
|
||||
|
||||
// Some users might have rotate their membership event (formally called fingerprint) meaning they might have
|
||||
// Some users might have rotate their ownMembership event (formally called fingerprint) meaning they might have
|
||||
// clear their key. Reset the `alreadySharedWith` flag for them.
|
||||
alreadySharedWith = alreadySharedWith.filter(
|
||||
(x) =>
|
||||
@@ -322,10 +421,11 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
this.logger?.trace(`Delay Rollout for key:${outboundKey.keyId}...`);
|
||||
await sleep(this.useKeyDelay);
|
||||
this.logger?.trace(`...Delayed rollout of index:${outboundKey.keyId} `);
|
||||
this.addKeyToParticipant(
|
||||
this.addKeyToParticipantWithBackendIdentity(
|
||||
outboundKey.key,
|
||||
outboundKey.keyId,
|
||||
getParticipantId(this.userId, this.deviceId),
|
||||
this.ownMembership,
|
||||
await this.getOwnRtcBackendIdentity(),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -354,7 +454,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
return 0;
|
||||
}
|
||||
|
||||
private generateRandomKey(): Uint8Array {
|
||||
private generateRandomKey(): Uint8Array<ArrayBuffer> {
|
||||
const key = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(key);
|
||||
return key;
|
||||
|
||||
@@ -24,6 +24,9 @@ import { type MatrixEvent } from "../models/event.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type Room, RoomEvent } from "../models/room.ts";
|
||||
|
||||
/**
|
||||
* @deprecated This is depreacted and not used anymore. use the ToDeviceTransport
|
||||
*/
|
||||
export class RoomKeyTransport
|
||||
extends TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>
|
||||
implements IKeyTransport
|
||||
@@ -174,8 +177,8 @@ export class RoomKeyTransport
|
||||
);
|
||||
this.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
userId,
|
||||
deviceId,
|
||||
// Using `${userId}:${deviceId}` makes no sense (but works). It does not matter since the RoomKeyTransport is deprecated
|
||||
{ userId, deviceId, memberId: `${userId}:${deviceId}` },
|
||||
encryptionKey,
|
||||
encryptionKeyIndex,
|
||||
event.getTs(),
|
||||
|
||||
@@ -23,6 +23,7 @@ import { type EncryptionKeysToDeviceEventContent, type ParticipantDeviceInfo, ty
|
||||
import { ClientEvent, type MatrixClient } from "../client.ts";
|
||||
import type { MatrixEvent } from "../models/event.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
import { type CallMembershipIdentityParts } from "./EncryptionManager.ts";
|
||||
|
||||
export class NotSupportedError extends Error {
|
||||
public constructor(message?: string) {
|
||||
@@ -47,8 +48,7 @@ export class ToDeviceKeyTransport
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private userId: string,
|
||||
private deviceId: string,
|
||||
private membership: CallMembershipIdentityParts,
|
||||
private roomId: string,
|
||||
private client: Pick<MatrixClient, "encryptAndSendToDevice" | "on" | "off">,
|
||||
private statistics: Statistics,
|
||||
@@ -74,7 +74,8 @@ export class ToDeviceKeyTransport
|
||||
},
|
||||
room_id: this.roomId,
|
||||
member: {
|
||||
claimed_device_id: this.deviceId,
|
||||
claimed_device_id: this.membership.deviceId,
|
||||
id: this.membership.memberId,
|
||||
},
|
||||
session: {
|
||||
call_id: "",
|
||||
@@ -92,7 +93,9 @@ export class ToDeviceKeyTransport
|
||||
};
|
||||
})
|
||||
// filter out me
|
||||
.filter((member) => !(member.userId == this.userId && member.deviceId == this.deviceId));
|
||||
.filter(
|
||||
(member) => !(member.userId == this.membership.userId && member.deviceId == this.membership.deviceId),
|
||||
);
|
||||
|
||||
if (targets.length > 0) {
|
||||
await this.client
|
||||
@@ -127,12 +130,16 @@ export class ToDeviceKeyTransport
|
||||
const age = now - (typeof content.sent_ts === "number" ? content.sent_ts : now);
|
||||
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
|
||||
|
||||
const hardcodedMemberIdAlternative = `${fromUser}:${content.member.claimed_device_id}`;
|
||||
|
||||
this.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
// TODO this is claimed information
|
||||
fromUser,
|
||||
// TODO: This is claimed information
|
||||
content.member.claimed_device_id!,
|
||||
// TODO userId this is claimed information, deviceId is claimed information
|
||||
{
|
||||
userId: fromUser,
|
||||
deviceId: content.member.claimed_device_id!,
|
||||
memberId: content.member.id ?? hardcodedMemberIdAlternative,
|
||||
},
|
||||
content.keys.key,
|
||||
content.keys.index,
|
||||
now,
|
||||
|
||||
@@ -16,8 +16,9 @@ limitations under the License.
|
||||
import type { IContent, IMentions } from "../matrix.ts";
|
||||
import type { RelationEvent } from "../types.ts";
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
import { type CallMembershipIdentityParts } from "./EncryptionManager.ts";
|
||||
|
||||
export type ParticipantId = string;
|
||||
export type EncryptionKeyMapKey = string;
|
||||
|
||||
export interface EncryptionKeyEntry {
|
||||
index: number;
|
||||
@@ -37,8 +38,8 @@ export type ParticipantDeviceInfo = {
|
||||
* A type representing the information needed to decrypt video streams.
|
||||
*/
|
||||
export type InboundEncryptionSession = {
|
||||
key: Uint8Array;
|
||||
participantId: ParticipantId;
|
||||
key: Uint8Array<ArrayBuffer>;
|
||||
membership: CallMembershipIdentityParts;
|
||||
keyIndex: number;
|
||||
creationTS: number;
|
||||
};
|
||||
@@ -47,7 +48,7 @@ export type InboundEncryptionSession = {
|
||||
* The information about the key used to encrypt video streams.
|
||||
*/
|
||||
export type OutboundEncryptionSession = {
|
||||
key: Uint8Array;
|
||||
key: Uint8Array<ArrayBuffer>;
|
||||
creationTS: number;
|
||||
// The devices that this key is shared with.
|
||||
sharedWith: Array<ParticipantDeviceInfo>;
|
||||
@@ -68,6 +69,7 @@ export interface EncryptionKeysEventContent {
|
||||
export interface EncryptionKeysToDeviceEventContent {
|
||||
keys: { index: number; key: string };
|
||||
member: {
|
||||
id: string;
|
||||
// TODO Remove that it is claimed, need to get the sealed sender from decryption info
|
||||
// Or add some validation on it based on the encryption info
|
||||
claimed_device_id: string;
|
||||
@@ -159,11 +161,8 @@ export interface IRTCDeclineContent extends RelationEvent {}
|
||||
export enum Status {
|
||||
Disconnected = "Disconnected",
|
||||
Connecting = "Connecting",
|
||||
ConnectingFailed = "ConnectingFailed",
|
||||
Connected = "Connected",
|
||||
Reconnecting = "Reconnecting",
|
||||
Disconnecting = "Disconnecting",
|
||||
Stuck = "Stuck",
|
||||
Unknown = "Unknown",
|
||||
}
|
||||
|
||||
|
||||
+9
-11
@@ -14,14 +14,15 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { InboundEncryptionSession, ParticipantId } from "./types.ts";
|
||||
import { getEncryptionKeyMapKey, type CallMembershipIdentityParts } from "./EncryptionManager.ts";
|
||||
import { type InboundEncryptionSession, type EncryptionKeyMapKey } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Detects when a key for a given index is outdated.
|
||||
*/
|
||||
export class OutdatedKeyFilter {
|
||||
// Map of participantId -> keyIndex -> timestamp
|
||||
private tsBuffer: Map<ParticipantId, Map<number, number>> = new Map();
|
||||
private tsBuffer: Map<EncryptionKeyMapKey, Map<number, number>> = new Map();
|
||||
|
||||
public constructor() {}
|
||||
|
||||
@@ -31,21 +32,18 @@ export class OutdatedKeyFilter {
|
||||
* @param participantId
|
||||
* @param item
|
||||
*/
|
||||
public isOutdated(participantId: ParticipantId, item: InboundEncryptionSession): boolean {
|
||||
if (!this.tsBuffer.has(participantId)) {
|
||||
this.tsBuffer.set(participantId, new Map<number, number>());
|
||||
public isOutdated(membership: CallMembershipIdentityParts, item: InboundEncryptionSession): boolean {
|
||||
const mapKey = getEncryptionKeyMapKey(membership);
|
||||
if (!this.tsBuffer.has(mapKey)) {
|
||||
this.tsBuffer.set(mapKey, new Map<number, number>());
|
||||
}
|
||||
|
||||
const latestTimestamp = this.tsBuffer.get(participantId)?.get(item.keyIndex);
|
||||
const latestTimestamp = this.tsBuffer.get(mapKey)?.get(item.keyIndex);
|
||||
if (latestTimestamp && latestTimestamp > item.creationTS) {
|
||||
// The existing key is more recent, ignore this one
|
||||
return true;
|
||||
}
|
||||
this.tsBuffer.get(participantId)!.set(item.keyIndex, item.creationTS);
|
||||
this.tsBuffer.get(mapKey)!.set(item.keyIndex, item.creationTS);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getParticipantId(userId: string, deviceId: string): ParticipantId {
|
||||
return `${userId}:${deviceId}`;
|
||||
}
|
||||
|
||||
@@ -57,14 +57,18 @@ export interface IRoomTimelineData {
|
||||
liveEvent?: boolean;
|
||||
}
|
||||
|
||||
export interface IAddEventToTimelineOptions
|
||||
extends Pick<IAddEventOptions, "toStartOfTimeline" | "roomState" | "timelineWasEmpty" | "addToState"> {
|
||||
export interface IAddEventToTimelineOptions extends Pick<
|
||||
IAddEventOptions,
|
||||
"toStartOfTimeline" | "roomState" | "timelineWasEmpty" | "addToState"
|
||||
> {
|
||||
/** Whether the sync response came from cache */
|
||||
fromCache?: boolean;
|
||||
}
|
||||
|
||||
export interface IAddLiveEventOptions
|
||||
extends Pick<IAddEventToTimelineOptions, "fromCache" | "roomState" | "timelineWasEmpty" | "addToState"> {
|
||||
export interface IAddLiveEventOptions extends Pick<
|
||||
IAddEventToTimelineOptions,
|
||||
"fromCache" | "roomState" | "timelineWasEmpty" | "addToState"
|
||||
> {
|
||||
/** Applies to events in the timeline only. If this is 'replace' then if a
|
||||
* duplicate is encountered, the event passed to this function will replace
|
||||
* the existing event in the timeline. If this is not specified, or is
|
||||
|
||||
+22
-43
@@ -19,9 +19,8 @@ limitations under the License.
|
||||
* the public classes.
|
||||
*/
|
||||
|
||||
import { type ExtensibleEvent, ExtensibleEvents, type Optional } from "matrix-events-sdk";
|
||||
import { type ExtensibleEvent, ExtensibleEvents } from "matrix-events-sdk";
|
||||
|
||||
import type { IEventDecryptionResult } from "../@types/crypto.ts";
|
||||
import { logger } from "../logger.ts";
|
||||
import {
|
||||
EVENT_VISIBILITY_CHANGE_TYPE,
|
||||
@@ -40,7 +39,7 @@ import { TypedReEmitter } from "../ReEmitter.ts";
|
||||
import { type MatrixError } from "../http-api/index.ts";
|
||||
import { TypedEventEmitter } from "./typed-event-emitter.ts";
|
||||
import { type EventStatus } from "./event-status.ts";
|
||||
import { type CryptoBackend, DecryptionError } from "../common-crypto/CryptoBackend.ts";
|
||||
import { type CryptoBackend, DecryptionError, type EventDecryptionResult } from "../common-crypto/CryptoBackend.ts";
|
||||
import { type IAnnotatedPushRule } from "../@types/PushRules.ts";
|
||||
import { type Room } from "./room.ts";
|
||||
import { EventTimeline } from "./event-timeline.ts";
|
||||
@@ -186,8 +185,7 @@ export interface IDecryptOptions {
|
||||
isRetry?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the message should be re-decrypted if it was previously successfully decrypted with an untrusted key.
|
||||
* Defaults to `false`.
|
||||
* @deprecated does nothing
|
||||
*/
|
||||
forceRedecryptIfUntrusted?: boolean;
|
||||
}
|
||||
@@ -270,7 +268,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
// addition to a falsy cached event value. We check the flag later on in
|
||||
// a public getter to decide if the cache is valid.
|
||||
private _hasCachedExtEv = false;
|
||||
private _cachedExtEv: Optional<ExtensibleEvent> = undefined;
|
||||
private _cachedExtEv?: ExtensibleEvent = undefined;
|
||||
|
||||
/** If we failed to decrypt this event, the reason for the failure. Otherwise, `null`. */
|
||||
private _decryptionFailureReason: DecryptionFailureCode | null = null;
|
||||
@@ -285,16 +283,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
*/
|
||||
private claimedEd25519Key: string | null = null;
|
||||
|
||||
/* curve25519 keys of devices involved in telling us about the
|
||||
* senderCurve25519Key and claimedEd25519Key.
|
||||
* See getForwardingCurve25519KeyChain().
|
||||
*/
|
||||
private forwardingCurve25519KeyChain: string[] = [];
|
||||
|
||||
/* where the decryption key is untrusted
|
||||
*/
|
||||
private untrusted: boolean | null = null;
|
||||
|
||||
/* if we have a process decrypting this event, a Promise which resolves
|
||||
* when it is finished. Normally null.
|
||||
*/
|
||||
@@ -481,9 +469,9 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
*
|
||||
* @deprecated Use stable functions where possible.
|
||||
*/
|
||||
public get unstableExtensibleEvent(): Optional<ExtensibleEvent> {
|
||||
public get unstableExtensibleEvent(): ExtensibleEvent | undefined {
|
||||
if (!this._hasCachedExtEv) {
|
||||
this._cachedExtEv = ExtensibleEvents.parse(this.getEffectiveEvent());
|
||||
this._cachedExtEv = ExtensibleEvents.parse(this.getEffectiveEvent()) ?? undefined;
|
||||
}
|
||||
return this._cachedExtEv;
|
||||
}
|
||||
@@ -753,7 +741,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
|
||||
/**
|
||||
* 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
|
||||
* string-unpacking on the state key, as per MSC4362. This will return
|
||||
* <code>undefined</code> for message events.
|
||||
* @returns The event's `state_key`.
|
||||
*/
|
||||
@@ -766,7 +754,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
|
||||
/**
|
||||
* 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
|
||||
* MSC4362 if the state event is encrypted. This will return <code>undefined
|
||||
* </code> for message events.
|
||||
* @returns The event's `state_key`.
|
||||
*/
|
||||
@@ -789,7 +777,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* @returns The user's room membership, or `undefined` if the server does
|
||||
* not report it.
|
||||
*/
|
||||
public getMembershipAtEvent(): Optional<Membership | string> {
|
||||
public getMembershipAtEvent(): Membership | string | undefined {
|
||||
const unsigned = this.getUnsigned();
|
||||
return UNSIGNED_MEMBERSHIP_FIELD.findIn<Membership | string>(unsigned);
|
||||
}
|
||||
@@ -894,8 +882,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
}
|
||||
|
||||
const alreadyDecrypted = this.clearEvent && !this.isDecryptionFailure();
|
||||
const forceRedecrypt = options.forceRedecryptIfUntrusted && this.isKeySourceUntrusted();
|
||||
if (alreadyDecrypted && !forceRedecrypt) {
|
||||
if (alreadyDecrypted) {
|
||||
// we may want to just ignore this? let's start with rejecting it.
|
||||
throw new Error("Attempt to decrypt event which has already been decrypted");
|
||||
}
|
||||
@@ -1025,12 +1012,10 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
*
|
||||
* @param decryptionResult - the decryption result, including the plaintext and some key info
|
||||
*/
|
||||
private setClearData(decryptionResult: IEventDecryptionResult): void {
|
||||
private setClearData(decryptionResult: EventDecryptionResult): void {
|
||||
this.clearEvent = decryptionResult.clearEvent;
|
||||
this.senderCurve25519Key = decryptionResult.senderCurve25519Key ?? null;
|
||||
this.claimedEd25519Key = decryptionResult.claimedEd25519Key ?? null;
|
||||
this.forwardingCurve25519KeyChain = decryptionResult.forwardingCurve25519KeyChain || [];
|
||||
this.untrusted = decryptionResult.untrusted || false;
|
||||
this.invalidateExtensibleEvent();
|
||||
}
|
||||
|
||||
@@ -1049,8 +1034,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
};
|
||||
this.senderCurve25519Key = null;
|
||||
this.claimedEd25519Key = null;
|
||||
this.forwardingCurve25519KeyChain = [];
|
||||
this.untrusted = false;
|
||||
this.invalidateExtensibleEvent();
|
||||
}
|
||||
|
||||
@@ -1120,29 +1103,25 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the curve25519 keys of the devices which were involved in telling us
|
||||
* about the claimedEd25519Key and sender curve25519 key.
|
||||
* Returns an empty array.
|
||||
*
|
||||
* Normally this will be empty, but in the case of a forwarded megolm
|
||||
* session, the sender keys are sent to us by another device (the forwarding
|
||||
* device), which we need to trust to do this. In that case, the result will
|
||||
* be a list consisting of one entry.
|
||||
* Previously, this returned the chain of Curve25519 keys through which
|
||||
* this session was forwarded, via `m.forwarded_room_key` events.
|
||||
* However, that is not cryptographically reliable, and clients should not
|
||||
* be using it.
|
||||
*
|
||||
* If the device that sent us the key (A) got it from another device which
|
||||
* it wasn't prepared to vouch for (B), the result will be [A, B]. And so on.
|
||||
*
|
||||
* @returns base64-encoded curve25519 keys, from oldest to newest.
|
||||
* @see https://github.com/matrix-org/matrix-spec/issues/1089
|
||||
* @deprecated
|
||||
*/
|
||||
public getForwardingCurve25519KeyChain(): string[] {
|
||||
return this.forwardingCurve25519KeyChain;
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the decryption key was obtained from an untrusted source. If so,
|
||||
* we cannot verify the authenticity of the message.
|
||||
* @deprecated always returns false
|
||||
*/
|
||||
public isKeySourceUntrusted(): boolean | undefined {
|
||||
return !!this.untrusted;
|
||||
public isKeySourceUntrusted(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
public getUnsigned(): IUnsigned {
|
||||
|
||||
@@ -94,16 +94,23 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
|
||||
* True if the room member is currently typing.
|
||||
*/
|
||||
public typing = false;
|
||||
|
||||
/**
|
||||
* The human-readable name for this room member. This will be
|
||||
* The human-readable name for this room member. Similar to {@link rawDisplayName}, but
|
||||
* disambiguated with a suffix of " (\@user_id:matrix.org)" if another member shares the
|
||||
* same displayname.
|
||||
*/
|
||||
public name: string;
|
||||
|
||||
/**
|
||||
* The ambiguous displayname of this room member.
|
||||
* The ambiguous displayname of this room member, with some preprocessing:
|
||||
*
|
||||
* * Direction override characters (RTO and LRO) are removed.
|
||||
* * If the displayname is empty, or contains only blank, non-printing, or diacritcic characters, it is
|
||||
* replaced with the user ID.
|
||||
*/
|
||||
public rawDisplayName: string;
|
||||
|
||||
/**
|
||||
* The power level for this room member.
|
||||
*/
|
||||
|
||||
+15
-20
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { M_POLL_START, type Optional } from "matrix-events-sdk";
|
||||
import { M_POLL_START } from "matrix-events-sdk";
|
||||
|
||||
import {
|
||||
DuplicateStrategy,
|
||||
@@ -1196,7 +1196,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// Get the main TimelineSet
|
||||
const timelineSet = this.getUnfilteredTimelineSet();
|
||||
|
||||
let newTimeline: Optional<EventTimeline>;
|
||||
let newTimeline: EventTimeline | null = null;
|
||||
// If there isn't any event in the timeline, let's go fetch the latest
|
||||
// event and construct a timeline from it.
|
||||
//
|
||||
@@ -1783,12 +1783,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
allowDefault = true,
|
||||
useAuthentication: boolean = false,
|
||||
): string | null {
|
||||
const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, "");
|
||||
if (!roomAvatarEvent && !allowDefault) {
|
||||
const mainUrl = this.getMxcAvatarUrl();
|
||||
if (!mainUrl && !allowDefault) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null;
|
||||
if (mainUrl) {
|
||||
return getHttpUriForMxc(
|
||||
baseUrl,
|
||||
@@ -1810,7 +1809,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* @returns the mxc avatar url or falsy
|
||||
*/
|
||||
public getMxcAvatarUrl(): string | null {
|
||||
return this.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url || null;
|
||||
const url = this.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent().url;
|
||||
return url && typeof url === "string" ? url : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1820,11 +1820,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* @returns The room's canonical alias, or null if there is none
|
||||
*/
|
||||
public getCanonicalAlias(): string | null {
|
||||
const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, "");
|
||||
if (canonicalAlias) {
|
||||
return canonicalAlias.getContent().alias || null;
|
||||
}
|
||||
return null;
|
||||
const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, "")?.getContent().alias;
|
||||
return canonicalAlias && typeof canonicalAlias === "string" ? canonicalAlias : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1832,9 +1829,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* @returns The room's alternative aliases, or an empty array
|
||||
*/
|
||||
public getAltAliases(): string[] {
|
||||
const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, "");
|
||||
if (canonicalAlias) {
|
||||
return canonicalAlias.getContent().alt_aliases || [];
|
||||
const altAliases = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, "")?.getContent().alt_aliases;
|
||||
if (Array.isArray(altAliases)) {
|
||||
return altAliases.filter((alias) => typeof alias === "string");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -2493,7 +2490,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
};
|
||||
|
||||
private updateThreadRootEvent = (
|
||||
timelineSet: Optional<EventTimelineSet>,
|
||||
timelineSet: EventTimelineSet | undefined,
|
||||
thread: Thread,
|
||||
toStartOfTimeline: boolean,
|
||||
recreateEvent: boolean,
|
||||
@@ -3640,13 +3637,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
*/
|
||||
private calculateRoomName(userId: string, ignoreRoomNameEvent = false): string {
|
||||
if (!ignoreRoomNameEvent) {
|
||||
// check for an alias, if any. for now, assume first alias is the
|
||||
// official one.
|
||||
const mRoomName = this.currentState.getStateEvents(EventType.RoomName, "");
|
||||
if (mRoomName?.getContent().name) {
|
||||
const name = this.currentState.getStateEvents(EventType.RoomName, "")?.getContent().name;
|
||||
if (name && typeof name === "string") {
|
||||
return this.roomNameGenerator({
|
||||
type: RoomNameType.Actual,
|
||||
name: mRoomName.getContent().name,
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Optional } from "matrix-events-sdk";
|
||||
|
||||
import { type MatrixClient, PendingEventOrdering } from "../client.ts";
|
||||
import { TypedReEmitter } from "../ReEmitter.ts";
|
||||
import { RelationType } from "../@types/event.ts";
|
||||
@@ -476,7 +474,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
}
|
||||
}
|
||||
|
||||
public async processEvent(event: Optional<MatrixEvent>): Promise<void> {
|
||||
public async processEvent(event: MatrixEvent | null | undefined): Promise<void> {
|
||||
if (event) {
|
||||
this.setEventMetadata(event);
|
||||
await this.fetchEditsWhereNeeded(event);
|
||||
@@ -686,14 +684,14 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
}
|
||||
}
|
||||
|
||||
public setEventMetadata(event: Optional<MatrixEvent>): void {
|
||||
public setEventMetadata(event: MatrixEvent | null | undefined): void {
|
||||
if (event) {
|
||||
EventTimeline.setEventMetadata(event, this.roomState, false);
|
||||
event.setThread(this);
|
||||
}
|
||||
}
|
||||
|
||||
public clearEventMetadata(event: Optional<MatrixEvent>): void {
|
||||
public clearEventMetadata(event: MatrixEvent | null | undefined): void {
|
||||
if (event) {
|
||||
event.setThread(undefined);
|
||||
delete event.event?.unsigned?.["m.relations"]?.[THREAD_RELATION_TYPE.name];
|
||||
@@ -739,7 +737,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
* A getter for the last event of the thread.
|
||||
* This might be a synthesized event, if so, it will not emit any events to listeners.
|
||||
*/
|
||||
public get replyToEvent(): Optional<MatrixEvent> {
|
||||
public get replyToEvent(): MatrixEvent | null {
|
||||
return this.lastPendingEvent ?? this.lastEvent ?? this.lastReply();
|
||||
}
|
||||
|
||||
|
||||
+35
-10
@@ -30,12 +30,15 @@ import { logger } from "../logger.ts";
|
||||
*/
|
||||
export class OidcTokenRefresher {
|
||||
/**
|
||||
* Promise which will complete once the OidcClient has been initialised
|
||||
* and is ready to start refreshing tokens.
|
||||
*
|
||||
* Will reject if the client initialisation fails.
|
||||
* This is now just a resolved promise and will be removed in a future version.
|
||||
* Initialisation is done lazily at token refresh time.
|
||||
* @deprecated Consumers no longer need to wait for this promise.
|
||||
*/
|
||||
public readonly oidcClientReady!: Promise<void>;
|
||||
|
||||
// If there is a initialisation attempt in progress, we keep track of it here.
|
||||
private initPromise?: Promise<void>;
|
||||
|
||||
private oidcClient!: OidcClient;
|
||||
private inflightRefreshRequest?: Promise<AccessTokens>;
|
||||
|
||||
@@ -43,26 +46,46 @@ export class OidcTokenRefresher {
|
||||
/**
|
||||
* The OIDC issuer as returned by the /auth_issuer API
|
||||
*/
|
||||
issuer: string,
|
||||
private issuer: string,
|
||||
/**
|
||||
* id of this client as registered with the OP
|
||||
*/
|
||||
clientId: string,
|
||||
private clientId: string,
|
||||
/**
|
||||
* redirectUri as registered with OP
|
||||
*/
|
||||
redirectUri: string,
|
||||
private redirectUri: string,
|
||||
/**
|
||||
* Device ID of current session
|
||||
*/
|
||||
deviceId: string,
|
||||
protected deviceId: string,
|
||||
/**
|
||||
* idTokenClaims as returned from authorization grant
|
||||
* used to validate tokens
|
||||
*/
|
||||
private readonly idTokenClaims: IdTokenClaims,
|
||||
) {
|
||||
this.oidcClientReady = this.initialiseOidcClient(issuer, clientId, deviceId, redirectUri);
|
||||
this.oidcClientReady = Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the client is initialised.
|
||||
* @returns Promise that resolves when initialisation is complete
|
||||
* @throws if initialisation fails
|
||||
*/
|
||||
private async ensureInit(): Promise<void> {
|
||||
if (!this.oidcClient) {
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
this.initPromise = this.initialiseOidcClient(this.issuer, this.clientId, this.deviceId, this.redirectUri);
|
||||
try {
|
||||
await this.initPromise;
|
||||
} finally {
|
||||
this.initPromise = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async initialiseOidcClient(
|
||||
@@ -98,6 +121,8 @@ export class OidcTokenRefresher {
|
||||
* @throws when token refresh fails
|
||||
*/
|
||||
public async doRefreshAccessToken(refreshToken: string): Promise<AccessTokens> {
|
||||
await this.ensureInit();
|
||||
|
||||
if (!this.inflightRefreshRequest) {
|
||||
this.inflightRefreshRequest = this.getNewTokens(refreshToken);
|
||||
}
|
||||
@@ -123,7 +148,7 @@ export class OidcTokenRefresher {
|
||||
* @param tokens.accessToken - new access token
|
||||
* @param tokens.refreshToken - OPTIONAL new refresh token
|
||||
*/
|
||||
public async persistTokens(tokens: { accessToken: string; refreshToken?: string }): Promise<void> {
|
||||
protected async persistTokens(tokens: { accessToken: string; refreshToken?: string }): Promise<void> {
|
||||
// NOOP
|
||||
}
|
||||
|
||||
|
||||
@@ -139,5 +139,29 @@ export class OutgoingRequestsManager {
|
||||
this.logger.error(`Failed to process outgoing request ${request.type}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If we handled any requests this time, more may have been queued as
|
||||
// part of that handling, so do an extra check to make sure we handle
|
||||
// them immediately. See
|
||||
// If we handled any requests this time, more may have been queued as
|
||||
// part of that handling.
|
||||
//
|
||||
// For example, we may have processed a `/keys/claim` request, which
|
||||
// meant the rust side could establish an Olm session and is now ready to
|
||||
// send out an `m.secret.send` message.
|
||||
// (See https://github.com/element-hq/element-web/issues/30988.)
|
||||
//
|
||||
// So, if we have processed any requests, flag that we need make another
|
||||
// pass around the outgoing-requests loop, to make sure we handle any
|
||||
// pending requests immediately.
|
||||
if (outgoingRequests.length > 0) {
|
||||
// We call doProcessOutgoingRequests but since we expect that we are
|
||||
// already processing outgoing requests, this call will not kick off
|
||||
// the processing loop, but just set `nextLoopDeferred` and return,
|
||||
// which will mean we loop one more time.
|
||||
this.doProcessOutgoingRequests().catch((e) => {
|
||||
this.logger.warn("processOutgoingRequests: Error re-checking outgoing requests", e);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import anotherjson from "another-json";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto.ts";
|
||||
import type { IMegolmSessionData } from "../@types/crypto.ts";
|
||||
import { KnownMembership } from "../@types/membership.ts";
|
||||
import { type IDeviceLists, type IToDeviceEvent, type ReceivedToDeviceMessage } from "../sync-accumulator.ts";
|
||||
import type { ToDeviceBatch, ToDevicePayload } from "../models/ToDeviceMessage.ts";
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type BackupDecryptor,
|
||||
type CryptoBackend,
|
||||
DecryptionError,
|
||||
type EventDecryptionResult,
|
||||
type OnSyncCompletedData,
|
||||
} from "../common-crypto/CryptoBackend.ts";
|
||||
import { type Logger, LogSpan } from "../logger.ts";
|
||||
@@ -129,6 +130,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
/** mapping of roomId → encryptor class */
|
||||
private roomEncryptors: Record<string, RoomEncryptor> = {};
|
||||
|
||||
/** mapping of room ID -> inviter ID for rooms pending MSC4268 key bundles */
|
||||
private readonly roomsPendingKeyBundles: Map<string, string> = new Map();
|
||||
|
||||
private eventDecryptor: EventDecryptor;
|
||||
private keyClaimManager: KeyClaimManager;
|
||||
private outgoingRequestProcessor: OutgoingRequestProcessor;
|
||||
@@ -164,7 +168,7 @@ 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. */
|
||||
/** Enable support for encrypted state events under MSC4362. */
|
||||
private readonly enableEncryptedStateEvents: boolean = false,
|
||||
) {
|
||||
super();
|
||||
@@ -282,7 +286,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
await encryptor.encryptEvent(event, this.globalBlacklistUnverifiedDevices, this.deviceIsolationMode);
|
||||
}
|
||||
|
||||
public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
|
||||
public async decryptEvent(event: MatrixEvent): Promise<EventDecryptionResult> {
|
||||
const roomId = event.getRoomId();
|
||||
if (!roomId) {
|
||||
// presumably, a to-device message. These are normally decrypted in preprocessToDeviceMessages
|
||||
@@ -329,10 +333,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend.maybeAcceptKeyBundle}.
|
||||
*/
|
||||
public async maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<void> {
|
||||
public async maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<boolean> {
|
||||
// TODO: retry this if it gets interrupted or it fails. (https://github.com/matrix-org/matrix-rust-sdk/issues/5112)
|
||||
// TODO: do this in the background.
|
||||
// TODO: handle the bundle message arriving after the invite (https://github.com/element-hq/element-web/issues/30740)
|
||||
|
||||
const logger = new LogSpan(this.logger, `maybeAcceptKeyBundle(${roomId}, ${inviter})`);
|
||||
|
||||
@@ -352,7 +355,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
);
|
||||
if (!bundleData) {
|
||||
logger.info("No key bundle found for user");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`Fetching key bundle ${bundleData.url}`);
|
||||
@@ -391,7 +394,17 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
logger.warn(`Error receiving encrypted bundle:`, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend.markRoomAsPendingKeyBundle}.
|
||||
*/
|
||||
public markRoomAsPendingKeyBundle(roomId: string, inviter: string): void {
|
||||
this.roomsPendingKeyBundles.set(roomId, inviter);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// CryptoApi implementation
|
||||
@@ -1703,6 +1716,37 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
senderVerified: encryptionInfo.isSenderVerified(),
|
||||
},
|
||||
});
|
||||
|
||||
// If we have received a room key bundle message, and have previously marked the room
|
||||
// IDs it references as pending key bundles, tell the Rust SDK to try and accept it,
|
||||
// just in case it was received after invite.
|
||||
//
|
||||
// We don't actually need to validate the contents of the bundle message, or do
|
||||
// anything with its contents at all. We simply want to inform the Rust SDK we have
|
||||
// received a new room key bundle that we might be able to download.
|
||||
if (
|
||||
isRoomKeyBundleMessage(parsedMessage) &&
|
||||
this.roomsPendingKeyBundles.has(parsedMessage.content.room_id)
|
||||
) {
|
||||
// No `await`-ing here, as this is called from inside the `/sync` loop.
|
||||
this.maybeAcceptKeyBundle(
|
||||
parsedMessage.content.room_id,
|
||||
this.roomsPendingKeyBundles.get(parsedMessage.content.room_id)!,
|
||||
).then(
|
||||
(success) => {
|
||||
if (success) {
|
||||
this.roomsPendingKeyBundles.delete(parsedMessage.content.room_id);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
this.logger.error(
|
||||
`Error attempting to download key bundle for room ${parsedMessage.content.room_id}`,
|
||||
);
|
||||
this.logger.error(err);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case RustSdkCryptoJs.ProcessedToDeviceEventType.PlainText: {
|
||||
@@ -1774,7 +1818,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
return;
|
||||
}
|
||||
|
||||
if (config["io.element.msc3414.encrypt_state_events"] && this.enableEncryptedStateEvents) {
|
||||
if (config["io.element.msc4362.encrypt_state_events"] && this.enableEncryptedStateEvents) {
|
||||
this.logger.info("crypto Enabling state event encryption...");
|
||||
settings.encryptStateEvents = true;
|
||||
}
|
||||
@@ -2159,7 +2203,7 @@ class EventDecryptor {
|
||||
public async attemptEventDecryption(
|
||||
event: MatrixEvent,
|
||||
isolationMode: DeviceIsolationMode,
|
||||
): Promise<IEventDecryptionResult> {
|
||||
): Promise<EventDecryptionResult> {
|
||||
// add the event to the pending list *before* attempting to decrypt.
|
||||
// then, if the key turns up while decryption is in progress (and
|
||||
// decryption fails), we will schedule a retry.
|
||||
@@ -2192,7 +2236,6 @@ class EventDecryptor {
|
||||
clearEvent: JSON.parse(res.event),
|
||||
claimedEd25519Key: res.senderClaimedEd25519Key,
|
||||
senderCurve25519Key: res.senderCurve25519Key,
|
||||
forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof RustSdkCryptoJs.MegolmDecryptionError) {
|
||||
@@ -2468,5 +2511,25 @@ function rustEncryptionInfoToJsEncryptionInfo(
|
||||
return { shieldColour, shieldReason };
|
||||
}
|
||||
|
||||
interface RoomKeyBundleMessage {
|
||||
type: "io.element.msc4268.room_key_bundle";
|
||||
content: {
|
||||
room_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the given payload is a RoomKeyBundleMessage.
|
||||
*
|
||||
* A RoomKeyBundleMessage is identified by having a specific message type
|
||||
* ("io.element.msc4268.room_key_bundle") and a valid room_id in its content.
|
||||
*
|
||||
* @param message - The received to-device message to check.
|
||||
* @returns True if the payload matches the RoomKeyBundleMessage structure, false otherwise.
|
||||
*/
|
||||
function isRoomKeyBundleMessage(message: IToDeviceEvent): message is IToDeviceEvent & RoomKeyBundleMessage {
|
||||
return message.type === "io.element.msc4268.room_key_bundle" && typeof message.content.room_id === "string";
|
||||
}
|
||||
|
||||
type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
|
||||
type RustCryptoEvents = Exclude<CryptoEvents, CryptoEvent.LegacyCryptoStoreMigrationProgress>;
|
||||
|
||||
+1
-3
@@ -23,8 +23,6 @@ limitations under the License.
|
||||
* for HTTP and WS at some point.
|
||||
*/
|
||||
|
||||
import { type Optional } from "matrix-events-sdk";
|
||||
|
||||
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend.ts";
|
||||
import { User } from "./models/user.ts";
|
||||
import { NotificationCountType, Room, RoomEvent } from "./models/room.ts";
|
||||
@@ -208,7 +206,7 @@ export class SyncApi {
|
||||
private readonly opts: IStoredClientOpts;
|
||||
private readonly syncOpts: SyncApiOptions;
|
||||
|
||||
private _peekRoom: Optional<Room> = null;
|
||||
private _peekRoom: Room | null = null;
|
||||
private currentSyncRequest?: Promise<ISyncResponse>;
|
||||
private abortController?: AbortController;
|
||||
private syncState: SyncState | null = null;
|
||||
|
||||
@@ -179,9 +179,7 @@ export async function decryptExistingEvent(
|
||||
type: opts.plainType,
|
||||
content: opts.plainContent,
|
||||
},
|
||||
forwardingCurve25519KeyChain: [],
|
||||
senderCurve25519Key: "",
|
||||
untrusted: false,
|
||||
};
|
||||
|
||||
const mockCrypto = {
|
||||
|
||||
@@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Optional } from "matrix-events-sdk";
|
||||
|
||||
import { type Direction, EventTimeline } from "./models/event-timeline.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import { type MatrixClient } from "./client.ts";
|
||||
@@ -105,7 +103,7 @@ export class TimelineWindow {
|
||||
public load(initialEventId?: string, initialWindowSize = 20): Promise<void> {
|
||||
// given an EventTimeline, find the event we were looking for, and initialise our
|
||||
// fields so that the event in question is in the middle of the window.
|
||||
const initFields = (timeline: Optional<EventTimeline>): void => {
|
||||
const initFields = (timeline: EventTimeline | null): void => {
|
||||
if (!timeline) {
|
||||
throw new Error("No timeline given to initFields");
|
||||
}
|
||||
|
||||
+1
-2
@@ -20,7 +20,6 @@ limitations under the License.
|
||||
|
||||
import unhomoglyph from "unhomoglyph";
|
||||
import promiseRetry from "p-retry";
|
||||
import { type Optional } from "matrix-events-sdk";
|
||||
|
||||
import { type IEvent, type MatrixEvent } from "./models/event.ts";
|
||||
import { M_TIMESTAMP } from "./@types/location.ts";
|
||||
@@ -115,7 +114,7 @@ export function decodeParams(query: string): Record<string, string | string[]> {
|
||||
* variables with. E.g. `{ "$bar": "baz" }`.
|
||||
* @returns The result of replacing all template variables e.g. '/foo/baz'.
|
||||
*/
|
||||
export function encodeUri(pathTemplate: string, variables: Record<string, Optional<string>>): string {
|
||||
export function encodeUri(pathTemplate: string, variables: Record<string, string | null | undefined>): string {
|
||||
for (const key in variables) {
|
||||
if (!variables.hasOwnProperty(key)) {
|
||||
continue;
|
||||
|
||||
+3
-3
@@ -3002,9 +3002,9 @@ export function supportsMatrixCall(): boolean {
|
||||
try {
|
||||
const supported = Boolean(
|
||||
window.RTCPeerConnection ??
|
||||
window.RTCSessionDescription ??
|
||||
window.RTCIceCandidate ??
|
||||
navigator.mediaDevices,
|
||||
window.RTCSessionDescription ??
|
||||
window.RTCIceCandidate ??
|
||||
navigator.mediaDevices,
|
||||
);
|
||||
if (!supported) {
|
||||
/* istanbul ignore if */ // Adds a lot of noise to test runs, so disable logging there.
|
||||
|
||||
+40
-23
@@ -264,8 +264,19 @@ export class MediaHandler extends TypedEventEmitter<
|
||||
}
|
||||
|
||||
if (!canReuseStream) {
|
||||
const constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo);
|
||||
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
let constraints: MediaStreamConstraints;
|
||||
try {
|
||||
// Not specifying exact for deviceId means switching devices does not always work,
|
||||
// try with exact and fallback to ideal if it fails
|
||||
constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo, true);
|
||||
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`MediaHandler getUserMediaStreamInternal() error (e=${e}), retrying without exact deviceId`,
|
||||
);
|
||||
constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo, false);
|
||||
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
}
|
||||
logger.log(
|
||||
`MediaHandler getUserMediaStreamInternal() calling getUserMediaStream (streamId=${
|
||||
stream.id
|
||||
@@ -435,30 +446,36 @@ export class MediaHandler extends TypedEventEmitter<
|
||||
this.emit(MediaHandlerEvent.LocalStreamsChanged);
|
||||
}
|
||||
|
||||
private getUserMediaContraints(audio: boolean, video: boolean): MediaStreamConstraints {
|
||||
private getUserMediaContraints(audio: boolean, video: boolean, exactDeviceId?: boolean): MediaStreamConstraints {
|
||||
const isWebkit = !!navigator.webkitGetUserMedia;
|
||||
const deviceIdKey = exactDeviceId ? "exact" : "ideal";
|
||||
|
||||
const audioConstraints: MediaTrackConstraints = {};
|
||||
if (this.audioInput) {
|
||||
audioConstraints.deviceId = { [deviceIdKey]: this.audioInput };
|
||||
}
|
||||
if (this.audioSettings) {
|
||||
audioConstraints.autoGainControl = { ideal: this.audioSettings.autoGainControl };
|
||||
audioConstraints.echoCancellation = { ideal: this.audioSettings.echoCancellation };
|
||||
audioConstraints.noiseSuppression = { ideal: this.audioSettings.noiseSuppression };
|
||||
}
|
||||
|
||||
const videoConstraints: MediaTrackConstraints = {
|
||||
/* We want 640x360. Chrome will give it only if we ask exactly,
|
||||
FF refuses entirely if we ask exactly, so have to ask for ideal
|
||||
instead
|
||||
XXX: Is this still true?
|
||||
*/
|
||||
width: isWebkit ? { exact: 640 } : { ideal: 640 },
|
||||
height: isWebkit ? { exact: 360 } : { ideal: 360 },
|
||||
};
|
||||
if (this.videoInput) {
|
||||
videoConstraints.deviceId = { [deviceIdKey]: this.videoInput };
|
||||
}
|
||||
|
||||
return {
|
||||
audio: audio
|
||||
? {
|
||||
deviceId: this.audioInput ? { ideal: this.audioInput } : undefined,
|
||||
autoGainControl: this.audioSettings ? { ideal: this.audioSettings.autoGainControl } : undefined,
|
||||
echoCancellation: this.audioSettings ? { ideal: this.audioSettings.echoCancellation } : undefined,
|
||||
noiseSuppression: this.audioSettings ? { ideal: this.audioSettings.noiseSuppression } : undefined,
|
||||
}
|
||||
: false,
|
||||
video: video
|
||||
? {
|
||||
deviceId: this.videoInput ? { ideal: this.videoInput } : undefined,
|
||||
/* We want 640x360. Chrome will give it only if we ask exactly,
|
||||
FF refuses entirely if we ask exactly, so have to ask for ideal
|
||||
instead
|
||||
XXX: Is this still true?
|
||||
*/
|
||||
width: isWebkit ? { exact: 640 } : { ideal: 640 },
|
||||
height: isWebkit ? { exact: 360 } : { ideal: 360 },
|
||||
}
|
||||
: false,
|
||||
audio: audio ? audioConstraints : false,
|
||||
video: video ? videoConstraints : false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user