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
+1 -1
View File
@@ -74,7 +74,7 @@ async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "n
}, },
"m.relates_to": { "m.relates_to": {
event_id: resp.event_id, event_id: resp.event_id,
rel_type: "org.matrix.msc4075.rtc.notification.parent", rel_type: "m.reference",
}, },
"m.call.intent": intent, "m.call.intent": intent,
"notification_type": notification, "notification_type": notification,
+91 -35
View File
@@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details.
*/ */
import { import {
type MatrixEvent, MatrixEvent,
MatrixEventEvent, MatrixEventEvent,
type Room, type Room,
RoomEvent, RoomEvent,
@@ -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 { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; import { type SessionMembershipData, type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
import { MatrixClientPeg } from "./MatrixClientPeg"; import { MatrixClientPeg } from "./MatrixClientPeg";
import { PosthogAnalytics } from "./PosthogAnalytics"; 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 { private async handleRTCNotification(ev: MatrixEvent, toaster: ToastStore, room: Room): Promise<void> {
const cli = MatrixClientPeg.safeGet(); // TODO: Use the call_id to get the *correct* call. We assume there is only one call per room here.
const room = cli.getRoom(ev.getRoomId()); const rtcSession = room && room.client.matrixRTC.getRoomSession(room);
const rtcSession = room ? cli.matrixRTC.getRoomSession(room) : null; if (
let thisUserHasConnectedDevice = false; rtcSession?.slotDescription?.application == "m.call" &&
if (rtcSession?.slotDescription?.application == "m.call") { rtcSession.memberships.some((membership) => membership.userId === room.client.getUserId())
// Get the current state, the actual IncomingCallToast will update as needed by ) {
// listening to the rtcSession directly. // If we're already joined to the session, don't notify.
thisUserHasConnectedDevice = rtcSession.memberships.some((m) => m.userId === cli.getUserId()); return;
} }
if (EventType.RTCNotification === ev.getType() && !thisUserHasConnectedDevice) { // XXX: Should use parseCallNotificationContent once the types are exported.
const content = ev.getContent() as IRTCNotificationContent; const content = ev.getContent() as IRTCNotificationContent;
const roomId = ev.getRoomId(); const roomId = ev.getRoomId();
const eventId = ev.getId(); const referencedMembershipEventId = ev.getRelation()?.event_id;
// Check maximum age of a call notification event that will trigger a ringing notification // Check maximum age of a call notification event that will trigger a ringing notification
if (Date.now() - getNotificationEventSendTs(ev) > content.lifetime) { if (Date.now() - getNotificationEventSendTs(ev) > content.lifetime) {
logger.warn("Received outdated RTCNotification event."); logger.warn("Received outdated RTCNotification event.");
return; 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"); // If the event could not be found even after requesting it from the homeserver.
return; if (!callMembership) {
} // We will not show a call notification if there is no valid call membership.
if (!eventId) { logger.warn(
logger.warn("Could not get eventId for RTCNotification event"); `Could not find call membership (${referencedMembershipEventId} ${roomId}) for notification event.`,
return; );
} return;
ToastStore.sharedInstance().addOrReplaceToast({ }
key: getIncomingCallToastKey(eventId, roomId),
priority: 100, // If we cannot determine the key, we'll accept it but assume it's empty string.
component: IncomingCallToast, // This means if you have malformed notifications or call memberships your notifications
bodyClassName: "mx_IncomingCallToast", // will overwrite, but the solution to that is to use well-formed events.
props: { notificationEvent: ev }, 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);
} }
} }
} }
+8
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>[] { public getToasts(): IToast<any>[] {
return this.toasts; return this.toasts;
} }
+30 -14
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 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 { 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 { logger } from "matrix-js-sdk/src/logger"; 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 ActionPayload } from "../dispatcher/payloads";
import { type Call, CallEvent } from "../models/Call"; import { type Call, CallEvent } from "../models/Call";
import LegacyCallHandler, { AudioID } from "../LegacyCallHandler"; import LegacyCallHandler, { AudioID } from "../LegacyCallHandler";
import { useEventEmitter } from "../hooks/useEventEmitter"; import { useEventEmitter, useTypedEventEmitter } from "../hooks/useEventEmitter";
import { CallStore, CallStoreEvent } from "../stores/CallStore"; import { CallStore, CallStoreEvent } from "../stores/CallStore";
import DMRoomMap from "../utils/DMRoomMap"; import DMRoomMap from "../utils/DMRoomMap";
/** /**
* Get the key for the incoming call toast. A combination of the event ID and room ID. * Get the key for the incoming call toast. A combination of the call ID and room ID.
* @param notificationEventId The ID of the notification event. * @param callId The ID of the call.
* @param roomId The ID of the room. * @param roomId The ID of the room.
* @returns The key for the incoming call toast. * @returns The key for the incoming call toast.
*/ */
export const getIncomingCallToastKey = (notificationEventId: string, roomId: string): string => export const getIncomingCallToastKey = (callId: string, roomId: string): string => `call_${callId}_${roomId}`;
`call_${notificationEventId}_${roomId}`;
/** /**
* Get the ts when the notification event was sent. * Get the ts when the notification event was sent.
@@ -126,10 +132,18 @@ function DeclineCallButtonWithNotificationEvent({
} }
interface Props { interface Props {
/**
* A MatrixRTC notification event which has a content type of `IRTCNotificationContent`
*/
notificationEvent: MatrixEvent; 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()!; const roomId = notificationEvent.getRoomId()!;
// Use a partial type so ts still helps us to not miss any type checks. // Use a partial type so ts still helps us to not miss any type checks.
const notificationContent = notificationEvent.getContent() as Partial<IRTCNotificationContent>; const notificationContent = notificationEvent.getContent() as Partial<IRTCNotificationContent>;
@@ -155,14 +169,16 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
// Stop ringing on dismiss. // Stop ringing on dismiss.
const dismissToast = useCallback((): void => { const dismissToast = useCallback((): void => {
const notificationId = notificationEvent.getId(); ToastStore.sharedInstance().dismissToast(toastKey);
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);
}, [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. // Dismiss if session got ended remotely.
const onCall = useCallback( const onCall = useCallback(
+88 -6
View File
@@ -19,7 +19,8 @@ import {
type AccountDataEvents, type AccountDataEvents,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { waitFor } from "jest-matrix-react"; 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 type BasePlatform from "../../src/BasePlatform";
import Notifier from "../../src/Notifier"; import Notifier from "../../src/Notifier";
@@ -57,6 +58,8 @@ jest.mock("../../src/audio/compat", () => ({
createAudioContext: jest.fn(), createAudioContext: jest.fn(),
})); }));
const settingsStoreGetValue = SettingsStore.getValue;
describe("Notifier", () => { describe("Notifier", () => {
const roomId = "!room1:server"; const roomId = "!room1:server";
const testEvent = mkEvent({ const testEvent = mkEvent({
@@ -124,6 +127,7 @@ describe("Notifier", () => {
}) })
: undefined; : undefined;
}), }),
fetchRoomEvent: jest.fn(),
decryptEventIfNeeded: jest.fn(), decryptEventIfNeeded: jest.fn(),
getRoom: jest.fn(), getRoom: jest.fn(),
getPushActionsForEvent: jest.fn(), getPushActionsForEvent: jest.fn(),
@@ -368,21 +372,40 @@ describe("Notifier", () => {
}); });
describe("group call notifications", () => { describe("group call notifications", () => {
let callId: string;
beforeEach(() => { 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(), "addOrReplaceToast");
jest.spyOn(ToastStore.sharedInstance(), "dismissToast"); jest.spyOn(ToastStore.sharedInstance(), "dismissToast");
ToastStore.sharedInstance().reset();
mockClient.getPushActionsForEvent.mockReturnValue({ mockClient.getPushActionsForEvent.mockReturnValue({
notify: true, notify: true,
tweaks: {}, 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.start();
Notifier.onSyncStateChange(SyncState.Syncing, null); Notifier.onSyncStateChange(SyncState.Syncing, null);
}); });
afterEach(() => { afterEach(() => {
jest.resetAllMocks(); jest.restoreAllMocks();
}); });
const emitCallNotificationEvent = ( const emitCallNotificationEvent = (
@@ -391,9 +414,10 @@ describe("Notifier", () => {
roomMention?: boolean; roomMention?: boolean;
lifetime?: number; lifetime?: number;
ts?: number; ts?: number;
content?: Partial<IContent>;
} = {}, } = {},
) => { ) => {
const { type, roomMention, lifetime, ts } = { const { type, roomMention, lifetime, ts, content } = {
type: EventType.RTCNotification, type: EventType.RTCNotification,
roomMention: true, roomMention: true,
lifetime: 30000, lifetime: 30000,
@@ -407,10 +431,11 @@ describe("Notifier", () => {
ts, ts,
content: { content: {
"notification_type": "ring", "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 }, "m.mentions": { user_ids: [], room: roomMention },
lifetime, lifetime,
"sender_ts": ts, "sender_ts": ts,
...content,
}, },
event: true, event: true,
}); });
@@ -423,7 +448,7 @@ describe("Notifier", () => {
expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith( expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
key: getIncomingCallToastKey(notificationEvent.getId() ?? "", roomId), key: getIncomingCallToastKey(callId, roomId),
priority: 100, priority: 100,
component: IncomingCallToast, component: IncomingCallToast,
bodyClassName: "mx_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", () => { it("should not show toast when group call is already connected", () => {
const members = [ const members = [
new CallMembership( new CallMembership(
@@ -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 { type Mock, mocked, type Mocked } from "jest-mock"; import { mocked, type Mocked } from "jest-mock";
import { import {
Room, Room,
RoomStateEvent, RoomStateEvent,
@@ -23,6 +23,7 @@ import {
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { Widget } from "matrix-widget-api"; import { Widget } from "matrix-widget-api";
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
import { randomUUID } from "node:crypto";
import { import {
useMockedCalls, useMockedCalls,
@@ -65,7 +66,7 @@ describe("IncomingCallToast", () => {
} as unknown as DMRoomMap; } as unknown as DMRoomMap;
const toastStore = { const toastStore = {
dismissToast: jest.fn(), dismissToast: jest.fn(),
} as unknown as ToastStore; } as unknown as Mocked<ToastStore>;
beforeEach(async () => { beforeEach(async () => {
stubClient(); stubClient();
@@ -118,6 +119,7 @@ describe("IncomingCallToast", () => {
jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap);
jest.spyOn(ToastStore, "sharedInstance").mockReturnValue(toastStore); jest.spyOn(ToastStore, "sharedInstance").mockReturnValue(toastStore);
toastStore.dismissToast.mockReset();
}); });
afterEach(async () => { afterEach(async () => {
@@ -128,13 +130,20 @@ describe("IncomingCallToast", () => {
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
const renderToast = () => { const renderToast = (): string => {
const callId = randomUUID();
call.event.getContent = () => call.event.getContent = () =>
({ ({
call_id: "", call_id: callId,
getRoomId: () => room.roomId, getRoomId: () => room.roomId,
}) as any; }) as any;
render(<IncomingCallToast notificationEvent={notificationEvent} />); render(
<IncomingCallToast
notificationEvent={notificationEvent}
toastKey={getIncomingCallToastKey(callId, room.roomId)}
/>,
);
return callId;
}; };
it("correctly shows all the information", () => { it("correctly shows all the information", () => {
@@ -159,7 +168,7 @@ describe("IncomingCallToast", () => {
}; };
const playMock = jest.spyOn(LegacyCallHandler.instance, "play"); const playMock = jest.spyOn(LegacyCallHandler.instance, "play");
render(<IncomingCallToast notificationEvent={notificationEvent} />); render(<IncomingCallToast notificationEvent={notificationEvent} toastKey="" />);
expect(playMock).toHaveBeenCalled(); 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 () => { 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 dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy); const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
@@ -193,16 +202,14 @@ describe("IncomingCallToast", () => {
}), }),
); );
await waitFor(() => await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith( expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
); );
defaultDispatcher.unregister(dispatcherRef); defaultDispatcher.unregister(dispatcherRef);
}); });
it("opens the call lobby and closes the toast when configured like that", async () => { it("opens the call lobby and closes the toast when configured like that", async () => {
renderToast(); const callId = renderToast();
const dispatcherSpy = jest.fn(); const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy); const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
@@ -221,16 +228,14 @@ describe("IncomingCallToast", () => {
}), }),
); );
await waitFor(() => await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith( expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, 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(); const callId = renderToast();
const dispatcherSpy = jest.fn(); const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy); const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
@@ -246,16 +251,14 @@ describe("IncomingCallToast", () => {
}), }),
); );
await waitFor(() => await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith( expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
); );
defaultDispatcher.unregister(dispatcherRef); defaultDispatcher.unregister(dispatcherRef);
}); });
it("Dismiss toast if user joins with a remote device", async () => { it("Dismiss toast if user joins with a remote device", async () => {
renderToast(); const callId = renderToast();
const dispatcherSpy = jest.fn(); const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy); const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
@@ -267,32 +270,28 @@ describe("IncomingCallToast", () => {
); );
await waitFor(() => await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith( expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
); );
defaultDispatcher.unregister(dispatcherRef); defaultDispatcher.unregister(dispatcherRef);
}); });
it("closes the toast", async () => { it("closes the toast", async () => {
renderToast(); const callId = renderToast();
const dispatcherSpy = jest.fn(); const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy); const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
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(callId, room.roomId)),
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
); );
defaultDispatcher.unregister(dispatcherRef); defaultDispatcher.unregister(dispatcherRef);
}); });
it("closes toast when the call lobby is viewed", async () => { it("closes toast when the call lobby is viewed", async () => {
renderToast(); const callId = renderToast();
defaultDispatcher.dispatch({ defaultDispatcher.dispatch({
action: Action.ViewRoom, action: Action.ViewRoom,
@@ -301,39 +300,42 @@ describe("IncomingCallToast", () => {
}); });
await waitFor(() => await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith( expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
); );
}); });
it("closes toast when the call event is redacted", async () => { it("closes toast when the call event is redacted", async () => {
renderToast(); const callId = renderToast();
const event = room.currentState.getStateEvents(MockedCall.EVENT_TYPE, "1")!; 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(() => await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith( expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
getIncomingCallToastKey(notificationEvent.getId()!, 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 () => { it("closes toast when the matrixRTC session has ended", async () => {
renderToast(); const callId = renderToast();
call.destroy(); call.destroy();
await waitFor(() => await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith( expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
); );
}); });
it("closes toast when a decline event was received", async () => { it("closes toast when a decline event was received", async () => {
(toastStore.dismissToast as Mock).mockReset(); const callId = renderToast();
renderToast();
room.emit( room.emit(
RoomEvent.Timeline, RoomEvent.Timeline,
@@ -350,15 +352,12 @@ describe("IncomingCallToast", () => {
); );
await waitFor(() => await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith( expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
); );
}); });
it("does not close toast when a decline event for another user was received", async () => { it("does not close toast when a decline event for another user was received", async () => {
(toastStore.dismissToast as Mock).mockReset(); const callId = renderToast();
renderToast();
room.emit( room.emit(
RoomEvent.Timeline, RoomEvent.Timeline,
@@ -375,15 +374,13 @@ describe("IncomingCallToast", () => {
); );
await waitFor(() => await waitFor(() =>
expect(toastStore.dismissToast).not.toHaveBeenCalledWith( expect(toastStore.dismissToast).not.toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
); );
}); });
it("does not close toast when a decline event for another notification Event was received", async () => { it("does not close toast when a decline event for another notification Event was received", async () => {
(toastStore.dismissToast as Mock).mockReset();
renderToast(); renderToast();
const callId = renderToast();
room.emit( room.emit(
RoomEvent.Timeline, RoomEvent.Timeline,
@@ -400,16 +397,12 @@ describe("IncomingCallToast", () => {
); );
await waitFor(() => await waitFor(() =>
expect(toastStore.dismissToast).not.toHaveBeenCalledWith( expect(toastStore.dismissToast).not.toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
); );
}); });
it("sends a decline event when clicking the decline button and only dismiss after sending", async () => { it("sends a decline event when clicking the decline button and only dismiss after sending", async () => {
(toastStore.dismissToast as Mock).mockReset(); const callId = renderToast();
renderToast();
const { promise, resolve } = Promise.withResolvers<ISendEventResponse>(); const { promise, resolve } = Promise.withResolvers<ISendEventResponse>();
client.sendRtcDecline.mockImplementation(() => { client.sendRtcDecline.mockImplementation(() => {
@@ -418,17 +411,13 @@ describe("IncomingCallToast", () => {
fireEvent.click(screen.getByRole("button", { name: "Decline" })); fireEvent.click(screen.getByRole("button", { name: "Decline" }));
expect(toastStore.dismissToast).not.toHaveBeenCalledWith( expect(toastStore.dismissToast).not.toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId));
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
);
expect(client.sendRtcDecline).toHaveBeenCalledWith("!1:example.org", "$notificationEventId"); expect(client.sendRtcDecline).toHaveBeenCalledWith("!1:example.org", "$notificationEventId");
resolve({ event_id: "$declineEventId" }); resolve({ event_id: "$declineEventId" });
await waitFor(() => await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith( expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
),
); );
}); });