You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-11-23 17:02:25 +03:00
Merge branch 'develop' into toger5/use-relation-based-CallMembership-create-ts
This commit is contained in:
2
.github/workflows/notify-downstream.yaml
vendored
2
.github/workflows/notify-downstream.yaml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
|
||||
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
|
||||
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
repository: ${{ matrix.repo }}
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
ref: staging
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: "yarn"
|
||||
|
||||
2
.github/workflows/release-gitflow.yml
vendored
2
.github/workflows/release-gitflow.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
sparse-checkout: |
|
||||
scripts/release
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
|
||||
2
.github/workflows/release-make.yml
vendored
2
.github/workflows/release-make.yml
vendored
@@ -125,7 +125,7 @@ jobs:
|
||||
git config --global user.email "releases@riot.im"
|
||||
git config --global user.name "RiotRobot"
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
|
||||
2
.github/workflows/release-npm.yml
vendored
2
.github/workflows/release-npm.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
ref: staging
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
ref: staging
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
|
||||
16
.github/workflows/static_analysis.yml
vendored
16
.github/workflows/static_analysis.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
- name: Build Types
|
||||
run: "yarn build:types"
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "npm"
|
||||
node-version-file: "examples/node/package.json"
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
with:
|
||||
repository: element-hq/element-web
|
||||
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- name: Setup Node
|
||||
id: setupNode
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
2
.github/workflows/triage-stale.yml
vendored
2
.github/workflows/triage-stale.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10
|
||||
with:
|
||||
operations-per-run: 250
|
||||
days-before-issue-stale: -1
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,3 +1,21 @@
|
||||
Changes in [39.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.0.0) (2025-10-21)
|
||||
==================================================================================================
|
||||
## 🚨 BREAKING CHANGES
|
||||
|
||||
* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5.
|
||||
* Implement Sticky Events MSC4354 ([#5028](https://github.com/matrix-org/matrix-js-sdk/pull/5028)). Contributed by @Half-Shot.
|
||||
* feat(client): allow disabling VoIP support ([#5021](https://github.com/matrix-org/matrix-js-sdk/pull/5021)). Contributed by @pkuzco.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Only use the first 3 viaServers specified ([#5034](https://github.com/matrix-org/matrix-js-sdk/pull/5034)). Contributed by @t3chguy.
|
||||
* Fetch the user's device info before processing a verification request ([#5030](https://github.com/matrix-org/matrix-js-sdk/pull/5030)). Contributed by @andybalaam.
|
||||
|
||||
|
||||
Changes in [38.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.4.0) (2025-10-07)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "38.4.0",
|
||||
"version": "39.0.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
@@ -97,9 +97,9 @@
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-import-resolver-typescript": "^4.0.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^28.0.0",
|
||||
"eslint-plugin-jsdoc": "^50.0.0",
|
||||
"eslint-plugin-matrix-org": "2.1.0",
|
||||
"eslint-plugin-jest": "^29.0.0",
|
||||
"eslint-plugin-jsdoc": "^61.0.0",
|
||||
"eslint-plugin-matrix-org": "^3.0.0",
|
||||
"eslint-plugin-n": "^14.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.4.0",
|
||||
"eslint-plugin-unicorn": "^56.0.0",
|
||||
|
||||
@@ -1053,17 +1053,28 @@ describe("MatrixClient", function () {
|
||||
);
|
||||
});
|
||||
|
||||
it("can look up delayed events", async () => {
|
||||
describe("lookups", () => {
|
||||
const statuses = [undefined, "scheduled" as const, "finalised" as const];
|
||||
const delayIds = [undefined, "dxyz", ["d123"], ["d456", "d789"]];
|
||||
const inputs = statuses.flatMap((status) =>
|
||||
delayIds.map((delayId) => [status, delayId] as [(typeof statuses)[0], (typeof delayIds)[0]]),
|
||||
);
|
||||
it.each(inputs)("can look up delayed events (status = %s, delayId = %s)", async (status, delayId) => {
|
||||
httpLookups = [
|
||||
{
|
||||
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 () => {
|
||||
|
||||
@@ -16,27 +16,30 @@ limitations under the License.
|
||||
|
||||
import {
|
||||
encodeBase64,
|
||||
type EventTimeline,
|
||||
EventType,
|
||||
MatrixClient,
|
||||
type MatrixError,
|
||||
MatrixEvent,
|
||||
RelationType,
|
||||
type MatrixError,
|
||||
type Room,
|
||||
} from "../../../src";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
||||
import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
|
||||
import { secureRandomString } from "../../../src/randomstring";
|
||||
import {
|
||||
makeMockEvent,
|
||||
makeMockRoom,
|
||||
makeKey,
|
||||
type MembershipData,
|
||||
mockRoomState,
|
||||
rtcMembershipTemplate,
|
||||
mockRTCEvent,
|
||||
sessionMembershipTemplate,
|
||||
rtcMembershipTemplate,
|
||||
} from "./mocks";
|
||||
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
|
||||
import { RoomStickyEventsEvent, type StickyMatrixEvent } from "../../../src/models/room-sticky-events.ts";
|
||||
import { StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
|
||||
|
||||
const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" };
|
||||
|
||||
@@ -66,11 +69,63 @@ describe("MatrixRTCSession", () => {
|
||||
sess = undefined;
|
||||
});
|
||||
|
||||
describe("roomSessionForRoom", () => {
|
||||
it("creates a room-scoped session from room state", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
describe.each([
|
||||
{
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
testCreateSticky: false,
|
||||
createWithDefaults: true, // Create MatrixRTCSession with defaults
|
||||
},
|
||||
{
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
testCreateSticky: false,
|
||||
},
|
||||
{
|
||||
listenForStickyEvents: false,
|
||||
listenForMemberStateEvents: true,
|
||||
testCreateSticky: false,
|
||||
},
|
||||
{
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
testCreateSticky: true,
|
||||
},
|
||||
{
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: false,
|
||||
testCreateSticky: true,
|
||||
},
|
||||
])(
|
||||
"roomSessionForRoom listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky",
|
||||
(testConfig) => {
|
||||
it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
|
||||
mockRoom.getLiveTimeline.mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue(undefined),
|
||||
} as unknown as EventTimeline);
|
||||
if (testConfig.listenForMemberStateEvents) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(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");
|
||||
@@ -84,8 +139,13 @@ describe("MatrixRTCSession", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate, {
|
||||
application: "not-m.call",
|
||||
});
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky);
|
||||
const sess = await MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -94,8 +154,13 @@ describe("MatrixRTCSession", () => {
|
||||
call_id: "not-empty",
|
||||
scope: "m.room",
|
||||
});
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky);
|
||||
const sess = await MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -104,19 +169,42 @@ describe("MatrixRTCSession", () => {
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.expires = 1000;
|
||||
expiredMembership.device_id = "EXPIRED";
|
||||
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]);
|
||||
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], testConfig.testCreateSticky);
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess = await MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("ignores memberships events of members not in the room", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
mockRoom.hasMembershipState = (state) => state === KnownMembership.Join;
|
||||
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -126,20 +214,29 @@ describe("MatrixRTCSession", () => {
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.created_ts = 500;
|
||||
expiredMembership.expires = 1000;
|
||||
const mockRoom = makeMockRoom([expiredMembership]);
|
||||
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
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([]);
|
||||
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
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 roomId = secureRandomString(8);
|
||||
const event = {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
@@ -147,10 +244,8 @@ describe("MatrixRTCSession", () => {
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
};
|
||||
const mockRoom = {
|
||||
...makeMockRoom([]),
|
||||
roomId,
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
const mockRoom = makeMockRoom([]);
|
||||
mockRoom.getLiveTimeline.mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
@@ -167,14 +262,17 @@ describe("MatrixRTCSession", () => {
|
||||
],
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession);
|
||||
} 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 roomId = secureRandomString(8);
|
||||
const event = {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }),
|
||||
@@ -182,10 +280,8 @@ describe("MatrixRTCSession", () => {
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
};
|
||||
const mockRoom = {
|
||||
...makeMockRoom([]),
|
||||
roomId,
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
const mockRoom = makeMockRoom([]);
|
||||
mockRoom.getLiveTimeline.mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
@@ -202,9 +298,13 @@ describe("MatrixRTCSession", () => {
|
||||
],
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession);
|
||||
} as unknown as EventTimeline);
|
||||
sess = await MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -212,7 +312,12 @@ describe("MatrixRTCSession", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.device_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
const sess = await MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -220,9 +325,107 @@ describe("MatrixRTCSession", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.call_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||
sess = await MatrixRTCSession.sessionForSlot(
|
||||
client,
|
||||
mockRoom,
|
||||
callSession,
|
||||
testConfig.createWithDefaults ? undefined : testConfig,
|
||||
);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("roomSessionForRoom combined state", () => {
|
||||
it("perfers sticky events when both membership and sticky events appear for the same user", async () => {
|
||||
// Create a room with identical member state and sticky state for the same user.
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
|
||||
const ev = mockRTCEvent(
|
||||
{
|
||||
...membershipTemplate,
|
||||
msc4354_sticky_key: `_${membershipTemplate.user_id}_${membershipTemplate.device_id}`,
|
||||
},
|
||||
mockRoom.roomId,
|
||||
);
|
||||
return [ev as StickyMatrixEvent];
|
||||
});
|
||||
|
||||
// Expect for there to be one membership as the state has been merged down.
|
||||
sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
});
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].slotDescription.id).toEqual("");
|
||||
expect(sess?.memberships[0].scope).toEqual("m.room");
|
||||
expect(sess?.memberships[0].application).toEqual("m.call");
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
expect(sess?.memberships[0].isExpired()).toEqual(false);
|
||||
expect(sess?.slotDescription.id).toEqual("");
|
||||
});
|
||||
it("combines sticky and membership events when both exist", async () => {
|
||||
// Create a room with identical member state and sticky state for the same user.
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
const stickyUserId = "@stickyev:user.example";
|
||||
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
|
||||
const ev = mockRTCEvent(
|
||||
{
|
||||
...membershipTemplate,
|
||||
user_id: stickyUserId,
|
||||
msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`,
|
||||
},
|
||||
mockRoom.roomId,
|
||||
15000,
|
||||
Date.now() - 1000, // Sticky event comes first.
|
||||
);
|
||||
return [ev as StickyMatrixEvent];
|
||||
});
|
||||
|
||||
sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
});
|
||||
|
||||
const memberships = sess.memberships;
|
||||
expect(memberships.length).toEqual(2);
|
||||
expect(memberships[0].sender).toEqual(stickyUserId);
|
||||
expect(memberships[0].slotDescription.id).toEqual("");
|
||||
expect(memberships[0].scope).toEqual("m.room");
|
||||
expect(memberships[0].application).toEqual("m.call");
|
||||
expect(memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
expect(memberships[0].isExpired()).toEqual(false);
|
||||
|
||||
// Then state
|
||||
expect(memberships[1].sender).toEqual(membershipTemplate.user_id);
|
||||
|
||||
expect(sess?.slotDescription.id).toEqual("");
|
||||
});
|
||||
it("handles an incoming sticky event to an existing session", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
const stickyUserId = "@stickyev:user.example";
|
||||
|
||||
sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
});
|
||||
expect(sess.memberships.length).toEqual(1);
|
||||
const stickyEv = mockRTCEvent(
|
||||
{
|
||||
...membershipTemplate,
|
||||
user_id: stickyUserId,
|
||||
msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`,
|
||||
},
|
||||
mockRoom.roomId,
|
||||
15000,
|
||||
Date.now() - 1000, // Sticky event comes first.
|
||||
) as StickyMatrixEvent;
|
||||
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
|
||||
return [stickyEv];
|
||||
});
|
||||
mockRoom.emit(RoomStickyEventsEvent.Update, [stickyEv], [], []);
|
||||
expect(sess.memberships.length).toEqual(2);
|
||||
});
|
||||
it("fetches related events if needed from room", async () => {
|
||||
const testMembership = {
|
||||
...rtcMembershipTemplate,
|
||||
@@ -233,9 +436,7 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const now = Date.now();
|
||||
mockRoom.findEventById = jest
|
||||
.fn()
|
||||
.mockImplementation((id) =>
|
||||
mockRoom.findEventById.mockImplementation((id) =>
|
||||
id === "id"
|
||||
? new MatrixEvent({ content: { ...rtcMembershipTemplate }, origin_server_ts: now + 100 })
|
||||
: undefined,
|
||||
@@ -254,7 +455,7 @@ describe("MatrixRTCSession", () => {
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const now = Date.now();
|
||||
|
||||
mockRoom.findEventById = jest.fn().mockReturnValue(undefined);
|
||||
mockRoom.findEventById.mockReturnValue(undefined);
|
||||
client.fetchRoomEvent = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ content: { ...rtcMembershipTemplate }, origin_server_ts: now + 100 });
|
||||
@@ -391,6 +592,12 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess!.isJoined()).toEqual(true);
|
||||
});
|
||||
|
||||
it("uses the sticky events membership manager implementation", () => {
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { unstableSendStickyEvents: true });
|
||||
expect(sess!.isJoined()).toEqual(true);
|
||||
expect(sess!["membershipManager"] instanceof StickyEventMembershipManager).toEqual(true);
|
||||
});
|
||||
|
||||
it("sends a notification when starting a call and emit DidSendCallNotification", async () => {
|
||||
// Simulate a join, including the update to the room state
|
||||
// Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them
|
||||
|
||||
@@ -14,18 +14,32 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
|
||||
import { ClientEvent, EventTimeline, MatrixClient, type Room } from "../../../src";
|
||||
import { RoomStateEvent } from "../../../src/models/room-state";
|
||||
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { makeMockRoom, sessionMembershipTemplate, mockRoomState } from "./mocks";
|
||||
import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks";
|
||||
import { logger } from "../../../src/logger";
|
||||
|
||||
describe("MatrixRTCSessionManager", () => {
|
||||
describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
"MatrixRTCSessionManager ($eventKind)",
|
||||
({ eventKind }) => {
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
function sendLeaveMembership(room: Room, membershipData: MembershipData[]): void {
|
||||
if (eventKind === "memberState") {
|
||||
mockRoomState(room, [{ user_id: membershipTemplate.user_id }]);
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
} else {
|
||||
membershipData.splice(0, 1, { user_id: membershipTemplate.user_id });
|
||||
client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000));
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
await client.matrixRTC.start();
|
||||
client.matrixRTC.start();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -33,20 +47,15 @@ describe("MatrixRTCSessionManager", () => {
|
||||
client.matrixRTC.stop();
|
||||
});
|
||||
|
||||
it("Fires event when session starts", async () => {
|
||||
it("Fires event when session starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, (...v) => {
|
||||
onStarted(...v);
|
||||
resolve();
|
||||
});
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([sessionMembershipTemplate]);
|
||||
const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky");
|
||||
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);
|
||||
@@ -58,7 +67,7 @@ describe("MatrixRTCSessionManager", () => {
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.other" }]);
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }], eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
@@ -68,81 +77,62 @@ describe("MatrixRTCSessionManager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("Fires event when session ends", async () => {
|
||||
it("Fires event when session ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
const { 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]);
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const membershipData: MembershipData[] = [membershipTemplate];
|
||||
const room1 = makeMockRoom(membershipData, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
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;
|
||||
sendLeaveMembership(room1, membershipData);
|
||||
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
|
||||
it("Fires correctly with for with custom sessionDescription", async () => {
|
||||
it("Fires correctly with custom sessionDescription", () => {
|
||||
const onStarted = jest.fn();
|
||||
const onEnded = jest.fn();
|
||||
// create a session manager with a custom session description
|
||||
const sessionManager = new MatrixRTCSessionManager(logger, client, { id: "test", application: "m.notCall" });
|
||||
const sessionManager = new MatrixRTCSessionManager(logger, client, {
|
||||
id: "test",
|
||||
application: "m.notCall",
|
||||
});
|
||||
|
||||
// manually start the session manager (its not the default one started by the client)
|
||||
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();
|
||||
});
|
||||
sessionManager.start();
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.other" }]);
|
||||
// Create a session for applicaation m.other, we ignore this session ecause it lacks a call_id
|
||||
const room1MembershipData: MembershipData[] = [{ ...membershipTemplate, application: "m.other" }];
|
||||
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
const room2 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.notCall", call_id: "test" }]);
|
||||
// Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id
|
||||
const room2MembershipData: MembershipData[] = [
|
||||
{ ...membershipTemplate, application: "m.notCall", call_id: "test" },
|
||||
];
|
||||
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
|
||||
|
||||
client.emit(ClientEvent.Room, room2);
|
||||
await startPromise;
|
||||
expect(onStarted).toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
mockRoomState(room2, [{ user_id: sessionMembershipTemplate.user_id }]);
|
||||
// Stop room1's RTC session. Tracked.
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room2);
|
||||
|
||||
const roomState = room2.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
await endPromise;
|
||||
sendLeaveMembership(room2, room2MembershipData);
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
onEnded.mockClear();
|
||||
|
||||
mockRoomState(room1, [{ user_id: sessionMembershipTemplate.user_id }]);
|
||||
// Stop room1's RTC session. Not tracked.
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
const roomStateOther = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEventOther = roomStateOther.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEventOther, roomStateOther, null);
|
||||
sendLeaveMembership(room1, room1MembershipData);
|
||||
expect(onEnded).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
@@ -153,18 +143,16 @@ describe("MatrixRTCSessionManager", () => {
|
||||
it("Doesn't fire event if unrelated sessions ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const room1 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.other_app" }]);
|
||||
const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }];
|
||||
const room1 = makeMockRoom(membership, eventKind === "sticky");
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, 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);
|
||||
sendLeaveMembership(room1, membership);
|
||||
|
||||
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
MatrixError,
|
||||
UnsupportedDelayedEventsEndpointError,
|
||||
type Room,
|
||||
MAX_STICKY_DURATION_MS,
|
||||
} from "../../../src";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
@@ -94,7 +95,9 @@ describe("MembershipManager", () => {
|
||||
// Provide a default mock that is like the default "non error" server behaviour.
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
(client.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(() => {
|
||||
@@ -152,45 +155,6 @@ describe("MembershipManager", () => {
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends a rtc membership event when using `useRtcMemberFormat`", async () => {
|
||||
// Spys/Mocks
|
||||
|
||||
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
|
||||
|
||||
// Test
|
||||
const memberManager = new MembershipManager({ useRtcMemberFormat: true }, room, client, callSession);
|
||||
memberManager.join([], focus);
|
||||
// expects
|
||||
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
|
||||
// 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 () => {
|
||||
const memberManager = new MembershipManager(undefined, room, client, callSession);
|
||||
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
||||
@@ -927,6 +891,63 @@ describe("MembershipManager", () => {
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StickyEventMembershipManager", () => {
|
||||
beforeEach(() => {
|
||||
// Provide a default mock that is like the default "non error" server behaviour.
|
||||
(client._unstable_sendStickyDelayedEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
|
||||
(client._unstable_sendStickyEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("join()", () => {
|
||||
describe("sends an rtc membership event", () => {
|
||||
it("sends a membership event and schedules delayed leave when joining a call", async () => {
|
||||
const 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 () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "stream";
|
||||
import { type Mocked } from "jest-mock";
|
||||
|
||||
import { EventType, MatrixEvent, type Room, RoomEvent, type MatrixClient } from "../../../src";
|
||||
import {
|
||||
@@ -65,6 +66,8 @@ export type MockClient = Pick<
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "_unstable_sendStickyEvent"
|
||||
| "_unstable_sendStickyDelayedEvent"
|
||||
| "cancelPendingEvent"
|
||||
>;
|
||||
/**
|
||||
@@ -79,15 +82,19 @@ export function makeMockClient(userId: string, deviceId: string): MockClient {
|
||||
cancelPendingEvent: jest.fn(),
|
||||
_unstable_updateDelayedEvent: jest.fn(),
|
||||
_unstable_sendDelayedStateEvent: jest.fn(),
|
||||
_unstable_sendStickyEvent: jest.fn(),
|
||||
_unstable_sendStickyDelayedEvent: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
export function makeMockRoom(
|
||||
membershipData: MembershipData[],
|
||||
): Room & { emitTimelineEvent: (event: MatrixEvent) => void } {
|
||||
useStickyEvents = false,
|
||||
): Mocked<Room & { emitTimelineEvent: (event: MatrixEvent) => void }> {
|
||||
const roomId = secureRandomString(8);
|
||||
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
|
||||
const roomState = makeMockRoomState(membershipData, roomId);
|
||||
const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId);
|
||||
const ts = Date.now();
|
||||
const room = Object.assign(new EventEmitter(), {
|
||||
roomId: roomId,
|
||||
hasMembershipState: jest.fn().mockReturnValue(true),
|
||||
@@ -95,11 +102,17 @@ export function makeMockRoom(
|
||||
getState: jest.fn().mockReturnValue(roomState),
|
||||
}),
|
||||
getVersion: jest.fn().mockReturnValue("default"),
|
||||
}) as unknown as Room;
|
||||
_unstable_getStickyEvents: jest
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, 10000, ts)) : [],
|
||||
) as any,
|
||||
findEventById: jest.fn(),
|
||||
});
|
||||
return Object.assign(room, {
|
||||
emitTimelineEvent: (event: MatrixEvent) =>
|
||||
room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any),
|
||||
});
|
||||
}) as unknown as Mocked<Room & { emitTimelineEvent: (event: MatrixEvent) => void }>;
|
||||
}
|
||||
|
||||
function makeMockRoomState(membershipData: MembershipData[], roomId: string) {
|
||||
@@ -143,6 +156,7 @@ export function makeMockEvent(
|
||||
roomId: string | undefined,
|
||||
content: any,
|
||||
timestamp?: number,
|
||||
stateKey?: string,
|
||||
): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
event_id: secureRandomString(8),
|
||||
@@ -151,11 +165,27 @@ export function makeMockEvent(
|
||||
content,
|
||||
room_id: roomId,
|
||||
origin_server_ts: timestamp ?? 0,
|
||||
state_key: stateKey,
|
||||
});
|
||||
}
|
||||
|
||||
export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipData, roomId: string): MatrixEvent {
|
||||
return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData, Date.now());
|
||||
export function mockRTCEvent(
|
||||
{ user_id: sender, ...membershipData }: MembershipData,
|
||||
roomId: string,
|
||||
stickyDuration?: number,
|
||||
timestamp?: number,
|
||||
): MatrixEvent {
|
||||
return {
|
||||
...makeMockEvent(
|
||||
stickyDuration !== undefined ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||
sender,
|
||||
roomId,
|
||||
membershipData,
|
||||
timestamp,
|
||||
!stickyDuration && "device_id" in membershipData ? `_${sender}_${membershipData.device_id}` : "",
|
||||
),
|
||||
unstableStickyExpiresAt: stickyDuration,
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
export function mockCallMembership(membershipData: MembershipData, roomId: string): CallMembership {
|
||||
|
||||
135
spec/unit/matrixrtc/types.spec.ts
Normal file
135
spec/unit/matrixrtc/types.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type CallMembership } from "../../../src/matrixrtc";
|
||||
import { isMyMembership, parseCallNotificationContent } from "../../../src/matrixrtc/types";
|
||||
|
||||
describe("types", () => {
|
||||
describe("isMyMembership", () => {
|
||||
it("returns false if userId is different", () => {
|
||||
expect(
|
||||
isMyMembership(
|
||||
{ sender: "@alice:example.org", deviceId: "DEVICE" } as CallMembership,
|
||||
"@bob:example.org",
|
||||
"DEVICE",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("returns true if userId and device is the same", () => {
|
||||
expect(
|
||||
isMyMembership(
|
||||
{ sender: "@alice:example.org", deviceId: "DEVICE" } as CallMembership,
|
||||
"@alice:example.org",
|
||||
"DEVICE",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("IRTCNotificationContent", () => {
|
||||
const validBase = Object.freeze({
|
||||
"m.mentions": { user_ids: [], room: true },
|
||||
"notification_type": "notification",
|
||||
"sender_ts": 123,
|
||||
"lifetime": 1000,
|
||||
});
|
||||
|
||||
it("parses valid content", () => {
|
||||
const res = parseCallNotificationContent({ ...validBase });
|
||||
expect(res).toMatchObject(validBase);
|
||||
});
|
||||
|
||||
it("caps lifetime to 90000ms", () => {
|
||||
const res = parseCallNotificationContent({ ...validBase, lifetime: 130000 });
|
||||
expect(res.lifetime).toBe(90000);
|
||||
});
|
||||
|
||||
it("throws on malformed m.mentions", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
"m.mentions": "not an object",
|
||||
} as any),
|
||||
).toThrow("malformed m.mentions");
|
||||
});
|
||||
|
||||
it("throws on missing or invalid notification_type", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
notification_type: undefined,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid notification_type");
|
||||
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
notification_type: 123 as any,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid notification_type");
|
||||
});
|
||||
|
||||
it("throws on missing or invalid sender_ts", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
sender_ts: undefined,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid sender_ts");
|
||||
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
sender_ts: "123" as any,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid sender_ts");
|
||||
});
|
||||
|
||||
it("throws on missing or invalid lifetime", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
lifetime: undefined,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid lifetime");
|
||||
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
lifetime: "1000" as any,
|
||||
} as any),
|
||||
).toThrow("Missing or invalid lifetime");
|
||||
});
|
||||
|
||||
it("accepts valid relation (m.reference)", () => {
|
||||
// Note: parseCallNotificationContent currently checks `relation.rel_type` rather than `m.relates_to`.
|
||||
const res = parseCallNotificationContent({
|
||||
...validBase,
|
||||
relation: { rel_type: "m.reference", event_id: "$ev" },
|
||||
} as any);
|
||||
expect(res).toBeTruthy();
|
||||
});
|
||||
|
||||
it("throws on invalid relation rel_type", () => {
|
||||
expect(() =>
|
||||
parseCallNotificationContent({
|
||||
...validBase,
|
||||
relation: { rel_type: "m.annotation", event_id: "$ev" },
|
||||
} as any),
|
||||
).toThrow("Invalid relation");
|
||||
});
|
||||
});
|
||||
@@ -259,4 +259,164 @@ describe("RoomStickyEvents", () => {
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleRedaction", () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
it("should not emit if the event does not exist in the map", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
content: {},
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
stickyEvents.handleRedaction("$123456");
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should emit a remove when the event exists in the map without a predecessor", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
stickyEvents.handleRedaction(stickyEvent.event_id);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
|
||||
});
|
||||
it("should emit a remove when the event has no sticky key", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
content: {},
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
stickyEvents.handleRedaction(stickyEvent.event_id);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
|
||||
});
|
||||
it("should emit an update when the event exists in the map with a predecessor", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
|
||||
const newerEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newer-ev",
|
||||
origin_server_ts: Date.now() + 1000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, newerEv]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
stickyEvents.handleRedaction(newerEv.getId()!);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newerEv }], []);
|
||||
});
|
||||
it("should emit a remove if the previous event has expired", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
|
||||
const newerEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newer-ev",
|
||||
origin_server_ts: Date.now() + 1000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, newerEv]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
// Expire the older event.
|
||||
jest.advanceTimersByTime(stickyEvent.msc4354_sticky.duration_ms);
|
||||
// Redact the newer event
|
||||
stickyEvents.handleRedaction(newerEv.getId()!);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [], [newerEv]);
|
||||
});
|
||||
it("should recurse the chain of events if the previous event has been redacted", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
|
||||
const middleEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newer-ev",
|
||||
origin_server_ts: Date.now() + 1000,
|
||||
});
|
||||
jest.advanceTimersByTime(1000);
|
||||
const newestEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newest-ev",
|
||||
origin_server_ts: Date.now() + 2000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, middleEv, newestEv]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
// Mark the middle event as redacted.
|
||||
middleEv.setUnsigned({
|
||||
redacted_because: {
|
||||
event_id: "$foo",
|
||||
} as any,
|
||||
});
|
||||
// Redact the newer event
|
||||
stickyEvents.handleRedaction(newestEv.getId()!);
|
||||
// expect immediate transition from newestEv -> ev and skipping middleEv
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newestEv }], []);
|
||||
});
|
||||
it("should revert to the most recent valid event regardless of insertion order", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
|
||||
const middleEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newer-ev",
|
||||
origin_server_ts: Date.now() + 1000,
|
||||
});
|
||||
jest.advanceTimersByTime(1000);
|
||||
const newestEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newest-ev",
|
||||
origin_server_ts: Date.now() + 2000,
|
||||
});
|
||||
// Invert in reverse order, to make sure we retain the older events.
|
||||
stickyEvents.addStickyEvents([newestEv, middleEv, ev]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
// Mark the middle event as redacted.
|
||||
middleEv.setUnsigned({
|
||||
redacted_because: {
|
||||
event_id: "$foo",
|
||||
} as any,
|
||||
});
|
||||
// Redact the newer event
|
||||
stickyEvents.handleRedaction(newestEv.getId()!);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newestEv }], []);
|
||||
});
|
||||
it("should handle redaction when using `handleRedaction` with a `MatrixEvent` parameter", () => {
|
||||
const emitSpy = jest.fn();
|
||||
const ev = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
origin_server_ts: Date.now(),
|
||||
});
|
||||
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
|
||||
const newerEv = new MatrixEvent({
|
||||
...stickyEvent,
|
||||
event_id: "$newer-ev",
|
||||
origin_server_ts: Date.now() + 1000,
|
||||
});
|
||||
stickyEvents.addStickyEvents([ev, newerEv]);
|
||||
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
|
||||
stickyEvents.handleRedaction(newerEv);
|
||||
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newerEv }], []);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -854,9 +854,27 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("getSecretStorageStatus", async () => {
|
||||
const mockSecretStorage = {
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("blah"),
|
||||
isStored: jest.fn().mockResolvedValue({ blah: {} }),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage);
|
||||
await expect(rustCrypto.getSecretStorageStatus()).resolves.toEqual({
|
||||
defaultKeyId: "blah",
|
||||
ready: true,
|
||||
secretStorageKeyValidityMap: {
|
||||
"m.cross_signing.master": true,
|
||||
"m.cross_signing.self_signing": true,
|
||||
"m.cross_signing.user_signing": true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("isSecretStorageReady", async () => {
|
||||
const mockSecretStorage = {
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue(null),
|
||||
isStored: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage);
|
||||
await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false);
|
||||
|
||||
@@ -338,6 +338,7 @@ export interface TimelineEvents {
|
||||
[M_BEACON.name]: MBeaconEventContent;
|
||||
[M_POLL_START.name]: PollStartEventContent;
|
||||
[M_POLL_END.name]: PollEndEventContent;
|
||||
[EventType.RTCMembership]: RtcMembershipData | { msc4354_sticky_key: string }; // An object containing just the sticky key is empty.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ import { type IEventWithRoomId, type SearchKey } from "./search.ts";
|
||||
import { type IRoomEventFilter } from "../filter.ts";
|
||||
import { type Direction } from "../models/event-timeline.ts";
|
||||
import { type PushRuleAction } from "./PushRules.ts";
|
||||
import { type MatrixError } from "../matrix.ts";
|
||||
import { type IRoomEvent } from "../sync-accumulator.ts";
|
||||
import { type EventType, type RelationType, type RoomType } from "./event.ts";
|
||||
|
||||
@@ -136,12 +137,22 @@ type DelayedPartialStateEvent = DelayedPartialTimelineEvent & {
|
||||
|
||||
type DelayedPartialEvent = DelayedPartialTimelineEvent | DelayedPartialStateEvent;
|
||||
|
||||
export type DelayedEventInfo = {
|
||||
delayed_events: (DelayedPartialEvent &
|
||||
export type DelayedEventInfoItem = DelayedPartialEvent &
|
||||
SendDelayedEventResponse &
|
||||
SendDelayedEventRequestOpts & {
|
||||
running_since: number;
|
||||
})[];
|
||||
};
|
||||
|
||||
export type DelayedEventInfo = {
|
||||
scheduled?: DelayedEventInfoItem[];
|
||||
finalised?: {
|
||||
delayed_event: DelayedEventInfoItem;
|
||||
outcome: "send" | "cancel";
|
||||
reason: "error" | "action" | "delay";
|
||||
error?: MatrixError["data"];
|
||||
event_id?: string;
|
||||
origin_server_ts?: number;
|
||||
}[];
|
||||
next_batch?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -3537,13 +3537,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending delayed events for the calling user.
|
||||
* Get information about delayed events owned by the requesting user.
|
||||
*
|
||||
* Note: This endpoint is unstable, and can throw an `Error`.
|
||||
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
public async _unstable_getDelayedEvents(fromToken?: string): Promise<DelayedEventInfo> {
|
||||
public async _unstable_getDelayedEvents(
|
||||
status?: "scheduled" | "finalised",
|
||||
delayId?: string | string[],
|
||||
fromToken?: string,
|
||||
): Promise<DelayedEventInfo> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
@@ -3551,7 +3555,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
);
|
||||
}
|
||||
|
||||
const queryDict = fromToken ? { from: fromToken } : undefined;
|
||||
const queryDict = {
|
||||
from: fromToken,
|
||||
status,
|
||||
delay_id: delayId,
|
||||
};
|
||||
return await this.http.authedRequest(Method.Get, "/delayed_events", queryDict, undefined, {
|
||||
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { ToDeviceBatch, ToDevicePayload } from "../models/ToDeviceMessage.t
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type DeviceMap } from "../models/device.ts";
|
||||
import { type UIAuthCallback } from "../interactive-auth.ts";
|
||||
import { type PassphraseInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
|
||||
import { type PassphraseInfo, type SecretStorageKey, type SecretStorageKeyDescription } from "../secret-storage.ts";
|
||||
import { type VerificationRequest } from "./verification.ts";
|
||||
import {
|
||||
type BackupTrustInfo,
|
||||
@@ -369,6 +369,11 @@ export interface CryptoApi {
|
||||
*/
|
||||
isSecretStorageReady(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Inspect the status of secret storage, in more detail than {@link isSecretStorageReady}.
|
||||
*/
|
||||
getSecretStorageStatus(): Promise<SecretStorageStatus>;
|
||||
|
||||
/**
|
||||
* Bootstrap [secret storage](https://spec.matrix.org/v1.12/client-server-api/#storage).
|
||||
*
|
||||
@@ -1148,6 +1153,30 @@ export interface CryptoCallbacks {
|
||||
cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of a call to {@link CryptoApi.getSecretStorageStatus}.
|
||||
*/
|
||||
export interface SecretStorageStatus {
|
||||
/** Whether secret storage is fully populated. The same as {@link CryptoApi.isSecretStorageReady}. */
|
||||
ready: boolean;
|
||||
|
||||
/** The ID of the current default secret storage key. */
|
||||
defaultKeyId: string | null;
|
||||
|
||||
/**
|
||||
* For each secret that we checked whether it is correctly stored in secret storage with the default secret storage key.
|
||||
*
|
||||
* Note that we will only check that the key backup key is stored if key backup is currently enabled (i.e. that
|
||||
* {@link CryptoApi.getActiveSessionBackupVersion} returns non-null). `m.megolm_backup.v1` will only be present in that case.
|
||||
*
|
||||
* (This is an object rather than a `Map` so that it JSON.stringify()s nicely, since its main purpose is to end up
|
||||
* in logs.)
|
||||
*/
|
||||
secretStorageKeyValidityMap: {
|
||||
[P in SecretStorageKey]?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter of {@link CryptoApi#bootstrapSecretStorage}
|
||||
*/
|
||||
|
||||
@@ -194,6 +194,10 @@ export type SessionMembershipData = {
|
||||
* something else.
|
||||
*/
|
||||
"m.call.intent"?: RTCCallIntent;
|
||||
/**
|
||||
* The sticky key in case of a sticky event. This string encodes the application + device_id indicating the used slot + device.
|
||||
*/
|
||||
"msc4354_sticky_key"?: string;
|
||||
};
|
||||
|
||||
const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => {
|
||||
|
||||
@@ -17,7 +17,6 @@ limitations under the License.
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { EventTimeline } from "../models/event-timeline.ts";
|
||||
import { MatrixEvent } from "../models/event.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type MatrixClient } from "../client.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 { CallMembership } from "./CallMembership.ts";
|
||||
import { RoomStateEvent } from "../models/room-state.ts";
|
||||
import { MembershipManager } from "./MembershipManager.ts";
|
||||
import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { deepCompare, logDurationSync } from "../utils.ts";
|
||||
import type {
|
||||
@@ -51,6 +50,8 @@ import {
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
import { TypedReEmitter } from "../ReEmitter.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
|
||||
@@ -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:
|
||||
// `time`, `duration`, `delay`, `timeout`... that might be mistaken/confused with technical terms.
|
||||
export interface MembershipConfig {
|
||||
/**
|
||||
* Use the new Manager.
|
||||
*
|
||||
* Default: `false`.
|
||||
* @deprecated does nothing anymore we always default to the new membership manager.
|
||||
*/
|
||||
useNewMembershipManager?: boolean;
|
||||
|
||||
/**
|
||||
* The timeout (in milliseconds) after we joined the call, that our membership should expire
|
||||
* unless we have explicitly updated it.
|
||||
@@ -193,7 +186,14 @@ export interface MembershipConfig {
|
||||
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
|
||||
*/
|
||||
delayedLeaveEventRestartLocalTimeoutMs?: number;
|
||||
useRtcMemberFormat?: boolean;
|
||||
|
||||
/**
|
||||
* Send membership using sticky events rather than state events.
|
||||
* This also make the client use the new m.rtc.member MSC4354 event format. (instead of m.call.member)
|
||||
*
|
||||
* **WARNING**: This is an unstable feature and not all clients will support it.
|
||||
*/
|
||||
unstableSendStickyEvents?: boolean;
|
||||
}
|
||||
|
||||
export interface EncryptionConfig {
|
||||
@@ -239,6 +239,19 @@ export interface EncryptionConfig {
|
||||
}
|
||||
export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig;
|
||||
|
||||
interface SessionMembershipsForRoomOpts {
|
||||
/**
|
||||
* Listen for incoming sticky member events. If disabled, this session will
|
||||
* ignore any incoming sticky events.
|
||||
*/
|
||||
listenForStickyEvents: boolean;
|
||||
/**
|
||||
* Listen for incoming member state events (legacy). If disabled, this session will
|
||||
* ignore any incoming state events.
|
||||
*/
|
||||
listenForMemberStateEvents: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
|
||||
* This class doesn't deal with media at all, just membership & properties of a session.
|
||||
@@ -308,7 +321,10 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead.
|
||||
*/
|
||||
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">,
|
||||
): Promise<CallMembership[]> {
|
||||
return await MatrixRTCSession.sessionMembershipsForSlot(room, client, {
|
||||
@@ -321,7 +337,10 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead.
|
||||
*/
|
||||
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">,
|
||||
sessionDescription: SlotDescription,
|
||||
): Promise<CallMembership[]> {
|
||||
@@ -331,23 +350,61 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
/**
|
||||
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
||||
* oldest first.
|
||||
*
|
||||
* By default, this will return *both* sticky and member state events.
|
||||
*/
|
||||
public static async sessionMembershipsForSlot(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "findEventById">,
|
||||
room: Pick<
|
||||
Room,
|
||||
"getLiveTimeline" | "roomId" | "hasMembershipState" | "findEventById" | "_unstable_getStickyEvents"
|
||||
>,
|
||||
client: Pick<MatrixClient, "fetchRoomEvent">,
|
||||
slotDescription: SlotDescription,
|
||||
existingMemberships?: CallMembership[],
|
||||
{ listenForStickyEvents, listenForMemberStateEvents }: SessionMembershipsForRoomOpts = {
|
||||
listenForStickyEvents: true,
|
||||
listenForMemberStateEvents: true,
|
||||
},
|
||||
): Promise<CallMembership[]> {
|
||||
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
|
||||
let callMemberEvents = [] as MatrixEvent[];
|
||||
if (listenForStickyEvents) {
|
||||
// prefill with sticky events
|
||||
callMemberEvents = [...room._unstable_getStickyEvents()].filter(
|
||||
(e) => e.getType() === EventType.RTCMembership,
|
||||
);
|
||||
}
|
||||
if (listenForMemberStateEvents) {
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!roomState) {
|
||||
logger.warn("Couldn't get state for room " + room.roomId);
|
||||
throw new Error("Could't get state for room " + room.roomId);
|
||||
}
|
||||
const callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
|
||||
const callMemberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
|
||||
callMemberEvents = callMemberEvents.concat(
|
||||
callMemberStateEvents.filter(
|
||||
(callMemberStateEvent) =>
|
||||
!callMemberEvents.some(
|
||||
// only care about state events which have keys which we have not yet seen in the sticky events.
|
||||
(stickyEvent) =>
|
||||
stickyEvent.getContent().msc4354_sticky_key === callMemberStateEvent.getStateKey(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
const callMemberships: CallMembership[] = [];
|
||||
|
||||
const 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 fetchRelatedEvent = async (): Promise<MatrixEvent | undefined> => {
|
||||
const eventData = await client
|
||||
@@ -415,11 +472,21 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
*
|
||||
* @deprecated Use `MatrixRTCSession.sessionForSlot` with sessionDescription `{ id: "", application: "m.call" }` instead.
|
||||
*/
|
||||
public static async roomSessionForRoom(client: MatrixClient, room: Room): Promise<MatrixRTCSession> {
|
||||
const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(room, client, {
|
||||
public static async roomSessionForRoom(
|
||||
client: MatrixClient,
|
||||
room: Room,
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -443,8 +510,15 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
client: MatrixClient,
|
||||
room: Room,
|
||||
slotDescription: SlotDescription,
|
||||
opts?: SessionMembershipsForRoomOpts,
|
||||
): 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);
|
||||
}
|
||||
@@ -478,10 +552,12 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
| "off"
|
||||
| "getUserId"
|
||||
| "getDeviceId"
|
||||
| "sendEvent"
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "sendEvent"
|
||||
| "_unstable_sendStickyEvent"
|
||||
| "_unstable_sendStickyDelayedEvent"
|
||||
| "cancelPendingEvent"
|
||||
| "encryptAndSendToDevice"
|
||||
| "decryptEventIfNeeded"
|
||||
@@ -489,7 +565,14 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
>,
|
||||
private roomSubset: Pick<
|
||||
Room,
|
||||
"on" | "off" | "getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "findEventById"
|
||||
| "on"
|
||||
| "off"
|
||||
| "getLiveTimeline"
|
||||
| "roomId"
|
||||
| "getVersion"
|
||||
| "hasMembershipState"
|
||||
| "findEventById"
|
||||
| "_unstable_getStickyEvents"
|
||||
>,
|
||||
public memberships: CallMembership[],
|
||||
/**
|
||||
@@ -504,9 +587,10 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
|
||||
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
this.roomSubset.on(RoomStickyEventsEvent.Update, this.onStickyEventUpdate);
|
||||
|
||||
this.setExpiryTimer();
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns true if we intend to be participating in the MatrixRTC session.
|
||||
* This is determined by checking if the relativeExpiry has been set.
|
||||
@@ -526,7 +610,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
}
|
||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
this.roomSubset.off(RoomStickyEventsEvent.Update, this.onStickyEventUpdate);
|
||||
}
|
||||
|
||||
private reEmitter = new TypedReEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap
|
||||
@@ -556,14 +642,15 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
return;
|
||||
} else {
|
||||
// Create MembershipManager and pass the RTCSession logger (with room id info)
|
||||
|
||||
this.membershipManager = new MembershipManager(
|
||||
this.membershipManager = joinConfig?.unstableSendStickyEvents
|
||||
? new StickyEventMembershipManager(
|
||||
joinConfig,
|
||||
this.roomSubset,
|
||||
this.client,
|
||||
this.slotDescription,
|
||||
this.logger,
|
||||
);
|
||||
)
|
||||
: new MembershipManager(joinConfig, this.roomSubset, this.client, this.slotDescription, this.logger);
|
||||
|
||||
this.reEmitter.reEmit(this.membershipManager!, [
|
||||
MembershipManagerEvent.ProbablyLeft,
|
||||
@@ -802,10 +889,27 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
/**
|
||||
* Call this when the Matrix room members have changed.
|
||||
*/
|
||||
public onRoomMemberUpdate = (): void => {
|
||||
public readonly onRoomMemberUpdate = (): void => {
|
||||
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.
|
||||
*/
|
||||
@@ -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 we were the joiner we already did sent the notification in the block above.)
|
||||
if (this.memberships.length > 0) this.pendingNotificationToSend = undefined;
|
||||
} else {
|
||||
this.logger.debug(`No membership changes detected for room ${this.roomSubset.roomId}`);
|
||||
}
|
||||
// This also needs to be done if `changed` = false
|
||||
// A member might have updated their fingerprint (created_ts)
|
||||
|
||||
@@ -18,7 +18,7 @@ import { type Logger } from "../logger.ts";
|
||||
import { type MatrixClient, ClientEvent } from "../client.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type RoomState, RoomStateEvent } from "../models/room-state.ts";
|
||||
import { RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
@@ -73,6 +73,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
}
|
||||
|
||||
this.client.on(ClientEvent.Room, this.onRoom);
|
||||
this.client.on(ClientEvent.Event, this.onEvent);
|
||||
this.client.on(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
@@ -83,6 +84,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
this.roomSessions.clear();
|
||||
|
||||
this.client.off(ClientEvent.Room, this.onRoom);
|
||||
this.client.off(ClientEvent.Event, this.onEvent);
|
||||
this.client.off(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
@@ -113,16 +115,28 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
void this.refreshRoom(room);
|
||||
};
|
||||
|
||||
private onRoomState = (event: MatrixEvent, _state: RoomState): void => {
|
||||
private readonly onEvent = (event: MatrixEvent): void => {
|
||||
if (!event.unstableStickyExpiresAt) return; // Not sticky, not interested.
|
||||
|
||||
if (event.getType() !== EventType.RTCMembership) return;
|
||||
|
||||
const room = this.client.getRoom(event.getRoomId());
|
||||
if (!room) return;
|
||||
|
||||
void this.refreshRoom(room);
|
||||
};
|
||||
|
||||
private readonly onRoomState = (event: MatrixEvent): void => {
|
||||
if (event.getType() !== EventType.GroupCallMemberPrefix) {
|
||||
return;
|
||||
}
|
||||
const room = this.client.getRoom(event.getRoomId());
|
||||
if (!room) {
|
||||
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.getType() == EventType.GroupCallMemberPrefix) {
|
||||
void this.refreshRoom(room);
|
||||
}
|
||||
};
|
||||
|
||||
private async refreshRoom(room: Room): Promise<void> {
|
||||
|
||||
@@ -16,7 +16,12 @@ limitations under the License.
|
||||
import { AbortError } from "p-retry";
|
||||
|
||||
import { EventType, RelationType } from "../@types/event.ts";
|
||||
import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
||||
import {
|
||||
type ISendEventResponse,
|
||||
type SendDelayedEventResponse,
|
||||
UpdateDelayedEventAction,
|
||||
} from "../@types/requests.ts";
|
||||
import { type EmptyObject } from "../@types/common.ts";
|
||||
import type { MatrixClient } from "../client.ts";
|
||||
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
@@ -85,6 +90,12 @@ On Leave: ───────── STOP ALL ABOVE
|
||||
(s) Successful restart/resend
|
||||
*/
|
||||
|
||||
/**
|
||||
* Call membership should always remain sticky for this amount
|
||||
* of time.
|
||||
*/
|
||||
const MEMBERSHIP_STICKY_DURATION_MS = 60 * 60 * 1000; // 60 minutes
|
||||
|
||||
/**
|
||||
* The different types of actions the MembershipManager can take.
|
||||
* @internal
|
||||
@@ -145,6 +156,23 @@ export interface MembershipManagerState {
|
||||
probablyLeft: boolean;
|
||||
}
|
||||
|
||||
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
insert: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
};
|
||||
}
|
||||
|
||||
function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
replace: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
};
|
||||
}
|
||||
|
||||
type MembershipManagerClient = Pick<
|
||||
MatrixClient,
|
||||
"getUserId" | "getDeviceId" | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent"
|
||||
>;
|
||||
|
||||
/**
|
||||
* This class is responsible for sending all events relating to the own membership of a matrixRTC call.
|
||||
* It has the following tasks:
|
||||
@@ -163,8 +191,8 @@ export class MembershipManager
|
||||
implements IMembershipManager
|
||||
{
|
||||
private activated = false;
|
||||
private logger: Logger;
|
||||
private callIntent: RTCCallIntent | undefined;
|
||||
private readonly logger: Logger;
|
||||
protected callIntent: RTCCallIntent | undefined;
|
||||
|
||||
public isActivated(): boolean {
|
||||
return this.activated;
|
||||
@@ -296,16 +324,9 @@ export class MembershipManager
|
||||
* @param client
|
||||
*/
|
||||
public constructor(
|
||||
private joinConfig: (SessionConfig & MembershipConfig) | undefined,
|
||||
private room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
|
||||
private client: Pick<
|
||||
MatrixClient,
|
||||
| "getUserId"
|
||||
| "getDeviceId"
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
>,
|
||||
private readonly joinConfig: (SessionConfig & MembershipConfig) | undefined,
|
||||
protected readonly room: Pick<Room, "roomId" | "getVersion">,
|
||||
protected readonly client: MembershipManagerClient,
|
||||
public readonly slotDescription: SlotDescription,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
@@ -362,11 +383,11 @@ export class MembershipManager
|
||||
};
|
||||
}
|
||||
// Membership Event static parameters:
|
||||
private deviceId: string;
|
||||
private memberId: string;
|
||||
protected deviceId: string;
|
||||
protected memberId: string;
|
||||
protected rtcTransport?: Transport;
|
||||
/** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */
|
||||
private fociPreferred?: Transport[];
|
||||
private rtcTransport?: Transport;
|
||||
|
||||
// Config:
|
||||
private delayedLeaveEventDelayMsOverride?: number;
|
||||
@@ -381,9 +402,13 @@ export class MembershipManager
|
||||
return this.joinConfig?.membershipEventExpiryHeadroomMs ?? 5_000;
|
||||
}
|
||||
private computeNextExpiryActionTs(iteration: number): number {
|
||||
return this.state.startTime + this.membershipEventExpiryMs * iteration - this.membershipEventExpiryHeadroomMs;
|
||||
return (
|
||||
this.state.startTime +
|
||||
Math.min(this.membershipEventExpiryMs, MEMBERSHIP_STICKY_DURATION_MS) * iteration -
|
||||
this.membershipEventExpiryHeadroomMs
|
||||
);
|
||||
}
|
||||
private get delayedLeaveEventDelayMs(): number {
|
||||
protected get delayedLeaveEventDelayMs(): number {
|
||||
return this.delayedLeaveEventDelayMsOverride ?? this.joinConfig?.delayedLeaveEventDelayMs ?? 8_000;
|
||||
}
|
||||
private get delayedLeaveEventRestartMs(): number {
|
||||
@@ -395,13 +420,10 @@ export class MembershipManager
|
||||
private get maximumNetworkErrorRetryCount(): number {
|
||||
return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10;
|
||||
}
|
||||
|
||||
private get delayedLeaveEventRestartLocalTimeoutMs(): number {
|
||||
return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000;
|
||||
}
|
||||
private get useRtcMemberFormat(): boolean {
|
||||
return this.joinConfig?.useRtcMemberFormat ?? false;
|
||||
}
|
||||
|
||||
// LOOP HANDLER:
|
||||
private membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
|
||||
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)
|
||||
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)
|
||||
// or during a call if the state event canceled our delayed event or caused by an unexpected error that removed our delayed event.
|
||||
// (Another client could have canceled it, the homeserver might have removed/lost it due to a restart, ...)
|
||||
// In the `then` and `catch` block we treat both cases differently. "if (this.state.hasMemberStateEvent) {} else {}"
|
||||
return this.client
|
||||
._unstable_sendDelayedStateEvent(
|
||||
this.room.roomId,
|
||||
{
|
||||
delay: this.delayedLeaveEventDelayMs,
|
||||
},
|
||||
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||
{}, // leave event
|
||||
this.memberId,
|
||||
)
|
||||
return this.clientSendDelayedDisconnectMembership()
|
||||
.then((response) => {
|
||||
this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs;
|
||||
this.setAndEmitProbablyLeft(false);
|
||||
@@ -495,7 +518,7 @@ export class MembershipManager
|
||||
if (this.manageMaxDelayExceededSituation(e)) {
|
||||
return createInsertActionUpdate(repeatActionType);
|
||||
}
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "sendDelayedStateEvent");
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "_unstable_sendDelayedStateEvent");
|
||||
if (update) return update;
|
||||
|
||||
if (this.state.hasMemberStateEvent) {
|
||||
@@ -651,14 +674,19 @@ export class MembershipManager
|
||||
});
|
||||
}
|
||||
|
||||
private sendJoinEvent(): Promise<ActionUpdate> {
|
||||
return this.client
|
||||
.sendStateEvent(
|
||||
protected clientSendMembership: (
|
||||
myMembership: RtcMembershipData | SessionMembershipData | EmptyObject,
|
||||
) => Promise<ISendEventResponse> = (myMembership) => {
|
||||
return this.client.sendStateEvent(
|
||||
this.room.roomId,
|
||||
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||
this.makeMyMembership(this.membershipEventExpiryMs),
|
||||
EventType.GroupCallMemberPrefix,
|
||||
myMembership as EmptyObject | SessionMembershipData,
|
||||
this.memberId,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
private async sendJoinEvent(): Promise<ActionUpdate> {
|
||||
return this.clientSendMembership(this.makeMyMembership(this.membershipEventExpiryMs))
|
||||
.then(() => {
|
||||
this.setAndEmitProbablyLeft(false);
|
||||
this.state.startTime = Date.now();
|
||||
@@ -698,12 +726,8 @@ export class MembershipManager
|
||||
|
||||
private updateExpiryOnJoinedEvent(): Promise<ActionUpdate> {
|
||||
const nextExpireUpdateIteration = this.state.expireUpdateIterations + 1;
|
||||
return this.client
|
||||
.sendStateEvent(
|
||||
this.room.roomId,
|
||||
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||
return this.clientSendMembership(
|
||||
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
|
||||
this.memberId,
|
||||
)
|
||||
.then(() => {
|
||||
// Success, we reset retries and schedule update.
|
||||
@@ -725,14 +749,8 @@ export class MembershipManager
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
private sendFallbackLeaveEvent(): Promise<ActionUpdate> {
|
||||
return this.client
|
||||
.sendStateEvent(
|
||||
this.room.roomId,
|
||||
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||
{},
|
||||
this.memberId,
|
||||
)
|
||||
private async sendFallbackLeaveEvent(): Promise<ActionUpdate> {
|
||||
return this.clientSendMembership({})
|
||||
.then(() => {
|
||||
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
|
||||
this.state.hasMemberStateEvent = false;
|
||||
@@ -758,25 +776,9 @@ export class MembershipManager
|
||||
/**
|
||||
* Constructs our own membership
|
||||
*/
|
||||
private makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
|
||||
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
|
||||
const ownMembership = this.ownMembership;
|
||||
if (this.useRtcMemberFormat) {
|
||||
const relationObject = ownMembership?.eventId
|
||||
? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } }
|
||||
: {};
|
||||
return {
|
||||
application: {
|
||||
type: this.slotDescription.application,
|
||||
...(this.callIntent ? { "m.call.intent": this.callIntent } : {}),
|
||||
},
|
||||
slot_id: slotDescriptionToId(this.slotDescription),
|
||||
rtc_transports: this.rtcTransport ? [this.rtcTransport] : [],
|
||||
member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId },
|
||||
versions: [],
|
||||
...relationObject,
|
||||
[UNSTABLE_STICKY_KEY.name]: this.memberId,
|
||||
};
|
||||
} else {
|
||||
|
||||
const focusObjects =
|
||||
this.rtcTransport === undefined
|
||||
? {
|
||||
@@ -798,7 +800,6 @@ export class MembershipManager
|
||||
...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Error checks and handlers
|
||||
|
||||
@@ -832,7 +833,7 @@ export class MembershipManager
|
||||
return false;
|
||||
}
|
||||
|
||||
private actionUpdateFromErrors(
|
||||
protected actionUpdateFromErrors(
|
||||
error: unknown,
|
||||
type: MembershipActionType,
|
||||
method: string,
|
||||
@@ -880,7 +881,7 @@ export class MembershipManager
|
||||
return createInsertActionUpdate(type, resendDelay);
|
||||
}
|
||||
|
||||
throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + "): " + (error as Error));
|
||||
throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + ")", { cause: error });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1024,14 +1025,69 @@ export class MembershipManager
|
||||
}
|
||||
}
|
||||
|
||||
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
insert: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Implementation of the Membership manager that uses sticky events
|
||||
* rather than state events.
|
||||
*/
|
||||
export class StickyEventMembershipManager extends MembershipManager {
|
||||
public constructor(
|
||||
joinConfig: (SessionConfig & MembershipConfig) | undefined,
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
|
||||
private readonly clientWithSticky: MembershipManagerClient &
|
||||
Pick<MatrixClient, "_unstable_sendStickyEvent" | "_unstable_sendStickyDelayedEvent">,
|
||||
sessionDescription: SlotDescription,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super(joinConfig, room, clientWithSticky, sessionDescription, parentLogger);
|
||||
}
|
||||
|
||||
function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
replace: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
protected clientSendDelayedDisconnectMembership: () => Promise<SendDelayedEventResponse> = () =>
|
||||
this.clientWithSticky._unstable_sendStickyDelayedEvent(
|
||||
this.room.roomId,
|
||||
MEMBERSHIP_STICKY_DURATION_MS,
|
||||
{ delay: this.delayedLeaveEventDelayMs },
|
||||
null,
|
||||
EventType.RTCMembership,
|
||||
{ msc4354_sticky_key: this.memberId },
|
||||
);
|
||||
|
||||
protected clientSendMembership: (
|
||||
myMembership: RtcMembershipData | SessionMembershipData | EmptyObject,
|
||||
) => Promise<ISendEventResponse> = (myMembership) => {
|
||||
return this.clientWithSticky._unstable_sendStickyEvent(
|
||||
this.room.roomId,
|
||||
MEMBERSHIP_STICKY_DURATION_MS,
|
||||
null,
|
||||
EventType.RTCMembership,
|
||||
{ ...myMembership, msc4354_sticky_key: this.memberId },
|
||||
);
|
||||
};
|
||||
|
||||
private static nameMap = new Map([
|
||||
["sendStateEvent", "_unstable_sendStickyEvent"],
|
||||
["sendDelayedStateEvent", "_unstable_sendStickyDelayedEvent"],
|
||||
]);
|
||||
protected actionUpdateFromErrors(e: unknown, t: MembershipActionType, m: string): ActionUpdate | undefined {
|
||||
return super.actionUpdateFromErrors(e, t, StickyEventMembershipManager.nameMap.get(m) ?? "unknown");
|
||||
}
|
||||
|
||||
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
|
||||
const ownMembership = this.ownMembership;
|
||||
|
||||
const relationObject = ownMembership?.eventId
|
||||
? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } }
|
||||
: {};
|
||||
return {
|
||||
application: {
|
||||
type: this.slotDescription.application,
|
||||
...(this.callIntent ? { "m.call.intent": this.callIntent } : {}),
|
||||
},
|
||||
slot_id: slotDescriptionToId(this.slotDescription),
|
||||
rtc_transports: this.rtcTransport ? [this.rtcTransport] : [],
|
||||
member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId },
|
||||
versions: [],
|
||||
...relationObject,
|
||||
[UNSTABLE_STICKY_KEY.name]: this.memberId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import type { IMentions } from "../matrix.ts";
|
||||
import type { IContent, IMentions } from "../matrix.ts";
|
||||
import type { RelationEvent } from "../types.ts";
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
|
||||
@@ -102,9 +102,45 @@ export type RTCNotificationType = "ring" | "notification";
|
||||
* May be any string, although `"audio"` and `"video"` are commonly accepted values.
|
||||
*/
|
||||
export type RTCCallIntent = "audio" | "video" | string;
|
||||
|
||||
/**
|
||||
* This will check if the content has all the expected fields to be a valid IRTCNotificationContent.
|
||||
* It will also cap the lifetime to 90000ms (1.5 min) if a higher value is provided.
|
||||
* @param content
|
||||
* @throws if the content is invalid
|
||||
* @returns a parsed IRTCNotificationContent
|
||||
*/
|
||||
export function parseCallNotificationContent(content: IContent): IRTCNotificationContent {
|
||||
if (content["m.mentions"] && typeof content["m.mentions"] !== "object") {
|
||||
throw new Error("malformed m.mentions");
|
||||
}
|
||||
if (typeof content["notification_type"] !== "string") {
|
||||
throw new Error("Missing or invalid notification_type");
|
||||
}
|
||||
if (typeof content["sender_ts"] !== "number") {
|
||||
throw new Error("Missing or invalid sender_ts");
|
||||
}
|
||||
if (typeof content["lifetime"] !== "number") {
|
||||
throw new Error("Missing or invalid lifetime");
|
||||
}
|
||||
|
||||
if (content["relation"] && content["relation"]["rel_type"] !== "m.reference") {
|
||||
throw new Error("Invalid relation");
|
||||
}
|
||||
if (content["m.call.intent"] && typeof content["m.call.intent"] !== "string") {
|
||||
throw new Error("Invalid m.call.intent");
|
||||
}
|
||||
|
||||
const cappedLifetime = content["lifetime"] >= 90000 ? 90000 : content["lifetime"];
|
||||
return { ...content, lifetime: cappedLifetime } as IRTCNotificationContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for `org.matrix.msc4075.rtc.notification` events.
|
||||
* Don't cast event content to this directly. Use `parseCallNotificationContent` instead to validate the content first.
|
||||
*/
|
||||
export interface IRTCNotificationContent extends RelationEvent {
|
||||
"m.mentions": IMentions;
|
||||
"decline_reason"?: string;
|
||||
"m.mentions"?: IMentions;
|
||||
"notification_type": RTCNotificationType;
|
||||
/**
|
||||
* The initial intent of the calling user.
|
||||
|
||||
@@ -32,7 +32,7 @@ export enum RoomStickyEventsEvent {
|
||||
Update = "RoomStickyEvents.Update",
|
||||
}
|
||||
|
||||
type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number };
|
||||
export type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number };
|
||||
|
||||
export type RoomStickyEventsMap = {
|
||||
/**
|
||||
@@ -48,17 +48,64 @@ export type RoomStickyEventsMap = {
|
||||
) => void;
|
||||
};
|
||||
|
||||
type UserId = `@${string}`;
|
||||
|
||||
function assertIsUserId(value: unknown): asserts value is UserId {
|
||||
if (typeof value !== "string") throw new Error("Not a string");
|
||||
if (!value.startsWith("@")) throw new Error("Not a userId");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks sticky events on behalf of one room, and fires an event
|
||||
* whenever a sticky event is updated or replaced.
|
||||
*/
|
||||
export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEvent, RoomStickyEventsMap> {
|
||||
private readonly stickyEventsMap = new Map<string, Map<string, StickyMatrixEvent>>(); // (type -> stickyKey+userId) -> event
|
||||
/**
|
||||
* Sticky event map is a nested map of:
|
||||
* eventType -> `content.sticky_key sender` -> StickyMatrixEvent[]
|
||||
*
|
||||
* The events are ordered in latest to earliest expiry, so that the first event
|
||||
* in the array will always be the "current" one.
|
||||
*/
|
||||
private readonly stickyEventsMap = new Map<string, Map<string, StickyMatrixEvent[]>>();
|
||||
/**
|
||||
* These are sticky events that have no sticky key and therefore exist outside the tuple
|
||||
* system above. They are just held in this Set until they expire.
|
||||
*/
|
||||
private readonly unkeyedStickyEvents = new Set<StickyMatrixEvent>();
|
||||
|
||||
private stickyEventTimer?: ReturnType<typeof setTimeout>;
|
||||
private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
/**
|
||||
* Sort two sticky events by order of expiry. This assumes the sticky events have the same
|
||||
* `type`, `sticky_key` and `sender`.
|
||||
* @returns A positive value if event A will expire sooner, or a negative value if event B will expire sooner.
|
||||
*/
|
||||
private static sortStickyEvent(eventA: StickyMatrixEvent, eventB: StickyMatrixEvent): number {
|
||||
// Sticky events with the same key have to use the same expiration duration.
|
||||
// Hence, comparing via `origin_server_ts` yields the exact same result as comparing their expiration time.
|
||||
if (eventB.getTs() !== eventA.getTs()) {
|
||||
return eventB.getTs() - eventA.getTs();
|
||||
}
|
||||
|
||||
if ((eventB.getId() ?? "") > (eventA.getId() ?? "")) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// This should fail as we've got corruption in our sticky array.
|
||||
throw Error("Comparing two sticky events with the same event ID is not allowed.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the correct key for an event to be found in the inner maps of `stickyEventsMap`.
|
||||
* @param stickyKey The sticky key of an event.
|
||||
* @param sender The sender of the event.
|
||||
*/
|
||||
private static stickyMapKey(stickyKey: string, sender: UserId): string {
|
||||
return `${stickyKey}${sender}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sticky events that are currently active.
|
||||
* @returns An iterable set of events.
|
||||
@@ -66,7 +113,11 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
||||
public *getStickyEvents(): Iterable<StickyMatrixEvent> {
|
||||
yield* this.unkeyedStickyEvents;
|
||||
for (const innerMap of this.stickyEventsMap.values()) {
|
||||
yield* innerMap.values();
|
||||
// Inner map contains a map of sender+stickykeys => all sticky events
|
||||
for (const events of innerMap.values()) {
|
||||
// The first sticky event is the "current" one in the sticky map.
|
||||
yield events[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +129,8 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
||||
* @returns A matching active sticky event, or undefined.
|
||||
*/
|
||||
public getKeyedStickyEvent(sender: string, type: string, stickyKey: string): StickyMatrixEvent | undefined {
|
||||
return this.stickyEventsMap.get(type)?.get(`${stickyKey}${sender}`);
|
||||
assertIsUserId(sender);
|
||||
return this.stickyEventsMap.get(type)?.get(RoomStickyEventsStore.stickyMapKey(stickyKey, sender))?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,9 +165,8 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
||||
}
|
||||
const sender = event.getSender();
|
||||
const type = event.getType();
|
||||
if (!sender) {
|
||||
throw new Error(`${event.getId()} is missing a sender`);
|
||||
} else if (event.unstableStickyExpiresAt <= Date.now()) {
|
||||
assertIsUserId(sender);
|
||||
if (event.unstableStickyExpiresAt <= Date.now()) {
|
||||
logger.info("ignored sticky event with older expiration time than current time", stickyKey);
|
||||
return { added: false };
|
||||
}
|
||||
@@ -126,42 +177,39 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
||||
throw new Error("Expected sender to start with @");
|
||||
}
|
||||
|
||||
let prevEvent: StickyMatrixEvent | undefined;
|
||||
if (stickyKey !== undefined) {
|
||||
// Why this is safe:
|
||||
// A type may contain anything but the *sender* is tightly
|
||||
// constrained so that a key will always end with a @<user_id>
|
||||
// E.g. Where a malicous event type might be "rtc.member.event@foo:bar" the key becomes:
|
||||
// "rtc.member.event.@foo:bar@bar:baz"
|
||||
const innerMapKey = `${stickyKey}${sender}`;
|
||||
prevEvent = this.stickyEventsMap.get(type)?.get(innerMapKey);
|
||||
|
||||
// sticky events are not allowed to expire sooner than their predecessor.
|
||||
if (prevEvent && event.unstableStickyExpiresAt < prevEvent.unstableStickyExpiresAt) {
|
||||
logger.info("ignored sticky event with older expiry time", stickyKey);
|
||||
return { added: false };
|
||||
} else if (
|
||||
prevEvent &&
|
||||
event.getTs() === prevEvent.getTs() &&
|
||||
(event.getId() ?? "") < (prevEvent.getId() ?? "")
|
||||
) {
|
||||
// This path is unlikely, as it requires both events to have the same TS.
|
||||
logger.info("ignored sticky event due to 'id tie break rule' on sticky_key", stickyKey);
|
||||
return { added: false };
|
||||
}
|
||||
if (!this.stickyEventsMap.has(type)) {
|
||||
this.stickyEventsMap.set(type, new Map());
|
||||
}
|
||||
this.stickyEventsMap.get(type)!.set(innerMapKey, event as StickyMatrixEvent);
|
||||
} else {
|
||||
this.unkeyedStickyEvents.add(event as StickyMatrixEvent);
|
||||
}
|
||||
const stickyEvent = event as StickyMatrixEvent;
|
||||
|
||||
if (stickyKey === undefined) {
|
||||
this.unkeyedStickyEvents.add(stickyEvent);
|
||||
// Recalculate the next expiry time.
|
||||
this.nextStickyEventExpiryTs = Math.min(event.unstableStickyExpiresAt, this.nextStickyEventExpiryTs);
|
||||
|
||||
this.scheduleStickyTimer();
|
||||
return { added: true, prevEvent };
|
||||
return { added: true };
|
||||
}
|
||||
|
||||
// Why this is safe:
|
||||
// A type may contain anything but the *sender* is tightly
|
||||
// constrained so that a key will always end with a @<user_id>
|
||||
// E.g. Where a malicious event type might be "rtc.member.event@foo:bar" the key becomes:
|
||||
// "rtc.member.event.@foo:bar@bar:baz"
|
||||
const innerMapKey = RoomStickyEventsStore.stickyMapKey(stickyKey, sender);
|
||||
const currentEventSet = [stickyEvent, ...(this.stickyEventsMap.get(type)?.get(innerMapKey) ?? [])].sort(
|
||||
RoomStickyEventsStore.sortStickyEvent,
|
||||
);
|
||||
if (!this.stickyEventsMap.has(type)) {
|
||||
this.stickyEventsMap.set(type, new Map());
|
||||
}
|
||||
this.stickyEventsMap.get(type)?.set(innerMapKey, currentEventSet);
|
||||
|
||||
// Recalculate the next expiry time.
|
||||
this.nextStickyEventExpiryTs = Math.min(stickyEvent.unstableStickyExpiresAt, this.nextStickyEventExpiryTs);
|
||||
|
||||
this.scheduleStickyTimer();
|
||||
return {
|
||||
added: currentEventSet[0] === stickyEvent,
|
||||
prevEvent: currentEventSet?.[1],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,17 +265,24 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
||||
// We will recalculate this as we check all events.
|
||||
this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER;
|
||||
for (const [eventType, innerEvents] of this.stickyEventsMap.entries()) {
|
||||
for (const [innerMapKey, event] of innerEvents) {
|
||||
for (const [innerMapKey, [currentEvent, ...previousEvents]] of innerEvents) {
|
||||
// we only added items with `sticky` into this map so we can assert non-null here
|
||||
if (now >= event.unstableStickyExpiresAt) {
|
||||
logger.debug("Expiring sticky event", event.getId());
|
||||
removedEvents.push(event);
|
||||
if (now >= currentEvent.unstableStickyExpiresAt) {
|
||||
logger.debug("Expiring sticky event", currentEvent.getId());
|
||||
removedEvents.push(currentEvent);
|
||||
this.stickyEventsMap.get(eventType)!.delete(innerMapKey);
|
||||
} else {
|
||||
// Ensure we remove any previous events which have now expired, to avoid unbounded memory consumption.
|
||||
this.stickyEventsMap
|
||||
.get(eventType)!
|
||||
.set(innerMapKey, [
|
||||
currentEvent,
|
||||
...previousEvents.filter((e) => e.unstableStickyExpiresAt <= now),
|
||||
]);
|
||||
// If not removing the event, check to see if it's the next lowest expiry.
|
||||
this.nextStickyEventExpiryTs = Math.min(
|
||||
this.nextStickyEventExpiryTs,
|
||||
event.unstableStickyExpiresAt,
|
||||
currentEvent.unstableStickyExpiresAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -253,6 +308,93 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
|
||||
this.scheduleStickyTimer();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles incoming event redactions. Checks the sticky map
|
||||
* for any active sticky events being redacted.
|
||||
* @param redactedEvent The MatrixEvent OR event ID of the event being redacted. MAY not be a sticky event.
|
||||
*/
|
||||
public handleRedaction(redactedEvent: MatrixEvent | string): void {
|
||||
// Note, we do not adjust`nextStickyEventExpiryTs` here.
|
||||
// If this event happens to be the most recent expiring event
|
||||
// then we may do one extra iteration of cleanExpiredStickyEvents
|
||||
// but this saves us having to iterate over all events here to calculate
|
||||
// the next expiry time.
|
||||
|
||||
// Note, as soon as we find a positive match on an event in this function
|
||||
// we can return. There is no need to continue iterating on a positive match
|
||||
// as an event can only appear in one map.
|
||||
|
||||
// Handle unkeyedStickyEvents first since it's *quick*.
|
||||
const redactEventId = typeof redactedEvent === "string" ? redactedEvent : redactedEvent.getId();
|
||||
for (const event of this.unkeyedStickyEvents) {
|
||||
if (event.getId() === redactEventId) {
|
||||
this.unkeyedStickyEvents.delete(event);
|
||||
this.emit(RoomStickyEventsEvent.Update, [], [], [event]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Faster method of finding the event since we have the event cached.
|
||||
if (typeof redactedEvent !== "string" && !redactedEvent.isRedacted()) {
|
||||
const stickyKey = redactedEvent.getContent().msc4354_sticky_key;
|
||||
if (typeof stickyKey !== "string" && stickyKey !== undefined) {
|
||||
return; // Not a sticky event.
|
||||
}
|
||||
const eventType = redactedEvent.getType();
|
||||
const sender = redactedEvent.getSender();
|
||||
assertIsUserId(sender);
|
||||
const innerMap = this.stickyEventsMap.get(eventType);
|
||||
if (!innerMap) {
|
||||
return;
|
||||
}
|
||||
const mapKey = RoomStickyEventsStore.stickyMapKey(stickyKey, sender);
|
||||
const [currentEvent, ...previousEvents] = innerMap.get(mapKey) ?? [];
|
||||
if (!currentEvent) {
|
||||
// No event current in the map so ignore.
|
||||
return;
|
||||
}
|
||||
logger.debug(`Redaction for ${redactEventId} under sticky key ${stickyKey}`);
|
||||
// Revert to previous state, taking care to skip any other redacted events.
|
||||
const newEvents = previousEvents.filter((e) => !e.isRedacted()).sort(RoomStickyEventsStore.sortStickyEvent);
|
||||
this.stickyEventsMap.get(eventType)?.set(mapKey, newEvents);
|
||||
if (newEvents.length) {
|
||||
this.emit(
|
||||
RoomStickyEventsEvent.Update,
|
||||
[],
|
||||
[
|
||||
{
|
||||
// This looks confusing. This emits that the newer event
|
||||
// has been redacted and the previous event has taken it's place.
|
||||
previous: currentEvent,
|
||||
current: newEvents[0],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
} else {
|
||||
// We did not find a previous event, so just expire.
|
||||
innerMap.delete(mapKey);
|
||||
if (innerMap.size === 0) {
|
||||
this.stickyEventsMap.delete(eventType);
|
||||
}
|
||||
this.emit(RoomStickyEventsEvent.Update, [], [], [currentEvent]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// We only know the event ID of the redacted event, so we need to
|
||||
// traverse the map to find our event.
|
||||
for (const innerMap of this.stickyEventsMap.values()) {
|
||||
for (const [currentEvent] of innerMap.values()) {
|
||||
if (currentEvent.getId() !== redactEventId) {
|
||||
continue;
|
||||
}
|
||||
// Found the event.
|
||||
return this.handleRedaction(currentEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all events and stop the timer from firing.
|
||||
*/
|
||||
|
||||
@@ -2633,6 +2633,14 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
|
||||
// if we know about this event, redact its contents now.
|
||||
const redactedEvent = redactId ? this.findEventById(redactId) : undefined;
|
||||
if (redactId) {
|
||||
try {
|
||||
this.stickyEvents.handleRedaction(redactedEvent || redactId);
|
||||
} catch (ex) {
|
||||
// Non-critical failure, but we should warn.
|
||||
logger.error("Failed to handle redaction for sticky event", ex);
|
||||
}
|
||||
}
|
||||
if (redactedEvent) {
|
||||
this.applyEventAsRedaction(event, redactedEvent);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
type KeyBackupRestoreOpts,
|
||||
type KeyBackupRestoreResult,
|
||||
type OwnDeviceKeys,
|
||||
type SecretStorageStatus,
|
||||
type StartDehydrationOpts,
|
||||
UserVerificationStatus,
|
||||
type VerificationRequest,
|
||||
@@ -78,7 +79,7 @@ import {
|
||||
type ServerSideSecretStorage,
|
||||
} from "../secret-storage.ts";
|
||||
import { CrossSigningIdentity } from "./CrossSigningIdentity.ts";
|
||||
import { secretStorageCanAccessSecrets, secretStorageContainsCrossSigningKeys } from "./secret-storage.ts";
|
||||
import { secretStorageContainsCrossSigningKeys } from "./secret-storage.ts";
|
||||
import { isVerificationEvent, RustVerificationRequest, verificationMethodIdentifierToMethod } from "./verification.ts";
|
||||
import { EventType, MsgType } from "../@types/event.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
@@ -827,6 +828,13 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
* Implementation of {@link CryptoApi#isSecretStorageReady}
|
||||
*/
|
||||
public async isSecretStorageReady(): Promise<boolean> {
|
||||
return (await this.getSecretStorageStatus()).ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getSecretStorageStatus}
|
||||
*/
|
||||
public async getSecretStorageStatus(): Promise<SecretStorageStatus> {
|
||||
// make sure that the cross-signing keys are stored
|
||||
const secretsToCheck: SecretStorageKey[] = [
|
||||
"m.cross_signing.master",
|
||||
@@ -834,13 +842,32 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
"m.cross_signing.self_signing",
|
||||
];
|
||||
|
||||
// if key backup is active, we also need to check that the backup decryption key is stored
|
||||
// If key backup is active, we also need to check that the backup decryption key is stored
|
||||
const keyBackupEnabled = (await this.backupManager.getActiveBackupVersion()) != null;
|
||||
if (keyBackupEnabled) {
|
||||
secretsToCheck.push("m.megolm_backup.v1");
|
||||
}
|
||||
|
||||
return secretStorageCanAccessSecrets(this.secretStorage, secretsToCheck);
|
||||
const defaultKeyId = await this.secretStorage.getDefaultKeyId();
|
||||
|
||||
const result: SecretStorageStatus = {
|
||||
// Assume we have all secrets until proven otherwise
|
||||
ready: true,
|
||||
defaultKeyId,
|
||||
secretStorageKeyValidityMap: {},
|
||||
};
|
||||
|
||||
for (const secretName of secretsToCheck) {
|
||||
// Check which keys this particular secret is encrypted with
|
||||
const record = (await this.secretStorage.isStored(secretName)) || {};
|
||||
|
||||
// If it's encrypted with the right key, it is valid
|
||||
const secretStored = !!defaultKeyId && defaultKeyId in record;
|
||||
result.secretStorageKeyValidityMap[secretName] = secretStored;
|
||||
result.ready = result.ready && secretStored;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user