Skip to content
Open
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
5659720
Add voiceOnly options.
Half-Shot Sep 17, 2025
197046c
tweaks
Half-Shot Sep 18, 2025
5650e13
Nearly working demo
Half-Shot Sep 18, 2025
f3360c5
Lots of minor fixes
Half-Shot Sep 18, 2025
b760d87
Better working version
Half-Shot Sep 18, 2025
5305cfc
remove unused payload
Half-Shot Sep 18, 2025
8ece935
bits and pieces
Half-Shot Sep 19, 2025
97095f1
Cleanup based on new hints
Half-Shot Sep 19, 2025
bee16ee
Simple refactor for skipLobby (and remove returnToLobby)
Half-Shot Sep 23, 2025
e3857e8
Tidyup
Half-Shot Sep 23, 2025
2523d2f
Remove unused tests
Half-Shot Sep 23, 2025
af3ed03
Merge remote-tracking branch 'origin/develop' into hs/ecall-voice-calls
Half-Shot Sep 23, 2025
3ce38bd
Update tests for voice calls
Half-Shot Sep 23, 2025
d1bb2d1
Add video room support.
Half-Shot Sep 25, 2025
7833337
Add a test for video rooms
Half-Shot Sep 25, 2025
7483d21
tidy
Half-Shot Sep 25, 2025
2a5d8c4
remove console log line
Half-Shot Sep 25, 2025
701ac66
Merge branch 'develop' into hs/ecall-voice-calls
Half-Shot Sep 25, 2025
264a844
lint and tests
Half-Shot Sep 25, 2025
0163d56
Bunch of fixes
Half-Shot Sep 25, 2025
3e30ff2
Merge branch 'hs/always-skip-lobby-alt-take' into hs/ecall-voice-calls
Half-Shot Sep 25, 2025
870a24e
Fixes
Half-Shot Sep 25, 2025
d0b8665
Merge remote-tracking branch 'origin/develop' into hs/always-skip-lob…
Half-Shot Sep 25, 2025
b1ccb67
Use correct title
Half-Shot Sep 25, 2025
b0a6367
Merge branch 'hs/always-skip-lobby-alt-take' into hs/ecall-voice-calls
Half-Shot Sep 25, 2025
44b0c77
make linter happier
Half-Shot Sep 25, 2025
eb985db
Update tests
Half-Shot Sep 25, 2025
96e4d3c
cleanup
Half-Shot Sep 25, 2025
f8ef596
Drop only
Half-Shot Sep 25, 2025
88b5b55
update snaps
Half-Shot Sep 25, 2025
62da299
Document
Half-Shot Sep 25, 2025
cd162c2
lint
Half-Shot Sep 25, 2025
6b9a517
Update snapshots
Half-Shot Sep 25, 2025
bea8d87
Merge branch 'hs/always-skip-lobby-alt-take' into hs/ecall-voice-calls
Half-Shot Sep 25, 2025
7380868
Merge remote-tracking branch 'origin/develop' into hs/ecall-voice-calls
Half-Shot Sep 25, 2025
7bc06f0
Remove duplicate test
Half-Shot Sep 25, 2025
cb6664b
add brackets
Half-Shot Sep 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 109 additions & 29 deletions playwright/e2e/voip/element-call.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { EventType, Preset } from "matrix-js-sdk/src/matrix";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { test, expect } from "../../element-web-test";
import type { Credentials } from "../../plugins/homeserver";
import type { Bot } from "../../pages/bot";
import { Bot } from "../../pages/bot";

function assertCommonCallParameters(
url: URLSearchParams,
Expand All @@ -27,27 +27,28 @@ function assertCommonCallParameters(
expect(hash.get("preload")).toEqual("false");
}

async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "notification") {
async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "notification", intent?: string) {
const resp = await bot.sendStateEvent(
roomId,
"org.matrix.msc3401.call.member",
{
application: "m.call",
call_id: "",
device_id: "OiDFxsZrjz",
expires: 180000000,
foci_preferred: [
"application": "m.call",
"call_id": "",
"m.call.intent": intent,
"device_id": "OiDFxsZrjz",
"expires": 180000000,
"foci_preferred": [
{
livekit_alias: roomId,
livekit_service_url: "https://example.org",
type: "livekit",
},
],
focus_active: {
"focus_active": {
focus_selection: "oldest_membership",
type: "livekit",
},
scope: "m.room",
"scope": "m.room",
},
`_@${bot.credentials.userId}_OiDFxsZrjz_m.call`,
);
Expand All @@ -64,6 +65,7 @@ async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "n
event_id: resp.event_id,
rel_type: "org.matrix.msc4075.rtc.notification.parent",
},
"m.call.intent": intent,
"notification_type": notification,
"sender_ts": 1758611895996,
});
Expand Down Expand Up @@ -103,15 +105,21 @@ test.describe("Element Call", () => {
});

test.describe("Group Chat", () => {
let charlie: Bot;
test.use({
room: async ({ page, app, user, bot }, use) => {
const roomId = await app.client.createRoom({ name: "TestRoom", invite: [bot.credentials.userId] });
room: async ({ page, app, user, homeserver, bot }, use) => {
charlie = new Bot(page, homeserver, { displayName: "Charlie" });
await charlie.prepareClient();
const roomId = await app.client.createRoom({
name: "TestRoom",
invite: [bot.credentials.userId, charlie.credentials.userId],
});
await use({ roomId });
},
});
test("should be able to start a video call", async ({ page, user, room, app }) => {
await app.viewRoomById(room.roomId);
await expect(page.getByText("Bob joined the room")).toBeVisible();
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();

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

test("should NOT be able to start a voice call", async ({ page, user, room, app }) => {
// Voice calls do not exist in group rooms
await app.viewRoomById(room.roomId);
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
await expect(page.getByRole("button", { name: "Voice call" })).not.toBeVisible();
});

test("should be able to skip lobby by holding down shift", async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
await expect(page.getByText("Bob joined the room")).toBeVisible();
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();

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

[true, false].forEach((skipLobbyToggle) => {
test(
`should be able to join a call via incoming call toast (skipLobby=${skipLobbyToggle})`,
`should be able to join a call via incoming video call toast (skipLobby=${skipLobbyToggle})`,
{ tag: ["@screenshot"] },
async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
// Allow bob to create a call
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Fake a start of a call
await sendRTCState(bot, room.roomId, "notification");
await sendRTCState(bot, room.roomId, "notification", "video");
const toast = page.locator(".mx_Toast_toast");
const button = toast.getByRole("button", { name: "Join" });

if (skipLobbyToggle) {
await toast.getByRole("switch").check();
await expect(toast).toMatchScreenshot("incoming-call-group-video-toast-checked.png");
await expect(toast).toMatchScreenshot(`incoming-call-group-video-toast-checked.png`);
} else {
await toast.getByRole("switch").uncheck();
await expect(toast).toMatchScreenshot("incoming-call-group-video-toast-unchecked.png");
await expect(toast).toMatchScreenshot(`incoming-call-group-video-toast-unchecked.png`);
}

// And test joining
await button.click();
const frameUrlStr = await page.locator("iframe").getAttribute("src");
console.log(frameUrlStr);
await expect(frameUrlStr).toBeDefined();
const url = new URL(frameUrlStr);
const hash = new URLSearchParams(url.hash.slice(1));
Expand All @@ -201,6 +215,34 @@ test.describe("Element Call", () => {
},
);
});

test(
`should be able to join a call via incoming voice call toast`,
{ tag: ["@screenshot"] },
async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
// Allow bob to create a call
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
// Fake a start of a call
await sendRTCState(bot, room.roomId, "notification", "audio");
const toast = page.locator(".mx_Toast_toast");
const button = toast.getByRole("button", { name: "Join" });

await expect(toast).toMatchScreenshot(`incoming-call-group-voice-toast.png`);

// And test joining
await button.click();
const frameUrlStr = await page.locator("iframe").getAttribute("src");
await expect(frameUrlStr).toBeDefined();
const url = new URL(frameUrlStr);
const hash = new URLSearchParams(url.hash.slice(1));
assertCommonCallParameters(url.searchParams, hash, user, room);

expect(hash.get("intent")).toEqual("join_existing");
expect(hash.get("skipLobby")).toEqual("true");
},
);
});

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

test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
// Allow bob to create a call
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Fake a start of a call
await sendRTCState(bot, room.roomId);
Expand All @@ -262,7 +303,6 @@ test.describe("Element Call", () => {
// And test joining
await button.click();
const frameUrlStr = await page.locator("iframe").getAttribute("src");
console.log(frameUrlStr);
await expect(frameUrlStr).toBeDefined();
const url = new URL(frameUrlStr);
const hash = new URLSearchParams(url.hash.slice(1));
Expand All @@ -278,24 +318,31 @@ test.describe("Element Call", () => {
{ tag: ["@screenshot"] },
async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
// Allow bob to create a call
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Fake a start of a call
await sendRTCState(bot, room.roomId, "ring");
await sendRTCState(bot, room.roomId, "ring", "video");
const toast = page.locator(".mx_Toast_toast");
const button = toast.getByRole("button", { name: "Join" });
const button = toast.getByRole("button", { name: "Accept" });
if (skipLobbyToggle) {
await toast.getByRole("switch").check();
await expect(toast).toMatchScreenshot("incoming-call-dm-video-toast-checked.png");
} else {
await toast.getByRole("switch").uncheck();
await expect(toast).toMatchScreenshot("incoming-call-dm-video-toast-unchecked.png");
}
await expect(toast).toMatchScreenshot(
`incoming-call-dm-video-toast-${skipLobbyToggle ? "checked" : "unchecked"}.png`,
{
// Hide UserId
css: `
.mx_IncomingCallToast_AvatarWithDetails span:nth-child(2) {
opacity: 0;
}
`,
},
);

// And test joining
await button.click();
const frameUrlStr = await page.locator("iframe").getAttribute("src");
console.log(frameUrlStr);
await expect(frameUrlStr).toBeDefined();
const url = new URL(frameUrlStr);
const hash = new URLSearchParams(url.hash.slice(1));
Expand All @@ -306,6 +353,39 @@ test.describe("Element Call", () => {
},
);
});

test(
`should be able to join a call via incoming voice call toast`,
{ tag: ["@screenshot"] },
async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Fake a start of a call
await sendRTCState(bot, room.roomId, "ring", "audio");
const toast = page.locator(".mx_Toast_toast");
const button = toast.getByRole("button", { name: "Accept" });

await expect(toast).toMatchScreenshot(`incoming-call-dm-voice-toast.png`, {
// Hide UserId
css: `
.mx_IncomingCallToast_AvatarWithDetails span:nth-child(2) {
opacity: 0;
}
`,
});

// And test joining
await button.click();
const frameUrlStr = await page.locator("iframe").getAttribute("src");
await expect(frameUrlStr).toBeDefined();
const url = new URL(frameUrlStr);
const hash = new URLSearchParams(url.hash.slice(1));
assertCommonCallParameters(url.searchParams, hash, user, room);

expect(hash.get("intent")).toEqual("join_existing_dm_voice");
expect(hash.get("skipLobby")).toEqual("true");
},
);
});

test.describe("Video Rooms", () => {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions res/css/views/rooms/_LiveContentSummary.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ Please see LICENSE files in the repository root for full details.
mask-image: url("$(res)/img/element-icons/call/video-call.svg");
}

&.mx_LiveContentSummary_text_voice::before {
mask-image: url("$(res)/img/element-icons/call/voice-call.svg");
}

&.mx_LiveContentSummary_text_active {
color: $accent;

Expand Down
17 changes: 13 additions & 4 deletions src/components/viewmodels/roomlist/RoomListItemViewModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

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

import dispatcher from "../../../dispatcher/dispatcher";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
Expand All @@ -19,7 +20,7 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useEventEmitter, useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { DefaultTagID } from "../../../stores/room-list/models";
import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall";
import { type ConnectionState } from "../../../models/Call";
import { CallEvent, type ConnectionState } from "../../../models/Call";
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import DMRoomMap from "../../../utils/DMRoomMap";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
Expand Down Expand Up @@ -67,6 +68,10 @@ export interface RoomListItemViewState {
* Whether there are participants in the call.
*/
hasParticipantInCall: boolean;
/**
* Whether the call is a voice or video call.
*/
callType: CallType | undefined;
/**
* Pre-rendered and translated preview for the latest message in the room, or undefined
* if no preview should be shown.
Expand Down Expand Up @@ -123,10 +128,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
// EC video call or video room
const call = useCall(room.roomId);
const connectionState = useConnectionState(call);
const hasParticipantInCall = useParticipantCount(call) > 0;
const participantCount = useParticipantCount(call);
const callConnectionState = call ? connectionState : null;

const showNotificationDecoration = hasVisibleNotification || hasParticipantInCall;
const showNotificationDecoration = hasVisibleNotification || participantCount > 0;

// Actions

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

const [callType, setCallType] = useState<CallType>(CallType.Video);
useTypedEventEmitter(call ?? undefined, CallEvent.CallTypeChanged, setCallType);

return {
name,
notificationState,
Expand All @@ -148,9 +156,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
isBold,
isVideoRoom,
callConnectionState,
hasParticipantInCall,
hasParticipantInCall: participantCount > 0,
messagePreview,
showNotificationDecoration,
callType: call ? callType : undefined,
};
}

Expand Down
18 changes: 2 additions & 16 deletions src/components/views/rooms/LiveContentSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,10 @@ import React, { type FC } from "react";
import classNames from "classnames";

import { _t } from "../../../languageHandler";
import { type Call } from "../../../models/Call";
import { useParticipantCount } from "../../../hooks/useCall";

export enum LiveContentType {
Video,
// More coming soon
Voice,
}

interface Props {
Expand All @@ -33,6 +31,7 @@ export const LiveContentSummary: FC<Props> = ({ type, text, active, participantC
<span
className={classNames("mx_LiveContentSummary_text", {
mx_LiveContentSummary_text_video: type === LiveContentType.Video,
mx_LiveContentSummary_text_voice: type === LiveContentType.Voice,
mx_LiveContentSummary_text_active: active,
})}
>
Expand All @@ -51,16 +50,3 @@ export const LiveContentSummary: FC<Props> = ({ type, text, active, participantC
)}
</span>
);

interface LiveContentSummaryWithCallProps {
call: Call;
}

export const LiveContentSummaryWithCall: FC<LiveContentSummaryWithCallProps> = ({ call }) => (
<LiveContentSummary
type={LiveContentType.Video}
text={_t("common|video")}
active={false}
participantCount={useParticipantCount(call)}
/>
);
Loading
Loading