Add decline button to call notification toast (use new notification event) (#30729)
* Add decline button to call notification toast (use new notification event) - This make EW incompatible with the old style notify events. Signed-off-by: Timo K <toger5@hotmail.de> * update styling for call toast Signed-off-by: Timo K <toger5@hotmail.de> * skip lobby on join button click / dont skip lobby on toast click Signed-off-by: Timo K <toger5@hotmail.de> * dismiss toast on remote decline Signed-off-by: Timo K <toger5@hotmail.de> * fixup docstring and event_id Signed-off-by: Timo K <toger5@hotmail.de> * Add tests Signed-off-by: Timo K <toger5@hotmail.de> * remove unused var Signed-off-by: Timo K <toger5@hotmail.de> * test that decline event gets sent Signed-off-by: Timo K <toger5@hotmail.de> * make "go to lobby" accessible via keyboard (fix sonar cloud) Signed-off-by: Timo K <toger5@hotmail.de> * remove keyboard input Signed-off-by: Timo K <toger5@hotmail.de> * fix lint Signed-off-by: Timo K <toger5@hotmail.de> * use actual button Signed-off-by: Timo K <toger5@hotmail.de> * review style + toggle for join immediately Signed-off-by: Timo K <toger5@hotmail.de> * fix `getNotificationEventSendTs` Signed-off-by: Timo K <toger5@hotmail.de> * use story component Signed-off-by: Timo K <toger5@hotmail.de> * english text Signed-off-by: Timo K <toger5@hotmail.de> * dont use legacy toggle Signed-off-by: Timo K <toger5@hotmail.de> * fix lint Signed-off-by: Timo K <toger5@hotmail.de> * review Signed-off-by: Timo K <toger5@hotmail.de> * review (mostly docs) Signed-off-by: Timo K <toger5@hotmail.de> --------- Signed-off-by: Timo K <toger5@hotmail.de>
This commit is contained in:
@@ -385,33 +385,49 @@ describe("Notifier", () => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const emitCallNotifyEvent = (type?: string, roomMention = true) => {
|
||||
const callEvent = mkEvent({
|
||||
type: type ?? EventType.CallNotify,
|
||||
const emitCallNotificationEvent = (
|
||||
params: {
|
||||
type?: string;
|
||||
roomMention?: boolean;
|
||||
lifetime?: number;
|
||||
ts?: number;
|
||||
} = {},
|
||||
) => {
|
||||
const { type, roomMention, lifetime, ts } = {
|
||||
type: EventType.RTCNotification,
|
||||
roomMention: true,
|
||||
lifetime: 30000,
|
||||
ts: Date.now(),
|
||||
...params,
|
||||
};
|
||||
const notificationEvent = mkEvent({
|
||||
type: type,
|
||||
user: "@alice:foo",
|
||||
room: roomId,
|
||||
ts,
|
||||
content: {
|
||||
"application": "m.call",
|
||||
"notification_type": "ring",
|
||||
"m.relation": { rel_type: "m.reference", event_id: "$memberEventId" },
|
||||
"m.mentions": { user_ids: [], room: roomMention },
|
||||
"notify_type": "ring",
|
||||
"call_id": "abc123",
|
||||
lifetime,
|
||||
"sender_ts": ts,
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
emitLiveEvent(callEvent);
|
||||
return callEvent;
|
||||
emitLiveEvent(notificationEvent);
|
||||
return notificationEvent;
|
||||
};
|
||||
|
||||
it("shows group call toast", () => {
|
||||
const notifyEvent = emitCallNotifyEvent();
|
||||
const notificationEvent = emitCallNotificationEvent();
|
||||
|
||||
expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: getIncomingCallToastKey(notifyEvent.getContent().call_id ?? "", roomId),
|
||||
key: getIncomingCallToastKey(notificationEvent.getId() ?? "", roomId),
|
||||
priority: 100,
|
||||
component: IncomingCallToast,
|
||||
bodyClassName: "mx_IncomingCallToast",
|
||||
props: { notifyEvent },
|
||||
props: { notificationEvent },
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -439,59 +455,19 @@ describe("Notifier", () => {
|
||||
const roomSession = MatrixRTCSession.roomSessionForRoom(mockClient, testRoom);
|
||||
|
||||
mockClient.matrixRTC.getRoomSession.mockReturnValue(roomSession);
|
||||
emitCallNotifyEvent();
|
||||
emitCallNotificationEvent();
|
||||
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
|
||||
spyCallMemberships.mockRestore();
|
||||
});
|
||||
|
||||
it("dismisses call notification when another device answers the call", () => {
|
||||
const notifyEvent = emitCallNotifyEvent();
|
||||
const spyCallMemberships = jest.spyOn(MatrixRTCSession, "callMembershipsForRoom");
|
||||
it("should not show toast when calling with a different event type to org.matrix.msc4075.rtc.notification", () => {
|
||||
emitCallNotificationEvent({ type: "event_type" });
|
||||
|
||||
expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: getIncomingCallToastKey(notifyEvent.getContent().call_id ?? "", roomId),
|
||||
priority: 100,
|
||||
component: IncomingCallToast,
|
||||
bodyClassName: "mx_IncomingCallToast",
|
||||
props: { notifyEvent },
|
||||
}),
|
||||
);
|
||||
// Mock ourselves joining the call.
|
||||
spyCallMemberships.mockReturnValue([
|
||||
new CallMembership(
|
||||
mkEvent({
|
||||
event: true,
|
||||
room: testRoom.roomId,
|
||||
user: userId,
|
||||
type: EventType.GroupCallMemberPrefix,
|
||||
content: {},
|
||||
}),
|
||||
{
|
||||
call_id: "123",
|
||||
application: "m.call",
|
||||
focus_active: { type: "livekit" },
|
||||
foci_preferred: [],
|
||||
device_id: "DEVICE",
|
||||
},
|
||||
),
|
||||
]);
|
||||
const callEvent = mkEvent({
|
||||
type: EventType.GroupCallMemberPrefix,
|
||||
user: "@alice:foo",
|
||||
room: roomId,
|
||||
content: {
|
||||
call_id: "abc123",
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
emitLiveEvent(callEvent);
|
||||
expect(ToastStore.sharedInstance().dismissToast).toHaveBeenCalled();
|
||||
spyCallMemberships.mockRestore();
|
||||
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not show toast when calling with non-group call event", () => {
|
||||
emitCallNotifyEvent("event_type");
|
||||
it("should not show notification event is expired", () => {
|
||||
emitCallNotificationEvent({ ts: Date.now() - 40000 });
|
||||
|
||||
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -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 { mocked, type Mocked } from "jest-mock";
|
||||
import { type Mock, mocked, type Mocked } from "jest-mock";
|
||||
import {
|
||||
Room,
|
||||
RoomStateEvent,
|
||||
@@ -16,9 +16,13 @@ import {
|
||||
MatrixEventEvent,
|
||||
type MatrixClient,
|
||||
type RoomMember,
|
||||
EventType,
|
||||
RoomEvent,
|
||||
type IRoomTimelineData,
|
||||
type ISendEventResponse,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { type ClientWidgetApi, Widget } from "matrix-widget-api";
|
||||
import { type ICallNotifyContent } from "matrix-js-sdk/src/matrixrtc";
|
||||
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
|
||||
|
||||
import {
|
||||
useMockedCalls,
|
||||
@@ -27,6 +31,7 @@ import {
|
||||
mkRoomMember,
|
||||
setupAsyncStoreWithClient,
|
||||
resetAsyncStoreWithClient,
|
||||
mkEvent,
|
||||
} from "../../test-utils";
|
||||
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../src/dispatcher/actions";
|
||||
@@ -35,15 +40,21 @@ import { CallStore } from "../../../src/stores/CallStore";
|
||||
import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore";
|
||||
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
||||
import ToastStore from "../../../src/stores/ToastStore";
|
||||
import { getIncomingCallToastKey, IncomingCallToast } from "../../../src/toasts/IncomingCallToast";
|
||||
import {
|
||||
getIncomingCallToastKey,
|
||||
getNotificationEventSendTs,
|
||||
IncomingCallToast,
|
||||
} from "../../../src/toasts/IncomingCallToast";
|
||||
import LegacyCallHandler, { AudioID } from "../../../src/LegacyCallHandler";
|
||||
import { CallEvent } from "../../../src/models/Call";
|
||||
|
||||
describe("IncomingCallToast", () => {
|
||||
useMockedCalls();
|
||||
|
||||
let client: Mocked<MatrixClient>;
|
||||
let room: Room;
|
||||
let notifyContent: ICallNotifyContent;
|
||||
let notificationEvent: MatrixEvent;
|
||||
|
||||
let alice: RoomMember;
|
||||
let bob: RoomMember;
|
||||
let call: MockedCall;
|
||||
@@ -64,10 +75,23 @@ describe("IncomingCallToast", () => {
|
||||
document.body.appendChild(audio);
|
||||
|
||||
room = new Room("!1:example.org", client, "@alice:example.org");
|
||||
notifyContent = {
|
||||
call_id: "",
|
||||
getRoomId: () => room.roomId,
|
||||
} as unknown as ICallNotifyContent;
|
||||
const ts = Date.now();
|
||||
const notificationContent = {
|
||||
"notification_type": "notification",
|
||||
"m.relation": { rel_type: "m.reference", event_id: "$memberEventId" },
|
||||
"m.mentions": { user_ids: [], room: true },
|
||||
"lifetime": 3000,
|
||||
"sender_ts": ts,
|
||||
} as unknown as IRTCNotificationContent;
|
||||
notificationEvent = mkEvent({
|
||||
type: EventType.RTCNotification,
|
||||
user: "@userId:matrix.org",
|
||||
content: notificationContent,
|
||||
room: room.roomId,
|
||||
ts,
|
||||
id: "$notificationEventId",
|
||||
event: true,
|
||||
});
|
||||
alice = mkRoomMember(room.roomId, "@alice:example.org");
|
||||
bob = mkRoomMember(room.roomId, "@bob:example.org");
|
||||
|
||||
@@ -104,8 +128,12 @@ describe("IncomingCallToast", () => {
|
||||
});
|
||||
|
||||
const renderToast = () => {
|
||||
call.event.getContent = () => notifyContent as any;
|
||||
render(<IncomingCallToast notifyEvent={call.event} />);
|
||||
call.event.getContent = () =>
|
||||
({
|
||||
call_id: "",
|
||||
getRoomId: () => room.roomId,
|
||||
}) as any;
|
||||
render(<IncomingCallToast notificationEvent={notificationEvent} />);
|
||||
};
|
||||
|
||||
it("correctly shows all the information", () => {
|
||||
@@ -124,14 +152,13 @@ describe("IncomingCallToast", () => {
|
||||
});
|
||||
|
||||
it("start ringing on ring notify event", () => {
|
||||
call.event.getContent = () =>
|
||||
({
|
||||
...notifyContent,
|
||||
notify_type: "ring",
|
||||
}) as any;
|
||||
const oldContent = notificationEvent.getContent() as IRTCNotificationContent;
|
||||
(notificationEvent as unknown as { getContent: () => IRTCNotificationContent }).getContent = () => {
|
||||
return { ...oldContent, notification_type: "ring" } as IRTCNotificationContent;
|
||||
};
|
||||
|
||||
const playMock = jest.spyOn(LegacyCallHandler.instance, "play");
|
||||
render(<IncomingCallToast notifyEvent={call.event} />);
|
||||
render(<IncomingCallToast notificationEvent={notificationEvent} />);
|
||||
expect(playMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -143,15 +170,44 @@ describe("IncomingCallToast", () => {
|
||||
screen.getByText("Video");
|
||||
|
||||
screen.getByRole("button", { name: "Join" });
|
||||
screen.getByRole("button", { name: "Decline" });
|
||||
screen.getByRole("button", { name: "Close" });
|
||||
});
|
||||
|
||||
it("joins the call and closes the toast", async () => {
|
||||
it("opens the call directly and closes the toast when pressing on the join button", async () => {
|
||||
renderToast();
|
||||
|
||||
const dispatcherSpy = jest.fn();
|
||||
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
|
||||
|
||||
// click on the avatar (which is the example used for pressing on any area other than the buttons)
|
||||
fireEvent.click(screen.getByRole("button", { name: "Join" }));
|
||||
await waitFor(() =>
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
skipLobby: true,
|
||||
view_call: true,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
||||
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
|
||||
),
|
||||
);
|
||||
|
||||
defaultDispatcher.unregister(dispatcherRef);
|
||||
});
|
||||
|
||||
it("opens the call lobby and closes the toast when configured like that", async () => {
|
||||
renderToast();
|
||||
|
||||
const dispatcherSpy = jest.fn();
|
||||
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", {}));
|
||||
|
||||
// click on the avatar (which is the example used for pressing on any area other than the buttons)
|
||||
fireEvent.click(screen.getByRole("button", { name: "Join" }));
|
||||
await waitFor(() =>
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
||||
@@ -163,12 +219,13 @@ describe("IncomingCallToast", () => {
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
||||
getIncomingCallToastKey(notifyContent.call_id, room.roomId),
|
||||
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
|
||||
),
|
||||
);
|
||||
|
||||
defaultDispatcher.unregister(dispatcherRef);
|
||||
});
|
||||
|
||||
it("Dismiss toast if user starts call and skips lobby when using shift key click", async () => {
|
||||
renderToast();
|
||||
|
||||
@@ -186,7 +243,28 @@ describe("IncomingCallToast", () => {
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
||||
getIncomingCallToastKey(notifyContent.call_id, room.roomId),
|
||||
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
|
||||
),
|
||||
);
|
||||
|
||||
defaultDispatcher.unregister(dispatcherRef);
|
||||
});
|
||||
|
||||
it("Dismiss toast if user joins with a remote device", async () => {
|
||||
renderToast();
|
||||
|
||||
const dispatcherSpy = jest.fn();
|
||||
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
|
||||
|
||||
call.emit(
|
||||
CallEvent.Participants,
|
||||
new Map([[mkRoomMember(room.roomId, "@userId:matrix.org"), new Set(["a"])]]),
|
||||
new Map(),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
||||
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -202,7 +280,7 @@ describe("IncomingCallToast", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
await waitFor(() =>
|
||||
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
||||
getIncomingCallToastKey(notifyContent.call_id, room.roomId),
|
||||
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -220,7 +298,7 @@ describe("IncomingCallToast", () => {
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
||||
getIncomingCallToastKey(notifyContent.call_id, room.roomId),
|
||||
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -233,7 +311,7 @@ describe("IncomingCallToast", () => {
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
||||
getIncomingCallToastKey(notifyContent.call_id, room.roomId),
|
||||
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -244,8 +322,136 @@ describe("IncomingCallToast", () => {
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
||||
getIncomingCallToastKey(notifyContent.call_id, room.roomId),
|
||||
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("closes toast when a decline event was received", async () => {
|
||||
(toastStore.dismissToast as Mock).mockReset();
|
||||
renderToast();
|
||||
|
||||
room.emit(
|
||||
RoomEvent.Timeline,
|
||||
mkEvent({
|
||||
user: "@userId:matrix.org",
|
||||
type: EventType.RTCDecline,
|
||||
content: { "m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" } },
|
||||
event: true,
|
||||
}),
|
||||
room,
|
||||
undefined,
|
||||
false,
|
||||
{} as unknown as IRoomTimelineData,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
||||
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not close toast when a decline event for another user was received", async () => {
|
||||
(toastStore.dismissToast as Mock).mockReset();
|
||||
renderToast();
|
||||
|
||||
room.emit(
|
||||
RoomEvent.Timeline,
|
||||
mkEvent({
|
||||
user: "@userIdNotMe:matrix.org",
|
||||
type: EventType.RTCDecline,
|
||||
content: { "m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" } },
|
||||
event: true,
|
||||
}),
|
||||
room,
|
||||
undefined,
|
||||
false,
|
||||
{} as unknown as IRoomTimelineData,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastStore.dismissToast).not.toHaveBeenCalledWith(
|
||||
getIncomingCallToastKey(notificationEvent.getId()!, room.roomId),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not close toast when a decline event for another notification Event was received", async () => {
|
||||
(toastStore.dismissToast as Mock).mockReset();
|
||||
renderToast();
|
||||
|
||||
room.emit(
|
||||
RoomEvent.Timeline,
|
||||
mkEvent({
|
||||
user: "@userId:matrix.org",
|
||||
type: EventType.RTCDecline,
|
||||
content: { "m.relates_to": { event_id: "$otherNotificationEventRelation", rel_type: "m.reference" } },
|
||||
event: true,
|
||||
}),
|
||||
room,
|
||||
undefined,
|
||||
false,
|
||||
{} as unknown as IRoomTimelineData,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastStore.dismissToast).not.toHaveBeenCalledWith(
|
||||
getIncomingCallToastKey(notificationEvent.getId()!, 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 { promise, resolve } = Promise.withResolvers<ISendEventResponse>();
|
||||
client.sendRtcDecline.mockImplementation(() => {
|
||||
return promise;
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Decline" }));
|
||||
|
||||
expect(toastStore.dismissToast).not.toHaveBeenCalledWith(
|
||||
getIncomingCallToastKey(notificationEvent.getId()!, 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),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("getNotificationEventSendTs returns the correct ts", () => {
|
||||
const eventOriginServerTs = mkEvent({
|
||||
user: "@userId:matrix.org",
|
||||
type: EventType.RTCNotification,
|
||||
content: {
|
||||
"m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" },
|
||||
"sender_ts": 222_000,
|
||||
},
|
||||
event: true,
|
||||
ts: 1111,
|
||||
});
|
||||
|
||||
const eventSendTs = mkEvent({
|
||||
user: "@userId:matrix.org",
|
||||
type: EventType.RTCNotification,
|
||||
content: {
|
||||
"m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" },
|
||||
"sender_ts": 2222,
|
||||
},
|
||||
event: true,
|
||||
ts: 1111,
|
||||
});
|
||||
|
||||
expect(getNotificationEventSendTs(eventOriginServerTs)).toBe(1111);
|
||||
expect(getNotificationEventSendTs(eventSendTs)).toBe(2222);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user