diff --git a/apps/web/package.json b/apps/web/package.json index d6d8c9b17b..06617d8f62 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", 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 9745feee8f..66d905afb7 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 @@ -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); diff --git a/apps/web/playwright/e2e/voip/element-call.spec.ts b/apps/web/playwright/e2e/voip/element-call.spec.ts index 4acf5f7aad..7c69a8a896 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 5439a4cd5a..559de9ab32 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 01ad3384c1..8838386ac3 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 9a1d2cd0ac..e5294b4392 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 9a3b6e0054..0a3580977e 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 3861298d82..c90c4fc54f 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 51d8bc87a8..ad8bd39b6d 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 6ebcb8635f..b6c4e65be2 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 5d47b6b9b5..005dae5da0 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 5d47b6b9b5..005dae5da0 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 b0fd216c56..2cf31a3569 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 a7b7aea8a9..c52a3824f1 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 39a5ec6a19..66c62eb96e 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 f3abb3442d..4a0c736fd4 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 069ef66fe1..99cf03d9d3 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 d255b63c6c..63b6b79ebd 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 6f21e495d6..5d73403be2 100644 --- a/apps/web/res/css/structures/_ToastContainer.pcss +++ b/apps/web/res/css/structures/_ToastContainer.pcss @@ -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); diff --git a/apps/web/res/css/views/toasts/_IncomingCallToast.pcss b/apps/web/res/css/views/toasts/_IncomingCallToast.pcss index 95359a5fad..428f0884dc 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_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; } } } diff --git a/apps/web/src/components/views/rooms/LiveContentSummary.tsx b/apps/web/src/components/views/rooms/LiveContentSummary.tsx index 1e283d9cd3..725830222d 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 ffd5272b68..253c50da99 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 6405d2fe7b..4af9d4fc0d 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": { @@ -3895,6 +3894,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", @@ -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. Transfer to %(transferee)s", - "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", diff --git a/apps/web/src/models/Call.ts b/apps/web/src/models/Call.ts index 086bb51f0f..6cf95a0681 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 42148671a4..95f456431f 100644 --- a/apps/web/src/toasts/IncomingCallToast.tsx +++ b/apps/web/src/toasts/IncomingCallToast.tsx @@ -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 ( - - - +function JoinCallButtonWithCall({ onClick, disabledTooltip }: JoinCallButtonWithCallProps): JSX.Element { + const button = ( + + ); + + return disabledTooltip === undefined ? ( + button + ) : ( + {button} ); } @@ -115,19 +134,16 @@ function DeclineCallButtonWithNotificationEvent({ [notificationEvent, onDeclined, room?.client, room?.roomId], ); return ( - - - + ); } @@ -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>, prevParticipants: Map>) => { + (participants: Map>) => { 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({ 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" ? ( - {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 }); + } + + let title: string; + let Icon: ComponentType>; + 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 ( - - <> -
- {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 0d5e152639..b4183ea7ac 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.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(); 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/apps/web/webpack.config.ts b/apps/web/webpack.config.ts index 28b71436ad..c68c19e609 100644 --- a/apps/web/webpack.config.ts +++ b/apps/web/webpack.config.ts @@ -217,10 +217,16 @@ export default (env: string, argv: Record): 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(), ] 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 498eadee7a..9c9171bbd4 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 62e7a569bf..62c71e3a9c 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); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c7fae5c76..eca2667c70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: