diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts index 30dd461041f..918fac756fd 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts @@ -317,6 +317,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); diff --git a/apps/web/playwright/e2e/voip/element-call.spec.ts b/apps/web/playwright/e2e/voip/element-call.spec.ts index 4acf5f7aad2..7c69a8a896c 100644 --- a/apps/web/playwright/e2e/voip/element-call.spec.ts +++ b/apps/web/playwright/e2e/voip/element-call.spec.ts @@ -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 diff --git a/apps/web/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png b/apps/web/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png index 5439a4cd5ac..559de9ab32b 100644 Binary files a/apps/web/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png and b/apps/web/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png differ diff --git a/apps/web/playwright/snapshots/crypto/toasts.spec.ts/verify-this-device-linux.png b/apps/web/playwright/snapshots/crypto/toasts.spec.ts/verify-this-device-linux.png index 01ad3384c12..8838386ac3c 100644 Binary files a/apps/web/playwright/snapshots/crypto/toasts.spec.ts/verify-this-device-linux.png and b/apps/web/playwright/snapshots/crypto/toasts.spec.ts/verify-this-device-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png index 9a1d2cd0ac3..e5294b4392c 100644 Binary files a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png and b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png index 9a3b6e0054c..0a3580977e6 100644 Binary files a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png and b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png index 3861298d828..c90c4fc54f3 100644 Binary files a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png and b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/window-custom-theme-linux.png b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/window-custom-theme-linux.png index 51d8bc87a81..ad8bd39b6d3 100644 Binary files a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/window-custom-theme-linux.png and b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/window-custom-theme-linux.png differ diff --git a/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png b/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png index 6ebcb8635f1..b6c4e65be23 100644 Binary files a/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png and b/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png differ diff --git a/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png b/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png index 5d47b6b9b5e..005dae5da01 100644 Binary files a/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png and b/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png differ diff --git a/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png b/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png index 5d47b6b9b5e..005dae5da01 100644 Binary files a/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png and b/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png differ diff --git a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-checked-linux.png b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-checked-linux.png index b0fd216c56e..2cf31a3569d 100644 Binary files a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-checked-linux.png and b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-checked-linux.png differ diff --git a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-unchecked-linux.png b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-unchecked-linux.png index a7b7aea8a95..c52a3824f1e 100644 Binary files a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-unchecked-linux.png and b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-unchecked-linux.png differ diff --git a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-voice-toast-linux.png b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-voice-toast-linux.png index 39a5ec6a19c..66c62eb96e7 100644 Binary files a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-voice-toast-linux.png and b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-voice-toast-linux.png differ diff --git a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-checked-linux.png b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-checked-linux.png index f3abb3442d3..4a0c736fd44 100644 Binary files a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-checked-linux.png and b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-checked-linux.png differ diff --git a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-unchecked-linux.png b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-unchecked-linux.png index 069ef66fe18..99cf03d9d3e 100644 Binary files a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-unchecked-linux.png and b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-unchecked-linux.png differ diff --git a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-voice-toast-linux.png b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-voice-toast-linux.png index d255b63c6c0..63b6b79ebdb 100644 Binary files a/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-voice-toast-linux.png and b/apps/web/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-voice-toast-linux.png differ diff --git a/apps/web/res/css/structures/_ToastContainer.pcss b/apps/web/res/css/structures/_ToastContainer.pcss index 6f21e495d60..0915810cc1a 100644 --- a/apps/web/res/css/structures/_ToastContainer.pcss +++ b/apps/web/res/css/structures/_ToastContainer.pcss @@ -13,16 +13,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 6px; + 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 +32,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,25 +52,12 @@ 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 { @@ -89,14 +76,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; diff --git a/apps/web/res/css/views/toasts/_IncomingCallToast.pcss b/apps/web/res/css/views/toasts/_IncomingCallToast.pcss index 95359a5fade..428f0884dcc 100644 --- a/apps/web/res/css/views/toasts/_IncomingCallToast.pcss +++ b/apps/web/res/css/views/toasts/_IncomingCallToast.pcss @@ -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_buttons { - display: flex; + .mx_IncomingCallToast_title { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; gap: var(--cpd-space-2x); - } - .mx_IncomingCallToast_actionButton { - position: relative; + h2 { + margin: 0; + } - align-self: flex-end; + .mx_IncomingCallToast_expandButton { + padding: var(--cpd-space-1x); + color: var(--cpd-color-icon-secondary); + transition: color 0.1s; - box-sizing: border-box; - min-width: 120px; + &:hover { + color: var(--cpd-color-icon-primary); + } - padding: var(--cpd-space-1x) 0; - padding-right: var(--cpd-space-4x); - line-height: var(--cpd-space-6x); + & > svg { + display: block; + } + } } - } - - .mx_IncomingCallToast_closeButton { - position: absolute; - - right: 0; - display: flex; - height: $closeButtonSize; - width: $closeButtonSize; + .mx_IncomingCallToast_avatars { + display: inline-block; + vertical-align: top; + } - svg { - height: inherit; - width: inherit; - color: $secondary-content; + .mx_IncomingCallToast_buttons { + display: flex; + gap: var(--cpd-space-2x); + padding-block-start: var(--cpd-space-2x); } - } - .mx_IncomingCallToast_toggleWithLabel { - display: flex; - span { - flex-grow: 1; + + .mx_IncomingCallToast_actionButton { + box-sizing: border-box; + min-width: 131px; } } } diff --git a/apps/web/src/components/views/rooms/LiveContentSummary.tsx b/apps/web/src/components/views/rooms/LiveContentSummary.tsx index 1e283d9cd32..725830222d0 100644 --- a/apps/web/src/components/views/rooms/LiveContentSummary.tsx +++ b/apps/web/src/components/views/rooms/LiveContentSummary.tsx @@ -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 = ({ type, text, active, participantCount }) => ( +export const LiveContentSummary: FC = ({ text, active, participantCount }) => ( - {type === LiveContentType.Video ? : } + {text} {participantCount > 0 && ( diff --git a/apps/web/src/hooks/useCall.ts b/apps/web/src/hooks/useCall.ts index ffd5272b682..253c50da99a 100644 --- a/apps/web/src/hooks/useCall.ts +++ b/apps/web/src/hooks/useCall.ts @@ -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]); }; diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 3f628fc9582..2e4ea41920e 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -588,7 +588,6 @@ "video": "Video", "video_room": "Video room", "view_message": "View message", - "voice": "Voice", "warning": "Warning" }, "composer": { @@ -3898,6 +3897,16 @@ "call_held": "%(peerName)s held the call", "call_held_resume": "You held the call Resume", "call_held_switch": "You held the call Switch", + "call_members": { + "exhaustive": { + "one": " on the call", + "other": " on the call" + }, + "overflow": { + "one": " +%(overflowCount)s on the call", + "other": " +%(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", @@ -3907,7 +3916,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. Transfer to %(transferee)s", - "decline_call": "Decline", "default_device": "Default Device", "dial": "Dial", "dialpad": "Dialpad", @@ -3921,10 +3929,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", @@ -3958,7 +3968,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", @@ -3980,7 +3989,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", diff --git a/apps/web/src/models/Call.ts b/apps/web/src/models/Call.ts index 086bb51f0fb..6cf95a06812 100644 --- a/apps/web/src/models/Call.ts +++ b/apps/web/src/models/Call.ts @@ -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); } } } diff --git a/apps/web/src/toasts/IncomingCallToast.tsx b/apps/web/src/toasts/IncomingCallToast.tsx index 42148671a40..ffa9bdf8c2f 100644 --- a/apps/web/src/toasts/IncomingCallToast.tsx +++ b/apps/web/src/toasts/IncomingCallToast.tsx @@ -6,7 +6,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, useEffect, useRef, useState } from "react"; +import React, { type JSX, type ReactNode, useCallback, useEffect, useRef, useState, useId } from "react"; import { type Room, type MatrixEvent, @@ -15,11 +15,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 +34,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 +43,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 +81,24 @@ interface JoinCallButtonWithCallProps { isRinging: boolean; } -function JoinCallButtonWithCall({ onClick, disabledTooltip, isRinging }: JoinCallButtonWithCallProps): JSX.Element { - return ( - - - +function JoinCallButtonWithCall({ onClick, disabledTooltip }: JoinCallButtonWithCallProps): JSX.Element { + const button = ( + + ); + + return disabledTooltip === undefined ? ( + button + ) : ( + {button} ); } @@ -115,19 +124,16 @@ function DeclineCallButtonWithNotificationEvent({ [notificationEvent, onDeclined, room?.client, room?.roomId], ); return ( - - - + ); } @@ -210,7 +216,7 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E // Dismiss if another device from this user joins. const onParticipantChange = useCallback( - (participants: Map>, prevParticipants: Map>) => { + (participants: Map>) => { if (Array.from(participants.keys()).some((p) => p.userId == room?.client.getUserId())) { dismissToast(); } @@ -238,32 +244,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({ 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 +279,95 @@ 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" ? ( - {otherUserId} - ) : ( - - ); + const members = useParticipatingMembers(call); + const avatars = (): ReactNode => ( + + {members.slice(0, 3).map((m) => ( + + ))} + + ); + + let detailsInformation: ReactNode; + if (notificationContent.notification_type === "ring") { + detailsInformation = {otherUserId}; + } 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 }); + } + + const Icon = isVoice ? VoiceCallSolidIcon : VideoCallSolidIcon; + const iconLabel = isVoice ? _t("voip|voice_call") : _t("voip|video_call"); + const title = + otherUserId === undefined + ? _t("voip|group_call_started") + : isVoice + ? _t("voip|voice_call_incoming") + : _t("voip|video_call_incoming"); return ( - - <> -
- {isVoice ? ( -
- {" "} - {_t("voip|voice_call_incoming")} -
- ) : ( -
- {" "} - {notificationContent.notification_type === "ring" - ? _t("voip|video_call_incoming") - : _t("voip|video_call_started")} -
- )} - } - details={detailsInformation} - title={room ? room.name : _t("voip|call_toast_unknown_room")} - className="mx_IncomingCallToast_AvatarWithDetails" - /> - {!isVoice && ( -
- {_t("voip|skip_lobby_toggle_option")} - setSkipLobbyToggle(e.target.checked)} - checked={skipLobbyToggle} - /> -
- )} -
- - -
-
+
+
+ + + {title} + - + - - +
+ } + details={detailsInformation} + title={room ? room.name : _t("voip|call_toast_unknown_room")} + className="mx_IncomingCallToast_AvatarWithDetails" + /> + {!isVoice && ( + { + evt.preventDefault(); + evt.stopPropagation(); + }} + > + setVideoToggle(e.target.checked)} + /> + } + > + + + + )} +
+ + +
+
); } diff --git a/apps/web/test/unit-tests/toasts/IncomingCallToast-test.tsx b/apps/web/test/unit-tests/toasts/IncomingCallToast-test.tsx index 0d5e152639e..b4183ea7ac0 100644 --- a/apps/web/test/unit-tests/toasts/IncomingCallToast-test.tsx +++ b/apps/web/test/unit-tests/toasts/IncomingCallToast-test.tsx @@ -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; 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("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; - }; + 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 notificationEvent = makeNotificationEvent(room, { notification_type: "ring" }); const playMock = jest.spyOn(LegacyCallHandler.instance, "play"); render(); 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(); @@ -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, diff --git a/packages/shared-components/__vis__/linux/__baselines__/core/AvatarWithDetails/AvatarWithDetails.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/core/AvatarWithDetails/AvatarWithDetails.stories.tsx/default-auto.png index 498eadee7a7..9c9171bbd43 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/core/AvatarWithDetails/AvatarWithDetails.stories.tsx/default-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/core/AvatarWithDetails/AvatarWithDetails.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/src/core/AvatarWithDetails/AvatarWithDetails.module.css b/packages/shared-components/src/core/AvatarWithDetails/AvatarWithDetails.module.css index 62e7a569bf7..62c71e3a9cf 100644 --- a/packages/shared-components/src/core/AvatarWithDetails/AvatarWithDetails.module.css +++ b/packages/shared-components/src/core/AvatarWithDetails/AvatarWithDetails.module.css @@ -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); } }