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 14:37:42 +00:00
committed by GitHub
parent 2ea0c4106b
commit 03b730db58
30 changed files with 350 additions and 270 deletions
+1 -1
View File
@@ -125,7 +125,7 @@
"@babel/preset-react": "^7.12.10", "@babel/preset-react": "^7.12.10",
"@babel/preset-typescript": "^7.12.7", "@babel/preset-typescript": "^7.12.7",
"@casualbot/jest-sonar-reporter": "2.6.0", "@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:*", "@element-hq/element-web-playwright-common": "workspace:*",
"@fetch-mock/jest": "^0.2.20", "@fetch-mock/jest": "^0.2.20",
"@jest/globals": "^30.2.0", "@jest/globals": "^30.2.0",
@@ -318,6 +318,10 @@ test.describe("Room list", () => {
.click(); .click();
await page.getByRole("menuitem", { name: "New video room" }).click(); await page.getByRole("menuitem", { name: "New video room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("video room"); 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(); await page.getByRole("button", { name: "Create video room" }).click();
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
@@ -216,9 +216,9 @@ test.describe("Element Call", () => {
}); });
}); });
[true, false].forEach((skipLobbyToggle) => { [true, false].forEach((joinWithVideo) => {
test( 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"] }, { tag: ["@screenshot"] },
async ({ page, user, bot, room, app }) => { async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId); await app.viewRoomById(room.roomId);
@@ -230,7 +230,7 @@ test.describe("Element Call", () => {
const toast = page.locator(".mx_Toast_toast"); const toast = page.locator(".mx_Toast_toast");
const button = toast.getByRole("button", { name: "Join" }); const button = toast.getByRole("button", { name: "Join" });
if (skipLobbyToggle) { if (joinWithVideo) {
await toast.getByRole("switch").check(); 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 { } else {
@@ -246,8 +246,8 @@ test.describe("Element Call", () => {
const hash = new URLSearchParams(url.hash.slice(1)); const hash = new URLSearchParams(url.hash.slice(1));
assertCommonCallParameters(url.searchParams, hash, user, room); assertCommonCallParameters(url.searchParams, hash, user, room);
expect(hash.get("intent")).toEqual("join_existing"); expect(hash.get("intent")).toEqual(joinWithVideo ? "join_existing" : "join_existing_voice");
expect(hash.get("skipLobby")).toEqual(skipLobbyToggle.toString()); expect(hash.get("skipLobby")).toEqual("true");
}, },
); );
}); });
@@ -275,7 +275,7 @@ test.describe("Element Call", () => {
const hash = new URLSearchParams(url.hash.slice(1)); const hash = new URLSearchParams(url.hash.slice(1));
assertCommonCallParameters(url.searchParams, hash, user, room); 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"); expect(hash.get("skipLobby")).toEqual("true");
}, },
); );
@@ -349,9 +349,9 @@ test.describe("Element Call", () => {
expect(hash.get("skipLobby")).toEqual(null); expect(hash.get("skipLobby")).toEqual(null);
}); });
[true, false].forEach((skipLobbyToggle) => { [true, false].forEach((joinWithVideo) => {
test( 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"] }, { tag: ["@screenshot"] },
async ({ page, user, bot, room, app }) => { async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId); await app.viewRoomById(room.roomId);
@@ -359,14 +359,14 @@ test.describe("Element Call", () => {
// Fake a start of a call // Fake a start of a call
await sendRTCState(bot, room.roomId, "ring", "video"); await sendRTCState(bot, room.roomId, "ring", "video");
const toast = page.locator(".mx_Toast_toast"); const toast = page.locator(".mx_Toast_toast");
const button = toast.getByRole("button", { name: "Accept" }); const button = toast.getByRole("button", { name: "Join" });
if (skipLobbyToggle) { if (joinWithVideo) {
await toast.getByRole("switch").check(); await toast.getByRole("switch").check();
} else { } else {
await toast.getByRole("switch").uncheck(); await toast.getByRole("switch").uncheck();
} }
await expect(toast).toMatchScreenshot( await expect(toast).toMatchScreenshot(
`incoming-call-dm-video-toast-${skipLobbyToggle ? "checked" : "unchecked"}.png`, `incoming-call-dm-video-toast-${joinWithVideo ? "checked" : "unchecked"}.png`,
{ {
// Hide UserId // Hide UserId
css: ` css: `
@@ -385,8 +385,8 @@ test.describe("Element Call", () => {
const hash = new URLSearchParams(url.hash.slice(1)); const hash = new URLSearchParams(url.hash.slice(1));
assertCommonCallParameters(url.searchParams, hash, user, room); assertCommonCallParameters(url.searchParams, hash, user, room);
expect(hash.get("intent")).toEqual("join_existing_dm"); expect(hash.get("intent")).toEqual(joinWithVideo ? "join_existing_dm" : "join_existing_dm_voice");
expect(hash.get("skipLobby")).toEqual(skipLobbyToggle.toString()); expect(hash.get("skipLobby")).toEqual("true");
}, },
); );
}); });
@@ -400,7 +400,7 @@ test.describe("Element Call", () => {
// Fake a start of a call // Fake a start of a call
await sendRTCState(bot, room.roomId, "ring", "audio"); await sendRTCState(bot, room.roomId, "ring", "audio");
const toast = page.locator(".mx_Toast_toast"); 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`, { await expect(toast).toMatchScreenshot(`incoming-call-dm-voice-toast.png`, {
// Hide UserId // 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

@@ -1,4 +1,5 @@
/* /*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C. 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; z-index: 101;
padding: 4px; padding: 4px;
display: grid; display: grid;
grid-template-rows: 1fr 14px 6px; grid-template-rows: 1fr 28px 8px;
&.mx_ToastContainer_stacked::before { &.mx_ToastContainer_stacked::before {
content: ""; content: "";
margin: 0 4px; margin: 0 var(--cpd-space-1-5x);
grid-row: 2 / 4; grid-row: 2 / -1;
grid-column: 1; grid-column: 1;
background-color: $system; background-color: $system;
box-shadow: 0px 4px 20px rgb(0, 0, 0, 0.5); box-shadow: 0px 4px 20px rgb(0, 0, 0, 0.5);
border-radius: 8px; border-radius: var(--cpd-space-6x);
} }
.mx_Toast_toast { .mx_Toast_toast {
@@ -32,19 +33,19 @@ Please see LICENSE files in the repository root for full details.
color: $primary-content; color: $primary-content;
box-shadow: 0px 4px 24px rgb(0, 0, 0, 0.1); 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: 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; overflow: hidden;
display: grid; display: grid;
grid-template-columns: 22px 1fr; grid-template-columns: 20px 1fr auto;
column-gap: 8px; column-gap: var(--cpd-space-2x);
row-gap: 4px; row-gap: 4px;
align-items: center; align-items: center;
padding: var(--cpd-space-3x); padding: calc(var(--cpd-space-5x) - var(--cpd-border-width-1));
&.mx_Toast_hasIcon { &.mx_Toast_hasIcon {
svg { svg {
width: 22px; width: 20px;
height: 22px; height: 20px;
grid-column: 1; grid-column: 1;
} }
@@ -52,31 +53,18 @@ Please see LICENSE files in the repository root for full details.
grid-column: 2; grid-column: 2;
} }
.mx_Toast_body {
grid-column: 2 / 4;
}
.mx_Toast_closebutton { .mx_Toast_closebutton {
grid-column: 3; grid-column: 3;
} }
} }
&:not(.mx_Toast_hasIcon) { &:not(.mx_Toast_hasIcon) .mx_Toast_title {
padding-left: 12px; grid-column: 1 / -1;
.mx_Toast_title {
grid-column: 1 / -1;
}
}
.mx_Toast_title,
.mx_Toast_description {
padding-right: 8px;
} }
.mx_Toast_title { .mx_Toast_title {
display: flex; display: flex;
align-items: center; align-items: center;
column-gap: 8px; column-gap: var(--cpd-space-2x);
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
@@ -89,14 +77,14 @@ Please see LICENSE files in the repository root for full details.
} }
.mx_Toast_body { .mx_Toast_body {
grid-column: 1 / 3; grid-column: 1 / -1;
grid-row: 2; grid-row: 2;
} }
.mx_Toast_buttons { .mx_Toast_buttons {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
column-gap: 5px; column-gap: var(--cpd-space-2x);
.mx_AccessibleButton { .mx_AccessibleButton {
min-width: 96px; min-width: 96px;
@@ -108,7 +96,7 @@ Please see LICENSE files in the repository root for full details.
max-width: 272px; max-width: 272px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
margin: 4px 0 11px 0; margin: var(--cpd-space-1x) 0 11px 0;
color: $secondary-content; color: $secondary-content;
font: var(--cpd-font-body-sm-regular); font: var(--cpd-font-body-sm-regular);
@@ -9,64 +9,55 @@ Please see LICENSE files in the repository root for full details.
.mx_IncomingCallToast { .mx_IncomingCallToast {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: row; flex-direction: column;
pointer-events: initial; /* restore pointer events so the user can accept/decline */ pointer-events: initial; /* restore pointer events so the user can accept/decline */
$closeButtonSize: var(--cpd-space-4x);
.mx_IncomingCallToast_content { .mx_IncomingCallToast_content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--cpd-space-4x); gap: var(--cpd-space-4x);
padding: var(--cpd-space-3x);
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
.mx_IncomingCallToast_message { .mx_IncomingCallToast_title {
font-size: var(--cpd-font-size-body-lg); display: grid;
line-height: var(--cpd-font-size-heading-sm); grid-template-columns: auto 1fr auto;
width: calc(100% - $closeButtonSize - 2 * var(--cpd-space-1x)); align-items: center;
font-weight: var(--cpd-font-weight-semibold); 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 { .mx_IncomingCallToast_buttons {
display: flex; display: flex;
gap: var(--cpd-space-2x); gap: var(--cpd-space-2x);
padding-block-start: var(--cpd-space-2x);
} }
.mx_IncomingCallToast_actionButton { .mx_IncomingCallToast_actionButton {
position: relative;
align-self: flex-end;
box-sizing: border-box; box-sizing: border-box;
min-width: 120px; min-width: 131px;
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;
} }
} }
} }
@@ -8,13 +8,12 @@ Please see LICENSE files in the repository root for full details.
import React, { type FC } from "react"; import React, { type FC } from "react";
import classNames from "classnames"; 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"; import { _t } from "../../../languageHandler";
export enum LiveContentType { export enum LiveContentType {
Video, Video,
Voice,
} }
interface Props { interface Props {
@@ -27,14 +26,14 @@ interface Props {
/** /**
* Summary line used to call out live, interactive content such as calls. * 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="mx_LiveContentSummary">
<span <span
className={classNames("mx_LiveContentSummary_text", { className={classNames("mx_LiveContentSummary_text", {
mx_LiveContentSummary_text_active: active, mx_LiveContentSummary_text_active: active,
})} })}
> >
{type === LiveContentType.Video ? <VideoCallSolidIcon /> : <VoiceCallSolidIcon />} <VideoCallSolidIcon />
{text} {text}
</span> </span>
{participantCount > 0 && ( {participantCount > 0 && (
+2 -10
View File
@@ -50,15 +50,7 @@ export const useParticipantCount = (call: Call | null): number => {
}, [participants]); }, [participants]);
}; };
export const useParticipatingMembers = (call: Call): RoomMember[] => { export const useParticipatingMembers = (call: Call | null): RoomMember[] => {
const participants = useParticipants(call); const participants = useParticipants(call);
return useMemo(() => [...participants.keys()], [participants]);
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]);
}; };
+12 -4
View File
@@ -588,7 +588,6 @@
"video": "Video", "video": "Video",
"video_room": "Video room", "video_room": "Video room",
"view_message": "View message", "view_message": "View message",
"voice": "Voice",
"warning": "Warning" "warning": "Warning"
}, },
"composer": { "composer": {
@@ -3895,6 +3894,16 @@
"call_held": "%(peerName)s held the call", "call_held": "%(peerName)s held the call",
"call_held_resume": "You held the call <a>Resume</a>", "call_held_resume": "You held the call <a>Resume</a>",
"call_held_switch": "You held the call <a>Switch</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", "call_toast_unknown_room": "Unknown room",
"camera_disabled": "Your camera is turned off", "camera_disabled": "Your camera is turned off",
"camera_enabled": "Your camera is still enabled", "camera_enabled": "Your camera is still enabled",
@@ -3904,7 +3913,6 @@
"connection_lost": "Connectivity to the server has been lost", "connection_lost": "Connectivity to the server has been lost",
"connection_lost_description": "You cannot place calls without a connection to the server.", "connection_lost_description": "You cannot place calls without a connection to the server.",
"consulting": "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>", "consulting": "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>",
"decline_call": "Decline",
"default_device": "Default Device", "default_device": "Default Device",
"dial": "Dial", "dial": "Dial",
"dialpad": "Dialpad", "dialpad": "Dialpad",
@@ -3918,10 +3926,12 @@
"enable_microphone": "Unmute microphone", "enable_microphone": "Unmute microphone",
"expand": "Return to call", "expand": "Return to call",
"get_call_link": "Share call link", "get_call_link": "Share call link",
"group_call_started": "Group call started",
"hangup": "Hangup", "hangup": "Hangup",
"hide_sidebar_button": "Hide sidebar", "hide_sidebar_button": "Hide sidebar",
"input_devices": "Input devices", "input_devices": "Input devices",
"jitsi_call": "Jitsi Conference", "jitsi_call": "Jitsi Conference",
"join_with_video": "Join with video",
"legacy_call": "Legacy Call", "legacy_call": "Legacy Call",
"maximise": "Fill screen", "maximise": "Fill screen",
"maximise_call": "Maximise call", "maximise_call": "Maximise call",
@@ -3955,7 +3965,6 @@
"show_sidebar_button": "Show sidebar", "show_sidebar_button": "Show sidebar",
"silence": "Silence call", "silence": "Silence call",
"silenced": "Notifications silenced", "silenced": "Notifications silenced",
"skip_lobby_toggle_option": "Join immediately",
"start_screenshare": "Start sharing your screen", "start_screenshare": "Start sharing your screen",
"stop_screenshare": "Stop sharing your screen", "stop_screenshare": "Stop sharing your screen",
"too_many_calls": "Too Many Calls", "too_many_calls": "Too Many Calls",
@@ -3977,7 +3986,6 @@
"user_is_presenting": "%(sharerName)s is presenting", "user_is_presenting": "%(sharerName)s is presenting",
"video_call": "Video call", "video_call": "Video call",
"video_call_incoming": "Incoming video call", "video_call_incoming": "Incoming video call",
"video_call_started": "Video call started",
"video_call_using": "Video call using:", "video_call_using": "Video call using:",
"voice_call": "Voice call", "voice_call": "Voice call",
"voice_call_incoming": "Incoming voice call", "voice_call_incoming": "Incoming voice call",
+7 -3
View File
@@ -592,6 +592,8 @@ export class JitsiCall extends Call {
export enum ElementCallIntent { export enum ElementCallIntent {
StartCall = "start_call", StartCall = "start_call",
JoinExisting = "join_existing", JoinExisting = "join_existing",
StartCallVoice = "start_call_voice",
JoinExistingVoice = "join_existing_voice",
StartCallDM = "start_call_dm", StartCallDM = "start_call_dm",
StartCallDMVoice = "start_call_dm_voice", StartCallDMVoice = "start_call_dm_voice",
JoinExistingDM = "join_existing_dm", JoinExistingDM = "join_existing_dm",
@@ -685,11 +687,13 @@ export class ElementCall extends Call {
params.append("intent", voiceOnly ? ElementCallIntent.StartCallDMVoice : ElementCallIntent.StartCallDM); params.append("intent", voiceOnly ? ElementCallIntent.StartCallDMVoice : ElementCallIntent.StartCallDM);
} }
} else { } else {
// Group chats do not have a voice option.
if (hasCallStarted) { if (hasCallStarted) {
params.append("intent", ElementCallIntent.JoinExisting); params.append(
"intent",
voiceOnly ? ElementCallIntent.JoinExistingVoice : ElementCallIntent.JoinExisting,
);
} else { } else {
params.append("intent", ElementCallIntent.StartCall); params.append("intent", voiceOnly ? ElementCallIntent.StartCallVoice : ElementCallIntent.StartCall);
} }
} }
} }
+151 -108
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. 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 { import {
type Room, type Room,
type MatrixEvent, type MatrixEvent,
@@ -15,11 +25,16 @@ import {
EventType, EventType,
MatrixEventEvent, MatrixEventEvent,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { Button, ToggleInput, Tooltip, TooltipProvider } from "@vector-im/compound-web"; import { AvatarStack, Button, Form, Heading, InlineField, Label, ToggleInput, Tooltip } from "@vector-im/compound-web";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; 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 { AvatarWithDetails } from "@element-hq/web-shared-components";
import { _t } from "../languageHandler"; import { _t } from "../languageHandler";
@@ -29,8 +44,7 @@ import defaultDispatcher from "../dispatcher/dispatcher";
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../dispatcher/actions"; import { Action } from "../dispatcher/actions";
import ToastStore from "../stores/ToastStore"; import ToastStore from "../stores/ToastStore";
import { LiveContentSummary, LiveContentType } from "../components/views/rooms/LiveContentSummary"; import { useCall, useParticipatingMembers } from "../hooks/useCall";
import { useCall, useParticipantCount } from "../hooks/useCall";
import AccessibleButton, { type ButtonEvent } from "../components/views/elements/AccessibleButton"; import AccessibleButton, { type ButtonEvent } from "../components/views/elements/AccessibleButton";
import { useDispatcher } from "../hooks/useDispatcher"; import { useDispatcher } from "../hooks/useDispatcher";
import { type ActionPayload } from "../dispatcher/payloads"; import { type ActionPayload } from "../dispatcher/payloads";
@@ -39,6 +53,7 @@ import LegacyCallHandler, { AudioID } from "../LegacyCallHandler";
import { useEventEmitter, useTypedEventEmitter } from "../hooks/useEventEmitter"; import { useEventEmitter, useTypedEventEmitter } from "../hooks/useEventEmitter";
import { CallStore, CallStoreEvent } from "../stores/CallStore"; import { CallStore, CallStoreEvent } from "../stores/CallStore";
import DMRoomMap from "../utils/DMRoomMap"; 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. * 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; isRinging: boolean;
} }
function JoinCallButtonWithCall({ onClick, disabledTooltip, isRinging }: JoinCallButtonWithCallProps): JSX.Element { function JoinCallButtonWithCall({ onClick, disabledTooltip }: JoinCallButtonWithCallProps): JSX.Element {
return ( const button = (
<Tooltip description={disabledTooltip ?? _t("voip|video_call")}> <Button
<Button className="mx_IncomingCallToast_actionButton"
className="mx_IncomingCallToast_actionButton" onClick={onClick}
onClick={onClick} disabled={disabledTooltip != undefined}
disabled={disabledTooltip != undefined} kind="primary"
kind="primary" Icon={CheckIcon}
Icon={CheckIcon} size="md"
size="md" >
> {_t("action|join")}
{isRinging ? _t("action|accept") : _t("action|join")} </Button>
</Button> );
</Tooltip>
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], [notificationEvent, onDeclined, room?.client, room?.roomId],
); );
return ( return (
<Tooltip description={_t("voip|decline_call")}> <Button
<Button className="mx_IncomingCallToast_actionButton"
className="mx_IncomingCallToast_actionButton" onClick={onClick}
onClick={onClick} kind="secondary"
kind="primary" disabled={declining}
destructive Icon={CloseIcon}
disabled={declining} size="md"
Icon={CloseIcon} >
size="md" {_t("action|ignore")}
> </Button>
{_t("action|decline")}
</Button>
</Tooltip>
); );
} }
@@ -210,7 +226,7 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E
// Dismiss if another device from this user joins. // Dismiss if another device from this user joins.
const onParticipantChange = useCallback( 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())) { if (Array.from(participants.keys()).some((p) => p.userId == room?.client.getUserId())) {
dismissToast(); 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. const isVoice = notificationContent["m.call.intent"] === "audio";
// 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 viewCall = useCallback(
(skipLobby: boolean) => {
// The toast will be automatically dismissed by the dispatcher callback above // The toast will be automatically dismissed by the dispatcher callback above
defaultDispatcher.dispatch<ViewRoomPayload>({ defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom, action: Action.ViewRoom,
room_id: room?.roomId, room_id: room?.roomId,
view_call: true, view_call: true,
skipLobby: ("shiftKey" in e && e.shiftKey) || skipLobbyToggle, skipLobby,
voiceOnly: notificationContent["m.call.intent"] === "audio", voiceOnly: isVoice || !videoToggle,
metricsTrigger: undefined, metricsTrigger: undefined,
}); });
}, },
[room, skipLobbyToggle, notificationContent], [room, isVoice, videoToggle],
); );
const onJoinClick = useCallback(() => viewCall(true), [viewCall]);
const onExpandClick = useCallback(() => viewCall(false), [viewCall]);
// Dismiss on closing toast. // Dismiss on closing toast.
const onCloseClick = useCallback( const onCloseClick = useCallback(
(e: ButtonEvent): void => { (e: ButtonEvent): void => {
e.stopPropagation(); e.stopPropagation();
dismissToast(); dismissToast();
}, },
[dismissToast], [dismissToast],
@@ -272,75 +289,101 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E
useEventEmitter(CallStore.instance, CallStoreEvent.Call, onCall); useEventEmitter(CallStore.instance, CallStoreEvent.Call, onCall);
useEventEmitter(call ?? undefined, CallEvent.Participants, onParticipantChange); useEventEmitter(call ?? undefined, CallEvent.Participants, onParticipantChange);
useEventEmitter(room, RoomEvent.Timeline, onTimelineChange); useEventEmitter(room, RoomEvent.Timeline, onTimelineChange);
const isVoice = notificationContent["m.call.intent"] === "audio";
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(roomId); const otherUserId = DMRoomMap.shared().getUserIdForRoomId(roomId);
const participantCount = useParticipantCount(call); const members = useParticipatingMembers(call);
const detailsInformation = const avatars = (): ReactNode => (
notificationContent.notification_type === "ring" ? ( <AvatarStack className="mx_IncomingCallToast_avatars">
<span>{otherUserId}</span> {members.slice(0, 3).map((m) => (
) : ( <MemberAvatar key={m.userId} size="20px" member={m} aria-label={m.name} />
<LiveContentSummary ))}
type={isVoice ? LiveContentType.Voice : LiveContentType.Video} </AvatarStack>
text={isVoice ? _t("common|voice") : _t("common|video")} );
active={false}
participantCount={participantCount} 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 ( return (
<TooltipProvider> <div className="mx_IncomingCallToast_content">
<> <div className="mx_IncomingCallToast_title">
<div className="mx_IncomingCallToast_content"> <Icon aria-label={iconLabel} width={20} height={20} />
{isVoice ? ( <Heading as="h2" type="body" size="lg" weight="semibold">
<div className="mx_IncomingCallToast_message"> {title}
<VoiceCallIcon width="20px" height="20px" style={{ position: "relative", top: "4px" }} />{" "} </Heading>
{_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>
<AccessibleButton <AccessibleButton
className="mx_IncomingCallToast_closeButton" className="mx_IncomingCallToast_expandButton"
onClick={onCloseClick} onClick={onExpandClick}
title={_t("action|close")} title={_t("action|expand")}
> >
<CloseIcon /> <ExpandIcon width={16} height={16} aria-hidden />
</AccessibleButton> </AccessibleButton>
</> </div>
</TooltipProvider> <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>
); );
} }
@@ -20,6 +20,7 @@ import {
RoomEvent, RoomEvent,
type IRoomTimelineData, type IRoomTimelineData,
type ISendEventResponse, type ISendEventResponse,
type IContent,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { Widget } from "matrix-widget-api"; import { Widget } from "matrix-widget-api";
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; 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 { CallEvent } from "../../../src/models/Call";
import { type WidgetMessaging } from "../../../src/stores/widgets/WidgetMessaging"; 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", () => { describe("IncomingCallToast", () => {
useMockedCalls(); useMockedCalls();
let client: Mocked<MatrixClient>; let client: Mocked<MatrixClient>;
let room: Room; let room: Room;
let notificationEvent: MatrixEvent;
let alice: RoomMember; let alice: RoomMember;
let bob: RoomMember; let bob: RoomMember;
@@ -77,23 +98,6 @@ describe("IncomingCallToast", () => {
document.body.appendChild(audio); document.body.appendChild(audio);
room = new Room("!1:example.org", client, "@alice:example.org"); 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"); alice = mkRoomMember(room.roomId, "@alice:example.org");
bob = mkRoomMember(room.roomId, "@bob:example.org"); bob = mkRoomMember(room.roomId, "@bob:example.org");
@@ -130,7 +134,7 @@ describe("IncomingCallToast", () => {
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
const renderToast = (): string => { const renderToast = (notificationEvent: MatrixEvent = makeNotificationEvent(room)): string => {
const callId = randomUUID(); const callId = randomUUID();
call.event.getContent = () => call.event.getContent = () =>
({ ({
@@ -146,27 +150,52 @@ describe("IncomingCallToast", () => {
return callId; 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([ call.participants = new Map([
[alice, new Set("a")], [alice, new Set("a")],
[bob, new Set(["b1", "b2"])], [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("Group call started");
screen.getByText("Video"); screen.getByLabelText(callType === "voice" ? "Voice call" : "Video call");
screen.getByLabelText("3 people joined"); screen.getByLabelText("@alice:example.org");
screen.getByLabelText("@bob:example.org");
screen.getByText("on the call");
screen.getByRole("button", { name: "Join" }); 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", () => { it("start ringing on ring notify event", () => {
const oldContent = notificationEvent.getContent() as IRTCNotificationContent; const notificationEvent = makeNotificationEvent(room, { notification_type: "ring" });
(notificationEvent as unknown as { getContent: () => IRTCNotificationContent }).getContent = () => {
return { ...oldContent, notification_type: "ring" } as IRTCNotificationContent;
};
const playMock = jest.spyOn(LegacyCallHandler.instance, "play"); const playMock = jest.spyOn(LegacyCallHandler.instance, "play");
render(<IncomingCallToast notificationEvent={notificationEvent} toastKey="" />); render(<IncomingCallToast notificationEvent={notificationEvent} toastKey="" />);
expect(playMock).toHaveBeenCalled(); expect(playMock).toHaveBeenCalled();
@@ -176,21 +205,23 @@ describe("IncomingCallToast", () => {
call.destroy(); call.destroy();
renderToast(); renderToast();
screen.getByText("Video call started"); screen.getByText("Group call started");
screen.getByText("Video"); screen.getByLabelText("Video call");
expect(screen.queryByText("on the call")).toBe(null);
screen.getByRole("button", { name: "Join" }); screen.getByRole("button", { name: "Join" });
screen.getByRole("button", { name: "Decline" }); screen.getByRole("button", { name: "Ignore" });
screen.getByRole("button", { name: "Close" }); 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 callId = renderToast();
const dispatcherSpy = jest.fn(); const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy); 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" })); fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() => await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({ expect(dispatcherSpy).toHaveBeenCalledWith({
@@ -208,23 +239,23 @@ describe("IncomingCallToast", () => {
defaultDispatcher.unregister(dispatcherRef); 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 callId = renderToast();
const dispatcherSpy = jest.fn(); const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy); const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("switch", {})); 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" })); fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() => await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({ expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom, action: Action.ViewRoom,
room_id: room.roomId, room_id: room.roomId,
skipLobby: false, skipLobby: true,
view_call: true, view_call: true,
voiceOnly: false, voiceOnly: true,
}), }),
); );
await waitFor(() => await waitFor(() =>
@@ -276,13 +307,22 @@ describe("IncomingCallToast", () => {
defaultDispatcher.unregister(dispatcherRef); defaultDispatcher.unregister(dispatcherRef);
}); });
it("closes the toast", async () => { it("expands to show the call lobby", async () => {
const callId = renderToast(); const callId = renderToast();
const dispatcherSpy = jest.fn(); const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy); 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(() => await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)), expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
); );
@@ -316,7 +356,8 @@ describe("IncomingCallToast", () => {
}); });
it("closes toast when the notification event is redacted", async () => { 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); 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 () => { it("closes toast when a decline event was received", async () => {
const callId = renderToast(); const notificationEvent = makeNotificationEvent(room);
const callId = renderToast(notificationEvent);
room.emit( room.emit(
RoomEvent.Timeline, RoomEvent.Timeline,
@@ -357,7 +399,8 @@ describe("IncomingCallToast", () => {
}); });
it("does not close toast when a decline event for another user was received", async () => { 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( room.emit(
RoomEvent.Timeline, 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 callId = renderToast();
const { promise, resolve } = Promise.withResolvers<ISendEventResponse>(); const { promise, resolve } = Promise.withResolvers<ISendEventResponse>();
@@ -409,7 +452,7 @@ describe("IncomingCallToast", () => {
return promise; 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(toastStore.dismissToast).not.toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId));
expect(client.sendRtcDecline).toHaveBeenCalledWith("!1:example.org", "$notificationEventId"); expect(client.sendRtcDecline).toHaveBeenCalledWith("!1:example.org", "$notificationEventId");
@@ -422,6 +465,7 @@ describe("IncomingCallToast", () => {
}); });
it("getNotificationEventSendTs returns the correct ts", () => { it("getNotificationEventSendTs returns the correct ts", () => {
const notificationEvent = makeNotificationEvent(room);
const eventOriginServerTs = mkEvent({ const eventOriginServerTs = mkEvent({
user: "@userId:matrix.org", user: "@userId:matrix.org",
type: EventType.RTCNotification, type: EventType.RTCNotification,
+10 -4
View File
@@ -217,10 +217,16 @@ export default (env: string, argv: Record<string, any>): webpack.Configuration =
minimizer: enableMinification minimizer: enableMinification
? [ ? [
new TerserPlugin({ new TerserPlugin({
// Already minified and includes an auto-generated license comment exclude: [
// that the plugin would otherwise pointlessly extract into a separate // Already minified and includes an auto-generated license comment
// file. We add the actual license using CopyWebpackPlugin below. // that the plugin would otherwise pointlessly extract into a separate
exclude: "jitsi_external_api.min.js", // 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(), new CssMinimizerPlugin(),
] ]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

@@ -11,7 +11,7 @@
border-radius: 12px; border-radius: 12px;
background-color: var(--cpd-color-gray-200); background-color: var(--cpd-color-gray-200);
padding: var(--cpd-space-2x); padding: var(--cpd-space-3x);
gap: var(--cpd-space-2x); gap: var(--cpd-space-2x);
.title { .title {
@@ -27,5 +27,6 @@
.details { .details {
font-size: var(--cpd-font-size-body-sm); font-size: var(--cpd-font-size-body-sm);
color: var(--cpd-color-text-secondary);
} }
} }
+5 -5
View File
@@ -571,8 +571,8 @@ importers:
specifier: 2.6.0 specifier: 2.6.0
version: 2.6.0 version: 2.6.0
'@element-hq/element-call-embedded': '@element-hq/element-call-embedded':
specifier: 0.19.1 specifier: 0.19.2
version: 0.19.1 version: 0.19.2
'@element-hq/element-web-playwright-common': '@element-hq/element-web-playwright-common':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/playwright-common version: link:../../packages/playwright-common
@@ -2472,8 +2472,8 @@ packages:
engines: {node: '>=14.14'} engines: {node: '>=14.14'}
hasBin: true hasBin: true
'@element-hq/element-call-embedded@0.19.1': '@element-hq/element-call-embedded@0.19.2':
resolution: {integrity: sha512-RDZY3P3LTx10ACaGhzkwh2+boNB3x54zHF/7v/cCyoQlAVfEYMhgMEb4CRTwJFwwYFe1r++6Higa0A0G5XxZ8Q==} resolution: {integrity: sha512-HlqB/5RkWU7LAvArUJdSLsHpZJyTnGzn6LPYPAMinqShLkXa0ILeurL6G6zpTmRH+/uoJUfpHo3DQd+5Tq6/zg==}
'@emnapi/core@1.10.0': '@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -14947,7 +14947,7 @@ snapshots:
- supports-color - supports-color
optional: true optional: true
'@element-hq/element-call-embedded@0.19.1': {} '@element-hq/element-call-embedded@0.19.2': {}
'@emnapi/core@1.10.0': '@emnapi/core@1.10.0':
dependencies: dependencies: