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
+88 -6
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(
@@ -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)),
);
});