Compare commits
107 Commits
v38.4.0
...
v40.0.0-rc.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 796135c7ce | |||
| 4cc4c01dd8 | |||
| 533b40922c | |||
| 0ae483ce27 | |||
| dbc1fa87ed | |||
| 0a3675b971 | |||
| ab3f529d29 | |||
| 607b712a07 | |||
| b6d9e49277 | |||
| 731d5943e2 | |||
| 01e7a43593 | |||
| b69a19ce7c | |||
| 8703acb533 | |||
| b59603d748 | |||
| b0cbe22f64 | |||
| 977d0322da | |||
| dd7394c14c | |||
| f2d082064e | |||
| 2731e20893 | |||
| 502a513b5b | |||
| 3ac47e71cd | |||
| 7c1e25e713 | |||
| 2d90ad95f1 | |||
| cd9794471f | |||
| b2d3ab8bc1 | |||
| d8b70ef83b | |||
| a67fb1fb8d | |||
| ddd6e77cde | |||
| 2e9f5b6033 | |||
| fd949fe486 | |||
| 7b3aed8a47 | |||
| b84a73c7cc | |||
| a03cf054a8 | |||
| b3d217717a | |||
| a213d177f9 | |||
| e885ecf08d | |||
| d1d9aba745 | |||
| 52bcc2c955 | |||
| 1994806c72 | |||
| c4d1fd2c67 | |||
| 7ad8288525 | |||
| 31a42964e6 | |||
| 2b1d37813c |
@@ -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 }}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
|
||||
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
|
||||
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
repository: ${{ matrix.repo }}
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check membership
|
||||
if: github.event.pull_request.user.login != 'renovate[bot]'
|
||||
if: github.event.pull_request.user.login != 'renovate[bot]' && github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
uses: tspascoal/get-user-teams-membership@57e9f42acd78f4d0f496b3be4368fc5f62696662 # v3
|
||||
id: teams
|
||||
with:
|
||||
@@ -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@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- 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@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- 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@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- 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@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
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@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- 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@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
steps:
|
||||
# We create the status here and then update it to success/failure in the `report` stage
|
||||
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
|
||||
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: pending
|
||||
@@ -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,19 +75,20 @@ 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 }}
|
||||
token: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
|
||||
if: always()
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -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@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- 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@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- 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@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- 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@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- 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@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- 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@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- 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@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- 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@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- 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@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
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: |
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
steps:
|
||||
- name: Skip SonarCloud on merge queues
|
||||
if: env.ENABLE_COVERAGE == 'false'
|
||||
uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
|
||||
with:
|
||||
operations-per-run: 250
|
||||
days-before-issue-stale: -1
|
||||
|
||||
@@ -1,3 +1,73 @@
|
||||
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
|
||||
|
||||
* [MatrixRTC] Sticky Events support (MSC4354) ([#5017](https://github.com/matrix-org/matrix-js-sdk/pull/5017)). Contributed by @toger5.
|
||||
* Add `CryptoApi.getSecretStorageStatus` ([#5054](https://github.com/matrix-org/matrix-js-sdk/pull/5054)). Contributed by @richvdh.
|
||||
* Add parseCallNotificationContent ([#5015](https://github.com/matrix-org/matrix-js-sdk/pull/5015)). Contributed by @toger5.
|
||||
* MSC4140: support filters on delayed event lookup ([#5038](https://github.com/matrix-org/matrix-js-sdk/pull/5038)). Contributed by @AndrewFerr.
|
||||
|
||||
|
||||
Changes in [39.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.0.0) (2025-10-21)
|
||||
==================================================================================================
|
||||
## 🚨 BREAKING CHANGES
|
||||
|
||||
* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5.
|
||||
* Implement Sticky Events MSC4354 ([#5028](https://github.com/matrix-org/matrix-js-sdk/pull/5028)). Contributed by @Half-Shot.
|
||||
* feat(client): allow disabling VoIP support ([#5021](https://github.com/matrix-org/matrix-js-sdk/pull/5021)). Contributed by @pkuzco.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Only use the first 3 viaServers specified ([#5034](https://github.com/matrix-org/matrix-js-sdk/pull/5034)). Contributed by @t3chguy.
|
||||
* Fetch the user's device info before processing a verification request ([#5030](https://github.com/matrix-org/matrix-js-sdk/pull/5030)). Contributed by @andybalaam.
|
||||
|
||||
|
||||
Changes in [38.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.4.0) (2025-10-07)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
+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
|
||||
) {
|
||||
// ...
|
||||
}
|
||||
|
||||
@@ -12,10 +12,6 @@ export default {
|
||||
"src/utils.ts", // not really an entrypoint but we have deprecated `defer` there
|
||||
"scripts/**",
|
||||
"spec/**",
|
||||
// XXX: these look entirely unused
|
||||
"src/crypto/aes.ts",
|
||||
"src/crypto/crypto.ts",
|
||||
"src/crypto/recoverykey.ts",
|
||||
// XXX: these should be re-exported by one of the supported exports
|
||||
"src/matrixrtc/index.ts",
|
||||
"src/sliding-sync.ts",
|
||||
@@ -38,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`
|
||||
|
||||
+15
-15
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "38.4.0",
|
||||
"version": "40.0.0-rc.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",
|
||||
@@ -97,25 +97,25 @@
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-import-resolver-typescript": "^4.0.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^28.0.0",
|
||||
"eslint-plugin-jsdoc": "^50.0.0",
|
||||
"eslint-plugin-matrix-org": "2.1.0",
|
||||
"eslint-plugin-jest": "^29.0.0",
|
||||
"eslint-plugin-jsdoc": "^61.0.0",
|
||||
"eslint-plugin-matrix-org": "^3.0.0",
|
||||
"eslint-plugin-n": "^14.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.4.0",
|
||||
"eslint-plugin-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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ import {
|
||||
type Verifier,
|
||||
VerifierEvent,
|
||||
} from "../../../src/crypto-api/verification";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { awaitDecryption, emitPromise, getSyncResponse, syncPromise } from "../../test-utils/test-utils";
|
||||
import { escapeRegExp, sleep } from "../../../src/utils";
|
||||
import { awaitDecryption, emitPromise, getSyncResponse, syncPromise, waitFor } from "../../test-utils/test-utils";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import {
|
||||
BACKUP_DECRYPTION_KEY_BASE64,
|
||||
@@ -79,11 +79,6 @@ import {
|
||||
import { type KeyBackupInfo, CryptoEvent } from "../../../src/crypto-api";
|
||||
import { encodeBase64 } from "../../../src/base64";
|
||||
|
||||
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
|
||||
// to ensure that we don't end up with dangling timeouts.
|
||||
// But the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
|
||||
beforeAll(async () => {
|
||||
// we use the libolm primitives in the test, so init the Olm library
|
||||
await Olm.init();
|
||||
@@ -96,6 +91,13 @@ beforeAll(async () => {
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
}, 10000);
|
||||
|
||||
beforeEach(() => {
|
||||
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
|
||||
// to ensure that we don't end up with dangling timeouts.
|
||||
// But the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
|
||||
@@ -1080,6 +1082,13 @@ describe("verification", () => {
|
||||
});
|
||||
|
||||
it("ignores old verification requests", async () => {
|
||||
const debug = jest.fn();
|
||||
const info = jest.fn();
|
||||
const warn = jest.fn();
|
||||
|
||||
// @ts-ignore overriding RustCrypto's logger
|
||||
aliceClient.getCrypto()!.logger = { debug, info, warn };
|
||||
|
||||
const eventHandler = jest.fn();
|
||||
aliceClient.on(CryptoEvent.VerificationRequestReceived, eventHandler);
|
||||
|
||||
@@ -1094,6 +1103,16 @@ describe("verification", () => {
|
||||
const matrixEvent = room.getLiveTimeline().getEvents()[0];
|
||||
expect(matrixEvent.getId()).toEqual(verificationRequestEvent.event_id);
|
||||
|
||||
// Wait until the request has been processed. We use a real sleep()
|
||||
// here to make sure any background async tasks are completed.
|
||||
jest.useRealTimers();
|
||||
await waitFor(async () => {
|
||||
expect(info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^Ignoring just-received verification request/),
|
||||
);
|
||||
sleep(100);
|
||||
});
|
||||
|
||||
// check that an event has not been raised, and that the request is not found
|
||||
expect(eventHandler).not.toHaveBeenCalled();
|
||||
expect(
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -1053,34 +1058,193 @@ describe("MatrixClient", function () {
|
||||
);
|
||||
});
|
||||
|
||||
it("can look up delayed events", async () => {
|
||||
describe("lookups", () => {
|
||||
const statuses = [undefined, "scheduled" as const, "finalised" as const];
|
||||
const delayIds = [undefined, "dxyz", ["d123"], ["d456", "d789"]];
|
||||
const inputs = statuses.flatMap((status) =>
|
||||
delayIds.map((delayId) => [status, delayId] as [(typeof statuses)[0], (typeof delayIds)[0]]),
|
||||
);
|
||||
it.each(inputs)("can look up delayed events (status = %s, delayId = %s)", async (status, delayId) => {
|
||||
httpLookups = [
|
||||
{
|
||||
method: "GET",
|
||||
prefix: unstableMSC4140Prefix,
|
||||
path: "/delayed_events",
|
||||
expectQueryParams: {
|
||||
status,
|
||||
delay_id: delayId,
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
];
|
||||
|
||||
await client._unstable_getDelayedEvents(status, delayId);
|
||||
});
|
||||
});
|
||||
|
||||
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";
|
||||
httpLookups = [
|
||||
{
|
||||
method: "GET",
|
||||
method: "POST",
|
||||
prefix: unstableMSC4140Prefix,
|
||||
path: "/delayed_events",
|
||||
data: [],
|
||||
path: `/delayed_events/${encodeURIComponent(delayId)}/cancel`,
|
||||
},
|
||||
];
|
||||
|
||||
await client._unstable_getDelayedEvents();
|
||||
await client._unstable_cancelScheduledDelayedEvent(delayId);
|
||||
});
|
||||
|
||||
it("can update delayed events", async () => {
|
||||
it("can restart 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)}/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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2366,6 +2530,61 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("disableVoip option", () => {
|
||||
const baseUrl = "https://alice-server.com";
|
||||
const userId = "@alice:bar";
|
||||
const accessToken = "sometoken";
|
||||
|
||||
beforeEach(() => {
|
||||
mocked(supportsMatrixCall).mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mocked(supportsMatrixCall).mockReset();
|
||||
});
|
||||
|
||||
it("should not call /voip/turnServer when disableVoip = true", () => {
|
||||
fetchMock.getOnce(`${baseUrl}/_matrix/client/unstable/voip/turnServer`, 200);
|
||||
|
||||
const client = createClient({
|
||||
baseUrl,
|
||||
accessToken,
|
||||
userId,
|
||||
disableVoip: true,
|
||||
});
|
||||
|
||||
// Only check createCall / supportsVoip, avoid startClient
|
||||
expect(client.createCall("!roomId:example.com")).toBeNull();
|
||||
expect(client.supportsVoip?.()).toBe(false);
|
||||
});
|
||||
|
||||
it("should call /voip/turnServer when disableVoip is not set", () => {
|
||||
fetchMock.getOnce(`${baseUrl}/_matrix/client/unstable/voip/turnServer`, {
|
||||
uris: ["turn:turn.example.org"],
|
||||
});
|
||||
|
||||
createClient({
|
||||
baseUrl,
|
||||
accessToken,
|
||||
userId,
|
||||
});
|
||||
|
||||
// The call will trigger the request if VoIP is supported
|
||||
expect(fetchMock.called(`${baseUrl}/_matrix/client/unstable/voip/turnServer`)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return null from createCall when disableVoip = true", () => {
|
||||
const client = createClient({
|
||||
baseUrl,
|
||||
accessToken,
|
||||
userId,
|
||||
disableVoip: true,
|
||||
});
|
||||
|
||||
expect(client.createCall("!roomId:example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("support for ignoring invites", () => {
|
||||
beforeEach(() => {
|
||||
// Mockup `getAccountData`/`setAccountData`.
|
||||
@@ -3717,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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CallMembership,
|
||||
type SessionMembershipData,
|
||||
DEFAULT_EXPIRE_DURATION,
|
||||
type RtcMembershipData,
|
||||
} from "../../../src/matrixrtc/CallMembership";
|
||||
import { membershipTemplate } from "./mocks";
|
||||
|
||||
@@ -26,9 +27,17 @@ function makeMockEvent(originTs = 0): MatrixEvent {
|
||||
return {
|
||||
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(() => {
|
||||
@@ -40,39 +49,40 @@ describe("CallMembership", () => {
|
||||
});
|
||||
|
||||
const membershipTemplate: SessionMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
focus_active: { type: "livekit" },
|
||||
foci_preferred: [{ type: "livekit" }],
|
||||
"call_id": "",
|
||||
"scope": "m.room",
|
||||
"application": "m.call",
|
||||
"device_id": "AAAAAAA",
|
||||
"focus_active": { type: "livekit", focus_selection: "oldest_membership" },
|
||||
"foci_preferred": [{ type: "livekit" }],
|
||||
"m.call.intent": "voice",
|
||||
};
|
||||
|
||||
it("rejects membership with no device_id", () => {
|
||||
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 }),
|
||||
);
|
||||
@@ -82,23 +92,283 @@ 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,
|
||||
Object.assign({}, membershipTemplate, { foci_preferred: [mockFocus] }),
|
||||
);
|
||||
expect(membership.getPreferredFoci()).toEqual([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 = createCallMembership(makeMockEvent(), membershipTemplate);
|
||||
it("gets the correct active transport with oldest_membership", () => {
|
||||
const membership = createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
foci_preferred: [mockFocus],
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
});
|
||||
|
||||
// if we are the oldest member we use our focus.
|
||||
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
|
||||
|
||||
// If there is an older member we use its focus.
|
||||
expect(membership.getTransport(oldestMembership)).toBe(membershipTemplate.foci_preferred[0]);
|
||||
});
|
||||
|
||||
it("gets the correct active transport with multi_sfu", () => {
|
||||
const membership = createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
foci_preferred: [mockFocus],
|
||||
focus_active: { type: "livekit", focus_selection: "multi_sfu" },
|
||||
});
|
||||
|
||||
// if we are the oldest member we use our focus.
|
||||
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
|
||||
|
||||
// If there is an older member we still use our own focus in multi sfu.
|
||||
expect(membership.getTransport(oldestMembership)).toBe(mockFocus);
|
||||
});
|
||||
it("does not provide focus if the selection method is unknown", () => {
|
||||
const membership = createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
foci_preferred: [mockFocus],
|
||||
focus_active: { type: "livekit", focus_selection: "unknown" },
|
||||
});
|
||||
|
||||
// if we are the oldest member we use our focus.
|
||||
expect(membership.getTransport(membership)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe("correct values from computed fields", () => {
|
||||
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
|
||||
it("returns correct sender", () => {
|
||||
expect(membership.sender).toBe("@alice:example.org");
|
||||
});
|
||||
it("returns correct eventId", () => {
|
||||
expect(membership.eventId).toBe("$eventid");
|
||||
});
|
||||
it("returns correct slot_id", () => {
|
||||
expect(membership.slotId).toBe("m.call#");
|
||||
expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" });
|
||||
});
|
||||
it("returns correct deviceId", () => {
|
||||
expect(membership.deviceId).toBe("AAAAAAA");
|
||||
});
|
||||
it("returns correct call intent", () => {
|
||||
expect(membership.callIntent).toBe("voice");
|
||||
});
|
||||
it("returns correct application", () => {
|
||||
expect(membership.application).toStrictEqual("m.call");
|
||||
});
|
||||
it("returns correct applicationData", () => {
|
||||
expect(membership.applicationData).toStrictEqual({ "type": "m.call", "m.call.intent": "voice" });
|
||||
});
|
||||
it("returns correct scope", () => {
|
||||
expect(membership.scope).toBe("m.room");
|
||||
});
|
||||
it("returns correct membershipID", () => {
|
||||
expect(membership.membershipID).toBe("@alice:example.org:AAAAAAA");
|
||||
});
|
||||
it("returns correct unused fields", () => {
|
||||
expect(membership.getAbsoluteExpiry()).toBe(DEFAULT_EXPIRE_DURATION);
|
||||
expect(membership.getMsUntilExpiry()).toBe(DEFAULT_EXPIRE_DURATION - Date.now());
|
||||
expect(membership.isExpired()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("RtcMembershipData", () => {
|
||||
const membershipTemplate: RtcMembershipData = {
|
||||
slot_id: "m.call#",
|
||||
application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" },
|
||||
member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" },
|
||||
rtc_transports: [{ type: "livekit" }],
|
||||
versions: [],
|
||||
msc4354_sticky_key: "abc123",
|
||||
};
|
||||
|
||||
it("rejects membership with no slot_id", () => {
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined });
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects membership with invalid slot_id", () => {
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
|
||||
}).toThrow();
|
||||
});
|
||||
it("accepts membership with valid slot_id", () => {
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with no application", () => {
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with incorrect application", () => {
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
application: { wrong_type_key: "unknown" },
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with no member", () => {
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with incorrect member", () => {
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } });
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
member: { id: "test", device_id: "test", user_id_wrong: "test" },
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" },
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
member: { id: "test", device_id: "test", user_id: "@@test" },
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
member: { id: "test", device_id: "test", user_id: "@test-wrong-user:user.id" },
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects membership with incorrect sticky_key", () => {
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), membershipTemplate);
|
||||
}).not.toThrow();
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
sticky_key: 1,
|
||||
msc4354_sticky_key: undefined,
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
sticky_key: "1",
|
||||
msc4354_sticky_key: undefined,
|
||||
});
|
||||
}).not.toThrow();
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined });
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
msc4354_sticky_key: 1,
|
||||
sticky_key: "valid",
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
msc4354_sticky_key: "valid",
|
||||
sticky_key: "valid",
|
||||
});
|
||||
}).not.toThrow();
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
msc4354_sticky_key: "valid_but_different",
|
||||
sticky_key: "valid",
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("considers memberships unexpired if local age low enough", () => {
|
||||
// TODO link prev event
|
||||
});
|
||||
|
||||
it("considers memberships expired if local age large enough", () => {
|
||||
// TODO link prev event
|
||||
});
|
||||
|
||||
describe("getTransport", () => {
|
||||
it("gets the correct active transport with oldest_membership", () => {
|
||||
const oldestMembership = createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
rtc_transports: [{ type: "oldest_transport" }],
|
||||
});
|
||||
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
|
||||
|
||||
// if we are the oldest member we use our focus.
|
||||
expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" });
|
||||
|
||||
// If there is an older member we use our own focus focus. (RtcMembershipData always uses multi sfu)
|
||||
expect(membership.getTransport(oldestMembership)).toStrictEqual({ type: "livekit" });
|
||||
});
|
||||
});
|
||||
describe("correct values from computed fields", () => {
|
||||
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
|
||||
it("returns correct sender", () => {
|
||||
expect(membership.sender).toBe("@alice:example.org");
|
||||
});
|
||||
it("returns correct eventId", () => {
|
||||
expect(membership.eventId).toBe("$eventid");
|
||||
});
|
||||
it("returns correct slot_id", () => {
|
||||
expect(membership.slotId).toBe("m.call#");
|
||||
expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" });
|
||||
});
|
||||
it("returns correct deviceId", () => {
|
||||
expect(membership.deviceId).toBe("AAAAAAA");
|
||||
});
|
||||
it("returns correct call intent", () => {
|
||||
expect(membership.callIntent).toBe("voice");
|
||||
});
|
||||
it("returns correct application", () => {
|
||||
expect(membership.application).toStrictEqual("m.call");
|
||||
});
|
||||
it("returns correct applicationData", () => {
|
||||
expect(membership.applicationData).toStrictEqual({
|
||||
"type": "m.call",
|
||||
"m.call.id": "",
|
||||
"m.call.intent": "voice",
|
||||
});
|
||||
});
|
||||
it("returns correct scope", () => {
|
||||
expect(membership.scope).toBe(undefined);
|
||||
});
|
||||
it("returns correct membershipID", () => {
|
||||
expect(membership.membershipID).toBe("xyzHASHxyz");
|
||||
});
|
||||
it("returns correct unused fields", () => {
|
||||
expect(membership.getAbsoluteExpiry()).toBe(undefined);
|
||||
expect(membership.getMsUntilExpiry()).toBe(undefined);
|
||||
expect(membership.isExpired()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,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();
|
||||
});
|
||||
|
||||
+17
-13
@@ -14,47 +14,51 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus";
|
||||
import {
|
||||
isLivekitTransport,
|
||||
isLivekitFocusSelection,
|
||||
isLivekitTransportConfig,
|
||||
} from "../../../src/matrixrtc/LivekitTransport";
|
||||
|
||||
describe("LivekitFocus", () => {
|
||||
it("isLivekitFocus", () => {
|
||||
expect(
|
||||
isLivekitFocus({
|
||||
isLivekitTransport({
|
||||
type: "livekit",
|
||||
livekit_service_url: "http://test.com",
|
||||
livekit_alias: "test",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(isLivekitFocus({ type: "livekit" })).toBeFalsy();
|
||||
expect(isLivekitTransport({ type: "livekit" })).toBeFalsy();
|
||||
expect(
|
||||
isLivekitFocus({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
|
||||
isLivekitTransport({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
isLivekitFocus({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
|
||||
isLivekitTransport({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
isLivekitFocus({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
|
||||
isLivekitTransport({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
|
||||
).toBeFalsy();
|
||||
});
|
||||
it("isLivekitFocusActive", () => {
|
||||
expect(
|
||||
isLivekitFocusActive({
|
||||
isLivekitFocusSelection({
|
||||
type: "livekit",
|
||||
focus_selection: "oldest_membership",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(isLivekitFocusActive({ type: "livekit" })).toBeFalsy();
|
||||
expect(isLivekitFocusActive({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
|
||||
expect(isLivekitFocusSelection({ type: "livekit" })).toBeFalsy();
|
||||
expect(isLivekitFocusSelection({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
|
||||
});
|
||||
it("isLivekitFocusConfig", () => {
|
||||
expect(
|
||||
isLivekitFocusConfig({
|
||||
isLivekitTransportConfig({
|
||||
type: "livekit",
|
||||
livekit_service_url: "http://test.com",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(isLivekitFocusConfig({ type: "livekit" })).toBeFalsy();
|
||||
expect(isLivekitFocusConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
|
||||
expect(isLivekitFocusConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
|
||||
expect(isLivekitTransportConfig({ type: "livekit" })).toBeFalsy();
|
||||
expect(isLivekitTransportConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
|
||||
expect(isLivekitTransportConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,136 +14,149 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
|
||||
import { RoomStateEvent } from "../../../src/models/room-state";
|
||||
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks";
|
||||
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("MatrixRTCSessionManager", () => {
|
||||
let client: MatrixClient;
|
||||
describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
"MatrixRTCSessionManager ($eventKind)",
|
||||
({ eventKind }) => {
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.matrixRTC.start();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
});
|
||||
|
||||
it("Fires event when session starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
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)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
} else {
|
||||
membershipData.splice(0, 1, { user_id: membershipTemplate.user_id });
|
||||
client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000));
|
||||
}
|
||||
await flushPromises();
|
||||
}
|
||||
});
|
||||
|
||||
it("Doesn't fire event if unrelated sessions starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
beforeEach(() => {
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.matrixRTC.start();
|
||||
});
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
});
|
||||
|
||||
it("Fires event when session starts", async () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
await flushPromises();
|
||||
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
}
|
||||
});
|
||||
|
||||
it("Doesn't fire event if unrelated sessions starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }], eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
}
|
||||
});
|
||||
|
||||
it("Fires event when session ends", async () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const membershipData: MembershipData[] = [membershipTemplate];
|
||||
const room1 = makeMockRoom(membershipData, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
}
|
||||
});
|
||||
await flushPromises();
|
||||
await sendLeaveMembership(room1, membershipData);
|
||||
|
||||
it("Fires event when session ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
it("Fires correctly with custom sessionDescription", async () => {
|
||||
const onStarted = jest.fn();
|
||||
const onEnded = jest.fn();
|
||||
// create a session manager with a custom session description
|
||||
const sessionManager = new MatrixRTCSessionManager(logger, client, {
|
||||
id: "test",
|
||||
application: "m.notCall",
|
||||
});
|
||||
|
||||
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
|
||||
// manually start the session manager (its not the default one started by the client)
|
||||
sessionManager.start();
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
try {
|
||||
// Create a session for applicaation m.other, we ignore this session ecause it lacks a call_id
|
||||
const room1MembershipData: MembershipData[] = [{ ...membershipTemplate, application: "m.other" }];
|
||||
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
await flushPromises();
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
// Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id
|
||||
const room2MembershipData: MembershipData[] = [
|
||||
{ ...membershipTemplate, application: "m.notCall", call_id: "test" },
|
||||
];
|
||||
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
|
||||
client.emit(ClientEvent.Room, room2);
|
||||
await flushPromises();
|
||||
expect(onStarted).toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
it("Fires correctly with for with custom sessionDescription", () => {
|
||||
const onStarted = jest.fn();
|
||||
const onEnded = jest.fn();
|
||||
// create a session manager with a custom session description
|
||||
const sessionManager = new MatrixRTCSessionManager(logger, client, { id: "test", application: "m.notCall" });
|
||||
// Stop room1's RTC session. Tracked.
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room2);
|
||||
await sendLeaveMembership(room2, room2MembershipData);
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
onEnded.mockClear();
|
||||
|
||||
// manually start the session manager (its not the default one started by the client)
|
||||
sessionManager.start();
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
// Stop room1's RTC session. Not tracked.
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
await sendLeaveMembership(room1, room1MembershipData);
|
||||
expect(onEnded).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
|
||||
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" }];
|
||||
const room1 = makeMockRoom(membership, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
const room2 = makeMockRoom([{ ...membershipTemplate, application: "m.notCall", call_id: "test" }]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
|
||||
|
||||
client.emit(ClientEvent.Room, room2);
|
||||
expect(onStarted).toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
mockRoomState(room2, [{ user_id: membershipTemplate.user_id }]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room2);
|
||||
|
||||
const roomState = room2.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
onEnded.mockClear();
|
||||
|
||||
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
const roomStateOther = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEventOther = roomStateOther.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEventOther, roomStateOther, null);
|
||||
expect(onEnded).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
}
|
||||
});
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
|
||||
it("Doesn't fire event if unrelated sessions ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other_app" }]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
await sendLeaveMembership(room1, membership);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
|
||||
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
|
||||
|
||||
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
|
||||
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
});
|
||||
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -23,17 +23,17 @@ import {
|
||||
MatrixError,
|
||||
UnsupportedDelayedEventsEndpointError,
|
||||
type Room,
|
||||
MAX_STICKY_DURATION_MS,
|
||||
} from "../../../src";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
Status,
|
||||
type Focus,
|
||||
type LivekitFocusActive,
|
||||
type Transport,
|
||||
type SessionMembershipData,
|
||||
type LivekitFocusSelection,
|
||||
} from "../../../src/matrixrtc";
|
||||
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
|
||||
import { MembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
|
||||
|
||||
/**
|
||||
* Create a promise that will resolve once a mocked method is called.
|
||||
@@ -76,11 +76,11 @@ const callSession = { id: "", application: "m.call" };
|
||||
describe("MembershipManager", () => {
|
||||
let client: MockClient;
|
||||
let room: Room;
|
||||
const focusActive: LivekitFocusActive = {
|
||||
const focusActive: LivekitFocusSelection = {
|
||||
focus_selection: "oldest_membership",
|
||||
type: "livekit",
|
||||
};
|
||||
const focus: Focus = {
|
||||
const focus: Transport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://active.url",
|
||||
livekit_alias: "!active:active.url",
|
||||
@@ -94,7 +94,12 @@ describe("MembershipManager", () => {
|
||||
// Provide a default mock that is like the default "non error" server behaviour.
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
(client.sendStateEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
(client._unstable_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" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -104,12 +109,12 @@ describe("MembershipManager", () => {
|
||||
|
||||
describe("isActivated()", () => {
|
||||
it("defaults to false", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
expect(manager.isActivated()).toEqual(false);
|
||||
});
|
||||
|
||||
it("returns true after join()", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([]);
|
||||
expect(manager.isActivated()).toEqual(true);
|
||||
});
|
||||
@@ -120,11 +125,13 @@ 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, () => undefined, callSession);
|
||||
memberManager.join([focus], focusActive);
|
||||
const memberManager = new MembershipManager(undefined, room, client, callSession);
|
||||
memberManager.join([focus], undefined);
|
||||
// expects
|
||||
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
@@ -136,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 },
|
||||
@@ -153,15 +161,15 @@ describe("MembershipManager", () => {
|
||||
});
|
||||
|
||||
it("reschedules delayed leave event if sending state cancels it", async () => {
|
||||
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
|
||||
const memberManager = new MembershipManager(undefined, room, client, callSession);
|
||||
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
||||
const waitForUpdateDelaye = waitForMockCallOnce(
|
||||
client._unstable_updateDelayedEvent,
|
||||
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)
|
||||
@@ -177,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({
|
||||
@@ -228,10 +236,9 @@ describe("MembershipManager", () => {
|
||||
},
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
manager.join([focus]);
|
||||
|
||||
await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches
|
||||
await sendDelayedStateAttempt;
|
||||
@@ -257,6 +264,7 @@ describe("MembershipManager", () => {
|
||||
expires: 14400000,
|
||||
device_id: "AAAAAAA",
|
||||
foci_preferred: [focus],
|
||||
membershipID: "@alice:example.org:AAAAAAA",
|
||||
focus_active: focusActive,
|
||||
} satisfies SessionMembershipData,
|
||||
userStateKey,
|
||||
@@ -264,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 () => {
|
||||
@@ -286,8 +294,8 @@ describe("MembershipManager", () => {
|
||||
describe("delayed leave event", () => {
|
||||
it("does not try again to schedule a delayed leave event if not supported", () => {
|
||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus]);
|
||||
delayedHandle.reject?.(
|
||||
new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
@@ -298,21 +306,15 @@ describe("MembershipManager", () => {
|
||||
});
|
||||
it("does try to schedule a delayed leave event again if rate limited", async () => {
|
||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus]);
|
||||
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("uses delayedLeaveEventDelayMs from config", () => {
|
||||
const manager = new MembershipManager(
|
||||
{ delayedLeaveEventDelayMs: 123456 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
const manager = new MembershipManager({ delayedLeaveEventDelayMs: 123456 }, room, client, callSession);
|
||||
manager.join([focus]);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
{ delay: 123456 },
|
||||
@@ -329,11 +331,11 @@ describe("MembershipManager", () => {
|
||||
{ delayedLeaveEventRestartMs: RESTART_DELAY },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
|
||||
callSession,
|
||||
);
|
||||
// Join with the membership manager
|
||||
manager.join([focus], focusActive);
|
||||
manager.join([focus]);
|
||||
expect(manager.status).toBe(Status.Connecting);
|
||||
// Let the scheduler run one iteration so that we can send the join state event
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
@@ -348,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" }),
|
||||
);
|
||||
|
||||
@@ -367,11 +369,11 @@ describe("MembershipManager", () => {
|
||||
{ membershipEventExpiryMs: 1234567 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
|
||||
callSession,
|
||||
);
|
||||
|
||||
manager.join([focus], focusActive);
|
||||
manager.join([focus]);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
@@ -383,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",
|
||||
@@ -393,11 +396,11 @@ describe("MembershipManager", () => {
|
||||
});
|
||||
|
||||
it("does nothing if join called when already joined", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus]);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
manager.join([focus], focusActive);
|
||||
manager.join([focus]);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -405,21 +408,21 @@ describe("MembershipManager", () => {
|
||||
describe("leave()", () => {
|
||||
// TODO add rate limit cases.
|
||||
it("resolves delayed leave event when leave is called", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus]);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
await manager.leave();
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send");
|
||||
expect(client._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, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus]);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
|
||||
(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",
|
||||
@@ -428,74 +431,36 @@ describe("MembershipManager", () => {
|
||||
);
|
||||
});
|
||||
it("does nothing if not joined", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
expect(async () => await manager.leave()).not.toThrow();
|
||||
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getsActiveFocus", () => {
|
||||
it("gets the correct active focus with oldest_membership", () => {
|
||||
const getOldestMembership = jest.fn();
|
||||
const manager = new MembershipManager({}, room, client, getOldestMembership, callSession);
|
||||
// Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession)
|
||||
expect(manager.getActiveFocus()).toBe(undefined);
|
||||
manager.join([focus], focusActive);
|
||||
// After joining we want our own focus to be the one we select.
|
||||
getOldestMembership.mockReturnValue(
|
||||
mockCallMembership(
|
||||
{
|
||||
...membershipTemplate,
|
||||
foci_preferred: [
|
||||
{
|
||||
livekit_alias: "!active:active.url",
|
||||
livekit_service_url: "https://active.url",
|
||||
type: "livekit",
|
||||
},
|
||||
],
|
||||
user_id: client.getUserId()!,
|
||||
device_id: client.getDeviceId()!,
|
||||
created_ts: 1000,
|
||||
},
|
||||
room.roomId,
|
||||
),
|
||||
);
|
||||
expect(manager.getActiveFocus()).toStrictEqual(focus);
|
||||
getOldestMembership.mockReturnValue(
|
||||
mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
|
||||
room.roomId,
|
||||
),
|
||||
);
|
||||
// If there is an older member we use its focus.
|
||||
expect(manager.getActiveFocus()).toBe(membershipTemplate.foci_preferred[0]);
|
||||
});
|
||||
|
||||
it("does not provide focus if the selection method is unknown", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], Object.assign(focusActive, { type: "unknown_type" }));
|
||||
expect(manager.getActiveFocus()).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onRTCSessionMemberUpdate()", () => {
|
||||
it("does nothing if not joined", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
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, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
|
||||
// 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([
|
||||
@@ -511,14 +476,17 @@ 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, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
(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:
|
||||
@@ -527,19 +495,19 @@ 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 () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
(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" }),
|
||||
);
|
||||
|
||||
@@ -552,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);
|
||||
});
|
||||
});
|
||||
@@ -564,7 +532,6 @@ describe("MembershipManager", () => {
|
||||
{ delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
{ id: "", application: "m.call" },
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
@@ -573,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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -596,7 +563,7 @@ describe("MembershipManager", () => {
|
||||
{ membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
|
||||
{ id: "", application: "m.call" },
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
@@ -621,14 +588,14 @@ describe("MembershipManager", () => {
|
||||
|
||||
describe("status updates", () => {
|
||||
it("starts 'Disconnected'", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
});
|
||||
it("emits 'Connection' and 'Connected' after join", async () => {
|
||||
const handleDelayedEvent = createAsyncHandle<void>(client._unstable_sendDelayedStateEvent);
|
||||
const handleStateEvent = createAsyncHandle<void>(client.sendStateEvent);
|
||||
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
@@ -642,7 +609,7 @@ describe("MembershipManager", () => {
|
||||
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
|
||||
});
|
||||
it("emits 'Disconnecting' and 'Disconnected' after leave", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
manager.join([focus], focusActive);
|
||||
@@ -658,7 +625,7 @@ describe("MembershipManager", () => {
|
||||
it("sends retry if call membership event is still valid at time of retry", async () => {
|
||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -685,7 +652,7 @@ describe("MembershipManager", () => {
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
|
||||
// RateLimit error.
|
||||
manager.join([focus], focusActive);
|
||||
@@ -705,7 +672,7 @@ describe("MembershipManager", () => {
|
||||
it("abandons retry loop if leave() was called before sending state event", async () => {
|
||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
handle.reject?.(
|
||||
new MatrixError(
|
||||
@@ -731,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,
|
||||
@@ -740,22 +707,22 @@ describe("MembershipManager", () => {
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
|
||||
// Hit rate limit
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -773,7 +740,7 @@ describe("MembershipManager", () => {
|
||||
new Headers({ "Retry-After": "2" }),
|
||||
),
|
||||
);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive, delayEventSendError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -784,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,
|
||||
@@ -793,7 +760,7 @@ describe("MembershipManager", () => {
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive, delayEventRestartError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -804,7 +771,7 @@ describe("MembershipManager", () => {
|
||||
it("falls back to using pure state events when some error occurs while sending delayed events", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(unrecoverableError).not.toHaveBeenCalledWith();
|
||||
@@ -817,7 +784,6 @@ describe("MembershipManager", () => {
|
||||
{ networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
@@ -836,7 +802,7 @@ describe("MembershipManager", () => {
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
|
||||
);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
@@ -850,7 +816,7 @@ describe("MembershipManager", () => {
|
||||
{ delayedLeaveEventDelayMs: 10000 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
|
||||
callSession,
|
||||
);
|
||||
const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers<EmptyObject>();
|
||||
@@ -859,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);
|
||||
@@ -873,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
|
||||
@@ -904,7 +870,7 @@ describe("MembershipManager", () => {
|
||||
|
||||
describe("updateCallIntent()", () => {
|
||||
it("should fail if the user has not joined the call", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
// After joining we want our own focus to be the one we select.
|
||||
try {
|
||||
await manager.updateCallIntent("video");
|
||||
@@ -913,7 +879,7 @@ describe("MembershipManager", () => {
|
||||
});
|
||||
|
||||
it("can adjust the intent", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([]);
|
||||
expect(manager.isActivated()).toEqual(true);
|
||||
const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId);
|
||||
@@ -926,7 +892,7 @@ describe("MembershipManager", () => {
|
||||
});
|
||||
|
||||
it("does nothing if the intent doesn't change", async () => {
|
||||
const manager = new MembershipManager({ callIntent: "video" }, room, client, () => undefined, callSession);
|
||||
const manager = new MembershipManager({ callIntent: "video" }, room, client, callSession);
|
||||
manager.join([]);
|
||||
expect(manager.isActivated()).toEqual(true);
|
||||
const membership = mockCallMembership(
|
||||
@@ -938,13 +904,76 @@ describe("MembershipManager", () => {
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StickyEventMembershipManager", () => {
|
||||
beforeEach(() => {
|
||||
// Provide a default mock that is like the default "non error" server behaviour.
|
||||
(client._unstable_sendStickyDelayedEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
|
||||
(client._unstable_sendStickyEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("join()", () => {
|
||||
describe("sends an rtc membership event", () => {
|
||||
it("sends a membership event and schedules delayed leave when joining a call", async () => {
|
||||
const restartScheduledDelayedEventHandle = createAsyncHandle<void>(
|
||||
client._unstable_restartScheduledDelayedEvent as Mock,
|
||||
);
|
||||
const memberManager = new StickyEventMembershipManager(
|
||||
undefined,
|
||||
room,
|
||||
client,
|
||||
callSession,
|
||||
"@alice:example.org:AAAAAAA_m.call",
|
||||
);
|
||||
|
||||
memberManager.join([], focus);
|
||||
|
||||
await waitForMockCall(client._unstable_sendStickyEvent, Promise.resolve({ event_id: "id" }));
|
||||
// Test we sent the initial join
|
||||
expect(client._unstable_sendStickyEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
3600000,
|
||||
null,
|
||||
"org.matrix.msc4143.rtc.member",
|
||||
{
|
||||
application: { type: "m.call" },
|
||||
member: {
|
||||
user_id: "@alice:example.org",
|
||||
id: "@alice:example.org:AAAAAAA_m.call",
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
slot_id: "m.call#",
|
||||
rtc_transports: [focus],
|
||||
versions: [],
|
||||
msc4354_sticky_key: "@alice:example.org:AAAAAAA_m.call",
|
||||
},
|
||||
);
|
||||
restartScheduledDelayedEventHandle.resolve?.();
|
||||
|
||||
// Ensure we have sent the delayed disconnect event.
|
||||
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
MAX_STICKY_DURATION_MS,
|
||||
{ delay: 8000 },
|
||||
null,
|
||||
"org.matrix.msc4143.rtc.member",
|
||||
{
|
||||
msc4354_sticky_key: "@alice:example.org:AAAAAAA_m.call",
|
||||
},
|
||||
);
|
||||
// ..once
|
||||
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Should prefix log with MembershipManager used", () => {
|
||||
const client = makeMockClient("@alice:example.org", "AAAAAAA");
|
||||
const room = makeMockRoom([membershipTemplate]);
|
||||
|
||||
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, callSession, logger);
|
||||
const membershipManager = new MembershipManager(undefined, room, client, callSession);
|
||||
|
||||
const spy = jest.spyOn(console, "error");
|
||||
// Double join
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,10 +22,9 @@ import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTra
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
|
||||
import { decodeBase64, TypedEventEmitter } from "../../../src";
|
||||
import { RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
|
||||
import { type RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
|
||||
import { logger, type Logger } from "../../../src/logger.ts";
|
||||
import { getParticipantId } from "../../../src/matrixrtc/utils.ts";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
import { getEncryptionKeyMapKey } from "../../../src/matrixrtc/EncryptionManager.ts";
|
||||
import { flushPromises } from "../../test-utils/flushPromises.ts";
|
||||
|
||||
describe("RTCEncryptionManager", () => {
|
||||
// The manager being tested
|
||||
@@ -34,6 +33,7 @@ describe("RTCEncryptionManager", () => {
|
||||
let mockTransport: Mocked<ToDeviceKeyTransport>;
|
||||
let statistics: Statistics;
|
||||
let onEncryptionKeysChanged: jest.Mock;
|
||||
let rtcIdentifierProvider: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
statistics = {
|
||||
@@ -55,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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -78,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();
|
||||
});
|
||||
@@ -92,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(
|
||||
@@ -114,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(
|
||||
@@ -145,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?)
|
||||
@@ -172,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);
|
||||
|
||||
@@ -194,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();
|
||||
|
||||
@@ -222,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);
|
||||
|
||||
@@ -244,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();
|
||||
|
||||
@@ -274,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);
|
||||
|
||||
@@ -289,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(
|
||||
@@ -316,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);
|
||||
|
||||
@@ -330,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) {
|
||||
@@ -366,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);
|
||||
|
||||
@@ -396,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);
|
||||
|
||||
@@ -419,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);
|
||||
|
||||
@@ -443,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",
|
||||
);
|
||||
|
||||
@@ -452,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);
|
||||
|
||||
@@ -479,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,
|
||||
@@ -491,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 });
|
||||
@@ -500,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 */,
|
||||
@@ -515,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);
|
||||
|
||||
@@ -527,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 */,
|
||||
@@ -554,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);
|
||||
@@ -575,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
|
||||
@@ -583,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,
|
||||
@@ -599,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,
|
||||
@@ -613,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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -621,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);
|
||||
|
||||
@@ -635,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,
|
||||
@@ -644,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,
|
||||
@@ -653,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,
|
||||
@@ -663,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(
|
||||
@@ -673,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(
|
||||
@@ -689,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(
|
||||
@@ -699,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();
|
||||
});
|
||||
});
|
||||
@@ -708,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);
|
||||
|
||||
@@ -723,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
|
||||
@@ -736,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
|
||||
@@ -744,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();
|
||||
@@ -759,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",
|
||||
);
|
||||
|
||||
@@ -782,90 +853,153 @@ describe("RTCEncryptionManager", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Should re-distribute key on transport switch", async () => {
|
||||
const toDeviceEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
const mockToDeviceTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: toDeviceEmitter.on.bind(toDeviceEmitter),
|
||||
off: toDeviceEmitter.off.bind(toDeviceEmitter),
|
||||
emit: toDeviceEmitter.emit.bind(toDeviceEmitter),
|
||||
setParentLogger: jest.fn(),
|
||||
} as unknown as Mocked<ToDeviceKeyTransport>;
|
||||
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();
|
||||
|
||||
const roomEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
const mockRoomTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: roomEmitter.on.bind(roomEmitter),
|
||||
off: roomEmitter.off.bind(roomEmitter),
|
||||
emit: roomEmitter.emit.bind(roomEmitter),
|
||||
setParentLogger: jest.fn(),
|
||||
} as unknown as Mocked<RoomKeyTransport>;
|
||||
await flushPromises();
|
||||
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
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>",
|
||||
);
|
||||
});
|
||||
|
||||
const transport = new RoomAndToDeviceTransport(mockToDeviceTransport, mockRoomTransport, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
it("Should use legacy participant id if not using sticky event", async () => {
|
||||
getMembershipMock.mockReturnValue([]);
|
||||
encryptionManager.join({
|
||||
manageMediaKeys: true,
|
||||
unstableSendStickyEvents: false,
|
||||
});
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
"@alice:example.org",
|
||||
"DEVICE01",
|
||||
getMembershipMock,
|
||||
transport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
0,
|
||||
{
|
||||
deviceId: "DEVICE01",
|
||||
userId: "@alice:example.org",
|
||||
memberId: "@alice:example.org:DEVICE01",
|
||||
},
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
});
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
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>;
|
||||
|
||||
// Should have sent the key to the toDevice transport
|
||||
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockRoomTransport.sendKey).not.toHaveBeenCalled();
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
|
||||
getMembershipMock,
|
||||
mockTransport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
logger,
|
||||
rtcIdentifierProvider,
|
||||
);
|
||||
|
||||
// Simulate receiving a key by room transport
|
||||
roomEmitter.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE",
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
getMembershipMock.mockReturnValue([]);
|
||||
encryptionManager.join({
|
||||
manageMediaKeys: true,
|
||||
unstableSendStickyEvents: true,
|
||||
});
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await flushPromises();
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
// 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 */,
|
||||
);
|
||||
|
||||
// The key should have been re-distributed to the room transport
|
||||
expect(mockRoomTransport.sendKey).toHaveBeenCalled();
|
||||
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key re-distributed
|
||||
0,
|
||||
// to all the members
|
||||
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
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): CallMembership {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -882,6 +1016,7 @@ function expectKeyAtIndexToHaveBeenSentTo(
|
||||
expect.arrayContaining([expect.objectContaining({ userId, deviceId })]),
|
||||
);
|
||||
}
|
||||
|
||||
function expectKeyAtIndexNotToHaveBeenSentTo(
|
||||
mockTransport: Mocked<ToDeviceKeyTransport>,
|
||||
index: number,
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Mocked } from "jest-mock";
|
||||
|
||||
import { makeKey, makeMockEvent, makeMockRoom } from "./mocks";
|
||||
import { EventType, type IRoomTimelineData, type Room, RoomEvent, type MatrixClient } from "../../../src";
|
||||
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import {
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsEvents,
|
||||
mockClientMethodsUser,
|
||||
} from "../../test-utils/client.ts";
|
||||
import { type ParticipantDeviceInfo, type Statistics } from "../../../src/matrixrtc";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { type Logger } from "../../../src/logger.ts";
|
||||
import { RoomAndToDeviceEvents, RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
|
||||
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
|
||||
|
||||
describe("RoomAndToDeviceTransport", () => {
|
||||
const roomId = "!room:id";
|
||||
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
let statistics: Statistics;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
let transport: RoomAndToDeviceTransport;
|
||||
let mockRoom: Room;
|
||||
let sendEventMock: jest.Mock;
|
||||
let roomKeyTransport: RoomKeyTransport;
|
||||
let toDeviceKeyTransport: ToDeviceKeyTransport;
|
||||
let toDeviceSendKeySpy: jest.SpyInstance;
|
||||
let roomSendKeySpy: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
sendEventMock = jest.fn();
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
encryptAndSendToDevice: jest.fn(),
|
||||
getDeviceId: jest.fn().mockReturnValue("MYDEVICE"),
|
||||
...mockClientMethodsEvents(),
|
||||
...mockClientMethodsUser("@alice:example.org"),
|
||||
sendEvent: sendEventMock,
|
||||
});
|
||||
mockRoom = makeMockRoom([]);
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
getChild: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
mockLogger.getChild.mockReturnValue(mockLogger);
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
roomKeyTransport = new RoomKeyTransport(mockRoom, mockClient, statistics);
|
||||
toDeviceKeyTransport = new ToDeviceKeyTransport(
|
||||
"@alice:example.org",
|
||||
"MYDEVICE",
|
||||
mockRoom.roomId,
|
||||
mockClient,
|
||||
statistics,
|
||||
);
|
||||
transport = new RoomAndToDeviceTransport(toDeviceKeyTransport, roomKeyTransport, mockLogger);
|
||||
toDeviceSendKeySpy = jest.spyOn(toDeviceKeyTransport, "sendKey");
|
||||
roomSendKeySpy = jest.spyOn(roomKeyTransport, "sendKey");
|
||||
});
|
||||
|
||||
it("should enable to device transport when starting", () => {
|
||||
transport.start();
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
});
|
||||
it("only sends to device keys when sending a key", async () => {
|
||||
transport.start();
|
||||
await transport.sendKey("1235", 0, [
|
||||
{ userId: "@alice:example.org", deviceId: "ALICEDEVICE", membershipTs: 1234 },
|
||||
]);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
});
|
||||
|
||||
it("enables room transport and disables to device transport when receiving a room key", async () => {
|
||||
transport.start();
|
||||
const onNewKeyFromTransport = jest.fn();
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
mockRoom.emit(
|
||||
RoomEvent.Timeline,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", roomId, {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(transport.enabled.room).toBeTruthy();
|
||||
expect(transport.enabled.toDevice).toBeFalsy();
|
||||
|
||||
await transport.sendKey("1235", 0, [
|
||||
{ userId: "@alice:example.org", deviceId: "AlICEDEV", membershipTs: 1234 },
|
||||
]);
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
|
||||
});
|
||||
|
||||
it("enables room transport and disables to device transport on widget driver error", async () => {
|
||||
mockClient.encryptAndSendToDevice.mockRejectedValue({
|
||||
message:
|
||||
"unknown variant `send_to_device`, expected one of `supported_api_versions`, `content_loaded`, `get_openid`, `org.matrix.msc2876.read_events`, `send_event`, `org.matrix.msc4157.update_delayed_event` at line 1 column 22",
|
||||
});
|
||||
|
||||
transport.start();
|
||||
const membership: ParticipantDeviceInfo = {
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "ALICEDEVICE",
|
||||
membershipTs: 1234,
|
||||
};
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
|
||||
// We start with toDevice transport enabled
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
|
||||
await transport.sendKey("1235", 0, [membership]);
|
||||
|
||||
// We switched transport, now room transport is enabled
|
||||
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
|
||||
expect(transport.enabled.room).toBeTruthy();
|
||||
expect(transport.enabled.toDevice).toBeFalsy();
|
||||
|
||||
// sanity check that we called the failang to device send key.
|
||||
expect(toDeviceKeyTransport.sendKey).toHaveBeenCalledWith("1235", 0, [membership]);
|
||||
expect(toDeviceKeyTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
// We re-sent the key via the room transport
|
||||
expect(roomKeyTransport.sendKey).toHaveBeenCalledWith("1235", 0, [membership]);
|
||||
expect(roomKeyTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
|
||||
mockClient.encryptAndSendToDevice.mockRestore();
|
||||
});
|
||||
|
||||
it("does log that it did nothing when disabled", () => {
|
||||
transport.start();
|
||||
const onNewKeyFromTransport = jest.fn();
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
|
||||
transport.setEnabled({ toDevice: false, room: false });
|
||||
const dateNow = Date.now();
|
||||
roomKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "roomKey", 0, dateNow);
|
||||
toDeviceKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "toDeviceKey", 0, Date.now());
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith("To Device transport is disabled, ignoring received keys");
|
||||
// for room key transport we will never get a disabled message because its will always just turn on
|
||||
expect(onTransportEnabled).toHaveBeenNthCalledWith(1, { toDevice: false, room: false });
|
||||
expect(onTransportEnabled).toHaveBeenNthCalledWith(2, { toDevice: false, room: true });
|
||||
expect(onNewKeyFromTransport).toHaveBeenCalledTimes(1);
|
||||
expect(onNewKeyFromTransport).toHaveBeenCalledWith("user", "device", "roomKey", 0, dateNow);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -15,13 +15,22 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "stream";
|
||||
import { type Mocked } from "jest-mock";
|
||||
|
||||
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
|
||||
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
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: "",
|
||||
@@ -51,6 +60,11 @@ export type MockClient = Pick<
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "_unstable_cancelScheduledDelayedEvent"
|
||||
| "_unstable_restartScheduledDelayedEvent"
|
||||
| "_unstable_sendScheduledDelayedEvent"
|
||||
| "_unstable_sendStickyEvent"
|
||||
| "_unstable_sendStickyDelayedEvent"
|
||||
| "cancelPendingEvent"
|
||||
>;
|
||||
/**
|
||||
@@ -64,16 +78,23 @@ 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(),
|
||||
};
|
||||
}
|
||||
|
||||
export function makeMockRoom(
|
||||
membershipData: MembershipData[],
|
||||
): Room & { emitTimelineEvent: (event: MatrixEvent) => void } {
|
||||
useStickyEvents = false,
|
||||
): Mocked<Room & { emitTimelineEvent: (event: MatrixEvent) => void }> {
|
||||
const roomId = secureRandomString(8);
|
||||
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
|
||||
const roomState = makeMockRoomState(membershipData, roomId);
|
||||
const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId);
|
||||
const ts = Date.now();
|
||||
const room = Object.assign(new EventEmitter(), {
|
||||
roomId: roomId,
|
||||
hasMembershipState: jest.fn().mockReturnValue(true),
|
||||
@@ -81,11 +102,16 @@ export function makeMockRoom(
|
||||
getState: jest.fn().mockReturnValue(roomState),
|
||||
}),
|
||||
getVersion: jest.fn().mockReturnValue("default"),
|
||||
}) as unknown as Room;
|
||||
_unstable_getStickyEvents: jest
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, 10000, ts)) : [],
|
||||
) as any,
|
||||
});
|
||||
return Object.assign(room, {
|
||||
emitTimelineEvent: (event: MatrixEvent) =>
|
||||
room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any),
|
||||
});
|
||||
}) as unknown as Mocked<Room & { emitTimelineEvent: (event: MatrixEvent) => void }>;
|
||||
}
|
||||
|
||||
function makeMockRoomState(membershipData: MembershipData[], roomId: string) {
|
||||
@@ -129,6 +155,7 @@ export function makeMockEvent(
|
||||
roomId: string | undefined,
|
||||
content: any,
|
||||
timestamp?: number,
|
||||
stateKey?: string,
|
||||
): MatrixEvent {
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(type),
|
||||
@@ -137,16 +164,39 @@ export function makeMockEvent(
|
||||
getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()),
|
||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||
getId: jest.fn().mockReturnValue(secureRandomString(8)),
|
||||
getStateKey: jest.fn().mockReturnValue(stateKey),
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(false),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipData, roomId: string): MatrixEvent {
|
||||
return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData);
|
||||
export function mockRTCEvent(
|
||||
{ user_id: sender, ...membershipData }: MembershipData,
|
||||
roomId: string,
|
||||
stickyDuration?: number,
|
||||
timestamp?: number,
|
||||
): MatrixEvent {
|
||||
return {
|
||||
...makeMockEvent(
|
||||
stickyDuration !== undefined ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||
sender,
|
||||
roomId,
|
||||
membershipData,
|
||||
timestamp,
|
||||
!stickyDuration && "device_id" in membershipData ? `_${sender}_${membershipData.device_id}` : "",
|
||||
),
|
||||
unstableStickyExpiresAt: stickyDuration,
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
export function mockCallMembership(membershipData: MembershipData, roomId: string): CallMembership {
|
||||
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 } {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type CallMembership } from "../../../src/matrixrtc";
|
||||
import { isMyMembership, parseCallNotificationContent } from "../../../src/matrixrtc/types";
|
||||
|
||||
describe("types", () => {
|
||||
describe("isMyMembership", () => {
|
||||
it("returns false if userId is different", () => {
|
||||
expect(
|
||||
isMyMembership(
|
||||
{ sender: "@alice:example.org", deviceId: "DEVICE" } as CallMembership,
|
||||
"@bob:example.org",
|
||||
"DEVICE",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("returns true if userId and device is the same", () => {
|
||||
expect(
|
||||
isMyMembership(
|
||||
{ sender: "@alice:example.org", deviceId: "DEVICE" } as CallMembership,
|
||||
"@alice:example.org",
|
||||
"DEVICE",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("IRTCNotificationContent", () => {
|
||||
const validBase = Object.freeze({
|
||||
"m.mentions": { user_ids: [], room: true },
|
||||
"notification_type": "notification",
|
||||
"sender_ts": 123,
|
||||
"lifetime": 1000,
|
||||
});
|
||||
|
||||
it("parses valid content", () => {
|
||||
const res = parseCallNotificationContent({ ...validBase });
|
||||
expect(res).toMatchObject(validBase);
|
||||
});
|
||||
|
||||
it("caps lifetime to 90000ms", () => {
|
||||
const res = parseCallNotificationContent({ ...validBase, lifetime: 130000 });
|
||||
expect(res.lifetime).toBe(90000);
|
||||
});
|
||||
|
||||
it("throws on malformed m.mentions", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
"m.mentions": "not an object",
|
||||
} as any),
|
||||
).toThrow("malformed m.mentions");
|
||||
});
|
||||
|
||||
it("throws on missing or invalid notification_type", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
notification_type: undefined,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid notification_type");
|
||||
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
notification_type: 123 as any,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid notification_type");
|
||||
});
|
||||
|
||||
it("throws on missing or invalid sender_ts", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
sender_ts: undefined,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid sender_ts");
|
||||
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
sender_ts: "123" as any,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid sender_ts");
|
||||
});
|
||||
|
||||
it("throws on missing or invalid lifetime", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
lifetime: undefined,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid lifetime");
|
||||
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
lifetime: "1000" as any,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid lifetime");
|
||||
});
|
||||
|
||||
it("accepts valid relation (m.reference)", () => {
|
||||
// Note: parseCallNotificationContent currently checks `relation.rel_type` rather than `m.relates_to`.
|
||||
const res = parseCallNotificationContent({
|
||||
...validBase,
|
||||
relation: { rel_type: "m.reference", event_id: "$ev" },
|
||||
} as any);
|
||||
expect(res).toBeTruthy();
|
||||
});
|
||||
|
||||
it("throws on invalid relation rel_type", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
relation: { rel_type: "m.annotation", event_id: "$ev" },
|
||||
} as any),
|
||||
).toThrow("Invalid relation");
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { type IContent, MatrixEvent, MatrixEventEvent } from "../../../src/model
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import {
|
||||
type IAnnotatedPushRule,
|
||||
type IStickyEvent,
|
||||
type MatrixClient,
|
||||
PushRuleActionName,
|
||||
Room,
|
||||
@@ -598,6 +599,39 @@ describe("MatrixEvent", () => {
|
||||
expect(stateEvent.isState()).toBeTruthy();
|
||||
expect(stateEvent.threadRootId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should calculate sticky duration correctly", async () => {
|
||||
const evData: IStickyEvent = {
|
||||
event_id: "$event_id",
|
||||
type: "some_state_event",
|
||||
content: {},
|
||||
sender: "@alice:example.org",
|
||||
origin_server_ts: 50,
|
||||
msc4354_sticky: {
|
||||
duration_ms: 1000,
|
||||
},
|
||||
unsigned: {
|
||||
msc4354_sticky_duration_ttl_ms: 5000,
|
||||
},
|
||||
};
|
||||
try {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(50);
|
||||
// Prefer unsigned
|
||||
expect(new MatrixEvent({ ...evData } satisfies IStickyEvent).unstableStickyExpiresAt).toEqual(5050);
|
||||
// Fall back to `duration_ms`
|
||||
expect(
|
||||
new MatrixEvent({ ...evData, unsigned: undefined } satisfies IStickyEvent).unstableStickyExpiresAt,
|
||||
).toEqual(1050);
|
||||
// Prefer current time if `origin_server_ts` is more recent.
|
||||
expect(
|
||||
new MatrixEvent({ ...evData, unsigned: undefined, origin_server_ts: 5000 } satisfies IStickyEvent)
|
||||
.unstableStickyExpiresAt,
|
||||
).toEqual(1050);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function mainTimelineLiveEventIds(room: Room): Array<string> {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import { type IStickyEvent, MatrixEvent } from "../../../src";
|
||||
import { RoomStickyEventsStore, RoomStickyEventsEvent } from "../../../src/models/room-sticky-events";
|
||||
|
||||
describe("RoomStickyEvents", () => {
|
||||
let stickyEvents: RoomStickyEventsStore;
|
||||
const emitSpy: jest.Mock = jest.fn();
|
||||
const stickyEvent: IStickyEvent = {
|
||||
event_id: "$foo:bar",
|
||||
room_id: "!roomId",
|
||||
type: "org.example.any_type",
|
||||
msc4354_sticky: {
|
||||
duration_ms: 15000,
|
||||
},
|
||||
content: {
|
||||
msc4354_sticky_key: "foobar",
|
||||
},
|
||||
sender: "@alice:example.org",
|
||||
origin_server_ts: Date.now(),
|
||||
unsigned: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
emitSpy.mockReset();
|
||||
stickyEvents = new RoomStickyEventsStore();
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stickyEvents?.clear();
|
||||
});
|
||||
|
||||
describe("addStickyEvents", () => {
|
||||
it("should allow adding an event without a msc4354_sticky_key", () => {
|
||||
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, content: {} })]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toHaveLength(1);
|
||||
});
|
||||
it("should not allow adding an event without a msc4354_sticky property", () => {
|
||||
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, msc4354_sticky: undefined })]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
|
||||
stickyEvents.addStickyEvents([
|
||||
new MatrixEvent({ ...stickyEvent, msc4354_sticky: { duration_ms: undefined } as any }),
|
||||
]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
|
||||
});
|
||||
it("should not allow adding an event without a sender", () => {
|
||||
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: undefined })]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
|
||||
});
|
||||
it("should not allow adding an event with an invalid sender", () => {
|
||||
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: "not_a_real_sender" })]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
|
||||
});
|
||||
it("should ignore old events", () => {
|
||||
stickyEvents.addStickyEvents([
|
||||
new MatrixEvent({ ...stickyEvent, origin_server_ts: 0, msc4354_sticky: { duration_ms: 1 } }),
|
||||
]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
|
||||
});
|
||||
it("should be able to just add an event", () => {
|
||||
const originalEv = new MatrixEvent({ ...stickyEvent });
|
||||
stickyEvents.addStickyEvents([originalEv]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
|
||||
});
|
||||
it("should not replace events on ID tie break", () => {
|
||||
const originalEv = new MatrixEvent({ ...stickyEvent });
|
||||
stickyEvents.addStickyEvents([originalEv]);
|
||||
stickyEvents.addStickyEvents([
|
||||
new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$abc:bar",
|
||||
}),
|
||||
]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
|
||||
});
|
||||
it("should not replace a newer event with an older event", () => {
|
||||
const originalEv = new MatrixEvent({ ...stickyEvent });
|
||||
stickyEvents.addStickyEvents([originalEv]);
|
||||
stickyEvents.addStickyEvents([
|
||||
new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: 1,
|
||||
}),
|
||||
]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
|
||||
});
|
||||
it("should replace an older event with a newer event", () => {
|
||||
const originalEv = new MatrixEvent({ ...stickyEvent, event_id: "$old" });
|
||||
const newerEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$new",
|
||||
origin_server_ts: Date.now() + 2000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([originalEv]);
|
||||
stickyEvents.addStickyEvents([newerEv]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([newerEv]);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [{ current: newerEv, previous: originalEv }], []);
|
||||
});
|
||||
it("should allow multiple events with the same sticky key for different event types", () => {
|
||||
const originalEv = new MatrixEvent({ ...stickyEvent });
|
||||
const anotherEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
type: "org.example.another_type",
|
||||
});
|
||||
stickyEvents.addStickyEvents([originalEv, anotherEv]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv, anotherEv]);
|
||||
});
|
||||
|
||||
it("should emit when a new sticky event is added", () => {
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
|
||||
expect(emitSpy).toHaveBeenCalledWith([ev], [], []);
|
||||
});
|
||||
it("should emit when a new unkeyed sticky event is added", () => {
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
content: {},
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
|
||||
expect(emitSpy).toHaveBeenCalledWith([ev], [], []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStickyEvents", () => {
|
||||
it("should have zero sticky events", () => {
|
||||
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
|
||||
});
|
||||
it("should contain a sticky event", () => {
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
|
||||
});
|
||||
it("should contain two sticky events", () => {
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
});
|
||||
const ev2 = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
sender: "@fibble:bobble",
|
||||
content: {
|
||||
msc4354_sticky_key: "bibble",
|
||||
},
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, ev2]);
|
||||
expect([...stickyEvents.getStickyEvents()]).toEqual([ev, ev2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKeyedStickyEvent", () => {
|
||||
it("should have zero sticky events", () => {
|
||||
expect(
|
||||
stickyEvents.getKeyedStickyEvent(
|
||||
stickyEvent.sender,
|
||||
stickyEvent.type,
|
||||
stickyEvent.content.msc4354_sticky_key!,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
it("should return a sticky event", () => {
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
expect(
|
||||
stickyEvents.getKeyedStickyEvent(
|
||||
stickyEvent.sender,
|
||||
stickyEvent.type,
|
||||
stickyEvent.content.msc4354_sticky_key!,
|
||||
),
|
||||
).toEqual(ev);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUnkeyedStickyEvent", () => {
|
||||
it("should have zero sticky events", () => {
|
||||
expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([]);
|
||||
});
|
||||
it("should return a sticky event", () => {
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
content: {
|
||||
msc4354_sticky_key: undefined,
|
||||
},
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([ev]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanExpiredStickyEvents", () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should emit when a sticky event expires", () => {
|
||||
jest.setSystemTime(1000);
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: 0,
|
||||
});
|
||||
const evLater = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$baz:bar",
|
||||
sender: "@bob:example.org",
|
||||
origin_server_ts: 1000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, evLater]);
|
||||
const emitSpy = jest.fn();
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
jest.advanceTimersByTime(15000);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
|
||||
// Then expire the next event
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [evLater]);
|
||||
});
|
||||
it("should emit two events when both expire at the same time", () => {
|
||||
const emitSpy = jest.fn();
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
jest.setSystemTime(0);
|
||||
const ev1 = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$eventA",
|
||||
origin_server_ts: 0,
|
||||
});
|
||||
const ev2 = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$eventB",
|
||||
content: {
|
||||
msc4354_sticky_key: "key_2",
|
||||
},
|
||||
origin_server_ts: 0,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev1, ev2]);
|
||||
expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], [], []);
|
||||
jest.advanceTimersByTime(15000);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [ev1, ev2]);
|
||||
});
|
||||
it("should emit when a unkeyed sticky event expires", () => {
|
||||
const emitSpy = jest.fn();
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
jest.setSystemTime(0);
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
content: {},
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
jest.advanceTimersByTime(15000);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleRedaction", () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
it("should not emit if the event does not exist in the map", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
content: {},
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
stickyEvents.handleRedaction("$123456");
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should emit a remove when the event exists in the map without a predecessor", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
stickyEvents.handleRedaction(stickyEvent.event_id);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
|
||||
});
|
||||
it("should emit a remove when the event has no sticky key", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
content: {},
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
stickyEvents.handleRedaction(stickyEvent.event_id);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
|
||||
});
|
||||
it("should emit an update when the event exists in the map with a predecessor", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
|
||||
const newerEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newer-ev",
|
||||
origin_server_ts: Date.now() + 1000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, newerEv]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
stickyEvents.handleRedaction(newerEv.getId()!);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newerEv }], []);
|
||||
});
|
||||
it("should emit a remove if the previous event has expired", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
|
||||
const newerEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newer-ev",
|
||||
origin_server_ts: Date.now() + 1000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, newerEv]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
// Expire the older event.
|
||||
jest.advanceTimersByTime(stickyEvent.msc4354_sticky.duration_ms);
|
||||
// Redact the newer event
|
||||
stickyEvents.handleRedaction(newerEv.getId()!);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [newerEv]);
|
||||
});
|
||||
it("should recurse the chain of events if the previous event has been redacted", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
|
||||
const middleEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newer-ev",
|
||||
origin_server_ts: Date.now() + 1000,
|
||||
});
|
||||
jest.advanceTimersByTime(1000);
|
||||
const newestEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newest-ev",
|
||||
origin_server_ts: Date.now() + 2000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, middleEv, newestEv]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
// Mark the middle event as redacted.
|
||||
middleEv.setUnsigned({
|
||||
redacted_because: {
|
||||
event_id: "$foo",
|
||||
} as any,
|
||||
});
|
||||
// Redact the newer event
|
||||
stickyEvents.handleRedaction(newestEv.getId()!);
|
||||
// expect immediate transition from newestEv -> ev and skipping middleEv
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newestEv }], []);
|
||||
});
|
||||
it("should revert to the most recent valid event regardless of insertion order", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
|
||||
const middleEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newer-ev",
|
||||
origin_server_ts: Date.now() + 1000,
|
||||
});
|
||||
jest.advanceTimersByTime(1000);
|
||||
const newestEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newest-ev",
|
||||
origin_server_ts: Date.now() + 2000,
|
||||
});
|
||||
// Invert in reverse order, to make sure we retain the older events.
|
||||
stickyEvents.addStickyEvents([newestEv, middleEv, ev]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
// Mark the middle event as redacted.
|
||||
middleEv.setUnsigned({
|
||||
redacted_because: {
|
||||
event_id: "$foo",
|
||||
} as any,
|
||||
});
|
||||
// Redact the newer event
|
||||
stickyEvents.handleRedaction(newestEv.getId()!);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newestEv }], []);
|
||||
});
|
||||
it("should handle redaction when using `handleRedaction` with a `MatrixEvent` parameter", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
|
||||
const newerEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newer-ev",
|
||||
origin_server_ts: Date.now() + 1000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, newerEv]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
stickyEvents.handleRedaction(newerEv);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newerEv }], []);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`] = `
|
||||
{
|
||||
|
||||
@@ -854,9 +854,27 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("getSecretStorageStatus", async () => {
|
||||
const mockSecretStorage = {
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("blah"),
|
||||
isStored: jest.fn().mockResolvedValue({ blah: {} }),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage);
|
||||
await expect(rustCrypto.getSecretStorageStatus()).resolves.toEqual({
|
||||
defaultKeyId: "blah",
|
||||
ready: true,
|
||||
secretStorageKeyValidityMap: {
|
||||
"m.cross_signing.master": true,
|
||||
"m.cross_signing.self_signing": true,
|
||||
"m.cross_signing.user_signing": true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("isSecretStorageReady", async () => {
|
||||
const mockSecretStorage = {
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue(null),
|
||||
isStored: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage);
|
||||
await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false);
|
||||
@@ -2419,6 +2437,135 @@ describe("RustCrypto", () => {
|
||||
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][1]).toEqual(new TextEncoder().encode("asdfghjkl"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Verification requests", () => {
|
||||
it("fetches device details before room verification requests", async () => {
|
||||
// Given a RustCrypto
|
||||
const olmMachine = mockedOlmMachine();
|
||||
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
|
||||
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
|
||||
|
||||
// When we receive a room verification request
|
||||
const event = mockedEvent("!r:s.co", "@u:s.co", "m.room.message", "m.key.verification.request");
|
||||
await rustCrypto.onLiveEventFromSync(event);
|
||||
|
||||
// Then we first fetch device details
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalled();
|
||||
|
||||
// And we handle the verification event as normal
|
||||
expect(olmMachine.receiveVerificationEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fetch device details before other verification events", async () => {
|
||||
// Given a RustCrypto
|
||||
const olmMachine = mockedOlmMachine();
|
||||
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
|
||||
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
|
||||
|
||||
// When we receive some verification event that is not a room request
|
||||
const event = mockedEvent("!r:s.co", "@u:s.co", "m.key.verification.start");
|
||||
await rustCrypto.onLiveEventFromSync(event);
|
||||
|
||||
// Then we do not fetch device details
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
|
||||
|
||||
// And we handle the verification event as normal
|
||||
expect(olmMachine.receiveVerificationEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws an error if sender is missing", async () => {
|
||||
// Given a RustCrypto
|
||||
const olmMachine = mockedOlmMachine();
|
||||
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
|
||||
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
|
||||
|
||||
// When we receive a verification event without a sender
|
||||
// Then we throw
|
||||
const event = mockedEvent("!r:s.co", null, "m.key.verification.start");
|
||||
|
||||
await expect(async () => await rustCrypto.onLiveEventFromSync(event)).rejects.toThrow(
|
||||
"missing sender in the event",
|
||||
);
|
||||
|
||||
// And we do not fetch device details or handle the event
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
|
||||
expect(olmMachine.receiveVerificationEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws an error if room is missing", async () => {
|
||||
// Given a RustCrypto
|
||||
const olmMachine = mockedOlmMachine();
|
||||
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
|
||||
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
|
||||
|
||||
// When we receive a verification event without a sender
|
||||
// Then we throw
|
||||
const event = mockedEvent(null, "@u:s.co", "m.key.verification.start");
|
||||
|
||||
await expect(async () => await rustCrypto.onLiveEventFromSync(event)).rejects.toThrow(
|
||||
"missing roomId in the event",
|
||||
);
|
||||
|
||||
// And we do not fetch device details or handle the event
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
|
||||
expect(olmMachine.receiveVerificationEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function mockedOlmMachine(): Mocked<OlmMachine> {
|
||||
return {
|
||||
queryKeysForUsers: jest.fn(),
|
||||
getVerificationRequest: jest.fn(),
|
||||
receiveVerificationEvent: jest.fn(),
|
||||
} as unknown as Mocked<OlmMachine>;
|
||||
}
|
||||
|
||||
function makeRustCrypto(
|
||||
olmMachine: OlmMachine,
|
||||
outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||
): RustCrypto {
|
||||
const rustCrypto = new RustCrypto(
|
||||
new DebugLogger(debug("test Verification requests")),
|
||||
olmMachine,
|
||||
{} as unknown as MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
);
|
||||
|
||||
// @ts-ignore mocking outgoingRequestProcessor
|
||||
rustCrypto.outgoingRequestProcessor = outgoingRequestProcessor;
|
||||
|
||||
return rustCrypto;
|
||||
}
|
||||
|
||||
function mockedOutgoingRequestProcessor(): OutgoingRequestProcessor {
|
||||
return {
|
||||
makeOutgoingRequest: jest.fn(),
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
}
|
||||
|
||||
function mockedEvent(
|
||||
roomId: string | null,
|
||||
senderId: string | null,
|
||||
eventType: string,
|
||||
msgtype?: string | undefined,
|
||||
): MatrixEvent {
|
||||
return {
|
||||
isState: jest.fn().mockReturnValue(false),
|
||||
getUnsigned: jest.fn().mockReturnValue({}),
|
||||
isDecryptionFailure: jest.fn(),
|
||||
isEncrypted: jest.fn(),
|
||||
getType: jest.fn().mockReturnValue(eventType),
|
||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||
getSender: jest.fn().mockReturnValue(senderId),
|
||||
getId: jest.fn(),
|
||||
getStateKey: jest.fn(),
|
||||
getContent: jest.fn().mockReturnValue({ msgtype: msgtype }),
|
||||
getTs: jest.fn(),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** Build a MatrixHttpApi instance */
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type ILeftRoom,
|
||||
type IRoomEvent,
|
||||
type IStateEvent,
|
||||
type IStickyEvent,
|
||||
type IStrippedState,
|
||||
type ISyncResponse,
|
||||
SyncAccumulator,
|
||||
@@ -1067,6 +1068,67 @@ describe("SyncAccumulator", function () {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MSC4354 sticky events", () => {
|
||||
function stickyEvent(ts = 0): IStickyEvent {
|
||||
const msgData = msg("test", "test text");
|
||||
return {
|
||||
...msgData,
|
||||
msc4354_sticky: {
|
||||
duration_ms: 1000,
|
||||
},
|
||||
origin_server_ts: ts,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should accumulate sticky events", () => {
|
||||
jest.setSystemTime(0);
|
||||
const ev = stickyEvent();
|
||||
sa.accumulate(
|
||||
syncSkeleton({
|
||||
msc4354_sticky: {
|
||||
events: [ev],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]);
|
||||
});
|
||||
it("should clear stale sticky events", () => {
|
||||
jest.setSystemTime(1000);
|
||||
const ev = stickyEvent(1000);
|
||||
sa.accumulate(
|
||||
syncSkeleton({
|
||||
msc4354_sticky: {
|
||||
events: [ev],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]);
|
||||
jest.setSystemTime(2000); // Expire the event
|
||||
sa.accumulate(syncSkeleton({}));
|
||||
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears stale sticky events that pretend to be from the distant future", () => {
|
||||
jest.setSystemTime(0);
|
||||
const eventFarInTheFuture = stickyEvent(999999999999);
|
||||
sa.accumulate(syncSkeleton({ msc4354_sticky: { events: [eventFarInTheFuture] } }));
|
||||
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([
|
||||
eventFarInTheFuture,
|
||||
]);
|
||||
jest.setSystemTime(1000); // Expire the event
|
||||
sa.accumulate(syncSkeleton({}));
|
||||
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function syncSkeleton(
|
||||
|
||||
@@ -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;
|
||||
|
||||
+4
-2
@@ -58,7 +58,7 @@ import {
|
||||
type ICallNotifyContent,
|
||||
} from "../matrixrtc/types.ts";
|
||||
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts";
|
||||
import { type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
|
||||
import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
|
||||
import { type LocalNotificationSettings } from "./local_notifications.ts";
|
||||
import { type IPushRules } from "./PushRules.ts";
|
||||
import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
|
||||
@@ -151,6 +151,7 @@ export enum EventType {
|
||||
GroupCallMemberPrefix = "org.matrix.msc3401.call.member",
|
||||
|
||||
// MatrixRTC events
|
||||
RTCMembership = "org.matrix.msc4143.rtc.member",
|
||||
CallNotify = "org.matrix.msc4075.call.notify",
|
||||
RTCNotification = "org.matrix.msc4075.rtc.notification",
|
||||
RTCDecline = "org.matrix.msc4310.rtc.decline",
|
||||
@@ -337,6 +338,7 @@ export interface TimelineEvents {
|
||||
[M_BEACON.name]: MBeaconEventContent;
|
||||
[M_POLL_START.name]: PollStartEventContent;
|
||||
[M_POLL_END.name]: PollEndEventContent;
|
||||
[EventType.RTCMembership]: RtcMembershipData | { msc4354_sticky_key: string }; // An object containing just the sticky key is empty.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -369,7 +371,7 @@ export interface StateEvents {
|
||||
// MSC3401
|
||||
[EventType.GroupCallPrefix]: IGroupCallRoomState;
|
||||
[EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject;
|
||||
|
||||
[EventType.RTCMembership]: RtcMembershipData | EmptyObject;
|
||||
// MSC3089
|
||||
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
+37
-23
@@ -20,6 +20,7 @@ import { type IEventWithRoomId, type SearchKey } from "./search.ts";
|
||||
import { type IRoomEventFilter } from "../filter.ts";
|
||||
import { type Direction } from "../models/event-timeline.ts";
|
||||
import { type PushRuleAction } from "./PushRules.ts";
|
||||
import { type MatrixError } from "../matrix.ts";
|
||||
import { type IRoomEvent } from "../sync-accumulator.ts";
|
||||
import { type EventType, type RelationType, type RoomType } from "./event.ts";
|
||||
|
||||
@@ -34,6 +35,7 @@ export interface IJoinRoomOpts {
|
||||
|
||||
/**
|
||||
* The server names to try and join through in addition to those that are automatically chosen.
|
||||
* Only the first 3 are actually used in the request, to avoid HTTP 414 Request-URI Too Long responses.
|
||||
*/
|
||||
viaServers?: string[];
|
||||
|
||||
@@ -71,6 +73,7 @@ export interface KnockRoomOpts {
|
||||
|
||||
/**
|
||||
* The server names to try and knock through in addition to those that are automatically chosen.
|
||||
* Only the first 3 are actually used in the request, to avoid HTTP 414 Request-URI Too Long responses.
|
||||
*/
|
||||
viaServers?: string | string[];
|
||||
}
|
||||
@@ -94,19 +97,20 @@ export interface ISendEventResponse {
|
||||
event_id: string;
|
||||
}
|
||||
|
||||
export type TimeoutDelay = {
|
||||
delay: number;
|
||||
};
|
||||
|
||||
export type ParentDelayId = {
|
||||
parent_delay_id: string;
|
||||
};
|
||||
|
||||
export type SendTimeoutDelayedEventRequestOpts = TimeoutDelay & Partial<ParentDelayId>;
|
||||
export type SendActionDelayedEventRequestOpts = ParentDelayId;
|
||||
|
||||
export type SendDelayedEventRequestOpts = SendTimeoutDelayedEventRequestOpts | SendActionDelayedEventRequestOpts;
|
||||
export type SendDelayedEventRequestOpts = { parent_delay_id: string } | { delay: number; parent_delay_id?: string };
|
||||
|
||||
export function isSendDelayedEventRequestOpts(opts: object): opts is SendDelayedEventRequestOpts {
|
||||
if ("parent_delay_id" in opts && typeof opts.parent_delay_id !== "string") {
|
||||
// Invalid type, reject
|
||||
return false;
|
||||
}
|
||||
if ("delay" in opts && typeof opts.delay !== "number") {
|
||||
// Invalid type, reject.
|
||||
return true;
|
||||
}
|
||||
// At least one of these fields must be specified.
|
||||
return "delay" in opts || "parent_delay_id" in opts;
|
||||
}
|
||||
export type SendDelayedEventResponse = {
|
||||
delay_id: string;
|
||||
};
|
||||
@@ -133,12 +137,22 @@ type DelayedPartialStateEvent = DelayedPartialTimelineEvent & {
|
||||
|
||||
type DelayedPartialEvent = DelayedPartialTimelineEvent | DelayedPartialStateEvent;
|
||||
|
||||
export type DelayedEventInfoItem = DelayedPartialEvent &
|
||||
SendDelayedEventResponse &
|
||||
SendDelayedEventRequestOpts & {
|
||||
running_since: number;
|
||||
};
|
||||
|
||||
export type DelayedEventInfo = {
|
||||
delayed_events: (DelayedPartialEvent &
|
||||
SendDelayedEventResponse &
|
||||
SendDelayedEventRequestOpts & {
|
||||
running_since: number;
|
||||
})[];
|
||||
scheduled?: DelayedEventInfoItem[];
|
||||
finalised?: {
|
||||
delayed_event: DelayedEventInfoItem;
|
||||
outcome: "send" | "cancel";
|
||||
reason: "error" | "action" | "delay";
|
||||
error?: MatrixError["data"];
|
||||
event_id?: string;
|
||||
origin_server_ts?: number;
|
||||
}[];
|
||||
next_batch?: string;
|
||||
};
|
||||
|
||||
@@ -284,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" });
|
||||
}
|
||||
|
||||
+464
-115
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+30
-1
@@ -20,7 +20,7 @@ import type { ToDeviceBatch, ToDevicePayload } from "../models/ToDeviceMessage.t
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type DeviceMap } from "../models/device.ts";
|
||||
import { type UIAuthCallback } from "../interactive-auth.ts";
|
||||
import { type PassphraseInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
|
||||
import { type PassphraseInfo, type SecretStorageKey, type SecretStorageKeyDescription } from "../secret-storage.ts";
|
||||
import { type VerificationRequest } from "./verification.ts";
|
||||
import {
|
||||
type BackupTrustInfo,
|
||||
@@ -369,6 +369,11 @@ export interface CryptoApi {
|
||||
*/
|
||||
isSecretStorageReady(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Inspect the status of secret storage, in more detail than {@link isSecretStorageReady}.
|
||||
*/
|
||||
getSecretStorageStatus(): Promise<SecretStorageStatus>;
|
||||
|
||||
/**
|
||||
* Bootstrap [secret storage](https://spec.matrix.org/v1.12/client-server-api/#storage).
|
||||
*
|
||||
@@ -1148,6 +1153,30 @@ export interface CryptoCallbacks {
|
||||
cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of a call to {@link CryptoApi.getSecretStorageStatus}.
|
||||
*/
|
||||
export interface SecretStorageStatus {
|
||||
/** Whether secret storage is fully populated. The same as {@link CryptoApi.isSecretStorageReady}. */
|
||||
ready: boolean;
|
||||
|
||||
/** The ID of the current default secret storage key. */
|
||||
defaultKeyId: string | null;
|
||||
|
||||
/**
|
||||
* For each secret that we checked whether it is correctly stored in secret storage with the default secret storage key.
|
||||
*
|
||||
* Note that we will only check that the key backup key is stored if key backup is currently enabled (i.e. that
|
||||
* {@link CryptoApi.getActiveSessionBackupVersion} returns non-null). `m.megolm_backup.v1` will only be present in that case.
|
||||
*
|
||||
* (This is an object rather than a `Map` so that it JSON.stringify()s nicely, since its main purpose is to end up
|
||||
* in logs.)
|
||||
*/
|
||||
secretStorageKeyValidityMap: {
|
||||
[P in SecretStorageKey]?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter of {@link CryptoApi#bootstrapSecretStorage}
|
||||
*/
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
|
||||
+22
-2
@@ -54,14 +54,34 @@ export class ClientStoppedError extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* This error is thrown when the Homeserver does not support the delayed events enpdpoints.
|
||||
* This error is thrown when the Homeserver does not support the delayed events endpoints.
|
||||
*/
|
||||
export class UnsupportedDelayedEventsEndpointError extends Error {
|
||||
public constructor(
|
||||
message: string,
|
||||
public clientEndpoint: "sendDelayedEvent" | "updateDelayedEvent" | "sendDelayedStateEvent" | "getDelayedEvents",
|
||||
public clientEndpoint:
|
||||
| "sendDelayedEvent"
|
||||
| "updateDelayedEvent"
|
||||
| "cancelScheduledDelayedEvent"
|
||||
| "restartScheduledDelayedEvent"
|
||||
| "sendScheduledDelayedEvent"
|
||||
| "sendDelayedStateEvent"
|
||||
| "getDelayedEvents",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "UnsupportedDelayedEventsEndpointError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This error is thrown when the Homeserver does not support the sticky events endpoints.
|
||||
*/
|
||||
export class UnsupportedStickyEventsEndpointError extends Error {
|
||||
public constructor(
|
||||
message: string,
|
||||
public clientEndpoint: "sendStickyEvent" | "sendStickyStateEvent",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "UnsupportedStickyEventsEndpointError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+480
-68
@@ -14,12 +14,16 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type MatrixEvent } from "../matrix.ts";
|
||||
import { MXID_PATTERN } from "../models/room-member.ts";
|
||||
import { deepCompare } from "../utils.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type SessionDescription } from "./MatrixRTCSession.ts";
|
||||
import { type RTCCallIntent } from "./types.ts";
|
||||
import { type LivekitFocusSelection } from "./LivekitTransport.ts";
|
||||
import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts";
|
||||
import type { RTCCallIntent, Transport } from "./types.ts";
|
||||
import { MatrixEvent, type IContent } from "../models/event.ts";
|
||||
import { type RelationType } from "../@types/event.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.
|
||||
@@ -29,6 +33,115 @@ import { type RTCCallIntent } from "./types.ts";
|
||||
export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4;
|
||||
|
||||
type CallScope = "m.room" | "m.user";
|
||||
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;
|
||||
"member": Member;
|
||||
"m.relates_to"?: {
|
||||
event_id: string;
|
||||
rel_type: RelationType.Reference;
|
||||
};
|
||||
"application": {
|
||||
type: string;
|
||||
// other application specific keys
|
||||
[key: string]: unknown;
|
||||
};
|
||||
"rtc_transports": Transport[];
|
||||
"versions": string[];
|
||||
"msc4354_sticky_key"?: string;
|
||||
"sticky_key"?: string;
|
||||
}
|
||||
|
||||
const checkRtcMembershipData = (
|
||||
data: IContent,
|
||||
errors: string[],
|
||||
referenceUserId: string,
|
||||
): data is RtcMembershipData => {
|
||||
const prefix = " - ";
|
||||
|
||||
// required fields
|
||||
if (typeof data.slot_id !== "string") {
|
||||
errors.push(prefix + "slot_id must be string");
|
||||
} else {
|
||||
if (data.slot_id.split("#").length !== 2) errors.push(prefix + 'slot_id must include exactly one "#"');
|
||||
}
|
||||
if (typeof data.member !== "object" || data.member === null) {
|
||||
errors.push(prefix + "member must be an object");
|
||||
} else {
|
||||
if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string");
|
||||
else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid");
|
||||
// This is not what the spec enforces but there currently are no rules what power levels are required to
|
||||
// send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there
|
||||
// is a proper definition when this is allowed.
|
||||
else if (data.member.user_id !== referenceUserId) errors.push(prefix + "member.user_id must match the sender");
|
||||
if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string");
|
||||
if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string");
|
||||
}
|
||||
if (typeof data.application !== "object" || data.application === null) {
|
||||
errors.push(prefix + "application must be an object");
|
||||
} else {
|
||||
if (typeof data.application.type !== "string") {
|
||||
errors.push(prefix + "application.type must be a string");
|
||||
} else {
|
||||
if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"');
|
||||
}
|
||||
}
|
||||
if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) {
|
||||
errors.push(prefix + "rtc_transports must be an array");
|
||||
} else {
|
||||
// validate that each transport has at least a string 'type'
|
||||
for (const t of data.rtc_transports) {
|
||||
if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") {
|
||||
errors.push(prefix + "rtc_transports entries must be objects with a string type");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.versions === undefined || !Array.isArray(data.versions)) {
|
||||
errors.push(prefix + "versions must be an array");
|
||||
} else if (!data.versions.every((v) => typeof v === "string")) {
|
||||
errors.push(prefix + "versions must be an array of strings");
|
||||
}
|
||||
|
||||
// optional fields
|
||||
if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) {
|
||||
errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined");
|
||||
}
|
||||
if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") {
|
||||
errors.push(prefix + "sticky_key must be a string");
|
||||
}
|
||||
if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") {
|
||||
errors.push(prefix + "msc4354_sticky_key must be a string");
|
||||
}
|
||||
if (
|
||||
data.sticky_key !== undefined &&
|
||||
data.msc4354_sticky_key !== undefined &&
|
||||
data.sticky_key !== data.msc4354_sticky_key
|
||||
) {
|
||||
errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined");
|
||||
}
|
||||
if (data["m.relates_to"] !== undefined) {
|
||||
const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"];
|
||||
if (typeof rel !== "object" || rel === null) {
|
||||
errors.push(prefix + "m.relates_to must be an object if provided");
|
||||
} else {
|
||||
if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
|
||||
if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
|
||||
}
|
||||
}
|
||||
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* MSC4143 (MatrixRTC) session membership data.
|
||||
@@ -56,13 +169,13 @@ export type SessionMembershipData = {
|
||||
/**
|
||||
* The focus selection system this user/membership is using.
|
||||
*/
|
||||
"focus_active": Focus;
|
||||
"focus_active": LivekitFocusSelection;
|
||||
|
||||
/**
|
||||
* A list of possible foci this uses knows about. One of them might be used based on the focus_active
|
||||
* A list of possible foci this user knows about. One of them might be used based on the focus_active
|
||||
* selection system.
|
||||
*/
|
||||
"foci_preferred": Focus[];
|
||||
"foci_preferred": Transport[];
|
||||
|
||||
/**
|
||||
* Optional field that contains the creation of the session. If it is undefined the creation
|
||||
@@ -77,7 +190,7 @@ export type SessionMembershipData = {
|
||||
|
||||
/**
|
||||
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
|
||||
* There can always be one room scroped call but multiple user owned calls (breakout sessions)
|
||||
* There can always be one room scoped call but multiple user owned calls (breakout sessions)
|
||||
*/
|
||||
"scope"?: CallScope;
|
||||
|
||||
@@ -93,18 +206,41 @@ export type SessionMembershipData = {
|
||||
* something else.
|
||||
*/
|
||||
"m.call.intent"?: RTCCallIntent;
|
||||
/**
|
||||
* The sticky key in case of a sticky event. This string encodes the application + device_id indicating the used slot + device.
|
||||
*/
|
||||
"msc4354_sticky_key"?: string;
|
||||
|
||||
/**
|
||||
* 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: Partial<Record<keyof SessionMembershipData, any>>,
|
||||
errors: string[],
|
||||
): data is SessionMembershipData => {
|
||||
const prefix = "Malformed session membership event: ";
|
||||
const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => {
|
||||
const prefix = " - ";
|
||||
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
|
||||
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
|
||||
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
|
||||
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
|
||||
if (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array");
|
||||
if (data.focus_active === undefined) {
|
||||
errors.push(prefix + "focus_active has an invalid type");
|
||||
}
|
||||
if (
|
||||
data.foci_preferred !== undefined &&
|
||||
!(
|
||||
Array.isArray(data.foci_preferred) &&
|
||||
data.foci_preferred.every(
|
||||
(f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string",
|
||||
)
|
||||
)
|
||||
) {
|
||||
errors.push(prefix + "foci_preferred must be an array of transport objects");
|
||||
}
|
||||
// optional parameters
|
||||
if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
|
||||
errors.push(prefix + "created_ts must be number");
|
||||
@@ -120,109 +256,385 @@ const checkSessionsMembershipData = (
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData };
|
||||
// TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file.
|
||||
export class CallMembership {
|
||||
public static equal(a: CallMembership, b: CallMembership): boolean {
|
||||
return deepCompare(a.membershipData, b.membershipData);
|
||||
public static equal(a?: CallMembership, b?: CallMembership): boolean {
|
||||
return deepCompare(a?.membershipData, b?.membershipData);
|
||||
}
|
||||
private membershipData: SessionMembershipData;
|
||||
|
||||
private 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; 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(
|
||||
private parentEvent: MatrixEvent,
|
||||
data: any,
|
||||
/** 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 sessionErrors: string[] = [];
|
||||
if (!checkSessionsMembershipData(data, sessionErrors)) {
|
||||
throw Error(
|
||||
`unknown CallMembership data. Does not match MSC4143 call.member (${sessionErrors.join(" & ")}) events this could be a legacy membership event: (${data})`,
|
||||
);
|
||||
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 {
|
||||
this.membershipData = data;
|
||||
// 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");
|
||||
|
||||
public get sender(): string | undefined {
|
||||
return this.parentEvent.getSender();
|
||||
}
|
||||
this.matrixEventData = { eventId, sender, ts };
|
||||
|
||||
public get eventId(): string | undefined {
|
||||
return this.parentEvent.getId();
|
||||
this.logger = logger?.getChild(`[CallMembership ${sender}:${this.deviceId}]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use sessionDescription.id instead.
|
||||
* sha256(`${this.userId}|${this.deviceId}|${this.memberId}`) for sticky events (kind = rtc)
|
||||
* `${this.userId}:${this.deviceId}` for state events (kind = session)
|
||||
*/
|
||||
public get callId(): string {
|
||||
return this.membershipData.call_id;
|
||||
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(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(content).replaceAll('"', "'");
|
||||
throw Error(`unknown CallMembership data.\n` + details + json);
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated use userId instead */
|
||||
public get sender(): string {
|
||||
return this.userId;
|
||||
}
|
||||
public get userId(): string {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.member.user_id;
|
||||
case "session":
|
||||
default:
|
||||
return this.matrixEventData.sender;
|
||||
}
|
||||
}
|
||||
|
||||
public get eventId(): string {
|
||||
return this.matrixEventData.eventId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID of the MatrixRTC slot that this membership belongs to (format `{application}#{id}`).
|
||||
* This is computed in case SessionMembershipData is used.
|
||||
*/
|
||||
public get slotId(): string {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.slot_id;
|
||||
case "session":
|
||||
default:
|
||||
return slotDescriptionToId({ application: this.application, id: data.call_id });
|
||||
}
|
||||
}
|
||||
|
||||
public get deviceId(): string {
|
||||
return this.membershipData.device_id;
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.member.device_id;
|
||||
case "session":
|
||||
default:
|
||||
return data.device_id;
|
||||
}
|
||||
}
|
||||
|
||||
public get callIntent(): RTCCallIntent | undefined {
|
||||
return this.membershipData["m.call.intent"];
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc": {
|
||||
const intent = data.application["m.call.intent"];
|
||||
if (typeof intent === "string") {
|
||||
return intent;
|
||||
}
|
||||
this.logger?.warn("RTC membership has invalid m.call.intent");
|
||||
return undefined;
|
||||
}
|
||||
case "session":
|
||||
default:
|
||||
return data["m.call.intent"];
|
||||
}
|
||||
}
|
||||
|
||||
public get sessionDescription(): SessionDescription {
|
||||
return {
|
||||
application: this.membershipData.application,
|
||||
id: this.membershipData.call_id,
|
||||
};
|
||||
/**
|
||||
* Parsed `slot_id` (format `{application}#{id}`) into its components (application and id).
|
||||
*/
|
||||
public get slotDescription(): SlotDescription {
|
||||
return slotIdToDescription(this.slotId);
|
||||
}
|
||||
|
||||
public get application(): string | undefined {
|
||||
return this.membershipData.application;
|
||||
public get application(): string {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.application.type;
|
||||
case "session":
|
||||
default:
|
||||
return data.application;
|
||||
}
|
||||
}
|
||||
public get applicationData(): { type: string; [key: string]: unknown } {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.application;
|
||||
case "session":
|
||||
default:
|
||||
return { "type": data.application, "m.call.intent": data["m.call.intent"] };
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated scope is not used and will be removed in future versions. replaced by application specific types.*/
|
||||
public get scope(): CallScope | undefined {
|
||||
return this.membershipData.scope;
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return undefined;
|
||||
case "session":
|
||||
default:
|
||||
return data.scope;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @deprecated renamed to `memberId`
|
||||
*/
|
||||
public get membershipID(): string {
|
||||
return this.memberId;
|
||||
}
|
||||
|
||||
public get membershipID(): string {
|
||||
/**
|
||||
* 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 envents where we needed to update them
|
||||
// we only need the field for the legacy member events where we needed to update them
|
||||
// synapse ignores sending state events if they have the same content.
|
||||
return this.createdTs().toString();
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.member.id;
|
||||
case "session":
|
||||
default:
|
||||
return (
|
||||
// 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public createdTs(): number {
|
||||
return this.membershipData.created_ts ?? this.parentEvent.getTs();
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
// TODO we need to read the referenced (relation) event if available to get the real created_ts
|
||||
return this.matrixEventData.ts;
|
||||
case "session":
|
||||
default:
|
||||
return data.created_ts ?? this.matrixEventData.ts;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the absolute expiry timestamp of the membership.
|
||||
* @returns The absolute expiry time of the membership as a unix timestamp in milliseconds or undefined if not applicable
|
||||
*/
|
||||
public getAbsoluteExpiry(): number {
|
||||
// TODO: calculate this from the MatrixRTCSession join configuration directly
|
||||
return this.createdTs() + (this.membershipData.expires ?? DEFAULT_EXPIRE_DURATION);
|
||||
public getAbsoluteExpiry(): number | undefined {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return undefined;
|
||||
case "session":
|
||||
default:
|
||||
// TODO: calculate this from the MatrixRTCSession join configuration directly
|
||||
return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The number of milliseconds until the membership expires or undefined if applicable
|
||||
*/
|
||||
public getMsUntilExpiry(): number {
|
||||
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
|
||||
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
|
||||
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
|
||||
return this.getAbsoluteExpiry() - Date.now();
|
||||
public getMsUntilExpiry(): number | undefined {
|
||||
const { kind } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return undefined;
|
||||
case "session":
|
||||
default:
|
||||
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
|
||||
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
|
||||
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
|
||||
return this.getAbsoluteExpiry()! - Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the membership has expired, otherwise false
|
||||
*/
|
||||
public isExpired(): boolean {
|
||||
return this.getMsUntilExpiry() <= 0;
|
||||
}
|
||||
|
||||
public getPreferredFoci(): Focus[] {
|
||||
return this.membershipData.foci_preferred;
|
||||
}
|
||||
|
||||
public getFocusSelection(): string | undefined {
|
||||
const focusActive = this.membershipData.focus_active;
|
||||
if (isLivekitFocusActive(focusActive)) {
|
||||
return focusActive.focus_selection;
|
||||
const { kind } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return false;
|
||||
case "session":
|
||||
default:
|
||||
return this.getMsUntilExpiry()! <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ## RTC Membership
|
||||
* Gets the primary transport to use for this RTC membership (m.rtc.member).
|
||||
* This will return the primary transport that is used by this call membership to publish their media.
|
||||
* Directly relates to the `rtc_transports` field.
|
||||
*
|
||||
* ## Legacy session membership
|
||||
* In case of a legacy session membership (m.call.member) this will return the selected transport where
|
||||
* media is published. How this selection happens depends on the `focus_active` field of the session membership.
|
||||
* If the `focus_selection` is `oldest_membership` this will return the transport of the oldest membership
|
||||
* in the room (based on the `created_ts` field of the session membership).
|
||||
* If the `focus_selection` is `multi_sfu` it will return the first transport of the `foci_preferred` list.
|
||||
* (`multi_sfu` is equivalent to how `m.rtc.member` `rtc_transports` work).
|
||||
* @param oldestMembership For backwards compatibility with session membership (legacy). Unused in case of RTC membership.
|
||||
* Always required to make the consumer not care if it deals with RTC or session memberships.
|
||||
* @returns The transport this membership uses to publish media or undefined if no transport is available.
|
||||
*/
|
||||
public getTransport(oldestMembership: CallMembership): Transport | undefined {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.rtc_transports[0];
|
||||
case "session":
|
||||
switch (data.focus_active.focus_selection) {
|
||||
case "multi_sfu":
|
||||
return data.foci_preferred[0];
|
||||
case "oldest_membership":
|
||||
if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0];
|
||||
if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The focus_active filed of the session membership (m.call.member).
|
||||
* @deprecated focus_active is not used and will be removed in future versions.
|
||||
*/
|
||||
public getFocusActive(): LivekitFocusSelection | undefined {
|
||||
const { kind, data } = this.membershipData;
|
||||
if (kind === "session") return data.focus_active;
|
||||
return undefined;
|
||||
}
|
||||
/**
|
||||
* The value of the `rtc_transports` field for RTC memberships (m.rtc.member).
|
||||
* Or the value of the `foci_preferred` field for legacy session memberships (m.call.member).
|
||||
*/
|
||||
public get transports(): Transport[] {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.rtc_transports;
|
||||
case "session":
|
||||
default:
|
||||
return data.foci_preferred;
|
||||
}
|
||||
}
|
||||
public get kind(): MembershipData["kind"] {
|
||||
return this.membershipData.kind;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +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 {
|
||||
type EnabledTransports,
|
||||
RoomAndToDeviceEvents,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.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.
|
||||
@@ -43,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:
|
||||
@@ -72,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
|
||||
@@ -84,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;
|
||||
}
|
||||
@@ -119,10 +157,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
|
||||
|
||||
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
// Deprecate RoomKeyTransport: this can get removed.
|
||||
if (this.transport instanceof RoomAndToDeviceTransport) {
|
||||
this.transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, this.onTransportChanged);
|
||||
}
|
||||
|
||||
this.transport.start();
|
||||
if (this.joinConfig?.manageMediaKeys) {
|
||||
this.makeNewSenderKey();
|
||||
@@ -134,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();
|
||||
|
||||
@@ -155,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
|
||||
@@ -210,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;
|
||||
}
|
||||
|
||||
@@ -248,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,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!");
|
||||
@@ -302,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) {
|
||||
@@ -315,20 +342,18 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
};
|
||||
|
||||
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
|
||||
this.requestSendCurrentKey();
|
||||
};
|
||||
|
||||
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
this.logger.debug(`Received key over key transport ${userId}:${deviceId} at index ${index}`);
|
||||
this.setEncryptionKey(userId, deviceId, index, keyBase64Encoded, timestamp);
|
||||
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()}`),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,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;
|
||||
}
|
||||
@@ -388,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).
|
||||
@@ -400,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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,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,
|
||||
|
||||
@@ -15,8 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
import type { Focus } from "./focus.ts";
|
||||
import type { RTCCallIntent, Status } from "./types.ts";
|
||||
import type { RTCCallIntent, Status, Transport } from "./types.ts";
|
||||
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
|
||||
export enum MembershipManagerEvent {
|
||||
@@ -27,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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -41,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.
|
||||
@@ -77,13 +84,22 @@ 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.
|
||||
* @param fociActive the active focus to use in the joined RTC membership event.
|
||||
* If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously.
|
||||
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
|
||||
* membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership`
|
||||
* transport selection will be used instead.
|
||||
* @throws can throw if it exceeds a configured maximum retry.
|
||||
*/
|
||||
join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void;
|
||||
join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void;
|
||||
/**
|
||||
* Send all necessary events to make this user leave the RTC session.
|
||||
* @param timeout the maximum duration in ms until the promise is forced to resolve.
|
||||
@@ -95,11 +111,6 @@ export interface IMembershipManager
|
||||
* Call this if the MatrixRTC session members have changed.
|
||||
*/
|
||||
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
|
||||
/**
|
||||
* The used active focus in the currently joined session.
|
||||
* @returns the used active focus in the currently joined session or undefined if not joined.
|
||||
*/
|
||||
getActiveFocus(): Focus | undefined;
|
||||
|
||||
/**
|
||||
* Update the intent of a membership on the call (e.g. user is now providing a video feed)
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Focus } from "./focus.ts";
|
||||
|
||||
export interface LivekitFocusConfig extends Focus {
|
||||
type: "livekit";
|
||||
livekit_service_url: string;
|
||||
}
|
||||
|
||||
export const isLivekitFocusConfig = (object: any): object is LivekitFocusConfig =>
|
||||
object.type === "livekit" && "livekit_service_url" in object;
|
||||
|
||||
export interface LivekitFocus extends LivekitFocusConfig {
|
||||
livekit_alias: string;
|
||||
}
|
||||
|
||||
export const isLivekitFocus = (object: any): object is LivekitFocus =>
|
||||
isLivekitFocusConfig(object) && "livekit_alias" in object;
|
||||
|
||||
export interface LivekitFocusActive extends Focus {
|
||||
type: "livekit";
|
||||
focus_selection: "oldest_membership";
|
||||
}
|
||||
export const isLivekitFocusActive = (object: any): object is LivekitFocusActive =>
|
||||
object.type === "livekit" && "focus_selection" in object;
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Transport } from "./types.ts";
|
||||
|
||||
export interface LivekitTransportConfig extends Transport {
|
||||
type: "livekit";
|
||||
livekit_service_url: string;
|
||||
}
|
||||
|
||||
export const isLivekitTransportConfig = (object: any): object is LivekitTransportConfig =>
|
||||
object.type === "livekit" && "livekit_service_url" in object;
|
||||
|
||||
export interface LivekitTransport extends LivekitTransportConfig {
|
||||
livekit_alias: string;
|
||||
}
|
||||
|
||||
export const isLivekitTransport = (object: any): object is LivekitTransport =>
|
||||
isLivekitTransportConfig(object) && "livekit_alias" in object;
|
||||
|
||||
/**
|
||||
* @deprecated, this is just needed for the old focus active / focus fields of a call membership.
|
||||
* Not needed for new implementations.
|
||||
*/
|
||||
export interface LivekitFocusSelection extends Transport {
|
||||
type: "livekit";
|
||||
focus_selection: "oldest_membership" | "multi_sfu";
|
||||
}
|
||||
/**
|
||||
* @deprecated see LivekitFocusSelection
|
||||
*/
|
||||
export const isLivekitFocusSelection = (object: any): object is LivekitFocusSelection =>
|
||||
object.type === "livekit" && "focus_selection" in object;
|
||||
+315
-184
@@ -24,32 +24,29 @@ import { KnownMembership } from "../@types/membership.ts";
|
||||
import { type ISendEventResponse } from "../@types/requests.ts";
|
||||
import { CallMembership } from "./CallMembership.ts";
|
||||
import { RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { MembershipManager } from "./MembershipManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts";
|
||||
import { type CallMembershipIdentityParts, EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { deepCompare, logDurationSync } from "../utils.ts";
|
||||
import {
|
||||
type Statistics,
|
||||
type RTCNotificationType,
|
||||
type Status,
|
||||
type IRTCNotificationContent,
|
||||
type ICallNotifyContent,
|
||||
type RTCCallIntent,
|
||||
import type {
|
||||
Statistics,
|
||||
RTCNotificationType,
|
||||
Status,
|
||||
IRTCNotificationContent,
|
||||
ICallNotifyContent,
|
||||
RTCCallIntent,
|
||||
Transport,
|
||||
} from "./types.ts";
|
||||
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
type MembershipManagerEventHandlerMap,
|
||||
type IMembershipManager,
|
||||
} from "./IMembershipManager.ts";
|
||||
import { RTCEncryptionManager } from "./RTCEncryptionManager.ts";
|
||||
import {
|
||||
RoomAndToDeviceEvents,
|
||||
type RoomAndToDeviceEventsHandlerMap,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
import { TypedReEmitter } from "../ReEmitter.ts";
|
||||
import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
||||
import { TypedReEmitter } from "../ReEmitter.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts";
|
||||
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
|
||||
/**
|
||||
* Events emitted by MatrixRTCSession
|
||||
@@ -76,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]: (
|
||||
@@ -103,10 +101,17 @@ export interface SessionConfig {
|
||||
/**
|
||||
* The session description is used to identify a session. Used in the state event.
|
||||
*/
|
||||
export interface SessionDescription {
|
||||
export interface SlotDescription {
|
||||
id: string;
|
||||
application: string;
|
||||
}
|
||||
export function slotIdToDescription(slotId: string): SlotDescription {
|
||||
const [application, id] = slotId.split("#");
|
||||
return { application, id };
|
||||
}
|
||||
export function slotDescriptionToId(slotDescription: SlotDescription): string {
|
||||
return `${slotDescription.application}#${slotDescription.id}`;
|
||||
}
|
||||
|
||||
// The names follow these principles:
|
||||
// - we use the technical term delay if the option is related to delayed events.
|
||||
@@ -116,14 +121,6 @@ export interface SessionDescription {
|
||||
// - we use a `Ms` postfix if the option is a duration to avoid using words like:
|
||||
// `time`, `duration`, `delay`, `timeout`... that might be mistaken/confused with technical terms.
|
||||
export interface MembershipConfig {
|
||||
/**
|
||||
* Use the new Manager.
|
||||
*
|
||||
* Default: `false`.
|
||||
* @deprecated does nothing anymore we always default to the new membership manager.
|
||||
*/
|
||||
useNewMembershipManager?: boolean;
|
||||
|
||||
/**
|
||||
* The timeout (in milliseconds) after we joined the call, that our membership should expire
|
||||
* unless we have explicitly updated it.
|
||||
@@ -182,9 +179,18 @@ 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;
|
||||
|
||||
/**
|
||||
* Send membership using sticky events rather than state events.
|
||||
* This also make the client use the new m.rtc.member MSC4354 event format. (instead of m.call.member)
|
||||
*
|
||||
* **WARNING**: This is an unstable feature and not all clients will support it.
|
||||
*/
|
||||
unstableSendStickyEvents?: boolean;
|
||||
}
|
||||
|
||||
export interface EncryptionConfig {
|
||||
@@ -230,18 +236,29 @@ export interface EncryptionConfig {
|
||||
}
|
||||
export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig;
|
||||
|
||||
interface SessionMembershipsForSlotOpts {
|
||||
/**
|
||||
* Listen for incoming sticky member events. If disabled, this session will
|
||||
* ignore any incoming sticky events.
|
||||
*/
|
||||
listenForStickyEvents: boolean;
|
||||
/**
|
||||
* Listen for incoming member state events (legacy). If disabled, this session will
|
||||
* ignore any incoming state events.
|
||||
*/
|
||||
listenForMemberStateEvents: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
|
||||
* This class doesn't deal with media at all, just membership & properties of a session.
|
||||
*/
|
||||
export class MatrixRTCSession extends TypedEventEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap
|
||||
MatrixRTCSessionEvent | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap & MembershipManagerEventHandlerMap
|
||||
> {
|
||||
private membershipManager?: IMembershipManager;
|
||||
private encryptionManager?: IEncryptionManager;
|
||||
// The session Id of the call, this is the call_id of the call Member event.
|
||||
private _callId: string | undefined;
|
||||
private joinConfig?: SessionConfig;
|
||||
private logger: Logger;
|
||||
|
||||
@@ -253,6 +270,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
*/
|
||||
private expiryTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
public memberships: CallMembership[] = [];
|
||||
|
||||
/**
|
||||
* The statistics for this session.
|
||||
*/
|
||||
@@ -269,94 +288,142 @@ 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.
|
||||
*
|
||||
* It can be undefined since the callId is only known once the first membership joins.
|
||||
* The callId is the property that, per definition, groups memberships into one call.
|
||||
* @deprecated use `slotId` instead.
|
||||
*/
|
||||
public get callId(): string | undefined {
|
||||
return this._callId;
|
||||
return this.slotDescription?.id;
|
||||
}
|
||||
/**
|
||||
* The slotId of the call.
|
||||
* `{application}#{appSpecificId}`
|
||||
* It can be undefined since the slotId is only known once the first membership joins.
|
||||
* The slotId is the property that, per definition, groups memberships into one call.
|
||||
*/
|
||||
public get slotId(): string | undefined {
|
||||
return slotDescriptionToId(this.slotDescription);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead.
|
||||
* @returns an empty array.
|
||||
*/
|
||||
public static callMembershipsForRoom(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
|
||||
): CallMembership[] {
|
||||
rootLogger.error(
|
||||
`[MatrixRTCSession ${room.roomId}] callMembershipsForRoom is deprecated. Use sessionMembershipsForSlot instead.`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
||||
* oldest first.
|
||||
*
|
||||
* @deprecated Use `MatrixRTCSession.sessionMembershipsForRoom` instead.
|
||||
* By default, this will return *both* sticky and member state events.
|
||||
*/
|
||||
public static callMembershipsForRoom(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||
): CallMembership[] {
|
||||
return MatrixRTCSession.sessionMembershipsForRoom(room, {
|
||||
id: "",
|
||||
application: "m.call",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
||||
* oldest first.
|
||||
*/
|
||||
public static sessionMembershipsForRoom(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||
sessionDescription: SessionDescription,
|
||||
): CallMembership[] {
|
||||
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 }: SessionMembershipsForSlotOpts = {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
},
|
||||
): Promise<CallMembership[]> {
|
||||
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!roomState) {
|
||||
logger.warn("Couldn't get state for room " + room.roomId);
|
||||
throw new Error("Could't get state for room " + room.roomId);
|
||||
let callMemberEvents = [] as MatrixEvent[];
|
||||
if (listenForStickyEvents) {
|
||||
// prefill with sticky events
|
||||
callMemberEvents = [...room._unstable_getStickyEvents()].filter(
|
||||
(e) => e.getType() === EventType.RTCMembership,
|
||||
);
|
||||
}
|
||||
if (listenForMemberStateEvents) {
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!roomState) {
|
||||
logger.warn("Couldn't get state for room " + room.roomId + "using empty membership array");
|
||||
return [];
|
||||
}
|
||||
const callMemberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
|
||||
callMemberEvents = callMemberEvents.concat(
|
||||
callMemberStateEvents.filter(
|
||||
(callMemberStateEvent) =>
|
||||
!callMemberEvents.some(
|
||||
// only care about state events which have keys which we have not yet seen in the sticky events.
|
||||
(stickyEvent) =>
|
||||
stickyEvent.getContent().msc4354_sticky_key === callMemberStateEvent.getStateKey(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
const callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
|
||||
|
||||
const callMemberships: CallMembership[] = [];
|
||||
for (const memberEvent of callMemberEvents) {
|
||||
const content = memberEvent.getContent();
|
||||
const eventKeysCount = Object.keys(content).length;
|
||||
// Ignore sticky keys for the count
|
||||
const eventKeysCount = Object.keys(content).filter((k) => k !== "msc4354_sticky_key").length;
|
||||
// Dont even bother about empty events (saves us from costly type/"key in" checks in bigger rooms)
|
||||
if (eventKeysCount === 0) continue;
|
||||
|
||||
const membershipContents: any[] = [];
|
||||
let membershipContent = undefined;
|
||||
|
||||
// We first decide if its a MSC4143 event (per device state key)
|
||||
if (eventKeysCount > 1 && "focus_active" in content) {
|
||||
if (eventKeysCount > 1 && "application" in content) {
|
||||
// We have a MSC4143 event membership event
|
||||
membershipContents.push(content);
|
||||
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) {
|
||||
try {
|
||||
const membership = new CallMembership(memberEvent, membershipData);
|
||||
if (!("application" in membershipContent)) {
|
||||
// This is a left membership event, ignore it here to not log warnings.
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const membershipData = CallMembership.membershipDataFromMatrixEvent(memberEvent);
|
||||
|
||||
if (!deepCompare(membership.sessionDescription, sessionDescription)) {
|
||||
logger.info(
|
||||
`Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.sessionDescription)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const membership = new CallMembership(
|
||||
memberEvent,
|
||||
membershipData,
|
||||
await CallMembership.computeRtcBackendIdentity(memberEvent, membershipData),
|
||||
rootLogger,
|
||||
);
|
||||
|
||||
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 (!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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,41 +431,25 @@ 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.sessionForRoom` with sessionDescription `{ id: "", application: "m.call" }` instead.
|
||||
*/
|
||||
public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession {
|
||||
const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, { id: "", application: "m.call" });
|
||||
return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the MatrixRTC session for the room.
|
||||
* This returned session can be used to find out if there are active sessions
|
||||
* for the requested room and `sessionDescription`.
|
||||
* for the requested room and `slotDescription`.
|
||||
*/
|
||||
public static sessionForRoom(
|
||||
public static sessionForSlot(
|
||||
client: MatrixClient,
|
||||
room: Room,
|
||||
sessionDescription: SessionDescription,
|
||||
slotDescription: SlotDescription,
|
||||
opts?: SessionMembershipsForSlotOpts,
|
||||
): MatrixRTCSession {
|
||||
const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription);
|
||||
|
||||
return new MatrixRTCSession(client, room, callMemberships, sessionDescription);
|
||||
return new MatrixRTCSession(client, room, slotDescription, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -421,17 +472,24 @@ 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<
|
||||
MatrixClient,
|
||||
| "getUserId"
|
||||
| "getDeviceId"
|
||||
| "sendEvent"
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "sendEvent"
|
||||
| "_unstable_cancelScheduledDelayedEvent"
|
||||
| "_unstable_restartScheduledDelayedEvent"
|
||||
| "_unstable_sendScheduledDelayedEvent"
|
||||
| "_unstable_sendStickyEvent"
|
||||
| "_unstable_sendStickyDelayedEvent"
|
||||
| "cancelPendingEvent"
|
||||
| "encryptAndSendToDevice"
|
||||
| "off"
|
||||
@@ -442,22 +500,21 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
Room,
|
||||
"getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off"
|
||||
>,
|
||||
public memberships: CallMembership[],
|
||||
/**
|
||||
* The session description is used to define the exact session this object is tracking.
|
||||
* A session is distinct from another session if one of those properties differ: `roomSubset.roomId`, `sessionDescription.application`, `sessionDescription.id`.
|
||||
*/
|
||||
public readonly sessionDescription: SessionDescription,
|
||||
|
||||
public readonly slotDescription: SlotDescription,
|
||||
private readonly calculateMembershipsOpts?: SessionMembershipsForSlotOpts,
|
||||
) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
|
||||
this._callId = memberships[0]?.sessionDescription.id;
|
||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
|
||||
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
|
||||
this.roomSubset.on(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();
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns true if we intend to be participating in the MatrixRTC session.
|
||||
* This is determined by checking if the relativeExpiry has been set.
|
||||
@@ -475,12 +532,14 @@ 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);
|
||||
}
|
||||
|
||||
private reEmitter = new TypedReEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap
|
||||
MatrixRTCSessionEvent | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap & MembershipManagerEventHandlerMap
|
||||
>(this);
|
||||
|
||||
/**
|
||||
@@ -490,28 +549,36 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* This will not subscribe to updates: remember to call subscribe() separately if
|
||||
* desired.
|
||||
* This method will return immediately and the session will be joined in the background.
|
||||
*
|
||||
* @param fociActive - The object representing the active focus. (This depends on the focus type.)
|
||||
* @param fociPreferred - The list of preferred foci this member proposes to use/knows/has access to.
|
||||
* For the livekit case this is a list of foci generated from the homeserver well-known, the current rtc session,
|
||||
* or optionally other room members homeserver well known.
|
||||
* @param 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
|
||||
* membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership`
|
||||
* transport selection will be used instead.
|
||||
* @param joinConfig - Additional configuration for the joined session.
|
||||
*/
|
||||
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
|
||||
public joinRTCSession(
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
fociPreferred: Transport[],
|
||||
multiSfuFocus?: Transport,
|
||||
joinConfig?: JoinSessionConfig,
|
||||
): void {
|
||||
if (this.isJoined()) {
|
||||
this.logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
|
||||
return;
|
||||
} else {
|
||||
// Create MembershipManager and pass the RTCSession logger (with room id info)
|
||||
|
||||
this.membershipManager = new MembershipManager(
|
||||
joinConfig,
|
||||
this.roomSubset,
|
||||
this.client,
|
||||
() => this.getOldestMembership(),
|
||||
this.sessionDescription,
|
||||
this.logger,
|
||||
);
|
||||
this.membershipManager = joinConfig?.unstableSendStickyEvents
|
||||
? new StickyEventMembershipManager(
|
||||
joinConfig,
|
||||
this.roomSubset,
|
||||
this.client,
|
||||
this.slotDescription,
|
||||
ownMembershipIdentity.memberId,
|
||||
this.logger,
|
||||
)
|
||||
: new MembershipManager(joinConfig, this.roomSubset, this.client, this.slotDescription, this.logger);
|
||||
|
||||
this.reEmitter.reEmit(this.membershipManager!, [
|
||||
MembershipManagerEvent.ProbablyLeft,
|
||||
@@ -522,45 +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];
|
||||
// Deprecate RoomKeyTransport: only ToDeviceKeyTransport is needed once deprecated
|
||||
const roomKeyTransport = new RoomKeyTransport(room, client, statistics);
|
||||
const toDeviceTransport = new ToDeviceKeyTransport(uId, dId, room.roomId, client, statistics);
|
||||
transport = new RoomAndToDeviceTransport(toDeviceTransport, roomKeyTransport, this.logger);
|
||||
|
||||
// Expose the changes so the ui can display the currently used transport.
|
||||
this.reEmitter.reEmit(transport, [RoomAndToDeviceEvents.EnabledTransportsChanged]);
|
||||
const transport = new ToDeviceKeyTransport(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,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -571,7 +642,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
this.pendingNotificationToSend = this.joinConfig?.notificationType;
|
||||
|
||||
// Join!
|
||||
this.membershipManager!.join(fociPreferred, fociActive, (e) => {
|
||||
this.membershipManager!.join(fociPreferred, multiSfuFocus, (e) => {
|
||||
this.logger.error("MembershipManager encountered an unrecoverable error: ", e);
|
||||
this.emit(MatrixRTCSessionEvent.MembershipManagerError, e);
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, this.isJoined());
|
||||
@@ -581,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.
|
||||
@@ -606,16 +695,23 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
|
||||
return await leavePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active focus from the current CallMemberState event
|
||||
* @returns The focus that is currently in use to connect to this session. This is undefined
|
||||
* if the client is not connected to this session.
|
||||
* This returns the focus in use by the oldest membership.
|
||||
* Do not use since this might be just the focus for the oldest membership. others might use a different focus.
|
||||
* @deprecated use `member.getTransport(session.getOldestMembership())` instead for the specific member you want to get the focus for.
|
||||
*/
|
||||
public getActiveFocus(): Focus | undefined {
|
||||
return this.membershipManager?.getActiveFocus();
|
||||
public getFocusInUse(): Transport | undefined {
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getTransport(oldestMembership);
|
||||
}
|
||||
|
||||
/**
|
||||
* The used focusActive of the oldest membership (to find out the selection type multi-sfu or oldest membership active focus)
|
||||
* @deprecated does not work with m.rtc.member. Do not rely on it.
|
||||
*/
|
||||
public getActiveFocus(): Transport | undefined {
|
||||
return this.getOldestMembership()?.getFocusActive();
|
||||
}
|
||||
public getOldestMembership(): CallMembership | undefined {
|
||||
return this.memberships[0];
|
||||
}
|
||||
@@ -646,28 +742,20 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
await this.membershipManager?.updateCallIntent(callIntent);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is used when the user is not yet connected to the Session but wants to know what focus
|
||||
* the users in the session are using to make a decision how it wants/should connect.
|
||||
*
|
||||
* See also `getActiveFocus`
|
||||
* @returns The focus which should be used when joining this session.
|
||||
*/
|
||||
public getFocusInUse(): Focus | undefined {
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
if (oldestMembership?.getFocusSelection() === "oldest_membership") {
|
||||
return oldestMembership.getPreferredFoci()[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-emit an EncryptionKeyChanged event for each tracked encryption key. This can be used to export
|
||||
* 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -692,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,17 +845,55 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
/**
|
||||
* Call this when the Matrix room members have changed.
|
||||
*/
|
||||
public onRoomMemberUpdate = (): void => {
|
||||
this.recalculateSessionMembers();
|
||||
private readonly onRoomMemberUpdate = (): void => {
|
||||
this.ensureRecalculateSessionMembers();
|
||||
};
|
||||
|
||||
/**
|
||||
* Call this when a sticky event update has occured.
|
||||
*/
|
||||
private readonly onStickyEventUpdate: RoomStickyEventsMap[RoomStickyEventsEvent.Update] = (
|
||||
added,
|
||||
updated,
|
||||
removed,
|
||||
): void => {
|
||||
if (
|
||||
[...added, ...removed, ...updated.flatMap((v) => [v.current, v.previous])].some(
|
||||
(e) => e.getType() === EventType.RTCMembership,
|
||||
)
|
||||
) {
|
||||
this.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.
|
||||
*
|
||||
@@ -775,11 +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.sessionMembershipsForRoom(this.room, this.sessionDescription);
|
||||
|
||||
this._callId = this._callId ?? this.memberships[0]?.sessionDescription.id;
|
||||
this.memberships = await MatrixRTCSession.sessionMembershipsForSlot(
|
||||
this.room,
|
||||
this.slotDescription,
|
||||
this.calculateMembershipsOpts,
|
||||
);
|
||||
|
||||
const changed =
|
||||
oldMemberships.length != this.memberships.length ||
|
||||
@@ -812,6 +941,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
// If anyone else joins the session it is no longer our responsibility to send the notification.
|
||||
// (If we were the joiner we already did sent the notification in the block above.)
|
||||
if (this.memberships.length > 0) this.pendingNotificationToSend = undefined;
|
||||
} else {
|
||||
this.logger.debug(`No membership changes detected for room ${this.roomSubset.roomId}`);
|
||||
}
|
||||
// This also needs to be done if `changed` = false
|
||||
// A member might have updated their fingerprint (created_ts)
|
||||
|
||||
@@ -18,9 +18,9 @@ import { type Logger } from "../logger.ts";
|
||||
import { type MatrixClient, ClientEvent } from "../client.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type RoomState, RoomStateEvent } from "../models/room-state.ts";
|
||||
import { RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { MatrixRTCSession, type SessionDescription } from "./MatrixRTCSession.ts";
|
||||
import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
|
||||
export enum MatrixRTCSessionManagerEvents {
|
||||
@@ -56,7 +56,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
public constructor(
|
||||
rootLogger: Logger,
|
||||
private client: MatrixClient,
|
||||
private readonly sessionDescription: SessionDescription = { id: "", application: "m.call" }, // Default to the Matrix Call application
|
||||
private readonly slotDescription: SlotDescription = { application: "m.call", id: "" }, // Default to the Matrix Call application
|
||||
) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
|
||||
@@ -66,13 +66,14 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
// We shouldn't need to null-check here, but matrix-client.spec.ts mocks getRooms
|
||||
// returning nothing, and breaks tests if you change it to return an empty array :'(
|
||||
for (const room of this.client.getRooms() ?? []) {
|
||||
const session = MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription);
|
||||
const session = MatrixRTCSession.sessionForSlot(this.client, room, this.slotDescription);
|
||||
if (session.memberships.length > 0) {
|
||||
this.roomSessions.set(room.roomId, session);
|
||||
}
|
||||
}
|
||||
|
||||
this.client.on(ClientEvent.Room, this.onRoom);
|
||||
this.client.on(ClientEvent.Event, this.onEvent);
|
||||
this.client.on(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
@@ -83,6 +84,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
this.roomSessions.clear();
|
||||
|
||||
this.client.off(ClientEvent.Room, this.onRoom);
|
||||
this.client.off(ClientEvent.Event, this.onEvent);
|
||||
this.client.off(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
@@ -102,7 +104,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
if (!this.roomSessions.has(room.roomId)) {
|
||||
this.roomSessions.set(
|
||||
room.roomId,
|
||||
MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription),
|
||||
MatrixRTCSession.sessionForSlot(this.client, room, this.slotDescription),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,22 +112,34 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
}
|
||||
|
||||
private onRoom = (room: Room): void => {
|
||||
this.refreshRoom(room);
|
||||
void this.refreshRoom(room);
|
||||
};
|
||||
|
||||
private onRoomState = (event: MatrixEvent, _state: RoomState): void => {
|
||||
private readonly onEvent = (event: MatrixEvent): void => {
|
||||
if (!event.unstableStickyExpiresAt) return; // Not sticky, not interested.
|
||||
|
||||
if (event.getType() !== EventType.RTCMembership) return;
|
||||
|
||||
const room = this.client.getRoom(event.getRoomId());
|
||||
if (!room) return;
|
||||
|
||||
void this.refreshRoom(room);
|
||||
};
|
||||
|
||||
private readonly onRoomState = (event: MatrixEvent): void => {
|
||||
if (event.getType() !== EventType.GroupCallMemberPrefix) {
|
||||
return;
|
||||
}
|
||||
const room = this.client.getRoom(event.getRoomId());
|
||||
if (!room) {
|
||||
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.getType() == EventType.GroupCallMemberPrefix) {
|
||||
this.refreshRoom(room);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -135,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;
|
||||
|
||||
|
||||
+225
-126
@@ -15,20 +15,29 @@ limitations under the License.
|
||||
*/
|
||||
import { AbortError } from "p-retry";
|
||||
|
||||
import { EventType } from "../@types/event.ts";
|
||||
import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
||||
import { type MatrixClient } from "../client.ts";
|
||||
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
|
||||
import { EventType, RelationType } from "../@types/event.ts";
|
||||
import { type ISendEventResponse, type SendDelayedEventResponse } from "../@types/requests.ts";
|
||||
import { type EmptyObject } from "../@types/common.ts";
|
||||
import type { MatrixClient } from "../client.ts";
|
||||
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { isMyMembership, type RTCCallIntent, Status } from "./types.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type SessionDescription, type MembershipConfig, type SessionConfig } from "./MatrixRTCSession.ts";
|
||||
import {
|
||||
type CallMembership,
|
||||
DEFAULT_EXPIRE_DURATION,
|
||||
type RtcMembershipData,
|
||||
type SessionMembershipData,
|
||||
} from "./CallMembership.ts";
|
||||
import { type Transport, isMyMembership, type RTCCallIntent, Status } from "./types.ts";
|
||||
import {
|
||||
type SlotDescription,
|
||||
type MembershipConfig,
|
||||
type SessionConfig,
|
||||
slotDescriptionToId,
|
||||
} from "./MatrixRTCSession.ts";
|
||||
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
type IMembershipManager,
|
||||
@@ -36,12 +45,11 @@ import {
|
||||
} from "./IMembershipManager.ts";
|
||||
|
||||
/* MembershipActionTypes:
|
||||
|
||||
On Join: ───────────────┐ ┌───────────────(1)───────────┐
|
||||
▼ ▼ │
|
||||
┌────────────────┐ │
|
||||
│SendDelayedEvent│ ──────(2)───┐ │
|
||||
└────────────────┘ │ │
|
||||
└────────────────┘ │ │
|
||||
│(3) │ │
|
||||
▼ │ │
|
||||
┌─────────────┐ │ │
|
||||
@@ -52,9 +60,9 @@ On Join: ───────────────┐ ┌─────
|
||||
┌────────────┐ │ │ ┌───────────────────┐ │
|
||||
│UpdateExpiry│ (s) (s)|RestartDelayedEvent│ │
|
||||
└────────────┘ │ │ └───────────────────┘ │
|
||||
│ │ │ │ │ │
|
||||
└─────┘ └──────┘ └───────┘
|
||||
|
||||
│ │ │ │ │ │
|
||||
└─────┘ └──────┘ └───────┘
|
||||
|
||||
On Leave: ───────── STOP ALL ABOVE
|
||||
▼
|
||||
┌────────────────────────────────┐
|
||||
@@ -77,6 +85,12 @@ On Leave: ───────── STOP ALL ABOVE
|
||||
(s) Successful restart/resend
|
||||
*/
|
||||
|
||||
/**
|
||||
* Call membership should always remain sticky for this amount
|
||||
* of time.
|
||||
*/
|
||||
const MEMBERSHIP_STICKY_DURATION_MS = 60 * 60 * 1000; // 60 minutes
|
||||
|
||||
/**
|
||||
* The different types of actions the MembershipManager can take.
|
||||
* @internal
|
||||
@@ -137,6 +151,30 @@ export interface MembershipManagerState {
|
||||
probablyLeft: boolean;
|
||||
}
|
||||
|
||||
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
insert: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
};
|
||||
}
|
||||
|
||||
function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
replace: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
};
|
||||
}
|
||||
|
||||
type MembershipManagerClient = Pick<
|
||||
MatrixClient,
|
||||
| "getUserId"
|
||||
| "getDeviceId"
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "_unstable_cancelScheduledDelayedEvent"
|
||||
| "_unstable_restartScheduledDelayedEvent"
|
||||
| "_unstable_sendScheduledDelayedEvent"
|
||||
>;
|
||||
|
||||
/**
|
||||
* This class is responsible for sending all events relating to the own membership of a matrixRTC call.
|
||||
* It has the following tasks:
|
||||
@@ -155,8 +193,8 @@ export class MembershipManager
|
||||
implements IMembershipManager
|
||||
{
|
||||
private activated = false;
|
||||
private logger: Logger;
|
||||
private callIntent: RTCCallIntent | undefined;
|
||||
private readonly logger: Logger;
|
||||
protected callIntent: RTCCallIntent | undefined;
|
||||
|
||||
public isActivated(): boolean {
|
||||
return this.activated;
|
||||
@@ -169,18 +207,21 @@ export class MembershipManager
|
||||
/**
|
||||
* Puts the MembershipManager in a state where it tries to be joined.
|
||||
* It will send delayed events and membership events
|
||||
* @param fociPreferred
|
||||
* @param focusActive
|
||||
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||
* If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously.
|
||||
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
|
||||
* membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership`
|
||||
* transport selection will be used instead.
|
||||
* @param onError This will be called once the membership manager encounters an unrecoverable error.
|
||||
* This should bubble up the the frontend to communicate that the call does not work in the current environment.
|
||||
*/
|
||||
public join(fociPreferred: Focus[], focusActive?: Focus, onError?: (error: unknown) => void): void {
|
||||
public join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void {
|
||||
if (this.scheduler.running) {
|
||||
this.logger.error("MembershipManager is already running. Ignoring join request.");
|
||||
return;
|
||||
}
|
||||
this.fociPreferred = fociPreferred;
|
||||
this.focusActive = focusActive;
|
||||
this.rtcTransport = multiSfuFocus;
|
||||
this.leavePromiseResolvers = undefined;
|
||||
this.activated = true;
|
||||
this.oldStatus = this.status;
|
||||
@@ -235,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
|
||||
@@ -266,25 +302,6 @@ export class MembershipManager
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public getActiveFocus(): Focus | undefined {
|
||||
if (this.focusActive) {
|
||||
// A livekit active focus
|
||||
if (isLivekitFocusActive(this.focusActive)) {
|
||||
if (this.focusActive.focus_selection === "oldest_membership") {
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
} else {
|
||||
this.logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
|
||||
}
|
||||
} else {
|
||||
// We do not understand the membership format (could be legacy). We default to oldestMembership
|
||||
// Once there are other methods this is a hard error!
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
}
|
||||
|
||||
public async updateCallIntent(callIntent: RTCCallIntent): Promise<void> {
|
||||
if (!this.activated || !this.ownMembership) {
|
||||
throw Error("You cannot update your intent before joining the call");
|
||||
@@ -302,21 +319,12 @@ export class MembershipManager
|
||||
* @param joinConfig
|
||||
* @param room
|
||||
* @param client
|
||||
* @param getOldestMembership
|
||||
*/
|
||||
public constructor(
|
||||
private joinConfig: (SessionConfig & MembershipConfig) | undefined,
|
||||
private room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
|
||||
private client: Pick<
|
||||
MatrixClient,
|
||||
| "getUserId"
|
||||
| "getDeviceId"
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
>,
|
||||
private getOldestMembership: () => CallMembership | undefined,
|
||||
public readonly sessionDescription: SessionDescription,
|
||||
private readonly joinConfig: (SessionConfig & MembershipConfig) | undefined,
|
||||
protected readonly room: Pick<Room, "roomId" | "getVersion">,
|
||||
protected readonly client: MembershipManagerClient,
|
||||
public readonly slotDescription: SlotDescription,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
@@ -325,6 +333,9 @@ 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.stateKey = this.makeMembershipStateKey(userId, deviceId);
|
||||
this.state = MembershipManager.defaultState;
|
||||
this.callIntent = joinConfig?.callIntent;
|
||||
@@ -370,10 +381,12 @@ export class MembershipManager
|
||||
};
|
||||
}
|
||||
// Membership Event static parameters:
|
||||
private deviceId: string;
|
||||
private stateKey: string;
|
||||
private fociPreferred?: Focus[];
|
||||
private focusActive?: Focus;
|
||||
protected deviceId: 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[];
|
||||
|
||||
// Config:
|
||||
private delayedLeaveEventDelayMsOverride?: number;
|
||||
@@ -388,9 +401,13 @@ export class MembershipManager
|
||||
return this.joinConfig?.membershipEventExpiryHeadroomMs ?? 5_000;
|
||||
}
|
||||
private computeNextExpiryActionTs(iteration: number): number {
|
||||
return this.state.startTime + this.membershipEventExpiryMs * iteration - this.membershipEventExpiryHeadroomMs;
|
||||
return (
|
||||
this.state.startTime +
|
||||
Math.min(this.membershipEventExpiryMs, MEMBERSHIP_STICKY_DURATION_MS) * iteration -
|
||||
this.membershipEventExpiryHeadroomMs
|
||||
);
|
||||
}
|
||||
private get delayedLeaveEventDelayMs(): number {
|
||||
protected get delayedLeaveEventDelayMs(): number {
|
||||
return this.delayedLeaveEventDelayMsOverride ?? this.joinConfig?.delayedLeaveEventDelayMs ?? 8_000;
|
||||
}
|
||||
private get delayedLeaveEventRestartMs(): number {
|
||||
@@ -402,10 +419,10 @@ export class MembershipManager
|
||||
private get maximumNetworkErrorRetryCount(): number {
|
||||
return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10;
|
||||
}
|
||||
|
||||
private get delayedLeaveEventRestartLocalTimeoutMs(): number {
|
||||
return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000;
|
||||
}
|
||||
|
||||
// LOOP HANDLER:
|
||||
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
|
||||
switch (type) {
|
||||
@@ -460,28 +477,29 @@ export class MembershipManager
|
||||
}
|
||||
}
|
||||
|
||||
// an abstraction to switch between sending state or a sticky event
|
||||
protected clientSendDelayedDisconnectMembership: () => Promise<SendDelayedEventResponse> = () =>
|
||||
this.client._unstable_sendDelayedStateEvent(
|
||||
this.room.roomId,
|
||||
{ delay: this.delayedLeaveEventDelayMs },
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{},
|
||||
this.stateKey,
|
||||
);
|
||||
|
||||
// HANDLERS (used in the membershipLoopHandler)
|
||||
private async sendOrResendDelayedLeaveEvent(): Promise<ActionUpdate> {
|
||||
// We can reach this at the start of a call (where we do not yet have a membership: state.hasMemberStateEvent=false)
|
||||
// or during a call if the state event canceled our delayed event or caused by an unexpected error that removed our delayed event.
|
||||
// (Another client could have canceled it, the homeserver might have removed/lost it due to a restart, ...)
|
||||
// In the `then` and `catch` block we treat both cases differently. "if (this.state.hasMemberStateEvent) {} else {}"
|
||||
return await this.client
|
||||
._unstable_sendDelayedStateEvent(
|
||||
this.room.roomId,
|
||||
{
|
||||
delay: this.delayedLeaveEventDelayMs,
|
||||
},
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{}, // leave event
|
||||
this.stateKey,
|
||||
)
|
||||
return await this.clientSendDelayedDisconnectMembership()
|
||||
.then((response) => {
|
||||
this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs;
|
||||
this.setAndEmitProbablyLeft(false);
|
||||
// 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
|
||||
@@ -499,7 +517,7 @@ export class MembershipManager
|
||||
if (this.manageMaxDelayExceededSituation(e)) {
|
||||
return createInsertActionUpdate(repeatActionType);
|
||||
}
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "sendDelayedStateEvent");
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "_unstable_sendDelayedStateEvent");
|
||||
if (update) return update;
|
||||
|
||||
if (this.state.hasMemberStateEvent) {
|
||||
@@ -526,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)) {
|
||||
@@ -566,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
|
||||
@@ -588,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
|
||||
@@ -612,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
|
||||
@@ -629,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);
|
||||
@@ -640,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)
|
||||
@@ -655,14 +677,19 @@ export class MembershipManager
|
||||
});
|
||||
}
|
||||
|
||||
protected clientSendMembership: (
|
||||
myMembership: RtcMembershipData | SessionMembershipData | EmptyObject,
|
||||
) => Promise<ISendEventResponse> = (myMembership) => {
|
||||
return this.client.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
myMembership as EmptyObject | SessionMembershipData,
|
||||
this.stateKey,
|
||||
);
|
||||
};
|
||||
|
||||
private async sendJoinEvent(): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
this.makeMyMembership(this.membershipEventExpiryMs),
|
||||
this.stateKey,
|
||||
)
|
||||
return await this.clientSendMembership(this.makeMyMembership(this.membershipEventExpiryMs))
|
||||
.then(() => {
|
||||
this.setAndEmitProbablyLeft(false);
|
||||
this.state.startTime = Date.now();
|
||||
@@ -702,13 +729,9 @@ export class MembershipManager
|
||||
|
||||
private async updateExpiryOnJoinedEvent(): Promise<ActionUpdate> {
|
||||
const nextExpireUpdateIteration = this.state.expireUpdateIterations + 1;
|
||||
return await this.client
|
||||
.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
|
||||
this.stateKey,
|
||||
)
|
||||
return await this.clientSendMembership(
|
||||
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
|
||||
)
|
||||
.then(() => {
|
||||
// Success, we reset retries and schedule update.
|
||||
this.resetRateLimitCounter(MembershipActionType.UpdateExpiry);
|
||||
@@ -730,8 +753,7 @@ export class MembershipManager
|
||||
});
|
||||
}
|
||||
private async sendFallbackLeaveEvent(): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.stateKey)
|
||||
return await this.clientSendMembership({})
|
||||
.then(() => {
|
||||
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
|
||||
this.state.hasMemberStateEvent = false;
|
||||
@@ -745,8 +767,12 @@ 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.sessionDescription.application}${this.sessionDescription.id}`;
|
||||
const stateKey = `${localUserId}_${localDeviceId}_${this.slotDescription.application}${this.slotDescription.id}`;
|
||||
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
|
||||
return stateKey;
|
||||
} else {
|
||||
@@ -757,19 +783,32 @@ export class MembershipManager
|
||||
/**
|
||||
* Constructs our own membership
|
||||
*/
|
||||
private makeMyMembership(expires: number): SessionMembershipData {
|
||||
const hasPreviousEvent = !!this.ownMembership;
|
||||
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
|
||||
const ownMembership = this.ownMembership;
|
||||
|
||||
const focusObjects =
|
||||
this.rtcTransport === undefined
|
||||
? {
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const,
|
||||
foci_preferred: this.fociPreferred ?? [],
|
||||
}
|
||||
: {
|
||||
focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const,
|
||||
foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])],
|
||||
};
|
||||
return {
|
||||
// TODO: use the new format for m.rtc.member events where call_id becomes session.id
|
||||
"application": this.sessionDescription.application,
|
||||
"call_id": this.sessionDescription.id,
|
||||
"application": this.slotDescription.application,
|
||||
"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,
|
||||
"focus_active": { type: "livekit", focus_selection: "oldest_membership" },
|
||||
"foci_preferred": this.fociPreferred ?? [],
|
||||
"m.call.intent": this.callIntent,
|
||||
...(hasPreviousEvent ? { created_ts: this.ownMembership?.createdTs() } : undefined),
|
||||
...focusObjects,
|
||||
...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -805,7 +844,7 @@ export class MembershipManager
|
||||
return false;
|
||||
}
|
||||
|
||||
private actionUpdateFromErrors(
|
||||
protected actionUpdateFromErrors(
|
||||
error: unknown,
|
||||
type: MembershipActionType,
|
||||
method: string,
|
||||
@@ -853,7 +892,7 @@ export class MembershipManager
|
||||
return createInsertActionUpdate(type, resendDelay);
|
||||
}
|
||||
|
||||
throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + "): " + (error as Error));
|
||||
throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + ")", { cause: error });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -995,16 +1034,76 @@ export class MembershipManager
|
||||
public get probablyLeft(): boolean {
|
||||
return this.state.probablyLeft;
|
||||
}
|
||||
public get delayId(): string | undefined {
|
||||
return this.state.delayId;
|
||||
}
|
||||
}
|
||||
|
||||
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
insert: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Implementation of the Membership manager that uses sticky events
|
||||
* rather than state events.
|
||||
*/
|
||||
export class StickyEventMembershipManager extends MembershipManager {
|
||||
public constructor(
|
||||
joinConfig: (SessionConfig & MembershipConfig) | undefined,
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
|
||||
private readonly clientWithSticky: MembershipManagerClient &
|
||||
Pick<MatrixClient, "_unstable_sendStickyEvent" | "_unstable_sendStickyDelayedEvent">,
|
||||
sessionDescription: SlotDescription,
|
||||
// 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);
|
||||
}
|
||||
|
||||
function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
replace: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
protected clientSendDelayedDisconnectMembership: () => Promise<SendDelayedEventResponse> = () =>
|
||||
this.clientWithSticky._unstable_sendStickyDelayedEvent(
|
||||
this.room.roomId,
|
||||
MEMBERSHIP_STICKY_DURATION_MS,
|
||||
{ delay: this.delayedLeaveEventDelayMs },
|
||||
null,
|
||||
EventType.RTCMembership,
|
||||
{ msc4354_sticky_key: this.memberId },
|
||||
);
|
||||
|
||||
protected clientSendMembership: (
|
||||
myMembership: RtcMembershipData | SessionMembershipData | EmptyObject,
|
||||
) => Promise<ISendEventResponse> = (myMembership) => {
|
||||
return this.clientWithSticky._unstable_sendStickyEvent(
|
||||
this.room.roomId,
|
||||
MEMBERSHIP_STICKY_DURATION_MS,
|
||||
null,
|
||||
EventType.RTCMembership,
|
||||
{ ...myMembership, msc4354_sticky_key: this.memberId },
|
||||
);
|
||||
};
|
||||
|
||||
private static nameMap = new Map([
|
||||
["sendStateEvent", "_unstable_sendStickyEvent"],
|
||||
["sendDelayedStateEvent", "_unstable_sendStickyDelayedEvent"],
|
||||
]);
|
||||
protected actionUpdateFromErrors(e: unknown, t: MembershipActionType, m: string): ActionUpdate | undefined {
|
||||
return super.actionUpdateFromErrors(e, t, StickyEventMembershipManager.nameMap.get(m) ?? "unknown");
|
||||
}
|
||||
|
||||
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
|
||||
const ownMembership = this.ownMembership;
|
||||
|
||||
const relationObject = ownMembership?.eventId
|
||||
? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } }
|
||||
: {};
|
||||
return {
|
||||
application: {
|
||||
type: this.slotDescription.application,
|
||||
...(this.callIntent ? { "m.call.intent": this.callIntent } : {}),
|
||||
},
|
||||
slot_id: slotDescriptionToId(this.slotDescription),
|
||||
rtc_transports: this.rtcTransport ? [this.rtcTransport] : [],
|
||||
member: { device_id: this.deviceId, user_id: this.userId, id: this.memberId },
|
||||
versions: [],
|
||||
...relationObject,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,26 +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,
|
||||
} from "./types.ts";
|
||||
import { getParticipantId, OutdatedKeyFilter } from "./utils.ts";
|
||||
import {
|
||||
type EnabledTransports,
|
||||
RoomAndToDeviceEvents,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
type EncryptionKeyMapKey,
|
||||
type InboundEncryptionSession,
|
||||
type OutboundEncryptionSession,
|
||||
type ParticipantDeviceInfo,
|
||||
type Statistics,
|
||||
} from "./types.ts";
|
||||
import { OutdatedKeyFilter } from "./utils.ts";
|
||||
|
||||
/**
|
||||
* RTCEncryptionManager is used to manage the encryption keys for a call.
|
||||
@@ -51,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;
|
||||
@@ -101,47 +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);
|
||||
// Deprecate RoomKeyTransport: this can get removed.
|
||||
if (this.transport instanceof RoomAndToDeviceTransport) {
|
||||
this.transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, this.onTransportChanged);
|
||||
}
|
||||
|
||||
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
void this.getOwnRtcBackendIdentity(); // precompute own identity
|
||||
|
||||
this.logger?.info(`Joining room`);
|
||||
this.transport.start();
|
||||
}
|
||||
|
||||
@@ -151,29 +236,6 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
this.participantKeyRings.clear();
|
||||
}
|
||||
|
||||
// Temporary for backwards compatibility
|
||||
// TODO: Remove this in the future
|
||||
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
|
||||
this.logger?.info("Transport change detected, restarting key distribution");
|
||||
if (this.currentKeyDistributionPromise) {
|
||||
this.currentKeyDistributionPromise
|
||||
.then(() => {
|
||||
if (this.outboundSession) {
|
||||
this.outboundSession.sharedWith = [];
|
||||
this.ensureKeyDistribution();
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger?.error("Failed to restart key distribution", e);
|
||||
});
|
||||
} else {
|
||||
if (this.outboundSession) {
|
||||
this.outboundSession.sharedWith = [];
|
||||
this.ensureKeyDistribution();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Will ensure that a new key is distributed and used to encrypt our media.
|
||||
* If there is already a key distribution in progress, it will schedule a new distribution round just after the current one is completed.
|
||||
@@ -199,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.
|
||||
*/
|
||||
@@ -252,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
|
||||
@@ -285,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) =>
|
||||
@@ -354,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) {
|
||||
@@ -386,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;
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger, type Logger } from "../logger.ts";
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import type { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import { NotSupportedError, type ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type ParticipantDeviceInfo } from "./types.ts";
|
||||
|
||||
// Deprecate RoomAndToDeviceTransport: This whole class is only a stop gap until we remove RoomKeyTransport.
|
||||
export interface EnabledTransports {
|
||||
toDevice: boolean;
|
||||
room: boolean;
|
||||
}
|
||||
|
||||
export enum RoomAndToDeviceEvents {
|
||||
EnabledTransportsChanged = "enabled_transports_changed",
|
||||
}
|
||||
export type RoomAndToDeviceEventsHandlerMap = {
|
||||
[RoomAndToDeviceEvents.EnabledTransportsChanged]: (enabledTransports: EnabledTransports) => void;
|
||||
};
|
||||
/**
|
||||
* A custom transport that subscribes to room key events (via `RoomKeyTransport`) and to device key events (via: `ToDeviceKeyTransport`)
|
||||
* The public setEnabled method allows to turn one or the other on or off on the fly.
|
||||
* It will emit `RoomAndToDeviceEvents.EnabledTransportsChanged` if the enabled transport changes to allow comminitcating this to
|
||||
* the user in the ui.
|
||||
*
|
||||
* Since it will always subscribe to both (room and to device) but only emit for the enabled ones, it can detect
|
||||
* if a room key event was received and autoenable it.
|
||||
*/
|
||||
export class RoomAndToDeviceTransport
|
||||
extends TypedEventEmitter<
|
||||
KeyTransportEvents | RoomAndToDeviceEvents,
|
||||
KeyTransportEventsHandlerMap & RoomAndToDeviceEventsHandlerMap
|
||||
>
|
||||
implements IKeyTransport
|
||||
{
|
||||
private readonly logger: Logger;
|
||||
private _enabled: EnabledTransports = { toDevice: true, room: false };
|
||||
public constructor(
|
||||
private toDeviceTransport: ToDeviceKeyTransport,
|
||||
private roomKeyTransport: RoomKeyTransport,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[RoomAndToDeviceTransport]`);
|
||||
// update parent loggers for the sub transports so filtering for `RoomAndToDeviceTransport` contains their logs too
|
||||
this.toDeviceTransport.setParentLogger(this.logger);
|
||||
this.roomKeyTransport.setParentLogger(this.logger);
|
||||
|
||||
this.roomKeyTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
|
||||
// Turn on the room transport if we receive a roomKey from another participant
|
||||
// and disable the toDevice transport.
|
||||
if (!this._enabled.room) {
|
||||
this.logger.debug("Received room key, enabling room key transport, disabling toDevice transport");
|
||||
this.setEnabled({ toDevice: false, room: true });
|
||||
}
|
||||
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
|
||||
});
|
||||
this.toDeviceTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
|
||||
if (this._enabled.toDevice) {
|
||||
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
|
||||
} else {
|
||||
this.logger.debug("To Device transport is disabled, ignoring received keys");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Set which transport type should be used to send and receive keys.*/
|
||||
public setEnabled(enabled: { toDevice: boolean; room: boolean }): void {
|
||||
if (this.enabled.toDevice !== enabled.toDevice || this.enabled.room !== enabled.room) {
|
||||
this._enabled = enabled;
|
||||
this.emit(RoomAndToDeviceEvents.EnabledTransportsChanged, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/** The currently enabled transports that are used to send and receive keys.*/
|
||||
public get enabled(): EnabledTransports {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
// always start the underlying transport since we need to enable room transport
|
||||
// when someone else sends us a room key. (we need to listen to roomKeyTransport)
|
||||
this.roomKeyTransport.start();
|
||||
this.toDeviceTransport.start();
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
// always stop since it is always running
|
||||
this.roomKeyTransport.stop();
|
||||
this.toDeviceTransport.stop();
|
||||
}
|
||||
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Sending key with index ${index} to call members (count=${members.length}) via:` +
|
||||
(this._enabled.room ? "room transport" : "") +
|
||||
(this._enabled.room && this._enabled.toDevice ? "and" : "") +
|
||||
(this._enabled.toDevice ? "to device transport" : ""),
|
||||
);
|
||||
if (this._enabled.room) await this.roomKeyTransport.sendKey(keyBase64Encoded, index, members);
|
||||
if (this._enabled.toDevice) {
|
||||
try {
|
||||
await this.toDeviceTransport.sendKey(keyBase64Encoded, index, members);
|
||||
} catch (error) {
|
||||
if (error instanceof NotSupportedError && !this._enabled.room) {
|
||||
this.logger.warn(
|
||||
"To device is not supported enabling room key transport, disabling toDevice transport",
|
||||
);
|
||||
this.setEnabled({ toDevice: false, room: true });
|
||||
await this.sendKey(keyBase64Encoded, index, members);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Information about a MatrixRTC conference focus. The only attribute that
|
||||
* the js-sdk (currently) knows about is the type: applications can extend
|
||||
* this class for different types of focus.
|
||||
*/
|
||||
export interface Focus {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -15,8 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
export * from "./CallMembership.ts";
|
||||
export type * from "./focus.ts";
|
||||
export * from "./LivekitFocus.ts";
|
||||
export * from "./LivekitTransport.ts";
|
||||
export * from "./MatrixRTCSession.ts";
|
||||
export * from "./MatrixRTCSessionManager.ts";
|
||||
export type * from "./types.ts";
|
||||
|
||||
+53
-10
@@ -13,11 +13,12 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import type { IMentions } from "../matrix.ts";
|
||||
import type { IContent, IMentions } from "../matrix.ts";
|
||||
import type { RelationEvent } from "../types.ts";
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
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;
|
||||
@@ -102,9 +104,45 @@ export type RTCNotificationType = "ring" | "notification";
|
||||
* May be any string, although `"audio"` and `"video"` are commonly accepted values.
|
||||
*/
|
||||
export type RTCCallIntent = "audio" | "video" | string;
|
||||
|
||||
/**
|
||||
* This will check if the content has all the expected fields to be a valid IRTCNotificationContent.
|
||||
* It will also cap the lifetime to 90000ms (1.5 min) if a higher value is provided.
|
||||
* @param content
|
||||
* @throws if the content is invalid
|
||||
* @returns a parsed IRTCNotificationContent
|
||||
*/
|
||||
export function parseCallNotificationContent(content: IContent): IRTCNotificationContent {
|
||||
if (content["m.mentions"] && typeof content["m.mentions"] !== "object") {
|
||||
throw new Error("malformed m.mentions");
|
||||
}
|
||||
if (typeof content["notification_type"] !== "string") {
|
||||
throw new Error("Missing or invalid notification_type");
|
||||
}
|
||||
if (typeof content["sender_ts"] !== "number") {
|
||||
throw new Error("Missing or invalid sender_ts");
|
||||
}
|
||||
if (typeof content["lifetime"] !== "number") {
|
||||
throw new Error("Missing or invalid lifetime");
|
||||
}
|
||||
|
||||
if (content["relation"] && content["relation"]["rel_type"] !== "m.reference") {
|
||||
throw new Error("Invalid relation");
|
||||
}
|
||||
if (content["m.call.intent"] && typeof content["m.call.intent"] !== "string") {
|
||||
throw new Error("Invalid m.call.intent");
|
||||
}
|
||||
|
||||
const cappedLifetime = content["lifetime"] >= 90000 ? 90000 : content["lifetime"];
|
||||
return { ...content, lifetime: cappedLifetime } as IRTCNotificationContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for `org.matrix.msc4075.rtc.notification` events.
|
||||
* Don't cast event content to this directly. Use `parseCallNotificationContent` instead to validate the content first.
|
||||
*/
|
||||
export interface IRTCNotificationContent extends RelationEvent {
|
||||
"m.mentions": IMentions;
|
||||
"decline_reason"?: string;
|
||||
"m.mentions"?: IMentions;
|
||||
"notification_type": RTCNotificationType;
|
||||
/**
|
||||
* The initial intent of the calling user.
|
||||
@@ -123,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",
|
||||
}
|
||||
|
||||
@@ -156,3 +191,11 @@ export type Statistics = {
|
||||
|
||||
export const isMyMembership = (m: CallMembership, userId: string, deviceId: string): boolean =>
|
||||
m.sender === userId && m.deviceId === deviceId;
|
||||
|
||||
/**
|
||||
* A RTC transport is a JSON object that describes how to connect to a RTC member.
|
||||
*/
|
||||
export interface Transport {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+64
-44
@@ -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";
|
||||
@@ -75,6 +74,7 @@ export interface IUnsigned {
|
||||
"transaction_id"?: string;
|
||||
"invite_room_state"?: StrippedState[];
|
||||
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
|
||||
"msc4354_sticky_duration_ttl_ms"?: number;
|
||||
[UNSIGNED_THREAD_ID_FIELD.name]?: string;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface IEvent {
|
||||
membership?: Membership;
|
||||
unsigned: IUnsigned;
|
||||
redacts?: string;
|
||||
msc4354_sticky?: { duration_ms: number };
|
||||
}
|
||||
|
||||
export interface IAggregatedRelation {
|
||||
@@ -184,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;
|
||||
}
|
||||
@@ -213,6 +213,7 @@ export interface IMessageVisibilityHidden {
|
||||
}
|
||||
// A singleton implementing `IMessageVisibilityVisible`.
|
||||
const MESSAGE_VISIBLE: IMessageVisibilityVisible = Object.freeze({ visible: true });
|
||||
export const MAX_STICKY_DURATION_MS = 3600000;
|
||||
|
||||
export enum MatrixEventEvent {
|
||||
/**
|
||||
@@ -267,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;
|
||||
@@ -282,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.
|
||||
*/
|
||||
@@ -408,6 +399,17 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
|
||||
private readonly reEmitter: TypedReEmitter<MatrixEventEmittedEvents, MatrixEventHandlerMap>;
|
||||
|
||||
/**
|
||||
* The timestamp for when this event should expire, in milliseconds.
|
||||
* Prefers using the server-provided value, but will fall back to local calculation.
|
||||
*
|
||||
* This value is **safe** to use, as malicious start time and duration are appropriately capped.
|
||||
*
|
||||
* If the event is not a sticky event (or not supported by the server),
|
||||
* then this returns `undefined`.
|
||||
*/
|
||||
public readonly unstableStickyExpiresAt: number | undefined;
|
||||
|
||||
/**
|
||||
* Construct a Matrix Event object
|
||||
*
|
||||
@@ -447,8 +449,17 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
// The fallback in these cases will be to use the origin_server_ts.
|
||||
// For EDUs, the origin_server_ts also is not defined so we use Date.now().
|
||||
const age = this.getAge();
|
||||
this.localTimestamp = age !== undefined ? Date.now() - age : (this.getTs() ?? Date.now());
|
||||
const now = Date.now();
|
||||
this.localTimestamp = age !== undefined ? now - age : (this.getTs() ?? now);
|
||||
this.reEmitter = new TypedReEmitter(this);
|
||||
if (this.unstableStickyInfo) {
|
||||
if (this.unstableStickyInfo.duration_ttl_ms) {
|
||||
this.unstableStickyExpiresAt = now + this.unstableStickyInfo.duration_ttl_ms;
|
||||
} else {
|
||||
// Bound the timestamp so it doesn't come from the future.
|
||||
this.unstableStickyExpiresAt = Math.min(now, this.getTs()) + this.unstableStickyInfo.duration_ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -458,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;
|
||||
}
|
||||
@@ -730,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`.
|
||||
*/
|
||||
@@ -743,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`.
|
||||
*/
|
||||
@@ -766,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);
|
||||
}
|
||||
@@ -871,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");
|
||||
}
|
||||
@@ -1002,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();
|
||||
}
|
||||
|
||||
@@ -1026,8 +1034,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
};
|
||||
this.senderCurve25519Key = null;
|
||||
this.claimedEd25519Key = null;
|
||||
this.forwardingCurve25519KeyChain = [];
|
||||
this.untrusted = false;
|
||||
this.invalidateExtensibleEvent();
|
||||
}
|
||||
|
||||
@@ -1097,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 {
|
||||
@@ -1739,6 +1741,24 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
public setThreadId(threadId?: string): void {
|
||||
this.threadId = threadId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unstable getter to try and get the sticky information for the event.
|
||||
* If the event is not a sticky event (or not supported by the server),
|
||||
* then this returns `undefined`.
|
||||
*
|
||||
* `duration_ms` is safely bounded to a hour.
|
||||
*/
|
||||
public get unstableStickyInfo(): { duration_ms: number; duration_ttl_ms?: number } | undefined {
|
||||
if (!this.event.msc4354_sticky?.duration_ms) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
duration_ms: Math.min(MAX_STICKY_DURATION_MS, this.event.msc4354_sticky.duration_ms),
|
||||
// This is assumed to be bounded server-side.
|
||||
duration_ttl_ms: this.event.unsigned?.msc4354_sticky_duration_ttl_ms,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* REDACT_KEEP_KEYS gives the keys we keep when an event is redacted
|
||||
|
||||
@@ -1,8 +1,33 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The timezone the user is currently in. The value of this property should
|
||||
* match a timezone provided in https://www.iana.org/time-zones.
|
||||
*
|
||||
* This key was introduced in Matrix v1.16.
|
||||
*/
|
||||
export const ProfileKeyTimezone = "m.tz";
|
||||
|
||||
/**
|
||||
* The timezone the user is currently in. The value of this property should
|
||||
* match a timezone provided in https://www.iana.org/time-zones.
|
||||
*
|
||||
* @see https://github.com/matrix-org/matrix-spec-proposals/blob/clokep/profile-tz/proposals/4175-profile-field-time-zone.md
|
||||
* @experimental
|
||||
* @deprecated Unstable MSC field - Use `ProfileKeyTimezone`
|
||||
*/
|
||||
export const ProfileKeyMSC4175Timezone = "us.cloke.msc4175.tz";
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -388,7 +395,7 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
|
||||
}
|
||||
}
|
||||
|
||||
const MXID_PATTERN = /@.+:.+/;
|
||||
export const MXID_PATTERN = /@.+:.+/;
|
||||
const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/;
|
||||
|
||||
function shouldDisambiguate(selfUserId: string, displayName?: string, roomState?: RoomState): boolean {
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { logger as loggerInstance } from "../logger.ts";
|
||||
import { type MatrixEvent } from "./event.ts";
|
||||
import { TypedEventEmitter } from "./typed-event-emitter.ts";
|
||||
|
||||
const logger = loggerInstance.getChild("RoomStickyEvents");
|
||||
|
||||
export enum RoomStickyEventsEvent {
|
||||
Update = "RoomStickyEvents.Update",
|
||||
}
|
||||
|
||||
export type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number };
|
||||
|
||||
export type RoomStickyEventsMap = {
|
||||
/**
|
||||
* Fires when any sticky event changes happen in a room.
|
||||
* @param added Any new sticky events with no predecessor events (matching sender, type, and sticky_key)
|
||||
* @param updated Any sticky events that supersede an existing event (matching sender, type, and sticky_key)
|
||||
* @param removed The events that were removed from the map due to expiry.
|
||||
*/
|
||||
[RoomStickyEventsEvent.Update]: (
|
||||
added: StickyMatrixEvent[],
|
||||
updated: { current: StickyMatrixEvent; previous: StickyMatrixEvent }[],
|
||||
removed: StickyMatrixEvent[],
|
||||
) => void;
|
||||
};
|
||||
|
||||
type UserId = `@${string}`;
|
||||
|
||||
function assertIsUserId(value: unknown): asserts value is UserId {
|
||||
if (typeof value !== "string") throw new Error("Not a string");
|
||||
if (!value.startsWith("@")) throw new Error("Not a userId");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks sticky events on behalf of one room, and fires an event
|
||||
* whenever a sticky event is updated or replaced.
|
||||
*/
|
||||
export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEvent, RoomStickyEventsMap> {
|
||||
/**
|
||||
* Sticky event map is a nested map of:
|
||||
* eventType -> `content.sticky_key sender` -> StickyMatrixEvent[]
|
||||
*
|
||||
* The events are ordered in latest to earliest expiry, so that the first event
|
||||
* in the array will always be the "current" one.
|
||||
*/
|
||||
private readonly stickyEventsMap = new Map<string, Map<string, StickyMatrixEvent[]>>();
|
||||
/**
|
||||
* These are sticky events that have no sticky key and therefore exist outside the tuple
|
||||
* system above. They are just held in this Set until they expire.
|
||||
*/
|
||||
private readonly unkeyedStickyEvents = new Set<StickyMatrixEvent>();
|
||||
|
||||
private stickyEventTimer?: ReturnType<typeof setTimeout>;
|
||||
private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
/**
|
||||
* Sort two sticky events by order of expiry. This assumes the sticky events have the same
|
||||
* `type`, `sticky_key` and `sender`.
|
||||
* @returns A positive value if event A will expire sooner, or a negative value if event B will expire sooner.
|
||||
*/
|
||||
private static sortStickyEvent(eventA: StickyMatrixEvent, eventB: StickyMatrixEvent): number {
|
||||
// Sticky events with the same key have to use the same expiration duration.
|
||||
// Hence, comparing via `origin_server_ts` yields the exact same result as comparing their expiration time.
|
||||
if (eventB.getTs() !== eventA.getTs()) {
|
||||
return eventB.getTs() - eventA.getTs();
|
||||
}
|
||||
|
||||
if ((eventB.getId() ?? "") > (eventA.getId() ?? "")) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// This should fail as we've got corruption in our sticky array.
|
||||
throw Error("Comparing two sticky events with the same event ID is not allowed.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the correct key for an event to be found in the inner maps of `stickyEventsMap`.
|
||||
* @param stickyKey The sticky key of an event.
|
||||
* @param sender The sender of the event.
|
||||
*/
|
||||
private static stickyMapKey(stickyKey: string, sender: UserId): string {
|
||||
return `${stickyKey}${sender}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sticky events that are currently active.
|
||||
* @returns An iterable set of events.
|
||||
*/
|
||||
public *getStickyEvents(): Iterable<StickyMatrixEvent> {
|
||||
yield* this.unkeyedStickyEvents;
|
||||
for (const innerMap of this.stickyEventsMap.values()) {
|
||||
// Inner map contains a map of sender+stickykeys => all sticky events
|
||||
for (const events of innerMap.values()) {
|
||||
// The first sticky event is the "current" one in the sticky map.
|
||||
yield events[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an active sticky event that match the given `type`, `sender`, and `stickyKey`
|
||||
* @param type The event `type`.
|
||||
* @param sender The sender of the sticky event.
|
||||
* @param stickyKey The sticky key used by the event.
|
||||
* @returns A matching active sticky event, or undefined.
|
||||
*/
|
||||
public getKeyedStickyEvent(sender: string, type: string, stickyKey: string): StickyMatrixEvent | undefined {
|
||||
assertIsUserId(sender);
|
||||
return this.stickyEventsMap.get(type)?.get(RoomStickyEventsStore.stickyMapKey(stickyKey, sender))?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active sticky events without a sticky key that match the given `type` and `sender`.
|
||||
* @param type The event `type`.
|
||||
* @param sender The sender of the sticky event.
|
||||
* @returns An array of matching sticky events.
|
||||
*/
|
||||
public getUnkeyedStickyEvent(sender: string, type: string): StickyMatrixEvent[] {
|
||||
return [...this.unkeyedStickyEvents].filter((ev) => ev.getType() === type && ev.getSender() === sender);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a sticky event into the local sticky event map.
|
||||
*
|
||||
* NOTE: This will not cause `RoomEvent.StickyEvents` to be emitted.
|
||||
*
|
||||
* @throws If the `event` does not contain valid sticky data.
|
||||
* @param event The MatrixEvent that contains sticky data.
|
||||
* @returns An object describing whether the event was added to the map,
|
||||
* and the previous event it may have replaced.
|
||||
*/
|
||||
private addStickyEvent(event: MatrixEvent): { added: true; prevEvent?: StickyMatrixEvent } | { added: false } {
|
||||
const stickyKey = event.getContent().msc4354_sticky_key;
|
||||
if (typeof stickyKey !== "string" && stickyKey !== undefined) {
|
||||
throw new Error(`${event.getId()} is missing msc4354_sticky_key`);
|
||||
}
|
||||
|
||||
// With this we have the guarantee, that all events in stickyEventsMap are correctly formatted
|
||||
if (event.unstableStickyExpiresAt === undefined) {
|
||||
throw new Error(`${event.getId()} is missing msc4354_sticky.duration_ms`);
|
||||
}
|
||||
const sender = event.getSender();
|
||||
const type = event.getType();
|
||||
assertIsUserId(sender);
|
||||
if (event.unstableStickyExpiresAt <= Date.now()) {
|
||||
logger.info("ignored sticky event with older expiration time than current time", stickyKey);
|
||||
return { added: false };
|
||||
}
|
||||
|
||||
// While we fully expect the server to always provide the correct value,
|
||||
// this is just insurance to protect against attacks on our Map.
|
||||
if (!sender.startsWith("@")) {
|
||||
throw new Error("Expected sender to start with @");
|
||||
}
|
||||
|
||||
const stickyEvent = event as StickyMatrixEvent;
|
||||
|
||||
if (stickyKey === undefined) {
|
||||
this.unkeyedStickyEvents.add(stickyEvent);
|
||||
// Recalculate the next expiry time.
|
||||
this.nextStickyEventExpiryTs = Math.min(event.unstableStickyExpiresAt, this.nextStickyEventExpiryTs);
|
||||
|
||||
this.scheduleStickyTimer();
|
||||
return { added: true };
|
||||
}
|
||||
|
||||
// Why this is safe:
|
||||
// A type may contain anything but the *sender* is tightly
|
||||
// constrained so that a key will always end with a @<user_id>
|
||||
// E.g. Where a malicious event type might be "rtc.member.event@foo:bar" the key becomes:
|
||||
// "rtc.member.event.@foo:bar@bar:baz"
|
||||
const innerMapKey = RoomStickyEventsStore.stickyMapKey(stickyKey, sender);
|
||||
const currentEventSet = [stickyEvent, ...(this.stickyEventsMap.get(type)?.get(innerMapKey) ?? [])].sort(
|
||||
RoomStickyEventsStore.sortStickyEvent,
|
||||
);
|
||||
if (!this.stickyEventsMap.has(type)) {
|
||||
this.stickyEventsMap.set(type, new Map());
|
||||
}
|
||||
this.stickyEventsMap.get(type)?.set(innerMapKey, currentEventSet);
|
||||
|
||||
// Recalculate the next expiry time.
|
||||
this.nextStickyEventExpiryTs = Math.min(stickyEvent.unstableStickyExpiresAt, this.nextStickyEventExpiryTs);
|
||||
|
||||
this.scheduleStickyTimer();
|
||||
return {
|
||||
added: currentEventSet[0] === stickyEvent,
|
||||
prevEvent: currentEventSet?.[1],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any
|
||||
* changes were made.
|
||||
* @param events A set of new sticky events.
|
||||
*/
|
||||
public addStickyEvents(events: MatrixEvent[]): void {
|
||||
const added: StickyMatrixEvent[] = [];
|
||||
const updated: { current: StickyMatrixEvent; previous: StickyMatrixEvent }[] = [];
|
||||
for (const event of events) {
|
||||
try {
|
||||
const result = this.addStickyEvent(event);
|
||||
if (result.added) {
|
||||
if (result.prevEvent) {
|
||||
// e is validated as a StickyMatrixEvent by virtue of `addStickyEvent` returning added: true.
|
||||
updated.push({ current: event as StickyMatrixEvent, previous: result.prevEvent });
|
||||
} else {
|
||||
added.push(event as StickyMatrixEvent);
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
logger.warn("ignored invalid sticky event", ex);
|
||||
}
|
||||
}
|
||||
if (added.length || updated.length) this.emit(RoomStickyEventsEvent.Update, added, updated, []);
|
||||
this.scheduleStickyTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the sticky event expiry timer. The timer will
|
||||
* run immediately if an event has already expired.
|
||||
*/
|
||||
private scheduleStickyTimer(): void {
|
||||
if (this.stickyEventTimer) {
|
||||
clearTimeout(this.stickyEventTimer);
|
||||
this.stickyEventTimer = undefined;
|
||||
}
|
||||
if (this.nextStickyEventExpiryTs === Number.MAX_SAFE_INTEGER) {
|
||||
// We have no events due to expire.
|
||||
return;
|
||||
} // otherwise, schedule in the future
|
||||
this.stickyEventTimer = setTimeout(this.cleanExpiredStickyEvents, this.nextStickyEventExpiryTs - Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean out any expired sticky events.
|
||||
*/
|
||||
private readonly cleanExpiredStickyEvents = (): void => {
|
||||
const now = Date.now();
|
||||
const removedEvents: StickyMatrixEvent[] = [];
|
||||
|
||||
// We will recalculate this as we check all events.
|
||||
this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER;
|
||||
for (const [eventType, innerEvents] of this.stickyEventsMap.entries()) {
|
||||
for (const [innerMapKey, [currentEvent, ...previousEvents]] of innerEvents) {
|
||||
// we only added items with `sticky` into this map so we can assert non-null here
|
||||
if (now >= currentEvent.unstableStickyExpiresAt) {
|
||||
logger.debug("Expiring sticky event", currentEvent.getId());
|
||||
removedEvents.push(currentEvent);
|
||||
this.stickyEventsMap.get(eventType)!.delete(innerMapKey);
|
||||
} else {
|
||||
// Ensure we remove any previous events which have now expired, to avoid unbounded memory consumption.
|
||||
this.stickyEventsMap
|
||||
.get(eventType)!
|
||||
.set(innerMapKey, [
|
||||
currentEvent,
|
||||
...previousEvents.filter((e) => e.unstableStickyExpiresAt <= now),
|
||||
]);
|
||||
// If not removing the event, check to see if it's the next lowest expiry.
|
||||
this.nextStickyEventExpiryTs = Math.min(
|
||||
this.nextStickyEventExpiryTs,
|
||||
currentEvent.unstableStickyExpiresAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Clean up map after use.
|
||||
if (this.stickyEventsMap.get(eventType)?.size === 0) {
|
||||
this.stickyEventsMap.delete(eventType);
|
||||
}
|
||||
}
|
||||
for (const event of this.unkeyedStickyEvents) {
|
||||
if (now >= event.unstableStickyExpiresAt) {
|
||||
logger.debug("Expiring sticky event", event.getId());
|
||||
this.unkeyedStickyEvents.delete(event);
|
||||
removedEvents.push(event);
|
||||
} else {
|
||||
// If not removing the event, check to see if it's the next lowest expiry.
|
||||
this.nextStickyEventExpiryTs = Math.min(this.nextStickyEventExpiryTs, event.unstableStickyExpiresAt);
|
||||
}
|
||||
}
|
||||
if (removedEvents.length) {
|
||||
this.emit(RoomStickyEventsEvent.Update, [], [], removedEvents);
|
||||
}
|
||||
// Finally, schedule the next run.
|
||||
this.scheduleStickyTimer();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles incoming event redactions. Checks the sticky map
|
||||
* for any active sticky events being redacted.
|
||||
* @param redactedEvent The MatrixEvent OR event ID of the event being redacted. MAY not be a sticky event.
|
||||
*/
|
||||
public handleRedaction(redactedEvent: MatrixEvent | string): void {
|
||||
// Note, we do not adjust`nextStickyEventExpiryTs` here.
|
||||
// If this event happens to be the most recent expiring event
|
||||
// then we may do one extra iteration of cleanExpiredStickyEvents
|
||||
// but this saves us having to iterate over all events here to calculate
|
||||
// the next expiry time.
|
||||
|
||||
// Note, as soon as we find a positive match on an event in this function
|
||||
// we can return. There is no need to continue iterating on a positive match
|
||||
// as an event can only appear in one map.
|
||||
|
||||
// Handle unkeyedStickyEvents first since it's *quick*.
|
||||
const redactEventId = typeof redactedEvent === "string" ? redactedEvent : redactedEvent.getId();
|
||||
for (const event of this.unkeyedStickyEvents) {
|
||||
if (event.getId() === redactEventId) {
|
||||
this.unkeyedStickyEvents.delete(event);
|
||||
this.emit(RoomStickyEventsEvent.Update, [], [], [event]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Faster method of finding the event since we have the event cached.
|
||||
if (typeof redactedEvent !== "string" && !redactedEvent.isRedacted()) {
|
||||
const stickyKey = redactedEvent.getContent().msc4354_sticky_key;
|
||||
if (typeof stickyKey !== "string" && stickyKey !== undefined) {
|
||||
return; // Not a sticky event.
|
||||
}
|
||||
const eventType = redactedEvent.getType();
|
||||
const sender = redactedEvent.getSender();
|
||||
assertIsUserId(sender);
|
||||
const innerMap = this.stickyEventsMap.get(eventType);
|
||||
if (!innerMap) {
|
||||
return;
|
||||
}
|
||||
const mapKey = RoomStickyEventsStore.stickyMapKey(stickyKey, sender);
|
||||
const [currentEvent, ...previousEvents] = innerMap.get(mapKey) ?? [];
|
||||
if (!currentEvent) {
|
||||
// No event current in the map so ignore.
|
||||
return;
|
||||
}
|
||||
logger.debug(`Redaction for ${redactEventId} under sticky key ${stickyKey}`);
|
||||
// Revert to previous state, taking care to skip any other redacted events.
|
||||
const newEvents = previousEvents.filter((e) => !e.isRedacted()).sort(RoomStickyEventsStore.sortStickyEvent);
|
||||
this.stickyEventsMap.get(eventType)?.set(mapKey, newEvents);
|
||||
if (newEvents.length) {
|
||||
this.emit(
|
||||
RoomStickyEventsEvent.Update,
|
||||
[],
|
||||
[
|
||||
{
|
||||
// This looks confusing. This emits that the newer event
|
||||
// has been redacted and the previous event has taken it's place.
|
||||
previous: currentEvent,
|
||||
current: newEvents[0],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
} else {
|
||||
// We did not find a previous event, so just expire.
|
||||
innerMap.delete(mapKey);
|
||||
if (innerMap.size === 0) {
|
||||
this.stickyEventsMap.delete(eventType);
|
||||
}
|
||||
this.emit(RoomStickyEventsEvent.Update, [], [], [currentEvent]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// We only know the event ID of the redacted event, so we need to
|
||||
// traverse the map to find our event.
|
||||
for (const innerMap of this.stickyEventsMap.values()) {
|
||||
for (const [currentEvent] of innerMap.values()) {
|
||||
if (currentEvent.getId() !== redactEventId) {
|
||||
continue;
|
||||
}
|
||||
// Found the event.
|
||||
return this.handleRedaction(currentEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all events and stop the timer from firing.
|
||||
*/
|
||||
public clear(): void {
|
||||
this.stickyEventsMap.clear();
|
||||
// Unschedule timer.
|
||||
this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER;
|
||||
this.scheduleStickyTimer();
|
||||
}
|
||||
}
|
||||
+81
-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,
|
||||
@@ -77,6 +77,7 @@ import { compareEventOrdering } from "./compare-event-ordering.ts";
|
||||
import { KnownMembership, type Membership } from "../@types/membership.ts";
|
||||
import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts";
|
||||
import { type MSC4186Hero } from "../sliding-sync.ts";
|
||||
import { RoomStickyEventsStore, RoomStickyEventsEvent, type RoomStickyEventsMap } from "./room-sticky-events.ts";
|
||||
|
||||
// These constants are used as sane defaults when the homeserver doesn't support
|
||||
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
|
||||
@@ -167,6 +168,7 @@ export type RoomEmittedEvents =
|
||||
| RoomStateEvent.NewMember
|
||||
| RoomStateEvent.Update
|
||||
| RoomStateEvent.Marker
|
||||
| RoomStickyEventsEvent.Update
|
||||
| ThreadEvent.New
|
||||
| ThreadEvent.Update
|
||||
| ThreadEvent.NewReply
|
||||
@@ -320,6 +322,7 @@ export type RoomEventHandlerMap = {
|
||||
} & Pick<ThreadHandlerMap, ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete> &
|
||||
EventTimelineSetHandlerMap &
|
||||
Pick<MatrixEventHandlerMap, MatrixEventEvent.BeforeRedaction> &
|
||||
Pick<RoomStickyEventsMap, RoomStickyEventsEvent.Update> &
|
||||
Pick<
|
||||
RoomStateEventHandlerMap,
|
||||
| RoomStateEvent.Events
|
||||
@@ -446,6 +449,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
*/
|
||||
private roomReceipts = new RoomReceipts(this);
|
||||
|
||||
/**
|
||||
* Stores and tracks sticky events
|
||||
*/
|
||||
private stickyEvents = new RoomStickyEventsStore();
|
||||
|
||||
/**
|
||||
* Construct a new Room.
|
||||
*
|
||||
@@ -492,6 +500,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// Listen to our own receipt event as a more modular way of processing our own
|
||||
// receipts. No need to remove the listener: it's on ourself anyway.
|
||||
this.on(RoomEvent.Receipt, this.onReceipt);
|
||||
this.reEmitter.reEmit(this.stickyEvents, [RoomStickyEventsEvent.Update]);
|
||||
|
||||
// all our per-room timeline sets. the first one is the unfiltered ones;
|
||||
// the subsequent ones are the filtered ones in no particular order.
|
||||
@@ -1187,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.
|
||||
//
|
||||
@@ -1774,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,
|
||||
@@ -1801,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1811,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1823,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 [];
|
||||
}
|
||||
@@ -2484,7 +2490,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
};
|
||||
|
||||
private updateThreadRootEvent = (
|
||||
timelineSet: Optional<EventTimelineSet>,
|
||||
timelineSet: EventTimelineSet | undefined,
|
||||
thread: Thread,
|
||||
toStartOfTimeline: boolean,
|
||||
recreateEvent: boolean,
|
||||
@@ -2624,6 +2630,14 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
|
||||
// if we know about this event, redact its contents now.
|
||||
const redactedEvent = redactId ? this.findEventById(redactId) : undefined;
|
||||
if (redactId) {
|
||||
try {
|
||||
this.stickyEvents.handleRedaction(redactedEvent || redactId);
|
||||
} catch (ex) {
|
||||
// Non-critical failure, but we should warn.
|
||||
logger.error("Failed to handle redaction for sticky event", ex);
|
||||
}
|
||||
}
|
||||
if (redactedEvent) {
|
||||
this.applyEventAsRedaction(event, redactedEvent);
|
||||
}
|
||||
@@ -3414,6 +3428,55 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
return this.accountData.get(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an iterator of currently active sticky events.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public _unstable_getStickyEvents(): ReturnType<RoomStickyEventsStore["getStickyEvents"]> {
|
||||
return this.stickyEvents.getStickyEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a sticky event that match the given `type`, `sender`, and `stickyKey`
|
||||
* @param type The event `type`.
|
||||
* @param sender The sender of the sticky event.
|
||||
* @param stickyKey The sticky key used by the event.
|
||||
* @returns A matching active sticky event, or undefined.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public _unstable_getKeyedStickyEvent(
|
||||
sender: string,
|
||||
type: string,
|
||||
stickyKey: string,
|
||||
): ReturnType<RoomStickyEventsStore["getKeyedStickyEvent"]> {
|
||||
return this.stickyEvents.getKeyedStickyEvent(sender, type, stickyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active sticky events without a sticky key that match the given `type` and `sender`.
|
||||
* @param type The event `type`.
|
||||
* @param sender The sender of the sticky event.
|
||||
* @returns An array of matching sticky events.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public _unstable_getUnkeyedStickyEvent(
|
||||
sender: string,
|
||||
type: string,
|
||||
): ReturnType<RoomStickyEventsStore["getUnkeyedStickyEvent"]> {
|
||||
return this.stickyEvents.getUnkeyedStickyEvent(sender, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any
|
||||
* changes were made.
|
||||
* @param events A set of new sticky events.
|
||||
* @internal
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public _unstable_addStickyEvents(events: MatrixEvent[]): ReturnType<RoomStickyEventsStore["addStickyEvents"]> {
|
||||
return this.stickyEvents.addStickyEvents(events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the syncing user has permission to send a message in the room
|
||||
* @returns true if the user should be permitted to send
|
||||
@@ -3574,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
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user