Skip to content

Commit f3a880f

Browse files
authored
Support using Element Call for voice calls in DMs (#30817)
* Add voiceOnly options. * tweaks * Nearly working demo * Lots of minor fixes * Better working version * remove unused payload * bits and pieces * Cleanup based on new hints * Simple refactor for skipLobby (and remove returnToLobby) * Tidyup * Remove unused tests * Update tests for voice calls * Add video room support. * Add a test for video rooms * tidy * remove console log line * lint and tests * Bunch of fixes * Fixes * Use correct title * make linter happier * Update tests * cleanup * Drop only * update snaps * Document * lint * Update snapshots * Remove duplicate test * add brackets * fix jest
1 parent 3d683ec commit f3a880f

25 files changed

Lines changed: 365 additions & 112 deletions

playwright/e2e/voip/element-call.spec.ts

Lines changed: 109 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { EventType, Preset } from "matrix-js-sdk/src/matrix";
99
import { SettingLevel } from "../../../src/settings/SettingLevel";
1010
import { test, expect } from "../../element-web-test";
1111
import type { Credentials } from "../../plugins/homeserver";
12-
import type { Bot } from "../../pages/bot";
12+
import { Bot } from "../../pages/bot";
1313

1414
function assertCommonCallParameters(
1515
url: URLSearchParams,
@@ -27,27 +27,28 @@ function assertCommonCallParameters(
2727
expect(hash.get("preload")).toEqual("false");
2828
}
2929

30-
async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "notification") {
30+
async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "notification", intent?: string) {
3131
const resp = await bot.sendStateEvent(
3232
roomId,
3333
"org.matrix.msc3401.call.member",
3434
{
35-
application: "m.call",
36-
call_id: "",
37-
device_id: "OiDFxsZrjz",
38-
expires: 180000000,
39-
foci_preferred: [
35+
"application": "m.call",
36+
"call_id": "",
37+
"m.call.intent": intent,
38+
"device_id": "OiDFxsZrjz",
39+
"expires": 180000000,
40+
"foci_preferred": [
4041
{
4142
livekit_alias: roomId,
4243
livekit_service_url: "https://example.org",
4344
type: "livekit",
4445
},
4546
],
46-
focus_active: {
47+
"focus_active": {
4748
focus_selection: "oldest_membership",
4849
type: "livekit",
4950
},
50-
scope: "m.room",
51+
"scope": "m.room",
5152
},
5253
`_@${bot.credentials.userId}_OiDFxsZrjz_m.call`,
5354
);
@@ -64,6 +65,7 @@ async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "n
6465
event_id: resp.event_id,
6566
rel_type: "org.matrix.msc4075.rtc.notification.parent",
6667
},
68+
"m.call.intent": intent,
6769
"notification_type": notification,
6870
"sender_ts": 1758611895996,
6971
});
@@ -103,15 +105,21 @@ test.describe("Element Call", () => {
103105
});
104106

105107
test.describe("Group Chat", () => {
108+
let charlie: Bot;
106109
test.use({
107-
room: async ({ page, app, user, bot }, use) => {
108-
const roomId = await app.client.createRoom({ name: "TestRoom", invite: [bot.credentials.userId] });
110+
room: async ({ page, app, user, homeserver, bot }, use) => {
111+
charlie = new Bot(page, homeserver, { displayName: "Charlie" });
112+
await charlie.prepareClient();
113+
const roomId = await app.client.createRoom({
114+
name: "TestRoom",
115+
invite: [bot.credentials.userId, charlie.credentials.userId],
116+
});
109117
await use({ roomId });
110118
},
111119
});
112120
test("should be able to start a video call", async ({ page, user, room, app }) => {
113121
await app.viewRoomById(room.roomId);
114-
await expect(page.getByText("Bob joined the room")).toBeVisible();
122+
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
115123

116124
await page.getByRole("button", { name: "Video call" }).click();
117125
await page.getByRole("menuitem", { name: "Element Call" }).click();
@@ -126,9 +134,16 @@ test.describe("Element Call", () => {
126134
expect(hash.get("skipLobby")).toEqual(null);
127135
});
128136

137+
test("should NOT be able to start a voice call", async ({ page, user, room, app }) => {
138+
// Voice calls do not exist in group rooms
139+
await app.viewRoomById(room.roomId);
140+
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
141+
await expect(page.getByRole("button", { name: "Voice call" })).not.toBeVisible();
142+
});
143+
129144
test("should be able to skip lobby by holding down shift", async ({ page, user, bot, room, app }) => {
130145
await app.viewRoomById(room.roomId);
131-
await expect(page.getByText("Bob joined the room")).toBeVisible();
146+
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
132147

133148
await page.getByRole("button", { name: "Video call" }).click();
134149
await page.keyboard.down("Shift");
@@ -147,16 +162,15 @@ test.describe("Element Call", () => {
147162
test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => {
148163
await app.viewRoomById(room.roomId);
149164
// Allow bob to create a call
165+
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
150166
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
151-
await expect(page.getByText("Bob joined the room")).toBeVisible();
152167
// Fake a start of a call
153168
await sendRTCState(bot, room.roomId);
154169
const button = page.getByTestId("join-call-button");
155170
await expect(button).toBeInViewport({ timeout: 5000 });
156171
// And test joining
157172
await button.click();
158173
const frameUrlStr = await page.locator("iframe").getAttribute("src");
159-
console.log(frameUrlStr);
160174
await expect(frameUrlStr).toBeDefined();
161175
const url = new URL(frameUrlStr);
162176
const hash = new URLSearchParams(url.hash.slice(1));
@@ -168,29 +182,29 @@ test.describe("Element Call", () => {
168182

169183
[true, false].forEach((skipLobbyToggle) => {
170184
test(
171-
`should be able to join a call via incoming call toast (skipLobby=${skipLobbyToggle})`,
185+
`should be able to join a call via incoming video call toast (skipLobby=${skipLobbyToggle})`,
172186
{ tag: ["@screenshot"] },
173187
async ({ page, user, bot, room, app }) => {
174188
await app.viewRoomById(room.roomId);
175189
// Allow bob to create a call
190+
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
176191
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
177-
await expect(page.getByText("Bob joined the room")).toBeVisible();
178192
// Fake a start of a call
179-
await sendRTCState(bot, room.roomId, "notification");
193+
await sendRTCState(bot, room.roomId, "notification", "video");
180194
const toast = page.locator(".mx_Toast_toast");
181195
const button = toast.getByRole("button", { name: "Join" });
196+
182197
if (skipLobbyToggle) {
183198
await toast.getByRole("switch").check();
184-
await expect(toast).toMatchScreenshot("incoming-call-group-video-toast-checked.png");
199+
await expect(toast).toMatchScreenshot(`incoming-call-group-video-toast-checked.png`);
185200
} else {
186201
await toast.getByRole("switch").uncheck();
187-
await expect(toast).toMatchScreenshot("incoming-call-group-video-toast-unchecked.png");
202+
await expect(toast).toMatchScreenshot(`incoming-call-group-video-toast-unchecked.png`);
188203
}
189204

190205
// And test joining
191206
await button.click();
192207
const frameUrlStr = await page.locator("iframe").getAttribute("src");
193-
console.log(frameUrlStr);
194208
await expect(frameUrlStr).toBeDefined();
195209
const url = new URL(frameUrlStr);
196210
const hash = new URLSearchParams(url.hash.slice(1));
@@ -201,6 +215,34 @@ test.describe("Element Call", () => {
201215
},
202216
);
203217
});
218+
219+
test(
220+
`should be able to join a call via incoming voice call toast`,
221+
{ tag: ["@screenshot"] },
222+
async ({ page, user, bot, room, app }) => {
223+
await app.viewRoomById(room.roomId);
224+
// Allow bob to create a call
225+
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
226+
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
227+
// Fake a start of a call
228+
await sendRTCState(bot, room.roomId, "notification", "audio");
229+
const toast = page.locator(".mx_Toast_toast");
230+
const button = toast.getByRole("button", { name: "Join" });
231+
232+
await expect(toast).toMatchScreenshot(`incoming-call-group-voice-toast.png`);
233+
234+
// And test joining
235+
await button.click();
236+
const frameUrlStr = await page.locator("iframe").getAttribute("src");
237+
await expect(frameUrlStr).toBeDefined();
238+
const url = new URL(frameUrlStr);
239+
const hash = new URLSearchParams(url.hash.slice(1));
240+
assertCommonCallParameters(url.searchParams, hash, user, room);
241+
242+
expect(hash.get("intent")).toEqual("join_existing");
243+
expect(hash.get("skipLobby")).toEqual("true");
244+
},
245+
);
204246
});
205247

206248
test.describe("DMs", () => {
@@ -253,7 +295,6 @@ test.describe("Element Call", () => {
253295

254296
test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => {
255297
await app.viewRoomById(room.roomId);
256-
// Allow bob to create a call
257298
await expect(page.getByText("Bob joined the room")).toBeVisible();
258299
// Fake a start of a call
259300
await sendRTCState(bot, room.roomId);
@@ -262,7 +303,6 @@ test.describe("Element Call", () => {
262303
// And test joining
263304
await button.click();
264305
const frameUrlStr = await page.locator("iframe").getAttribute("src");
265-
console.log(frameUrlStr);
266306
await expect(frameUrlStr).toBeDefined();
267307
const url = new URL(frameUrlStr);
268308
const hash = new URLSearchParams(url.hash.slice(1));
@@ -278,24 +318,31 @@ test.describe("Element Call", () => {
278318
{ tag: ["@screenshot"] },
279319
async ({ page, user, bot, room, app }) => {
280320
await app.viewRoomById(room.roomId);
281-
// Allow bob to create a call
282321
await expect(page.getByText("Bob joined the room")).toBeVisible();
283322
// Fake a start of a call
284-
await sendRTCState(bot, room.roomId, "ring");
323+
await sendRTCState(bot, room.roomId, "ring", "video");
285324
const toast = page.locator(".mx_Toast_toast");
286-
const button = toast.getByRole("button", { name: "Join" });
325+
const button = toast.getByRole("button", { name: "Accept" });
287326
if (skipLobbyToggle) {
288327
await toast.getByRole("switch").check();
289-
await expect(toast).toMatchScreenshot("incoming-call-dm-video-toast-checked.png");
290328
} else {
291329
await toast.getByRole("switch").uncheck();
292-
await expect(toast).toMatchScreenshot("incoming-call-dm-video-toast-unchecked.png");
293330
}
331+
await expect(toast).toMatchScreenshot(
332+
`incoming-call-dm-video-toast-${skipLobbyToggle ? "checked" : "unchecked"}.png`,
333+
{
334+
// Hide UserId
335+
css: `
336+
.mx_IncomingCallToast_AvatarWithDetails span:nth-child(2) {
337+
opacity: 0;
338+
}
339+
`,
340+
},
341+
);
294342

295343
// And test joining
296344
await button.click();
297345
const frameUrlStr = await page.locator("iframe").getAttribute("src");
298-
console.log(frameUrlStr);
299346
await expect(frameUrlStr).toBeDefined();
300347
const url = new URL(frameUrlStr);
301348
const hash = new URLSearchParams(url.hash.slice(1));
@@ -306,6 +353,39 @@ test.describe("Element Call", () => {
306353
},
307354
);
308355
});
356+
357+
test(
358+
`should be able to join a call via incoming voice call toast`,
359+
{ tag: ["@screenshot"] },
360+
async ({ page, user, bot, room, app }) => {
361+
await app.viewRoomById(room.roomId);
362+
await expect(page.getByText("Bob joined the room")).toBeVisible();
363+
// Fake a start of a call
364+
await sendRTCState(bot, room.roomId, "ring", "audio");
365+
const toast = page.locator(".mx_Toast_toast");
366+
const button = toast.getByRole("button", { name: "Accept" });
367+
368+
await expect(toast).toMatchScreenshot(`incoming-call-dm-voice-toast.png`, {
369+
// Hide UserId
370+
css: `
371+
.mx_IncomingCallToast_AvatarWithDetails span:nth-child(2) {
372+
opacity: 0;
373+
}
374+
`,
375+
});
376+
377+
// And test joining
378+
await button.click();
379+
const frameUrlStr = await page.locator("iframe").getAttribute("src");
380+
await expect(frameUrlStr).toBeDefined();
381+
const url = new URL(frameUrlStr);
382+
const hash = new URLSearchParams(url.hash.slice(1));
383+
assertCommonCallParameters(url.searchParams, hash, user, room);
384+
385+
expect(hash.get("intent")).toEqual("join_existing_dm_voice");
386+
expect(hash.get("skipLobby")).toEqual("true");
387+
},
388+
);
309389
});
310390

311391
test.describe("Video Rooms", () => {
-872 Bytes
Loading
-870 Bytes
Loading
9.96 KB
Loading
11.4 KB
Loading

res/css/views/rooms/_LiveContentSummary.pcss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ Please see LICENSE files in the repository root for full details.
2525
mask-image: url("$(res)/img/element-icons/call/video-call.svg");
2626
}
2727

28+
&.mx_LiveContentSummary_text_voice::before {
29+
mask-image: url("$(res)/img/element-icons/call/voice-call.svg");
30+
}
31+
2832
&.mx_LiveContentSummary_text_active {
2933
color: $accent;
3034

src/components/viewmodels/roomlist/RoomListItemViewModel.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { useCallback, useEffect, useMemo, useState } from "react";
99
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
10+
import { CallType } from "matrix-js-sdk/src/webrtc/call";
1011

1112
import dispatcher from "../../../dispatcher/dispatcher";
1213
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
@@ -19,7 +20,7 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
1920
import { useEventEmitter, useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter";
2021
import { DefaultTagID } from "../../../stores/room-list/models";
2122
import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall";
22-
import { type ConnectionState } from "../../../models/Call";
23+
import { CallEvent, type ConnectionState } from "../../../models/Call";
2324
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
2425
import DMRoomMap from "../../../utils/DMRoomMap";
2526
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
@@ -67,6 +68,10 @@ export interface RoomListItemViewState {
6768
* Whether there are participants in the call.
6869
*/
6970
hasParticipantInCall: boolean;
71+
/**
72+
* Whether the call is a voice or video call.
73+
*/
74+
callType: CallType | undefined;
7075
/**
7176
* Pre-rendered and translated preview for the latest message in the room, or undefined
7277
* if no preview should be shown.
@@ -123,10 +128,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
123128
// EC video call or video room
124129
const call = useCall(room.roomId);
125130
const connectionState = useConnectionState(call);
126-
const hasParticipantInCall = useParticipantCount(call) > 0;
131+
const participantCount = useParticipantCount(call);
127132
const callConnectionState = call ? connectionState : null;
128133

129-
const showNotificationDecoration = hasVisibleNotification || hasParticipantInCall;
134+
const showNotificationDecoration = hasVisibleNotification || participantCount > 0;
130135

131136
// Actions
132137

@@ -138,6 +143,9 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
138143
});
139144
}, [room]);
140145

146+
const [callType, setCallType] = useState<CallType>(CallType.Video);
147+
useTypedEventEmitter(call ?? undefined, CallEvent.CallTypeChanged, setCallType);
148+
141149
return {
142150
name,
143151
notificationState,
@@ -148,9 +156,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
148156
isBold,
149157
isVideoRoom,
150158
callConnectionState,
151-
hasParticipantInCall,
159+
hasParticipantInCall: participantCount > 0,
152160
messagePreview,
153161
showNotificationDecoration,
162+
callType: call ? callType : undefined,
154163
};
155164
}
156165

src/components/views/rooms/LiveContentSummary.tsx

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,10 @@ import React, { type FC } from "react";
1010
import classNames from "classnames";
1111

1212
import { _t } from "../../../languageHandler";
13-
import { type Call } from "../../../models/Call";
14-
import { useParticipantCount } from "../../../hooks/useCall";
1513

1614
export enum LiveContentType {
1715
Video,
18-
// More coming soon
16+
Voice,
1917
}
2018

2119
interface Props {
@@ -33,6 +31,7 @@ export const LiveContentSummary: FC<Props> = ({ type, text, active, participantC
3331
<span
3432
className={classNames("mx_LiveContentSummary_text", {
3533
mx_LiveContentSummary_text_video: type === LiveContentType.Video,
34+
mx_LiveContentSummary_text_voice: type === LiveContentType.Voice,
3635
mx_LiveContentSummary_text_active: active,
3736
})}
3837
>
@@ -51,16 +50,3 @@ export const LiveContentSummary: FC<Props> = ({ type, text, active, participantC
5150
)}
5251
</span>
5352
);
54-
55-
interface LiveContentSummaryWithCallProps {
56-
call: Call;
57-
}
58-
59-
export const LiveContentSummaryWithCall: FC<LiveContentSummaryWithCallProps> = ({ call }) => (
60-
<LiveContentSummary
61-
type={LiveContentType.Video}
62-
text={_t("common|video")}
63-
active={false}
64-
participantCount={useParticipantCount(call)}
65-
/>
66-
);

0 commit comments

Comments
 (0)