Add option to pick call options for voice calls. (#31407)

* Add option to pick call options for voice calls.

* hook on the right thing

* Fix wrong call being disabled

* update snaps

* Add tests for menus

* more snaps

* snap snap
This commit is contained in:
Will Hunt 2025-12-03 15:21:15 +00:00 committed by GitHub
parent 61168f0531
commit a352a3838e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 135 additions and 45 deletions

View File

@ -1,4 +1,5 @@
/*
Copyright (C) 2025 Element Creations Ltd
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
@ -6,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, useCallback, useMemo, useState } from "react";
import React, { type JSX, useCallback, useState } from "react";
import { Text, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call-solid";
@ -29,7 +30,6 @@ import { _t } from "../../../../languageHandler.tsx";
import { getPlatformCallTypeProps, useRoomCall } from "../../../../hooks/room/useRoomCall.tsx";
import { useRoomThreadNotifications } from "../../../../hooks/room/useRoomThreadNotifications.ts";
import { useGlobalNotificationState } from "../../../../hooks/useGlobalNotificationState.ts";
import SdkConfig from "../../../../SdkConfig.ts";
import { useFeatureEnabled } from "../../../../hooks/useSettings.ts";
import { useEncryptionStatus } from "../../../../hooks/useEncryptionStatus.ts";
import { E2EStatus } from "../../../../utils/ShieldUtils.ts";
@ -78,16 +78,6 @@ function RoomHeaderButtons({
showVoiceCallButton,
showVideoCallButton,
} = useRoomCall(room);
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
/**
* A special mode where only Element Call is used. In this case we want to
* hide the voice call button
*/
const useElementCallExclusively = useMemo(() => {
return SdkConfig.get("element_call").use_exclusively && groupCallsEnabled;
}, [groupCallsEnabled]);
const threadNotifications = useRoomThreadNotifications(room);
const globalNotificationState = useGlobalNotificationState();
@ -101,6 +91,11 @@ function RoomHeaderButtons({
[callOptions, videoCallClick],
);
const voiceClick = useCallback(
(ev: React.MouseEvent) => voiceCallClick(ev, callOptions[0]),
[callOptions, voiceCallClick],
);
const toggleCallButton = (
<Tooltip label={isViewingCall ? _t("voip|minimise_call") : _t("voip|maximise_call")}>
<IconButton onClick={toggleCall}>
@ -126,35 +121,50 @@ function RoomHeaderButtons({
</Tooltip>
);
const callIconWithTooltip = (
const videoCallIconWithTooltip = (
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
<VideoCallIcon />
</Tooltip>
);
const [menuOpen, setMenuOpen] = useState(false);
const voiceCallIconWithTooltip = (
<Tooltip label={videoCallDisabledReason ?? _t("voip|voice_call")}>
<VoiceCallIcon />
</Tooltip>
);
const onOpenChange = useCallback(
const [videoMenuOpen, setVideoMenuOpen] = useState(false);
const onVideoOpenChange = useCallback(
(newOpen: boolean) => {
if (!videoCallDisabledReason) setMenuOpen(newOpen);
if (!videoCallDisabledReason) setVideoMenuOpen(newOpen);
},
[videoCallDisabledReason],
);
const [voiceMenuOpen, setVoiceMenuOpen] = useState(false);
const onVoiceOpenChange = useCallback(
(newOpen: boolean) => {
if (!voiceCallDisabledReason) setVoiceMenuOpen(newOpen);
},
[voiceCallDisabledReason],
);
const startVideoCallButton = (
<>
{/* Can be either a menu or just a button depending on the number of call options.*/}
{callOptions.length > 1 ? (
<Menu
open={menuOpen}
onOpenChange={onOpenChange}
open={videoMenuOpen}
onOpenChange={onVideoOpenChange}
title={_t("voip|video_call_using")}
trigger={
<IconButton
disabled={!!videoCallDisabledReason}
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
>
{callIconWithTooltip}
{videoCallIconWithTooltip}
</IconButton>
}
side="left"
@ -170,7 +180,7 @@ function RoomHeaderButtons({
children={children}
className="mx_RoomHeader_videoCallOption"
onClick={(ev) => {
setMenuOpen(false);
setVideoMenuOpen(false);
videoCallClick(ev, option);
}}
Icon={VideoCallIcon}
@ -185,25 +195,61 @@ function RoomHeaderButtons({
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
onClick={videoClick}
>
{callIconWithTooltip}
{videoCallIconWithTooltip}
</IconButton>
)}
</>
);
let voiceCallButton: JSX.Element | undefined = (
<Tooltip label={voiceCallDisabledReason ?? _t("voip|voice_call")}>
const startVoiceCallButton = (
<>
{/* Can be either a menu or just a button depending on the number of call options.*/}
{callOptions.length > 1 ? (
<Menu
open={voiceMenuOpen}
onOpenChange={onVoiceOpenChange}
title={_t("voip|voice_call_using")}
trigger={
<IconButton
// We need both: isViewingCall and isConnectedToCall
// - in the Lobby we are viewing a call but are not connected to it.
// - in pip view we are connected to the call but not viewing it.
disabled={!!voiceCallDisabledReason || isViewingCall || isConnectedToCall}
disabled={!!voiceCallDisabledReason}
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
onClick={(ev) => voiceCallClick(ev, callOptions[0])}
>
<VoiceCallIcon />
{voiceCallIconWithTooltip}
</IconButton>
</Tooltip>
}
side="left"
align="start"
>
{callOptions.map((option) => {
const { label, children } = getPlatformCallTypeProps(option);
return (
<MenuItem
key={option}
label={label}
aria-label={label}
children={children}
className="mx_RoomHeader_videoCallOption"
onClick={(ev) => {
setVoiceMenuOpen(false);
voiceCallClick(ev, option);
}}
Icon={VoiceCallIcon}
onSelect={() => {} /* Dummy handler since we want the click event.*/}
/>
);
})}
</Menu>
) : (
<IconButton
disabled={!!voiceCallDisabledReason}
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
onClick={voiceClick}
>
{voiceCallIconWithTooltip}
</IconButton>
)}
</>
);
const closeLobbyButton = (
<Tooltip label={_t("voip|close_lobby")}>
<IconButton onClick={toggleCall}>
@ -212,15 +258,19 @@ function RoomHeaderButtons({
</Tooltip>
);
let videoCallButton: JSX.Element | undefined = startVideoCallButton;
let voiceCallButton: JSX.Element | undefined = startVoiceCallButton;
if (isConnectedToCall) {
videoCallButton = toggleCallButton;
voiceCallButton = undefined;
} else if (isViewingCall) {
videoCallButton = closeLobbyButton;
voiceCallButton = undefined;
}
if (!showVideoCallButton) {
videoCallButton = undefined;
}
if (!showVoiceCallButton) {
voiceCallButton = undefined;
}
@ -258,7 +308,7 @@ function RoomHeaderButtons({
) : (
<>
{!isVideoRoom && videoCallButton}
{!useElementCallExclusively && !isVideoRoom && voiceCallButton}
{!isVideoRoom && voiceCallButton}
</>
)}

View File

@ -4103,6 +4103,7 @@
"video_call_using": "Video call using:",
"voice_call": "Voice call",
"voice_call_incoming": "Incoming voice call",
"voice_call_using": "Voice call using:",
"you_are_presenting": "You are presenting"
},
"web_default_device_name": "%(appName)s: %(browserName)s on %(osName)s",

View File

@ -1023,7 +1023,6 @@ exports[`RoomView should hide the composer when hideComposer=true 1`] = `
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="_r_5_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -1035,6 +1034,7 @@ exports[`RoomView should hide the composer when hideComposer=true 1`] = `
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_5_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -1519,7 +1519,6 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="_r_5g_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -1531,6 +1530,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_5g_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -1953,7 +1953,6 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="_r_2j_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -1965,6 +1964,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_2j_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -2387,7 +2387,6 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="_r_ag_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -2399,6 +2398,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_ag_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -2599,7 +2599,6 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="_r_ag_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -2611,6 +2610,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_ag_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"

View File

@ -1,4 +1,5 @@
/*
Copyright (C) 2025 Element Creations Ltd
Copyright 2024, 2025 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
@ -458,7 +459,10 @@ describe("RoomHeader", () => {
} as unknown as Call);
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]);
render(<RoomHeader room={room} />, getWrapper());
expect(screen.getByRole("button", { name: "Ongoing call" })).toHaveAttribute("aria-disabled", "true");
// Voice and video
for (const button of screen.getAllByRole("button", { name: "Ongoing call" })) {
expect(button).toHaveAttribute("aria-disabled", "true");
}
});
it("clicking on ongoing (unpinned) call re-pins it", async () => {
@ -632,6 +636,41 @@ describe("RoomHeader", () => {
expect(getByLabelText(document.body, _t("voip|get_call_link"))).toBeInTheDocument();
});
it("gives the option of element call or legacy calling for video", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 2);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
if (key === ElementCallMemberEventType.name) return true;
return false;
});
render(<RoomHeader room={room} />, getWrapper());
const button = screen.getByRole("button", { name: "Video call" });
expect(button).not.toHaveAttribute("aria-disabled", "true");
await user.click(button);
const elementCallButton = screen.getByRole("menuitem", { name: "Element Call" });
const legacyCallButton = screen.getByRole("menuitem", { name: "Legacy Call" });
expect(elementCallButton).toBeInTheDocument();
expect(legacyCallButton).toBeInTheDocument();
});
it("gives the option of element call or legacy calling for voice in DM rooms", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 2);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
if (key === ElementCallMemberEventType.name) return true;
return false;
});
render(<RoomHeader room={room} />, getWrapper());
const button = screen.getByRole("button", { name: "Voice call" });
expect(button).not.toHaveAttribute("aria-disabled", "true");
await user.click(button);
const elementCallButton = screen.getByRole("menuitem", { name: "Element Call" });
const legacyCallButton = screen.getByRole("menuitem", { name: "Legacy Call" });
expect(elementCallButton).toBeInTheDocument();
expect(legacyCallButton).toBeInTheDocument();
});
});
describe("public room", () => {

View File

@ -56,7 +56,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_14k_"
aria-labelledby="_r_17e_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -72,7 +72,6 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="_r_14p_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -84,6 +83,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_17j_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -98,7 +98,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
</button>
<button
aria-label="Threads"
aria-labelledby="_r_14u_"
aria-labelledby="_r_17o_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -125,7 +125,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
</button>
<button
aria-label="Room info"
aria-labelledby="_r_153_"
aria-labelledby="_r_17t_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"