diff --git a/playwright/shared-component-snapshots/avatar-avatarwithdetails--default-linux.png b/playwright/shared-component-snapshots/avatar-avatarwithdetails--default-linux.png new file mode 100644 index 00000000000..ae339219e5f Binary files /dev/null and b/playwright/shared-component-snapshots/avatar-avatarwithdetails--default-linux.png differ diff --git a/res/css/views/toasts/_IncomingCallToast.pcss b/res/css/views/toasts/_IncomingCallToast.pcss index 2aafff6b04e..52a6cd9275f 100644 --- a/res/css/views/toasts/_IncomingCallToast.pcss +++ b/res/css/views/toasts/_IncomingCallToast.pcss @@ -11,76 +11,52 @@ Please see LICENSE files in the repository root for full details. display: flex; flex-direction: row; pointer-events: initial; /* restore pointer events so the user can accept/decline */ - width: 250px; - $closeButtonSize: 16px; + $closeButtonSize: var(--cpd-space-4x); .mx_IncomingCallToast_content { display: flex; flex-direction: column; - margin-left: 8px; + gap: var(--cpd-space-4x); + padding: var(--cpd-space-3x); width: 100%; overflow: hidden; - .mx_IncomingCallToast_info { - margin-bottom: $spacing-16; - - .mx_IncomingCallToast_room { - display: inline-block; - - font-weight: var(--cpd-font-weight-semibold); - font-size: $font-15px; - line-height: $font-24px; - - /* Prevent overlap with the close button */ - width: calc(100% - $closeButtonSize - 2 * $spacing-4); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - margin-bottom: $spacing-4; - } - - .mx_IncomingCallToast_message { - font-size: $font-12px; - line-height: $font-15px; - - margin-bottom: $spacing-4; - } + .mx_IncomingCallToast_message { + font-size: $font-17px; + line-height: $font-20px; + width: calc(100% - $closeButtonSize - 2 * var(--cpd-space-1x)); + font-weight: var(--cpd-font-weight-semibold); + } - .mx_LiveContentSummary { - font-size: $font-12px; - line-height: $font-15px; + .mx_LiveContentSummary_participants::before { + width: 15px; + height: 15px; + } - .mx_LiveContentSummary_participants::before { - width: 15px; - height: 15px; - } - } + .mx_IncomingCallToast_buttons { + display: flex; + gap: var(--cpd-space-2x); } - .mx_IncomingCallToast_joinButton { + .mx_IncomingCallToast_actionButton { position: relative; - bottom: $spacing-4; - right: $spacing-4; - align-self: flex-end; box-sizing: border-box; min-width: 120px; - padding: $spacing-4 0; - - line-height: $font-24px; + padding: var(--cpd-space-1x) 0; + padding-right: var(--cpd-space-4x); + line-height: var(--cpd-space-6x); } } .mx_IncomingCallToast_closeButton { position: absolute; - top: $spacing-4; - right: $spacing-4; + right: 0; display: flex; height: $closeButtonSize; @@ -99,4 +75,10 @@ Please see LICENSE files in the repository root for full details. mask-position: center; } } + .mx_IncomingCallToast_toggleWithLabel { + display: flex; + span { + flex-grow: 1; + } + } } diff --git a/src/Notifier.ts b/src/Notifier.ts index dd47b8b204f..aa68b386a34 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -25,7 +25,7 @@ import { } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { type PermissionChanged as PermissionChangedEvent } from "@matrix-org/analytics-events/types/typescript/PermissionChanged"; -import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; +import { type IRTCNotificationContent, MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; import { MatrixClientPeg } from "./MatrixClientPeg"; import { PosthogAnalytics } from "./PosthogAnalytics"; @@ -45,7 +45,7 @@ import { mediaFromMxc } from "./customisations/Media"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import { SdkContextClass } from "./contexts/SDKContext"; import { localNotificationsAreSilenced, createLocalNotificationSettingsIfNeeded } from "./utils/notifications"; -import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast"; +import { getIncomingCallToastKey, getNotificationEventSendTs, IncomingCallToast } from "./toasts/IncomingCallToast"; import ToastStore from "./stores/ToastStore"; import { stripPlainReply } from "./utils/Reply"; import { BackgroundAudio } from "./audio/BackgroundAudio"; @@ -486,41 +486,33 @@ class NotifierClass extends TypedEventEmitter m.sender === cli.getUserId()); - if (EventType.GroupCallMemberPrefix === type && thisUserHasConnectedDevice) { - const content = ev.getContent(); - - if (typeof content.call_id !== "string") { - logger.warn( - "Received malformatted GroupCallMemberPrefix event. Did not contain 'call_id' of type 'string'", - ); - return; - } - // One of our devices has joined the call, so dismiss it. - ToastStore.sharedInstance().dismissToast(getIncomingCallToastKey(content.call_id, room.roomId)); - } - // Check maximum age (<= 15 seconds) of a call notify event that will trigger a ringing notification - else if (EventType.CallNotify === type && (ev.getAge() ?? 0) < 15000 && !thisUserHasConnectedDevice) { - const content = ev.getContent(); + if (EventType.RTCNotification === ev.getType() && !thisUserHasConnectedDevice) { + const content = ev.getContent() as IRTCNotificationContent; const roomId = ev.getRoomId(); + const eventId = ev.getId(); - if (typeof content.call_id !== "string") { - logger.warn("Received malformatted CallNotify event. Did not contain 'call_id' of type 'string'"); + // Check maximum age of a call notification event that will trigger a ringing notification + if (Date.now() - getNotificationEventSendTs(ev) > content.lifetime) { + logger.warn("Received outdated RTCNotification event."); return; } if (!roomId) { - logger.warn("Could not get roomId for CallNotify event"); + logger.warn("Could not get roomId for RTCNotification event"); + return; + } + if (!eventId) { + logger.warn("Could not get eventId for RTCNotification event"); return; } ToastStore.sharedInstance().addOrReplaceToast({ - key: getIncomingCallToastKey(content.call_id, roomId), + key: getIncomingCallToastKey(eventId, roomId), priority: 100, component: IncomingCallToast, bodyClassName: "mx_IncomingCallToast", - props: { notifyEvent: ev }, + props: { notificationEvent: ev }, }); } } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 00ac3eb1168..f212820e396 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3989,6 +3989,7 @@ "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", @@ -4040,6 +4041,7 @@ "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", diff --git a/src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.module.css b/src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.module.css new file mode 100644 index 00000000000..8659131ba27 --- /dev/null +++ b/src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.module.css @@ -0,0 +1,31 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.avatarWithDetails { + display: flex; + align-items: center; + + border-radius: 12px; + background-color: var(--cpd-color-gray-200); + padding: var(--cpd-space-2x); + gap: var(--cpd-space-2x); + + .room { + display: inline-block; + + font-weight: var(--cpd-font-weight-semibold); + font-size: var(--cpd-font-size-body-md); + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .details { + font-size: var(--cpd-font-size-body-sm); + } +} diff --git a/src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.stories.tsx b/src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.stories.tsx new file mode 100644 index 00000000000..a79559218bf --- /dev/null +++ b/src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.stories.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; + +import { AvatarWithDetails } from "./AvatarWithDetails"; +import type { Meta, StoryFn } from "@storybook/react-vite"; + +export default { + title: "Avatar/AvatarWithDetails", + component: AvatarWithDetails, + tags: ["autodocs"], + args: { + avatar:
, + details: "Details about the avatar go here", + roomName: "Room Name", + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); diff --git a/src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.test.tsx b/src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.test.tsx new file mode 100644 index 00000000000..f5d482613f1 --- /dev/null +++ b/src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.test.tsx @@ -0,0 +1,21 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; + +import * as stories from "./AvatarWithDetails.stories.tsx"; + +const { Default } = composeStories(stories); + +describe("AvatarWithDetails", () => { + it("renders a textual event", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.tsx b/src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.tsx new file mode 100644 index 00000000000..e8186695e51 --- /dev/null +++ b/src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type ComponentProps, type ElementType, type JSX, type PropsWithChildren } from "react"; +import React from "react"; +import classNames from "classnames"; + +import styles from "./AvatarWithDetails.module.css"; +import { Flex } from "../../utils/Flex"; + +export type AvatarWithDetailsProps = { + /** + * The HTML tag. + * @default "div" + */ + as?: C; + /** + * The CSS class name. + */ + className?: string; + roomName: string; + avatar: React.ReactNode; + details: React.ReactNode; +} & ComponentProps; + +/** + * A component to display the body of a media message. + * + * @example + * ```tsx + * Media body content + * ``` + */ +export function AvatarWithDetails({ + as, + className, + details, + avatar, + roomName, + ...props +}: PropsWithChildren>): JSX.Element { + const Component = as || "div"; + + // Keep Mx_MediaBody to support the compatibility with existing timeline and the all the layout + return ( + + {avatar} + + {roomName} + {details} + + + ); +} diff --git a/src/shared-components/avatar/AvatarWithDetails/__snapshots__/AvatarWithDetails.test.tsx.snap b/src/shared-components/avatar/AvatarWithDetails/__snapshots__/AvatarWithDetails.test.tsx.snap new file mode 100644 index 00000000000..b676c729a6c --- /dev/null +++ b/src/shared-components/avatar/AvatarWithDetails/__snapshots__/AvatarWithDetails.test.tsx.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AvatarWithDetails renders a textual event 1`] = ` +
+
+
+
+ + Room Name + + + Details about the avatar go here + +
+
+
+`; diff --git a/src/shared-components/avatar/AvatarWithDetails/index.tsx b/src/shared-components/avatar/AvatarWithDetails/index.tsx new file mode 100644 index 00000000000..a54f416d829 --- /dev/null +++ b/src/shared-components/avatar/AvatarWithDetails/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { AvatarWithDetails } from "./AvatarWithDetails"; diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx index 9f393beb06d..903c0a2f526 100644 --- a/src/toasts/IncomingCallToast.tsx +++ b/src/toasts/IncomingCallToast.tsx @@ -7,9 +7,13 @@ Please see LICENSE files in the repository root for full details. */ import React, { type JSX, useCallback, useEffect, useState } from "react"; -import { type MatrixEvent, type RoomMember } from "matrix-js-sdk/src/matrix"; -import { Button, Tooltip, TooltipProvider } from "@vector-im/compound-web"; +import { type Room, type MatrixEvent, type RoomMember, RoomEvent, EventType } 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 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 { _t } from "../languageHandler"; import RoomAvatar from "../components/views/avatars/RoomAvatar"; @@ -31,8 +35,22 @@ import { type Call, CallEvent } from "../models/Call"; import LegacyCallHandler, { AudioID } from "../LegacyCallHandler"; import { useEventEmitter } from "../hooks/useEventEmitter"; import { CallStore, CallStoreEvent } from "../stores/CallStore"; +import { AvatarWithDetails } from "../shared-components/avatar/AvatarWithDetails"; -export const getIncomingCallToastKey = (callId: string, roomId: string): string => `call_${callId}_${roomId}`; +export const getIncomingCallToastKey = (notificationEventId: string, roomId: string): string => + `call_${notificationEventId}_${roomId}`; + +export const getNotificationEventSendTs = (event: MatrixEvent): number => { + const content = event.getContent() as Partial; + const sendTs = content.sender_ts; + if (sendTs && Math.abs(sendTs - event.getTs()) >= 15000) { + logger.warn( + "Received RTCNotification event. With large sender_ts origin_server_ts offset -> using origin_server_ts", + ); + return event.getTs(); + } + return sendTs ?? event.getTs(); +}; const MAX_RING_TIME_MS = 90 * 1000; interface JoinCallButtonWithCallProps { @@ -49,11 +67,11 @@ function JoinCallButtonWithCall({ onClick, call, disabledTooltip }: JoinCallButt return ( + + ); +} + interface Props { - notifyEvent: MatrixEvent; + notificationEvent: MatrixEvent; } -export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { - const roomId = notifyEvent.getRoomId()!; +export function IncomingCallToast({ notificationEvent }: Props): JSX.Element { + const roomId = notificationEvent.getRoomId()!; + // Use a partial type so ts still helps us to not miss any type checks. + const notificationContent = notificationEvent.getContent() as Partial; const room = MatrixClientPeg.safeGet().getRoom(roomId) ?? undefined; const call = useCall(roomId); const [connectedCalls, setConnectedCalls] = useState(Array.from(CallStore.instance.connectedCalls)); @@ -77,33 +135,54 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { const otherCallIsOngoing = connectedCalls.find((call) => call.roomId !== roomId); // Start ringing if not already. useEffect(() => { - const isRingToast = (notifyEvent.getContent() as unknown as { notify_type: string })["notify_type"] == "ring"; + const isRingToast = notificationContent.notification_type == "ring"; if (isRingToast && !LegacyCallHandler.instance.isPlaying(AudioID.Ring)) { LegacyCallHandler.instance.play(AudioID.Ring); } - }, [notifyEvent]); + }, [notificationContent.notification_type]); // Stop ringing on dismiss. const dismissToast = useCallback((): void => { - ToastStore.sharedInstance().dismissToast( - getIncomingCallToastKey(notifyEvent.getContent().call_id ?? "", roomId), - ); + const notificationId = notificationEvent.getId(); + if (!notificationId) { + logger.warn("Could not get eventId for RTCNotification event"); + return; + } + ToastStore.sharedInstance().dismissToast(getIncomingCallToastKey(notificationId, roomId)); LegacyCallHandler.instance.pause(AudioID.Ring); - }, [notifyEvent, roomId]); + }, [notificationEvent, roomId]); // Dismiss if session got ended remotely. const onCall = useCallback( (call: Call, callRoomId: string): void => { - const roomId = notifyEvent.getRoomId(); + const roomId = notificationEvent.getRoomId(); if (!roomId && roomId !== callRoomId) return; if (call === null || call.participants.size === 0) { dismissToast(); } }, - [dismissToast, notifyEvent], + [dismissToast, notificationEvent], ); - // Dismiss if antother device from this user joins. + // Dismiss if session got declined remotely. + const onTimelineChange = useCallback( + (ev: MatrixEvent) => { + const userId = room?.client.getUserId(); + if ( + ev.getType() === EventType.RTCDecline && + userId !== undefined && + ev.getSender() === userId //&& // It is our decline not someone elses + // TODO: We don't do this check for now since EX will send a different event relation so this check would not + // react to EX declines. + // ev.relationEventId === notificationEvent.getId() // The event declines this ringing toast. + ) { + dismissToast(); + } + }, + [dismissToast, room?.client], + ); + + // Dismiss if another device from this user joins. const onParticipantChange = useCallback( (participants: Map>, prevParticipants: Map>) => { if (Array.from(participants.keys()).some((p) => p.userId == room?.client.getUserId())) { @@ -115,7 +194,8 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { // Dismiss on timeout. useEffect(() => { - const timeout = setTimeout(dismissToast, MAX_RING_TIME_MS); + const lifetime = notificationContent.lifetime ?? MAX_RING_TIME_MS; + const timeout = setTimeout(dismissToast, getNotificationEventSendTs(notificationEvent) + lifetime - Date.now()); return () => clearTimeout(timeout); }); @@ -132,7 +212,10 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { ), ); + const [skipLobbyToggle, setSkipLobbyToggle] = useState(true); + // 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(); @@ -142,11 +225,11 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { action: Action.ViewRoom, room_id: room?.roomId, view_call: true, - skipLobby: "shiftKey" in e ? e.shiftKey : false, + skipLobby: skipLobbyToggle ?? ("shiftKey" in e ? e.shiftKey : false), metricsTrigger: undefined, }); }, - [room], + [room, skipLobbyToggle], ); // Dismiss on closing toast. @@ -161,35 +244,47 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { useEventEmitter(CallStore.instance, CallStoreEvent.Call, onCall); useEventEmitter(call ?? undefined, CallEvent.Participants, onParticipantChange); + useEventEmitter(room, RoomEvent.Timeline, onTimelineChange); + const callLiveContentSummary = call ? ( + + ) : ( + + ); return ( <> -
- -
-
- - {room ? room.name : _t("voip|call_toast_unknown_room")} - -
{_t("voip|video_call_started")}
- {call ? ( - - ) : ( - - )} +
+ {" "} + {_t("voip|video_call_started")}
- } + details={callLiveContentSummary} + roomName={room ? room.name : _t("voip|call_toast_unknown_room")} /> +
+ {_t("voip|skip_lobby_toggle_option")} + setSkipLobbyToggle(e.target.checked)} checked={skipLobbyToggle} /> +
+
+ + +
"t35tcl1Ent5ECr3T", isGuest: jest.fn().mockReturnValue(false), diff --git a/test/unit-tests/Notifier-test.ts b/test/unit-tests/Notifier-test.ts index e5b8f84f51d..5ff4b0bf771 100644 --- a/test/unit-tests/Notifier-test.ts +++ b/test/unit-tests/Notifier-test.ts @@ -385,33 +385,49 @@ describe("Notifier", () => { jest.resetAllMocks(); }); - const emitCallNotifyEvent = (type?: string, roomMention = true) => { - const callEvent = mkEvent({ - type: type ?? EventType.CallNotify, + const emitCallNotificationEvent = ( + params: { + type?: string; + roomMention?: boolean; + lifetime?: number; + ts?: number; + } = {}, + ) => { + const { type, roomMention, lifetime, ts } = { + type: EventType.RTCNotification, + roomMention: true, + lifetime: 30000, + ts: Date.now(), + ...params, + }; + const notificationEvent = mkEvent({ + type: type, user: "@alice:foo", room: roomId, + ts, content: { - "application": "m.call", + "notification_type": "ring", + "m.relation": { rel_type: "m.reference", event_id: "$memberEventId" }, "m.mentions": { user_ids: [], room: roomMention }, - "notify_type": "ring", - "call_id": "abc123", + lifetime, + "sender_ts": ts, }, event: true, }); - emitLiveEvent(callEvent); - return callEvent; + emitLiveEvent(notificationEvent); + return notificationEvent; }; it("shows group call toast", () => { - const notifyEvent = emitCallNotifyEvent(); + const notificationEvent = emitCallNotificationEvent(); expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith( expect.objectContaining({ - key: getIncomingCallToastKey(notifyEvent.getContent().call_id ?? "", roomId), + key: getIncomingCallToastKey(notificationEvent.getId() ?? "", roomId), priority: 100, component: IncomingCallToast, bodyClassName: "mx_IncomingCallToast", - props: { notifyEvent }, + props: { notificationEvent }, }), ); }); @@ -439,59 +455,19 @@ describe("Notifier", () => { const roomSession = MatrixRTCSession.roomSessionForRoom(mockClient, testRoom); mockClient.matrixRTC.getRoomSession.mockReturnValue(roomSession); - emitCallNotifyEvent(); + emitCallNotificationEvent(); expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled(); spyCallMemberships.mockRestore(); }); - it("dismisses call notification when another device answers the call", () => { - const notifyEvent = emitCallNotifyEvent(); - const spyCallMemberships = jest.spyOn(MatrixRTCSession, "callMembershipsForRoom"); + it("should not show toast when calling with a different event type to org.matrix.msc4075.rtc.notification", () => { + emitCallNotificationEvent({ type: "event_type" }); - expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith( - expect.objectContaining({ - key: getIncomingCallToastKey(notifyEvent.getContent().call_id ?? "", roomId), - priority: 100, - component: IncomingCallToast, - bodyClassName: "mx_IncomingCallToast", - props: { notifyEvent }, - }), - ); - // Mock ourselves joining the call. - spyCallMemberships.mockReturnValue([ - new CallMembership( - mkEvent({ - event: true, - room: testRoom.roomId, - user: userId, - type: EventType.GroupCallMemberPrefix, - content: {}, - }), - { - call_id: "123", - application: "m.call", - focus_active: { type: "livekit" }, - foci_preferred: [], - device_id: "DEVICE", - }, - ), - ]); - const callEvent = mkEvent({ - type: EventType.GroupCallMemberPrefix, - user: "@alice:foo", - room: roomId, - content: { - call_id: "abc123", - }, - event: true, - }); - emitLiveEvent(callEvent); - expect(ToastStore.sharedInstance().dismissToast).toHaveBeenCalled(); - spyCallMemberships.mockRestore(); + expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled(); }); - it("should not show toast when calling with non-group call event", () => { - emitCallNotifyEvent("event_type"); + it("should not show notification event is expired", () => { + emitCallNotificationEvent({ ts: Date.now() - 40000 }); expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled(); }); diff --git a/test/unit-tests/toasts/IncomingCallToast-test.tsx b/test/unit-tests/toasts/IncomingCallToast-test.tsx index f73a24dc449..3ac1d89548f 100644 --- a/test/unit-tests/toasts/IncomingCallToast-test.tsx +++ b/test/unit-tests/toasts/IncomingCallToast-test.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { render, screen, cleanup, fireEvent, waitFor } from "jest-matrix-react"; -import { mocked, type Mocked } from "jest-mock"; +import { type Mock, mocked, type Mocked } from "jest-mock"; import { Room, RoomStateEvent, @@ -16,9 +16,13 @@ import { MatrixEventEvent, type MatrixClient, type RoomMember, + EventType, + RoomEvent, + type IRoomTimelineData, + type ISendEventResponse, } from "matrix-js-sdk/src/matrix"; import { type ClientWidgetApi, Widget } from "matrix-widget-api"; -import { type ICallNotifyContent } from "matrix-js-sdk/src/matrixrtc"; +import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; import { useMockedCalls, @@ -27,6 +31,7 @@ import { mkRoomMember, setupAsyncStoreWithClient, resetAsyncStoreWithClient, + mkEvent, } from "../../test-utils"; import defaultDispatcher from "../../../src/dispatcher/dispatcher"; import { Action } from "../../../src/dispatcher/actions"; @@ -35,15 +40,21 @@ import { CallStore } from "../../../src/stores/CallStore"; import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore"; import DMRoomMap from "../../../src/utils/DMRoomMap"; import ToastStore from "../../../src/stores/ToastStore"; -import { getIncomingCallToastKey, IncomingCallToast } from "../../../src/toasts/IncomingCallToast"; +import { + getIncomingCallToastKey, + getNotificationEventSendTs, + IncomingCallToast, +} from "../../../src/toasts/IncomingCallToast"; import LegacyCallHandler, { AudioID } from "../../../src/LegacyCallHandler"; +import { CallEvent } from "../../../src/models/Call"; describe("IncomingCallToast", () => { useMockedCalls(); let client: Mocked; let room: Room; - let notifyContent: ICallNotifyContent; + let notificationEvent: MatrixEvent; + let alice: RoomMember; let bob: RoomMember; let call: MockedCall; @@ -64,10 +75,23 @@ describe("IncomingCallToast", () => { document.body.appendChild(audio); room = new Room("!1:example.org", client, "@alice:example.org"); - notifyContent = { - call_id: "", - getRoomId: () => room.roomId, - } as unknown as ICallNotifyContent; + 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"); @@ -104,8 +128,12 @@ describe("IncomingCallToast", () => { }); const renderToast = () => { - call.event.getContent = () => notifyContent as any; - render(); + call.event.getContent = () => + ({ + call_id: "", + getRoomId: () => room.roomId, + }) as any; + render(); }; it("correctly shows all the information", () => { @@ -124,14 +152,13 @@ describe("IncomingCallToast", () => { }); it("start ringing on ring notify event", () => { - call.event.getContent = () => - ({ - ...notifyContent, - notify_type: "ring", - }) as any; + const oldContent = notificationEvent.getContent() as IRTCNotificationContent; + (notificationEvent as unknown as { getContent: () => IRTCNotificationContent }).getContent = () => { + return { ...oldContent, notification_type: "ring" } as IRTCNotificationContent; + }; const playMock = jest.spyOn(LegacyCallHandler.instance, "play"); - render(); + render(); expect(playMock).toHaveBeenCalled(); }); @@ -143,15 +170,44 @@ describe("IncomingCallToast", () => { screen.getByText("Video"); screen.getByRole("button", { name: "Join" }); + screen.getByRole("button", { name: "Decline" }); screen.getByRole("button", { name: "Close" }); }); - it("joins the call and closes the toast", async () => { + it("opens the call directly and closes the toast when pressing on the join button", async () => { + 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) + fireEvent.click(screen.getByRole("button", { name: "Join" })); + await waitFor(() => + expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + skipLobby: true, + view_call: true, + }), + ); + await waitFor(() => + expect(toastStore.dismissToast).toHaveBeenCalledWith( + getIncomingCallToastKey(notificationEvent.getId()!, room.roomId), + ), + ); + + defaultDispatcher.unregister(dispatcherRef); + }); + + it("opens the call lobby and closes the toast when configured like that", async () => { renderToast(); const dispatcherSpy = jest.fn(); const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + fireEvent.click(screen.getByRole("switch", {})); + + // 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({ @@ -163,12 +219,13 @@ describe("IncomingCallToast", () => { ); await waitFor(() => expect(toastStore.dismissToast).toHaveBeenCalledWith( - getIncomingCallToastKey(notifyContent.call_id, room.roomId), + getIncomingCallToastKey(notificationEvent.getId()!, room.roomId), ), ); defaultDispatcher.unregister(dispatcherRef); }); + it("Dismiss toast if user starts call and skips lobby when using shift key click", async () => { renderToast(); @@ -186,7 +243,28 @@ describe("IncomingCallToast", () => { ); await waitFor(() => expect(toastStore.dismissToast).toHaveBeenCalledWith( - getIncomingCallToastKey(notifyContent.call_id, room.roomId), + getIncomingCallToastKey(notificationEvent.getId()!, room.roomId), + ), + ); + + defaultDispatcher.unregister(dispatcherRef); + }); + + it("Dismiss toast if user joins with a remote device", async () => { + renderToast(); + + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + + call.emit( + CallEvent.Participants, + new Map([[mkRoomMember(room.roomId, "@userId:matrix.org"), new Set(["a"])]]), + new Map(), + ); + + await waitFor(() => + expect(toastStore.dismissToast).toHaveBeenCalledWith( + getIncomingCallToastKey(notificationEvent.getId()!, room.roomId), ), ); @@ -202,7 +280,7 @@ describe("IncomingCallToast", () => { fireEvent.click(screen.getByRole("button", { name: "Close" })); await waitFor(() => expect(toastStore.dismissToast).toHaveBeenCalledWith( - getIncomingCallToastKey(notifyContent.call_id, room.roomId), + getIncomingCallToastKey(notificationEvent.getId()!, room.roomId), ), ); @@ -220,7 +298,7 @@ describe("IncomingCallToast", () => { await waitFor(() => expect(toastStore.dismissToast).toHaveBeenCalledWith( - getIncomingCallToastKey(notifyContent.call_id, room.roomId), + getIncomingCallToastKey(notificationEvent.getId()!, room.roomId), ), ); }); @@ -233,7 +311,7 @@ describe("IncomingCallToast", () => { await waitFor(() => expect(toastStore.dismissToast).toHaveBeenCalledWith( - getIncomingCallToastKey(notifyContent.call_id, room.roomId), + getIncomingCallToastKey(notificationEvent.getId()!, room.roomId), ), ); }); @@ -244,8 +322,136 @@ describe("IncomingCallToast", () => { await waitFor(() => expect(toastStore.dismissToast).toHaveBeenCalledWith( - getIncomingCallToastKey(notifyContent.call_id, room.roomId), + getIncomingCallToastKey(notificationEvent.getId()!, room.roomId), + ), + ); + }); + + it("closes toast when a decline event was received", async () => { + (toastStore.dismissToast as Mock).mockReset(); + renderToast(); + + room.emit( + RoomEvent.Timeline, + mkEvent({ + user: "@userId:matrix.org", + type: EventType.RTCDecline, + content: { "m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" } }, + event: true, + }), + room, + undefined, + false, + {} as unknown as IRoomTimelineData, + ); + + await waitFor(() => + expect(toastStore.dismissToast).toHaveBeenCalledWith( + getIncomingCallToastKey(notificationEvent.getId()!, room.roomId), + ), + ); + }); + + it("does not close toast when a decline event for another user was received", async () => { + (toastStore.dismissToast as Mock).mockReset(); + renderToast(); + + room.emit( + RoomEvent.Timeline, + mkEvent({ + user: "@userIdNotMe:matrix.org", + type: EventType.RTCDecline, + content: { "m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" } }, + event: true, + }), + room, + undefined, + false, + {} as unknown as IRoomTimelineData, + ); + + await waitFor(() => + expect(toastStore.dismissToast).not.toHaveBeenCalledWith( + getIncomingCallToastKey(notificationEvent.getId()!, room.roomId), + ), + ); + }); + + it("does not close toast when a decline event for another notification Event was received", async () => { + (toastStore.dismissToast as Mock).mockReset(); + renderToast(); + + room.emit( + RoomEvent.Timeline, + mkEvent({ + user: "@userId:matrix.org", + type: EventType.RTCDecline, + content: { "m.relates_to": { event_id: "$otherNotificationEventRelation", rel_type: "m.reference" } }, + event: true, + }), + room, + undefined, + false, + {} as unknown as IRoomTimelineData, + ); + + await waitFor(() => + expect(toastStore.dismissToast).not.toHaveBeenCalledWith( + getIncomingCallToastKey(notificationEvent.getId()!, room.roomId), + ), + ); + }); + + it("sends a decline event when clicking the decline button and only dismiss after sending", async () => { + (toastStore.dismissToast as Mock).mockReset(); + + renderToast(); + + const { promise, resolve } = Promise.withResolvers(); + client.sendRtcDecline.mockImplementation(() => { + return promise; + }); + + fireEvent.click(screen.getByRole("button", { name: "Decline" })); + + expect(toastStore.dismissToast).not.toHaveBeenCalledWith( + getIncomingCallToastKey(notificationEvent.getId()!, room.roomId), + ); + expect(client.sendRtcDecline).toHaveBeenCalledWith("!1:example.org", "$notificationEventId"); + + resolve({ event_id: "$declineEventId" }); + + await waitFor(() => + expect(toastStore.dismissToast).toHaveBeenCalledWith( + getIncomingCallToastKey(notificationEvent.getId()!, room.roomId), ), ); }); + + it("getNotificationEventSendTs returns the correct ts", () => { + const eventOriginServerTs = mkEvent({ + user: "@userId:matrix.org", + type: EventType.RTCNotification, + content: { + "m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" }, + "sender_ts": 222_000, + }, + event: true, + ts: 1111, + }); + + const eventSendTs = mkEvent({ + user: "@userId:matrix.org", + type: EventType.RTCNotification, + content: { + "m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" }, + "sender_ts": 2222, + }, + event: true, + ts: 1111, + }); + + expect(getNotificationEventSendTs(eventOriginServerTs)).toBe(1111); + expect(getNotificationEventSendTs(eventSendTs)).toBe(2222); + }); });