Update toast styles, improve incoming call notifications (#33043)

* Update design of incoming call notifications

* Make toast show avatars of group call participants

* Further expand test coverage for call notifications

* Update screenshots

* Update screenshots

* Delete unused variables

* Upgrade Element Call to v0.19.2

For the new group call intents.

* Consolidate some branches

* Apply Compound spacing variables a little more

* Fix lints

* Exclude Element Call assets from being re-minified to fix build
This commit is contained in:
Robin 2026-04-27 16:37:42 +02:00 committed by GitHub
parent 2ea0c4106b
commit 03b730db58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 350 additions and 270 deletions

View File

@ -125,7 +125,7 @@
"@babel/preset-react": "^7.12.10",
"@babel/preset-typescript": "^7.12.7",
"@casualbot/jest-sonar-reporter": "2.6.0",
"@element-hq/element-call-embedded": "0.19.1",
"@element-hq/element-call-embedded": "0.19.2",
"@element-hq/element-web-playwright-common": "workspace:*",
"@fetch-mock/jest": "^0.2.20",
"@jest/globals": "^30.2.0",

View File

@ -318,6 +318,10 @@ test.describe("Room list", () => {
.click();
await page.getByRole("menuitem", { name: "New video room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("video room");
// Make it public to avoid any crypto setup toasts
await page.getByRole("button", { name: "Room visibility" }).click();
await page.getByRole("option", { name: "Public room" }).click();
await page.getByRole("textbox", { name: "Room address" }).fill("video-room");
await page.getByRole("button", { name: "Create video room" }).click();
const roomListView = getRoomList(page);

View File

@ -216,9 +216,9 @@ test.describe("Element Call", () => {
});
});
[true, false].forEach((skipLobbyToggle) => {
[true, false].forEach((joinWithVideo) => {
test(
`should be able to join a call via incoming video call toast (skipLobby=${skipLobbyToggle})`,
`should be able to join a call via incoming video call toast (joinWithVideo=${joinWithVideo})`,
{ tag: ["@screenshot"] },
async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
@ -230,7 +230,7 @@ test.describe("Element Call", () => {
const toast = page.locator(".mx_Toast_toast");
const button = toast.getByRole("button", { name: "Join" });
if (skipLobbyToggle) {
if (joinWithVideo) {
await toast.getByRole("switch").check();
await expect(toast).toMatchScreenshot(`incoming-call-group-video-toast-checked.png`);
} else {
@ -246,8 +246,8 @@ test.describe("Element Call", () => {
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(skipLobbyToggle.toString());
expect(hash.get("intent")).toEqual(joinWithVideo ? "join_existing" : "join_existing_voice");
expect(hash.get("skipLobby")).toEqual("true");
},
);
});
@ -275,7 +275,7 @@ test.describe("Element Call", () => {
const hash = new URLSearchParams(url.hash.slice(1));
assertCommonCallParameters(url.searchParams, hash, user, room);
expect(hash.get("intent")).toEqual("join_existing");
expect(hash.get("intent")).toEqual("join_existing_voice");
expect(hash.get("skipLobby")).toEqual("true");
},
);
@ -349,9 +349,9 @@ test.describe("Element Call", () => {
expect(hash.get("skipLobby")).toEqual(null);
});
[true, false].forEach((skipLobbyToggle) => {
[true, false].forEach((joinWithVideo) => {
test(
`should be able to join a call via incoming call toast (skipLobby=${skipLobbyToggle})`,
`should be able to join a call via incoming call toast (joinWithVideo=${joinWithVideo})`,
{ tag: ["@screenshot"] },
async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
@ -359,14 +359,14 @@ test.describe("Element Call", () => {
// Fake a start of a call
await sendRTCState(bot, room.roomId, "ring", "video");
const toast = page.locator(".mx_Toast_toast");
const button = toast.getByRole("button", { name: "Accept" });
if (skipLobbyToggle) {
const button = toast.getByRole("button", { name: "Join" });
if (joinWithVideo) {
await toast.getByRole("switch").check();
} else {
await toast.getByRole("switch").uncheck();
}
await expect(toast).toMatchScreenshot(
`incoming-call-dm-video-toast-${skipLobbyToggle ? "checked" : "unchecked"}.png`,
`incoming-call-dm-video-toast-${joinWithVideo ? "checked" : "unchecked"}.png`,
{
// Hide UserId
css: `
@ -385,8 +385,8 @@ test.describe("Element Call", () => {
const hash = new URLSearchParams(url.hash.slice(1));
assertCommonCallParameters(url.searchParams, hash, user, room);
expect(hash.get("intent")).toEqual("join_existing_dm");
expect(hash.get("skipLobby")).toEqual(skipLobbyToggle.toString());
expect(hash.get("intent")).toEqual(joinWithVideo ? "join_existing_dm" : "join_existing_dm_voice");
expect(hash.get("skipLobby")).toEqual("true");
},
);
});
@ -400,7 +400,7 @@ test.describe("Element Call", () => {
// 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" });
const button = toast.getByRole("button", { name: "Join" });
await expect(toast).toMatchScreenshot(`incoming-call-dm-voice-toast.png`, {
// Hide UserId

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,4 +1,5 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
@ -13,16 +14,16 @@ Please see LICENSE files in the repository root for full details.
z-index: 101;
padding: 4px;
display: grid;
grid-template-rows: 1fr 14px 6px;
grid-template-rows: 1fr 28px 8px;
&.mx_ToastContainer_stacked::before {
content: "";
margin: 0 4px;
grid-row: 2 / 4;
margin: 0 var(--cpd-space-1-5x);
grid-row: 2 / -1;
grid-column: 1;
background-color: $system;
box-shadow: 0px 4px 20px rgb(0, 0, 0, 0.5);
border-radius: 8px;
border-radius: var(--cpd-space-6x);
}
.mx_Toast_toast {
@ -32,19 +33,19 @@ Please see LICENSE files in the repository root for full details.
color: $primary-content;
box-shadow: 0px 4px 24px rgb(0, 0, 0, 0.1);
border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary);
border-radius: 12px;
border-radius: calc(var(--cpd-space-6x) - var(--cpd-border-width-1));
overflow: hidden;
display: grid;
grid-template-columns: 22px 1fr;
column-gap: 8px;
grid-template-columns: 20px 1fr auto;
column-gap: var(--cpd-space-2x);
row-gap: 4px;
align-items: center;
padding: var(--cpd-space-3x);
padding: calc(var(--cpd-space-5x) - var(--cpd-border-width-1));
&.mx_Toast_hasIcon {
svg {
width: 22px;
height: 22px;
width: 20px;
height: 20px;
grid-column: 1;
}
@ -52,31 +53,18 @@ Please see LICENSE files in the repository root for full details.
grid-column: 2;
}
.mx_Toast_body {
grid-column: 2 / 4;
}
.mx_Toast_closebutton {
grid-column: 3;
}
}
&:not(.mx_Toast_hasIcon) {
padding-left: 12px;
.mx_Toast_title {
grid-column: 1 / -1;
}
}
.mx_Toast_title,
.mx_Toast_description {
padding-right: 8px;
&:not(.mx_Toast_hasIcon) .mx_Toast_title {
grid-column: 1 / -1;
}
.mx_Toast_title {
display: flex;
align-items: center;
column-gap: 8px;
column-gap: var(--cpd-space-2x);
width: 100%;
box-sizing: border-box;
@ -89,14 +77,14 @@ Please see LICENSE files in the repository root for full details.
}
.mx_Toast_body {
grid-column: 1 / 3;
grid-column: 1 / -1;
grid-row: 2;
}
.mx_Toast_buttons {
display: flex;
justify-content: flex-end;
column-gap: 5px;
column-gap: var(--cpd-space-2x);
.mx_AccessibleButton {
min-width: 96px;
@ -108,7 +96,7 @@ Please see LICENSE files in the repository root for full details.
max-width: 272px;
overflow: hidden;
text-overflow: ellipsis;
margin: 4px 0 11px 0;
margin: var(--cpd-space-1x) 0 11px 0;
color: $secondary-content;
font: var(--cpd-font-body-sm-regular);

View File

@ -9,64 +9,55 @@ Please see LICENSE files in the repository root for full details.
.mx_IncomingCallToast {
position: relative;
display: flex;
flex-direction: row;
flex-direction: column;
pointer-events: initial; /* restore pointer events so the user can accept/decline */
$closeButtonSize: var(--cpd-space-4x);
.mx_IncomingCallToast_content {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
padding: var(--cpd-space-3x);
width: 100%;
overflow: hidden;
.mx_IncomingCallToast_message {
font-size: var(--cpd-font-size-body-lg);
line-height: var(--cpd-font-size-heading-sm);
width: calc(100% - $closeButtonSize - 2 * var(--cpd-space-1x));
font-weight: var(--cpd-font-weight-semibold);
.mx_IncomingCallToast_title {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--cpd-space-2x);
h2 {
margin: 0;
}
.mx_IncomingCallToast_expandButton {
padding: var(--cpd-space-1x);
color: var(--cpd-color-icon-secondary);
transition: color 0.1s;
&:hover {
color: var(--cpd-color-icon-primary);
}
& > svg {
display: block;
}
}
}
.mx_IncomingCallToast_avatars {
display: inline-block;
vertical-align: top;
}
.mx_IncomingCallToast_buttons {
display: flex;
gap: var(--cpd-space-2x);
padding-block-start: var(--cpd-space-2x);
}
.mx_IncomingCallToast_actionButton {
position: relative;
align-self: flex-end;
box-sizing: border-box;
min-width: 120px;
padding: var(--cpd-space-1x) 0;
padding-right: var(--cpd-space-4x);
line-height: var(--cpd-space-6x);
}
}
.mx_IncomingCallToast_closeButton {
position: absolute;
right: 0;
display: flex;
height: $closeButtonSize;
width: $closeButtonSize;
svg {
height: inherit;
width: inherit;
color: $secondary-content;
}
}
.mx_IncomingCallToast_toggleWithLabel {
display: flex;
span {
flex-grow: 1;
min-width: 131px;
}
}
}

View File

@ -8,13 +8,12 @@ Please see LICENSE files in the repository root for full details.
import React, { type FC } from "react";
import classNames from "classnames";
import { GroupIcon, VideoCallSolidIcon, VoiceCallSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { GroupIcon, VideoCallSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../languageHandler";
export enum LiveContentType {
Video,
Voice,
}
interface Props {
@ -27,14 +26,14 @@ interface Props {
/**
* Summary line used to call out live, interactive content such as calls.
*/
export const LiveContentSummary: FC<Props> = ({ type, text, active, participantCount }) => (
export const LiveContentSummary: FC<Props> = ({ text, active, participantCount }) => (
<span className="mx_LiveContentSummary">
<span
className={classNames("mx_LiveContentSummary_text", {
mx_LiveContentSummary_text_active: active,
})}
>
{type === LiveContentType.Video ? <VideoCallSolidIcon /> : <VoiceCallSolidIcon />}
<VideoCallSolidIcon />
{text}
</span>
{participantCount > 0 && (

View File

@ -50,15 +50,7 @@ export const useParticipantCount = (call: Call | null): number => {
}, [participants]);
};
export const useParticipatingMembers = (call: Call): RoomMember[] => {
export const useParticipatingMembers = (call: Call | null): RoomMember[] => {
const participants = useParticipants(call);
return useMemo(() => {
const members: RoomMember[] = [];
for (const [member, devices] of participants) {
// Repeat the member for as many devices as they're using
for (let i = 0; i < devices.size; i++) members.push(member);
}
return members;
}, [participants]);
return useMemo(() => [...participants.keys()], [participants]);
};

View File

@ -588,7 +588,6 @@
"video": "Video",
"video_room": "Video room",
"view_message": "View message",
"voice": "Voice",
"warning": "Warning"
},
"composer": {
@ -3895,6 +3894,16 @@
"call_held": "%(peerName)s held the call",
"call_held_resume": "You held the call <a>Resume</a>",
"call_held_switch": "You held the call <a>Switch</a>",
"call_members": {
"exhaustive": {
"one": "<avatars/>on the call",
"other": "<avatars/>on the call"
},
"overflow": {
"one": "<avatars/>+%(overflowCount)s on the call",
"other": "<avatars/>+%(overflowCount)s on the call"
}
},
"call_toast_unknown_room": "Unknown room",
"camera_disabled": "Your camera is turned off",
"camera_enabled": "Your camera is still enabled",
@ -3904,7 +3913,6 @@
"connection_lost": "Connectivity to the server has been lost",
"connection_lost_description": "You cannot place calls without a connection to the server.",
"consulting": "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>",
"decline_call": "Decline",
"default_device": "Default Device",
"dial": "Dial",
"dialpad": "Dialpad",
@ -3918,10 +3926,12 @@
"enable_microphone": "Unmute microphone",
"expand": "Return to call",
"get_call_link": "Share call link",
"group_call_started": "Group call started",
"hangup": "Hangup",
"hide_sidebar_button": "Hide sidebar",
"input_devices": "Input devices",
"jitsi_call": "Jitsi Conference",
"join_with_video": "Join with video",
"legacy_call": "Legacy Call",
"maximise": "Fill screen",
"maximise_call": "Maximise call",
@ -3955,7 +3965,6 @@
"show_sidebar_button": "Show sidebar",
"silence": "Silence call",
"silenced": "Notifications silenced",
"skip_lobby_toggle_option": "Join immediately",
"start_screenshare": "Start sharing your screen",
"stop_screenshare": "Stop sharing your screen",
"too_many_calls": "Too Many Calls",
@ -3977,7 +3986,6 @@
"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",

View File

@ -592,6 +592,8 @@ export class JitsiCall extends Call {
export enum ElementCallIntent {
StartCall = "start_call",
JoinExisting = "join_existing",
StartCallVoice = "start_call_voice",
JoinExistingVoice = "join_existing_voice",
StartCallDM = "start_call_dm",
StartCallDMVoice = "start_call_dm_voice",
JoinExistingDM = "join_existing_dm",
@ -685,11 +687,13 @@ export class ElementCall extends Call {
params.append("intent", voiceOnly ? ElementCallIntent.StartCallDMVoice : ElementCallIntent.StartCallDM);
}
} else {
// Group chats do not have a voice option.
if (hasCallStarted) {
params.append("intent", ElementCallIntent.JoinExisting);
params.append(
"intent",
voiceOnly ? ElementCallIntent.JoinExistingVoice : ElementCallIntent.JoinExisting,
);
} else {
params.append("intent", ElementCallIntent.StartCall);
params.append("intent", voiceOnly ? ElementCallIntent.StartCallVoice : ElementCallIntent.StartCall);
}
}
}

View File

@ -6,7 +6,17 @@ 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, useEffect, useRef, useState } from "react";
import React, {
type JSX,
type ReactNode,
type ComponentType,
type SVGAttributes,
useCallback,
useEffect,
useRef,
useState,
useId,
} from "react";
import {
type Room,
type MatrixEvent,
@ -15,11 +25,16 @@ import {
EventType,
MatrixEventEvent,
} from "matrix-js-sdk/src/matrix";
import { Button, ToggleInput, Tooltip, TooltipProvider } from "@vector-im/compound-web";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import { AvatarStack, Button, Form, Heading, InlineField, Label, ToggleInput, Tooltip } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
import { CheckIcon, VoiceCallIcon, CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import {
CheckIcon,
CloseIcon,
ExpandIcon,
VideoCallSolidIcon,
VoiceCallSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { AvatarWithDetails } from "@element-hq/web-shared-components";
import { _t } from "../languageHandler";
@ -29,8 +44,7 @@ import defaultDispatcher from "../dispatcher/dispatcher";
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../dispatcher/actions";
import ToastStore from "../stores/ToastStore";
import { LiveContentSummary, LiveContentType } from "../components/views/rooms/LiveContentSummary";
import { useCall, useParticipantCount } from "../hooks/useCall";
import { useCall, useParticipatingMembers } from "../hooks/useCall";
import AccessibleButton, { type ButtonEvent } from "../components/views/elements/AccessibleButton";
import { useDispatcher } from "../hooks/useDispatcher";
import { type ActionPayload } from "../dispatcher/payloads";
@ -39,6 +53,7 @@ import LegacyCallHandler, { AudioID } from "../LegacyCallHandler";
import { useEventEmitter, useTypedEventEmitter } from "../hooks/useEventEmitter";
import { CallStore, CallStoreEvent } from "../stores/CallStore";
import DMRoomMap from "../utils/DMRoomMap";
import MemberAvatar from "../components/views/avatars/MemberAvatar";
/**
* Get the key for the incoming call toast. A combination of the call ID and room ID.
@ -76,20 +91,24 @@ interface JoinCallButtonWithCallProps {
isRinging: boolean;
}
function JoinCallButtonWithCall({ onClick, disabledTooltip, isRinging }: JoinCallButtonWithCallProps): JSX.Element {
return (
<Tooltip description={disabledTooltip ?? _t("voip|video_call")}>
<Button
className="mx_IncomingCallToast_actionButton"
onClick={onClick}
disabled={disabledTooltip != undefined}
kind="primary"
Icon={CheckIcon}
size="md"
>
{isRinging ? _t("action|accept") : _t("action|join")}
</Button>
</Tooltip>
function JoinCallButtonWithCall({ onClick, disabledTooltip }: JoinCallButtonWithCallProps): JSX.Element {
const button = (
<Button
className="mx_IncomingCallToast_actionButton"
onClick={onClick}
disabled={disabledTooltip != undefined}
kind="primary"
Icon={CheckIcon}
size="md"
>
{_t("action|join")}
</Button>
);
return disabledTooltip === undefined ? (
button
) : (
<Tooltip description={disabledTooltip ?? _t("voip|video_call")}>{button}</Tooltip>
);
}
@ -115,19 +134,16 @@ function DeclineCallButtonWithNotificationEvent({
[notificationEvent, onDeclined, room?.client, room?.roomId],
);
return (
<Tooltip description={_t("voip|decline_call")}>
<Button
className="mx_IncomingCallToast_actionButton"
onClick={onClick}
kind="primary"
destructive
disabled={declining}
Icon={CloseIcon}
size="md"
>
{_t("action|decline")}
</Button>
</Tooltip>
<Button
className="mx_IncomingCallToast_actionButton"
onClick={onClick}
kind="secondary"
disabled={declining}
Icon={CloseIcon}
size="md"
>
{_t("action|ignore")}
</Button>
);
}
@ -210,7 +226,7 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E
// Dismiss if another device from this user joins.
const onParticipantChange = useCallback(
(participants: Map<RoomMember, Set<string>>, prevParticipants: Map<RoomMember, Set<string>>) => {
(participants: Map<RoomMember, Set<string>>) => {
if (Array.from(participants.keys()).some((p) => p.userId == room?.client.getUserId())) {
dismissToast();
}
@ -238,32 +254,33 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E
),
);
const [skipLobbyToggle, setSkipLobbyToggle] = useState(true);
const [videoToggle, setVideoToggle] = useState(true);
const videoToggleId = useId();
// Dismiss on clicking join.
// If the skip lobby option is undefined, it will use to the shift key state to decide if the lobby is skipped.
const onJoinClick = useCallback(
(e: ButtonEvent): void => {
e.stopPropagation();
const isVoice = notificationContent["m.call.intent"] === "audio";
const viewCall = useCallback(
(skipLobby: boolean) => {
// The toast will be automatically dismissed by the dispatcher callback above
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room?.roomId,
view_call: true,
skipLobby: ("shiftKey" in e && e.shiftKey) || skipLobbyToggle,
voiceOnly: notificationContent["m.call.intent"] === "audio",
skipLobby,
voiceOnly: isVoice || !videoToggle,
metricsTrigger: undefined,
});
},
[room, skipLobbyToggle, notificationContent],
[room, isVoice, videoToggle],
);
const onJoinClick = useCallback(() => viewCall(true), [viewCall]);
const onExpandClick = useCallback(() => viewCall(false), [viewCall]);
// Dismiss on closing toast.
const onCloseClick = useCallback(
(e: ButtonEvent): void => {
e.stopPropagation();
dismissToast();
},
[dismissToast],
@ -272,75 +289,101 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E
useEventEmitter(CallStore.instance, CallStoreEvent.Call, onCall);
useEventEmitter(call ?? undefined, CallEvent.Participants, onParticipantChange);
useEventEmitter(room, RoomEvent.Timeline, onTimelineChange);
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={isVoice ? LiveContentType.Voice : LiveContentType.Video}
text={isVoice ? _t("common|voice") : _t("common|video")}
active={false}
participantCount={participantCount}
/>
);
const members = useParticipatingMembers(call);
const avatars = (): ReactNode => (
<AvatarStack className="mx_IncomingCallToast_avatars">
{members.slice(0, 3).map((m) => (
<MemberAvatar key={m.userId} size="20px" member={m} aria-label={m.name} />
))}
</AvatarStack>
);
let detailsInformation: ReactNode;
if (notificationContent.notification_type === "ring") {
detailsInformation = <span>{otherUserId}</span>;
} else if (members.length > 0) {
detailsInformation =
members.length > 3
? _t(
"voip|call_members|overflow",
{ count: members.length, overflowCount: members.length - 3 },
{ avatars },
)
: _t("voip|call_members|exhaustive", { count: members.length }, { avatars });
}
let title: string;
let Icon: ComponentType<SVGAttributes<SVGElement>>;
let iconLabel: string;
// Special title for group calls
if (otherUserId === undefined) title = _t("voip|group_call_started");
if (isVoice) {
title ??= _t("voip|voice_call_incoming");
Icon = VoiceCallSolidIcon;
iconLabel = _t("voip|voice_call");
} else {
title ??= _t("voip|video_call_incoming");
Icon = VideoCallSolidIcon;
iconLabel = _t("voip|video_call");
}
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" }} />{" "}
{notificationContent.notification_type === "ring"
? _t("voip|video_call_incoming")
: _t("voip|video_call_started")}
</div>
)}
<AvatarWithDetails
avatar={<RoomAvatar room={room ?? undefined} size="32px" />}
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}
/>
</div>
)}
<div className="mx_IncomingCallToast_buttons">
<DeclineCallButtonWithNotificationEvent
notificationEvent={notificationEvent}
room={room}
onDeclined={onCloseClick}
/>
<JoinCallButtonWithCall
onClick={onJoinClick}
call={call}
isRinging={notificationContent.notification_type === "ring"}
disabledTooltip={otherCallIsOngoing ? "Ongoing call" : undefined}
/>
</div>
</div>
<div className="mx_IncomingCallToast_content">
<div className="mx_IncomingCallToast_title">
<Icon aria-label={iconLabel} width={20} height={20} />
<Heading as="h2" type="body" size="lg" weight="semibold">
{title}
</Heading>
<AccessibleButton
className="mx_IncomingCallToast_closeButton"
onClick={onCloseClick}
title={_t("action|close")}
className="mx_IncomingCallToast_expandButton"
onClick={onExpandClick}
title={_t("action|expand")}
>
<CloseIcon />
<ExpandIcon width={16} height={16} aria-hidden />
</AccessibleButton>
</>
</TooltipProvider>
</div>
<AvatarWithDetails
avatar={<RoomAvatar room={room ?? undefined} size="40px" />}
details={detailsInformation}
title={room ? room.name : _t("voip|call_toast_unknown_room")}
className="mx_IncomingCallToast_AvatarWithDetails"
/>
{!isVoice && (
<Form.Root
onSubmit={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
>
<InlineField
name="videoToggle"
control={
<ToggleInput
id={videoToggleId}
checked={videoToggle}
onChange={(e) => setVideoToggle(e.target.checked)}
/>
}
>
<Label htmlFor={videoToggleId}>{_t("voip|join_with_video")}</Label>
</InlineField>
</Form.Root>
)}
<div className="mx_IncomingCallToast_buttons">
<DeclineCallButtonWithNotificationEvent
notificationEvent={notificationEvent}
room={room}
onDeclined={onCloseClick}
/>
<JoinCallButtonWithCall
onClick={onJoinClick}
call={call}
isRinging={notificationContent.notification_type === "ring"}
disabledTooltip={otherCallIsOngoing ? "Ongoing call" : undefined}
/>
</div>
</div>
);
}

View File

@ -20,6 +20,7 @@ import {
RoomEvent,
type IRoomTimelineData,
type ISendEventResponse,
type IContent,
} from "matrix-js-sdk/src/matrix";
import { Widget } from "matrix-widget-api";
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
@ -50,12 +51,32 @@ import LegacyCallHandler, { AudioID } from "../../../src/LegacyCallHandler";
import { CallEvent } from "../../../src/models/Call";
import { type WidgetMessaging } from "../../../src/stores/widgets/WidgetMessaging";
function makeNotificationEvent(room: Room, content: IContent = {}): MatrixEvent {
const ts = Date.now();
const notificationContent = {
"notification_type": "notification",
"m.relation": { rel_type: "m.reference", event_id: "$memberEventId" },
"m.mentions": { user_ids: [], room: true },
"lifetime": 3000,
"sender_ts": ts,
...content,
} as unknown as IRTCNotificationContent;
return mkEvent({
type: EventType.RTCNotification,
user: "@userId:matrix.org",
content: notificationContent,
room: room.roomId,
ts,
id: "$notificationEventId",
event: true,
});
}
describe("IncomingCallToast", () => {
useMockedCalls();
let client: Mocked<MatrixClient>;
let room: Room;
let notificationEvent: MatrixEvent;
let alice: RoomMember;
let bob: RoomMember;
@ -77,23 +98,6 @@ describe("IncomingCallToast", () => {
document.body.appendChild(audio);
room = new Room("!1:example.org", client, "@alice:example.org");
const ts = Date.now();
const notificationContent = {
"notification_type": "notification",
"m.relation": { rel_type: "m.reference", event_id: "$memberEventId" },
"m.mentions": { user_ids: [], room: true },
"lifetime": 3000,
"sender_ts": ts,
} as unknown as IRTCNotificationContent;
notificationEvent = mkEvent({
type: EventType.RTCNotification,
user: "@userId:matrix.org",
content: notificationContent,
room: room.roomId,
ts,
id: "$notificationEventId",
event: true,
});
alice = mkRoomMember(room.roomId, "@alice:example.org");
bob = mkRoomMember(room.roomId, "@bob:example.org");
@ -130,7 +134,7 @@ describe("IncomingCallToast", () => {
jest.restoreAllMocks();
});
const renderToast = (): string => {
const renderToast = (notificationEvent: MatrixEvent = makeNotificationEvent(room)): string => {
const callId = randomUUID();
call.event.getContent = () =>
({
@ -146,27 +150,52 @@ describe("IncomingCallToast", () => {
return callId;
};
it("correctly shows all the information", () => {
it.each(["video", "voice"])("shows information for a group %s call", (callType: string) => {
call.participants = new Map([
[alice, new Set("a")],
[bob, new Set(["b1", "b2"])],
]);
renderToast();
const notificationEvent = makeNotificationEvent(room, {
"m.call.intent": callType === "voice" ? "audio" : "video",
});
renderToast(notificationEvent);
screen.getByText("Video call started");
screen.getByText("Video");
screen.getByLabelText("3 people joined");
screen.getByText("Group call started");
screen.getByLabelText(callType === "voice" ? "Voice call" : "Video call");
screen.getByLabelText("@alice:example.org");
screen.getByLabelText("@bob:example.org");
screen.getByText("on the call");
screen.getByRole("button", { name: "Join" });
screen.getByRole("button", { name: "Close" });
screen.getByRole("button", { name: "Ignore" });
screen.getByRole("button", { name: "Expand" });
});
it.each(["video", "voice"])("shows information for a DM %s call", (callType: string) => {
mocked(dmRoomMap.getUserIdForRoomId).mockImplementation((roomId) =>
roomId === room.roomId ? alice.userId : undefined,
);
try {
call.participants = new Map([[alice, new Set("a")]]);
const notificationEvent = makeNotificationEvent(room, {
"m.call.intent": callType === "voice" ? "audio" : "video",
});
renderToast(notificationEvent);
screen.getByText(callType === "voice" ? "Incoming voice call" : "Incoming video call");
screen.getByLabelText(callType === "voice" ? "Voice call" : "Video call");
screen.getByLabelText("@alice:example.org");
screen.getByRole("button", { name: "Join" });
screen.getByRole("button", { name: "Ignore" });
screen.getByRole("button", { name: "Expand" });
} finally {
mocked(dmRoomMap.getUserIdForRoomId).mockReset();
}
});
it("start ringing on ring notify event", () => {
const oldContent = notificationEvent.getContent() as IRTCNotificationContent;
(notificationEvent as unknown as { getContent: () => IRTCNotificationContent }).getContent = () => {
return { ...oldContent, notification_type: "ring" } as IRTCNotificationContent;
};
const notificationEvent = makeNotificationEvent(room, { notification_type: "ring" });
const playMock = jest.spyOn(LegacyCallHandler.instance, "play");
render(<IncomingCallToast notificationEvent={notificationEvent} toastKey="" />);
expect(playMock).toHaveBeenCalled();
@ -176,21 +205,23 @@ describe("IncomingCallToast", () => {
call.destroy();
renderToast();
screen.getByText("Video call started");
screen.getByText("Video");
screen.getByText("Group call started");
screen.getByLabelText("Video call");
expect(screen.queryByText("on the call")).toBe(null);
screen.getByRole("button", { name: "Join" });
screen.getByRole("button", { name: "Decline" });
screen.getByRole("button", { name: "Close" });
screen.getByRole("button", { name: "Ignore" });
screen.getByRole("button", { name: "Expand" });
});
it("opens the call directly and closes the toast when pressing on the join button", async () => {
it("joins with video and closes the toast", async () => {
const callId = renderToast();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
// click on the avatar (which is the example used for pressing on any area other than the buttons)
screen.getByRole("switch", { name: "Join with video", checked: true });
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
@ -208,23 +239,23 @@ describe("IncomingCallToast", () => {
defaultDispatcher.unregister(dispatcherRef);
});
it("opens the call lobby and closes the toast when configured like that", async () => {
it("joins without video and closes the toast", async () => {
const callId = renderToast();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("switch", {}));
screen.getByRole("switch", { name: "Join with video", checked: false });
// click on the avatar (which is the example used for pressing on any area other than the buttons)
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
skipLobby: false,
skipLobby: true,
view_call: true,
voiceOnly: false,
voiceOnly: true,
}),
);
await waitFor(() =>
@ -276,13 +307,22 @@ describe("IncomingCallToast", () => {
defaultDispatcher.unregister(dispatcherRef);
});
it("closes the toast", async () => {
it("expands to show the call lobby", async () => {
const callId = renderToast();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
fireEvent.click(screen.getByRole("button", { name: "Expand" }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
skipLobby: false,
view_call: true,
voiceOnly: false,
}),
);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
@ -316,7 +356,8 @@ describe("IncomingCallToast", () => {
});
it("closes toast when the notification event is redacted", async () => {
const callId = renderToast();
const notificationEvent = makeNotificationEvent(room);
const callId = renderToast(notificationEvent);
room.emit(MatrixEventEvent.BeforeRedaction, notificationEvent, {} as unknown as MatrixEvent);
@ -335,7 +376,8 @@ describe("IncomingCallToast", () => {
});
it("closes toast when a decline event was received", async () => {
const callId = renderToast();
const notificationEvent = makeNotificationEvent(room);
const callId = renderToast(notificationEvent);
room.emit(
RoomEvent.Timeline,
@ -357,7 +399,8 @@ describe("IncomingCallToast", () => {
});
it("does not close toast when a decline event for another user was received", async () => {
const callId = renderToast();
const notificationEvent = makeNotificationEvent(room);
const callId = renderToast(notificationEvent);
room.emit(
RoomEvent.Timeline,
@ -401,7 +444,7 @@ describe("IncomingCallToast", () => {
);
});
it("sends a decline event when clicking the decline button and only dismiss after sending", async () => {
it("sends a decline event when clicking the ignore button and only dismiss after sending", async () => {
const callId = renderToast();
const { promise, resolve } = Promise.withResolvers<ISendEventResponse>();
@ -409,7 +452,7 @@ describe("IncomingCallToast", () => {
return promise;
});
fireEvent.click(screen.getByRole("button", { name: "Decline" }));
fireEvent.click(screen.getByRole("button", { name: "Ignore" }));
expect(toastStore.dismissToast).not.toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId));
expect(client.sendRtcDecline).toHaveBeenCalledWith("!1:example.org", "$notificationEventId");
@ -422,6 +465,7 @@ describe("IncomingCallToast", () => {
});
it("getNotificationEventSendTs returns the correct ts", () => {
const notificationEvent = makeNotificationEvent(room);
const eventOriginServerTs = mkEvent({
user: "@userId:matrix.org",
type: EventType.RTCNotification,

View File

@ -217,10 +217,16 @@ export default (env: string, argv: Record<string, any>): webpack.Configuration =
minimizer: enableMinification
? [
new TerserPlugin({
// Already minified and includes an auto-generated license comment
// that the plugin would otherwise pointlessly extract into a separate
// file. We add the actual license using CopyWebpackPlugin below.
exclude: "jitsi_external_api.min.js",
exclude: [
// Already minified and includes an auto-generated license comment
// that the plugin would otherwise pointlessly extract into a separate
// file. We add the actual license using CopyWebpackPlugin below.
"jitsi_external_api.min.js",
// Already minified by Element Call's build process (and Terser has
// issues with some Unicode characters found within)
// https://github.com/terser/terser/issues/1677
"widgets/element-call/",
],
}),
new CssMinimizerPlugin(),
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@ -11,7 +11,7 @@
border-radius: 12px;
background-color: var(--cpd-color-gray-200);
padding: var(--cpd-space-2x);
padding: var(--cpd-space-3x);
gap: var(--cpd-space-2x);
.title {
@ -27,5 +27,6 @@
.details {
font-size: var(--cpd-font-size-body-sm);
color: var(--cpd-color-text-secondary);
}
}

10
pnpm-lock.yaml generated
View File

@ -571,8 +571,8 @@ importers:
specifier: 2.6.0
version: 2.6.0
'@element-hq/element-call-embedded':
specifier: 0.19.1
version: 0.19.1
specifier: 0.19.2
version: 0.19.2
'@element-hq/element-web-playwright-common':
specifier: workspace:*
version: link:../../packages/playwright-common
@ -2472,8 +2472,8 @@ packages:
engines: {node: '>=14.14'}
hasBin: true
'@element-hq/element-call-embedded@0.19.1':
resolution: {integrity: sha512-RDZY3P3LTx10ACaGhzkwh2+boNB3x54zHF/7v/cCyoQlAVfEYMhgMEb4CRTwJFwwYFe1r++6Higa0A0G5XxZ8Q==}
'@element-hq/element-call-embedded@0.19.2':
resolution: {integrity: sha512-HlqB/5RkWU7LAvArUJdSLsHpZJyTnGzn6LPYPAMinqShLkXa0ILeurL6G6zpTmRH+/uoJUfpHo3DQd+5Tq6/zg==}
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@ -14947,7 +14947,7 @@ snapshots:
- supports-color
optional: true
'@element-hq/element-call-embedded@0.19.1': {}
'@element-hq/element-call-embedded@0.19.2': {}
'@emnapi/core@1.10.0':
dependencies: