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
This commit is contained in:
Will Hunt 2025-11-17 11:50:22 +00:00 committed by GitHub
parent 3d683ec5c6
commit f3a880f1c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 365 additions and 112 deletions

View File

@ -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,
@ -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`,
);
@ -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,
});
@ -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();
@ -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");
@ -147,8 +162,8 @@ 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");
@ -156,7 +171,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));
@ -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));
@ -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", () => {
@ -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);
@ -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));
@ -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));
@ -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", () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

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

View File

@ -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";
@ -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";
@ -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.
@ -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
@ -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,
@ -148,9 +156,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
isBold,
isVideoRoom,
callConnectionState,
hasParticipantInCall,
hasParticipantInCall: participantCount > 0,
messagePreview,
showNotificationDecoration,
callType: call ? callType : undefined,
};
}

View File

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

View File

@ -12,6 +12,8 @@ import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/ic
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid";
import { UnreadCounter, Unread } from "@vector-im/compound-web";
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call-solid";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { Flex } from "@element-hq/web-shared-components";
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
@ -24,9 +26,9 @@ interface NotificationDecorationProps extends HTMLProps<HTMLDivElement> {
*/
notificationState: RoomNotificationState;
/**
* Whether the room has a video call.
* Whether the room has a voice or video call.
*/
hasVideoCall: boolean;
callType?: CallType;
}
/**
@ -34,7 +36,7 @@ interface NotificationDecorationProps extends HTMLProps<HTMLDivElement> {
*/
export function NotificationDecoration({
notificationState,
hasVideoCall,
callType,
...props
}: NotificationDecorationProps): JSX.Element | null {
// Listen to the notification state and update the component when it changes
@ -58,7 +60,7 @@ export function NotificationDecoration({
muted: notificationState.muted,
}));
if (!hasAnyNotificationOrActivity && !muted && !hasVideoCall) return null;
if (!hasAnyNotificationOrActivity && !muted && !callType) return null;
return (
<Flex
@ -69,7 +71,12 @@ export function NotificationDecoration({
data-testid="notification-decoration"
>
{isUnsentMessage && <ErrorIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />}
{hasVideoCall && <VideoCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{callType === CallType.Video && (
<VideoCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
)}
{callType === CallType.Voice && (
<VoiceCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
)}
{invited && <EmailIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{isMention && <MentionIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{(isMention || isNotification) && <UnreadCounter count={count || null} />}

View File

@ -132,7 +132,7 @@ export const RoomListItemView = memo(function RoomListItemView({
<NotificationDecoration
notificationState={vm.notificationState}
aria-hidden={true}
hasVideoCall={vm.hasParticipantInCall}
callType={vm.callType}
/>
)}
</>

View File

@ -121,7 +121,7 @@ export enum Action {
UpdateSystemFont = "update_system_font",
/**
* Changes room based on payload parameters. Should be used with JoinRoomPayload.
* Changes room based on payload parameters. Should be used with ViewRoomPayload.
*/
ViewRoom = "view_room",

View File

@ -39,6 +39,7 @@ interface BaseViewRoomPayload extends Pick<ActionPayload, "action"> {
clear_search?: boolean; // Whether to clear the room list search
view_call?: boolean; // Whether to view the call or call lobby for the room
skipLobby?: boolean; // Whether to skip the call lobby when showing the call (only supported for element calls)
voiceOnly?: boolean; // Whether the call is voice only (only supported for element calls)
opts?: JoinRoomPayload["opts"];
deferred_action?: ActionPayload; // Action to fire after MatrixChat handles this ViewRoom action

View File

@ -142,11 +142,6 @@ export const useRoomCall = (
// If there are multiple options, the user will be prompted to choose.
const callOptions = useMemo((): PlatformCallType[] => {
const options: PlatformCallType[] = [];
if (memberCount <= 2) {
options.push(PlatformCallType.LegacyCall);
} else if (mayEditWidgets || hasJitsiWidget) {
options.push(PlatformCallType.JitsiCall);
}
if (groupCallsEnabled) {
if (hasGroupCall || mayCreateElementCalls) {
options.push(PlatformCallType.ElementCall);
@ -155,6 +150,11 @@ export const useRoomCall = (
return [PlatformCallType.ElementCall];
}
}
if (memberCount <= 2) {
options.push(PlatformCallType.LegacyCall);
} else if (mayEditWidgets || hasJitsiWidget) {
options.push(PlatformCallType.JitsiCall);
}
if (hasGroupCall && WidgetType.CALL.matches(groupCall.widget.type)) {
// only allow joining the ongoing Element call if there is one.
return [PlatformCallType.ElementCall];
@ -231,7 +231,7 @@ export const useRoomCall = (
if (widget && promptPinWidget) {
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
} else {
placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey || undefined);
placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey || undefined, true);
}
},
[promptPinWidget, room, widget],
@ -244,7 +244,7 @@ export const useRoomCall = (
} else {
// If we have pressed shift then always skip the lobby, otherwise `undefined` will defer
// to the defaults of the call implementation.
placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey || undefined);
placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey || undefined, false);
}
},
[widget, promptPinWidget, room],
@ -279,7 +279,13 @@ export const useRoomCall = (
const roomDoesNotExist = room instanceof LocalRoom && room.state !== LocalRoomState.CREATED;
// We hide the voice call button if it'd have the same effect as the video call button
let hideVoiceCallButton = isManagedHybridWidgetEnabled(room) || !callOptions.includes(PlatformCallType.LegacyCall);
let hideVoiceCallButton =
isManagedHybridWidgetEnabled(room) ||
// Disable voice calls if Legacy calls are disabled
(!callOptions.includes(PlatformCallType.LegacyCall) &&
// Disable voice calls in ECall if the room is a group (we only present video calls for groups of users)
(!callOptions.includes(PlatformCallType.ElementCall) || memberCount > 2));
let hideVideoCallButton = false;
// We hide both buttons if:
// - they require widgets but widgets are disabled

View File

@ -603,6 +603,7 @@
"video": "Video",
"video_room": "Video room",
"view_message": "View message",
"voice": "Voice",
"warning": "Warning"
},
"composer": {
@ -4096,9 +4097,11 @@
"user_busy_description": "The user you called is busy.",
"user_is_presenting": "%(sharerName)s is presenting",
"video_call": "Video call",
"video_call_incoming": "Incoming video call",
"video_call_started": "Video call started",
"video_call_using": "Video call using:",
"voice_call": "Voice call",
"voice_call_incoming": "Incoming voice call",
"you_are_presenting": "You are presenting"
},
"web_default_device_name": "%(appName)s: %(browserName)s on %(osName)s",

View File

@ -84,6 +84,7 @@ export enum CallEvent {
Participants = "participants",
Close = "close",
Destroy = "destroy",
CallTypeChanged = "call_type_changed",
}
interface CallEventHandlerMap {
@ -94,6 +95,7 @@ interface CallEventHandlerMap {
) => void;
[CallEvent.Close]: () => void;
[CallEvent.Destroy]: () => void;
[CallEvent.CallTypeChanged]: (callType: CallType) => void;
}
/**
@ -103,6 +105,18 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
protected readonly widgetUid: string;
protected readonly room: Room;
private _callType: CallType = CallType.Video;
public get callType(): CallType {
return this._callType;
}
protected set callType(callType: CallType) {
if (this._callType !== callType) {
this.emit(CallEvent.CallTypeChanged, callType);
}
this._callType = callType;
}
/**
* The time after which device member state should be considered expired.
*/
@ -544,7 +558,24 @@ export enum ElementCallIntent {
StartCall = "start_call",
JoinExisting = "join_existing",
StartCallDM = "start_call_dm",
StartCallDMVoice = "start_call_dm_voice",
JoinExistingDM = "join_existing_dm",
JoinExistingDMVoice = "join_existing_dm_voice",
}
/**
* Parameters to be passed during widget creation.
* These parameters are hints only, and may not be accepted by the implementation.
*/
export interface WidgetGenerationParameters {
/**
* Skip showing the lobby screen of a call.
*/
skipLobby?: boolean;
/**
* Does the user intent to start a voice call?
*/
voiceOnly?: boolean;
}
/**
@ -586,7 +617,12 @@ export class ElementCall extends Call {
* @param client The current client.
* @param roomId The room ID for the call.
*/
private static appendRoomParams(params: URLSearchParams, client: MatrixClient, roomId: string): void {
private static appendRoomParams(
params: URLSearchParams,
client: MatrixClient,
roomId: string,
{ voiceOnly }: WidgetGenerationParameters,
): void {
const room = client.getRoom(roomId);
if (!room) {
// If the room isn't known, or the room is a video room then skip setting an intent.
@ -610,13 +646,17 @@ export class ElementCall extends Call {
// is released and upgraded.
if (isDM) {
if (hasCallStarted) {
params.append("intent", ElementCallIntent.JoinExistingDM);
params.append(
"intent",
voiceOnly ? ElementCallIntent.JoinExistingDMVoice : ElementCallIntent.JoinExistingDM,
);
params.append("preload", "false");
} else {
params.append("intent", ElementCallIntent.StartCallDM);
params.append("intent", voiceOnly ? ElementCallIntent.StartCallDMVoice : ElementCallIntent.StartCallDM);
params.append("preload", "false");
}
} else {
// Group chats do not have a voice option.
if (hasCallStarted) {
params.append("intent", ElementCallIntent.JoinExisting);
params.append("preload", "false");
@ -717,7 +757,7 @@ export class ElementCall extends Call {
.forEach((font) => params.append("font", font));
}
this.appendAnalyticsParams(params, client);
this.appendRoomParams(params, client, roomId);
this.appendRoomParams(params, client, roomId, opts);
const replacedUrl = params.toString().replace(/%24/g, "$");
url.hash = `#?${replacedUrl}`;
@ -751,11 +791,43 @@ export class ElementCall extends Call {
);
}
/**
* Get the correct intent for a widget, so that Element Call presents the correct
* default config.
* @param client The matrix client.
* @param roomId
* @param voiceOnly Should the call be voice-only, or video (default).
*/
public static getWidgetIntent(client: MatrixClient, roomId: string, voiceOnly?: boolean): ElementCallIntent {
const room = client.getRoom(roomId);
if (room !== null && !isVideoRoom(room)) {
const isDM = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId);
const oldestCallMember = client.matrixRTC.getRoomSession(room).getOldestMembership();
const hasCallStarted = !!oldestCallMember && oldestCallMember.sender !== client.getSafeUserId();
if (isDM) {
if (hasCallStarted) {
return voiceOnly ? ElementCallIntent.JoinExistingDMVoice : ElementCallIntent.JoinExistingDM;
} else {
return voiceOnly ? ElementCallIntent.StartCallDMVoice : ElementCallIntent.StartCallDM;
}
} else {
if (hasCallStarted) {
return ElementCallIntent.JoinExisting;
} else {
return ElementCallIntent.StartCall;
}
}
}
// If unknown, default to joining an existing call.
return ElementCallIntent.JoinExisting;
}
private static getWidgetData(
client: MatrixClient,
roomId: string,
currentData: IWidgetData,
overwriteData: IWidgetData,
voiceOnly?: boolean,
): IWidgetData {
let perParticipantE2EE = false;
if (
@ -763,9 +835,13 @@ export class ElementCall extends Call {
!SettingsStore.getValue("feature_disable_call_per_sender_encryption")
)
perParticipantE2EE = true;
const intent = ElementCall.getWidgetIntent(client, roomId, voiceOnly);
return {
...currentData,
...overwriteData,
intent,
perParticipantE2EE,
};
}
@ -791,7 +867,7 @@ export class ElementCall extends Call {
this.updateParticipants();
}
public static get(room: Room): ElementCall | null {
public static get(room: Room, voiceOnly?: boolean): ElementCall | null {
const apps = WidgetStore.instance.getApps(room.roomId);
const hasEcWidget = apps.some((app) => WidgetType.CALL.matches(app.type));
const session = room.client.matrixRTC.getRoomSession(room);
@ -874,7 +950,10 @@ export class ElementCall extends Call {
if (this.session.memberships.length === 0 && !this.presented && !this.room.isCallRoom()) this.destroy();
};
private readonly onMembershipChanged = (): void => this.updateParticipants();
private readonly onMembershipChanged = (): void => {
this.updateParticipants();
this.callType = this.session.getConsensusCallIntent() === "audio" ? CallType.Voice : CallType.Video;
};
private updateParticipants(): void {
const participants = new Map<RoomMember, Set<string>>();

View File

@ -365,7 +365,9 @@ export class RoomViewStore extends EventEmitter {
call.presented = true;
// Immediately start the call. This will connect to all required widget events
// and allow the widget to show the lobby.
if (call.connectionState === ConnectionState.Disconnected) call.start({ skipLobby: payload.skipLobby });
if (call.connectionState === ConnectionState.Disconnected) {
call.start({ skipLobby: payload.skipLobby, voiceOnly: payload.voiceOnly });
}
}
// If we switch to a different room from the call, we are no longer presenting it
const prevRoomCall = this.state.roomId ? CallStore.instance.getCall(this.state.roomId) : null;

View File

@ -14,6 +14,7 @@ import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"
import CrossIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import { logger } from "matrix-js-sdk/src/logger";
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
import { VoiceCallIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { AvatarWithDetails } from "@element-hq/web-shared-components";
import { _t } from "../languageHandler";
@ -23,12 +24,8 @@ import defaultDispatcher from "../dispatcher/dispatcher";
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../dispatcher/actions";
import ToastStore from "../stores/ToastStore";
import {
LiveContentSummary,
LiveContentSummaryWithCall,
LiveContentType,
} from "../components/views/rooms/LiveContentSummary";
import { useCall, useJoinCallButtonDisabledTooltip } from "../hooks/useCall";
import { LiveContentSummary, LiveContentType } from "../components/views/rooms/LiveContentSummary";
import { useCall, useJoinCallButtonDisabledTooltip, useParticipantCount } from "../hooks/useCall";
import AccessibleButton, { type ButtonEvent } from "../components/views/elements/AccessibleButton";
import { useDispatcher } from "../hooks/useDispatcher";
import { type ActionPayload } from "../dispatcher/payloads";
@ -36,6 +33,7 @@ import { type Call, CallEvent } from "../models/Call";
import LegacyCallHandler, { AudioID } from "../LegacyCallHandler";
import { useEventEmitter } from "../hooks/useEventEmitter";
import { CallStore, CallStoreEvent } from "../stores/CallStore";
import DMRoomMap from "../utils/DMRoomMap";
/**
* Get the key for the incoming call toast. A combination of the event ID and room ID.
@ -71,9 +69,15 @@ interface JoinCallButtonWithCallProps {
onClick: (e: ButtonEvent) => void;
call: Call | null;
disabledTooltip: string | undefined;
isRinging: boolean;
}
function JoinCallButtonWithCall({ onClick, call, disabledTooltip }: JoinCallButtonWithCallProps): JSX.Element {
function JoinCallButtonWithCall({
onClick,
call,
disabledTooltip,
isRinging,
}: JoinCallButtonWithCallProps): JSX.Element {
let disTooltip = disabledTooltip;
const disabledBecauseFullTooltip = useJoinCallButtonDisabledTooltip(call);
disTooltip = disabledTooltip ?? disabledBecauseFullTooltip ?? undefined;
@ -88,7 +92,7 @@ function JoinCallButtonWithCall({ onClick, call, disabledTooltip }: JoinCallButt
Icon={CheckIcon}
size="sm"
>
{_t("action|join")}
{isRinging ? _t("action|accept") : _t("action|join")}
</Button>
</Tooltip>
);
@ -152,7 +156,7 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
// This section can race, so we use a ref to keep track of whether we have started trying to play.
// This is because `LegacyCallHandler.play` tries to load the sound and then play it asynchonously
// and `LegacyCallHandler.isPlaying` will not be `true` until the sound starts playing.
const isRingToast = notificationContent.notification_type == "ring";
const isRingToast = notificationContent.notification_type === "ring";
if (isRingToast && !soundHasStarted.current && !LegacyCallHandler.instance.isPlaying(AudioID.Ring)) {
// Start ringing if not already.
soundHasStarted.current = true;
@ -243,10 +247,11 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
room_id: room?.roomId,
view_call: true,
skipLobby: ("shiftKey" in e && e.shiftKey) || skipLobbyToggle,
voiceOnly: notificationContent["m.call.intent"] === "audio",
metricsTrigger: undefined,
});
},
[room, skipLobbyToggle],
[room, skipLobbyToggle, notificationContent],
);
// Dismiss on closing toast.
@ -262,34 +267,53 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
useEventEmitter(CallStore.instance, CallStoreEvent.Call, onCall);
useEventEmitter(call ?? undefined, CallEvent.Participants, onParticipantChange);
useEventEmitter(room, RoomEvent.Timeline, onTimelineChange);
const callLiveContentSummary = call ? (
<LiveContentSummaryWithCall call={call} />
const isVoice = notificationContent["m.call.intent"] === "audio";
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(roomId);
const participantCount = useParticipantCount(call);
const detailsInformation =
notificationContent.notification_type === "ring" ? (
<span>{otherUserId}</span>
) : (
<LiveContentSummary
type={LiveContentType.Video}
text={_t("common|video")}
type={isVoice ? LiveContentType.Voice : LiveContentType.Video}
text={isVoice ? _t("common|voice") : _t("common|video")}
active={false}
participantCount={0}
participantCount={participantCount}
/>
);
return (
<TooltipProvider>
<>
<div className="mx_IncomingCallToast_content">
{isVoice ? (
<div className="mx_IncomingCallToast_message">
<VoiceCallIcon width="20px" height="20px" style={{ position: "relative", top: "4px" }} />{" "}
{_t("voip|voice_call_incoming")}
</div>
) : (
<div className="mx_IncomingCallToast_message">
<VideoCallIcon width="20px" height="20px" style={{ position: "relative", top: "4px" }} />{" "}
{_t("voip|video_call_started")}
{notificationContent.notification_type === "ring"
? _t("voip|video_call_incoming")
: _t("voip|video_call_started")}
</div>
)}
<AvatarWithDetails
avatar={<RoomAvatar room={room ?? undefined} size="32px" />}
details={callLiveContentSummary}
details={detailsInformation}
title={room ? room.name : _t("voip|call_toast_unknown_room")}
className="mx_IncomingCallToast_AvatarWithDetails"
/>
{!isVoice && (
<div className="mx_IncomingCallToast_toggleWithLabel">
<span>{_t("voip|skip_lobby_toggle_option")}</span>
<ToggleInput onChange={(e) => setSkipLobbyToggle(e.target.checked)} checked={skipLobbyToggle} />
<ToggleInput
onChange={(e) => setSkipLobbyToggle(e.target.checked)}
checked={skipLobbyToggle}
/>
</div>
)}
<div className="mx_IncomingCallToast_buttons">
<DeclineCallButtonWithNotificationEvent
notificationEvent={notificationEvent}
@ -299,6 +323,7 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
<JoinCallButtonWithCall
onClick={onJoinClick}
call={call}
isRinging={notificationContent.notification_type === "ring"}
disabledTooltip={otherCallIsOngoing ? "Ongoing call" : undefined}
/>
</div>

View File

@ -27,7 +27,8 @@ export const placeCall = async (
room: Room,
callType: CallType,
platformCallType: PlatformCallType,
skipLobby?: boolean,
skipLobby: boolean | undefined,
voiceOnly: boolean,
): Promise<void> => {
const { analyticsName } = getPlatformCallTypeProps(platformCallType);
PosthogTrackers.trackInteraction(analyticsName);
@ -39,6 +40,7 @@ export const placeCall = async (
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
voiceOnly,
skipLobby,
metricsTrigger: undefined,
});

View File

@ -181,6 +181,7 @@ export function setUpClientRoomAndStores(): {
const roomSession = new MockEventEmitter({
memberships: [],
getOldestMembership: jest.fn().mockReturnValue(undefined),
getConsensusCallIntent: jest.fn().mockReturnValue(undefined),
room,
}) as Mocked<MatrixRTCSession>;

View File

@ -7,6 +7,7 @@
import React from "react";
import { render, screen } from "jest-matrix-react";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { RoomNotificationState } from "../../../../../src/stores/notifications/RoomNotificationState";
import { NotificationDecoration } from "../../../../../src/components/views/rooms/NotificationDecoration";
@ -22,7 +23,7 @@ describe("<NotificationDecoration />", () => {
it("should not render if RoomNotificationState.hasAnyNotificationOrActivity=true", () => {
jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(false);
render(<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />);
render(<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />);
expect(screen.queryByTestId("notification-decoration")).toBeNull();
});
@ -30,7 +31,7 @@ describe("<NotificationDecoration />", () => {
jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
jest.spyOn(roomNotificationState, "isUnsentMessage", "get").mockReturnValue(true);
const { asFragment } = render(
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />,
<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />,
);
expect(asFragment()).toMatchSnapshot();
});
@ -39,7 +40,7 @@ describe("<NotificationDecoration />", () => {
jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
jest.spyOn(roomNotificationState, "invited", "get").mockReturnValue(true);
const { asFragment } = render(
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />,
<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />,
);
expect(asFragment()).toMatchSnapshot();
});
@ -49,7 +50,7 @@ describe("<NotificationDecoration />", () => {
jest.spyOn(roomNotificationState, "isMention", "get").mockReturnValue(true);
jest.spyOn(roomNotificationState, "count", "get").mockReturnValue(1);
const { asFragment } = render(
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />,
<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />,
);
expect(asFragment()).toMatchSnapshot();
});
@ -59,7 +60,7 @@ describe("<NotificationDecoration />", () => {
jest.spyOn(roomNotificationState, "isNotification", "get").mockReturnValue(true);
jest.spyOn(roomNotificationState, "count", "get").mockReturnValue(1);
const { asFragment } = render(
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />,
<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />,
);
expect(asFragment()).toMatchSnapshot();
});
@ -69,7 +70,7 @@ describe("<NotificationDecoration />", () => {
jest.spyOn(roomNotificationState, "isNotification", "get").mockReturnValue(true);
jest.spyOn(roomNotificationState, "count", "get").mockReturnValue(0);
const { asFragment } = render(
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />,
<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />,
);
expect(asFragment()).toMatchSnapshot();
});
@ -78,7 +79,7 @@ describe("<NotificationDecoration />", () => {
jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
jest.spyOn(roomNotificationState, "isActivityNotification", "get").mockReturnValue(true);
const { asFragment } = render(
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />,
<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />,
);
expect(asFragment()).toMatchSnapshot();
});
@ -87,14 +88,21 @@ describe("<NotificationDecoration />", () => {
jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
jest.spyOn(roomNotificationState, "muted", "get").mockReturnValue(true);
const { asFragment } = render(
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />,
<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />,
);
expect(asFragment()).toMatchSnapshot();
});
it("should render the video decoration", () => {
it("should render the video call decoration", () => {
jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(false);
const { asFragment } = render(
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={true} />,
<NotificationDecoration notificationState={roomNotificationState} callType={CallType.Video} />,
);
expect(asFragment()).toMatchSnapshot();
});
it("should render the audio call decoration", () => {
jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(false);
const { asFragment } = render(
<NotificationDecoration notificationState={roomNotificationState} callType={CallType.Voice} />,
);
expect(asFragment()).toMatchSnapshot();
});

View File

@ -10,6 +10,7 @@ import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { render, screen, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { mkRoom, stubClient, withClientContextRenderOptions } from "../../../../../test-utils";
import { RoomListItemView } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListItemView";
@ -64,6 +65,7 @@ describe("<RoomListItemView />", () => {
isBold: false,
isVideoRoom: false,
callConnectionState: null,
callType: CallType.Video,
hasParticipantInCall: false,
name: room.name,
showNotificationDecoration: false,

View File

@ -103,6 +103,17 @@ exports[`<RoomListItemView /> should display notification decoration 1`] = `
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-accent-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
<span
class="_unread-counter_9mg0k_8"
>

View File

@ -16,6 +16,28 @@ exports[`<NotificationDecoration /> should render the activity decoration 1`] =
</DocumentFragment>
`;
exports[`<NotificationDecoration /> should render the audio call decoration 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-accent-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20.958 16.374.039 3.527q0 .427-.33.756-.33.33-.756.33a16 16 0 0 1-6.57-1.105 16.2 16.2 0 0 1-5.563-3.663 16.1 16.1 0 0 1-3.653-5.573 16.3 16.3 0 0 1-1.115-6.56q0-.427.33-.757T4.095 3l3.528.039a1.07 1.07 0 0 1 1.085.93l.543 3.954q.039.271-.039.504a1.1 1.1 0 0 1-.271.426l-1.64 1.64q.505 1.008 1.154 1.909c.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.193-.193.426-.27t.504-.04l3.954.543q.406.059.668.359t.262.727"
/>
</svg>
</div>
</DocumentFragment>
`;
exports[`<NotificationDecoration /> should render the invitation decoration 1`] = `
<DocumentFragment>
<div
@ -142,7 +164,7 @@ exports[`<NotificationDecoration /> should render the unset message decoration 1
</DocumentFragment>
`;
exports[`<NotificationDecoration /> should render the video decoration 1`] = `
exports[`<NotificationDecoration /> should render the video call decoration 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9"

View File

@ -32,6 +32,7 @@ import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import { CallView as _CallView } from "../../../../../src/components/views/voip/CallView";
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
import { CallStore } from "../../../../../src/stores/CallStore";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
const CallView = wrapInMatrixClientContext(_CallView);
@ -50,6 +51,7 @@ describe("CallView", () => {
stubClient();
client = mocked(MatrixClientPeg.safeGet());
DMRoomMap.makeShared(client);
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,

View File

@ -188,6 +188,7 @@ describe("IncomingCallToast", () => {
room_id: room.roomId,
skipLobby: true,
view_call: true,
voiceOnly: false,
}),
);
await waitFor(() =>
@ -215,6 +216,7 @@ describe("IncomingCallToast", () => {
room_id: room.roomId,
skipLobby: false,
view_call: true,
voiceOnly: false,
}),
);
await waitFor(() =>
@ -239,6 +241,7 @@ describe("IncomingCallToast", () => {
room_id: room.roomId,
skipLobby: true,
view_call: true,
voiceOnly: false,
}),
);
await waitFor(() =>