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:
Timo 2025-09-16 12:41:44 +02:00 committed by GitHub
parent 3c13f55b74
commit 0783f27f33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 638 additions and 193 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@ -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;
}
}
} }

View File

@ -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 },
}); });
} }
} }

View File

@ -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",

View File

@ -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);
}
}

View File

@ -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 = {};

View File

@ -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();
});
});

View File

@ -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>
);
}

View File

@ -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>
`;

View 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";

View File

@ -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"

View File

@ -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),

View File

@ -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();
}); });

View File

@ -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);
});
}); });