Update toast styles, improve incoming call notifications (#33043)

* Update design of incoming call notifications

* Make toast show avatars of group call participants

* Further expand test coverage for call notifications

* Update screenshots

* Update screenshots

* Delete unused variables

* Upgrade Element Call to v0.19.2

For the new group call intents.

* Consolidate some branches

* Apply Compound spacing variables a little more

* Fix lints

* Exclude Element Call assets from being re-minified to fix build
This commit is contained in:
Robin
2026-04-27 14:37:42 +00:00
committed by GitHub
parent 2ea0c4106b
commit 03b730db58
30 changed files with 350 additions and 270 deletions
@@ -20,6 +20,7 @@ import {
RoomEvent,
type IRoomTimelineData,
type ISendEventResponse,
type IContent,
} from "matrix-js-sdk/src/matrix";
import { Widget } from "matrix-widget-api";
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
@@ -50,12 +51,32 @@ import LegacyCallHandler, { AudioID } from "../../../src/LegacyCallHandler";
import { CallEvent } from "../../../src/models/Call";
import { type WidgetMessaging } from "../../../src/stores/widgets/WidgetMessaging";
function makeNotificationEvent(room: Room, content: IContent = {}): MatrixEvent {
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,
...content,
} as unknown as IRTCNotificationContent;
return mkEvent({
type: EventType.RTCNotification,
user: "@userId:matrix.org",
content: notificationContent,
room: room.roomId,
ts,
id: "$notificationEventId",
event: true,
});
}
describe("IncomingCallToast", () => {
useMockedCalls();
let client: Mocked<MatrixClient>;
let room: Room;
let notificationEvent: MatrixEvent;
let alice: RoomMember;
let bob: RoomMember;
@@ -77,23 +98,6 @@ describe("IncomingCallToast", () => {
document.body.appendChild(audio);
room = new Room("!1:example.org", client, "@alice:example.org");
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");
@@ -130,7 +134,7 @@ describe("IncomingCallToast", () => {
jest.restoreAllMocks();
});
const renderToast = (): string => {
const renderToast = (notificationEvent: MatrixEvent = makeNotificationEvent(room)): string => {
const callId = randomUUID();
call.event.getContent = () =>
({
@@ -146,27 +150,52 @@ describe("IncomingCallToast", () => {
return callId;
};
it("correctly shows all the information", () => {
it.each(["video", "voice"])("shows information for a group %s call", (callType: string) => {
call.participants = new Map([
[alice, new Set("a")],
[bob, new Set(["b1", "b2"])],
]);
renderToast();
const notificationEvent = makeNotificationEvent(room, {
"m.call.intent": callType === "voice" ? "audio" : "video",
});
renderToast(notificationEvent);
screen.getByText("Video call started");
screen.getByText("Video");
screen.getByLabelText("3 people joined");
screen.getByText("Group call started");
screen.getByLabelText(callType === "voice" ? "Voice call" : "Video call");
screen.getByLabelText("@alice:example.org");
screen.getByLabelText("@bob:example.org");
screen.getByText("on the call");
screen.getByRole("button", { name: "Join" });
screen.getByRole("button", { name: "Close" });
screen.getByRole("button", { name: "Ignore" });
screen.getByRole("button", { name: "Expand" });
});
it.each(["video", "voice"])("shows information for a DM %s call", (callType: string) => {
mocked(dmRoomMap.getUserIdForRoomId).mockImplementation((roomId) =>
roomId === room.roomId ? alice.userId : undefined,
);
try {
call.participants = new Map([[alice, new Set("a")]]);
const notificationEvent = makeNotificationEvent(room, {
"m.call.intent": callType === "voice" ? "audio" : "video",
});
renderToast(notificationEvent);
screen.getByText(callType === "voice" ? "Incoming voice call" : "Incoming video call");
screen.getByLabelText(callType === "voice" ? "Voice call" : "Video call");
screen.getByLabelText("@alice:example.org");
screen.getByRole("button", { name: "Join" });
screen.getByRole("button", { name: "Ignore" });
screen.getByRole("button", { name: "Expand" });
} finally {
mocked(dmRoomMap.getUserIdForRoomId).mockReset();
}
});
it("start ringing on ring notify event", () => {
const oldContent = notificationEvent.getContent() as IRTCNotificationContent;
(notificationEvent as unknown as { getContent: () => IRTCNotificationContent }).getContent = () => {
return { ...oldContent, notification_type: "ring" } as IRTCNotificationContent;
};
const notificationEvent = makeNotificationEvent(room, { notification_type: "ring" });
const playMock = jest.spyOn(LegacyCallHandler.instance, "play");
render(<IncomingCallToast notificationEvent={notificationEvent} toastKey="" />);
expect(playMock).toHaveBeenCalled();
@@ -176,21 +205,23 @@ describe("IncomingCallToast", () => {
call.destroy();
renderToast();
screen.getByText("Video call started");
screen.getByText("Video");
screen.getByText("Group call started");
screen.getByLabelText("Video call");
expect(screen.queryByText("on the call")).toBe(null);
screen.getByRole("button", { name: "Join" });
screen.getByRole("button", { name: "Decline" });
screen.getByRole("button", { name: "Close" });
screen.getByRole("button", { name: "Ignore" });
screen.getByRole("button", { name: "Expand" });
});
it("opens the call directly and closes the toast when pressing on the join button", async () => {
it("joins with video and closes the toast", async () => {
const callId = 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)
screen.getByRole("switch", { name: "Join with video", checked: true });
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
@@ -208,23 +239,23 @@ describe("IncomingCallToast", () => {
defaultDispatcher.unregister(dispatcherRef);
});
it("opens the call lobby and closes the toast when configured like that", async () => {
it("joins without video and closes the toast", async () => {
const callId = renderToast();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("switch", {}));
screen.getByRole("switch", { name: "Join with video", checked: false });
// 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: false,
skipLobby: true,
view_call: true,
voiceOnly: false,
voiceOnly: true,
}),
);
await waitFor(() =>
@@ -276,13 +307,22 @@ describe("IncomingCallToast", () => {
defaultDispatcher.unregister(dispatcherRef);
});
it("closes the toast", async () => {
it("expands to show the call lobby", async () => {
const callId = renderToast();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
fireEvent.click(screen.getByRole("button", { name: "Expand" }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
skipLobby: false,
view_call: true,
voiceOnly: false,
}),
);
await waitFor(() =>
expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId)),
);
@@ -316,7 +356,8 @@ describe("IncomingCallToast", () => {
});
it("closes toast when the notification event is redacted", async () => {
const callId = renderToast();
const notificationEvent = makeNotificationEvent(room);
const callId = renderToast(notificationEvent);
room.emit(MatrixEventEvent.BeforeRedaction, notificationEvent, {} as unknown as MatrixEvent);
@@ -335,7 +376,8 @@ describe("IncomingCallToast", () => {
});
it("closes toast when a decline event was received", async () => {
const callId = renderToast();
const notificationEvent = makeNotificationEvent(room);
const callId = renderToast(notificationEvent);
room.emit(
RoomEvent.Timeline,
@@ -357,7 +399,8 @@ describe("IncomingCallToast", () => {
});
it("does not close toast when a decline event for another user was received", async () => {
const callId = renderToast();
const notificationEvent = makeNotificationEvent(room);
const callId = renderToast(notificationEvent);
room.emit(
RoomEvent.Timeline,
@@ -401,7 +444,7 @@ describe("IncomingCallToast", () => {
);
});
it("sends a decline event when clicking the decline button and only dismiss after sending", async () => {
it("sends a decline event when clicking the ignore button and only dismiss after sending", async () => {
const callId = renderToast();
const { promise, resolve } = Promise.withResolvers<ISendEventResponse>();
@@ -409,7 +452,7 @@ describe("IncomingCallToast", () => {
return promise;
});
fireEvent.click(screen.getByRole("button", { name: "Decline" }));
fireEvent.click(screen.getByRole("button", { name: "Ignore" }));
expect(toastStore.dismissToast).not.toHaveBeenCalledWith(getIncomingCallToastKey(callId, room.roomId));
expect(client.sendRtcDecline).toHaveBeenCalledWith("!1:example.org", "$notificationEventId");
@@ -422,6 +465,7 @@ describe("IncomingCallToast", () => {
});
it("getNotificationEventSendTs returns the correct ts", () => {
const notificationEvent = makeNotificationEvent(room);
const eventOriginServerTs = mkEvent({
user: "@userId:matrix.org",
type: EventType.RTCNotification,