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:
@@ -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)),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user