1
0
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:
Timo K
2025-10-30 16:15:22 +01:00
32 changed files with 2450 additions and 1266 deletions

View File

@@ -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 }}

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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/*"

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -1053,17 +1053,28 @@ describe("MatrixClient", function () {
); );
}); });
it("can look up delayed events", async () => { describe("lookups", () => {
httpLookups = [ const statuses = [undefined, "scheduled" as const, "finalised" as const];
{ const delayIds = [undefined, "dxyz", ["d123"], ["d456", "d789"]];
method: "GET", const inputs = statuses.flatMap((status) =>
prefix: unstableMSC4140Prefix, delayIds.map((delayId) => [status, delayId] as [(typeof statuses)[0], (typeof delayIds)[0]]),
path: "/delayed_events", );
data: [], it.each(inputs)("can look up delayed events (status = %s, delayId = %s)", async (status, delayId) => {
}, httpLookups = [
]; {
method: "GET",
prefix: unstableMSC4140Prefix,
path: "/delayed_events",
expectQueryParams: {
status,
delay_id: delayId,
},
data: [],
},
];
await client._unstable_getDelayedEvents(); await client._unstable_getDelayedEvents(status, delayId);
});
}); });
it("can update delayed events", async () => { it("can update delayed events", async () => {

View File

@@ -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,293 @@ 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[0].slotDescription.id).toEqual("");
expect(sess?.memberships[0].scope).toEqual("m.room");
expect(sess?.memberships[0].application).toEqual("m.call");
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
expect(sess?.memberships[0].isExpired()).toEqual(false);
expect(sess?.slotDescription.id).toEqual("");
});
it("ignores memberships where application is not m.call", async () => {
const testMembership = Object.assign({}, membershipTemplate, {
application: "not-m.call",
});
const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky);
const sess = await MatrixRTCSession.sessionForSlot(
client,
mockRoom,
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
expect(sess?.memberships).toHaveLength(0);
});
it("ignores memberships where callId is not empty", async () => {
const testMembership = Object.assign({}, membershipTemplate, {
call_id: "not-empty",
scope: "m.room",
});
const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky);
const sess = await MatrixRTCSession.sessionForSlot(
client,
mockRoom,
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
expect(sess?.memberships).toHaveLength(0);
});
it("ignores expired memberships events", async () => {
jest.useFakeTimers();
const expiredMembership = Object.assign({}, membershipTemplate);
expiredMembership.expires = 1000;
expiredMembership.device_id = "EXPIRED";
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], testConfig.testCreateSticky);
jest.advanceTimersByTime(2000);
sess = await MatrixRTCSession.sessionForSlot(
client,
mockRoom,
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
expect(sess?.memberships.length).toEqual(1);
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
jest.useRealTimers();
});
it("ignores memberships events of members not in the room", async () => {
const mockRoom = makeMockRoom([membershipTemplate], 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);
});
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);
});
it("honours created_ts", async () => {
jest.useFakeTimers();
jest.setSystemTime(500);
const expiredMembership = Object.assign({}, membershipTemplate);
expiredMembership.created_ts = 500;
expiredMembership.expires = 1000;
const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky);
sess = await MatrixRTCSession.sessionForSlot(
client,
mockRoom,
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
jest.useRealTimers();
});
it("returns empty session if no membership events are present", async () => {
const mockRoom = makeMockRoom([], testConfig.testCreateSticky);
sess = await MatrixRTCSession.sessionForSlot(
client,
mockRoom,
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
expect(sess?.memberships).toHaveLength(0);
});
it("safely ignores events with no memberships section", async () => {
const event = {
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue({}),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getTs: jest.fn().mockReturnValue(1000),
getLocalAge: jest.fn().mockReturnValue(0),
};
const mockRoom = makeMockRoom([]);
mockRoom.getLiveTimeline.mockReturnValue({
getState: jest.fn().mockReturnValue({
on: jest.fn(),
off: jest.fn(),
getStateEvents: (_type: string, _stateKey: string) => [event],
events: new Map([
[
EventType.GroupCallMemberPrefix,
{
size: () => true,
has: (_stateKey: string) => true,
get: (_stateKey: string) => event,
values: () => [event],
},
],
]),
}),
} as unknown as EventTimeline);
sess = await MatrixRTCSession.sessionForSlot(
client,
mockRoom,
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
expect(sess.memberships).toHaveLength(0);
});
it("safely ignores events with junk memberships section", async () => {
const event = {
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getTs: jest.fn().mockReturnValue(1000),
getLocalAge: jest.fn().mockReturnValue(0),
};
const mockRoom = makeMockRoom([]);
mockRoom.getLiveTimeline.mockReturnValue({
getState: jest.fn().mockReturnValue({
on: jest.fn(),
off: jest.fn(),
getStateEvents: (_type: string, _stateKey: string) => [event],
events: new Map([
[
EventType.GroupCallMemberPrefix,
{
size: () => true,
has: (_stateKey: string) => true,
get: (_stateKey: string) => event,
values: () => [event],
},
],
]),
}),
} as unknown as EventTimeline);
sess = await MatrixRTCSession.sessionForSlot(
client,
mockRoom,
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
expect(sess.memberships).toHaveLength(0);
});
it("ignores memberships with no device_id", async () => {
const testMembership = Object.assign({}, membershipTemplate);
(testMembership.device_id as string | undefined) = undefined;
const mockRoom = makeMockRoom([testMembership]);
const sess = await MatrixRTCSession.sessionForSlot(
client,
mockRoom,
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
expect(sess.memberships).toHaveLength(0);
});
it("ignores memberships with no call_id", async () => {
const testMembership = Object.assign({}, membershipTemplate);
(testMembership.call_id as string | undefined) = undefined;
const mockRoom = makeMockRoom([testMembership]);
sess = await MatrixRTCSession.sessionForSlot(
client,
mockRoom,
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
expect(sess.memberships).toHaveLength(0);
});
},
);
describe("roomSessionForRoom combined state", () => {
it("perfers sticky events when both membership and sticky events appear for the same user", 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.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");
@@ -79,149 +364,67 @@ describe("MatrixRTCSession", () => {
expect(sess?.memberships[0].isExpired()).toEqual(false); expect(sess?.memberships[0].isExpired()).toEqual(false);
expect(sess?.slotDescription.id).toEqual(""); expect(sess?.slotDescription.id).toEqual("");
}); });
it("combines sticky and membership events when both exist", async () => {
it("ignores memberships where application is not m.call", async () => { // Create a room with identical member state and sticky state for the same user.
const testMembership = Object.assign({}, membershipTemplate, {
application: "not-m.call",
});
const mockRoom = makeMockRoom([testMembership]);
const sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships).toHaveLength(0);
});
it("ignores memberships where callId is not empty", async () => {
const testMembership = Object.assign({}, membershipTemplate, {
call_id: "not-empty",
scope: "m.room",
});
const mockRoom = makeMockRoom([testMembership]);
const sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships).toHaveLength(0);
});
it("ignores expired memberships events", async () => {
jest.useFakeTimers();
const expiredMembership = Object.assign({}, membershipTemplate);
expiredMembership.expires = 1000;
expiredMembership.device_id = "EXPIRED";
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]);
jest.advanceTimersByTime(2000);
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships.length).toEqual(1);
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
jest.useRealTimers();
});
it("ignores memberships events of members not in the room", async () => {
const mockRoom = makeMockRoom([membershipTemplate]); const mockRoom = makeMockRoom([membershipTemplate]);
mockRoom.hasMembershipState = (state) => state === KnownMembership.Join; const stickyUserId = "@stickyev:user.example";
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); mockRoom._unstable_getStickyEvents.mockImplementation(() => {
expect(sess?.memberships.length).toEqual(0); const ev = mockRTCEvent(
}); {
...membershipTemplate,
user_id: stickyUserId,
msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`,
},
mockRoom.roomId,
15000,
Date.now() - 1000, // Sticky event comes first.
);
return [ev as StickyMatrixEvent];
});
it("honours created_ts", async () => { sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
jest.useFakeTimers(); listenForStickyEvents: true,
jest.setSystemTime(500); listenForMemberStateEvents: true,
const expiredMembership = Object.assign({}, membershipTemplate); });
expiredMembership.created_ts = 500;
expiredMembership.expires = 1000;
const mockRoom = makeMockRoom([expiredMembership]);
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
jest.useRealTimers();
});
it("returns empty session if no membership events are present", async () => { const memberships = sess.memberships;
const mockRoom = makeMockRoom([]); expect(memberships.length).toEqual(2);
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(memberships[0].sender).toEqual(stickyUserId);
expect(sess?.memberships).toHaveLength(0); expect(memberships[0].slotDescription.id).toEqual("");
}); expect(memberships[0].scope).toEqual("m.room");
expect(memberships[0].application).toEqual("m.call");
expect(memberships[0].deviceId).toEqual("AAAAAAA");
expect(memberships[0].isExpired()).toEqual(false);
it("safely ignores events with no memberships section", async () => { // Then state
const roomId = secureRandomString(8); expect(memberships[1].sender).toEqual(membershipTemplate.user_id);
const event = {
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue({}),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getTs: jest.fn().mockReturnValue(1000),
getLocalAge: jest.fn().mockReturnValue(0),
};
const mockRoom = {
...makeMockRoom([]),
roomId,
getLiveTimeline: jest.fn().mockReturnValue({
getState: jest.fn().mockReturnValue({
on: jest.fn(),
off: jest.fn(),
getStateEvents: (_type: string, _stateKey: string) => [event],
events: new Map([
[
EventType.GroupCallMemberPrefix,
{
size: () => true,
has: (_stateKey: string) => true,
get: (_stateKey: string) => event,
values: () => [event],
},
],
]),
}),
}),
};
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession);
expect(sess.memberships).toHaveLength(0);
});
it("safely ignores events with junk memberships section", async () => { expect(sess?.slotDescription.id).toEqual("");
const roomId = secureRandomString(8);
const event = {
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getTs: jest.fn().mockReturnValue(1000),
getLocalAge: jest.fn().mockReturnValue(0),
};
const mockRoom = {
...makeMockRoom([]),
roomId,
getLiveTimeline: jest.fn().mockReturnValue({
getState: jest.fn().mockReturnValue({
on: jest.fn(),
off: jest.fn(),
getStateEvents: (_type: string, _stateKey: string) => [event],
events: new Map([
[
EventType.GroupCallMemberPrefix,
{
size: () => true,
has: (_stateKey: string) => true,
get: (_stateKey: string) => event,
values: () => [event],
},
],
]),
}),
}),
};
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession);
expect(sess.memberships).toHaveLength(0);
}); });
it("handles an incoming sticky event to an existing session", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
const stickyUserId = "@stickyev:user.example";
it("ignores memberships with no device_id", async () => { sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
const testMembership = Object.assign({}, membershipTemplate); listenForStickyEvents: true,
(testMembership.device_id as string | undefined) = undefined; listenForMemberStateEvents: true,
const mockRoom = makeMockRoom([testMembership]); });
const sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess.memberships.length).toEqual(1);
expect(sess.memberships).toHaveLength(0); const stickyEv = mockRTCEvent(
}); {
...membershipTemplate,
it("ignores memberships with no call_id", async () => { user_id: stickyUserId,
const testMembership = Object.assign({}, membershipTemplate); msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`,
(testMembership.call_id as string | undefined) = undefined; },
const mockRoom = makeMockRoom([testMembership]); mockRoom.roomId,
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); 15000,
expect(sess.memberships).toHaveLength(0); 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 = {
@@ -233,13 +436,11 @@ 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() id === "id"
.mockImplementation((id) => ? new MatrixEvent({ content: { ...rtcMembershipTemplate }, origin_server_ts: now + 100 })
id === "id" : undefined,
? new MatrixEvent({ content: { ...rtcMembershipTemplate }, origin_server_ts: now + 100 }) );
: undefined,
);
sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
expect(sess.memberships[0].createdTs()).toBe(now + 100); expect(sess.memberships[0].createdTs()).toBe(now + 100);
}); });
@@ -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

View File

@@ -14,157 +14,145 @@ 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" }])(
let client: MatrixClient; "MatrixRTCSessionManager ($eventKind)",
({ eventKind }) => {
let client: MatrixClient;
beforeEach(async () => { function sendLeaveMembership(room: Room, membershipData: MembershipData[]): void {
client = new MatrixClient({ baseUrl: "base_url" }); if (eventKind === "memberState") {
await client.matrixRTC.start(); mockRoomState(room, [{ user_id: membershipTemplate.user_id }]);
}); const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
afterEach(() => { client.emit(RoomStateEvent.Events, membEvent, roomState, null);
client.stopClient(); } else {
client.matrixRTC.stop(); membershipData.splice(0, 1, { user_id: membershipTemplate.user_id });
}); client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000));
}
it("Fires event when session starts", async () => {
const onStarted = jest.fn();
const { promise, resolve } = Promise.withResolvers<void>();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, (...v) => {
onStarted(...v);
resolve();
});
try {
const room1 = makeMockRoom([sessionMembershipTemplate]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
await promise;
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
} }
});
it("Doesn't fire event if unrelated sessions starts", () => { beforeEach(() => {
const onStarted = jest.fn(); client = new MatrixClient({ baseUrl: "base_url" });
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); client.matrixRTC.start();
try {
const room1 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.other" }]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
expect(onStarted).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
}
});
it("Fires event when session ends", async () => {
const onEnded = jest.fn();
const { promise: endPromise, resolve: rEnd } = Promise.withResolvers();
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const { promise: startPromise, resolve: rStart } = Promise.withResolvers();
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, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1);
await startPromise;
mockRoomState(room1, [{ user_id: sessionMembershipTemplate.user_id }]);
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
await endPromise;
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
it("Fires correctly with for with custom sessionDescription", async () => {
const onStarted = jest.fn();
const onEnded = jest.fn();
// create a session manager with a custom session description
const sessionManager = new MatrixRTCSessionManager(logger, client, { id: "test", application: "m.notCall" });
// manually start the session manager (its not the default one started by the client)
await sessionManager.start();
const { promise: startPromise, resolve: rStart } = Promise.withResolvers<void>();
const { promise: endPromise, resolve: rEnd } = Promise.withResolvers<void>();
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, (v) => {
onEnded(v);
rEnd();
});
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, (v) => {
onStarted(v);
rStart();
}); });
try { afterEach(() => {
const room1 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.other" }]); client.stopClient();
client.matrixRTC.stop();
});
it("Fires event when session starts", () => {
const onStarted = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
}
});
it("Doesn't fire event if unrelated sessions starts", () => {
const onStarted = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }], eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
expect(onStarted).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
}
});
it("Fires event when session ends", () => {
const onEnded = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const membershipData: MembershipData[] = [membershipTemplate];
const room1 = makeMockRoom(membershipData, eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1]); jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1); client.emit(ClientEvent.Room, room1);
expect(onStarted).not.toHaveBeenCalled();
onStarted.mockClear();
const room2 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.notCall", call_id: "test" }]); sendLeaveMembership(room1, membershipData);
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
client.emit(ClientEvent.Room, room2); expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
await startPromise; });
expect(onStarted).toHaveBeenCalled();
onStarted.mockClear();
mockRoomState(room2, [{ user_id: sessionMembershipTemplate.user_id }]); it("Fires correctly with custom sessionDescription", () => {
jest.spyOn(client, "getRoom").mockReturnValue(room2); const onStarted = jest.fn();
const onEnded = jest.fn();
// create a session manager with a custom session description
const sessionManager = new MatrixRTCSessionManager(logger, client, {
id: "test",
application: "m.notCall",
});
const roomState = room2.getLiveTimeline().getState(EventTimeline.FORWARDS)!; // manually start the session manager (its not the default one started by the client)
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; sessionManager.start();
client.emit(RoomStateEvent.Events, membEvent, roomState, null); sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
await endPromise; sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
expect(onEnded).toHaveBeenCalled();
onEnded.mockClear();
mockRoomState(room1, [{ user_id: sessionMembershipTemplate.user_id }]); try {
// Create a session for applicaation m.other, we ignore this session ecause it lacks a call_id
const room1MembershipData: MembershipData[] = [{ ...membershipTemplate, application: "m.other" }];
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
expect(onStarted).not.toHaveBeenCalled();
onStarted.mockClear();
// Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id
const room2MembershipData: MembershipData[] = [
{ ...membershipTemplate, application: "m.notCall", call_id: "test" },
];
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
client.emit(ClientEvent.Room, room2);
expect(onStarted).toHaveBeenCalled();
onStarted.mockClear();
// Stop room1's RTC session. Tracked.
jest.spyOn(client, "getRoom").mockReturnValue(room2);
sendLeaveMembership(room2, room2MembershipData);
expect(onEnded).toHaveBeenCalled();
onEnded.mockClear();
// Stop room1's RTC session. Not tracked.
jest.spyOn(client, "getRoom").mockReturnValue(room1);
sendLeaveMembership(room1, room1MembershipData);
expect(onEnded).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
}
});
it("Doesn't fire event if unrelated sessions ends", () => {
const onEnded = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }];
const room1 = makeMockRoom(membership, eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1); jest.spyOn(client, "getRoom").mockReturnValue(room1);
const roomStateOther = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!; client.emit(ClientEvent.Room, room1);
const membEventOther = roomStateOther.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEventOther, roomStateOther, null);
expect(onEnded).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
}
});
it("Doesn't fire event if unrelated sessions ends", () => { sendLeaveMembership(room1, membership);
const onEnded = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const room1 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.other_app" }]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1); expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
mockRoomState(room1, [{ user_id: sessionMembershipTemplate.user_id }]); },
);
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
});

View File

@@ -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 () => {

View File

@@ -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 {

View 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");
});
});

View File

@@ -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 }], []);
});
});
}); });

View File

@@ -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);

View File

@@ -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.
} }
/** /**

View File

@@ -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 DelayedEventInfoItem = DelayedPartialEvent &
SendDelayedEventResponse &
SendDelayedEventRequestOpts & {
running_since: number;
};
export type DelayedEventInfo = { export type DelayedEventInfo = {
delayed_events: (DelayedPartialEvent & scheduled?: DelayedEventInfoItem[];
SendDelayedEventResponse & finalised?: {
SendDelayedEventRequestOpts & { delayed_event: DelayedEventInfoItem;
running_since: number; outcome: "send" | "cancel";
})[]; reason: "error" | "action" | "delay";
error?: MatrixError["data"];
event_id?: string;
origin_server_ts?: number;
}[];
next_batch?: string; next_batch?: string;
}; };

View File

@@ -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}`,
}); });

View File

@@ -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}
*/ */

View File

@@ -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 => {

View File

@@ -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}]`);
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); let callMemberEvents = [] as MatrixEvent[];
if (!roomState) { if (listenForStickyEvents) {
logger.warn("Couldn't get state for room " + room.roomId); // prefill with sticky events
throw new Error("Could't get state for room " + room.roomId); callMemberEvents = [...room._unstable_getStickyEvents()].filter(
(e) => e.getType() === EventType.RTCMembership,
);
}
if (listenForMemberStateEvents) {
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
if (!roomState) {
logger.warn("Couldn't get state for room " + room.roomId);
throw new Error("Could't get state for room " + room.roomId);
}
const callMemberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
callMemberEvents = callMemberEvents.concat(
callMemberStateEvents.filter(
(callMemberStateEvent) =>
!callMemberEvents.some(
// only care about state events which have keys which we have not yet seen in the sticky events.
(stickyEvent) =>
stickyEvent.getContent().msc4354_sticky_key === callMemberStateEvent.getStateKey(),
),
),
);
} }
const callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
const callMemberships: CallMembership[] = []; 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,
id: "", room: Room,
application: "m.call", opts?: SessionMembershipsForRoomOpts,
}); ): Promise<MatrixRTCSession> {
const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(
room,
client,
{
id: "",
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)

View File

@@ -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> {

View File

@@ -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) => {
this.room.roomId, return this.client.sendStateEvent(
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, this.room.roomId,
this.makeMyMembership(this.membershipEventExpiryMs), EventType.GroupCallMemberPrefix,
this.memberId, myMembership as EmptyObject | SessionMembershipData,
) 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,13 +726,9 @@ 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.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
this.room.roomId, )
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
this.memberId,
)
.then(() => { .then(() => {
// Success, we reset retries and schedule update. // Success, we reset retries and schedule update.
this.resetRateLimitCounter(MembershipActionType.UpdateExpiry); this.resetRateLimitCounter(MembershipActionType.UpdateExpiry);
@@ -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,46 +776,29 @@ 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 const focusObjects =
? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } } this.rtcTransport === undefined
: {}; ? {
return { focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const,
application: { foci_preferred: this.fociPreferred ?? [],
type: this.slotDescription.application, }
...(this.callIntent ? { "m.call.intent": this.callIntent } : {}), : {
}, focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const,
slot_id: slotDescriptionToId(this.slotDescription), foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])],
rtc_transports: this.rtcTransport ? [this.rtcTransport] : [], };
member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId }, return {
versions: [], "application": this.slotDescription.application,
...relationObject, "call_id": this.slotDescription.id,
[UNSTABLE_STICKY_KEY.name]: this.memberId, "scope": "m.room",
}; "device_id": this.deviceId,
} else { expires,
const focusObjects = "m.call.intent": this.callIntent,
this.rtcTransport === undefined ...focusObjects,
? { ...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined),
focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const, };
foci_preferred: this.fociPreferred ?? [],
}
: {
focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const,
foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])],
};
return {
"application": this.slotDescription.application,
"call_id": this.slotDescription.id,
"scope": "m.room",
"device_id": this.deviceId,
expires,
"m.call.intent": this.callIntent,
...focusObjects,
...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined),
};
}
} }
// Error checks and handlers // 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,
};
}
} }

View File

@@ -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.

View File

@@ -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 (stickyKey === undefined) {
if (prevEvent && event.unstableStickyExpiresAt < prevEvent.unstableStickyExpiresAt) { this.unkeyedStickyEvents.add(stickyEvent);
logger.info("ignored sticky event with older expiry time", stickyKey); // Recalculate the next expiry time.
return { added: false }; this.nextStickyEventExpiryTs = Math.min(event.unstableStickyExpiresAt, this.nextStickyEventExpiryTs);
} else if (
prevEvent && this.scheduleStickyTimer();
event.getTs() === prevEvent.getTs() && return { added: true };
(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);
} }
// 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. // Recalculate the next expiry time.
this.nextStickyEventExpiryTs = Math.min(event.unstableStickyExpiresAt, this.nextStickyEventExpiryTs); this.nextStickyEventExpiryTs = Math.min(stickyEvent.unstableStickyExpiresAt, this.nextStickyEventExpiryTs);
this.scheduleStickyTimer(); this.scheduleStickyTimer();
return { added: true, prevEvent }; 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.
*/ */

View File

@@ -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);
} }

View File

@@ -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;
} }
/** /**

1554
yarn.lock

File diff suppressed because it is too large Load Diff