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:
@@ -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,
|
||||
|
||||
126
src/Notifier.ts
126
src/Notifier.ts
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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