diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts index 1911843046b..b3c31be7ac2 100644 --- a/playwright/e2e/voip/element-call.spec.ts +++ b/playwright/e2e/voip/element-call.spec.ts @@ -9,7 +9,7 @@ import type { EventType, Preset } from "matrix-js-sdk/src/matrix"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { test, expect } from "../../element-web-test"; import type { Credentials } from "../../plugins/homeserver"; -import type { Bot } from "../../pages/bot"; +import { Bot } from "../../pages/bot"; function assertCommonCallParameters( url: URLSearchParams, @@ -27,27 +27,28 @@ function assertCommonCallParameters( expect(hash.get("preload")).toEqual("false"); } -async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "notification") { +async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "notification", intent?: string) { const resp = await bot.sendStateEvent( roomId, "org.matrix.msc3401.call.member", { - application: "m.call", - call_id: "", - device_id: "OiDFxsZrjz", - expires: 180000000, - foci_preferred: [ + "application": "m.call", + "call_id": "", + "m.call.intent": intent, + "device_id": "OiDFxsZrjz", + "expires": 180000000, + "foci_preferred": [ { livekit_alias: roomId, livekit_service_url: "https://example.org", type: "livekit", }, ], - focus_active: { + "focus_active": { focus_selection: "oldest_membership", type: "livekit", }, - scope: "m.room", + "scope": "m.room", }, `_@${bot.credentials.userId}_OiDFxsZrjz_m.call`, ); @@ -64,6 +65,7 @@ async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "n event_id: resp.event_id, rel_type: "org.matrix.msc4075.rtc.notification.parent", }, + "m.call.intent": intent, "notification_type": notification, "sender_ts": 1758611895996, }); @@ -103,15 +105,21 @@ test.describe("Element Call", () => { }); test.describe("Group Chat", () => { + let charlie: Bot; test.use({ - room: async ({ page, app, user, bot }, use) => { - const roomId = await app.client.createRoom({ name: "TestRoom", invite: [bot.credentials.userId] }); + room: async ({ page, app, user, homeserver, bot }, use) => { + charlie = new Bot(page, homeserver, { displayName: "Charlie" }); + await charlie.prepareClient(); + const roomId = await app.client.createRoom({ + name: "TestRoom", + invite: [bot.credentials.userId, charlie.credentials.userId], + }); await use({ roomId }); }, }); test("should be able to start a video call", async ({ page, user, room, app }) => { await app.viewRoomById(room.roomId); - await expect(page.getByText("Bob joined the room")).toBeVisible(); + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); await page.getByRole("button", { name: "Video call" }).click(); await page.getByRole("menuitem", { name: "Element Call" }).click(); @@ -126,9 +134,16 @@ test.describe("Element Call", () => { expect(hash.get("skipLobby")).toEqual(null); }); + test("should NOT be able to start a voice call", async ({ page, user, room, app }) => { + // Voice calls do not exist in group rooms + await app.viewRoomById(room.roomId); + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); + await expect(page.getByRole("button", { name: "Voice call" })).not.toBeVisible(); + }); + test("should be able to skip lobby by holding down shift", async ({ page, user, bot, room, app }) => { await app.viewRoomById(room.roomId); - await expect(page.getByText("Bob joined the room")).toBeVisible(); + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); await page.getByRole("button", { name: "Video call" }).click(); await page.keyboard.down("Shift"); @@ -147,8 +162,8 @@ test.describe("Element Call", () => { test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => { await app.viewRoomById(room.roomId); // Allow bob to create a call + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50); - await expect(page.getByText("Bob joined the room")).toBeVisible(); // Fake a start of a call await sendRTCState(bot, room.roomId); const button = page.getByTestId("join-call-button"); @@ -156,7 +171,6 @@ test.describe("Element Call", () => { // And test joining await button.click(); const frameUrlStr = await page.locator("iframe").getAttribute("src"); - console.log(frameUrlStr); await expect(frameUrlStr).toBeDefined(); const url = new URL(frameUrlStr); const hash = new URLSearchParams(url.hash.slice(1)); @@ -168,29 +182,29 @@ test.describe("Element Call", () => { [true, false].forEach((skipLobbyToggle) => { test( - `should be able to join a call via incoming call toast (skipLobby=${skipLobbyToggle})`, + `should be able to join a call via incoming video call toast (skipLobby=${skipLobbyToggle})`, { tag: ["@screenshot"] }, async ({ page, user, bot, room, app }) => { await app.viewRoomById(room.roomId); // Allow bob to create a call + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50); - await expect(page.getByText("Bob joined the room")).toBeVisible(); // Fake a start of a call - await sendRTCState(bot, room.roomId, "notification"); + await sendRTCState(bot, room.roomId, "notification", "video"); const toast = page.locator(".mx_Toast_toast"); const button = toast.getByRole("button", { name: "Join" }); + if (skipLobbyToggle) { 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 { await toast.getByRole("switch").uncheck(); - await expect(toast).toMatchScreenshot("incoming-call-group-video-toast-unchecked.png"); + await expect(toast).toMatchScreenshot(`incoming-call-group-video-toast-unchecked.png`); } // And test joining await button.click(); const frameUrlStr = await page.locator("iframe").getAttribute("src"); - console.log(frameUrlStr); await expect(frameUrlStr).toBeDefined(); const url = new URL(frameUrlStr); const hash = new URLSearchParams(url.hash.slice(1)); @@ -201,6 +215,34 @@ test.describe("Element Call", () => { }, ); }); + + test( + `should be able to join a call via incoming voice call toast`, + { tag: ["@screenshot"] }, + async ({ page, user, bot, room, app }) => { + await app.viewRoomById(room.roomId); + // Allow bob to create a call + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); + await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50); + // Fake a start of a call + await sendRTCState(bot, room.roomId, "notification", "audio"); + const toast = page.locator(".mx_Toast_toast"); + const button = toast.getByRole("button", { name: "Join" }); + + await expect(toast).toMatchScreenshot(`incoming-call-group-voice-toast.png`); + + // And test joining + await button.click(); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); + const url = new URL(frameUrlStr); + 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("true"); + }, + ); }); test.describe("DMs", () => { @@ -253,7 +295,6 @@ test.describe("Element Call", () => { test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => { await app.viewRoomById(room.roomId); - // Allow bob to create a call await expect(page.getByText("Bob joined the room")).toBeVisible(); // Fake a start of a call await sendRTCState(bot, room.roomId); @@ -262,7 +303,6 @@ test.describe("Element Call", () => { // And test joining await button.click(); const frameUrlStr = await page.locator("iframe").getAttribute("src"); - console.log(frameUrlStr); await expect(frameUrlStr).toBeDefined(); const url = new URL(frameUrlStr); const hash = new URLSearchParams(url.hash.slice(1)); @@ -278,24 +318,31 @@ test.describe("Element Call", () => { { tag: ["@screenshot"] }, async ({ page, user, bot, room, app }) => { await app.viewRoomById(room.roomId); - // Allow bob to create a call await expect(page.getByText("Bob joined the room")).toBeVisible(); // Fake a start of a call - await sendRTCState(bot, room.roomId, "ring"); + await sendRTCState(bot, room.roomId, "ring", "video"); const toast = page.locator(".mx_Toast_toast"); - const button = toast.getByRole("button", { name: "Join" }); + const button = toast.getByRole("button", { name: "Accept" }); if (skipLobbyToggle) { await toast.getByRole("switch").check(); - await expect(toast).toMatchScreenshot("incoming-call-dm-video-toast-checked.png"); } else { await toast.getByRole("switch").uncheck(); - await expect(toast).toMatchScreenshot("incoming-call-dm-video-toast-unchecked.png"); } + await expect(toast).toMatchScreenshot( + `incoming-call-dm-video-toast-${skipLobbyToggle ? "checked" : "unchecked"}.png`, + { + // Hide UserId + css: ` + .mx_IncomingCallToast_AvatarWithDetails span:nth-child(2) { + opacity: 0; + } + `, + }, + ); // And test joining await button.click(); const frameUrlStr = await page.locator("iframe").getAttribute("src"); - console.log(frameUrlStr); await expect(frameUrlStr).toBeDefined(); const url = new URL(frameUrlStr); const hash = new URLSearchParams(url.hash.slice(1)); @@ -306,6 +353,39 @@ test.describe("Element Call", () => { }, ); }); + + test( + `should be able to join a call via incoming voice call toast`, + { tag: ["@screenshot"] }, + async ({ page, user, bot, room, app }) => { + await app.viewRoomById(room.roomId); + await expect(page.getByText("Bob joined the room")).toBeVisible(); + // 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" }); + + await expect(toast).toMatchScreenshot(`incoming-call-dm-voice-toast.png`, { + // Hide UserId + css: ` + .mx_IncomingCallToast_AvatarWithDetails span:nth-child(2) { + opacity: 0; + } + `, + }); + + // And test joining + await button.click(); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, room); + + expect(hash.get("intent")).toEqual("join_existing_dm_voice"); + expect(hash.get("skipLobby")).toEqual("true"); + }, + ); }); test.describe("Video Rooms", () => { diff --git a/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-checked-linux.png b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-checked-linux.png index f309c37f3ae..b0fd216c56e 100644 Binary files a/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-checked-linux.png and b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-checked-linux.png differ diff --git a/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-unchecked-linux.png b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-unchecked-linux.png index 6d75547fcd5..a7b7aea8a95 100644 Binary files a/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-unchecked-linux.png and b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-unchecked-linux.png differ diff --git a/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-voice-toast-linux.png b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-voice-toast-linux.png new file mode 100644 index 00000000000..34ddc1b02fb Binary files /dev/null and b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-voice-toast-linux.png differ diff --git a/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-voice-toast-linux.png b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-voice-toast-linux.png new file mode 100644 index 00000000000..8841e13823b Binary files /dev/null and b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-voice-toast-linux.png differ diff --git a/res/css/views/rooms/_LiveContentSummary.pcss b/res/css/views/rooms/_LiveContentSummary.pcss index 0736f0dd45b..a367e7e75d8 100644 --- a/res/css/views/rooms/_LiveContentSummary.pcss +++ b/res/css/views/rooms/_LiveContentSummary.pcss @@ -25,6 +25,10 @@ Please see LICENSE files in the repository root for full details. mask-image: url("$(res)/img/element-icons/call/video-call.svg"); } + &.mx_LiveContentSummary_text_voice::before { + mask-image: url("$(res)/img/element-icons/call/voice-call.svg"); + } + &.mx_LiveContentSummary_text_active { color: $accent; diff --git a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx index 107d1a4c607..30576e2dc22 100644 --- a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; +import { CallType } from "matrix-js-sdk/src/webrtc/call"; import dispatcher from "../../../dispatcher/dispatcher"; import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; @@ -19,7 +20,7 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useEventEmitter, useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import { DefaultTagID } from "../../../stores/room-list/models"; import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall"; -import { type ConnectionState } from "../../../models/Call"; +import { CallEvent, type ConnectionState } from "../../../models/Call"; import { NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import DMRoomMap from "../../../utils/DMRoomMap"; import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; @@ -67,6 +68,10 @@ export interface RoomListItemViewState { * Whether there are participants in the call. */ hasParticipantInCall: boolean; + /** + * Whether the call is a voice or video call. + */ + callType: CallType | undefined; /** * Pre-rendered and translated preview for the latest message in the room, or undefined * if no preview should be shown. @@ -123,10 +128,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState { // EC video call or video room const call = useCall(room.roomId); const connectionState = useConnectionState(call); - const hasParticipantInCall = useParticipantCount(call) > 0; + const participantCount = useParticipantCount(call); const callConnectionState = call ? connectionState : null; - const showNotificationDecoration = hasVisibleNotification || hasParticipantInCall; + const showNotificationDecoration = hasVisibleNotification || participantCount > 0; // Actions @@ -138,6 +143,9 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState { }); }, [room]); + const [callType, setCallType] = useState(CallType.Video); + useTypedEventEmitter(call ?? undefined, CallEvent.CallTypeChanged, setCallType); + return { name, notificationState, @@ -148,9 +156,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState { isBold, isVideoRoom, callConnectionState, - hasParticipantInCall, + hasParticipantInCall: participantCount > 0, messagePreview, showNotificationDecoration, + callType: call ? callType : undefined, }; } diff --git a/src/components/views/rooms/LiveContentSummary.tsx b/src/components/views/rooms/LiveContentSummary.tsx index 1f5d36e8d02..ada3209f076 100644 --- a/src/components/views/rooms/LiveContentSummary.tsx +++ b/src/components/views/rooms/LiveContentSummary.tsx @@ -10,12 +10,10 @@ import React, { type FC } from "react"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; -import { type Call } from "../../../models/Call"; -import { useParticipantCount } from "../../../hooks/useCall"; export enum LiveContentType { Video, - // More coming soon + Voice, } interface Props { @@ -33,6 +31,7 @@ export const LiveContentSummary: FC = ({ type, text, active, participantC @@ -51,16 +50,3 @@ export const LiveContentSummary: FC = ({ type, text, active, participantC )} ); - -interface LiveContentSummaryWithCallProps { - call: Call; -} - -export const LiveContentSummaryWithCall: FC = ({ call }) => ( - -); diff --git a/src/components/views/rooms/NotificationDecoration.tsx b/src/components/views/rooms/NotificationDecoration.tsx index a6c6c5c93a5..212fd79ba1c 100644 --- a/src/components/views/rooms/NotificationDecoration.tsx +++ b/src/components/views/rooms/NotificationDecoration.tsx @@ -12,6 +12,8 @@ import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/ic import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid"; import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid"; import { UnreadCounter, Unread } from "@vector-im/compound-web"; +import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call-solid"; +import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { Flex } from "../../../shared-components/utils/Flex"; import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState"; @@ -24,9 +26,9 @@ interface NotificationDecorationProps extends HTMLProps { */ notificationState: RoomNotificationState; /** - * Whether the room has a video call. + * Whether the room has a voice or video call. */ - hasVideoCall: boolean; + callType?: CallType; } /** @@ -34,7 +36,7 @@ interface NotificationDecorationProps extends HTMLProps { */ export function NotificationDecoration({ notificationState, - hasVideoCall, + callType, ...props }: NotificationDecorationProps): JSX.Element | null { // Listen to the notification state and update the component when it changes @@ -58,7 +60,7 @@ export function NotificationDecoration({ muted: notificationState.muted, })); - if (!hasAnyNotificationOrActivity && !muted && !hasVideoCall) return null; + if (!hasAnyNotificationOrActivity && !muted && !callType) return null; return ( {isUnsentMessage && } - {hasVideoCall && } + {callType === CallType.Video && ( + + )} + {callType === CallType.Voice && ( + + )} {invited && } {isMention && } {(isMention || isNotification) && } diff --git a/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx b/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx index 500fbe953fe..4036876a4d0 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx @@ -137,7 +137,7 @@ export const RoomListItemView = memo(function RoomListItemView({ )} diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index e6616a7e215..04e2f713f24 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -121,7 +121,7 @@ export enum Action { UpdateSystemFont = "update_system_font", /** - * Changes room based on payload parameters. Should be used with JoinRoomPayload. + * Changes room based on payload parameters. Should be used with ViewRoomPayload. */ ViewRoom = "view_room", diff --git a/src/dispatcher/payloads/ViewRoomPayload.ts b/src/dispatcher/payloads/ViewRoomPayload.ts index 525dd50d624..c1dba33febe 100644 --- a/src/dispatcher/payloads/ViewRoomPayload.ts +++ b/src/dispatcher/payloads/ViewRoomPayload.ts @@ -39,6 +39,7 @@ interface BaseViewRoomPayload extends Pick { clear_search?: boolean; // Whether to clear the room list search view_call?: boolean; // Whether to view the call or call lobby for the room skipLobby?: boolean; // Whether to skip the call lobby when showing the call (only supported for element calls) + voiceOnly?: boolean; // Whether the call is voice only (only supported for element calls) opts?: JoinRoomPayload["opts"]; deferred_action?: ActionPayload; // Action to fire after MatrixChat handles this ViewRoom action diff --git a/src/hooks/room/useRoomCall.tsx b/src/hooks/room/useRoomCall.tsx index 39e77256594..f7bcbb1854c 100644 --- a/src/hooks/room/useRoomCall.tsx +++ b/src/hooks/room/useRoomCall.tsx @@ -140,11 +140,6 @@ export const useRoomCall = ( // If there are multiple options, the user will be prompted to choose. const callOptions = useMemo((): PlatformCallType[] => { const options: PlatformCallType[] = []; - if (memberCount <= 2) { - options.push(PlatformCallType.LegacyCall); - } else if (mayEditWidgets || hasJitsiWidget) { - options.push(PlatformCallType.JitsiCall); - } if (groupCallsEnabled) { if (hasGroupCall || mayCreateElementCalls) { options.push(PlatformCallType.ElementCall); @@ -153,6 +148,11 @@ export const useRoomCall = ( return [PlatformCallType.ElementCall]; } } + if (memberCount <= 2) { + options.push(PlatformCallType.LegacyCall); + } else if (mayEditWidgets || hasJitsiWidget) { + options.push(PlatformCallType.JitsiCall); + } if (hasGroupCall && WidgetType.CALL.matches(groupCall.widget.type)) { // only allow joining the ongoing Element call if there is one. return [PlatformCallType.ElementCall]; @@ -229,7 +229,7 @@ export const useRoomCall = ( if (widget && promptPinWidget) { WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); } else { - placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey || undefined); + placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey || undefined, true); } }, [promptPinWidget, room, widget], @@ -242,7 +242,7 @@ export const useRoomCall = ( } else { // If we have pressed shift then always skip the lobby, otherwise `undefined` will defer // to the defaults of the call implementation. - placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey || undefined); + placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey || undefined, false); } }, [widget, promptPinWidget, room], @@ -275,7 +275,13 @@ export const useRoomCall = ( }, [isViewingCall, room.roomId]); // We hide the voice call button if it'd have the same effect as the video call button - let hideVoiceCallButton = isManagedHybridWidgetEnabled(room) || !callOptions.includes(PlatformCallType.LegacyCall); + let hideVoiceCallButton = + isManagedHybridWidgetEnabled(room) || + // Disable voice calls if Legacy calls are disabled + (!callOptions.includes(PlatformCallType.LegacyCall) && + // Disable voice calls in ECall if the room is a group (we only present video calls for groups of users) + (!callOptions.includes(PlatformCallType.ElementCall) || memberCount > 2)); + let hideVideoCallButton = false; // We hide both buttons if they require widgets but widgets are disabled, or if the Voip feature is disabled. if ((memberCount > 2 && !widgetsFeatureEnabled) || !voipFeatureEnabled) { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 811edc15905..81753c70b18 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -604,6 +604,7 @@ "video": "Video", "video_room": "Video room", "view_message": "View message", + "voice": "Voice", "warning": "Warning" }, "composer": { @@ -4060,9 +4061,11 @@ "user_busy_description": "The user you called is busy.", "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", "you_are_presenting": "You are presenting" }, "web_default_device_name": "%(appName)s: %(browserName)s on %(osName)s", diff --git a/src/models/Call.ts b/src/models/Call.ts index 356aef1f3bc..8c43fef50b7 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -84,6 +84,7 @@ export enum CallEvent { Participants = "participants", Close = "close", Destroy = "destroy", + CallTypeChanged = "call_type_changed", } interface CallEventHandlerMap { @@ -94,6 +95,7 @@ interface CallEventHandlerMap { ) => void; [CallEvent.Close]: () => void; [CallEvent.Destroy]: () => void; + [CallEvent.CallTypeChanged]: (callType: CallType) => void; } /** @@ -103,6 +105,18 @@ export abstract class Call extends TypedEventEmitter params.append("font", font)); } this.appendAnalyticsParams(params, client); - this.appendRoomParams(params, client, roomId); + this.appendRoomParams(params, client, roomId, opts); const replacedUrl = params.toString().replace(/%24/g, "$"); url.hash = `#?${replacedUrl}`; @@ -755,11 +795,43 @@ export class ElementCall extends Call { ); } + /** + * Get the correct intent for a widget, so that Element Call presents the correct + * default config. + * @param client The matrix client. + * @param roomId + * @param voiceOnly Should the call be voice-only, or video (default). + */ + public static getWidgetIntent(client: MatrixClient, roomId: string, voiceOnly?: boolean): ElementCallIntent { + const room = client.getRoom(roomId); + if (room !== null && !isVideoRoom(room)) { + const isDM = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId); + const oldestCallMember = client.matrixRTC.getRoomSession(room).getOldestMembership(); + const hasCallStarted = !!oldestCallMember && oldestCallMember.sender !== client.getSafeUserId(); + if (isDM) { + if (hasCallStarted) { + return voiceOnly ? ElementCallIntent.JoinExistingDMVoice : ElementCallIntent.JoinExistingDM; + } else { + return voiceOnly ? ElementCallIntent.StartCallDMVoice : ElementCallIntent.StartCallDM; + } + } else { + if (hasCallStarted) { + return ElementCallIntent.JoinExisting; + } else { + return ElementCallIntent.StartCall; + } + } + } + // If unknown, default to joining an existing call. + return ElementCallIntent.JoinExisting; + } + private static getWidgetData( client: MatrixClient, roomId: string, currentData: IWidgetData, overwriteData: IWidgetData, + voiceOnly?: boolean, ): IWidgetData { let perParticipantE2EE = false; if ( @@ -767,9 +839,13 @@ export class ElementCall extends Call { !SettingsStore.getValue("feature_disable_call_per_sender_encryption") ) perParticipantE2EE = true; + + const intent = ElementCall.getWidgetIntent(client, roomId, voiceOnly); + return { ...currentData, ...overwriteData, + intent, perParticipantE2EE, }; } @@ -795,7 +871,7 @@ export class ElementCall extends Call { this.updateParticipants(); } - public static get(room: Room): ElementCall | null { + public static get(room: Room, voiceOnly?: boolean): ElementCall | null { const apps = WidgetStore.instance.getApps(room.roomId); const hasEcWidget = apps.some((app) => WidgetType.CALL.matches(app.type)); const session = room.client.matrixRTC.getRoomSession(room); @@ -878,7 +954,10 @@ export class ElementCall extends Call { if (this.session.memberships.length === 0 && !this.presented && !this.room.isCallRoom()) this.destroy(); }; - private readonly onMembershipChanged = (): void => this.updateParticipants(); + private readonly onMembershipChanged = (): void => { + this.updateParticipants(); + this.callType = this.session.getConsensusCallIntent() === "audio" ? CallType.Voice : CallType.Video; + }; private updateParticipants(): void { const participants = new Map>(); diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 418a7a7d4f4..b8bcf35a3a5 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -361,7 +361,9 @@ export class RoomViewStore extends EventEmitter { call.presented = true; // Immediately start the call. This will connect to all required widget events // and allow the widget to show the lobby. - if (call.connectionState === ConnectionState.Disconnected) call.start({ skipLobby: payload.skipLobby }); + if (call.connectionState === ConnectionState.Disconnected) { + call.start({ skipLobby: payload.skipLobby, voiceOnly: payload.voiceOnly }); + } } // If we switch to a different room from the call, we are no longer presenting it const prevRoomCall = this.state.roomId ? CallStore.instance.getCall(this.state.roomId) : null; diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx index b5f426986ef..e9edba6f201 100644 --- a/src/toasts/IncomingCallToast.tsx +++ b/src/toasts/IncomingCallToast.tsx @@ -14,6 +14,7 @@ import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check" import CrossIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; import { logger } from "matrix-js-sdk/src/logger"; import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; +import { VoiceCallIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t } from "../languageHandler"; import RoomAvatar from "../components/views/avatars/RoomAvatar"; @@ -22,12 +23,8 @@ import defaultDispatcher from "../dispatcher/dispatcher"; import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../dispatcher/actions"; import ToastStore from "../stores/ToastStore"; -import { - LiveContentSummary, - LiveContentSummaryWithCall, - LiveContentType, -} from "../components/views/rooms/LiveContentSummary"; -import { useCall, useJoinCallButtonDisabledTooltip } from "../hooks/useCall"; +import { LiveContentSummary, LiveContentType } from "../components/views/rooms/LiveContentSummary"; +import { useCall, useJoinCallButtonDisabledTooltip, useParticipantCount } from "../hooks/useCall"; import AccessibleButton, { type ButtonEvent } from "../components/views/elements/AccessibleButton"; import { useDispatcher } from "../hooks/useDispatcher"; import { type ActionPayload } from "../dispatcher/payloads"; @@ -36,6 +33,7 @@ import LegacyCallHandler, { AudioID } from "../LegacyCallHandler"; import { useEventEmitter } from "../hooks/useEventEmitter"; import { CallStore, CallStoreEvent } from "../stores/CallStore"; import { AvatarWithDetails } from "../shared-components/avatar/AvatarWithDetails"; +import DMRoomMap from "../utils/DMRoomMap"; /** * Get the key for the incoming call toast. A combination of the event ID and room ID. @@ -71,9 +69,15 @@ interface JoinCallButtonWithCallProps { onClick: (e: ButtonEvent) => void; call: Call | null; disabledTooltip: string | undefined; + isRinging: boolean; } -function JoinCallButtonWithCall({ onClick, call, disabledTooltip }: JoinCallButtonWithCallProps): JSX.Element { +function JoinCallButtonWithCall({ + onClick, + call, + disabledTooltip, + isRinging, +}: JoinCallButtonWithCallProps): JSX.Element { let disTooltip = disabledTooltip; const disabledBecauseFullTooltip = useJoinCallButtonDisabledTooltip(call); disTooltip = disabledTooltip ?? disabledBecauseFullTooltip ?? undefined; @@ -88,7 +92,7 @@ function JoinCallButtonWithCall({ onClick, call, disabledTooltip }: JoinCallButt Icon={CheckIcon} size="sm" > - {_t("action|join")} + {isRinging ? _t("action|accept") : _t("action|join")} ); @@ -152,7 +156,7 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element { // This section can race, so we use a ref to keep track of whether we have started trying to play. // This is because `LegacyCallHandler.play` tries to load the sound and then play it asynchonously // and `LegacyCallHandler.isPlaying` will not be `true` until the sound starts playing. - const isRingToast = notificationContent.notification_type == "ring"; + const isRingToast = notificationContent.notification_type === "ring"; if (isRingToast && !soundHasStarted.current && !LegacyCallHandler.instance.isPlaying(AudioID.Ring)) { // Start ringing if not already. soundHasStarted.current = true; @@ -243,10 +247,11 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element { room_id: room?.roomId, view_call: true, skipLobby: ("shiftKey" in e && e.shiftKey) || skipLobbyToggle, + voiceOnly: notificationContent["m.call.intent"] === "audio", metricsTrigger: undefined, }); }, - [room, skipLobbyToggle], + [room, skipLobbyToggle, notificationContent], ); // Dismiss on closing toast. @@ -262,34 +267,53 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element { 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 callLiveContentSummary = call ? ( - - ) : ( - - ); return ( <>
-
- {" "} - {_t("voip|video_call_started")} -
+ {isVoice ? ( +
+ {" "} + {_t("voip|voice_call_incoming")} +
+ ) : ( +
+ {" "} + {notificationContent.notification_type === "ring" + ? _t("voip|video_call_incoming") + : _t("voip|video_call_started")} +
+ )} } - details={callLiveContentSummary} + details={detailsInformation} title={room ? room.name : _t("voip|call_toast_unknown_room")} + className="mx_IncomingCallToast_AvatarWithDetails" /> -
- {_t("voip|skip_lobby_toggle_option")} - setSkipLobbyToggle(e.target.checked)} checked={skipLobbyToggle} /> -
+ {!isVoice && ( +
+ {_t("voip|skip_lobby_toggle_option")} + setSkipLobbyToggle(e.target.checked)} + checked={skipLobbyToggle} + /> +
+ )}
diff --git a/src/utils/room/placeCall.ts b/src/utils/room/placeCall.ts index 590ded7a805..6c046116ce9 100644 --- a/src/utils/room/placeCall.ts +++ b/src/utils/room/placeCall.ts @@ -27,7 +27,8 @@ export const placeCall = async ( room: Room, callType: CallType, platformCallType: PlatformCallType, - skipLobby?: boolean, + skipLobby: boolean | undefined, + voiceOnly: boolean, ): Promise => { const { analyticsName } = getPlatformCallTypeProps(platformCallType); PosthogTrackers.trackInteraction(analyticsName); @@ -39,6 +40,7 @@ export const placeCall = async ( action: Action.ViewRoom, room_id: room.roomId, view_call: true, + voiceOnly, skipLobby, metricsTrigger: undefined, }); diff --git a/test/test-utils/call.ts b/test/test-utils/call.ts index c122d1d756c..90ef5d1ea27 100644 --- a/test/test-utils/call.ts +++ b/test/test-utils/call.ts @@ -181,6 +181,7 @@ export function setUpClientRoomAndStores(): { const roomSession = new MockEventEmitter({ memberships: [], getOldestMembership: jest.fn().mockReturnValue(undefined), + getConsensusCallIntent: jest.fn().mockReturnValue(undefined), room, }) as Mocked; diff --git a/test/unit-tests/components/views/rooms/NotificationDecoration-test.tsx b/test/unit-tests/components/views/rooms/NotificationDecoration-test.tsx index c54e16215e3..deeab46bc51 100644 --- a/test/unit-tests/components/views/rooms/NotificationDecoration-test.tsx +++ b/test/unit-tests/components/views/rooms/NotificationDecoration-test.tsx @@ -7,6 +7,7 @@ import React from "react"; import { render, screen } from "jest-matrix-react"; +import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { RoomNotificationState } from "../../../../../src/stores/notifications/RoomNotificationState"; import { NotificationDecoration } from "../../../../../src/components/views/rooms/NotificationDecoration"; @@ -22,7 +23,7 @@ describe("", () => { it("should not render if RoomNotificationState.hasAnyNotificationOrActivity=true", () => { jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(false); - render(); + render(); expect(screen.queryByTestId("notification-decoration")).toBeNull(); }); @@ -30,7 +31,7 @@ describe("", () => { jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true); jest.spyOn(roomNotificationState, "isUnsentMessage", "get").mockReturnValue(true); const { asFragment } = render( - , + , ); expect(asFragment()).toMatchSnapshot(); }); @@ -39,7 +40,7 @@ describe("", () => { jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true); jest.spyOn(roomNotificationState, "invited", "get").mockReturnValue(true); const { asFragment } = render( - , + , ); expect(asFragment()).toMatchSnapshot(); }); @@ -49,7 +50,7 @@ describe("", () => { jest.spyOn(roomNotificationState, "isMention", "get").mockReturnValue(true); jest.spyOn(roomNotificationState, "count", "get").mockReturnValue(1); const { asFragment } = render( - , + , ); expect(asFragment()).toMatchSnapshot(); }); @@ -59,7 +60,7 @@ describe("", () => { jest.spyOn(roomNotificationState, "isNotification", "get").mockReturnValue(true); jest.spyOn(roomNotificationState, "count", "get").mockReturnValue(1); const { asFragment } = render( - , + , ); expect(asFragment()).toMatchSnapshot(); }); @@ -69,7 +70,7 @@ describe("", () => { jest.spyOn(roomNotificationState, "isNotification", "get").mockReturnValue(true); jest.spyOn(roomNotificationState, "count", "get").mockReturnValue(0); const { asFragment } = render( - , + , ); expect(asFragment()).toMatchSnapshot(); }); @@ -78,7 +79,7 @@ describe("", () => { jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true); jest.spyOn(roomNotificationState, "isActivityNotification", "get").mockReturnValue(true); const { asFragment } = render( - , + , ); expect(asFragment()).toMatchSnapshot(); }); @@ -87,14 +88,21 @@ describe("", () => { jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true); jest.spyOn(roomNotificationState, "muted", "get").mockReturnValue(true); const { asFragment } = render( - , + , ); expect(asFragment()).toMatchSnapshot(); }); - it("should render the video decoration", () => { + it("should render the video call decoration", () => { jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(false); const { asFragment } = render( - , + , + ); + expect(asFragment()).toMatchSnapshot(); + }); + it("should render the audio call decoration", () => { + jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(false); + const { asFragment } = render( + , ); expect(asFragment()).toMatchSnapshot(); }); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx index b72bcc4156d..aad78a09b7f 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx @@ -10,6 +10,7 @@ import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; import { render, screen, waitFor } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { mocked } from "jest-mock"; +import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { mkRoom, stubClient, withClientContextRenderOptions } from "../../../../../test-utils"; import { RoomListItemView } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListItemView"; @@ -64,6 +65,7 @@ describe("", () => { isBold: false, isVideoRoom: false, callConnectionState: null, + callType: CallType.Video, hasParticipantInCall: false, name: room.name, showNotificationDecoration: false, diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListItemView-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListItemView-test.tsx.snap index 180bfd5259a..a9ac6ee071e 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListItemView-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListItemView-test.tsx.snap @@ -103,6 +103,17 @@ exports[` should display notification decoration 1`] = ` data-testid="notification-decoration" style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;" > + + + diff --git a/test/unit-tests/components/views/rooms/__snapshots__/NotificationDecoration-test.tsx.snap b/test/unit-tests/components/views/rooms/__snapshots__/NotificationDecoration-test.tsx.snap index 11875acd14d..02aa6af6ac7 100644 --- a/test/unit-tests/components/views/rooms/__snapshots__/NotificationDecoration-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/__snapshots__/NotificationDecoration-test.tsx.snap @@ -16,6 +16,28 @@ exports[` should render the activity decoration 1`] = `; +exports[` should render the audio call decoration 1`] = ` + +
+ + + +
+
+`; + exports[` should render the invitation decoration 1`] = `
should render the unset message decoration 1 `; -exports[` should render the video decoration 1`] = ` +exports[` should render the video call decoration 1`] = `
{ stubClient(); client = mocked(MatrixClientPeg.safeGet()); + DMRoomMap.makeShared(client); room = new Room("!1:example.org", client, "@alice:example.org", { pendingEventOrdering: PendingEventOrdering.Detached, diff --git a/test/unit-tests/toasts/IncomingCallToast-test.tsx b/test/unit-tests/toasts/IncomingCallToast-test.tsx index 3ac1d89548f..fd6508c2286 100644 --- a/test/unit-tests/toasts/IncomingCallToast-test.tsx +++ b/test/unit-tests/toasts/IncomingCallToast-test.tsx @@ -188,6 +188,7 @@ describe("IncomingCallToast", () => { room_id: room.roomId, skipLobby: true, view_call: true, + voiceOnly: false, }), ); await waitFor(() => @@ -215,6 +216,7 @@ describe("IncomingCallToast", () => { room_id: room.roomId, skipLobby: false, view_call: true, + voiceOnly: false, }), ); await waitFor(() => @@ -239,6 +241,7 @@ describe("IncomingCallToast", () => { room_id: room.roomId, skipLobby: true, view_call: true, + voiceOnly: false, }), ); await waitFor(() =>