You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-11-23 17:02:25 +03:00
Merge branch 'develop' into toger5/use-relation-based-CallMembership-create-ts
This commit is contained in:
2
.github/workflows/notify-downstream.yaml
vendored
2
.github/workflows/notify-downstream.yaml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
|
- 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:
|
with:
|
||||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
repository: ${{ matrix.repo }}
|
repository: ${{ matrix.repo }}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
ref: staging
|
ref: staging
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||||
with:
|
with:
|
||||||
node-version-file: package.json
|
node-version-file: package.json
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
|
|||||||
2
.github/workflows/release-gitflow.yml
vendored
2
.github/workflows/release-gitflow.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
sparse-checkout: |
|
sparse-checkout: |
|
||||||
scripts/release
|
scripts/release
|
||||||
|
|
||||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version-file: package.json
|
node-version-file: package.json
|
||||||
|
|||||||
2
.github/workflows/release-make.yml
vendored
2
.github/workflows/release-make.yml
vendored
@@ -125,7 +125,7 @@ jobs:
|
|||||||
git config --global user.email "releases@riot.im"
|
git config --global user.email "releases@riot.im"
|
||||||
git config --global user.name "RiotRobot"
|
git config --global user.name "RiotRobot"
|
||||||
|
|
||||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version-file: package.json
|
node-version-file: package.json
|
||||||
|
|||||||
2
.github/workflows/release-npm.yml
vendored
2
.github/workflows/release-npm.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
ref: staging
|
ref: staging
|
||||||
|
|
||||||
- name: 🔧 Yarn cache
|
- name: 🔧 Yarn cache
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|||||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
|||||||
ref: staging
|
ref: staging
|
||||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
|
|
||||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version: "lts/*"
|
node-version: "lts/*"
|
||||||
@@ -76,7 +76,7 @@ jobs:
|
|||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||||
|
|
||||||
- name: 🔧 Yarn cache
|
- name: 🔧 Yarn cache
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version-file: package.json
|
node-version-file: package.json
|
||||||
|
|||||||
16
.github/workflows/static_analysis.yml
vendored
16
.github/workflows/static_analysis.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||||
|
|
||||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version-file: package.json
|
node-version-file: package.json
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||||
|
|
||||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version-file: package.json
|
node-version-file: package.json
|
||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||||
|
|
||||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version-file: package.json
|
node-version-file: package.json
|
||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
- name: Build Types
|
- name: Build Types
|
||||||
run: "yarn build:types"
|
run: "yarn build:types"
|
||||||
|
|
||||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||||
with:
|
with:
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
node-version-file: "examples/node/package.json"
|
node-version-file: "examples/node/package.json"
|
||||||
@@ -85,7 +85,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||||
|
|
||||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version-file: package.json
|
node-version-file: package.json
|
||||||
@@ -102,7 +102,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||||
|
|
||||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version-file: package.json
|
node-version-file: package.json
|
||||||
@@ -127,7 +127,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||||
|
|
||||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version-file: package.json
|
node-version-file: package.json
|
||||||
@@ -147,7 +147,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repository: element-hq/element-web
|
repository: element-hq/element-web
|
||||||
|
|
||||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version: "lts/*"
|
node-version: "lts/*"
|
||||||
|
|||||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
id: setupNode
|
id: setupNode
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
node-version: ${{ matrix.node }}
|
node-version: ${{ matrix.node }}
|
||||||
|
|||||||
2
.github/workflows/triage-stale.yml
vendored
2
.github/workflows/triage-stale.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10
|
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10
|
||||||
with:
|
with:
|
||||||
operations-per-run: 250
|
operations-per-run: 250
|
||||||
days-before-issue-stale: -1
|
days-before-issue-stale: -1
|
||||||
|
|||||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,3 +1,21 @@
|
|||||||
|
Changes in [39.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.0.0) (2025-10-21)
|
||||||
|
==================================================================================================
|
||||||
|
## 🚨 BREAKING CHANGES
|
||||||
|
|
||||||
|
* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5.
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5.
|
||||||
|
* Implement Sticky Events MSC4354 ([#5028](https://github.com/matrix-org/matrix-js-sdk/pull/5028)). Contributed by @Half-Shot.
|
||||||
|
* feat(client): allow disabling VoIP support ([#5021](https://github.com/matrix-org/matrix-js-sdk/pull/5021)). Contributed by @pkuzco.
|
||||||
|
|
||||||
|
## 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* Only use the first 3 viaServers specified ([#5034](https://github.com/matrix-org/matrix-js-sdk/pull/5034)). Contributed by @t3chguy.
|
||||||
|
* Fetch the user's device info before processing a verification request ([#5030](https://github.com/matrix-org/matrix-js-sdk/pull/5030)). Contributed by @andybalaam.
|
||||||
|
|
||||||
|
|
||||||
Changes in [38.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.4.0) (2025-10-07)
|
Changes in [38.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.4.0) (2025-10-07)
|
||||||
==================================================================================================
|
==================================================================================================
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "matrix-js-sdk",
|
"name": "matrix-js-sdk",
|
||||||
"version": "38.4.0",
|
"version": "39.0.0",
|
||||||
"description": "Matrix Client-Server SDK for Javascript",
|
"description": "Matrix Client-Server SDK for Javascript",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0"
|
"node": ">=22.0.0"
|
||||||
@@ -97,9 +97,9 @@
|
|||||||
"eslint-config-prettier": "^10.0.0",
|
"eslint-config-prettier": "^10.0.0",
|
||||||
"eslint-import-resolver-typescript": "^4.0.0",
|
"eslint-import-resolver-typescript": "^4.0.0",
|
||||||
"eslint-plugin-import": "^2.26.0",
|
"eslint-plugin-import": "^2.26.0",
|
||||||
"eslint-plugin-jest": "^28.0.0",
|
"eslint-plugin-jest": "^29.0.0",
|
||||||
"eslint-plugin-jsdoc": "^50.0.0",
|
"eslint-plugin-jsdoc": "^61.0.0",
|
||||||
"eslint-plugin-matrix-org": "2.1.0",
|
"eslint-plugin-matrix-org": "^3.0.0",
|
||||||
"eslint-plugin-n": "^14.0.0",
|
"eslint-plugin-n": "^14.0.0",
|
||||||
"eslint-plugin-tsdoc": "^0.4.0",
|
"eslint-plugin-tsdoc": "^0.4.0",
|
||||||
"eslint-plugin-unicorn": "^56.0.0",
|
"eslint-plugin-unicorn": "^56.0.0",
|
||||||
|
|||||||
@@ -1053,17 +1053,28 @@ 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 = [
|
httpLookups = [
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
prefix: unstableMSC4140Prefix,
|
prefix: unstableMSC4140Prefix,
|
||||||
path: "/delayed_events",
|
path: "/delayed_events",
|
||||||
|
expectQueryParams: {
|
||||||
|
status,
|
||||||
|
delay_id: delayId,
|
||||||
|
},
|
||||||
data: [],
|
data: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
await client._unstable_getDelayedEvents();
|
await client._unstable_getDelayedEvents(status, delayId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can update delayed events", async () => {
|
it("can update delayed events", async () => {
|
||||||
|
|||||||
@@ -16,27 +16,30 @@ limitations under the License.
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
encodeBase64,
|
encodeBase64,
|
||||||
|
type EventTimeline,
|
||||||
EventType,
|
EventType,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
|
type MatrixError,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
RelationType,
|
RelationType,
|
||||||
type MatrixError,
|
|
||||||
type Room,
|
type Room,
|
||||||
} from "../../../src";
|
} from "../../../src";
|
||||||
import { KnownMembership } from "../../../src/@types/membership";
|
import { KnownMembership } from "../../../src/@types/membership";
|
||||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
||||||
import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
|
import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
|
||||||
import { secureRandomString } from "../../../src/randomstring";
|
|
||||||
import {
|
import {
|
||||||
makeMockEvent,
|
makeMockEvent,
|
||||||
makeMockRoom,
|
makeMockRoom,
|
||||||
makeKey,
|
makeKey,
|
||||||
type MembershipData,
|
type MembershipData,
|
||||||
mockRoomState,
|
mockRoomState,
|
||||||
rtcMembershipTemplate,
|
mockRTCEvent,
|
||||||
sessionMembershipTemplate,
|
sessionMembershipTemplate,
|
||||||
|
rtcMembershipTemplate,
|
||||||
} from "./mocks";
|
} from "./mocks";
|
||||||
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
|
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: "livekit", livekit_service_url: "https://test.org" };
|
const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" };
|
||||||
|
|
||||||
@@ -66,11 +69,63 @@ describe("MatrixRTCSession", () => {
|
|||||||
sess = undefined;
|
sess = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("roomSessionForRoom", () => {
|
describe.each([
|
||||||
it("creates a room-scoped session from room state", async () => {
|
{
|
||||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
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(async () => {
|
||||||
|
await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig);
|
||||||
|
}).toThrow();
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line jest/no-conditional-expect
|
||||||
|
expect(async () => {
|
||||||
|
await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig);
|
||||||
|
}).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
it("creates a room-scoped session from room state", async () => {
|
||||||
|
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
|
||||||
|
|
||||||
|
sess = await MatrixRTCSession.sessionForSlot(
|
||||||
|
client,
|
||||||
|
mockRoom,
|
||||||
|
callSession,
|
||||||
|
testConfig.createWithDefaults ? undefined : testConfig,
|
||||||
|
);
|
||||||
expect(sess?.memberships.length).toEqual(1);
|
expect(sess?.memberships.length).toEqual(1);
|
||||||
expect(sess?.memberships[0].slotDescription.id).toEqual("");
|
expect(sess?.memberships[0].slotDescription.id).toEqual("");
|
||||||
expect(sess?.memberships[0].scope).toEqual("m.room");
|
expect(sess?.memberships[0].scope).toEqual("m.room");
|
||||||
@@ -84,8 +139,13 @@ describe("MatrixRTCSession", () => {
|
|||||||
const testMembership = Object.assign({}, membershipTemplate, {
|
const testMembership = Object.assign({}, membershipTemplate, {
|
||||||
application: "not-m.call",
|
application: "not-m.call",
|
||||||
});
|
});
|
||||||
const mockRoom = makeMockRoom([testMembership]);
|
const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky);
|
||||||
const sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
const sess = await MatrixRTCSession.sessionForSlot(
|
||||||
|
client,
|
||||||
|
mockRoom,
|
||||||
|
callSession,
|
||||||
|
testConfig.createWithDefaults ? undefined : testConfig,
|
||||||
|
);
|
||||||
expect(sess?.memberships).toHaveLength(0);
|
expect(sess?.memberships).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,8 +154,13 @@ describe("MatrixRTCSession", () => {
|
|||||||
call_id: "not-empty",
|
call_id: "not-empty",
|
||||||
scope: "m.room",
|
scope: "m.room",
|
||||||
});
|
});
|
||||||
const mockRoom = makeMockRoom([testMembership]);
|
const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky);
|
||||||
const sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
const sess = await MatrixRTCSession.sessionForSlot(
|
||||||
|
client,
|
||||||
|
mockRoom,
|
||||||
|
callSession,
|
||||||
|
testConfig.createWithDefaults ? undefined : testConfig,
|
||||||
|
);
|
||||||
expect(sess?.memberships).toHaveLength(0);
|
expect(sess?.memberships).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,19 +169,42 @@ describe("MatrixRTCSession", () => {
|
|||||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||||
expiredMembership.expires = 1000;
|
expiredMembership.expires = 1000;
|
||||||
expiredMembership.device_id = "EXPIRED";
|
expiredMembership.device_id = "EXPIRED";
|
||||||
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]);
|
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], testConfig.testCreateSticky);
|
||||||
|
|
||||||
jest.advanceTimersByTime(2000);
|
jest.advanceTimersByTime(2000);
|
||||||
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
sess = await MatrixRTCSession.sessionForSlot(
|
||||||
|
client,
|
||||||
|
mockRoom,
|
||||||
|
callSession,
|
||||||
|
testConfig.createWithDefaults ? undefined : testConfig,
|
||||||
|
);
|
||||||
expect(sess?.memberships.length).toEqual(1);
|
expect(sess?.memberships.length).toEqual(1);
|
||||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores memberships events of members not in the room", async () => {
|
it("ignores memberships events of members not in the room", async () => {
|
||||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
|
||||||
mockRoom.hasMembershipState = (state) => state === KnownMembership.Join;
|
mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join);
|
||||||
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
sess = await MatrixRTCSession.sessionForSlot(
|
||||||
|
client,
|
||||||
|
mockRoom,
|
||||||
|
callSession,
|
||||||
|
testConfig.createWithDefaults ? undefined : testConfig,
|
||||||
|
);
|
||||||
|
expect(sess?.memberships.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores memberships events with no sender", async () => {
|
||||||
|
// Force the sender to be undefined.
|
||||||
|
const mockRoom = makeMockRoom([{ ...membershipTemplate, user_id: "" }], testConfig.testCreateSticky);
|
||||||
|
mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join);
|
||||||
|
sess = await MatrixRTCSession.sessionForSlot(
|
||||||
|
client,
|
||||||
|
mockRoom,
|
||||||
|
callSession,
|
||||||
|
testConfig.createWithDefaults ? undefined : testConfig,
|
||||||
|
);
|
||||||
expect(sess?.memberships.length).toEqual(0);
|
expect(sess?.memberships.length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,20 +214,29 @@ describe("MatrixRTCSession", () => {
|
|||||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||||
expiredMembership.created_ts = 500;
|
expiredMembership.created_ts = 500;
|
||||||
expiredMembership.expires = 1000;
|
expiredMembership.expires = 1000;
|
||||||
const mockRoom = makeMockRoom([expiredMembership]);
|
const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky);
|
||||||
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
sess = await MatrixRTCSession.sessionForSlot(
|
||||||
|
client,
|
||||||
|
mockRoom,
|
||||||
|
callSession,
|
||||||
|
testConfig.createWithDefaults ? undefined : testConfig,
|
||||||
|
);
|
||||||
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
|
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty session if no membership events are present", async () => {
|
it("returns empty session if no membership events are present", async () => {
|
||||||
const mockRoom = makeMockRoom([]);
|
const mockRoom = makeMockRoom([], testConfig.testCreateSticky);
|
||||||
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
sess = await MatrixRTCSession.sessionForSlot(
|
||||||
|
client,
|
||||||
|
mockRoom,
|
||||||
|
callSession,
|
||||||
|
testConfig.createWithDefaults ? undefined : testConfig,
|
||||||
|
);
|
||||||
expect(sess?.memberships).toHaveLength(0);
|
expect(sess?.memberships).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("safely ignores events with no memberships section", async () => {
|
it("safely ignores events with no memberships section", async () => {
|
||||||
const roomId = secureRandomString(8);
|
|
||||||
const event = {
|
const event = {
|
||||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||||
getContent: jest.fn().mockReturnValue({}),
|
getContent: jest.fn().mockReturnValue({}),
|
||||||
@@ -147,10 +244,8 @@ describe("MatrixRTCSession", () => {
|
|||||||
getTs: jest.fn().mockReturnValue(1000),
|
getTs: jest.fn().mockReturnValue(1000),
|
||||||
getLocalAge: jest.fn().mockReturnValue(0),
|
getLocalAge: jest.fn().mockReturnValue(0),
|
||||||
};
|
};
|
||||||
const mockRoom = {
|
const mockRoom = makeMockRoom([]);
|
||||||
...makeMockRoom([]),
|
mockRoom.getLiveTimeline.mockReturnValue({
|
||||||
roomId,
|
|
||||||
getLiveTimeline: jest.fn().mockReturnValue({
|
|
||||||
getState: jest.fn().mockReturnValue({
|
getState: jest.fn().mockReturnValue({
|
||||||
on: jest.fn(),
|
on: jest.fn(),
|
||||||
off: jest.fn(),
|
off: jest.fn(),
|
||||||
@@ -167,14 +262,17 @@ describe("MatrixRTCSession", () => {
|
|||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
}),
|
} as unknown as EventTimeline);
|
||||||
};
|
sess = await MatrixRTCSession.sessionForSlot(
|
||||||
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession);
|
client,
|
||||||
|
mockRoom,
|
||||||
|
callSession,
|
||||||
|
testConfig.createWithDefaults ? undefined : testConfig,
|
||||||
|
);
|
||||||
expect(sess.memberships).toHaveLength(0);
|
expect(sess.memberships).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("safely ignores events with junk memberships section", async () => {
|
it("safely ignores events with junk memberships section", async () => {
|
||||||
const roomId = secureRandomString(8);
|
|
||||||
const event = {
|
const event = {
|
||||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||||
getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }),
|
getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }),
|
||||||
@@ -182,10 +280,8 @@ describe("MatrixRTCSession", () => {
|
|||||||
getTs: jest.fn().mockReturnValue(1000),
|
getTs: jest.fn().mockReturnValue(1000),
|
||||||
getLocalAge: jest.fn().mockReturnValue(0),
|
getLocalAge: jest.fn().mockReturnValue(0),
|
||||||
};
|
};
|
||||||
const mockRoom = {
|
const mockRoom = makeMockRoom([]);
|
||||||
...makeMockRoom([]),
|
mockRoom.getLiveTimeline.mockReturnValue({
|
||||||
roomId,
|
|
||||||
getLiveTimeline: jest.fn().mockReturnValue({
|
|
||||||
getState: jest.fn().mockReturnValue({
|
getState: jest.fn().mockReturnValue({
|
||||||
on: jest.fn(),
|
on: jest.fn(),
|
||||||
off: jest.fn(),
|
off: jest.fn(),
|
||||||
@@ -202,9 +298,13 @@ describe("MatrixRTCSession", () => {
|
|||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
}),
|
} as unknown as EventTimeline);
|
||||||
};
|
sess = await MatrixRTCSession.sessionForSlot(
|
||||||
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession);
|
client,
|
||||||
|
mockRoom,
|
||||||
|
callSession,
|
||||||
|
testConfig.createWithDefaults ? undefined : testConfig,
|
||||||
|
);
|
||||||
expect(sess.memberships).toHaveLength(0);
|
expect(sess.memberships).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,7 +312,12 @@ describe("MatrixRTCSession", () => {
|
|||||||
const testMembership = Object.assign({}, membershipTemplate);
|
const testMembership = Object.assign({}, membershipTemplate);
|
||||||
(testMembership.device_id as string | undefined) = undefined;
|
(testMembership.device_id as string | undefined) = undefined;
|
||||||
const mockRoom = makeMockRoom([testMembership]);
|
const mockRoom = makeMockRoom([testMembership]);
|
||||||
const sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
const sess = await MatrixRTCSession.sessionForSlot(
|
||||||
|
client,
|
||||||
|
mockRoom,
|
||||||
|
callSession,
|
||||||
|
testConfig.createWithDefaults ? undefined : testConfig,
|
||||||
|
);
|
||||||
expect(sess.memberships).toHaveLength(0);
|
expect(sess.memberships).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,9 +325,107 @@ describe("MatrixRTCSession", () => {
|
|||||||
const testMembership = Object.assign({}, membershipTemplate);
|
const testMembership = Object.assign({}, membershipTemplate);
|
||||||
(testMembership.call_id as string | undefined) = undefined;
|
(testMembership.call_id as string | undefined) = undefined;
|
||||||
const mockRoom = makeMockRoom([testMembership]);
|
const mockRoom = makeMockRoom([testMembership]);
|
||||||
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
sess = await MatrixRTCSession.sessionForSlot(
|
||||||
|
client,
|
||||||
|
mockRoom,
|
||||||
|
callSession,
|
||||||
|
testConfig.createWithDefaults ? undefined : testConfig,
|
||||||
|
);
|
||||||
expect(sess.memberships).toHaveLength(0);
|
expect(sess.memberships).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("roomSessionForRoom combined state", () => {
|
||||||
|
it("perfers sticky events when both membership and sticky events appear for the same user", async () => {
|
||||||
|
// Create a room with identical member state and sticky state for the same user.
|
||||||
|
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||||
|
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
|
||||||
|
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 = await 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");
|
||||||
|
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("combines sticky and membership events when both exist", async () => {
|
||||||
|
// Create a room with identical member state and sticky state for the same user.
|
||||||
|
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||||
|
const stickyUserId = "@stickyev:user.example";
|
||||||
|
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];
|
||||||
|
});
|
||||||
|
|
||||||
|
sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
|
||||||
|
listenForStickyEvents: true,
|
||||||
|
listenForMemberStateEvents: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Then state
|
||||||
|
expect(memberships[1].sender).toEqual(membershipTemplate.user_id);
|
||||||
|
|
||||||
|
expect(sess?.slotDescription.id).toEqual("");
|
||||||
|
});
|
||||||
|
it("handles an incoming sticky event to an existing session", async () => {
|
||||||
|
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||||
|
const stickyUserId = "@stickyev:user.example";
|
||||||
|
|
||||||
|
sess = await 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);
|
||||||
|
});
|
||||||
it("fetches related events if needed from room", async () => {
|
it("fetches related events if needed from room", async () => {
|
||||||
const testMembership = {
|
const testMembership = {
|
||||||
...rtcMembershipTemplate,
|
...rtcMembershipTemplate,
|
||||||
@@ -233,9 +436,7 @@ describe("MatrixRTCSession", () => {
|
|||||||
|
|
||||||
const mockRoom = makeMockRoom([testMembership]);
|
const mockRoom = makeMockRoom([testMembership]);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
mockRoom.findEventById = jest
|
mockRoom.findEventById.mockImplementation((id) =>
|
||||||
.fn()
|
|
||||||
.mockImplementation((id) =>
|
|
||||||
id === "id"
|
id === "id"
|
||||||
? new MatrixEvent({ content: { ...rtcMembershipTemplate }, origin_server_ts: now + 100 })
|
? new MatrixEvent({ content: { ...rtcMembershipTemplate }, origin_server_ts: now + 100 })
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -254,7 +455,7 @@ describe("MatrixRTCSession", () => {
|
|||||||
const mockRoom = makeMockRoom([testMembership]);
|
const mockRoom = makeMockRoom([testMembership]);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
mockRoom.findEventById = jest.fn().mockReturnValue(undefined);
|
mockRoom.findEventById.mockReturnValue(undefined);
|
||||||
client.fetchRoomEvent = jest
|
client.fetchRoomEvent = jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ content: { ...rtcMembershipTemplate }, origin_server_ts: now + 100 });
|
.mockResolvedValue({ content: { ...rtcMembershipTemplate }, origin_server_ts: now + 100 });
|
||||||
@@ -391,6 +592,12 @@ describe("MatrixRTCSession", () => {
|
|||||||
expect(sess!.isJoined()).toEqual(true);
|
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 () => {
|
it("sends a notification when starting a call and emit DidSendCallNotification", async () => {
|
||||||
// Simulate a join, including the update to the room state
|
// Simulate a join, including the update to the room state
|
||||||
// Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them
|
// Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them
|
||||||
|
|||||||
@@ -14,18 +14,32 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
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 { RoomStateEvent } from "../../../src/models/room-state";
|
||||||
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||||
import { makeMockRoom, sessionMembershipTemplate, mockRoomState } from "./mocks";
|
import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks";
|
||||||
import { logger } from "../../../src/logger";
|
import { logger } from "../../../src/logger";
|
||||||
|
|
||||||
describe("MatrixRTCSessionManager", () => {
|
describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||||
|
"MatrixRTCSessionManager ($eventKind)",
|
||||||
|
({ eventKind }) => {
|
||||||
let client: MatrixClient;
|
let client: MatrixClient;
|
||||||
|
|
||||||
beforeEach(async () => {
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
client = new MatrixClient({ baseUrl: "base_url" });
|
client = new MatrixClient({ baseUrl: "base_url" });
|
||||||
await client.matrixRTC.start();
|
client.matrixRTC.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -33,20 +47,15 @@ describe("MatrixRTCSessionManager", () => {
|
|||||||
client.matrixRTC.stop();
|
client.matrixRTC.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Fires event when session starts", async () => {
|
it("Fires event when session starts", () => {
|
||||||
const onStarted = jest.fn();
|
const onStarted = jest.fn();
|
||||||
const { promise, resolve } = Promise.withResolvers<void>();
|
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, (...v) => {
|
|
||||||
onStarted(...v);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const room1 = makeMockRoom([sessionMembershipTemplate]);
|
const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky");
|
||||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||||
|
|
||||||
client.emit(ClientEvent.Room, room1);
|
client.emit(ClientEvent.Room, room1);
|
||||||
await promise;
|
|
||||||
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||||
} finally {
|
} finally {
|
||||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||||
@@ -58,7 +67,7 @@ describe("MatrixRTCSessionManager", () => {
|
|||||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const room1 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.other" }]);
|
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }], eventKind === "sticky");
|
||||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||||
|
|
||||||
client.emit(ClientEvent.Room, room1);
|
client.emit(ClientEvent.Room, room1);
|
||||||
@@ -68,81 +77,62 @@ describe("MatrixRTCSessionManager", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Fires event when session ends", async () => {
|
it("Fires event when session ends", () => {
|
||||||
const onEnded = jest.fn();
|
const onEnded = jest.fn();
|
||||||
const { promise: endPromise, resolve: rEnd } = Promise.withResolvers();
|
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||||
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
const membershipData: MembershipData[] = [membershipTemplate];
|
||||||
const { promise: startPromise, resolve: rStart } = Promise.withResolvers();
|
const room1 = makeMockRoom(membershipData, eventKind === "sticky");
|
||||||
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionEnded, rEnd);
|
|
||||||
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionStarted, rStart);
|
|
||||||
|
|
||||||
const room1 = makeMockRoom([sessionMembershipTemplate]);
|
|
||||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||||
|
|
||||||
client.emit(ClientEvent.Room, room1);
|
client.emit(ClientEvent.Room, room1);
|
||||||
await startPromise;
|
|
||||||
|
|
||||||
mockRoomState(room1, [{ user_id: sessionMembershipTemplate.user_id }]);
|
sendLeaveMembership(room1, membershipData);
|
||||||
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
|
||||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
|
||||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
|
||||||
await endPromise;
|
|
||||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Fires correctly with for with custom sessionDescription", async () => {
|
it("Fires correctly with custom sessionDescription", () => {
|
||||||
const onStarted = jest.fn();
|
const onStarted = jest.fn();
|
||||||
const onEnded = jest.fn();
|
const onEnded = jest.fn();
|
||||||
// create a session manager with a custom session description
|
// create a session manager with a custom session description
|
||||||
const sessionManager = new MatrixRTCSessionManager(logger, client, { id: "test", application: "m.notCall" });
|
const sessionManager = new MatrixRTCSessionManager(logger, client, {
|
||||||
|
id: "test",
|
||||||
|
application: "m.notCall",
|
||||||
|
});
|
||||||
|
|
||||||
// manually start the session manager (its not the default one started by the client)
|
// manually start the session manager (its not the default one started by the client)
|
||||||
await sessionManager.start();
|
sessionManager.start();
|
||||||
const { promise: startPromise, resolve: rStart } = Promise.withResolvers<void>();
|
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||||
const { promise: endPromise, resolve: rEnd } = Promise.withResolvers<void>();
|
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||||
|
|
||||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, (v) => {
|
|
||||||
onEnded(v);
|
|
||||||
rEnd();
|
|
||||||
});
|
|
||||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, (v) => {
|
|
||||||
onStarted(v);
|
|
||||||
rStart();
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const room1 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.other" }]);
|
// 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]);
|
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||||
|
|
||||||
client.emit(ClientEvent.Room, room1);
|
client.emit(ClientEvent.Room, room1);
|
||||||
expect(onStarted).not.toHaveBeenCalled();
|
expect(onStarted).not.toHaveBeenCalled();
|
||||||
onStarted.mockClear();
|
onStarted.mockClear();
|
||||||
|
|
||||||
const room2 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.notCall", call_id: "test" }]);
|
// 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]);
|
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
|
||||||
|
|
||||||
client.emit(ClientEvent.Room, room2);
|
client.emit(ClientEvent.Room, room2);
|
||||||
await startPromise;
|
|
||||||
expect(onStarted).toHaveBeenCalled();
|
expect(onStarted).toHaveBeenCalled();
|
||||||
onStarted.mockClear();
|
onStarted.mockClear();
|
||||||
|
|
||||||
mockRoomState(room2, [{ user_id: sessionMembershipTemplate.user_id }]);
|
// Stop room1's RTC session. Tracked.
|
||||||
jest.spyOn(client, "getRoom").mockReturnValue(room2);
|
jest.spyOn(client, "getRoom").mockReturnValue(room2);
|
||||||
|
sendLeaveMembership(room2, room2MembershipData);
|
||||||
const roomState = room2.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
|
||||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
|
||||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
|
||||||
await endPromise;
|
|
||||||
expect(onEnded).toHaveBeenCalled();
|
expect(onEnded).toHaveBeenCalled();
|
||||||
onEnded.mockClear();
|
onEnded.mockClear();
|
||||||
|
|
||||||
mockRoomState(room1, [{ user_id: sessionMembershipTemplate.user_id }]);
|
// Stop room1's RTC session. Not tracked.
|
||||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||||
|
sendLeaveMembership(room1, room1MembershipData);
|
||||||
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();
|
expect(onEnded).not.toHaveBeenCalled();
|
||||||
} finally {
|
} finally {
|
||||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||||
@@ -153,18 +143,16 @@ describe("MatrixRTCSessionManager", () => {
|
|||||||
it("Doesn't fire event if unrelated sessions ends", () => {
|
it("Doesn't fire event if unrelated sessions ends", () => {
|
||||||
const onEnded = jest.fn();
|
const onEnded = jest.fn();
|
||||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||||
const room1 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.other_app" }]);
|
const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }];
|
||||||
|
const room1 = makeMockRoom(membership, eventKind === "sticky");
|
||||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||||
|
|
||||||
client.emit(ClientEvent.Room, room1);
|
client.emit(ClientEvent.Room, room1);
|
||||||
|
|
||||||
mockRoomState(room1, [{ user_id: sessionMembershipTemplate.user_id }]);
|
sendLeaveMembership(room1, membership);
|
||||||
|
|
||||||
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,
|
MatrixError,
|
||||||
UnsupportedDelayedEventsEndpointError,
|
UnsupportedDelayedEventsEndpointError,
|
||||||
type Room,
|
type Room,
|
||||||
|
MAX_STICKY_DURATION_MS,
|
||||||
} from "../../../src";
|
} from "../../../src";
|
||||||
import {
|
import {
|
||||||
MembershipManagerEvent,
|
MembershipManagerEvent,
|
||||||
@@ -94,7 +95,9 @@ describe("MembershipManager", () => {
|
|||||||
// Provide a default mock that is like the default "non error" server behaviour.
|
// 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_sendDelayedStateEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
|
||||||
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
|
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
|
||||||
(client.sendStateEvent as Mock<any>).mockResolvedValue(undefined);
|
(client._unstable_sendStickyEvent as Mock<any>).mockResolvedValue({ event_id: "id" });
|
||||||
|
(client._unstable_sendStickyDelayedEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
|
||||||
|
(client.sendStateEvent as Mock<any>).mockResolvedValue({ event_id: "id" });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -152,45 +155,6 @@ describe("MembershipManager", () => {
|
|||||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
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" }));
|
|
||||||
// This should check for send sticky once we merge with the sticky matrixRTC branch.
|
|
||||||
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: [],
|
|
||||||
msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call",
|
|
||||||
},
|
|
||||||
"_@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 () => {
|
it("reschedules delayed leave event if sending state cancels it", async () => {
|
||||||
const memberManager = new MembershipManager(undefined, room, client, callSession);
|
const memberManager = new MembershipManager(undefined, room, client, callSession);
|
||||||
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
||||||
@@ -927,6 +891,63 @@ describe("MembershipManager", () => {
|
|||||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(0);
|
expect(client.sendStateEvent).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("StickyEventMembershipManager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Provide a default mock that is like the default "non error" server behaviour.
|
||||||
|
(client._unstable_sendStickyDelayedEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
|
||||||
|
(client._unstable_sendStickyEvent as Mock<any>).mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("join()", () => {
|
||||||
|
describe("sends an rtc membership event", () => {
|
||||||
|
it("sends a membership event and schedules delayed leave when joining a call", async () => {
|
||||||
|
const updateDelayedEventHandle = createAsyncHandle<void>(
|
||||||
|
client._unstable_updateDelayedEvent as Mock,
|
||||||
|
);
|
||||||
|
const memberManager = new StickyEventMembershipManager(undefined, room, client, callSession);
|
||||||
|
|
||||||
|
memberManager.join([], focus);
|
||||||
|
|
||||||
|
await waitForMockCall(client._unstable_sendStickyEvent, Promise.resolve({ event_id: "id" }));
|
||||||
|
// Test we sent the initial join
|
||||||
|
expect(client._unstable_sendStickyEvent).toHaveBeenCalledWith(
|
||||||
|
room.roomId,
|
||||||
|
3600000,
|
||||||
|
null,
|
||||||
|
"org.matrix.msc4143.rtc.member",
|
||||||
|
{
|
||||||
|
application: { type: "m.call" },
|
||||||
|
member: {
|
||||||
|
user_id: "@alice:example.org",
|
||||||
|
id: "_@alice:example.org_AAAAAAA_m.call",
|
||||||
|
device_id: "AAAAAAA",
|
||||||
|
},
|
||||||
|
slot_id: "m.call#",
|
||||||
|
rtc_transports: [focus],
|
||||||
|
versions: [],
|
||||||
|
msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
updateDelayedEventHandle.resolve?.();
|
||||||
|
|
||||||
|
// Ensure we have sent the delayed disconnect event.
|
||||||
|
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith(
|
||||||
|
room.roomId,
|
||||||
|
MAX_STICKY_DURATION_MS,
|
||||||
|
{ delay: 8000 },
|
||||||
|
null,
|
||||||
|
"org.matrix.msc4143.rtc.member",
|
||||||
|
{
|
||||||
|
msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// ..once
|
||||||
|
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Should prefix log with MembershipManager used", async () => {
|
it("Should prefix log with MembershipManager used", async () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from "stream";
|
import { EventEmitter } from "stream";
|
||||||
|
import { type Mocked } from "jest-mock";
|
||||||
|
|
||||||
import { EventType, MatrixEvent, type Room, RoomEvent, type MatrixClient } from "../../../src";
|
import { EventType, MatrixEvent, type Room, RoomEvent, type MatrixClient } from "../../../src";
|
||||||
import {
|
import {
|
||||||
@@ -65,6 +66,8 @@ export type MockClient = Pick<
|
|||||||
| "sendStateEvent"
|
| "sendStateEvent"
|
||||||
| "_unstable_sendDelayedStateEvent"
|
| "_unstable_sendDelayedStateEvent"
|
||||||
| "_unstable_updateDelayedEvent"
|
| "_unstable_updateDelayedEvent"
|
||||||
|
| "_unstable_sendStickyEvent"
|
||||||
|
| "_unstable_sendStickyDelayedEvent"
|
||||||
| "cancelPendingEvent"
|
| "cancelPendingEvent"
|
||||||
>;
|
>;
|
||||||
/**
|
/**
|
||||||
@@ -79,15 +82,19 @@ export function makeMockClient(userId: string, deviceId: string): MockClient {
|
|||||||
cancelPendingEvent: jest.fn(),
|
cancelPendingEvent: jest.fn(),
|
||||||
_unstable_updateDelayedEvent: jest.fn(),
|
_unstable_updateDelayedEvent: jest.fn(),
|
||||||
_unstable_sendDelayedStateEvent: jest.fn(),
|
_unstable_sendDelayedStateEvent: jest.fn(),
|
||||||
|
_unstable_sendStickyEvent: jest.fn(),
|
||||||
|
_unstable_sendStickyDelayedEvent: jest.fn(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeMockRoom(
|
export function makeMockRoom(
|
||||||
membershipData: MembershipData[],
|
membershipData: MembershipData[],
|
||||||
): Room & { emitTimelineEvent: (event: MatrixEvent) => void } {
|
useStickyEvents = false,
|
||||||
|
): Mocked<Room & { emitTimelineEvent: (event: MatrixEvent) => void }> {
|
||||||
const roomId = secureRandomString(8);
|
const roomId = secureRandomString(8);
|
||||||
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
|
// 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(), {
|
const room = Object.assign(new EventEmitter(), {
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
hasMembershipState: jest.fn().mockReturnValue(true),
|
hasMembershipState: jest.fn().mockReturnValue(true),
|
||||||
@@ -95,11 +102,17 @@ export function makeMockRoom(
|
|||||||
getState: jest.fn().mockReturnValue(roomState),
|
getState: jest.fn().mockReturnValue(roomState),
|
||||||
}),
|
}),
|
||||||
getVersion: jest.fn().mockReturnValue("default"),
|
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,
|
||||||
|
findEventById: jest.fn(),
|
||||||
|
});
|
||||||
return Object.assign(room, {
|
return Object.assign(room, {
|
||||||
emitTimelineEvent: (event: MatrixEvent) =>
|
emitTimelineEvent: (event: MatrixEvent) =>
|
||||||
room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any),
|
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) {
|
function makeMockRoomState(membershipData: MembershipData[], roomId: string) {
|
||||||
@@ -143,6 +156,7 @@ export function makeMockEvent(
|
|||||||
roomId: string | undefined,
|
roomId: string | undefined,
|
||||||
content: any,
|
content: any,
|
||||||
timestamp?: number,
|
timestamp?: number,
|
||||||
|
stateKey?: string,
|
||||||
): MatrixEvent {
|
): MatrixEvent {
|
||||||
return new MatrixEvent({
|
return new MatrixEvent({
|
||||||
event_id: secureRandomString(8),
|
event_id: secureRandomString(8),
|
||||||
@@ -151,11 +165,27 @@ export function makeMockEvent(
|
|||||||
content,
|
content,
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
origin_server_ts: timestamp ?? 0,
|
origin_server_ts: timestamp ?? 0,
|
||||||
|
state_key: stateKey,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipData, roomId: string): MatrixEvent {
|
export function mockRTCEvent(
|
||||||
return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData, Date.now());
|
{ 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 {
|
export function mockCallMembership(membershipData: MembershipData, roomId: string): CallMembership {
|
||||||
|
|||||||
135
spec/unit/matrixrtc/types.spec.ts
Normal file
135
spec/unit/matrixrtc/types.spec.ts
Normal file
@@ -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]);
|
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 () => {
|
it("isSecretStorageReady", async () => {
|
||||||
const mockSecretStorage = {
|
const mockSecretStorage = {
|
||||||
getDefaultKeyId: jest.fn().mockResolvedValue(null),
|
getDefaultKeyId: jest.fn().mockResolvedValue(null),
|
||||||
|
isStored: jest.fn().mockResolvedValue(null),
|
||||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||||
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage);
|
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage);
|
||||||
await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false);
|
await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false);
|
||||||
|
|||||||
@@ -338,6 +338,7 @@ export interface TimelineEvents {
|
|||||||
[M_BEACON.name]: MBeaconEventContent;
|
[M_BEACON.name]: MBeaconEventContent;
|
||||||
[M_POLL_START.name]: PollStartEventContent;
|
[M_POLL_START.name]: PollStartEventContent;
|
||||||
[M_POLL_END.name]: PollEndEventContent;
|
[M_POLL_END.name]: PollEndEventContent;
|
||||||
|
[EventType.RTCMembership]: RtcMembershipData | { msc4354_sticky_key: string }; // An object containing just the sticky key is empty.
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { type IEventWithRoomId, type SearchKey } from "./search.ts";
|
|||||||
import { type IRoomEventFilter } from "../filter.ts";
|
import { type IRoomEventFilter } from "../filter.ts";
|
||||||
import { type Direction } from "../models/event-timeline.ts";
|
import { type Direction } from "../models/event-timeline.ts";
|
||||||
import { type PushRuleAction } from "./PushRules.ts";
|
import { type PushRuleAction } from "./PushRules.ts";
|
||||||
|
import { type MatrixError } from "../matrix.ts";
|
||||||
import { type IRoomEvent } from "../sync-accumulator.ts";
|
import { type IRoomEvent } from "../sync-accumulator.ts";
|
||||||
import { type EventType, type RelationType, type RoomType } from "./event.ts";
|
import { type EventType, type RelationType, type RoomType } from "./event.ts";
|
||||||
|
|
||||||
@@ -136,12 +137,22 @@ type DelayedPartialStateEvent = DelayedPartialTimelineEvent & {
|
|||||||
|
|
||||||
type DelayedPartialEvent = DelayedPartialTimelineEvent | DelayedPartialStateEvent;
|
type DelayedPartialEvent = DelayedPartialTimelineEvent | DelayedPartialStateEvent;
|
||||||
|
|
||||||
export type DelayedEventInfo = {
|
export type DelayedEventInfoItem = DelayedPartialEvent &
|
||||||
delayed_events: (DelayedPartialEvent &
|
|
||||||
SendDelayedEventResponse &
|
SendDelayedEventResponse &
|
||||||
SendDelayedEventRequestOpts & {
|
SendDelayedEventRequestOpts & {
|
||||||
running_since: number;
|
running_since: number;
|
||||||
})[];
|
};
|
||||||
|
|
||||||
|
export type DelayedEventInfo = {
|
||||||
|
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;
|
next_batch?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
* 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.
|
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line
|
// 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))) {
|
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||||
throw new UnsupportedDelayedEventsEndpointError(
|
throw new UnsupportedDelayedEventsEndpointError(
|
||||||
"Server does not support the delayed events API",
|
"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, {
|
return await this.http.authedRequest(Method.Get, "/delayed_events", queryDict, undefined, {
|
||||||
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
|
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type { ToDeviceBatch, ToDevicePayload } from "../models/ToDeviceMessage.t
|
|||||||
import { type Room } from "../models/room.ts";
|
import { type Room } from "../models/room.ts";
|
||||||
import { type DeviceMap } from "../models/device.ts";
|
import { type DeviceMap } from "../models/device.ts";
|
||||||
import { type UIAuthCallback } from "../interactive-auth.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 VerificationRequest } from "./verification.ts";
|
||||||
import {
|
import {
|
||||||
type BackupTrustInfo,
|
type BackupTrustInfo,
|
||||||
@@ -369,6 +369,11 @@ export interface CryptoApi {
|
|||||||
*/
|
*/
|
||||||
isSecretStorageReady(): Promise<boolean>;
|
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).
|
* 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;
|
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}
|
* Parameter of {@link CryptoApi#bootstrapSecretStorage}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -194,6 +194,10 @@ export type SessionMembershipData = {
|
|||||||
* something else.
|
* something else.
|
||||||
*/
|
*/
|
||||||
"m.call.intent"?: RTCCallIntent;
|
"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 => {
|
const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ limitations under the License.
|
|||||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||||
import { EventTimeline } from "../models/event-timeline.ts";
|
import { EventTimeline } from "../models/event-timeline.ts";
|
||||||
import { MatrixEvent } from "../models/event.ts";
|
|
||||||
import { type Room } from "../models/room.ts";
|
import { type Room } from "../models/room.ts";
|
||||||
import { type MatrixClient } from "../client.ts";
|
import { type MatrixClient } from "../client.ts";
|
||||||
import { EventType, RelationType } from "../@types/event.ts";
|
import { EventType, RelationType } from "../@types/event.ts";
|
||||||
@@ -25,7 +24,7 @@ import { KnownMembership } from "../@types/membership.ts";
|
|||||||
import { type ISendEventResponse } from "../@types/requests.ts";
|
import { type ISendEventResponse } from "../@types/requests.ts";
|
||||||
import { CallMembership } from "./CallMembership.ts";
|
import { CallMembership } from "./CallMembership.ts";
|
||||||
import { RoomStateEvent } from "../models/room-state.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 { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||||
import { deepCompare, logDurationSync } from "../utils.ts";
|
import { deepCompare, logDurationSync } from "../utils.ts";
|
||||||
import type {
|
import type {
|
||||||
@@ -51,6 +50,8 @@ import {
|
|||||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||||
import { TypedReEmitter } from "../ReEmitter.ts";
|
import { TypedReEmitter } from "../ReEmitter.ts";
|
||||||
import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
||||||
|
import { MatrixEvent } from "../models/event.ts";
|
||||||
|
import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Events emitted by MatrixRTCSession
|
* Events emitted by MatrixRTCSession
|
||||||
@@ -124,14 +125,6 @@ export function slotDescriptionToId(slotDescription: SlotDescription): string {
|
|||||||
// - we use a `Ms` postfix if the option is a duration to avoid using words like:
|
// - 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.
|
// `time`, `duration`, `delay`, `timeout`... that might be mistaken/confused with technical terms.
|
||||||
export interface MembershipConfig {
|
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
|
* The timeout (in milliseconds) after we joined the call, that our membership should expire
|
||||||
* unless we have explicitly updated it.
|
* unless we have explicitly updated it.
|
||||||
@@ -193,7 +186,14 @@ export interface MembershipConfig {
|
|||||||
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
|
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
|
||||||
*/
|
*/
|
||||||
delayedLeaveEventRestartLocalTimeoutMs?: number;
|
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 {
|
export interface EncryptionConfig {
|
||||||
@@ -239,6 +239,19 @@ export interface EncryptionConfig {
|
|||||||
}
|
}
|
||||||
export type JoinSessionConfig = SessionConfig & MembershipConfig & 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.
|
* 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.
|
* This class doesn't deal with media at all, just membership & properties of a session.
|
||||||
@@ -308,7 +321,10 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
* @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead.
|
* @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead.
|
||||||
*/
|
*/
|
||||||
public static async callMembershipsForRoom(
|
public static async callMembershipsForRoom(
|
||||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "findEventById" | "client">,
|
room: Pick<
|
||||||
|
Room,
|
||||||
|
"getLiveTimeline" | "roomId" | "hasMembershipState" | "findEventById" | "_unstable_getStickyEvents"
|
||||||
|
>,
|
||||||
client: Pick<MatrixClient, "fetchRoomEvent">,
|
client: Pick<MatrixClient, "fetchRoomEvent">,
|
||||||
): Promise<CallMembership[]> {
|
): Promise<CallMembership[]> {
|
||||||
return await MatrixRTCSession.sessionMembershipsForSlot(room, client, {
|
return await MatrixRTCSession.sessionMembershipsForSlot(room, client, {
|
||||||
@@ -321,7 +337,10 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
* @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead.
|
* @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead.
|
||||||
*/
|
*/
|
||||||
public static async sessionMembershipsForRoom(
|
public static async sessionMembershipsForRoom(
|
||||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "findEventById" | "client">,
|
room: Pick<
|
||||||
|
Room,
|
||||||
|
"getLiveTimeline" | "roomId" | "hasMembershipState" | "findEventById" | "_unstable_getStickyEvents"
|
||||||
|
>,
|
||||||
client: Pick<MatrixClient, "fetchRoomEvent">,
|
client: Pick<MatrixClient, "fetchRoomEvent">,
|
||||||
sessionDescription: SlotDescription,
|
sessionDescription: SlotDescription,
|
||||||
): Promise<CallMembership[]> {
|
): Promise<CallMembership[]> {
|
||||||
@@ -331,23 +350,61 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
/**
|
/**
|
||||||
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
||||||
* oldest first.
|
* oldest first.
|
||||||
|
*
|
||||||
|
* By default, this will return *both* sticky and member state events.
|
||||||
*/
|
*/
|
||||||
public static async sessionMembershipsForSlot(
|
public static async sessionMembershipsForSlot(
|
||||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "findEventById">,
|
room: Pick<
|
||||||
|
Room,
|
||||||
|
"getLiveTimeline" | "roomId" | "hasMembershipState" | "findEventById" | "_unstable_getStickyEvents"
|
||||||
|
>,
|
||||||
client: Pick<MatrixClient, "fetchRoomEvent">,
|
client: Pick<MatrixClient, "fetchRoomEvent">,
|
||||||
slotDescription: SlotDescription,
|
slotDescription: SlotDescription,
|
||||||
existingMemberships?: CallMembership[],
|
existingMemberships?: CallMembership[],
|
||||||
|
{ listenForStickyEvents, listenForMemberStateEvents }: SessionMembershipsForRoomOpts = {
|
||||||
|
listenForStickyEvents: true,
|
||||||
|
listenForMemberStateEvents: true,
|
||||||
|
},
|
||||||
): Promise<CallMembership[]> {
|
): Promise<CallMembership[]> {
|
||||||
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
|
const logger = rootLogger.getChild(`[MatrixRTCSession ${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);
|
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||||
if (!roomState) {
|
if (!roomState) {
|
||||||
logger.warn("Couldn't get state for room " + room.roomId);
|
logger.warn("Couldn't get state for room " + room.roomId);
|
||||||
throw new Error("Could't get state for room " + room.roomId);
|
throw new Error("Could't get state for room " + room.roomId);
|
||||||
}
|
}
|
||||||
const callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
|
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 callMemberships: CallMembership[] = [];
|
const callMemberships: CallMembership[] = [];
|
||||||
|
|
||||||
const createMembership = async (memberEvent: MatrixEvent): Promise<CallMembership | undefined> => {
|
const createMembership = async (memberEvent: MatrixEvent): Promise<CallMembership | undefined> => {
|
||||||
|
const content = memberEvent.getContent();
|
||||||
|
|
||||||
|
// 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) return undefined;
|
||||||
|
|
||||||
|
// We first decide if its a MSC4143 event (per device state key)
|
||||||
|
if (!(eventKeysCount > 1 && "application" in content)) return undefined;
|
||||||
|
|
||||||
const relatedEventId = memberEvent.relationEventId;
|
const relatedEventId = memberEvent.relationEventId;
|
||||||
const fetchRelatedEvent = async (): Promise<MatrixEvent | undefined> => {
|
const fetchRelatedEvent = async (): Promise<MatrixEvent | undefined> => {
|
||||||
const eventData = await client
|
const eventData = await client
|
||||||
@@ -415,11 +472,21 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
*
|
*
|
||||||
* @deprecated Use `MatrixRTCSession.sessionForSlot` with sessionDescription `{ id: "", application: "m.call" }` instead.
|
* @deprecated Use `MatrixRTCSession.sessionForSlot` with sessionDescription `{ id: "", application: "m.call" }` instead.
|
||||||
*/
|
*/
|
||||||
public static async roomSessionForRoom(client: MatrixClient, room: Room): Promise<MatrixRTCSession> {
|
public static async roomSessionForRoom(
|
||||||
const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(room, client, {
|
client: MatrixClient,
|
||||||
|
room: Room,
|
||||||
|
opts?: SessionMembershipsForRoomOpts,
|
||||||
|
): Promise<MatrixRTCSession> {
|
||||||
|
const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(
|
||||||
|
room,
|
||||||
|
client,
|
||||||
|
{
|
||||||
id: "",
|
id: "",
|
||||||
application: "m.call",
|
application: "m.call",
|
||||||
});
|
},
|
||||||
|
undefined,
|
||||||
|
opts,
|
||||||
|
);
|
||||||
return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" });
|
return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,8 +510,15 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
client: MatrixClient,
|
client: MatrixClient,
|
||||||
room: Room,
|
room: Room,
|
||||||
slotDescription: SlotDescription,
|
slotDescription: SlotDescription,
|
||||||
|
opts?: SessionMembershipsForRoomOpts,
|
||||||
): Promise<MatrixRTCSession> {
|
): Promise<MatrixRTCSession> {
|
||||||
const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(room, client, slotDescription);
|
const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(
|
||||||
|
room,
|
||||||
|
client,
|
||||||
|
slotDescription,
|
||||||
|
undefined,
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
|
||||||
return new MatrixRTCSession(client, room, callMemberships, slotDescription);
|
return new MatrixRTCSession(client, room, callMemberships, slotDescription);
|
||||||
}
|
}
|
||||||
@@ -478,10 +552,12 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
| "off"
|
| "off"
|
||||||
| "getUserId"
|
| "getUserId"
|
||||||
| "getDeviceId"
|
| "getDeviceId"
|
||||||
|
| "sendEvent"
|
||||||
| "sendStateEvent"
|
| "sendStateEvent"
|
||||||
| "_unstable_sendDelayedStateEvent"
|
| "_unstable_sendDelayedStateEvent"
|
||||||
| "_unstable_updateDelayedEvent"
|
| "_unstable_updateDelayedEvent"
|
||||||
| "sendEvent"
|
| "_unstable_sendStickyEvent"
|
||||||
|
| "_unstable_sendStickyDelayedEvent"
|
||||||
| "cancelPendingEvent"
|
| "cancelPendingEvent"
|
||||||
| "encryptAndSendToDevice"
|
| "encryptAndSendToDevice"
|
||||||
| "decryptEventIfNeeded"
|
| "decryptEventIfNeeded"
|
||||||
@@ -489,7 +565,14 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
>,
|
>,
|
||||||
private roomSubset: Pick<
|
private roomSubset: Pick<
|
||||||
Room,
|
Room,
|
||||||
"on" | "off" | "getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "findEventById"
|
| "on"
|
||||||
|
| "off"
|
||||||
|
| "getLiveTimeline"
|
||||||
|
| "roomId"
|
||||||
|
| "getVersion"
|
||||||
|
| "hasMembershipState"
|
||||||
|
| "findEventById"
|
||||||
|
| "_unstable_getStickyEvents"
|
||||||
>,
|
>,
|
||||||
public memberships: CallMembership[],
|
public memberships: CallMembership[],
|
||||||
/**
|
/**
|
||||||
@@ -504,9 +587,10 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||||
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
|
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
|
||||||
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||||
|
this.roomSubset.on(RoomStickyEventsEvent.Update, this.onStickyEventUpdate);
|
||||||
|
|
||||||
this.setExpiryTimer();
|
this.setExpiryTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Returns true if we intend to be participating in the MatrixRTC session.
|
* Returns true if we intend to be participating in the MatrixRTC session.
|
||||||
* This is determined by checking if the relativeExpiry has been set.
|
* This is determined by checking if the relativeExpiry has been set.
|
||||||
@@ -526,7 +610,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
}
|
}
|
||||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||||
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||||
|
this.roomSubset.off(RoomStickyEventsEvent.Update, this.onStickyEventUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
private reEmitter = new TypedReEmitter<
|
private reEmitter = new TypedReEmitter<
|
||||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
||||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap
|
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap
|
||||||
@@ -556,14 +642,15 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
// Create MembershipManager and pass the RTCSession logger (with room id info)
|
// Create MembershipManager and pass the RTCSession logger (with room id info)
|
||||||
|
this.membershipManager = joinConfig?.unstableSendStickyEvents
|
||||||
this.membershipManager = new MembershipManager(
|
? new StickyEventMembershipManager(
|
||||||
joinConfig,
|
joinConfig,
|
||||||
this.roomSubset,
|
this.roomSubset,
|
||||||
this.client,
|
this.client,
|
||||||
this.slotDescription,
|
this.slotDescription,
|
||||||
this.logger,
|
this.logger,
|
||||||
);
|
)
|
||||||
|
: new MembershipManager(joinConfig, this.roomSubset, this.client, this.slotDescription, this.logger);
|
||||||
|
|
||||||
this.reEmitter.reEmit(this.membershipManager!, [
|
this.reEmitter.reEmit(this.membershipManager!, [
|
||||||
MembershipManagerEvent.ProbablyLeft,
|
MembershipManagerEvent.ProbablyLeft,
|
||||||
@@ -802,10 +889,27 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
/**
|
/**
|
||||||
* Call this when the Matrix room members have changed.
|
* Call this when the Matrix room members have changed.
|
||||||
*/
|
*/
|
||||||
public onRoomMemberUpdate = (): void => {
|
public readonly onRoomMemberUpdate = (): void => {
|
||||||
void this.recalculateSessionMembers();
|
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,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
void this.recalculateSessionMembers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call this when something changed that may impacts the current MatrixRTC members in this session.
|
* Call this when something changed that may impacts the current MatrixRTC members in this session.
|
||||||
*/
|
*/
|
||||||
@@ -861,6 +965,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
// If anyone else joins the session it is no longer our responsibility to send the notification.
|
// 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 we were the joiner we already did sent the notification in the block above.)
|
||||||
if (this.memberships.length > 0) this.pendingNotificationToSend = undefined;
|
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
|
// This also needs to be done if `changed` = false
|
||||||
// A member might have updated their fingerprint (created_ts)
|
// 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 { type MatrixClient, ClientEvent } from "../client.ts";
|
||||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||||
import { type Room } from "../models/room.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 { type MatrixEvent } from "../models/event.ts";
|
||||||
import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts";
|
import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts";
|
||||||
import { EventType } from "../@types/event.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.Room, this.onRoom);
|
||||||
|
this.client.on(ClientEvent.Event, this.onEvent);
|
||||||
this.client.on(RoomStateEvent.Events, this.onRoomState);
|
this.client.on(RoomStateEvent.Events, this.onRoomState);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
|||||||
this.roomSessions.clear();
|
this.roomSessions.clear();
|
||||||
|
|
||||||
this.client.off(ClientEvent.Room, this.onRoom);
|
this.client.off(ClientEvent.Room, this.onRoom);
|
||||||
|
this.client.off(ClientEvent.Event, this.onEvent);
|
||||||
this.client.off(RoomStateEvent.Events, this.onRoomState);
|
this.client.off(RoomStateEvent.Events, this.onRoomState);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,16 +115,28 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
|||||||
void this.refreshRoom(room);
|
void this.refreshRoom(room);
|
||||||
};
|
};
|
||||||
|
|
||||||
private onRoomState = (event: MatrixEvent, _state: RoomState): void => {
|
private readonly onEvent = (event: MatrixEvent): void => {
|
||||||
|
if (!event.unstableStickyExpiresAt) return; // Not sticky, not interested.
|
||||||
|
|
||||||
|
if (event.getType() !== EventType.RTCMembership) return;
|
||||||
|
|
||||||
|
const room = this.client.getRoom(event.getRoomId());
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
void this.refreshRoom(room);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onRoomState = (event: MatrixEvent): void => {
|
||||||
|
if (event.getType() !== EventType.GroupCallMemberPrefix) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const room = this.client.getRoom(event.getRoomId());
|
const room = this.client.getRoom(event.getRoomId());
|
||||||
if (!room) {
|
if (!room) {
|
||||||
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.getType() == EventType.GroupCallMemberPrefix) {
|
|
||||||
void this.refreshRoom(room);
|
void this.refreshRoom(room);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private async refreshRoom(room: Room): Promise<void> {
|
private async refreshRoom(room: Room): Promise<void> {
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ limitations under the License.
|
|||||||
import { AbortError } from "p-retry";
|
import { AbortError } from "p-retry";
|
||||||
|
|
||||||
import { EventType, RelationType } from "../@types/event.ts";
|
import { EventType, RelationType } from "../@types/event.ts";
|
||||||
import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
import {
|
||||||
|
type ISendEventResponse,
|
||||||
|
type SendDelayedEventResponse,
|
||||||
|
UpdateDelayedEventAction,
|
||||||
|
} from "../@types/requests.ts";
|
||||||
|
import { type EmptyObject } from "../@types/common.ts";
|
||||||
import type { MatrixClient } from "../client.ts";
|
import type { MatrixClient } from "../client.ts";
|
||||||
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
|
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
|
||||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||||
@@ -85,6 +90,12 @@ On Leave: ───────── STOP ALL ABOVE
|
|||||||
(s) Successful restart/resend
|
(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.
|
* The different types of actions the MembershipManager can take.
|
||||||
* @internal
|
* @internal
|
||||||
@@ -145,6 +156,23 @@ export interface MembershipManagerState {
|
|||||||
probablyLeft: boolean;
|
probablyLeft: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||||
|
return {
|
||||||
|
insert: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||||
|
return {
|
||||||
|
replace: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type MembershipManagerClient = Pick<
|
||||||
|
MatrixClient,
|
||||||
|
"getUserId" | "getDeviceId" | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent"
|
||||||
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is responsible for sending all events relating to the own membership of a matrixRTC call.
|
* This class is responsible for sending all events relating to the own membership of a matrixRTC call.
|
||||||
* It has the following tasks:
|
* It has the following tasks:
|
||||||
@@ -163,8 +191,8 @@ export class MembershipManager
|
|||||||
implements IMembershipManager
|
implements IMembershipManager
|
||||||
{
|
{
|
||||||
private activated = false;
|
private activated = false;
|
||||||
private logger: Logger;
|
private readonly logger: Logger;
|
||||||
private callIntent: RTCCallIntent | undefined;
|
protected callIntent: RTCCallIntent | undefined;
|
||||||
|
|
||||||
public isActivated(): boolean {
|
public isActivated(): boolean {
|
||||||
return this.activated;
|
return this.activated;
|
||||||
@@ -296,16 +324,9 @@ export class MembershipManager
|
|||||||
* @param client
|
* @param client
|
||||||
*/
|
*/
|
||||||
public constructor(
|
public constructor(
|
||||||
private joinConfig: (SessionConfig & MembershipConfig) | undefined,
|
private readonly joinConfig: (SessionConfig & MembershipConfig) | undefined,
|
||||||
private room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
|
protected readonly room: Pick<Room, "roomId" | "getVersion">,
|
||||||
private client: Pick<
|
protected readonly client: MembershipManagerClient,
|
||||||
MatrixClient,
|
|
||||||
| "getUserId"
|
|
||||||
| "getDeviceId"
|
|
||||||
| "sendStateEvent"
|
|
||||||
| "_unstable_sendDelayedStateEvent"
|
|
||||||
| "_unstable_updateDelayedEvent"
|
|
||||||
>,
|
|
||||||
public readonly slotDescription: SlotDescription,
|
public readonly slotDescription: SlotDescription,
|
||||||
parentLogger?: Logger,
|
parentLogger?: Logger,
|
||||||
) {
|
) {
|
||||||
@@ -362,11 +383,11 @@ export class MembershipManager
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Membership Event static parameters:
|
// Membership Event static parameters:
|
||||||
private deviceId: string;
|
protected deviceId: string;
|
||||||
private memberId: string;
|
protected memberId: string;
|
||||||
|
protected rtcTransport?: Transport;
|
||||||
/** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */
|
/** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */
|
||||||
private fociPreferred?: Transport[];
|
private fociPreferred?: Transport[];
|
||||||
private rtcTransport?: Transport;
|
|
||||||
|
|
||||||
// Config:
|
// Config:
|
||||||
private delayedLeaveEventDelayMsOverride?: number;
|
private delayedLeaveEventDelayMsOverride?: number;
|
||||||
@@ -381,9 +402,13 @@ export class MembershipManager
|
|||||||
return this.joinConfig?.membershipEventExpiryHeadroomMs ?? 5_000;
|
return this.joinConfig?.membershipEventExpiryHeadroomMs ?? 5_000;
|
||||||
}
|
}
|
||||||
private computeNextExpiryActionTs(iteration: number): number {
|
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;
|
return this.delayedLeaveEventDelayMsOverride ?? this.joinConfig?.delayedLeaveEventDelayMs ?? 8_000;
|
||||||
}
|
}
|
||||||
private get delayedLeaveEventRestartMs(): number {
|
private get delayedLeaveEventRestartMs(): number {
|
||||||
@@ -395,13 +420,10 @@ export class MembershipManager
|
|||||||
private get maximumNetworkErrorRetryCount(): number {
|
private get maximumNetworkErrorRetryCount(): number {
|
||||||
return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10;
|
return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get delayedLeaveEventRestartLocalTimeoutMs(): number {
|
private get delayedLeaveEventRestartLocalTimeoutMs(): number {
|
||||||
return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000;
|
return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000;
|
||||||
}
|
}
|
||||||
private get useRtcMemberFormat(): boolean {
|
|
||||||
return this.joinConfig?.useRtcMemberFormat ?? false;
|
|
||||||
}
|
|
||||||
// LOOP HANDLER:
|
// LOOP HANDLER:
|
||||||
private membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
|
private membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -456,22 +478,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)
|
// HANDLERS (used in the membershipLoopHandler)
|
||||||
private sendOrResendDelayedLeaveEvent(): Promise<ActionUpdate> {
|
private sendOrResendDelayedLeaveEvent(): Promise<ActionUpdate> {
|
||||||
// We can reach this at the start of a call (where we do not yet have a membership: state.hasMemberStateEvent=false)
|
// 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.
|
// 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, ...)
|
// (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 {}"
|
// In the `then` and `catch` block we treat both cases differently. "if (this.state.hasMemberStateEvent) {} else {}"
|
||||||
return this.client
|
return this.clientSendDelayedDisconnectMembership()
|
||||||
._unstable_sendDelayedStateEvent(
|
|
||||||
this.room.roomId,
|
|
||||||
{
|
|
||||||
delay: this.delayedLeaveEventDelayMs,
|
|
||||||
},
|
|
||||||
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
|
||||||
{}, // leave event
|
|
||||||
this.memberId,
|
|
||||||
)
|
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs;
|
this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs;
|
||||||
this.setAndEmitProbablyLeft(false);
|
this.setAndEmitProbablyLeft(false);
|
||||||
@@ -495,7 +518,7 @@ export class MembershipManager
|
|||||||
if (this.manageMaxDelayExceededSituation(e)) {
|
if (this.manageMaxDelayExceededSituation(e)) {
|
||||||
return createInsertActionUpdate(repeatActionType);
|
return createInsertActionUpdate(repeatActionType);
|
||||||
}
|
}
|
||||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "sendDelayedStateEvent");
|
const update = this.actionUpdateFromErrors(e, repeatActionType, "_unstable_sendDelayedStateEvent");
|
||||||
if (update) return update;
|
if (update) return update;
|
||||||
|
|
||||||
if (this.state.hasMemberStateEvent) {
|
if (this.state.hasMemberStateEvent) {
|
||||||
@@ -651,14 +674,19 @@ export class MembershipManager
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendJoinEvent(): Promise<ActionUpdate> {
|
protected clientSendMembership: (
|
||||||
return this.client
|
myMembership: RtcMembershipData | SessionMembershipData | EmptyObject,
|
||||||
.sendStateEvent(
|
) => Promise<ISendEventResponse> = (myMembership) => {
|
||||||
|
return this.client.sendStateEvent(
|
||||||
this.room.roomId,
|
this.room.roomId,
|
||||||
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
EventType.GroupCallMemberPrefix,
|
||||||
this.makeMyMembership(this.membershipEventExpiryMs),
|
myMembership as EmptyObject | SessionMembershipData,
|
||||||
this.memberId,
|
this.memberId,
|
||||||
)
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private async sendJoinEvent(): Promise<ActionUpdate> {
|
||||||
|
return this.clientSendMembership(this.makeMyMembership(this.membershipEventExpiryMs))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.setAndEmitProbablyLeft(false);
|
this.setAndEmitProbablyLeft(false);
|
||||||
this.state.startTime = Date.now();
|
this.state.startTime = Date.now();
|
||||||
@@ -698,12 +726,8 @@ export class MembershipManager
|
|||||||
|
|
||||||
private updateExpiryOnJoinedEvent(): Promise<ActionUpdate> {
|
private updateExpiryOnJoinedEvent(): Promise<ActionUpdate> {
|
||||||
const nextExpireUpdateIteration = this.state.expireUpdateIterations + 1;
|
const nextExpireUpdateIteration = this.state.expireUpdateIterations + 1;
|
||||||
return this.client
|
return this.clientSendMembership(
|
||||||
.sendStateEvent(
|
|
||||||
this.room.roomId,
|
|
||||||
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
|
||||||
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
|
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
|
||||||
this.memberId,
|
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Success, we reset retries and schedule update.
|
// Success, we reset retries and schedule update.
|
||||||
@@ -725,14 +749,8 @@ export class MembershipManager
|
|||||||
throw e;
|
throw e;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
private sendFallbackLeaveEvent(): Promise<ActionUpdate> {
|
private async sendFallbackLeaveEvent(): Promise<ActionUpdate> {
|
||||||
return this.client
|
return this.clientSendMembership({})
|
||||||
.sendStateEvent(
|
|
||||||
this.room.roomId,
|
|
||||||
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
|
||||||
{},
|
|
||||||
this.memberId,
|
|
||||||
)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
|
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
|
||||||
this.state.hasMemberStateEvent = false;
|
this.state.hasMemberStateEvent = false;
|
||||||
@@ -758,25 +776,9 @@ export class MembershipManager
|
|||||||
/**
|
/**
|
||||||
* Constructs our own membership
|
* Constructs our own membership
|
||||||
*/
|
*/
|
||||||
private makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
|
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
|
||||||
const ownMembership = this.ownMembership;
|
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,
|
|
||||||
[UNSTABLE_STICKY_KEY.name]: this.memberId,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const focusObjects =
|
const focusObjects =
|
||||||
this.rtcTransport === undefined
|
this.rtcTransport === undefined
|
||||||
? {
|
? {
|
||||||
@@ -798,7 +800,6 @@ export class MembershipManager
|
|||||||
...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined),
|
...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Error checks and handlers
|
// Error checks and handlers
|
||||||
|
|
||||||
@@ -832,7 +833,7 @@ export class MembershipManager
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private actionUpdateFromErrors(
|
protected actionUpdateFromErrors(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
type: MembershipActionType,
|
type: MembershipActionType,
|
||||||
method: string,
|
method: string,
|
||||||
@@ -880,7 +881,7 @@ export class MembershipManager
|
|||||||
return createInsertActionUpdate(type, resendDelay);
|
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1024,14 +1025,69 @@ export class MembershipManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
/**
|
||||||
return {
|
* Implementation of the Membership manager that uses sticky events
|
||||||
insert: [{ ts: Date.now() + (offset ?? 0), type }],
|
* 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 {
|
protected clientSendDelayedDisconnectMembership: () => Promise<SendDelayedEventResponse> = () =>
|
||||||
return {
|
this.clientWithSticky._unstable_sendStickyDelayedEvent(
|
||||||
replace: [{ ts: Date.now() + (offset ?? 0), type }],
|
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,
|
||||||
|
[UNSTABLE_STICKY_KEY.name]: this.memberId,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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 { RelationEvent } from "../types.ts";
|
||||||
import type { CallMembership } from "./CallMembership.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.
|
* May be any string, although `"audio"` and `"video"` are commonly accepted values.
|
||||||
*/
|
*/
|
||||||
export type RTCCallIntent = "audio" | "video" | string;
|
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 {
|
export interface IRTCNotificationContent extends RelationEvent {
|
||||||
"m.mentions": IMentions;
|
"m.mentions"?: IMentions;
|
||||||
"decline_reason"?: string;
|
|
||||||
"notification_type": RTCNotificationType;
|
"notification_type": RTCNotificationType;
|
||||||
/**
|
/**
|
||||||
* The initial intent of the calling user.
|
* The initial intent of the calling user.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export enum RoomStickyEventsEvent {
|
|||||||
Update = "RoomStickyEvents.Update",
|
Update = "RoomStickyEvents.Update",
|
||||||
}
|
}
|
||||||
|
|
||||||
type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number };
|
export type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number };
|
||||||
|
|
||||||
export type RoomStickyEventsMap = {
|
export type RoomStickyEventsMap = {
|
||||||
/**
|
/**
|
||||||
@@ -48,17 +48,64 @@ export type RoomStickyEventsMap = {
|
|||||||
) => void;
|
) => 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
|
* Tracks sticky events on behalf of one room, and fires an event
|
||||||
* whenever a sticky event is updated or replaced.
|
* whenever a sticky event is updated or replaced.
|
||||||
*/
|
*/
|
||||||
export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEvent, RoomStickyEventsMap> {
|
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 readonly unkeyedStickyEvents = new Set<StickyMatrixEvent>();
|
||||||
|
|
||||||
private stickyEventTimer?: ReturnType<typeof setTimeout>;
|
private stickyEventTimer?: ReturnType<typeof setTimeout>;
|
||||||
private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER;
|
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.
|
* Get all sticky events that are currently active.
|
||||||
* @returns An iterable set of events.
|
* @returns An iterable set of events.
|
||||||
@@ -66,7 +113,11 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
|||||||
public *getStickyEvents(): Iterable<StickyMatrixEvent> {
|
public *getStickyEvents(): Iterable<StickyMatrixEvent> {
|
||||||
yield* this.unkeyedStickyEvents;
|
yield* this.unkeyedStickyEvents;
|
||||||
for (const innerMap of this.stickyEventsMap.values()) {
|
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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +129,8 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
|||||||
* @returns A matching active sticky event, or undefined.
|
* @returns A matching active sticky event, or undefined.
|
||||||
*/
|
*/
|
||||||
public getKeyedStickyEvent(sender: string, type: string, stickyKey: string): StickyMatrixEvent | 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];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,9 +165,8 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
|||||||
}
|
}
|
||||||
const sender = event.getSender();
|
const sender = event.getSender();
|
||||||
const type = event.getType();
|
const type = event.getType();
|
||||||
if (!sender) {
|
assertIsUserId(sender);
|
||||||
throw new Error(`${event.getId()} is missing a sender`);
|
if (event.unstableStickyExpiresAt <= Date.now()) {
|
||||||
} else if (event.unstableStickyExpiresAt <= Date.now()) {
|
|
||||||
logger.info("ignored sticky event with older expiration time than current time", stickyKey);
|
logger.info("ignored sticky event with older expiration time than current time", stickyKey);
|
||||||
return { added: false };
|
return { added: false };
|
||||||
}
|
}
|
||||||
@@ -126,42 +177,39 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
|||||||
throw new Error("Expected sender to start with @");
|
throw new Error("Expected sender to start with @");
|
||||||
}
|
}
|
||||||
|
|
||||||
let prevEvent: StickyMatrixEvent | undefined;
|
const stickyEvent = event as StickyMatrixEvent;
|
||||||
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);
|
|
||||||
|
|
||||||
// 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.
|
// Recalculate the next expiry time.
|
||||||
this.nextStickyEventExpiryTs = Math.min(event.unstableStickyExpiresAt, this.nextStickyEventExpiryTs);
|
this.nextStickyEventExpiryTs = Math.min(event.unstableStickyExpiresAt, this.nextStickyEventExpiryTs);
|
||||||
|
|
||||||
this.scheduleStickyTimer();
|
this.scheduleStickyTimer();
|
||||||
return { added: true, prevEvent };
|
return { added: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Why this is safe:
|
||||||
|
// A type may contain anything but the *sender* is tightly
|
||||||
|
// constrained so that a key will always end with a @<user_id>
|
||||||
|
// E.g. Where a malicious event type might be "rtc.member.event@foo:bar" the key becomes:
|
||||||
|
// "rtc.member.event.@foo:bar@bar:baz"
|
||||||
|
const innerMapKey = RoomStickyEventsStore.stickyMapKey(stickyKey, sender);
|
||||||
|
const currentEventSet = [stickyEvent, ...(this.stickyEventsMap.get(type)?.get(innerMapKey) ?? [])].sort(
|
||||||
|
RoomStickyEventsStore.sortStickyEvent,
|
||||||
|
);
|
||||||
|
if (!this.stickyEventsMap.has(type)) {
|
||||||
|
this.stickyEventsMap.set(type, new Map());
|
||||||
|
}
|
||||||
|
this.stickyEventsMap.get(type)?.set(innerMapKey, currentEventSet);
|
||||||
|
|
||||||
|
// Recalculate the next expiry time.
|
||||||
|
this.nextStickyEventExpiryTs = Math.min(stickyEvent.unstableStickyExpiresAt, this.nextStickyEventExpiryTs);
|
||||||
|
|
||||||
|
this.scheduleStickyTimer();
|
||||||
|
return {
|
||||||
|
added: currentEventSet[0] === stickyEvent,
|
||||||
|
prevEvent: currentEventSet?.[1],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -217,17 +265,24 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
|||||||
// We will recalculate this as we check all events.
|
// We will recalculate this as we check all events.
|
||||||
this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER;
|
this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER;
|
||||||
for (const [eventType, innerEvents] of this.stickyEventsMap.entries()) {
|
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
|
// we only added items with `sticky` into this map so we can assert non-null here
|
||||||
if (now >= event.unstableStickyExpiresAt) {
|
if (now >= currentEvent.unstableStickyExpiresAt) {
|
||||||
logger.debug("Expiring sticky event", event.getId());
|
logger.debug("Expiring sticky event", currentEvent.getId());
|
||||||
removedEvents.push(event);
|
removedEvents.push(currentEvent);
|
||||||
this.stickyEventsMap.get(eventType)!.delete(innerMapKey);
|
this.stickyEventsMap.get(eventType)!.delete(innerMapKey);
|
||||||
} else {
|
} 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.
|
// If not removing the event, check to see if it's the next lowest expiry.
|
||||||
this.nextStickyEventExpiryTs = Math.min(
|
this.nextStickyEventExpiryTs = Math.min(
|
||||||
this.nextStickyEventExpiryTs,
|
this.nextStickyEventExpiryTs,
|
||||||
event.unstableStickyExpiresAt,
|
currentEvent.unstableStickyExpiresAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,6 +308,93 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
|||||||
this.scheduleStickyTimer();
|
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.
|
* 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.
|
// if we know about this event, redact its contents now.
|
||||||
const redactedEvent = redactId ? this.findEventById(redactId) : undefined;
|
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) {
|
if (redactedEvent) {
|
||||||
this.applyEventAsRedaction(event, redactedEvent);
|
this.applyEventAsRedaction(event, redactedEvent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
type KeyBackupRestoreOpts,
|
type KeyBackupRestoreOpts,
|
||||||
type KeyBackupRestoreResult,
|
type KeyBackupRestoreResult,
|
||||||
type OwnDeviceKeys,
|
type OwnDeviceKeys,
|
||||||
|
type SecretStorageStatus,
|
||||||
type StartDehydrationOpts,
|
type StartDehydrationOpts,
|
||||||
UserVerificationStatus,
|
UserVerificationStatus,
|
||||||
type VerificationRequest,
|
type VerificationRequest,
|
||||||
@@ -78,7 +79,7 @@ import {
|
|||||||
type ServerSideSecretStorage,
|
type ServerSideSecretStorage,
|
||||||
} from "../secret-storage.ts";
|
} from "../secret-storage.ts";
|
||||||
import { CrossSigningIdentity } from "./CrossSigningIdentity.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 { isVerificationEvent, RustVerificationRequest, verificationMethodIdentifierToMethod } from "./verification.ts";
|
||||||
import { EventType, MsgType } from "../@types/event.ts";
|
import { EventType, MsgType } from "../@types/event.ts";
|
||||||
import { TypedEventEmitter } from "../models/typed-event-emitter.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}
|
* Implementation of {@link CryptoApi#isSecretStorageReady}
|
||||||
*/
|
*/
|
||||||
public async isSecretStorageReady(): Promise<boolean> {
|
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
|
// make sure that the cross-signing keys are stored
|
||||||
const secretsToCheck: SecretStorageKey[] = [
|
const secretsToCheck: SecretStorageKey[] = [
|
||||||
"m.cross_signing.master",
|
"m.cross_signing.master",
|
||||||
@@ -834,13 +842,32 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
|||||||
"m.cross_signing.self_signing",
|
"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;
|
const keyBackupEnabled = (await this.backupManager.getActiveBackupVersion()) != null;
|
||||||
if (keyBackupEnabled) {
|
if (keyBackupEnabled) {
|
||||||
secretsToCheck.push("m.megolm_backup.v1");
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user