Fix duplicate toasts appearing for the same call if two events appear. (#31693)

* fix export

* Don't allow duplicate toasts.

* fix imports

* fix

* fix rel_type to be m.reference in tests

* Fix case where redactions were not longer checked.

* Ensure toastkey is calculated by the room call_id

* Ensure we fetch the call event if it does not exist locally.

* cleanup / reduce code complexity

* Add docs

* Add comment

* merge condition
This commit is contained in:
Will Hunt
2026-01-23 10:38:39 +00:00
committed by GitHub
parent 88562127d7
commit c135c16824
6 changed files with 269 additions and 118 deletions

View File

@@ -74,7 +74,7 @@ async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "n
},
"m.relates_to": {
event_id: resp.event_id,
rel_type: "org.matrix.msc4075.rtc.notification.parent",
rel_type: "m.reference",
},
"m.call.intent": intent,
"notification_type": notification,

View File

@@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details.
*/
import {
type MatrixEvent,
MatrixEvent,
MatrixEventEvent,
type Room,
RoomEvent,
@@ -25,7 +25,7 @@ import {
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type PermissionChanged as PermissionChangedEvent } from "@matrix-org/analytics-events/types/typescript/PermissionChanged";
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
import { type SessionMembershipData, type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { PosthogAnalytics } from "./PosthogAnalytics";
@@ -481,44 +481,100 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
}
/**
* Some events require special handling such as showing in-app toasts
* Handle `EventType.RTCNotification` notifications.
* @param ev The notification event.
* @param toaster The toast store.
* @param room The room that contains the notification
* @returns A promise that will always resolve.
*/
private performCustomEventHandling(ev: MatrixEvent): void {
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(ev.getRoomId());
const rtcSession = room ? cli.matrixRTC.getRoomSession(room) : null;
let thisUserHasConnectedDevice = false;
if (rtcSession?.slotDescription?.application == "m.call") {
// Get the current state, the actual IncomingCallToast will update as needed by
// listening to the rtcSession directly.
thisUserHasConnectedDevice = rtcSession.memberships.some((m) => m.userId === cli.getUserId());
private async handleRTCNotification(ev: MatrixEvent, toaster: ToastStore, room: Room): Promise<void> {
// TODO: Use the call_id to get the *correct* call. We assume there is only one call per room here.
const rtcSession = room && room.client.matrixRTC.getRoomSession(room);
if (
rtcSession?.slotDescription?.application == "m.call" &&
rtcSession.memberships.some((membership) => membership.userId === room.client.getUserId())
) {
// If we're already joined to the session, don't notify.
return;
}
if (EventType.RTCNotification === ev.getType() && !thisUserHasConnectedDevice) {
const content = ev.getContent() as IRTCNotificationContent;
const roomId = ev.getRoomId();
const eventId = ev.getId();
// XXX: Should use parseCallNotificationContent once the types are exported.
const content = ev.getContent() as IRTCNotificationContent;
const roomId = ev.getRoomId();
const referencedMembershipEventId = ev.getRelation()?.event_id;
// Check maximum age of a call notification event that will trigger a ringing notification
if (Date.now() - getNotificationEventSendTs(ev) > content.lifetime) {
logger.warn("Received outdated RTCNotification event.");
return;
// Check maximum age of a call notification event that will trigger a ringing notification
if (Date.now() - getNotificationEventSendTs(ev) > content.lifetime) {
logger.warn("Received outdated RTCNotification event.");
return;
}
if (!roomId) {
logger.warn("Could not get roomId for RTCNotification event");
return;
}
if (!referencedMembershipEventId) {
logger.warn("Could not get referenced membership for notification");
return;
}
if (content["m.relates_to"].rel_type !== "m.reference") {
logger.warn("Ignored RTCNotification due to invalid rel_type");
return;
}
let callMembership = room?.findEventById(referencedMembershipEventId);
if (!callMembership) {
// Attempt to fetch from the homeserver, if we do not have the event locally.
// This is a rare case as obviously the referenced event for a m.call notification must
// be sent first.
try {
callMembership = new MatrixEvent(await room.client.fetchRoomEvent(roomId, referencedMembershipEventId));
} catch (ex) {
logger.warn(`Call membership for notification could not be found`, ex);
}
if (!roomId) {
logger.warn("Could not get roomId for RTCNotification event");
return;
}
if (!eventId) {
logger.warn("Could not get eventId for RTCNotification event");
return;
}
ToastStore.sharedInstance().addOrReplaceToast({
key: getIncomingCallToastKey(eventId, roomId),
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
props: { notificationEvent: ev },
});
}
// If the event could not be found even after requesting it from the homeserver.
if (!callMembership) {
// We will not show a call notification if there is no valid call membership.
logger.warn(
`Could not find call membership (${referencedMembershipEventId} ${roomId}) for notification event.`,
);
return;
}
// If we cannot determine the key, we'll accept it but assume it's empty string.
// This means if you have malformed notifications or call memberships your notifications
// will overwrite, but the solution to that is to use well-formed events.
const callId = callMembership.getContent<SessionMembershipData>().call_id ?? "";
const key = getIncomingCallToastKey(callId, roomId);
if (toaster.hasToast(key)) {
logger.debug(`Detected duplicate notification for call ${key}, ignoring`);
return;
}
toaster.addOrReplaceToast({
key,
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
props: { notificationEvent: ev },
});
}
/**
* Some events require special handling such as showing in-app toasts.
* This function may either create a toast or ignore the event based
* on current app state.
*/
private performCustomEventHandling(ev: MatrixEvent): void {
const toaster = ToastStore.sharedInstance();
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(ev.getRoomId());
if (room && EventType.RTCNotification === ev.getType()) {
// We don't need to await this.
void this.handleRTCNotification(ev, toaster, room);
}
}
}

View File

@@ -72,6 +72,14 @@ export default class ToastStore extends EventEmitter {
}
}
/**
* Is a toast currently present on the store.
* @param key The toast key to look for.
*/
public hasToast(key: string): boolean {
return this.toasts.some((toast) => toast.key === key);
}
public getToasts(): IToast<any>[] {
return this.toasts;
}

View File

@@ -7,7 +7,14 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, useCallback, useEffect, useRef, useState } from "react";
import { type Room, type MatrixEvent, type RoomMember, RoomEvent, EventType } from "matrix-js-sdk/src/matrix";
import {
type Room,
type MatrixEvent,
type RoomMember,
RoomEvent,
EventType,
MatrixEventEvent,
} from "matrix-js-sdk/src/matrix";
import { Button, ToggleInput, Tooltip, TooltipProvider } from "@vector-im/compound-web";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import { logger } from "matrix-js-sdk/src/logger";
@@ -29,18 +36,17 @@ import { useDispatcher } from "../hooks/useDispatcher";
import { type ActionPayload } from "../dispatcher/payloads";
import { type Call, CallEvent } from "../models/Call";
import LegacyCallHandler, { AudioID } from "../LegacyCallHandler";
import { useEventEmitter } from "../hooks/useEventEmitter";
import { useEventEmitter, useTypedEventEmitter } from "../hooks/useEventEmitter";
import { CallStore, CallStoreEvent } from "../stores/CallStore";
import DMRoomMap from "../utils/DMRoomMap";
/**
* 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.
* Get the key for the incoming call toast. A combination of the call ID and room ID.
* @param callId The ID of the call.
* @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}`;
export const getIncomingCallToastKey = (callId: string, roomId: string): string => `call_${callId}_${roomId}`;
/**
* Get the ts when the notification event was sent.
@@ -126,10 +132,18 @@ function DeclineCallButtonWithNotificationEvent({
}
interface Props {
/**
* A MatrixRTC notification event which has a content type of `IRTCNotificationContent`
*/
notificationEvent: MatrixEvent;
/**
* The unique key of the toast notification, used to dismiss the toast if the
* notification expires for any reason.
*/
toastKey: string;
}
export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
export function IncomingCallToast({ notificationEvent, toastKey }: 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>;
@@ -155,14 +169,16 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
// Stop ringing on dismiss.
const dismissToast = useCallback((): void => {
const notificationId = notificationEvent.getId();
if (!notificationId) {
logger.warn("Could not get eventId for RTCNotification event");
return;
}
ToastStore.sharedInstance().dismissToast(getIncomingCallToastKey(notificationId, roomId));
ToastStore.sharedInstance().dismissToast(toastKey);
LegacyCallHandler.instance.pause(AudioID.Ring);
}, [notificationEvent, roomId]);
}, [toastKey]);
// Dismiss if the notification event or call event is redacted
useTypedEventEmitter(room, MatrixEventEvent.BeforeRedaction, (ev: MatrixEvent) => {
if ([ev.getId(), ev.getRelation()?.event_id].includes(ev.getId())) {
dismissToast();
}
});
// Dismiss if session got ended remotely.
const onCall = useCallback(

View File

@@ -19,7 +19,8 @@ import {
type AccountDataEvents,
} from "matrix-js-sdk/src/matrix";
import { waitFor } from "jest-matrix-react";
import { CallMembership, type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { CallMembership, type SessionMembershipData, type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { randomUUID } from "node:crypto";
import type BasePlatform from "../../src/BasePlatform";
import Notifier from "../../src/Notifier";
@@ -57,6 +58,8 @@ jest.mock("../../src/audio/compat", () => ({
createAudioContext: jest.fn(),
}));
const settingsStoreGetValue = SettingsStore.getValue;
describe("Notifier", () => {
const roomId = "!room1:server";
const testEvent = mkEvent({
@@ -124,6 +127,7 @@ describe("Notifier", () => {
})
: undefined;
}),
fetchRoomEvent: jest.fn(),
decryptEventIfNeeded: jest.fn(),
getRoom: jest.fn(),
getPushActionsForEvent: jest.fn(),
@@ -368,21 +372,40 @@ describe("Notifier", () => {
});
describe("group call notifications", () => {
let callId: string;
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
jest.spyOn(SettingsStore, "getValue").mockImplementation((key, ...params) => {
if (key === "notificationsEnabled") {
return true;
}
return settingsStoreGetValue(key, ...params);
});
jest.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast");
jest.spyOn(ToastStore.sharedInstance(), "dismissToast");
ToastStore.sharedInstance().reset();
mockClient.getPushActionsForEvent.mockReturnValue({
notify: true,
tweaks: {},
});
callId = randomUUID();
jest.spyOn(testRoom, "findEventById").mockImplementation((eventId) => {
if (eventId === "$memberEventId") {
return mkEvent({
event: true,
user: "@alice:foo",
type: "org.matrix.msc4143.rtc.member",
content: { call_id: callId } satisfies Partial<SessionMembershipData>,
});
}
return undefined;
});
Notifier.start();
Notifier.onSyncStateChange(SyncState.Syncing, null);
});
afterEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
});
const emitCallNotificationEvent = (
@@ -391,9 +414,10 @@ describe("Notifier", () => {
roomMention?: boolean;
lifetime?: number;
ts?: number;
content?: Partial<IContent>;
} = {},
) => {
const { type, roomMention, lifetime, ts } = {
const { type, roomMention, lifetime, ts, content } = {
type: EventType.RTCNotification,
roomMention: true,
lifetime: 30000,
@@ -407,10 +431,11 @@ describe("Notifier", () => {
ts,
content: {
"notification_type": "ring",
"m.relation": { rel_type: "m.reference", event_id: "$memberEventId" },
"m.relates_to": { rel_type: "m.reference", event_id: "$memberEventId" },
"m.mentions": { user_ids: [], room: roomMention },
lifetime,
"sender_ts": ts,
...content,
},
event: true,
});
@@ -423,7 +448,7 @@ describe("Notifier", () => {
expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith(
expect.objectContaining({
key: getIncomingCallToastKey(notificationEvent.getId() ?? "", roomId),
key: getIncomingCallToastKey(callId, roomId),
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
@@ -432,6 +457,63 @@ describe("Notifier", () => {
);
});
it("shows group call toast once for multiple notifications to the same call", () => {
// Call the same function twice.
emitCallNotificationEvent();
emitCallNotificationEvent();
expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledTimes(1);
});
it("shows group call toast even if the call membership is not stored locally", () => {
jest.spyOn(testRoom, "findEventById").mockReturnValue(undefined);
jest.spyOn(mockClient, "fetchRoomEvent").mockImplementation(async (roomId, eventId) => {
if (eventId === "$memberEventId" && roomId === testRoom.roomId) {
return {
user: "@alice:foo",
type: "org.matrix.msc4143.rtc.member",
content: { call_id: callId } satisfies Partial<SessionMembershipData>,
};
}
throw new Error("Test mockClient.fetchRoomEvent failed to find event");
});
const notificationEvent = emitCallNotificationEvent();
waitFor(() => {
expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith(
expect.objectContaining({
key: getIncomingCallToastKey(callId, roomId),
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
props: { notificationEvent },
}),
);
});
});
it.each<IContent>([
{ "m.relates_to": undefined },
{ "m.relates_to": { rel_type: "m.reference" } },
{ "m.relates_to": { event_id: "$memberEventId", rel_type: "something.else" } },
])("ignores invalid relations for call notification", (content) => {
emitCallNotificationEvent({ content });
waitFor(() => {
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
});
});
it("ignores a call if the membership is missing", () => {
jest.spyOn(testRoom, "findEventById").mockReturnValue(undefined);
jest.spyOn(mockClient, "fetchRoomEvent").mockImplementation(async () => {
throw new Error("Test mockClient.fetchRoomEvent expected not to find event");
});
emitCallNotificationEvent();
waitFor(() => {
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
});
});
it("should not show toast when group call is already connected", () => {
const members = [
new CallMembership(

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { render, screen, cleanup, fireEvent, waitFor } from "jest-matrix-react";
import { type Mock, mocked, type Mocked } from "jest-mock";
import { mocked, type Mocked } from "jest-mock";
import {
Room,
RoomStateEvent,
@@ -23,6 +23,7 @@ import {
} from "matrix-js-sdk/src/matrix";
import { Widget } from "matrix-widget-api";
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
import { randomUUID } from "node:crypto";
import {
useMockedCalls,
@@ -65,7 +66,7 @@ describe("IncomingCallToast", () => {
} as unknown as DMRoomMap;
const toastStore = {
dismissToast: jest.fn(),
} as unknown as ToastStore;
} as unknown as Mocked<ToastStore>;
beforeEach(async () => {
stubClient();
@@ -118,6 +119,7 @@ describe("IncomingCallToast", () => {
jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap);
jest.spyOn(ToastStore, "sharedInstance").mockReturnValue(toastStore);
toastStore.dismissToast.mockReset();
});
afterEach(async () => {
@@ -128,13 +130,20 @@ describe("IncomingCallToast", () => {
jest.restoreAllMocks();
});
const renderToast = () => {
const renderToast = (): string => {
const callId = randomUUID();
call.event.getContent = () =>
({
call_id: "",
call_id: callId,
getRoomId: () => room.roomId,
}) as any;
render(<IncomingCallToast notificationEvent={notificationEvent} />);
render(
<IncomingCallToast
notificationEvent={notificationEvent}
toastKey={getIncomingCallToastKey(callId, room.roomId)}
/>,
);
return callId;
};
it("correctly shows all the information", () => {
@@ -159,7 +168,7 @@ describe("IncomingCallToast", () => {
};
const playMock = jest.spyOn(LegacyCallHandler.instance, "play");
render(<IncomingCallToast notificationEvent={notificationEvent} />);
render(<IncomingCallToast notificationEvent={notificationEvent} toastKey="" />);
expect(playMock).toHaveBeenCalled();
});
@@ -176,7 +185,7 @@ describe("IncomingCallToast", () => {
});
it("opens the call directly and closes the toast when pressing on the join button", async () => {
renderToast();
const callId = renderToast();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
@@ -193,16 +202,14 @@ describe("IncomingCallToast", () => {
}),
);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
defaultDispatcher.unregister(dispatcherRef);
});
it("opens the call lobby and closes the toast when configured like that", async () => {
renderToast();
const callId = renderToast();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
@@ -221,16 +228,14 @@ describe("IncomingCallToast", () => {
}),
);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
defaultDispatcher.unregister(dispatcherRef);
});
it("Dismiss toast if user starts call and skips lobby when using shift key click", async () => {
renderToast();
const callId = renderToast();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
@@ -246,16 +251,14 @@ describe("IncomingCallToast", () => {
}),
);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
defaultDispatcher.unregister(dispatcherRef);
});
it("Dismiss toast if user joins with a remote device", async () => {
renderToast();
const callId = renderToast();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
@@ -267,32 +270,28 @@ describe("IncomingCallToast", () => {
);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
defaultDispatcher.unregister(dispatcherRef);
});
it("closes the toast", async () => {
renderToast();
const callId = renderToast();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
defaultDispatcher.unregister(dispatcherRef);
});
it("closes toast when the call lobby is viewed", async () => {
renderToast();
const callId = renderToast();
defaultDispatcher.dispatch({
action: Action.ViewRoom,
@@ -301,39 +300,42 @@ describe("IncomingCallToast", () => {
});
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
});
it("closes toast when the call event is redacted", async () => {
renderToast();
const callId = renderToast();
const event = room.currentState.getStateEvents(MockedCall.EVENT_TYPE, "1")!;
event.emit(MatrixEventEvent.BeforeRedaction, event, {} as unknown as MatrixEvent);
room.emit(MatrixEventEvent.BeforeRedaction, event, {} as unknown as MatrixEvent);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
});
it("closes toast when the notification event is redacted", async () => {
const callId = renderToast();
room.emit(MatrixEventEvent.BeforeRedaction, notificationEvent, {} as unknown as MatrixEvent);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
});
it("closes toast when the matrixRTC session has ended", async () => {
renderToast();
const callId = renderToast();
call.destroy();
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
});
it("closes toast when a decline event was received", async () => {
(toastStore.dismissToast as Mock).mockReset();
renderToast();
const callId = renderToast();
room.emit(
RoomEvent.Timeline,
@@ -350,15 +352,12 @@ describe("IncomingCallToast", () => {
);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
});
it("does not close toast when a decline event for another user was received", async () => {
(toastStore.dismissToast as Mock).mockReset();
renderToast();
const callId = renderToast();
room.emit(
RoomEvent.Timeline,
@@ -375,15 +374,13 @@ describe("IncomingCallToast", () => {
);
await waitFor(() =>
expect(toastStore.dismissToast).not.toHaveBeenCalledWith(
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
expect(toastStore.dismissToast).not.toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
});
it("does not close toast when a decline event for another notification Event was received", async () => {
(toastStore.dismissToast as Mock).mockReset();
renderToast();
const callId = renderToast();
room.emit(
RoomEvent.Timeline,
@@ -400,16 +397,12 @@ describe("IncomingCallToast", () => {
);
await waitFor(() =>
expect(toastStore.dismissToast).not.toHaveBeenCalledWith(
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
expect(toastStore.dismissToast).not.toHaveBeenCalledWith(getIncomingCallToastKey(callId, 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 callId = renderToast();
const { promise, resolve } = Promise.withResolvers<ISendEventResponse>();
client.sendRtcDecline.mockImplementation(() => {
@@ -418,17 +411,13 @@ describe("IncomingCallToast", () => {
fireEvent.click(screen.getByRole("button", { name: "Decline" }));
expect(toastStore.dismissToast).not.toHaveBeenCalledWith(
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
);
expect(toastStore.dismissToast).not.toHaveBeenCalledWith(getIncomingCallToastKey(callId, 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),
),
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
});