From 0783f27f338e9bc382119cacb3ba39e7dc933f36 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Tue, 16 Sep 2025 12:41:44 +0200 Subject: [PATCH] Add decline button to call notification toast (use new notification event) (#30729) * Add decline button to call notification toast (use new notification event) - This make EW incompatible with the old style notify events. Signed-off-by: Timo K * update styling for call toast Signed-off-by: Timo K * skip lobby on join button click / dont skip lobby on toast click Signed-off-by: Timo K * dismiss toast on remote decline Signed-off-by: Timo K * fixup docstring and event_id Signed-off-by: Timo K * Add tests Signed-off-by: Timo K * remove unused var Signed-off-by: Timo K * test that decline event gets sent Signed-off-by: Timo K * make "go to lobby" accessible via keyboard (fix sonar cloud) Signed-off-by: Timo K * remove keyboard input Signed-off-by: Timo K * fix lint Signed-off-by: Timo K * use actual button Signed-off-by: Timo K * review style + toggle for join immediately Signed-off-by: Timo K * fix `getNotificationEventSendTs` Signed-off-by: Timo K * use story component Signed-off-by: Timo K * english text Signed-off-by: Timo K * dont use legacy toggle Signed-off-by: Timo K * fix lint Signed-off-by: Timo K * review Signed-off-by: Timo K * review (mostly docs) Signed-off-by: Timo K --------- Signed-off-by: Timo K --- ...vatar-avatarwithdetails--default-linux.png | Bin 0 -> 9740 bytes res/css/views/toasts/_IncomingCallToast.pcss | 76 ++---- src/Notifier.ts | 38 ++- src/i18n/strings/en_EN.json | 2 + .../AvatarWithDetails.module.css | 31 +++ .../AvatarWithDetails.stories.tsx | 26 ++ .../AvatarWithDetails.test.tsx | 21 ++ .../AvatarWithDetails/AvatarWithDetails.tsx | 65 +++++ .../AvatarWithDetails.test.tsx.snap | 28 ++ .../avatar/AvatarWithDetails/index.tsx | 8 + src/toasts/IncomingCallToast.tsx | 193 +++++++++++--- test/test-utils/test-utils.ts | 1 + test/unit-tests/Notifier-test.ts | 90 +++---- .../toasts/IncomingCallToast-test.tsx | 252 ++++++++++++++++-- 14 files changed, 638 insertions(+), 193 deletions(-) create mode 100644 playwright/shared-component-snapshots/avatar-avatarwithdetails--default-linux.png create mode 100644 src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.module.css create mode 100644 src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.stories.tsx create mode 100644 src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.test.tsx create mode 100644 src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.tsx create mode 100644 src/shared-components/avatar/AvatarWithDetails/__snapshots__/AvatarWithDetails.test.tsx.snap create mode 100644 src/shared-components/avatar/AvatarWithDetails/index.tsx 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 0000000000000000000000000000000000000000..ae339219e5fc38694670aa89b9513923e75835ff GIT binary patch literal 9740 zcmeHNX;_ojwvJ-aBO+}rqEeu(3@Sp(BtitL)*>=h5D-F$%BTT?Oc6+kiqNA7D98}P zP-IeuAcQGE5)hdoks(Be5M~TR!Wak%$^AIbbN=_-+vmCGhd=V=+iMSN@BOWJuXnG# zpZ;!RE_?9AK@bQeYjO2gI}qqA;C1iE1ABqLUsSH`0fD{-S^RqO#)FJS7AC-94l};e z92=sfF=_>6SrPj}qi#W;xSyOnMzVLS>*|UR;~Z;_cSjtRLVzDk?n(H^gMmlq;~XOH z;hy(AXuHKb*8N;(?1T>@!0W}R^}bA(53=w5zv(ZX&UEge@rx{i7She{y5t7s67u6UcC$R6AJY+~e{(XiE690I24Rc;y-7MCZ%lU54V+${Xij`| z;sHp-By{mjG#rq}d~E;b3y_sdF6

4osK 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 ZKz-P&x!*4BwE-j`iz_z25-#0({6E4Do_+uT literal 0 HcmV?d00001 diff --git a/res/css/views/toasts/_IncomingCallToast.pcss b/res/css/views/toasts/_IncomingCallToast.pcss index 2aafff6b04..e5ba041f12 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_LiveContentSummary { - font-size: $font-12px; - line-height: $font-15px; - - .mx_LiveContentSummary_participants::before { - width: 15px; - height: 15px; - } - } + .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_joinButton { - position: relative; + .mx_LiveContentSummary_participants::before { + width: 15px; + height: 15px; + } - bottom: $spacing-4; - right: $spacing-4; + .mx_IncomingCallToast_buttons { + display: flex; + gap: var(--cpd-space-2x); + } + + .mx_IncomingCallToast_actionButton { + position: relative; 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 dd47b8b204..aa68b386a3 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 d0a95a6d35..811edc1590 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3987,6 +3987,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", @@ -4038,6 +4039,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 0000000000..62e7a569bf --- /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); + + .title { + 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 0000000000..17a25ecfe7 --- /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 { type Meta, type StoryObj } from "@storybook/react-vite/*"; + +import { AvatarWithDetails } from "./AvatarWithDetails"; + +const meta = { + title: "Avatar/AvatarWithDetails", + component: AvatarWithDetails, + tags: ["autodocs"], + args: { + avatar:

, + details: "Details about the avatar go here", + title: "Room Name", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; +export const Default: Story = {}; 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 0000000000..f5d482613f --- /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 0000000000..aab5729d5f --- /dev/null +++ b/src/shared-components/avatar/AvatarWithDetails/AvatarWithDetails.tsx @@ -0,0 +1,65 @@ +/* + * 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; + /** + * The title/label next to the avatar. Usually the user or room name. + */ + title: string; + /** + * A label with details to display under the avatar title. + * Commonly used to display the number of participants in a room. + */ + details: React.ReactNode; + /** The avatar to display. */ + avatar: React.ReactNode; +} & ComponentProps; + +/** + * A component to display an avatar with a title next to it in a grey box. + * + * @example + * ```tsx + * + * ``` + */ +export function AvatarWithDetails({ + as, + className, + details, + avatar, + title, + ...props +}: PropsWithChildren>): JSX.Element { + const Component = as || "div"; + + return ( + + {avatar} + + {title} + {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 0000000000..e8a2e4579a --- /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 0000000000..a54f416d82 --- /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 9f393beb06..30ba79541d 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,36 @@ 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}`; +/** + * Get the key for the incoming call toast. A combination of the event ID and room ID. + * @param notificationEventId The ID of the notification event. + * @param roomId The ID of the room. + * @returns The key for the incoming call toast. + */ +export const getIncomingCallToastKey = (notificationEventId: string, roomId: string): string => + `call_${notificationEventId}_${roomId}`; + +/** + * Get the ts when the notification event was sent. + * This can be either the origin_server_ts or a ts the sender of this event claims as + * the time they sent it (sender_ts). + * The origin_server_ts is the fallback if sender_ts seems wrong. + * @param event The RTCNotification event. + * @returns The timestamp to use as the expect start time to apply the `lifetime` to. + */ +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 +81,11 @@ function JoinCallButtonWithCall({ onClick, call, disabledTooltip }: JoinCallButt return ( + + ); +} + +interface Props { + notificationEvent: MatrixEvent; +} + +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 +149,52 @@ 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 + ev.relationEventId === notificationEvent.getId() // The event declines this ringing toast. + ) { + dismissToast(); + } + }, + [dismissToast, notificationEvent, 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 +206,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 +224,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 +237,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 +256,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} + title={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 e5b8f84f51..5ff4b0bf77 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 f73a24dc44..3ac1d89548 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); + }); });