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:
Timo
2025-09-16 12:41:44 +02:00
committed by GitHub
parent 3c13f55b74
commit 0783f27f33
14 changed files with 638 additions and 193 deletions

View File

@@ -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();
});

View File

@@ -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);
});
});