Skip to content

Commit 83cbced

Browse files
authored
Use MatrixRTC slots behind labs flag (#34392)
* Use MatrixRTC slots behind labs flag * Remove superfluous act's * Remove outdated ensureSlotOpen wrapper * Add description for labs togglec * Add doc strings for slot methods * Throw from the slot functions when the feature flag is off * Log when opening slot fails * Assert on slot ID * Adapt to JS SDK changes
1 parent e395689 commit 83cbced

10 files changed

Lines changed: 523 additions & 36 deletions

File tree

‎.github/workflows/static_analysis.yaml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ jobs:
115115
console_dev_note
116116
labs|element_call_video_rooms
117117
labs|feature_disable_call_per_sender_encryption
118+
labs|feature_matrixrtc_slots
119+
labs|feature_matrixrtc_slots_description
118120
voip|element_call
119121
error|invalid_json
120122
error|misconfigured

‎apps/web/src/components/views/settings/tabs/room/VoipRoomSettingsTab.test.tsx‎

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ Please see LICENSE files in the repository root for full details.
99
// @vitest-environment happy-dom
1010

1111
import React from "react";
12-
import { describe, it, expect, beforeEach, vi } from "vitest";
13-
import { fireEvent, render, type RenderResult, waitFor } from "test-utils-rtl";
12+
import { describe, it, expect, beforeEach, afterEach, type Mock, vi } from "vitest";
13+
import { act, fireEvent, render, type RenderResult, waitFor } from "test-utils-rtl";
1414
import { type MatrixClient, type Room, type MatrixEvent, EventType, JoinRule } from "matrix-js-sdk/src/matrix";
15+
import { RTC_SLOT_ENCRYPTION_PER_MEMBER } from "matrix-js-sdk/src/matrixrtc";
1516
import { mkStubRoom, stubClient } from "test-utils";
1617

1718
import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
1819
import { VoipRoomSettingsTab } from "./VoipRoomSettingsTab";
1920
import { ElementCallEventType, ElementCallMemberEventType } from "../../../../../call-types";
21+
import SettingsStore from "../../../../../settings/SettingsStore";
22+
import { SettingLevel } from "../../../../../settings/SettingLevel";
2023

2124
describe("VoipRoomSettingsTab", () => {
2225
const roomId = "!room:example.com";
@@ -134,5 +137,163 @@ describe("VoipRoomSettingsTab", () => {
134137
);
135138
});
136139
});
140+
141+
describe("slot handling", () => {
142+
interface MockSlotSession {
143+
ensureRtcSlotOpen: Mock;
144+
ensureRtcSlotClosed: Mock;
145+
}
146+
147+
/**
148+
* Mocks the room's MatrixRTC session with the given slot status (`undefined` for no slot).
149+
* @returns the mocked session for asserting on the slot methods.
150+
*/
151+
const mockSlotSession = (slotStatus?: "open" | "closed"): MockSlotSession => {
152+
const session: MockSlotSession = {
153+
ensureRtcSlotOpen: vi.fn().mockResolvedValue(undefined),
154+
ensureRtcSlotClosed: vi.fn().mockResolvedValue(undefined),
155+
};
156+
vi.mocked(cli.matrixRTC.getRoomSession).mockReturnValue(
157+
session as unknown as ReturnType<typeof cli.matrixRTC.getRoomSession>,
158+
);
159+
vi.mocked(cli.matrixRTC.isSlotClosed).mockReturnValue(
160+
slotStatus === undefined ? undefined : slotStatus === "closed",
161+
);
162+
return session;
163+
};
164+
165+
beforeEach(() => {
166+
SettingsStore.setValue("feature_matrixrtc_slots", null, SettingLevel.DEVICE, true);
167+
});
168+
169+
afterEach(() => {
170+
SettingsStore.setValue("feature_matrixrtc_slots", null, SettingLevel.DEVICE, false);
171+
});
172+
173+
it("opens/creates the slot when enabling Element calls", async () => {
174+
mockPowerLevels({ [ElementCallMemberEventType.name]: 100 });
175+
const session = mockSlotSession();
176+
177+
const tab = renderTab();
178+
fireEvent.click(getElementCallSwitch(tab));
179+
180+
await waitFor(() => expect(session.ensureRtcSlotOpen).toHaveBeenCalledWith({ encryption: undefined }));
181+
expect(session.ensureRtcSlotClosed).not.toHaveBeenCalled();
182+
});
183+
184+
it("reopens the slot when enabling Element calls", async () => {
185+
mockPowerLevels({ [ElementCallMemberEventType.name]: 100 });
186+
const session = mockSlotSession("closed");
187+
188+
const tab = renderTab();
189+
fireEvent.click(getElementCallSwitch(tab));
190+
191+
await waitFor(() => expect(session.ensureRtcSlotOpen).toHaveBeenCalledWith({ encryption: undefined }));
192+
expect(session.ensureRtcSlotClosed).not.toHaveBeenCalled();
193+
});
194+
195+
it("declares per-member encryption when opening the slot in an encrypted room", async () => {
196+
vi.mocked(room.hasEncryptionStateEvent).mockReturnValue(true);
197+
mockPowerLevels({ [ElementCallMemberEventType.name]: 100 });
198+
const session = mockSlotSession();
199+
200+
const tab = renderTab();
201+
fireEvent.click(getElementCallSwitch(tab));
202+
203+
await waitFor(() =>
204+
expect(session.ensureRtcSlotOpen).toHaveBeenCalledWith({
205+
encryption: { type: RTC_SLOT_ENCRYPTION_PER_MEMBER },
206+
}),
207+
);
208+
});
209+
210+
it("closes the slot when disabling Element calls", async () => {
211+
mockPowerLevels({ [ElementCallMemberEventType.name]: 0 });
212+
const session = mockSlotSession("open");
213+
214+
const tab = renderTab();
215+
fireEvent.click(getElementCallSwitch(tab));
216+
217+
await waitFor(() => expect(session.ensureRtcSlotClosed).toHaveBeenCalled());
218+
expect(session.ensureRtcSlotOpen).not.toHaveBeenCalled();
219+
});
220+
221+
it("renders the switch as off when no slot has ever been created, even though power levels say enabled", () => {
222+
mockPowerLevels({ [ElementCallMemberEventType.name]: 0 });
223+
mockSlotSession();
224+
225+
const tab = renderTab();
226+
227+
expect(getElementCallSwitch(tab)).not.toBeChecked();
228+
});
229+
230+
it("does not touch the slot when the labs flag is disabled", async () => {
231+
SettingsStore.setValue("feature_matrixrtc_slots", null, SettingLevel.DEVICE, false);
232+
mockPowerLevels({ [ElementCallMemberEventType.name]: 100 });
233+
const session = mockSlotSession();
234+
235+
const tab = renderTab();
236+
fireEvent.click(getElementCallSwitch(tab));
237+
238+
await waitFor(() =>
239+
expect(cli.sendStateEvent).toHaveBeenCalledWith(
240+
room.roomId,
241+
EventType.RoomPowerLevels,
242+
expect.anything(),
243+
),
244+
);
245+
expect(session.ensureRtcSlotOpen).not.toHaveBeenCalled();
246+
expect(session.ensureRtcSlotClosed).not.toHaveBeenCalled();
247+
});
248+
249+
it("does not touch power levels or the slot when the user cannot send RTCSlot state events", () => {
250+
vi.spyOn(room.currentState, "maySendStateEvent").mockImplementation(
251+
(eventType) => eventType !== EventType.RTCSlot,
252+
);
253+
mockPowerLevels({ [ElementCallMemberEventType.name]: 100 });
254+
const session = mockSlotSession();
255+
256+
const tab = renderTab();
257+
fireEvent.click(getElementCallSwitch(tab));
258+
259+
expect(cli.sendStateEvent).not.toHaveBeenCalled();
260+
expect(session.ensureRtcSlotOpen).not.toHaveBeenCalled();
261+
expect(session.ensureRtcSlotClosed).not.toHaveBeenCalled();
262+
});
263+
264+
it("disables the switch when the feature is on and the user cannot send RTCSlot state events", () => {
265+
vi.spyOn(room.currentState, "maySendStateEvent").mockImplementation(
266+
(eventType) => eventType !== EventType.RTCSlot,
267+
);
268+
mockPowerLevels({ [ElementCallMemberEventType.name]: 100 });
269+
270+
const tab = renderTab();
271+
272+
expect(getElementCallSwitch(tab)).toBeDisabled();
273+
});
274+
275+
it("does not disable the switch for missing RTCSlot permission when the feature is off", () => {
276+
act(() => {
277+
SettingsStore.setValue("feature_matrixrtc_slots", null, SettingLevel.DEVICE, false);
278+
});
279+
vi.spyOn(room.currentState, "maySendStateEvent").mockImplementation(
280+
(eventType) => eventType !== EventType.RTCSlot,
281+
);
282+
mockPowerLevels({ [ElementCallMemberEventType.name]: 100 });
283+
284+
const tab = renderTab();
285+
286+
expect(getElementCallSwitch(tab)).not.toBeDisabled();
287+
});
288+
289+
it("renders the switch as off when the slot is closed, even though power levels say enabled", () => {
290+
mockPowerLevels({ [ElementCallMemberEventType.name]: 0 });
291+
mockSlotSession("closed");
292+
293+
const tab = renderTab();
294+
295+
expect(getElementCallSwitch(tab)).not.toBeChecked();
296+
});
297+
});
137298
});
138299
});

‎apps/web/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@ import { useRoomState } from "../../../../../hooks/useRoomState";
1818
import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig";
1919
import { SettingsSection } from "../../shared/SettingsSection";
2020
import { ElementCallEventType, ElementCallMemberEventType } from "../../../../../call-types";
21+
import { ensureSlotOpen, ensureSlotClosed } from "../../../../../utils/room/rtcSlot";
22+
import { useSettingValue } from "../../../../../hooks/useSettings";
2123

2224
interface ElementCallSwitchProps {
2325
room: Room;
2426
}
2527

2628
const ElementCallSwitch: React.FC<ElementCallSwitchProps> = ({ room }) => {
2729
const isPublic = useMemo(() => room.getJoinRule() === JoinRule.Public, [room]);
28-
const [content, maySend] = useRoomState(
30+
const [content, maySendPowerLevels, maySendSlot] = useRoomState(
2931
room,
3032
useCallback(
3133
(state: RoomState) => {
@@ -35,18 +37,26 @@ const ElementCallSwitch: React.FC<ElementCallSwitchProps> = ({ room }) => {
3537
return [
3638
content ?? {},
3739
state?.maySendStateEvent(EventType.RoomPowerLevels, room.client.getSafeUserId()),
40+
state?.maySendStateEvent(EventType.RTCSlot, room.client.getSafeUserId()),
3841
] as const;
3942
},
4043
[room.client],
4144
),
4245
);
4346

47+
const matrixRtcSlotsEnabled = useSettingValue("feature_matrixrtc_slots");
48+
const maySend = maySendPowerLevels && (!matrixRtcSlotsEnabled || maySendSlot);
49+
4450
const [elementCallEnabled, setElementCallEnabled] = useState<boolean>(() => {
45-
return content.events?.[ElementCallMemberEventType.name] === 0;
51+
const anyoneCanSendCallMemberEvents = content.events?.[ElementCallMemberEventType.name] === 0;
52+
const slotOpenOrNoSlotRequired = room.client.matrixRTC.isSlotClosed(room) === false || !matrixRtcSlotsEnabled;
53+
return anyoneCanSendCallMemberEvents && slotOpenOrNoSlotRequired;
4654
});
4755

4856
const onChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
4957
(evt): void => {
58+
if (!maySend) return;
59+
5060
const enabled = evt.target.checked;
5161
setElementCallEnabled(enabled);
5262

@@ -67,8 +77,16 @@ const ElementCallSwitch: React.FC<ElementCallSwitchProps> = ({ room }) => {
6777
}
6878

6979
void room.client.sendStateEvent(room.roomId, EventType.RoomPowerLevels, newContent);
80+
81+
if (matrixRtcSlotsEnabled) {
82+
if (enabled) {
83+
void ensureSlotOpen(room);
84+
} else {
85+
void ensureSlotClosed(room);
86+
}
87+
}
7088
},
71-
[room.client, room.roomId, content, isPublic],
89+
[room, content, isPublic, maySend, matrixRtcSlotsEnabled],
7290
);
7391

7492
const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand;

‎apps/web/src/hooks/room/useRoomCall.ts‎

Lines changed: 62 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
66
Please see LICENSE files in the repository root for full details.
77
*/
88

9-
import { type Room } from "matrix-js-sdk/src/matrix";
9+
import { EventType, type Room } from "matrix-js-sdk/src/matrix";
1010
import { CallType } from "matrix-js-sdk/src/webrtc/call";
1111
import { type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
1212
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
@@ -39,6 +39,7 @@ import { LocalRoom, LocalRoomState } from "../../models/LocalRoom";
3939
import { useScopedRoomContext } from "../../contexts/ScopedRoomContext";
4040
import { SDKContext } from "../../contexts/SDKContext.ts";
4141
import SdkConfig from "../../SdkConfig";
42+
import { ensureSlotOpen } from "../../utils/room/rtcSlot";
4243

4344
const logger = rootLogger.getChild("useRoomCall");
4445

@@ -113,6 +114,7 @@ export const useRoomCall = (
113114
const widgetsFeatureEnabled = useSettingValue(UIFeature.Widgets);
114115
const voipFeatureEnabled = useSettingValue(UIFeature.Voip);
115116
const enableLegacyCallsVoip = useSettingValue("enableLegacyCallsVoip");
117+
const matrixRtcSlotsEnabled = useSettingValue("feature_matrixrtc_slots");
116118
const sdkConfigEcOnly = useMemo(() => {
117119
return SdkConfig.get("element_call").use_exclusively;
118120
}, []);
@@ -166,12 +168,17 @@ export const useRoomCall = (
166168
// room
167169
const memberCount = useRoomMemberCount(room);
168170

169-
const [mayEditWidgets, mayCreateElementCallState] = useRoomState(room, () => [
171+
const [mayEditWidgets, mayCreateElementCallState, maySendSlotState, isSlotClosed] = useRoomState(room, () => [
170172
room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client),
171173
room.currentState.mayClientSendStateEvent(ElementCallMemberEventType.name, room.client),
174+
matrixRtcSlotsEnabled ? room.currentState.mayClientSendStateEvent(EventType.RTCSlot, room.client) : false,
175+
matrixRtcSlotsEnabled ? room.client.matrixRTC.isSlotClosed(room) : undefined,
172176
]);
173177

174-
const mayCreateElementCalls = mayCreateElementCallState && serverIsConfiguredForElementCall;
178+
const mayOpenClosedSlotOrNoSlotExists = maySendSlotState || isSlotClosed !== true;
179+
const mayCreateElementCalls =
180+
mayCreateElementCallState && serverIsConfiguredForElementCall && mayOpenClosedSlotOrNoSlotExists;
181+
const shouldEnsureSlotOpen = !hasGroupCall && maySendSlotState && isSlotClosed !== false;
175182

176183
// The options provided to the RoomHeader.
177184
// If there are multiple options, the user will be prompted to choose.
@@ -264,42 +271,70 @@ export const useRoomCall = (
264271
]);
265272

266273
const voiceCallClick = useCallback(
267-
(evt: React.MouseEvent | undefined, callPlatformType: PlatformCallType): void => {
274+
async (evt: React.MouseEvent | undefined, callPlatformType: PlatformCallType): Promise<void> => {
268275
evt?.stopPropagation();
269276
if (widget && promptPinWidget) {
270277
sdkContext.widgetLayoutStore.moveToContainer(room, widget, "top");
271-
} else {
272-
void placeCall(
273-
sdkContext.legacyCallHandler,
274-
room,
275-
CallType.Voice,
276-
callPlatformType,
277-
evt?.shiftKey || undefined,
278-
true,
279-
);
278+
return;
280279
}
280+
if (callPlatformType === PlatformCallType.ElementCall && shouldEnsureSlotOpen) {
281+
const slotOpen = await ensureSlotOpen(room);
282+
if (!slotOpen) {
283+
logger.warn("Not placing voice call because the MatrixRTC slot could not be opened");
284+
return;
285+
}
286+
}
287+
void placeCall(
288+
sdkContext.legacyCallHandler,
289+
room,
290+
CallType.Voice,
291+
callPlatformType,
292+
evt?.shiftKey || undefined,
293+
true,
294+
);
281295
},
282-
[promptPinWidget, room, widget, sdkContext.widgetLayoutStore, sdkContext.legacyCallHandler],
296+
[
297+
promptPinWidget,
298+
room,
299+
widget,
300+
sdkContext.widgetLayoutStore,
301+
sdkContext.legacyCallHandler,
302+
shouldEnsureSlotOpen,
303+
],
283304
);
284305
const videoCallClick = useCallback(
285-
(evt: React.MouseEvent | undefined, callPlatformType: PlatformCallType): void => {
306+
async (evt: React.MouseEvent | undefined, callPlatformType: PlatformCallType): Promise<void> => {
286307
evt?.stopPropagation();
287308
if (widget && promptPinWidget) {
288309
sdkContext.widgetLayoutStore.moveToContainer(room, widget, "top");
289-
} else {
290-
// If we have pressed shift then always skip the lobby, otherwise `undefined` will defer
291-
// to the defaults of the call implementation.
292-
void placeCall(
293-
sdkContext.legacyCallHandler,
294-
room,
295-
CallType.Video,
296-
callPlatformType,
297-
evt?.shiftKey || undefined,
298-
false,
299-
);
310+
return;
300311
}
312+
if (callPlatformType === PlatformCallType.ElementCall && shouldEnsureSlotOpen) {
313+
const slotOpen = await ensureSlotOpen(room);
314+
if (!slotOpen) {
315+
logger.warn("Not placing video call because the MatrixRTC slot could not be opened");
316+
return;
317+
}
318+
}
319+
// If we have pressed shift then always skip the lobby, otherwise `undefined` will defer
320+
// to the defaults of the call implementation.
321+
void placeCall(
322+
sdkContext.legacyCallHandler,
323+
room,
324+
CallType.Video,
325+
callPlatformType,
326+
evt?.shiftKey || undefined,
327+
false,
328+
);
301329
},
302-
[widget, promptPinWidget, room, sdkContext.widgetLayoutStore, sdkContext.legacyCallHandler],
330+
[
331+
widget,
332+
promptPinWidget,
333+
room,
334+
sdkContext.widgetLayoutStore,
335+
sdkContext.legacyCallHandler,
336+
shouldEnsureSlotOpen,
337+
],
303338
);
304339

305340
let voiceCallDisabledReason: string | null;

0 commit comments

Comments
 (0)