mirror of
https://github.com/vector-im/element-web.git
synced 2025-11-09 12:41:07 +01:00
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 <toger5@hotmail.de> * update styling for call toast Signed-off-by: Timo K <toger5@hotmail.de> * skip lobby on join button click / dont skip lobby on toast click Signed-off-by: Timo K <toger5@hotmail.de> * dismiss toast on remote decline Signed-off-by: Timo K <toger5@hotmail.de> * fixup docstring and event_id Signed-off-by: Timo K <toger5@hotmail.de> * Add tests Signed-off-by: Timo K <toger5@hotmail.de> * remove unused var Signed-off-by: Timo K <toger5@hotmail.de> * test that decline event gets sent Signed-off-by: Timo K <toger5@hotmail.de> * make "go to lobby" accessible via keyboard (fix sonar cloud) Signed-off-by: Timo K <toger5@hotmail.de> * remove keyboard input Signed-off-by: Timo K <toger5@hotmail.de> * fix lint Signed-off-by: Timo K <toger5@hotmail.de> * use actual button Signed-off-by: Timo K <toger5@hotmail.de> * review style + toggle for join immediately Signed-off-by: Timo K <toger5@hotmail.de> * fix `getNotificationEventSendTs` Signed-off-by: Timo K <toger5@hotmail.de> * use story component Signed-off-by: Timo K <toger5@hotmail.de> * english text Signed-off-by: Timo K <toger5@hotmail.de> * dont use legacy toggle Signed-off-by: Timo K <toger5@hotmail.de> * fix lint Signed-off-by: Timo K <toger5@hotmail.de> * review Signed-off-by: Timo K <toger5@hotmail.de> * review (mostly docs) Signed-off-by: Timo K <toger5@hotmail.de> --------- Signed-off-by: Timo K <toger5@hotmail.de>
This commit is contained in:
parent
3c13f55b74
commit
0783f27f33
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
@ -11,76 +11,52 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
pointer-events: initial; /* restore pointer events so the user can accept/decline */
|
pointer-events: initial; /* restore pointer events so the user can accept/decline */
|
||||||
width: 250px;
|
|
||||||
|
|
||||||
$closeButtonSize: 16px;
|
$closeButtonSize: var(--cpd-space-4x);
|
||||||
|
|
||||||
.mx_IncomingCallToast_content {
|
.mx_IncomingCallToast_content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-left: 8px;
|
gap: var(--cpd-space-4x);
|
||||||
|
padding: var(--cpd-space-3x);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.mx_IncomingCallToast_info {
|
.mx_IncomingCallToast_message {
|
||||||
margin-bottom: $spacing-16;
|
font-size: var(--cpd-font-size-body-lg);
|
||||||
|
line-height: var(--cpd-font-size-heading-sm);
|
||||||
.mx_IncomingCallToast_room {
|
width: calc(100% - $closeButtonSize - 2 * var(--cpd-space-1x));
|
||||||
display: inline-block;
|
font-weight: var(--cpd-font-weight-semibold);
|
||||||
|
|
||||||
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_joinButton {
|
.mx_LiveContentSummary_participants::before {
|
||||||
position: relative;
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
bottom: $spacing-4;
|
.mx_IncomingCallToast_buttons {
|
||||||
right: $spacing-4;
|
display: flex;
|
||||||
|
gap: var(--cpd-space-2x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_IncomingCallToast_actionButton {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
|
|
||||||
padding: $spacing-4 0;
|
padding: var(--cpd-space-1x) 0;
|
||||||
|
padding-right: var(--cpd-space-4x);
|
||||||
line-height: $font-24px;
|
line-height: var(--cpd-space-6x);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_IncomingCallToast_closeButton {
|
.mx_IncomingCallToast_closeButton {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
top: $spacing-4;
|
right: 0;
|
||||||
right: $spacing-4;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
height: $closeButtonSize;
|
height: $closeButtonSize;
|
||||||
@ -99,4 +75,10 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
mask-position: center;
|
mask-position: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.mx_IncomingCallToast_toggleWithLabel {
|
||||||
|
display: flex;
|
||||||
|
span {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ import {
|
|||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { type PermissionChanged as PermissionChangedEvent } from "@matrix-org/analytics-events/types/typescript/PermissionChanged";
|
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 { MatrixClientPeg } from "./MatrixClientPeg";
|
||||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||||
@ -45,7 +45,7 @@ import { mediaFromMxc } from "./customisations/Media";
|
|||||||
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
||||||
import { SdkContextClass } from "./contexts/SDKContext";
|
import { SdkContextClass } from "./contexts/SDKContext";
|
||||||
import { localNotificationsAreSilenced, createLocalNotificationSettingsIfNeeded } from "./utils/notifications";
|
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 ToastStore from "./stores/ToastStore";
|
||||||
import { stripPlainReply } from "./utils/Reply";
|
import { stripPlainReply } from "./utils/Reply";
|
||||||
import { BackgroundAudio } from "./audio/BackgroundAudio";
|
import { BackgroundAudio } from "./audio/BackgroundAudio";
|
||||||
@ -486,41 +486,33 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
|
|||||||
private performCustomEventHandling(ev: MatrixEvent): void {
|
private performCustomEventHandling(ev: MatrixEvent): void {
|
||||||
const cli = MatrixClientPeg.safeGet();
|
const cli = MatrixClientPeg.safeGet();
|
||||||
const room = cli.getRoom(ev.getRoomId());
|
const room = cli.getRoom(ev.getRoomId());
|
||||||
const type = ev.getType();
|
|
||||||
const thisUserHasConnectedDevice =
|
const thisUserHasConnectedDevice =
|
||||||
room && MatrixRTCSession.callMembershipsForRoom(room).some((m) => m.sender === cli.getUserId());
|
room && MatrixRTCSession.callMembershipsForRoom(room).some((m) => m.sender === cli.getUserId());
|
||||||
|
|
||||||
if (EventType.GroupCallMemberPrefix === type && thisUserHasConnectedDevice) {
|
if (EventType.RTCNotification === ev.getType() && !thisUserHasConnectedDevice) {
|
||||||
const content = ev.getContent();
|
const content = ev.getContent() as IRTCNotificationContent;
|
||||||
|
|
||||||
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();
|
|
||||||
const roomId = ev.getRoomId();
|
const roomId = ev.getRoomId();
|
||||||
|
const eventId = ev.getId();
|
||||||
|
|
||||||
if (typeof content.call_id !== "string") {
|
// Check maximum age of a call notification event that will trigger a ringing notification
|
||||||
logger.warn("Received malformatted CallNotify event. Did not contain 'call_id' of type 'string'");
|
if (Date.now() - getNotificationEventSendTs(ev) > content.lifetime) {
|
||||||
|
logger.warn("Received outdated RTCNotification event.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!roomId) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
ToastStore.sharedInstance().addOrReplaceToast({
|
ToastStore.sharedInstance().addOrReplaceToast({
|
||||||
key: getIncomingCallToastKey(content.call_id, roomId),
|
key: getIncomingCallToastKey(eventId, roomId),
|
||||||
priority: 100,
|
priority: 100,
|
||||||
component: IncomingCallToast,
|
component: IncomingCallToast,
|
||||||
bodyClassName: "mx_IncomingCallToast",
|
bodyClassName: "mx_IncomingCallToast",
|
||||||
props: { notifyEvent: ev },
|
props: { notificationEvent: ev },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3987,6 +3987,7 @@
|
|||||||
"connection_lost": "Connectivity to the server has been lost",
|
"connection_lost": "Connectivity to the server has been lost",
|
||||||
"connection_lost_description": "You cannot place calls without a connection to the server.",
|
"connection_lost_description": "You cannot place calls without a connection to the server.",
|
||||||
"consulting": "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>",
|
"consulting": "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>",
|
||||||
|
"decline_call": "Decline",
|
||||||
"default_device": "Default Device",
|
"default_device": "Default Device",
|
||||||
"dial": "Dial",
|
"dial": "Dial",
|
||||||
"dialpad": "Dialpad",
|
"dialpad": "Dialpad",
|
||||||
@ -4038,6 +4039,7 @@
|
|||||||
"show_sidebar_button": "Show sidebar",
|
"show_sidebar_button": "Show sidebar",
|
||||||
"silence": "Silence call",
|
"silence": "Silence call",
|
||||||
"silenced": "Notifications silenced",
|
"silenced": "Notifications silenced",
|
||||||
|
"skip_lobby_toggle_option": "Join immediately",
|
||||||
"start_screenshare": "Start sharing your screen",
|
"start_screenshare": "Start sharing your screen",
|
||||||
"stop_screenshare": "Stop sharing your screen",
|
"stop_screenshare": "Stop sharing your screen",
|
||||||
"too_many_calls": "Too Many Calls",
|
"too_many_calls": "Too Many Calls",
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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: <div style={{ width: 40, height: 40, backgroundColor: "#888", borderRadius: "50%" }} />,
|
||||||
|
details: "Details about the avatar go here",
|
||||||
|
title: "Room Name",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof AvatarWithDetails>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
export const Default: Story = {};
|
||||||
@ -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(<Default />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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<C extends ElementType> = {
|
||||||
|
/**
|
||||||
|
* 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<C>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component to display an avatar with a title next to it in a grey box.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <AvatarWithDetails title="Room Name" details="10 participants" className="custom-class" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function AvatarWithDetails<C extends React.ElementType = "div">({
|
||||||
|
as,
|
||||||
|
className,
|
||||||
|
details,
|
||||||
|
avatar,
|
||||||
|
title,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<AvatarWithDetailsProps<C>>): JSX.Element {
|
||||||
|
const Component = as || "div";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component className={classNames(styles.avatarWithDetails, className)} {...props}>
|
||||||
|
{avatar}
|
||||||
|
<Flex direction="column">
|
||||||
|
<span className={styles.title}>{title}</span>
|
||||||
|
<span className={styles.details}>{details}</span>
|
||||||
|
</Flex>
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`AvatarWithDetails renders a textual event 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="avatarWithDetails"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="width: 40px; height: 40px; background-color: rgb(136, 136, 136); border-radius: 50%;"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="flex"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Room Name
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="details"
|
||||||
|
>
|
||||||
|
Details about the avatar go here
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
8
src/shared-components/avatar/AvatarWithDetails/index.tsx
Normal file
8
src/shared-components/avatar/AvatarWithDetails/index.tsx
Normal file
@ -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";
|
||||||
@ -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 React, { type JSX, useCallback, useEffect, useState } from "react";
|
||||||
import { type MatrixEvent, type RoomMember } from "matrix-js-sdk/src/matrix";
|
import { type Room, type MatrixEvent, type RoomMember, RoomEvent, EventType } from "matrix-js-sdk/src/matrix";
|
||||||
import { Button, Tooltip, TooltipProvider } from "@vector-im/compound-web";
|
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 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 { _t } from "../languageHandler";
|
||||||
import RoomAvatar from "../components/views/avatars/RoomAvatar";
|
import RoomAvatar from "../components/views/avatars/RoomAvatar";
|
||||||
@ -31,8 +35,36 @@ import { type Call, CallEvent } from "../models/Call";
|
|||||||
import LegacyCallHandler, { AudioID } from "../LegacyCallHandler";
|
import LegacyCallHandler, { AudioID } from "../LegacyCallHandler";
|
||||||
import { useEventEmitter } from "../hooks/useEventEmitter";
|
import { useEventEmitter } from "../hooks/useEventEmitter";
|
||||||
import { CallStore, CallStoreEvent } from "../stores/CallStore";
|
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<IRTCNotificationContent>;
|
||||||
|
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;
|
const MAX_RING_TIME_MS = 90 * 1000;
|
||||||
|
|
||||||
interface JoinCallButtonWithCallProps {
|
interface JoinCallButtonWithCallProps {
|
||||||
@ -49,11 +81,11 @@ function JoinCallButtonWithCall({ onClick, call, disabledTooltip }: JoinCallButt
|
|||||||
return (
|
return (
|
||||||
<Tooltip description={disTooltip ?? _t("voip|video_call")}>
|
<Tooltip description={disTooltip ?? _t("voip|video_call")}>
|
||||||
<Button
|
<Button
|
||||||
className="mx_IncomingCallToast_joinButton"
|
className="mx_IncomingCallToast_actionButton"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disTooltip != undefined}
|
disabled={disTooltip != undefined}
|
||||||
kind="primary"
|
kind="primary"
|
||||||
Icon={VideoCallIcon}
|
Icon={CheckIcon}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{_t("action|join")}
|
{_t("action|join")}
|
||||||
@ -62,12 +94,52 @@ function JoinCallButtonWithCall({ onClick, call, disabledTooltip }: JoinCallButt
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface DeclineCallButtonWithNotificationEventProps {
|
||||||
notifyEvent: MatrixEvent;
|
onDeclined: (e: ButtonEvent) => void;
|
||||||
|
notificationEvent: MatrixEvent;
|
||||||
|
room?: Room;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IncomingCallToast({ notifyEvent }: Props): JSX.Element {
|
function DeclineCallButtonWithNotificationEvent({
|
||||||
const roomId = notifyEvent.getRoomId()!;
|
notificationEvent,
|
||||||
|
room,
|
||||||
|
onDeclined,
|
||||||
|
}: DeclineCallButtonWithNotificationEventProps): JSX.Element {
|
||||||
|
const [declining, setDeclining] = useState(false);
|
||||||
|
const onClick = useCallback(
|
||||||
|
async (e: ButtonEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeclining(true);
|
||||||
|
await room?.client.sendRtcDecline(room.roomId, notificationEvent.getId() ?? "");
|
||||||
|
onDeclined(e);
|
||||||
|
},
|
||||||
|
[notificationEvent, onDeclined, room?.client, room?.roomId],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Tooltip description={_t("voip|decline_call")}>
|
||||||
|
<Button
|
||||||
|
className="mx_IncomingCallToast_actionButton"
|
||||||
|
onClick={onClick}
|
||||||
|
kind="primary"
|
||||||
|
destructive
|
||||||
|
disabled={declining}
|
||||||
|
Icon={CrossIcon}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{_t("action|decline")}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<IRTCNotificationContent>;
|
||||||
const room = MatrixClientPeg.safeGet().getRoom(roomId) ?? undefined;
|
const room = MatrixClientPeg.safeGet().getRoom(roomId) ?? undefined;
|
||||||
const call = useCall(roomId);
|
const call = useCall(roomId);
|
||||||
const [connectedCalls, setConnectedCalls] = useState<Call[]>(Array.from(CallStore.instance.connectedCalls));
|
const [connectedCalls, setConnectedCalls] = useState<Call[]>(Array.from(CallStore.instance.connectedCalls));
|
||||||
@ -77,33 +149,52 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element {
|
|||||||
const otherCallIsOngoing = connectedCalls.find((call) => call.roomId !== roomId);
|
const otherCallIsOngoing = connectedCalls.find((call) => call.roomId !== roomId);
|
||||||
// Start ringing if not already.
|
// Start ringing if not already.
|
||||||
useEffect(() => {
|
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)) {
|
if (isRingToast && !LegacyCallHandler.instance.isPlaying(AudioID.Ring)) {
|
||||||
LegacyCallHandler.instance.play(AudioID.Ring);
|
LegacyCallHandler.instance.play(AudioID.Ring);
|
||||||
}
|
}
|
||||||
}, [notifyEvent]);
|
}, [notificationContent.notification_type]);
|
||||||
|
|
||||||
// Stop ringing on dismiss.
|
// Stop ringing on dismiss.
|
||||||
const dismissToast = useCallback((): void => {
|
const dismissToast = useCallback((): void => {
|
||||||
ToastStore.sharedInstance().dismissToast(
|
const notificationId = notificationEvent.getId();
|
||||||
getIncomingCallToastKey(notifyEvent.getContent().call_id ?? "", roomId),
|
if (!notificationId) {
|
||||||
);
|
logger.warn("Could not get eventId for RTCNotification event");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ToastStore.sharedInstance().dismissToast(getIncomingCallToastKey(notificationId, roomId));
|
||||||
LegacyCallHandler.instance.pause(AudioID.Ring);
|
LegacyCallHandler.instance.pause(AudioID.Ring);
|
||||||
}, [notifyEvent, roomId]);
|
}, [notificationEvent, roomId]);
|
||||||
|
|
||||||
// Dismiss if session got ended remotely.
|
// Dismiss if session got ended remotely.
|
||||||
const onCall = useCallback(
|
const onCall = useCallback(
|
||||||
(call: Call, callRoomId: string): void => {
|
(call: Call, callRoomId: string): void => {
|
||||||
const roomId = notifyEvent.getRoomId();
|
const roomId = notificationEvent.getRoomId();
|
||||||
if (!roomId && roomId !== callRoomId) return;
|
if (!roomId && roomId !== callRoomId) return;
|
||||||
if (call === null || call.participants.size === 0) {
|
if (call === null || call.participants.size === 0) {
|
||||||
dismissToast();
|
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(
|
const onParticipantChange = useCallback(
|
||||||
(participants: Map<RoomMember, Set<string>>, prevParticipants: Map<RoomMember, Set<string>>) => {
|
(participants: Map<RoomMember, Set<string>>, prevParticipants: Map<RoomMember, Set<string>>) => {
|
||||||
if (Array.from(participants.keys()).some((p) => p.userId == room?.client.getUserId())) {
|
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.
|
// Dismiss on timeout.
|
||||||
useEffect(() => {
|
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);
|
return () => clearTimeout(timeout);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -132,7 +224,10 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [skipLobbyToggle, setSkipLobbyToggle] = useState(true);
|
||||||
|
|
||||||
// Dismiss on clicking join.
|
// 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(
|
const onJoinClick = useCallback(
|
||||||
(e: ButtonEvent): void => {
|
(e: ButtonEvent): void => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -142,11 +237,11 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element {
|
|||||||
action: Action.ViewRoom,
|
action: Action.ViewRoom,
|
||||||
room_id: room?.roomId,
|
room_id: room?.roomId,
|
||||||
view_call: true,
|
view_call: true,
|
||||||
skipLobby: "shiftKey" in e ? e.shiftKey : false,
|
skipLobby: skipLobbyToggle ?? ("shiftKey" in e ? e.shiftKey : false),
|
||||||
metricsTrigger: undefined,
|
metricsTrigger: undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[room],
|
[room, skipLobbyToggle],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Dismiss on closing toast.
|
// Dismiss on closing toast.
|
||||||
@ -161,35 +256,47 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element {
|
|||||||
|
|
||||||
useEventEmitter(CallStore.instance, CallStoreEvent.Call, onCall);
|
useEventEmitter(CallStore.instance, CallStoreEvent.Call, onCall);
|
||||||
useEventEmitter(call ?? undefined, CallEvent.Participants, onParticipantChange);
|
useEventEmitter(call ?? undefined, CallEvent.Participants, onParticipantChange);
|
||||||
|
useEventEmitter(room, RoomEvent.Timeline, onTimelineChange);
|
||||||
|
|
||||||
|
const callLiveContentSummary = call ? (
|
||||||
|
<LiveContentSummaryWithCall call={call} />
|
||||||
|
) : (
|
||||||
|
<LiveContentSummary
|
||||||
|
type={LiveContentType.Video}
|
||||||
|
text={_t("common|video")}
|
||||||
|
active={false}
|
||||||
|
participantCount={0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<>
|
<>
|
||||||
<div>
|
|
||||||
<RoomAvatar room={room ?? undefined} size="24px" />
|
|
||||||
</div>
|
|
||||||
<div className="mx_IncomingCallToast_content">
|
<div className="mx_IncomingCallToast_content">
|
||||||
<div className="mx_IncomingCallToast_info">
|
<div className="mx_IncomingCallToast_message">
|
||||||
<span className="mx_IncomingCallToast_room">
|
<VideoCallIcon width="20px" height="20px" style={{ position: "relative", top: "4px" }} />{" "}
|
||||||
{room ? room.name : _t("voip|call_toast_unknown_room")}
|
{_t("voip|video_call_started")}
|
||||||
</span>
|
|
||||||
<div className="mx_IncomingCallToast_message">{_t("voip|video_call_started")}</div>
|
|
||||||
{call ? (
|
|
||||||
<LiveContentSummaryWithCall call={call} />
|
|
||||||
) : (
|
|
||||||
<LiveContentSummary
|
|
||||||
type={LiveContentType.Video}
|
|
||||||
text={_t("common|video")}
|
|
||||||
active={false}
|
|
||||||
participantCount={0}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<JoinCallButtonWithCall
|
<AvatarWithDetails
|
||||||
onClick={onJoinClick}
|
avatar={<RoomAvatar room={room ?? undefined} size="32px" />}
|
||||||
call={call}
|
details={callLiveContentSummary}
|
||||||
disabledTooltip={otherCallIsOngoing ? "Ongoing call" : undefined}
|
title={room ? room.name : _t("voip|call_toast_unknown_room")}
|
||||||
/>
|
/>
|
||||||
|
<div className="mx_IncomingCallToast_toggleWithLabel">
|
||||||
|
<span>{_t("voip|skip_lobby_toggle_option")}</span>
|
||||||
|
<ToggleInput onChange={(e) => setSkipLobbyToggle(e.target.checked)} checked={skipLobbyToggle} />
|
||||||
|
</div>
|
||||||
|
<div className="mx_IncomingCallToast_buttons">
|
||||||
|
<DeclineCallButtonWithNotificationEvent
|
||||||
|
notificationEvent={notificationEvent}
|
||||||
|
room={room}
|
||||||
|
onDeclined={onCloseClick}
|
||||||
|
/>
|
||||||
|
<JoinCallButtonWithCall
|
||||||
|
onClick={onJoinClick}
|
||||||
|
call={call}
|
||||||
|
disabledTooltip={otherCallIsOngoing ? "Ongoing call" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
className="mx_IncomingCallToast_closeButton"
|
className="mx_IncomingCallToast_closeButton"
|
||||||
|
|||||||
@ -204,6 +204,7 @@ export function createTestClient(): MatrixClient {
|
|||||||
sendTyping: jest.fn().mockResolvedValue({}),
|
sendTyping: jest.fn().mockResolvedValue({}),
|
||||||
sendMessage: jest.fn().mockResolvedValue({}),
|
sendMessage: jest.fn().mockResolvedValue({}),
|
||||||
sendStateEvent: jest.fn().mockResolvedValue(undefined),
|
sendStateEvent: jest.fn().mockResolvedValue(undefined),
|
||||||
|
sendRtcDecline: jest.fn().mockResolvedValue(undefined),
|
||||||
getSyncState: jest.fn().mockReturnValue("SYNCING"),
|
getSyncState: jest.fn().mockReturnValue("SYNCING"),
|
||||||
generateClientSecret: () => "t35tcl1Ent5ECr3T",
|
generateClientSecret: () => "t35tcl1Ent5ECr3T",
|
||||||
isGuest: jest.fn().mockReturnValue(false),
|
isGuest: jest.fn().mockReturnValue(false),
|
||||||
|
|||||||
@ -385,33 +385,49 @@ describe("Notifier", () => {
|
|||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
const emitCallNotifyEvent = (type?: string, roomMention = true) => {
|
const emitCallNotificationEvent = (
|
||||||
const callEvent = mkEvent({
|
params: {
|
||||||
type: type ?? EventType.CallNotify,
|
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",
|
user: "@alice:foo",
|
||||||
room: roomId,
|
room: roomId,
|
||||||
|
ts,
|
||||||
content: {
|
content: {
|
||||||
"application": "m.call",
|
"notification_type": "ring",
|
||||||
|
"m.relation": { rel_type: "m.reference", event_id: "$memberEventId" },
|
||||||
"m.mentions": { user_ids: [], room: roomMention },
|
"m.mentions": { user_ids: [], room: roomMention },
|
||||||
"notify_type": "ring",
|
lifetime,
|
||||||
"call_id": "abc123",
|
"sender_ts": ts,
|
||||||
},
|
},
|
||||||
event: true,
|
event: true,
|
||||||
});
|
});
|
||||||
emitLiveEvent(callEvent);
|
emitLiveEvent(notificationEvent);
|
||||||
return callEvent;
|
return notificationEvent;
|
||||||
};
|
};
|
||||||
|
|
||||||
it("shows group call toast", () => {
|
it("shows group call toast", () => {
|
||||||
const notifyEvent = emitCallNotifyEvent();
|
const notificationEvent = emitCallNotificationEvent();
|
||||||
|
|
||||||
expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith(
|
expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
key: getIncomingCallToastKey(notifyEvent.getContent().call_id ?? "", roomId),
|
key: getIncomingCallToastKey(notificationEvent.getId() ?? "", roomId),
|
||||||
priority: 100,
|
priority: 100,
|
||||||
component: IncomingCallToast,
|
component: IncomingCallToast,
|
||||||
bodyClassName: "mx_IncomingCallToast",
|
bodyClassName: "mx_IncomingCallToast",
|
||||||
props: { notifyEvent },
|
props: { notificationEvent },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -439,59 +455,19 @@ describe("Notifier", () => {
|
|||||||
const roomSession = MatrixRTCSession.roomSessionForRoom(mockClient, testRoom);
|
const roomSession = MatrixRTCSession.roomSessionForRoom(mockClient, testRoom);
|
||||||
|
|
||||||
mockClient.matrixRTC.getRoomSession.mockReturnValue(roomSession);
|
mockClient.matrixRTC.getRoomSession.mockReturnValue(roomSession);
|
||||||
emitCallNotifyEvent();
|
emitCallNotificationEvent();
|
||||||
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
|
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
|
||||||
spyCallMemberships.mockRestore();
|
spyCallMemberships.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dismisses call notification when another device answers the call", () => {
|
it("should not show toast when calling with a different event type to org.matrix.msc4075.rtc.notification", () => {
|
||||||
const notifyEvent = emitCallNotifyEvent();
|
emitCallNotificationEvent({ type: "event_type" });
|
||||||
const spyCallMemberships = jest.spyOn(MatrixRTCSession, "callMembershipsForRoom");
|
|
||||||
|
|
||||||
expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith(
|
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not show toast when calling with non-group call event", () => {
|
it("should not show notification event is expired", () => {
|
||||||
emitCallNotifyEvent("event_type");
|
emitCallNotificationEvent({ ts: Date.now() - 40000 });
|
||||||
|
|
||||||
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
|
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, screen, cleanup, fireEvent, waitFor } from "jest-matrix-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 {
|
import {
|
||||||
Room,
|
Room,
|
||||||
RoomStateEvent,
|
RoomStateEvent,
|
||||||
@ -16,9 +16,13 @@ import {
|
|||||||
MatrixEventEvent,
|
MatrixEventEvent,
|
||||||
type MatrixClient,
|
type MatrixClient,
|
||||||
type RoomMember,
|
type RoomMember,
|
||||||
|
EventType,
|
||||||
|
RoomEvent,
|
||||||
|
type IRoomTimelineData,
|
||||||
|
type ISendEventResponse,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import { type ClientWidgetApi, Widget } from "matrix-widget-api";
|
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 {
|
import {
|
||||||
useMockedCalls,
|
useMockedCalls,
|
||||||
@ -27,6 +31,7 @@ import {
|
|||||||
mkRoomMember,
|
mkRoomMember,
|
||||||
setupAsyncStoreWithClient,
|
setupAsyncStoreWithClient,
|
||||||
resetAsyncStoreWithClient,
|
resetAsyncStoreWithClient,
|
||||||
|
mkEvent,
|
||||||
} from "../../test-utils";
|
} from "../../test-utils";
|
||||||
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
|
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
|
||||||
import { Action } from "../../../src/dispatcher/actions";
|
import { Action } from "../../../src/dispatcher/actions";
|
||||||
@ -35,15 +40,21 @@ import { CallStore } from "../../../src/stores/CallStore";
|
|||||||
import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore";
|
import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore";
|
||||||
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
||||||
import ToastStore from "../../../src/stores/ToastStore";
|
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 LegacyCallHandler, { AudioID } from "../../../src/LegacyCallHandler";
|
||||||
|
import { CallEvent } from "../../../src/models/Call";
|
||||||
|
|
||||||
describe("IncomingCallToast", () => {
|
describe("IncomingCallToast", () => {
|
||||||
useMockedCalls();
|
useMockedCalls();
|
||||||
|
|
||||||
let client: Mocked<MatrixClient>;
|
let client: Mocked<MatrixClient>;
|
||||||
let room: Room;
|
let room: Room;
|
||||||
let notifyContent: ICallNotifyContent;
|
let notificationEvent: MatrixEvent;
|
||||||
|
|
||||||
let alice: RoomMember;
|
let alice: RoomMember;
|
||||||
let bob: RoomMember;
|
let bob: RoomMember;
|
||||||
let call: MockedCall;
|
let call: MockedCall;
|
||||||
@ -64,10 +75,23 @@ describe("IncomingCallToast", () => {
|
|||||||
document.body.appendChild(audio);
|
document.body.appendChild(audio);
|
||||||
|
|
||||||
room = new Room("!1:example.org", client, "@alice:example.org");
|
room = new Room("!1:example.org", client, "@alice:example.org");
|
||||||
notifyContent = {
|
const ts = Date.now();
|
||||||
call_id: "",
|
const notificationContent = {
|
||||||
getRoomId: () => room.roomId,
|
"notification_type": "notification",
|
||||||
} as unknown as ICallNotifyContent;
|
"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");
|
alice = mkRoomMember(room.roomId, "@alice:example.org");
|
||||||
bob = mkRoomMember(room.roomId, "@bob:example.org");
|
bob = mkRoomMember(room.roomId, "@bob:example.org");
|
||||||
|
|
||||||
@ -104,8 +128,12 @@ describe("IncomingCallToast", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const renderToast = () => {
|
const renderToast = () => {
|
||||||
call.event.getContent = () => notifyContent as any;
|
call.event.getContent = () =>
|
||||||
render(<IncomingCallToast notifyEvent={call.event} />);
|
({
|
||||||
|
call_id: "",
|
||||||
|
getRoomId: () => room.roomId,
|
||||||
|
}) as any;
|
||||||
|
render(<IncomingCallToast notificationEvent={notificationEvent} />);
|
||||||
};
|
};
|
||||||
|
|
||||||
it("correctly shows all the information", () => {
|
it("correctly shows all the information", () => {
|
||||||
@ -124,14 +152,13 @@ describe("IncomingCallToast", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("start ringing on ring notify event", () => {
|
it("start ringing on ring notify event", () => {
|
||||||
call.event.getContent = () =>
|
const oldContent = notificationEvent.getContent() as IRTCNotificationContent;
|
||||||
({
|
(notificationEvent as unknown as { getContent: () => IRTCNotificationContent }).getContent = () => {
|
||||||
...notifyContent,
|
return { ...oldContent, notification_type: "ring" } as IRTCNotificationContent;
|
||||||
notify_type: "ring",
|
};
|
||||||
}) as any;
|
|
||||||
|
|
||||||
const playMock = jest.spyOn(LegacyCallHandler.instance, "play");
|
const playMock = jest.spyOn(LegacyCallHandler.instance, "play");
|
||||||
render(<IncomingCallToast notifyEvent={call.event} />);
|
render(<IncomingCallToast notificationEvent={notificationEvent} />);
|
||||||
expect(playMock).toHaveBeenCalled();
|
expect(playMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -143,15 +170,44 @@ describe("IncomingCallToast", () => {
|
|||||||
screen.getByText("Video");
|
screen.getByText("Video");
|
||||||
|
|
||||||
screen.getByRole("button", { name: "Join" });
|
screen.getByRole("button", { name: "Join" });
|
||||||
|
screen.getByRole("button", { name: "Decline" });
|
||||||
screen.getByRole("button", { name: "Close" });
|
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();
|
renderToast();
|
||||||
|
|
||||||
const dispatcherSpy = jest.fn();
|
const dispatcherSpy = jest.fn();
|
||||||
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
|
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" }));
|
fireEvent.click(screen.getByRole("button", { name: "Join" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
expect(dispatcherSpy).toHaveBeenCalledWith({
|
||||||
@ -163,12 +219,13 @@ describe("IncomingCallToast", () => {
|
|||||||
);
|
);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
||||||
getIncomingCallToastKey(notifyContent.call_id, room.roomId),
|
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
defaultDispatcher.unregister(dispatcherRef);
|
defaultDispatcher.unregister(dispatcherRef);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Dismiss toast if user starts call and skips lobby when using shift key click", async () => {
|
it("Dismiss toast if user starts call and skips lobby when using shift key click", async () => {
|
||||||
renderToast();
|
renderToast();
|
||||||
|
|
||||||
@ -186,7 +243,28 @@ describe("IncomingCallToast", () => {
|
|||||||
);
|
);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
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" }));
|
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
||||||
getIncomingCallToastKey(notifyContent.call_id, room.roomId),
|
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -220,7 +298,7 @@ describe("IncomingCallToast", () => {
|
|||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
||||||
getIncomingCallToastKey(notifyContent.call_id, room.roomId),
|
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -233,7 +311,7 @@ describe("IncomingCallToast", () => {
|
|||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
||||||
getIncomingCallToastKey(notifyContent.call_id, room.roomId),
|
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -244,8 +322,136 @@ describe("IncomingCallToast", () => {
|
|||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
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<ISendEventResponse>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user