Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98b2b9745d | |||
| 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 | |||
| 2731e20893 | |||
| 502a513b5b | |||
| 3ac47e71cd | |||
| 7c1e25e713 | |||
| 2d90ad95f1 | |||
| cd9794471f | |||
| b2d3ab8bc1 | |||
| d8b70ef83b | |||
| a67fb1fb8d | |||
| ddd6e77cde |
@@ -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 }}
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
ref: staging
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: "yarn"
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
sparse-checkout: |
|
||||
scripts/release
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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:
|
||||
@@ -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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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"
|
||||
@@ -25,27 +22,24 @@ jobs:
|
||||
ref: staging
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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' }}
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
ref: staging
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
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 }}
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
- name: Build Types
|
||||
run: "yarn build:types"
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "npm"
|
||||
node-version-file: "examples/node/package.json"
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # 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
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
with:
|
||||
repository: element-hq/element-web
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- name: Setup Node
|
||||
id: setupNode
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: ${{ matrix.node }}
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
- name: Upload Artifact
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
|
||||
path: |
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10
|
||||
with:
|
||||
operations-per-run: 250
|
||||
days-before-issue-stale: -1
|
||||
|
||||
@@ -1,3 +1,35 @@
|
||||
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
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "39.0.0",
|
||||
"version": "39.2.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
@@ -56,10 +56,10 @@
|
||||
"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"
|
||||
},
|
||||
@@ -97,9 +97,9 @@
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-import-resolver-typescript": "^4.0.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^28.0.0",
|
||||
"eslint-plugin-jsdoc": "^50.0.0",
|
||||
"eslint-plugin-matrix-org": "2.1.0",
|
||||
"eslint-plugin-jest": "^29.0.0",
|
||||
"eslint-plugin-jsdoc": "^61.0.0",
|
||||
"eslint-plugin-matrix-org": "^3.0.0",
|
||||
"eslint-plugin-n": "^14.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.4.0",
|
||||
"eslint-plugin-unicorn": "^56.0.0",
|
||||
|
||||
+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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -801,6 +801,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 +1057,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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,13 +14,30 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEvent, type Room } from "../../../src";
|
||||
import {
|
||||
encodeBase64,
|
||||
type EventTimeline,
|
||||
EventType,
|
||||
MatrixClient,
|
||||
type MatrixError,
|
||||
type MatrixEvent,
|
||||
type Room,
|
||||
} from "../../../src";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
||||
import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
|
||||
import { secureRandomString } from "../../../src/randomstring";
|
||||
import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey, type MembershipData, mockRoomState } from "./mocks";
|
||||
import {
|
||||
makeMockEvent,
|
||||
makeMockRoom,
|
||||
membershipTemplate,
|
||||
makeKey,
|
||||
type MembershipData,
|
||||
mockRoomState,
|
||||
mockRTCEvent,
|
||||
} from "./mocks";
|
||||
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
|
||||
import { RoomStickyEventsEvent, type StickyMatrixEvent } from "../../../src/models/room-sticky-events.ts";
|
||||
import { StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
|
||||
|
||||
const mockFocus = { type: "mock" };
|
||||
|
||||
@@ -47,11 +64,293 @@ describe("MatrixRTCSession", () => {
|
||||
sess = undefined;
|
||||
});
|
||||
|
||||
describe("roomSessionForRoom", () => {
|
||||
it("creates a room-scoped session from room state", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
describe.each([
|
||||
{
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
testCreateSticky: false,
|
||||
createWithDefaults: true, // Create MatrixRTCSession with defaults
|
||||
},
|
||||
{
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
testCreateSticky: false,
|
||||
},
|
||||
{
|
||||
listenForStickyEvents: false,
|
||||
listenForMemberStateEvents: true,
|
||||
testCreateSticky: false,
|
||||
},
|
||||
{
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
testCreateSticky: true,
|
||||
},
|
||||
{
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: false,
|
||||
testCreateSticky: true,
|
||||
},
|
||||
])(
|
||||
"roomSessionForRoom listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky",
|
||||
(testConfig) => {
|
||||
it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
|
||||
mockRoom.getLiveTimeline.mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue(undefined),
|
||||
} as unknown as EventTimeline);
|
||||
if (testConfig.listenForMemberStateEvents) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(() => {
|
||||
MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig);
|
||||
}).toThrow();
|
||||
} else {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(() => {
|
||||
MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig);
|
||||
}).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
it("creates a room-scoped session from room state", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
|
||||
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].slotDescription.id).toEqual("");
|
||||
expect(sess?.memberships[0].scope).toEqual("m.room");
|
||||
expect(sess?.memberships[0].application).toEqual("m.call");
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
expect(sess?.memberships[0].isExpired()).toEqual(false);
|
||||
expect(sess?.slotDescription.id).toEqual("");
|
||||
});
|
||||
|
||||
it("ignores memberships where application is not m.call", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate, {
|
||||
application: "not-m.call",
|
||||
});
|
||||
const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky);
|
||||
const sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships where callId is not empty", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate, {
|
||||
call_id: "not-empty",
|
||||
scope: "m.room",
|
||||
});
|
||||
const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky);
|
||||
const sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores expired memberships events", () => {
|
||||
jest.useFakeTimers();
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.expires = 1000;
|
||||
expiredMembership.device_id = "EXPIRED";
|
||||
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], testConfig.testCreateSticky);
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("ignores memberships events of members not in the room", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
|
||||
mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("ignores memberships events with no sender", () => {
|
||||
// Force the sender to be undefined.
|
||||
const mockRoom = makeMockRoom([{ ...membershipTemplate, user_id: "" }], testConfig.testCreateSticky);
|
||||
mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("honours created_ts", () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(500);
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.created_ts = 500;
|
||||
expiredMembership.expires = 1000;
|
||||
const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns empty session if no membership events are present", () => {
|
||||
const mockRoom = makeMockRoom([], testConfig.testCreateSticky);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("safely ignores events with no memberships section", () => {
|
||||
const event = {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
};
|
||||
const mockRoom = makeMockRoom([]);
|
||||
mockRoom.getLiveTimeline.mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
getStateEvents: (_type: string, _stateKey: string) => [event],
|
||||
events: new Map([
|
||||
[
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
} as unknown as EventTimeline);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("safely ignores events with junk memberships section", () => {
|
||||
const event = {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
};
|
||||
const mockRoom = makeMockRoom([]);
|
||||
mockRoom.getLiveTimeline.mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
getStateEvents: (_type: string, _stateKey: string) => [event],
|
||||
events: new Map([
|
||||
[
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
} as unknown as EventTimeline);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships with no device_id", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.device_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships with no call_id", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.call_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
sess = MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("roomSessionForRoom combined state", () => {
|
||||
it("perfers sticky events when both membership and sticky events appear for the same user", () => {
|
||||
// Create a room with identical member state and sticky state for the same user.
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
|
||||
const ev = mockRTCEvent(
|
||||
{
|
||||
...membershipTemplate,
|
||||
msc4354_sticky_key: `_${membershipTemplate.user_id}_${membershipTemplate.device_id}`,
|
||||
},
|
||||
mockRoom.roomId,
|
||||
);
|
||||
return [ev as StickyMatrixEvent];
|
||||
});
|
||||
|
||||
// Expect for there to be one membership as the state has been merged down.
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
});
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].slotDescription.id).toEqual("");
|
||||
expect(sess?.memberships[0].scope).toEqual("m.room");
|
||||
@@ -60,149 +359,67 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess?.memberships[0].isExpired()).toEqual(false);
|
||||
expect(sess?.slotDescription.id).toEqual("");
|
||||
});
|
||||
|
||||
it("ignores memberships where application is not m.call", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate, {
|
||||
application: "not-m.call",
|
||||
});
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships where callId is not empty", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate, {
|
||||
call_id: "not-empty",
|
||||
scope: "m.room",
|
||||
});
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores expired memberships events", () => {
|
||||
jest.useFakeTimers();
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.expires = 1000;
|
||||
expiredMembership.device_id = "EXPIRED";
|
||||
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]);
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("ignores memberships events of members not in the room", () => {
|
||||
it("combines sticky and membership events when both exist", () => {
|
||||
// Create a room with identical member state and sticky state for the same user.
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
mockRoom.hasMembershipState = (state) => state === KnownMembership.Join;
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships.length).toEqual(0);
|
||||
});
|
||||
const stickyUserId = "@stickyev:user.example";
|
||||
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
|
||||
const ev = mockRTCEvent(
|
||||
{
|
||||
...membershipTemplate,
|
||||
user_id: stickyUserId,
|
||||
msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`,
|
||||
},
|
||||
mockRoom.roomId,
|
||||
15000,
|
||||
Date.now() - 1000, // Sticky event comes first.
|
||||
);
|
||||
return [ev as StickyMatrixEvent];
|
||||
});
|
||||
|
||||
it("honours created_ts", () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(500);
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.created_ts = 500;
|
||||
expiredMembership.expires = 1000;
|
||||
const mockRoom = makeMockRoom([expiredMembership]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
});
|
||||
|
||||
it("returns empty session if no membership events are present", () => {
|
||||
const mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
const memberships = sess.memberships;
|
||||
expect(memberships.length).toEqual(2);
|
||||
expect(memberships[0].sender).toEqual(stickyUserId);
|
||||
expect(memberships[0].slotDescription.id).toEqual("");
|
||||
expect(memberships[0].scope).toEqual("m.room");
|
||||
expect(memberships[0].application).toEqual("m.call");
|
||||
expect(memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
expect(memberships[0].isExpired()).toEqual(false);
|
||||
|
||||
it("safely ignores events with no memberships section", () => {
|
||||
const roomId = secureRandomString(8);
|
||||
const event = {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
};
|
||||
const mockRoom = {
|
||||
...makeMockRoom([]),
|
||||
roomId,
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
getStateEvents: (_type: string, _stateKey: string) => [event],
|
||||
events: new Map([
|
||||
[
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
// Then state
|
||||
expect(memberships[1].sender).toEqual(membershipTemplate.user_id);
|
||||
|
||||
it("safely ignores events with junk memberships section", () => {
|
||||
const roomId = secureRandomString(8);
|
||||
const event = {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
};
|
||||
const mockRoom = {
|
||||
...makeMockRoom([]),
|
||||
roomId,
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
getStateEvents: (_type: string, _stateKey: string) => [event],
|
||||
events: new Map([
|
||||
[
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
expect(sess?.slotDescription.id).toEqual("");
|
||||
});
|
||||
it("handles an incoming sticky event to an existing session", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
const stickyUserId = "@stickyev:user.example";
|
||||
|
||||
it("ignores memberships with no device_id", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.device_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships with no call_id", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.call_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
});
|
||||
expect(sess.memberships.length).toEqual(1);
|
||||
const stickyEv = mockRTCEvent(
|
||||
{
|
||||
...membershipTemplate,
|
||||
user_id: stickyUserId,
|
||||
msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`,
|
||||
},
|
||||
mockRoom.roomId,
|
||||
15000,
|
||||
Date.now() - 1000, // Sticky event comes first.
|
||||
) as StickyMatrixEvent;
|
||||
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
|
||||
return [stickyEv];
|
||||
});
|
||||
mockRoom.emit(RoomStickyEventsEvent.Update, [stickyEv], [], []);
|
||||
expect(sess.memberships.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -306,6 +523,9 @@ describe("MatrixRTCSession", () => {
|
||||
client.sendEvent = sendEventMock;
|
||||
|
||||
client._unstable_updateDelayedEvent = jest.fn();
|
||||
client._unstable_cancelScheduledDelayedEvent = jest.fn();
|
||||
client._unstable_restartScheduledDelayedEvent = jest.fn();
|
||||
client._unstable_sendScheduledDelayedEvent = jest.fn();
|
||||
|
||||
mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
@@ -329,6 +549,12 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess!.isJoined()).toEqual(true);
|
||||
});
|
||||
|
||||
it("uses the sticky events membership manager implementation", () => {
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { unstableSendStickyEvents: true });
|
||||
expect(sess!.isJoined()).toEqual(true);
|
||||
expect(sess!["membershipManager"] instanceof StickyEventMembershipManager).toEqual(true);
|
||||
});
|
||||
|
||||
it("sends a notification when starting a call and emit DidSendCallNotification", async () => {
|
||||
// Simulate a join, including the update to the room state
|
||||
// Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them
|
||||
|
||||
@@ -14,136 +14,145 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
|
||||
import { ClientEvent, EventTimeline, MatrixClient, type Room } from "../../../src";
|
||||
import { RoomStateEvent } from "../../../src/models/room-state";
|
||||
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks";
|
||||
import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks";
|
||||
import { logger } from "../../../src/logger";
|
||||
|
||||
describe("MatrixRTCSessionManager", () => {
|
||||
let client: MatrixClient;
|
||||
describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
"MatrixRTCSessionManager ($eventKind)",
|
||||
({ eventKind }) => {
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.matrixRTC.start();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
});
|
||||
|
||||
it("Fires event when session starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
function sendLeaveMembership(room: Room, membershipData: MembershipData[]): void {
|
||||
if (eventKind === "memberState") {
|
||||
mockRoomState(room, [{ user_id: membershipTemplate.user_id }]);
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
} else {
|
||||
membershipData.splice(0, 1, { user_id: membershipTemplate.user_id });
|
||||
client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("Doesn't fire event if unrelated sessions starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
beforeEach(() => {
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.matrixRTC.start();
|
||||
});
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
});
|
||||
|
||||
it("Fires event when session starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
}
|
||||
});
|
||||
|
||||
it("Doesn't fire event if unrelated sessions starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }], eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
}
|
||||
});
|
||||
|
||||
it("Fires event when session ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const membershipData: MembershipData[] = [membershipTemplate];
|
||||
const room1 = makeMockRoom(membershipData, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
}
|
||||
});
|
||||
|
||||
it("Fires event when session ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
sendLeaveMembership(room1, membershipData);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
|
||||
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
|
||||
it("Fires correctly with custom sessionDescription", () => {
|
||||
const onStarted = jest.fn();
|
||||
const onEnded = jest.fn();
|
||||
// create a session manager with a custom session description
|
||||
const sessionManager = new MatrixRTCSessionManager(logger, client, {
|
||||
id: "test",
|
||||
application: "m.notCall",
|
||||
});
|
||||
|
||||
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
// manually start the session manager (its not the default one started by the client)
|
||||
sessionManager.start();
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
try {
|
||||
// Create a session for applicaation m.other, we ignore this session ecause it lacks a call_id
|
||||
const room1MembershipData: MembershipData[] = [{ ...membershipTemplate, application: "m.other" }];
|
||||
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
it("Fires correctly with for with custom sessionDescription", () => {
|
||||
const onStarted = jest.fn();
|
||||
const onEnded = jest.fn();
|
||||
// create a session manager with a custom session description
|
||||
const sessionManager = new MatrixRTCSessionManager(logger, client, { id: "test", application: "m.notCall" });
|
||||
// Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id
|
||||
const room2MembershipData: MembershipData[] = [
|
||||
{ ...membershipTemplate, application: "m.notCall", call_id: "test" },
|
||||
];
|
||||
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
|
||||
client.emit(ClientEvent.Room, room2);
|
||||
expect(onStarted).toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
// manually start the session manager (its not the default one started by the client)
|
||||
sessionManager.start();
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
// Stop room1's RTC session. Tracked.
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room2);
|
||||
sendLeaveMembership(room2, room2MembershipData);
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
onEnded.mockClear();
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
|
||||
// Stop room1's RTC session. Not tracked.
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
sendLeaveMembership(room1, room1MembershipData);
|
||||
expect(onEnded).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
}
|
||||
});
|
||||
|
||||
it("Doesn't fire event if unrelated sessions ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }];
|
||||
const room1 = makeMockRoom(membership, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
const room2 = makeMockRoom([{ ...membershipTemplate, application: "m.notCall", call_id: "test" }]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
|
||||
|
||||
client.emit(ClientEvent.Room, room2);
|
||||
expect(onStarted).toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
mockRoomState(room2, [{ user_id: membershipTemplate.user_id }]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room2);
|
||||
|
||||
const roomState = room2.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
onEnded.mockClear();
|
||||
|
||||
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
const roomStateOther = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEventOther = roomStateOther.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEventOther, roomStateOther, null);
|
||||
expect(onEnded).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
}
|
||||
});
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
|
||||
it("Doesn't fire event if unrelated sessions ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other_app" }]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
sendLeaveMembership(room1, membership);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
|
||||
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
|
||||
|
||||
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
|
||||
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
});
|
||||
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
MatrixError,
|
||||
UnsupportedDelayedEventsEndpointError,
|
||||
type Room,
|
||||
MAX_STICKY_DURATION_MS,
|
||||
} from "../../../src";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
type LivekitFocusSelection,
|
||||
} from "../../../src/matrixrtc";
|
||||
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
|
||||
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.
|
||||
@@ -93,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(() => {
|
||||
@@ -119,7 +125,9 @@ describe("MembershipManager", () => {
|
||||
it("sends a membership event and schedules delayed leave when joining a call", async () => {
|
||||
// Spys/Mocks
|
||||
|
||||
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
|
||||
const restartScheduledDelayedEventHandle = createAsyncHandle<void>(
|
||||
client._unstable_restartScheduledDelayedEvent as Mock,
|
||||
);
|
||||
|
||||
// Test
|
||||
const memberManager = new MembershipManager(undefined, room, client, callSession);
|
||||
@@ -140,7 +148,7 @@ describe("MembershipManager", () => {
|
||||
},
|
||||
"_@alice:example.org_AAAAAAA_m.call",
|
||||
);
|
||||
updateDelayedEventHandle.resolve?.();
|
||||
restartScheduledDelayedEventHandle.resolve?.();
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
{ delay: 8000 },
|
||||
@@ -151,53 +159,16 @@ describe("MembershipManager", () => {
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends a rtc membership event when using `useRtcMemberFormat`", async () => {
|
||||
// Spys/Mocks
|
||||
|
||||
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
|
||||
|
||||
// Test
|
||||
const memberManager = new MembershipManager({ useRtcMemberFormat: true }, room, client, callSession);
|
||||
memberManager.join([], focus);
|
||||
// expects
|
||||
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
"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: [],
|
||||
},
|
||||
"_@alice:example.org_AAAAAAA_m.call",
|
||||
);
|
||||
updateDelayedEventHandle.resolve?.();
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
{ delay: 8000 },
|
||||
"org.matrix.msc4143.rtc.member",
|
||||
{},
|
||||
"_@alice:example.org_AAAAAAA_m.call",
|
||||
);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reschedules delayed leave event if sending state cancels it", async () => {
|
||||
const memberManager = new MembershipManager(undefined, room, client, callSession);
|
||||
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
||||
const waitForUpdateDelaye = waitForMockCallOnce(
|
||||
client._unstable_updateDelayedEvent,
|
||||
const waitForRestartScheduledDelayedEvent = waitForMockCallOnce(
|
||||
client._unstable_restartScheduledDelayedEvent,
|
||||
Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })),
|
||||
);
|
||||
memberManager.join([focus], focusActive);
|
||||
await waitForSendState;
|
||||
await waitForUpdateDelaye;
|
||||
await waitForRestartScheduledDelayedEvent;
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// Once for the initial event and once because of the errcode: "M_NOT_FOUND"
|
||||
// Different to "sends a membership event and schedules delayed leave when joining a call" where its only called once (1)
|
||||
@@ -213,7 +184,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({
|
||||
@@ -299,13 +270,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 () => {
|
||||
@@ -377,7 +348,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" }),
|
||||
);
|
||||
|
||||
@@ -438,17 +409,17 @@ describe("MembershipManager", () => {
|
||||
manager.join([focus]);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
await manager.leave();
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send");
|
||||
expect(client._unstable_sendScheduledDelayedEvent).toHaveBeenLastCalledWith("id");
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
});
|
||||
it("send leave event when leave is called and resolving delayed leave fails", async () => {
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([focus]);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
|
||||
(client._unstable_sendScheduledDelayedEvent as Mock<any>).mockRejectedValue("unknown");
|
||||
await manager.leave();
|
||||
|
||||
// We send a normal leave event since we failed using updateDelayedEvent with the "send" action.
|
||||
// We send a normal leave event since we failed using sendScheduledDelayedEvent.
|
||||
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
|
||||
room.roomId,
|
||||
"org.matrix.msc3401.call.member",
|
||||
@@ -472,6 +443,9 @@ describe("MembershipManager", () => {
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_cancelScheduledDelayedEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_restartScheduledDelayedEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_sendScheduledDelayedEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does nothing if own membership still present", async () => {
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
@@ -481,6 +455,9 @@ describe("MembershipManager", () => {
|
||||
// reset all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
(client.sendStateEvent as Mock).mockClear();
|
||||
(client._unstable_updateDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_cancelScheduledDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_restartScheduledDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_sendScheduledDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
|
||||
|
||||
await manager.onRTCSessionMemberUpdate([
|
||||
@@ -496,6 +473,9 @@ describe("MembershipManager", () => {
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_cancelScheduledDelayedEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_restartScheduledDelayedEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_sendScheduledDelayedEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("recreates membership if it is missing", async () => {
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
@@ -503,7 +483,7 @@ describe("MembershipManager", () => {
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
(client.sendStateEvent as Mock).mockClear();
|
||||
(client._unstable_updateDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_restartScheduledDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
|
||||
|
||||
// Our own membership is removed:
|
||||
@@ -512,7 +492,7 @@ describe("MembershipManager", () => {
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
|
||||
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalled();
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates the UpdateExpiry entry in the action scheduler", async () => {
|
||||
@@ -521,10 +501,10 @@ describe("MembershipManager", () => {
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
(client.sendStateEvent as Mock).mockClear();
|
||||
(client._unstable_updateDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_restartScheduledDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
|
||||
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValueOnce(
|
||||
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockRejectedValueOnce(
|
||||
new MatrixError({ errcode: "M_NOT_FOUND" }),
|
||||
);
|
||||
|
||||
@@ -537,7 +517,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);
|
||||
});
|
||||
});
|
||||
@@ -557,17 +537,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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -715,7 +695,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,
|
||||
@@ -729,17 +709,17 @@ describe("MembershipManager", () => {
|
||||
|
||||
// Hit rate limit
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Hit second rate limit.
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2);
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Setup resolve
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3);
|
||||
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(3);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -768,7 +748,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,
|
||||
@@ -842,11 +822,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);
|
||||
@@ -856,18 +836,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
|
||||
@@ -921,6 +901,63 @@ 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);
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -22,9 +22,7 @@ import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTra
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
|
||||
import { decodeBase64, TypedEventEmitter } from "../../../src";
|
||||
import { RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
|
||||
import { type RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
|
||||
import { logger, type Logger } from "../../../src/logger.ts";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
import { getParticipantId } from "../../../src/matrixrtc/utils.ts";
|
||||
|
||||
describe("RTCEncryptionManager", () => {
|
||||
@@ -782,86 +780,6 @@ describe("RTCEncryptionManager", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("Should re-distribute key on transport switch", async () => {
|
||||
const toDeviceEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
const mockToDeviceTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: toDeviceEmitter.on.bind(toDeviceEmitter),
|
||||
off: toDeviceEmitter.off.bind(toDeviceEmitter),
|
||||
emit: toDeviceEmitter.emit.bind(toDeviceEmitter),
|
||||
setParentLogger: jest.fn(),
|
||||
} as unknown as Mocked<ToDeviceKeyTransport>;
|
||||
|
||||
const roomEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
const mockRoomTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: roomEmitter.on.bind(roomEmitter),
|
||||
off: roomEmitter.off.bind(roomEmitter),
|
||||
emit: roomEmitter.emit.bind(roomEmitter),
|
||||
setParentLogger: jest.fn(),
|
||||
} as unknown as Mocked<RoomKeyTransport>;
|
||||
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
|
||||
const transport = new RoomAndToDeviceTransport(mockToDeviceTransport, mockRoomTransport, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
"@alice:example.org",
|
||||
"DEVICE01",
|
||||
getMembershipMock,
|
||||
transport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
);
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Should have sent the key to the toDevice transport
|
||||
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockRoomTransport.sendKey).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate receiving a key by room transport
|
||||
roomEmitter.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE",
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
// The key should have been re-distributed to the room transport
|
||||
expect(mockRoomTransport.sendKey).toHaveBeenCalled();
|
||||
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key re-distributed
|
||||
0,
|
||||
// to all the members
|
||||
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
});
|
||||
|
||||
function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
|
||||
return mockCallMembership(
|
||||
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Mocked } from "jest-mock";
|
||||
|
||||
import { makeKey, makeMockEvent, makeMockRoom } from "./mocks";
|
||||
import { EventType, type IRoomTimelineData, type Room, RoomEvent, type MatrixClient } from "../../../src";
|
||||
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import {
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsEvents,
|
||||
mockClientMethodsUser,
|
||||
} from "../../test-utils/client.ts";
|
||||
import { type ParticipantDeviceInfo, type Statistics } from "../../../src/matrixrtc";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { type Logger } from "../../../src/logger.ts";
|
||||
import { RoomAndToDeviceEvents, RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
|
||||
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
|
||||
|
||||
describe("RoomAndToDeviceTransport", () => {
|
||||
const roomId = "!room:id";
|
||||
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
let statistics: Statistics;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
let transport: RoomAndToDeviceTransport;
|
||||
let mockRoom: Room;
|
||||
let sendEventMock: jest.Mock;
|
||||
let roomKeyTransport: RoomKeyTransport;
|
||||
let toDeviceKeyTransport: ToDeviceKeyTransport;
|
||||
let toDeviceSendKeySpy: jest.SpyInstance;
|
||||
let roomSendKeySpy: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
sendEventMock = jest.fn();
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
encryptAndSendToDevice: jest.fn(),
|
||||
getDeviceId: jest.fn().mockReturnValue("MYDEVICE"),
|
||||
...mockClientMethodsEvents(),
|
||||
...mockClientMethodsUser("@alice:example.org"),
|
||||
sendEvent: sendEventMock,
|
||||
});
|
||||
mockRoom = makeMockRoom([]);
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
getChild: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
mockLogger.getChild.mockReturnValue(mockLogger);
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
roomKeyTransport = new RoomKeyTransport(mockRoom, mockClient, statistics);
|
||||
toDeviceKeyTransport = new ToDeviceKeyTransport(
|
||||
"@alice:example.org",
|
||||
"MYDEVICE",
|
||||
mockRoom.roomId,
|
||||
mockClient,
|
||||
statistics,
|
||||
);
|
||||
transport = new RoomAndToDeviceTransport(toDeviceKeyTransport, roomKeyTransport, mockLogger);
|
||||
toDeviceSendKeySpy = jest.spyOn(toDeviceKeyTransport, "sendKey");
|
||||
roomSendKeySpy = jest.spyOn(roomKeyTransport, "sendKey");
|
||||
});
|
||||
|
||||
it("should enable to device transport when starting", () => {
|
||||
transport.start();
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
});
|
||||
it("only sends to device keys when sending a key", async () => {
|
||||
transport.start();
|
||||
await transport.sendKey("1235", 0, [
|
||||
{ userId: "@alice:example.org", deviceId: "ALICEDEVICE", membershipTs: 1234 },
|
||||
]);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
});
|
||||
|
||||
it("enables room transport and disables to device transport when receiving a room key", async () => {
|
||||
transport.start();
|
||||
const onNewKeyFromTransport = jest.fn();
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
mockRoom.emit(
|
||||
RoomEvent.Timeline,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", roomId, {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(transport.enabled.room).toBeTruthy();
|
||||
expect(transport.enabled.toDevice).toBeFalsy();
|
||||
|
||||
await transport.sendKey("1235", 0, [
|
||||
{ userId: "@alice:example.org", deviceId: "AlICEDEV", membershipTs: 1234 },
|
||||
]);
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
|
||||
});
|
||||
|
||||
it("enables room transport and disables to device transport on widget driver error", async () => {
|
||||
mockClient.encryptAndSendToDevice.mockRejectedValue({
|
||||
message:
|
||||
"unknown variant `send_to_device`, expected one of `supported_api_versions`, `content_loaded`, `get_openid`, `org.matrix.msc2876.read_events`, `send_event`, `org.matrix.msc4157.update_delayed_event` at line 1 column 22",
|
||||
});
|
||||
|
||||
transport.start();
|
||||
const membership: ParticipantDeviceInfo = {
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "ALICEDEVICE",
|
||||
membershipTs: 1234,
|
||||
};
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
|
||||
// We start with toDevice transport enabled
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
|
||||
await transport.sendKey("1235", 0, [membership]);
|
||||
|
||||
// We switched transport, now room transport is enabled
|
||||
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
|
||||
expect(transport.enabled.room).toBeTruthy();
|
||||
expect(transport.enabled.toDevice).toBeFalsy();
|
||||
|
||||
// sanity check that we called the failang to device send key.
|
||||
expect(toDeviceKeyTransport.sendKey).toHaveBeenCalledWith("1235", 0, [membership]);
|
||||
expect(toDeviceKeyTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
// We re-sent the key via the room transport
|
||||
expect(roomKeyTransport.sendKey).toHaveBeenCalledWith("1235", 0, [membership]);
|
||||
expect(roomKeyTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
|
||||
mockClient.encryptAndSendToDevice.mockRestore();
|
||||
});
|
||||
|
||||
it("does log that it did nothing when disabled", () => {
|
||||
transport.start();
|
||||
const onNewKeyFromTransport = jest.fn();
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
|
||||
transport.setEnabled({ toDevice: false, room: false });
|
||||
const dateNow = Date.now();
|
||||
roomKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "roomKey", 0, dateNow);
|
||||
toDeviceKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "toDeviceKey", 0, Date.now());
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith("To Device transport is disabled, ignoring received keys");
|
||||
// for room key transport we will never get a disabled message because its will always just turn on
|
||||
expect(onTransportEnabled).toHaveBeenNthCalledWith(1, { toDevice: false, room: false });
|
||||
expect(onTransportEnabled).toHaveBeenNthCalledWith(2, { toDevice: false, room: true });
|
||||
expect(onNewKeyFromTransport).toHaveBeenCalledTimes(1);
|
||||
expect(onNewKeyFromTransport).toHaveBeenCalledWith("user", "device", "roomKey", 0, dateNow);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "stream";
|
||||
import { type Mocked } from "jest-mock";
|
||||
|
||||
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
|
||||
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
@@ -51,6 +52,11 @@ export type MockClient = Pick<
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "_unstable_cancelScheduledDelayedEvent"
|
||||
| "_unstable_restartScheduledDelayedEvent"
|
||||
| "_unstable_sendScheduledDelayedEvent"
|
||||
| "_unstable_sendStickyEvent"
|
||||
| "_unstable_sendStickyDelayedEvent"
|
||||
| "cancelPendingEvent"
|
||||
>;
|
||||
/**
|
||||
@@ -64,16 +70,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 +94,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 +147,7 @@ export function makeMockEvent(
|
||||
roomId: string | undefined,
|
||||
content: any,
|
||||
timestamp?: number,
|
||||
stateKey?: string,
|
||||
): MatrixEvent {
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(type),
|
||||
@@ -137,12 +156,28 @@ export function makeMockEvent(
|
||||
getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()),
|
||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||
getId: jest.fn().mockReturnValue(secureRandomString(8)),
|
||||
getStateKey: jest.fn().mockReturnValue(stateKey),
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(false),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipData, roomId: string): MatrixEvent {
|
||||
return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData);
|
||||
export function mockRTCEvent(
|
||||
{ user_id: sender, ...membershipData }: MembershipData,
|
||||
roomId: string,
|
||||
stickyDuration?: number,
|
||||
timestamp?: number,
|
||||
): MatrixEvent {
|
||||
return {
|
||||
...makeMockEvent(
|
||||
stickyDuration !== undefined ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||
sender,
|
||||
roomId,
|
||||
membershipData,
|
||||
timestamp,
|
||||
!stickyDuration && "device_id" in membershipData ? `_${sender}_${membershipData.device_id}` : "",
|
||||
),
|
||||
unstableStickyExpiresAt: stickyDuration,
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
export function mockCallMembership(membershipData: MembershipData, roomId: string): CallMembership {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type CallMembership } from "../../../src/matrixrtc";
|
||||
import { isMyMembership, parseCallNotificationContent } from "../../../src/matrixrtc/types";
|
||||
|
||||
describe("types", () => {
|
||||
describe("isMyMembership", () => {
|
||||
it("returns false if userId is different", () => {
|
||||
expect(
|
||||
isMyMembership(
|
||||
{ sender: "@alice:example.org", deviceId: "DEVICE" } as CallMembership,
|
||||
"@bob:example.org",
|
||||
"DEVICE",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("returns true if userId and device is the same", () => {
|
||||
expect(
|
||||
isMyMembership(
|
||||
{ sender: "@alice:example.org", deviceId: "DEVICE" } as CallMembership,
|
||||
"@alice:example.org",
|
||||
"DEVICE",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("IRTCNotificationContent", () => {
|
||||
const validBase = Object.freeze({
|
||||
"m.mentions": { user_ids: [], room: true },
|
||||
"notification_type": "notification",
|
||||
"sender_ts": 123,
|
||||
"lifetime": 1000,
|
||||
});
|
||||
|
||||
it("parses valid content", () => {
|
||||
const res = parseCallNotificationContent({ ...validBase });
|
||||
expect(res).toMatchObject(validBase);
|
||||
});
|
||||
|
||||
it("caps lifetime to 90000ms", () => {
|
||||
const res = parseCallNotificationContent({ ...validBase, lifetime: 130000 });
|
||||
expect(res.lifetime).toBe(90000);
|
||||
});
|
||||
|
||||
it("throws on malformed m.mentions", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
"m.mentions": "not an object",
|
||||
} as any),
|
||||
).toThrow("malformed m.mentions");
|
||||
});
|
||||
|
||||
it("throws on missing or invalid notification_type", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
notification_type: undefined,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid notification_type");
|
||||
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
notification_type: 123 as any,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid notification_type");
|
||||
});
|
||||
|
||||
it("throws on missing or invalid sender_ts", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
sender_ts: undefined,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid sender_ts");
|
||||
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
sender_ts: "123" as any,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid sender_ts");
|
||||
});
|
||||
|
||||
it("throws on missing or invalid lifetime", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
lifetime: undefined,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid lifetime");
|
||||
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
lifetime: "1000" as any,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid lifetime");
|
||||
});
|
||||
|
||||
it("accepts valid relation (m.reference)", () => {
|
||||
// Note: parseCallNotificationContent currently checks `relation.rel_type` rather than `m.relates_to`.
|
||||
const res = parseCallNotificationContent({
|
||||
...validBase,
|
||||
relation: { rel_type: "m.reference", event_id: "$ev" },
|
||||
} as any);
|
||||
expect(res).toBeTruthy();
|
||||
});
|
||||
|
||||
it("throws on invalid relation rel_type", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
relation: { rel_type: "m.annotation", event_id: "$ev" },
|
||||
} as any),
|
||||
).toThrow("Invalid relation");
|
||||
});
|
||||
});
|
||||
@@ -259,4 +259,164 @@ describe("RoomStickyEvents", () => {
|
||||
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 }], []);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -338,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.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+16
-5
@@ -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";
|
||||
|
||||
@@ -136,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;
|
||||
};
|
||||
|
||||
|
||||
+128
-9
@@ -106,6 +106,7 @@ import { RoomMemberEvent, type RoomMemberEventHandlerMap } from "./models/room-m
|
||||
import { type IPowerLevelsContent, type RoomStateEvent, type RoomStateEventHandlerMap } from "./models/room-state.ts";
|
||||
import {
|
||||
isSendDelayedEventRequestOpts,
|
||||
UpdateDelayedEventAction,
|
||||
type DelayedEventInfo,
|
||||
type IAddThreePidOnlyBody,
|
||||
type IBindThreePidBody,
|
||||
@@ -130,7 +131,6 @@ import {
|
||||
type KnockRoomOpts,
|
||||
type SendDelayedEventRequestOpts,
|
||||
type SendDelayedEventResponse,
|
||||
type UpdateDelayedEventAction,
|
||||
} from "./@types/requests.ts";
|
||||
import {
|
||||
type AccountDataEvents,
|
||||
@@ -3537,13 +3537,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending delayed events for the calling user.
|
||||
* Get information about delayed events owned by the requesting user.
|
||||
*
|
||||
* Note: This endpoint is unstable, and can throw an `Error`.
|
||||
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public async _unstable_getDelayedEvents(fromToken?: string): Promise<DelayedEventInfo> {
|
||||
public async _unstable_getDelayedEvents(
|
||||
status?: "scheduled" | "finalised",
|
||||
delayId?: string | string[],
|
||||
fromToken?: string,
|
||||
): Promise<DelayedEventInfo> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
@@ -3551,7 +3555,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
);
|
||||
}
|
||||
|
||||
const queryDict = fromToken ? { from: fromToken } : undefined;
|
||||
const queryDict = {
|
||||
from: fromToken,
|
||||
status,
|
||||
delay_id: delayId,
|
||||
};
|
||||
return await this.http.authedRequest(Method.Get, "/delayed_events", queryDict, undefined, {
|
||||
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
|
||||
});
|
||||
@@ -3562,8 +3570,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* Note: This endpoint is unstable, and can throw an `Error`.
|
||||
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
|
||||
*
|
||||
* @deprecated Instead use one of:
|
||||
* - {@link _unstable_cancelScheduledDelayedEvent}
|
||||
* - {@link _unstable_restartScheduledDelayedEvent}
|
||||
* - {@link _unstable_sendScheduledDelayedEvent}
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_updateDelayedEvent(
|
||||
delayId: string,
|
||||
action: UpdateDelayedEventAction,
|
||||
@@ -3575,17 +3588,123 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
"updateDelayedEvent",
|
||||
);
|
||||
}
|
||||
return await this.updateScheduledDelayedEventWithActionInBody(delayId, action, requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the scheduled delivery of the delayed event matching the provided delayId.
|
||||
*
|
||||
* Note: This endpoint is unstable, and can throw an `Error`.
|
||||
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
|
||||
*
|
||||
* @throws A M_NOT_FOUND error if no matching delayed event could be found.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_cancelScheduledDelayedEvent(
|
||||
delayId: string,
|
||||
requestOptions: IRequestOpts = {},
|
||||
): Promise<EmptyObject> {
|
||||
return await this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Cancel, requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the scheduled delivery of the delayed event matching the given delayId.
|
||||
*
|
||||
* Note: This endpoint is unstable, and can throw an `Error`.
|
||||
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
|
||||
*
|
||||
* @throws A M_NOT_FOUND error if no matching delayed event could be found.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_restartScheduledDelayedEvent(
|
||||
delayId: string,
|
||||
requestOptions: IRequestOpts = {},
|
||||
): Promise<EmptyObject> {
|
||||
return await this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Restart, requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately send the delayed event matching the given delayId,
|
||||
* instead of waiting for its scheduled delivery.
|
||||
*
|
||||
* Note: This endpoint is unstable, and can throw an `Error`.
|
||||
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
|
||||
*
|
||||
* @throws A M_NOT_FOUND error if no matching delayed event could be found.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_sendScheduledDelayedEvent(
|
||||
delayId: string,
|
||||
requestOptions: IRequestOpts = {},
|
||||
): Promise<EmptyObject> {
|
||||
return await this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Send, requestOptions);
|
||||
}
|
||||
|
||||
private async updateScheduledDelayedEvent(
|
||||
delayId: string,
|
||||
action: UpdateDelayedEventAction,
|
||||
requestOptions: IRequestOpts = {},
|
||||
): Promise<EmptyObject> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
`${action}ScheduledDelayedEvent`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const path = utils.encodeUri("/delayed_events/$delayId/$action", {
|
||||
$delayId: delayId,
|
||||
$action: action,
|
||||
});
|
||||
return await this.http.request(Method.Post, path, undefined, undefined, {
|
||||
...requestOptions,
|
||||
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") {
|
||||
// For backwards compatibility with an older version of this endpoint
|
||||
// which put the update action in the request body instead of the path
|
||||
return await this.updateScheduledDelayedEventWithActionInBody(delayId, action, requestOptions);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Present for backwards compatibility with an older version of MSC4140
|
||||
* which had a single, authenticated endpoint for updating a delayed event, instead
|
||||
* of one unauthenticated endpoint per update action.
|
||||
*/
|
||||
private async updateScheduledDelayedEventWithActionInBody(
|
||||
delayId: string,
|
||||
action: UpdateDelayedEventAction,
|
||||
requestOptions: IRequestOpts = {},
|
||||
): Promise<EmptyObject> {
|
||||
const path = utils.encodeUri("/delayed_events/$delayId", {
|
||||
$delayId: delayId,
|
||||
});
|
||||
const data = {
|
||||
action,
|
||||
};
|
||||
return await this.http.authedRequest(Method.Post, path, undefined, data, {
|
||||
...requestOptions,
|
||||
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
|
||||
});
|
||||
try {
|
||||
return await this.http.request(Method.Post, path, undefined, data, {
|
||||
...requestOptions,
|
||||
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof MatrixError && e.errcode === "M_MISSING_TOKEN") {
|
||||
// For backwards compatibility with an older version of this endpoint
|
||||
// which required authentication
|
||||
return await this.http.authedRequest(Method.Post, path, undefined, data, {
|
||||
...requestOptions,
|
||||
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+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}
|
||||
*/
|
||||
|
||||
+67
-3
@@ -37,7 +37,7 @@ import {
|
||||
type ISendEventResponse,
|
||||
type SendDelayedEventRequestOpts,
|
||||
type SendDelayedEventResponse,
|
||||
type UpdateDelayedEventAction,
|
||||
UpdateDelayedEventAction,
|
||||
} from "./@types/requests.ts";
|
||||
import { EventType, type StateEvents } from "./@types/event.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
@@ -459,8 +459,12 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
|
||||
/**
|
||||
* @experimental This currently relies on an unstable MSC (MSC4140).
|
||||
* @deprecated Instead use one of:
|
||||
* - {@link _unstable_cancelScheduledDelayedEvent}
|
||||
* - {@link _unstable_restartScheduledDelayedEvent}
|
||||
* - {@link _unstable_sendScheduledDelayedEvent}
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise<EmptyObject> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
@@ -469,7 +473,67 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
);
|
||||
}
|
||||
|
||||
await this.widgetApi.updateDelayedEvent(delayId, action).catch(timeoutToConnectionError);
|
||||
let updateDelayedEvent: (delayId: string) => Promise<unknown>;
|
||||
switch (action) {
|
||||
case UpdateDelayedEventAction.Cancel:
|
||||
updateDelayedEvent = this.widgetApi.cancelScheduledDelayedEvent;
|
||||
break;
|
||||
case UpdateDelayedEventAction.Restart:
|
||||
updateDelayedEvent = this.widgetApi.cancelScheduledDelayedEvent;
|
||||
break;
|
||||
case UpdateDelayedEventAction.Send:
|
||||
updateDelayedEvent = this.widgetApi.sendScheduledDelayedEvent;
|
||||
break;
|
||||
}
|
||||
await updateDelayedEvent.call(this.widgetApi, delayId).catch(timeoutToConnectionError);
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental This currently relies on an unstable MSC (MSC4140).
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_cancelScheduledDelayedEvent(delayId: string): Promise<EmptyObject> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"cancelScheduledDelayedEvent",
|
||||
);
|
||||
}
|
||||
|
||||
await this.widgetApi.cancelScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental This currently relies on an unstable MSC (MSC4140).
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_restartScheduledDelayedEvent(delayId: string): Promise<EmptyObject> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"restartScheduledDelayedEvent",
|
||||
);
|
||||
}
|
||||
|
||||
await this.widgetApi.restartScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental This currently relies on an unstable MSC (MSC4140).
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public async _unstable_sendScheduledDelayedEvent(delayId: string): Promise<EmptyObject> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"sendScheduledDelayedEvent",
|
||||
);
|
||||
}
|
||||
|
||||
await this.widgetApi.sendScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -59,7 +59,14 @@ export class ClientStoppedError extends Error {
|
||||
export class UnsupportedDelayedEventsEndpointError extends Error {
|
||||
public constructor(
|
||||
message: string,
|
||||
public clientEndpoint: "sendDelayedEvent" | "updateDelayedEvent" | "sendDelayedStateEvent" | "getDelayedEvents",
|
||||
public clientEndpoint:
|
||||
| "sendDelayedEvent"
|
||||
| "updateDelayedEvent"
|
||||
| "cancelScheduledDelayedEvent"
|
||||
| "restartScheduledDelayedEvent"
|
||||
| "sendScheduledDelayedEvent"
|
||||
| "sendDelayedStateEvent"
|
||||
| "getDelayedEvents",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "UnsupportedDelayedEventsEndpointError";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -195,6 +195,10 @@ 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;
|
||||
};
|
||||
|
||||
const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => {
|
||||
|
||||
@@ -7,11 +7,6 @@ import { type CallMembership } from "./CallMembership.ts";
|
||||
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import { isMyMembership, type ParticipantId, type Statistics } from "./types.ts";
|
||||
import { getParticipantId } from "./utils.ts";
|
||||
import {
|
||||
type EnabledTransports,
|
||||
RoomAndToDeviceEvents,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
|
||||
/**
|
||||
* This interface is for testing and for making it possible to interchange the encryption manager.
|
||||
@@ -119,10 +114,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
|
||||
|
||||
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
// Deprecate RoomKeyTransport: this can get removed.
|
||||
if (this.transport instanceof RoomAndToDeviceTransport) {
|
||||
this.transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, this.onTransportChanged);
|
||||
}
|
||||
|
||||
this.transport.start();
|
||||
if (this.joinConfig?.manageMediaKeys) {
|
||||
this.makeNewSenderKey();
|
||||
@@ -315,10 +307,6 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
};
|
||||
|
||||
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
|
||||
this.requestSendCurrentKey();
|
||||
};
|
||||
|
||||
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
this.logger.debug(`Received key over key transport ${userId}:${deviceId} at index ${index}`);
|
||||
this.setEncryptionKey(userId, deviceId, index, keyBase64Encoded, timestamp);
|
||||
|
||||
@@ -24,7 +24,7 @@ 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 { MembershipManager } from "./MembershipManager.ts";
|
||||
import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { deepCompare, logDurationSync } from "../utils.ts";
|
||||
import type {
|
||||
@@ -36,20 +36,17 @@ import type {
|
||||
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
|
||||
@@ -123,14 +120,6 @@ export function slotDescriptionToId(slotDescription: SlotDescription): string {
|
||||
// - 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.
|
||||
@@ -189,10 +178,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;
|
||||
useRtcMemberFormat?: boolean;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
@@ -238,13 +235,26 @@ export interface EncryptionConfig {
|
||||
}
|
||||
export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig;
|
||||
|
||||
interface SessionMembershipsForRoomOpts {
|
||||
/**
|
||||
* Listen for incoming sticky member events. If disabled, this session will
|
||||
* ignore any incoming sticky events.
|
||||
*/
|
||||
listenForStickyEvents: boolean;
|
||||
/**
|
||||
* Listen for incoming member state events (legacy). If disabled, this session will
|
||||
* ignore any incoming state events.
|
||||
*/
|
||||
listenForMemberStateEvents: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
|
||||
* This class doesn't deal with media at all, just membership & properties of a session.
|
||||
*/
|
||||
export class MatrixRTCSession extends TypedEventEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap
|
||||
MatrixRTCSessionEvent | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap & MembershipManagerEventHandlerMap
|
||||
> {
|
||||
private membershipManager?: IMembershipManager;
|
||||
private encryptionManager?: IEncryptionManager;
|
||||
@@ -307,7 +317,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead.
|
||||
*/
|
||||
public static callMembershipsForRoom(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
|
||||
): CallMembership[] {
|
||||
return MatrixRTCSession.sessionMembershipsForSlot(room, {
|
||||
id: "",
|
||||
@@ -319,7 +329,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead.
|
||||
*/
|
||||
public static sessionMembershipsForRoom(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
|
||||
sessionDescription: SlotDescription,
|
||||
): CallMembership[] {
|
||||
return this.sessionMembershipsForSlot(room, sessionDescription);
|
||||
@@ -328,30 +338,58 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
/**
|
||||
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
||||
* oldest first.
|
||||
*
|
||||
* By default, this will return *both* sticky and member state events.
|
||||
*/
|
||||
public static sessionMembershipsForSlot(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
|
||||
slotDescription: SlotDescription,
|
||||
// default both true this implied we combine sticky and state events for the final call state
|
||||
// (prefer sticky events in case of a duplicate)
|
||||
{ listenForStickyEvents, listenForMemberStateEvents }: SessionMembershipsForRoomOpts = {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
},
|
||||
): CallMembership[] {
|
||||
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!roomState) {
|
||||
logger.warn("Couldn't get state for room " + room.roomId);
|
||||
throw new Error("Could't get state for room " + room.roomId);
|
||||
let callMemberEvents = [] as MatrixEvent[];
|
||||
if (listenForStickyEvents) {
|
||||
// prefill with sticky events
|
||||
callMemberEvents = [...room._unstable_getStickyEvents()].filter(
|
||||
(e) => e.getType() === EventType.RTCMembership,
|
||||
);
|
||||
}
|
||||
if (listenForMemberStateEvents) {
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!roomState) {
|
||||
logger.warn("Couldn't get state for room " + room.roomId);
|
||||
throw new Error("Could't get state for room " + room.roomId);
|
||||
}
|
||||
const callMemberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
|
||||
callMemberEvents = callMemberEvents.concat(
|
||||
callMemberStateEvents.filter(
|
||||
(callMemberStateEvent) =>
|
||||
!callMemberEvents.some(
|
||||
// only care about state events which have keys which we have not yet seen in the sticky events.
|
||||
(stickyEvent) =>
|
||||
stickyEvent.getContent().msc4354_sticky_key === callMemberStateEvent.getStateKey(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
const callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
|
||||
|
||||
const callMemberships: CallMembership[] = [];
|
||||
for (const memberEvent of callMemberEvents) {
|
||||
const content = memberEvent.getContent();
|
||||
const eventKeysCount = Object.keys(content).length;
|
||||
// Ignore sticky keys for the count
|
||||
const eventKeysCount = Object.keys(content).filter((k) => k !== "msc4354_sticky_key").length;
|
||||
// Dont even bother about empty events (saves us from costly type/"key in" checks in bigger rooms)
|
||||
if (eventKeysCount === 0) continue;
|
||||
|
||||
const membershipContents: any[] = [];
|
||||
|
||||
// We first decide if its a MSC4143 event (per device state key)
|
||||
if (eventKeysCount > 1 && "focus_active" in content) {
|
||||
if (eventKeysCount > 1 && "application" in content) {
|
||||
// We have a MSC4143 event membership event
|
||||
membershipContents.push(content);
|
||||
} else if (eventKeysCount === 1 && "memberships" in content) {
|
||||
@@ -411,8 +449,16 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
*
|
||||
* @deprecated Use `MatrixRTCSession.sessionForSlot` with sessionDescription `{ id: "", application: "m.call" }` instead.
|
||||
*/
|
||||
public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession {
|
||||
const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, { id: "", application: "m.call" });
|
||||
public static roomSessionForRoom(
|
||||
client: MatrixClient,
|
||||
room: Room,
|
||||
opts?: SessionMembershipsForRoomOpts,
|
||||
): MatrixRTCSession {
|
||||
const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(
|
||||
room,
|
||||
{ id: "", application: "m.call" },
|
||||
opts,
|
||||
);
|
||||
return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" });
|
||||
}
|
||||
|
||||
@@ -428,9 +474,13 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* This returned session can be used to find out if there are active sessions
|
||||
* for the requested room and `slotDescription`.
|
||||
*/
|
||||
public static sessionForSlot(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession {
|
||||
const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription);
|
||||
|
||||
public static sessionForSlot(
|
||||
client: MatrixClient,
|
||||
room: Room,
|
||||
slotDescription: SlotDescription,
|
||||
opts?: SessionMembershipsForRoomOpts,
|
||||
): MatrixRTCSession {
|
||||
const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription, opts);
|
||||
return new MatrixRTCSession(client, room, callMemberships, slotDescription);
|
||||
}
|
||||
|
||||
@@ -461,10 +511,15 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
MatrixClient,
|
||||
| "getUserId"
|
||||
| "getDeviceId"
|
||||
| "sendEvent"
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "sendEvent"
|
||||
| "_unstable_cancelScheduledDelayedEvent"
|
||||
| "_unstable_restartScheduledDelayedEvent"
|
||||
| "_unstable_sendScheduledDelayedEvent"
|
||||
| "_unstable_sendStickyEvent"
|
||||
| "_unstable_sendStickyDelayedEvent"
|
||||
| "cancelPendingEvent"
|
||||
| "encryptAndSendToDevice"
|
||||
| "off"
|
||||
@@ -488,9 +543,10 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
|
||||
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
this.roomSubset.on(RoomStickyEventsEvent.Update, this.onStickyEventUpdate);
|
||||
|
||||
this.setExpiryTimer();
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns true if we intend to be participating in the MatrixRTC session.
|
||||
* This is determined by checking if the relativeExpiry has been set.
|
||||
@@ -510,10 +566,12 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
}
|
||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
this.roomSubset.off(RoomStickyEventsEvent.Update, this.onStickyEventUpdate);
|
||||
}
|
||||
|
||||
private reEmitter = new TypedReEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap
|
||||
MatrixRTCSessionEvent | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap & MembershipManagerEventHandlerMap
|
||||
>(this);
|
||||
|
||||
/**
|
||||
@@ -540,14 +598,15 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
return;
|
||||
} else {
|
||||
// Create MembershipManager and pass the RTCSession logger (with room id info)
|
||||
|
||||
this.membershipManager = new MembershipManager(
|
||||
joinConfig,
|
||||
this.roomSubset,
|
||||
this.client,
|
||||
this.slotDescription,
|
||||
this.logger,
|
||||
);
|
||||
this.membershipManager = joinConfig?.unstableSendStickyEvents
|
||||
? new StickyEventMembershipManager(
|
||||
joinConfig,
|
||||
this.roomSubset,
|
||||
this.client,
|
||||
this.slotDescription,
|
||||
this.logger,
|
||||
)
|
||||
: new MembershipManager(joinConfig, this.roomSubset, this.client, this.slotDescription, this.logger);
|
||||
|
||||
this.reEmitter.reEmit(this.membershipManager!, [
|
||||
MembershipManagerEvent.ProbablyLeft,
|
||||
@@ -560,13 +619,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
this.logger.info("Using to-device with room fallback transport for encryption keys");
|
||||
const [uId, dId] = [this.client.getUserId()!, this.client.getDeviceId()!];
|
||||
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics];
|
||||
// Deprecate RoomKeyTransport: only ToDeviceKeyTransport is needed once deprecated
|
||||
const roomKeyTransport = new RoomKeyTransport(room, client, statistics);
|
||||
const toDeviceTransport = new ToDeviceKeyTransport(uId, dId, room.roomId, client, statistics);
|
||||
transport = new RoomAndToDeviceTransport(toDeviceTransport, roomKeyTransport, this.logger);
|
||||
|
||||
// Expose the changes so the ui can display the currently used transport.
|
||||
this.reEmitter.reEmit(transport, [RoomAndToDeviceEvents.EnabledTransportsChanged]);
|
||||
const transport = new ToDeviceKeyTransport(uId, dId, room.roomId, client, statistics);
|
||||
this.encryptionManager = new RTCEncryptionManager(
|
||||
this.client.getUserId()!,
|
||||
this.client.getDeviceId()!,
|
||||
@@ -786,10 +839,27 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
/**
|
||||
* Call this when the Matrix room members have changed.
|
||||
*/
|
||||
public onRoomMemberUpdate = (): void => {
|
||||
private readonly onRoomMemberUpdate = (): void => {
|
||||
this.recalculateSessionMembers();
|
||||
};
|
||||
|
||||
/**
|
||||
* Call this when a sticky event update has occured.
|
||||
*/
|
||||
private readonly onStickyEventUpdate: RoomStickyEventsMap[RoomStickyEventsEvent.Update] = (
|
||||
added,
|
||||
updated,
|
||||
removed,
|
||||
): void => {
|
||||
if (
|
||||
[...added, ...removed, ...updated.flatMap((v) => [v.current, v.previous])].some(
|
||||
(e) => e.getType() === EventType.RTCMembership,
|
||||
)
|
||||
) {
|
||||
this.recalculateSessionMembers();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Call this when something changed that may impacts the current MatrixRTC members in this session.
|
||||
*/
|
||||
@@ -839,6 +909,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,7 +18,7 @@ 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 SlotDescription } from "./MatrixRTCSession.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
@@ -73,6 +73,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -113,16 +115,28 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
this.refreshRoom(room);
|
||||
};
|
||||
|
||||
private onRoomState = (event: MatrixEvent, _state: RoomState): void => {
|
||||
private readonly onEvent = (event: MatrixEvent): void => {
|
||||
if (!event.unstableStickyExpiresAt) return; // Not sticky, not interested.
|
||||
|
||||
if (event.getType() !== EventType.RTCMembership) return;
|
||||
|
||||
const room = this.client.getRoom(event.getRoomId());
|
||||
if (!room) return;
|
||||
|
||||
this.refreshRoom(room);
|
||||
};
|
||||
|
||||
private readonly onRoomState = (event: MatrixEvent): void => {
|
||||
if (event.getType() !== EventType.GroupCallMemberPrefix) {
|
||||
return;
|
||||
}
|
||||
const room = this.client.getRoom(event.getRoomId());
|
||||
if (!room) {
|
||||
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.getType() == EventType.GroupCallMemberPrefix) {
|
||||
this.refreshRoom(room);
|
||||
}
|
||||
this.refreshRoom(room);
|
||||
};
|
||||
|
||||
private refreshRoom(room: Room): void {
|
||||
|
||||
+167
-111
@@ -16,7 +16,8 @@ limitations under the License.
|
||||
import { AbortError } from "p-retry";
|
||||
|
||||
import { EventType, RelationType } from "../@types/event.ts";
|
||||
import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
||||
import { type ISendEventResponse, type SendDelayedEventResponse } from "../@types/requests.ts";
|
||||
import { type EmptyObject } from "../@types/common.ts";
|
||||
import type { MatrixClient } from "../client.ts";
|
||||
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
@@ -84,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
|
||||
@@ -144,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:
|
||||
@@ -162,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;
|
||||
@@ -295,16 +326,9 @@ export class MembershipManager
|
||||
* @param client
|
||||
*/
|
||||
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 readonly joinConfig: (SessionConfig & MembershipConfig) | undefined,
|
||||
protected readonly room: Pick<Room, "roomId" | "getVersion">,
|
||||
protected readonly client: MembershipManagerClient,
|
||||
public readonly slotDescription: SlotDescription,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
@@ -361,11 +385,11 @@ export class MembershipManager
|
||||
};
|
||||
}
|
||||
// Membership Event static parameters:
|
||||
private deviceId: string;
|
||||
private memberId: string;
|
||||
protected deviceId: string;
|
||||
protected memberId: string;
|
||||
protected rtcTransport?: Transport;
|
||||
/** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */
|
||||
private fociPreferred?: Transport[];
|
||||
private rtcTransport?: Transport;
|
||||
|
||||
// Config:
|
||||
private delayedLeaveEventDelayMsOverride?: number;
|
||||
@@ -380,9 +404,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 {
|
||||
@@ -394,13 +422,10 @@ export class MembershipManager
|
||||
private get maximumNetworkErrorRetryCount(): number {
|
||||
return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10;
|
||||
}
|
||||
|
||||
private get delayedLeaveEventRestartLocalTimeoutMs(): number {
|
||||
return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000;
|
||||
}
|
||||
private get useRtcMemberFormat(): boolean {
|
||||
return this.joinConfig?.useRtcMemberFormat ?? false;
|
||||
}
|
||||
|
||||
// LOOP HANDLER:
|
||||
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
|
||||
switch (type) {
|
||||
@@ -455,22 +480,23 @@ export class MembershipManager
|
||||
}
|
||||
}
|
||||
|
||||
// an abstraction to switch between sending state or a sticky event
|
||||
protected clientSendDelayedDisconnectMembership: () => Promise<SendDelayedEventResponse> = () =>
|
||||
this.client._unstable_sendDelayedStateEvent(
|
||||
this.room.roomId,
|
||||
{ delay: this.delayedLeaveEventDelayMs },
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{},
|
||||
this.memberId,
|
||||
);
|
||||
|
||||
// HANDLERS (used in the membershipLoopHandler)
|
||||
private async sendOrResendDelayedLeaveEvent(): Promise<ActionUpdate> {
|
||||
// We can reach this at the start of a call (where we do not yet have a membership: state.hasMemberStateEvent=false)
|
||||
// or during a call if the state event canceled our delayed event or caused by an unexpected error that removed our delayed event.
|
||||
// (Another client could have canceled it, the homeserver might have removed/lost it due to a restart, ...)
|
||||
// In the `then` and `catch` block we treat both cases differently. "if (this.state.hasMemberStateEvent) {} else {}"
|
||||
return await this.client
|
||||
._unstable_sendDelayedStateEvent(
|
||||
this.room.roomId,
|
||||
{
|
||||
delay: this.delayedLeaveEventDelayMs,
|
||||
},
|
||||
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||
{}, // leave event
|
||||
this.memberId,
|
||||
)
|
||||
return await this.clientSendDelayedDisconnectMembership()
|
||||
.then((response) => {
|
||||
this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs;
|
||||
this.setAndEmitProbablyLeft(false);
|
||||
@@ -494,7 +520,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) {
|
||||
@@ -521,7 +547,7 @@ 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.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
|
||||
@@ -529,7 +555,7 @@ export class MembershipManager
|
||||
})
|
||||
.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)) {
|
||||
@@ -583,10 +609,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
|
||||
@@ -614,7 +637,7 @@ export class MembershipManager
|
||||
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
|
||||
@@ -624,7 +647,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);
|
||||
@@ -638,7 +661,7 @@ export class MembershipManager
|
||||
this.state.delayId = 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)
|
||||
@@ -650,14 +673,19 @@ export class MembershipManager
|
||||
});
|
||||
}
|
||||
|
||||
protected clientSendMembership: (
|
||||
myMembership: RtcMembershipData | SessionMembershipData | EmptyObject,
|
||||
) => Promise<ISendEventResponse> = (myMembership) => {
|
||||
return this.client.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
myMembership as EmptyObject | SessionMembershipData,
|
||||
this.memberId,
|
||||
);
|
||||
};
|
||||
|
||||
private async sendJoinEvent(): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
.sendStateEvent(
|
||||
this.room.roomId,
|
||||
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||
this.makeMyMembership(this.membershipEventExpiryMs),
|
||||
this.memberId,
|
||||
)
|
||||
return await this.clientSendMembership(this.makeMyMembership(this.membershipEventExpiryMs))
|
||||
.then(() => {
|
||||
this.setAndEmitProbablyLeft(false);
|
||||
this.state.startTime = Date.now();
|
||||
@@ -697,13 +725,9 @@ export class MembershipManager
|
||||
|
||||
private async updateExpiryOnJoinedEvent(): Promise<ActionUpdate> {
|
||||
const nextExpireUpdateIteration = this.state.expireUpdateIterations + 1;
|
||||
return await this.client
|
||||
.sendStateEvent(
|
||||
this.room.roomId,
|
||||
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
|
||||
this.memberId,
|
||||
)
|
||||
return await this.clientSendMembership(
|
||||
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
|
||||
)
|
||||
.then(() => {
|
||||
// Success, we reset retries and schedule update.
|
||||
this.resetRateLimitCounter(MembershipActionType.UpdateExpiry);
|
||||
@@ -725,13 +749,7 @@ export class MembershipManager
|
||||
});
|
||||
}
|
||||
private async sendFallbackLeaveEvent(): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
.sendStateEvent(
|
||||
this.room.roomId,
|
||||
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||
{},
|
||||
this.memberId,
|
||||
)
|
||||
return await this.clientSendMembership({})
|
||||
.then(() => {
|
||||
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
|
||||
this.state.hasMemberStateEvent = false;
|
||||
@@ -757,45 +775,29 @@ export class MembershipManager
|
||||
/**
|
||||
* Constructs our own membership
|
||||
*/
|
||||
private makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
|
||||
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
|
||||
const ownMembership = this.ownMembership;
|
||||
if (this.useRtcMemberFormat) {
|
||||
const relationObject = ownMembership?.eventId
|
||||
? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } }
|
||||
: {};
|
||||
return {
|
||||
application: {
|
||||
type: this.slotDescription.application,
|
||||
...(this.callIntent ? { "m.call.intent": this.callIntent } : {}),
|
||||
},
|
||||
slot_id: slotDescriptionToId(this.slotDescription),
|
||||
rtc_transports: this.rtcTransport ? [this.rtcTransport] : [],
|
||||
member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId },
|
||||
versions: [],
|
||||
...relationObject,
|
||||
};
|
||||
} else {
|
||||
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 {
|
||||
"application": this.slotDescription.application,
|
||||
"call_id": this.slotDescription.id,
|
||||
"scope": "m.room",
|
||||
"device_id": this.deviceId,
|
||||
expires,
|
||||
"m.call.intent": this.callIntent,
|
||||
...focusObjects,
|
||||
...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
"application": this.slotDescription.application,
|
||||
"call_id": this.slotDescription.id,
|
||||
"scope": "m.room",
|
||||
"device_id": this.deviceId,
|
||||
expires,
|
||||
"m.call.intent": this.callIntent,
|
||||
...focusObjects,
|
||||
...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
// Error checks and handlers
|
||||
@@ -830,7 +832,7 @@ export class MembershipManager
|
||||
return false;
|
||||
}
|
||||
|
||||
private actionUpdateFromErrors(
|
||||
protected actionUpdateFromErrors(
|
||||
error: unknown,
|
||||
type: MembershipActionType,
|
||||
method: string,
|
||||
@@ -878,7 +880,7 @@ export class MembershipManager
|
||||
return createInsertActionUpdate(type, resendDelay);
|
||||
}
|
||||
|
||||
throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + "): " + (error as Error));
|
||||
throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + ")", { cause: error });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1022,14 +1024,68 @@ export class MembershipManager
|
||||
}
|
||||
}
|
||||
|
||||
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
insert: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Implementation of the Membership manager that uses sticky events
|
||||
* rather than state events.
|
||||
*/
|
||||
export class StickyEventMembershipManager extends MembershipManager {
|
||||
public constructor(
|
||||
joinConfig: (SessionConfig & MembershipConfig) | undefined,
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
|
||||
private readonly clientWithSticky: MembershipManagerClient &
|
||||
Pick<MatrixClient, "_unstable_sendStickyEvent" | "_unstable_sendStickyDelayedEvent">,
|
||||
sessionDescription: SlotDescription,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super(joinConfig, room, clientWithSticky, sessionDescription, parentLogger);
|
||||
}
|
||||
|
||||
function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
replace: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
protected clientSendDelayedDisconnectMembership: () => Promise<SendDelayedEventResponse> = () =>
|
||||
this.clientWithSticky._unstable_sendStickyDelayedEvent(
|
||||
this.room.roomId,
|
||||
MEMBERSHIP_STICKY_DURATION_MS,
|
||||
{ delay: this.delayedLeaveEventDelayMs },
|
||||
null,
|
||||
EventType.RTCMembership,
|
||||
{ msc4354_sticky_key: this.memberId },
|
||||
);
|
||||
|
||||
protected clientSendMembership: (
|
||||
myMembership: RtcMembershipData | SessionMembershipData | EmptyObject,
|
||||
) => Promise<ISendEventResponse> = (myMembership) => {
|
||||
return this.clientWithSticky._unstable_sendStickyEvent(
|
||||
this.room.roomId,
|
||||
MEMBERSHIP_STICKY_DURATION_MS,
|
||||
null,
|
||||
EventType.RTCMembership,
|
||||
{ ...myMembership, msc4354_sticky_key: this.memberId },
|
||||
);
|
||||
};
|
||||
|
||||
private static nameMap = new Map([
|
||||
["sendStateEvent", "_unstable_sendStickyEvent"],
|
||||
["sendDelayedStateEvent", "_unstable_sendStickyDelayedEvent"],
|
||||
]);
|
||||
protected actionUpdateFromErrors(e: unknown, t: MembershipActionType, m: string): ActionUpdate | undefined {
|
||||
return super.actionUpdateFromErrors(e, t, StickyEventMembershipManager.nameMap.get(m) ?? "unknown");
|
||||
}
|
||||
|
||||
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
|
||||
const ownMembership = this.ownMembership;
|
||||
|
||||
const relationObject = ownMembership?.eventId
|
||||
? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } }
|
||||
: {};
|
||||
return {
|
||||
application: {
|
||||
type: this.slotDescription.application,
|
||||
...(this.callIntent ? { "m.call.intent": this.callIntent } : {}),
|
||||
},
|
||||
slot_id: slotDescriptionToId(this.slotDescription),
|
||||
rtc_transports: this.rtcTransport ? [this.rtcTransport] : [],
|
||||
member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId },
|
||||
versions: [],
|
||||
...relationObject,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,6 @@ import type {
|
||||
Statistics,
|
||||
} from "./types.ts";
|
||||
import { getParticipantId, OutdatedKeyFilter } from "./utils.ts";
|
||||
import {
|
||||
type EnabledTransports,
|
||||
RoomAndToDeviceEvents,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
|
||||
/**
|
||||
* RTCEncryptionManager is used to manage the encryption keys for a call.
|
||||
@@ -137,10 +132,6 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
this.useKeyDelay = joinConfig?.useKeyDelay ?? 1000;
|
||||
this.keyRotationGracePeriodMs = joinConfig?.keyRotationGracePeriodMs ?? 10_000;
|
||||
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
// Deprecate RoomKeyTransport: this can get removed.
|
||||
if (this.transport instanceof RoomAndToDeviceTransport) {
|
||||
this.transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, this.onTransportChanged);
|
||||
}
|
||||
|
||||
this.transport.start();
|
||||
}
|
||||
@@ -151,29 +142,6 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
this.participantKeyRings.clear();
|
||||
}
|
||||
|
||||
// Temporary for backwards compatibility
|
||||
// TODO: Remove this in the future
|
||||
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
|
||||
this.logger?.info("Transport change detected, restarting key distribution");
|
||||
if (this.currentKeyDistributionPromise) {
|
||||
this.currentKeyDistributionPromise
|
||||
.then(() => {
|
||||
if (this.outboundSession) {
|
||||
this.outboundSession.sharedWith = [];
|
||||
this.ensureKeyDistribution();
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger?.error("Failed to restart key distribution", e);
|
||||
});
|
||||
} else {
|
||||
if (this.outboundSession) {
|
||||
this.outboundSession.sharedWith = [];
|
||||
this.ensureKeyDistribution();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Will ensure that a new key is distributed and used to encrypt our media.
|
||||
* If there is already a key distribution in progress, it will schedule a new distribution round just after the current one is completed.
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger, type Logger } from "../logger.ts";
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import type { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import { NotSupportedError, type ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type ParticipantDeviceInfo } from "./types.ts";
|
||||
|
||||
// Deprecate RoomAndToDeviceTransport: This whole class is only a stop gap until we remove RoomKeyTransport.
|
||||
export interface EnabledTransports {
|
||||
toDevice: boolean;
|
||||
room: boolean;
|
||||
}
|
||||
|
||||
export enum RoomAndToDeviceEvents {
|
||||
EnabledTransportsChanged = "enabled_transports_changed",
|
||||
}
|
||||
export type RoomAndToDeviceEventsHandlerMap = {
|
||||
[RoomAndToDeviceEvents.EnabledTransportsChanged]: (enabledTransports: EnabledTransports) => void;
|
||||
};
|
||||
/**
|
||||
* A custom transport that subscribes to room key events (via `RoomKeyTransport`) and to device key events (via: `ToDeviceKeyTransport`)
|
||||
* The public setEnabled method allows to turn one or the other on or off on the fly.
|
||||
* It will emit `RoomAndToDeviceEvents.EnabledTransportsChanged` if the enabled transport changes to allow comminitcating this to
|
||||
* the user in the ui.
|
||||
*
|
||||
* Since it will always subscribe to both (room and to device) but only emit for the enabled ones, it can detect
|
||||
* if a room key event was received and autoenable it.
|
||||
*/
|
||||
export class RoomAndToDeviceTransport
|
||||
extends TypedEventEmitter<
|
||||
KeyTransportEvents | RoomAndToDeviceEvents,
|
||||
KeyTransportEventsHandlerMap & RoomAndToDeviceEventsHandlerMap
|
||||
>
|
||||
implements IKeyTransport
|
||||
{
|
||||
private readonly logger: Logger;
|
||||
private _enabled: EnabledTransports = { toDevice: true, room: false };
|
||||
public constructor(
|
||||
private toDeviceTransport: ToDeviceKeyTransport,
|
||||
private roomKeyTransport: RoomKeyTransport,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[RoomAndToDeviceTransport]`);
|
||||
// update parent loggers for the sub transports so filtering for `RoomAndToDeviceTransport` contains their logs too
|
||||
this.toDeviceTransport.setParentLogger(this.logger);
|
||||
this.roomKeyTransport.setParentLogger(this.logger);
|
||||
|
||||
this.roomKeyTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
|
||||
// Turn on the room transport if we receive a roomKey from another participant
|
||||
// and disable the toDevice transport.
|
||||
if (!this._enabled.room) {
|
||||
this.logger.debug("Received room key, enabling room key transport, disabling toDevice transport");
|
||||
this.setEnabled({ toDevice: false, room: true });
|
||||
}
|
||||
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
|
||||
});
|
||||
this.toDeviceTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
|
||||
if (this._enabled.toDevice) {
|
||||
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
|
||||
} else {
|
||||
this.logger.debug("To Device transport is disabled, ignoring received keys");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Set which transport type should be used to send and receive keys.*/
|
||||
public setEnabled(enabled: { toDevice: boolean; room: boolean }): void {
|
||||
if (this.enabled.toDevice !== enabled.toDevice || this.enabled.room !== enabled.room) {
|
||||
this._enabled = enabled;
|
||||
this.emit(RoomAndToDeviceEvents.EnabledTransportsChanged, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/** The currently enabled transports that are used to send and receive keys.*/
|
||||
public get enabled(): EnabledTransports {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
// always start the underlying transport since we need to enable room transport
|
||||
// when someone else sends us a room key. (we need to listen to roomKeyTransport)
|
||||
this.roomKeyTransport.start();
|
||||
this.toDeviceTransport.start();
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
// always stop since it is always running
|
||||
this.roomKeyTransport.stop();
|
||||
this.toDeviceTransport.stop();
|
||||
}
|
||||
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Sending key with index ${index} to call members (count=${members.length}) via:` +
|
||||
(this._enabled.room ? "room transport" : "") +
|
||||
(this._enabled.room && this._enabled.toDevice ? "and" : "") +
|
||||
(this._enabled.toDevice ? "to device transport" : ""),
|
||||
);
|
||||
if (this._enabled.room) await this.roomKeyTransport.sendKey(keyBase64Encoded, index, members);
|
||||
if (this._enabled.toDevice) {
|
||||
try {
|
||||
await this.toDeviceTransport.sendKey(keyBase64Encoded, index, members);
|
||||
} catch (error) {
|
||||
if (error instanceof NotSupportedError && !this._enabled.room) {
|
||||
this.logger.warn(
|
||||
"To device is not supported enabling room key transport, disabling toDevice transport",
|
||||
);
|
||||
this.setEnabled({ toDevice: false, room: true });
|
||||
await this.sendKey(keyBase64Encoded, index, members);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-3
@@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import type { IMentions } from "../matrix.ts";
|
||||
import type { IContent, IMentions } from "../matrix.ts";
|
||||
import type { RelationEvent } from "../types.ts";
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
|
||||
@@ -102,9 +102,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.
|
||||
|
||||
@@ -8,7 +8,7 @@ export enum RoomStickyEventsEvent {
|
||||
Update = "RoomStickyEvents.Update",
|
||||
}
|
||||
|
||||
type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number };
|
||||
export type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number };
|
||||
|
||||
export type RoomStickyEventsMap = {
|
||||
/**
|
||||
@@ -24,17 +24,64 @@ export type RoomStickyEventsMap = {
|
||||
) => 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> {
|
||||
private readonly stickyEventsMap = new Map<string, Map<string, StickyMatrixEvent>>(); // (type -> stickyKey+userId) -> event
|
||||
/**
|
||||
* 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.
|
||||
@@ -42,7 +89,11 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
||||
public *getStickyEvents(): Iterable<StickyMatrixEvent> {
|
||||
yield* this.unkeyedStickyEvents;
|
||||
for (const innerMap of this.stickyEventsMap.values()) {
|
||||
yield* innerMap.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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +105,8 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
||||
* @returns A matching active sticky event, or undefined.
|
||||
*/
|
||||
public getKeyedStickyEvent(sender: string, type: string, stickyKey: string): StickyMatrixEvent | undefined {
|
||||
return this.stickyEventsMap.get(type)?.get(`${stickyKey}${sender}`);
|
||||
assertIsUserId(sender);
|
||||
return this.stickyEventsMap.get(type)?.get(RoomStickyEventsStore.stickyMapKey(stickyKey, sender))?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,9 +141,8 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
||||
}
|
||||
const sender = event.getSender();
|
||||
const type = event.getType();
|
||||
if (!sender) {
|
||||
throw new Error(`${event.getId()} is missing a sender`);
|
||||
} else if (event.unstableStickyExpiresAt <= Date.now()) {
|
||||
assertIsUserId(sender);
|
||||
if (event.unstableStickyExpiresAt <= Date.now()) {
|
||||
logger.info("ignored sticky event with older expiration time than current time", stickyKey);
|
||||
return { added: false };
|
||||
}
|
||||
@@ -102,42 +153,39 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
||||
throw new Error("Expected sender to start with @");
|
||||
}
|
||||
|
||||
let prevEvent: StickyMatrixEvent | undefined;
|
||||
if (stickyKey !== undefined) {
|
||||
// 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 malicous event type might be "rtc.member.event@foo:bar" the key becomes:
|
||||
// "rtc.member.event.@foo:bar@bar:baz"
|
||||
const innerMapKey = `${stickyKey}${sender}`;
|
||||
prevEvent = this.stickyEventsMap.get(type)?.get(innerMapKey);
|
||||
const stickyEvent = event as StickyMatrixEvent;
|
||||
|
||||
// sticky events are not allowed to expire sooner than their predecessor.
|
||||
if (prevEvent && event.unstableStickyExpiresAt < prevEvent.unstableStickyExpiresAt) {
|
||||
logger.info("ignored sticky event with older expiry time", stickyKey);
|
||||
return { added: false };
|
||||
} else if (
|
||||
prevEvent &&
|
||||
event.getTs() === prevEvent.getTs() &&
|
||||
(event.getId() ?? "") < (prevEvent.getId() ?? "")
|
||||
) {
|
||||
// This path is unlikely, as it requires both events to have the same TS.
|
||||
logger.info("ignored sticky event due to 'id tie break rule' on sticky_key", stickyKey);
|
||||
return { added: false };
|
||||
}
|
||||
if (!this.stickyEventsMap.has(type)) {
|
||||
this.stickyEventsMap.set(type, new Map());
|
||||
}
|
||||
this.stickyEventsMap.get(type)!.set(innerMapKey, event as StickyMatrixEvent);
|
||||
} else {
|
||||
this.unkeyedStickyEvents.add(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(event.unstableStickyExpiresAt, this.nextStickyEventExpiryTs);
|
||||
this.nextStickyEventExpiryTs = Math.min(stickyEvent.unstableStickyExpiresAt, this.nextStickyEventExpiryTs);
|
||||
|
||||
this.scheduleStickyTimer();
|
||||
return { added: true, prevEvent };
|
||||
return {
|
||||
added: currentEventSet[0] === stickyEvent,
|
||||
prevEvent: currentEventSet?.[1],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,17 +241,24 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
||||
// 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, event] of innerEvents) {
|
||||
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 >= event.unstableStickyExpiresAt) {
|
||||
logger.debug("Expiring sticky event", event.getId());
|
||||
removedEvents.push(event);
|
||||
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,
|
||||
event.unstableStickyExpiresAt,
|
||||
currentEvent.unstableStickyExpiresAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -229,6 +284,93 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -2633,6 +2633,14 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
|
||||
// if we know about this event, redact its contents now.
|
||||
const redactedEvent = redactId ? this.findEventById(redactId) : undefined;
|
||||
if (redactId) {
|
||||
try {
|
||||
this.stickyEvents.handleRedaction(redactedEvent || redactId);
|
||||
} catch (ex) {
|
||||
// Non-critical failure, but we should warn.
|
||||
logger.error("Failed to handle redaction for sticky event", ex);
|
||||
}
|
||||
}
|
||||
if (redactedEvent) {
|
||||
this.applyEventAsRedaction(event, redactedEvent);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
type KeyBackupRestoreOpts,
|
||||
type KeyBackupRestoreResult,
|
||||
type OwnDeviceKeys,
|
||||
type SecretStorageStatus,
|
||||
type StartDehydrationOpts,
|
||||
UserVerificationStatus,
|
||||
type VerificationRequest,
|
||||
@@ -78,7 +79,7 @@ import {
|
||||
type ServerSideSecretStorage,
|
||||
} from "../secret-storage.ts";
|
||||
import { CrossSigningIdentity } from "./CrossSigningIdentity.ts";
|
||||
import { secretStorageCanAccessSecrets, secretStorageContainsCrossSigningKeys } from "./secret-storage.ts";
|
||||
import { secretStorageContainsCrossSigningKeys } from "./secret-storage.ts";
|
||||
import { isVerificationEvent, RustVerificationRequest, verificationMethodIdentifierToMethod } from "./verification.ts";
|
||||
import { EventType, MsgType } from "../@types/event.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
@@ -827,6 +828,13 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
* Implementation of {@link CryptoApi#isSecretStorageReady}
|
||||
*/
|
||||
public async isSecretStorageReady(): Promise<boolean> {
|
||||
return (await this.getSecretStorageStatus()).ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getSecretStorageStatus}
|
||||
*/
|
||||
public async getSecretStorageStatus(): Promise<SecretStorageStatus> {
|
||||
// make sure that the cross-signing keys are stored
|
||||
const secretsToCheck: SecretStorageKey[] = [
|
||||
"m.cross_signing.master",
|
||||
@@ -834,13 +842,32 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
"m.cross_signing.self_signing",
|
||||
];
|
||||
|
||||
// if key backup is active, we also need to check that the backup decryption key is stored
|
||||
// If key backup is active, we also need to check that the backup decryption key is stored
|
||||
const keyBackupEnabled = (await this.backupManager.getActiveBackupVersion()) != null;
|
||||
if (keyBackupEnabled) {
|
||||
secretsToCheck.push("m.megolm_backup.v1");
|
||||
}
|
||||
|
||||
return secretStorageCanAccessSecrets(this.secretStorage, secretsToCheck);
|
||||
const defaultKeyId = await this.secretStorage.getDefaultKeyId();
|
||||
|
||||
const result: SecretStorageStatus = {
|
||||
// Assume we have all secrets until proven otherwise
|
||||
ready: true,
|
||||
defaultKeyId,
|
||||
secretStorageKeyValidityMap: {},
|
||||
};
|
||||
|
||||
for (const secretName of secretsToCheck) {
|
||||
// Check which keys this particular secret is encrypted with
|
||||
const record = (await this.secretStorage.isStored(secretName)) || {};
|
||||
|
||||
// If it's encrypted with the right key, it is valid
|
||||
const secretStored = !!defaultKeyId && defaultKeyId in record;
|
||||
result.secretStorageKeyValidityMap[secretName] = secretStored;
|
||||
result.ready = result.ready && secretStored;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+40
-23
@@ -264,8 +264,19 @@ export class MediaHandler extends TypedEventEmitter<
|
||||
}
|
||||
|
||||
if (!canReuseStream) {
|
||||
const constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo);
|
||||
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
let constraints: MediaStreamConstraints;
|
||||
try {
|
||||
// Not specifying exact for deviceId means switching devices does not always work,
|
||||
// try with exact and fallback to ideal if it fails
|
||||
constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo, true);
|
||||
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`MediaHandler getUserMediaStreamInternal() error (e=${e}), retrying without exact deviceId`,
|
||||
);
|
||||
constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo, false);
|
||||
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
}
|
||||
logger.log(
|
||||
`MediaHandler getUserMediaStreamInternal() calling getUserMediaStream (streamId=${
|
||||
stream.id
|
||||
@@ -435,30 +446,36 @@ export class MediaHandler extends TypedEventEmitter<
|
||||
this.emit(MediaHandlerEvent.LocalStreamsChanged);
|
||||
}
|
||||
|
||||
private getUserMediaContraints(audio: boolean, video: boolean): MediaStreamConstraints {
|
||||
private getUserMediaContraints(audio: boolean, video: boolean, exactDeviceId?: boolean): MediaStreamConstraints {
|
||||
const isWebkit = !!navigator.webkitGetUserMedia;
|
||||
const deviceIdKey = exactDeviceId ? "exact" : "ideal";
|
||||
|
||||
const audioConstraints: MediaTrackConstraints = {};
|
||||
if (this.audioInput) {
|
||||
audioConstraints.deviceId = { [deviceIdKey]: this.audioInput };
|
||||
}
|
||||
if (this.audioSettings) {
|
||||
audioConstraints.autoGainControl = { ideal: this.audioSettings.autoGainControl };
|
||||
audioConstraints.echoCancellation = { ideal: this.audioSettings.echoCancellation };
|
||||
audioConstraints.noiseSuppression = { ideal: this.audioSettings.noiseSuppression };
|
||||
}
|
||||
|
||||
const videoConstraints: MediaTrackConstraints = {
|
||||
/* We want 640x360. Chrome will give it only if we ask exactly,
|
||||
FF refuses entirely if we ask exactly, so have to ask for ideal
|
||||
instead
|
||||
XXX: Is this still true?
|
||||
*/
|
||||
width: isWebkit ? { exact: 640 } : { ideal: 640 },
|
||||
height: isWebkit ? { exact: 360 } : { ideal: 360 },
|
||||
};
|
||||
if (this.videoInput) {
|
||||
videoConstraints.deviceId = { [deviceIdKey]: this.videoInput };
|
||||
}
|
||||
|
||||
return {
|
||||
audio: audio
|
||||
? {
|
||||
deviceId: this.audioInput ? { ideal: this.audioInput } : undefined,
|
||||
autoGainControl: this.audioSettings ? { ideal: this.audioSettings.autoGainControl } : undefined,
|
||||
echoCancellation: this.audioSettings ? { ideal: this.audioSettings.echoCancellation } : undefined,
|
||||
noiseSuppression: this.audioSettings ? { ideal: this.audioSettings.noiseSuppression } : undefined,
|
||||
}
|
||||
: false,
|
||||
video: video
|
||||
? {
|
||||
deviceId: this.videoInput ? { ideal: this.videoInput } : undefined,
|
||||
/* We want 640x360. Chrome will give it only if we ask exactly,
|
||||
FF refuses entirely if we ask exactly, so have to ask for ideal
|
||||
instead
|
||||
XXX: Is this still true?
|
||||
*/
|
||||
width: isWebkit ? { exact: 640 } : { ideal: 640 },
|
||||
height: isWebkit ? { exact: 360 } : { ideal: 360 },
|
||||
}
|
||||
: false,
|
||||
audio: audio ? audioConstraints : false,
|
||||
video: video ? videoConstraints : false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user