diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 17fb679f24..6f9eb010ec 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -337,6 +337,7 @@ @import "./views/spaces/_SpacePublicShare.pcss"; @import "./views/terms/_InlineTermsAgreement.pcss"; @import "./views/toasts/_AnalyticsToast.pcss"; +@import "./views/toasts/_IncomingCallToast.pcss"; @import "./views/toasts/_IncomingLegacyCallToast.pcss"; @import "./views/toasts/_NonUrgentEchoFailureToast.pcss"; @import "./views/typography/_Heading.pcss"; diff --git a/res/css/views/toasts/_IncomingCallToast.pcss b/res/css/views/toasts/_IncomingCallToast.pcss new file mode 100644 index 0000000000..e66e1c31d4 --- /dev/null +++ b/res/css/views/toasts/_IncomingCallToast.pcss @@ -0,0 +1,105 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_IncomingCallToast { + position: relative; + display: flex; + flex-direction: row; + pointer-events: initial; /* restore pointer events so the user can accept/decline */ + width: 250px; + + .mx_IncomingCallToast_content { + display: flex; + flex-direction: column; + margin-left: 8px; + width: 100%; + + .mx_IncomingCallToast_info { + margin-bottom: $spacing-16; + + .mx_IncomingCallToast_room { + display: inline-block; + + font-weight: bold; + font-size: $font-15px; + line-height: $font-24px; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + margin-bottom: $spacing-4; + } + + .mx_IncomingCallToast_message { + font-size: $font-12px; + line-height: $font-15px; + + margin-bottom: $spacing-4; + } + + .mx_LiveContentSummary { + font-size: $font-12px; + line-height: $font-15px; + + .mx_LiveContentSummary_participants::before { + width: 15px; + height: 15px; + } + } + } + + .mx_IncomingCallToast_joinButton { + position: relative; + + bottom: $spacing-4; + right: $spacing-4; + + align-self: flex-end; + + box-sizing: border-box; + min-width: 120px; + + padding: $spacing-4 0; + + line-height: $font-24px; + } + } + + .mx_IncomingCallToast_closeButton { + position: absolute; + + top: $spacing-4; + right: $spacing-4; + + display: flex; + height: 16px; + width: 16px; + + &::before { + content: ''; + + mask-image: url('$(res)/img/cancel.svg'); + + height: inherit; + width: inherit; + background-color: $secondary-content; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } + } +} diff --git a/src/Notifier.ts b/src/Notifier.ts index 8c7a8e4bed..875402d982 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -47,6 +47,9 @@ import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import LegacyCallHandler from "./LegacyCallHandler"; import VoipUserMapper from "./VoipUserMapper"; import { localNotificationsAreSilenced } from "./utils/notifications"; +import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast"; +import ToastStore from "./stores/ToastStore"; +import { ElementCall } from "./models/Call"; /* * Dispatches: @@ -358,7 +361,7 @@ export const Notifier = { onEvent: function(ev: MatrixEvent) { if (!this.isSyncing) return; // don't alert for any messages initially - if (ev.getSender() === MatrixClientPeg.get().credentials.userId) return; + if (ev.getSender() === MatrixClientPeg.get().getUserId()) return; MatrixClientPeg.get().decryptEventIfNeeded(ev); @@ -419,6 +422,8 @@ export const Notifier = { const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); if (actions?.notify) { + this._performCustomEventHandling(ev); + if (RoomViewStore.instance.getRoomId() === room.roomId && UserActivity.sharedInstance().userActiveRecently() && !Modal.hasDialogs() @@ -436,6 +441,24 @@ export const Notifier = { } } }, + + /** + * Some events require special handling such as showing in-app toasts + */ + _performCustomEventHandling: function(ev: MatrixEvent) { + if ( + ElementCall.CALL_EVENT_TYPE.names.includes(ev.getType()) + && SettingsStore.getValue("feature_group_calls") + ) { + ToastStore.sharedInstance().addOrReplaceToast({ + key: getIncomingCallToastKey(ev.getStateKey()), + priority: 100, + component: IncomingCallToast, + bodyClassName: "mx_IncomingCallToast", + props: { callEvent: ev }, + }); + } + }, }; if (!window.mxNotifier) { diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 6be8dee332..361edcb1e2 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -45,6 +45,7 @@ import AccessibleButton from './components/views/elements/AccessibleButton'; import RightPanelStore from './stores/right-panel/RightPanelStore'; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; import { isLocationEvent } from './utils/EventUtils'; +import { ElementCall } from "./models/Call"; export function getSenderName(event: MatrixEvent): string { return event.sender?.name ?? event.getSender() ?? _t("Someone"); @@ -57,6 +58,15 @@ function getRoomMemberDisplayname(event: MatrixEvent, userId = event.getSender() return member?.name || member?.rawDisplayName || userId || _t("Someone"); } +function textForCallEvent(event: MatrixEvent): () => string { + const roomName = MatrixClientPeg.get().getRoom(event.getRoomId()!).name; + const isSupported = MatrixClientPeg.get().supportsVoip(); + + return isSupported + ? () => _t("Video call started in %(roomName)s.", { roomName }) + : () => _t("Video call started in %(roomName)s. (not supported by this browser)", { roomName }); +} + // These functions are frequently used just to check whether an event has // any text to display at all. For this reason they return deferred values // to avoid the expense of looking up translations when they're not needed. @@ -798,6 +808,11 @@ for (const evType of ALL_RULE_TYPES) { stateHandlers[evType] = textForMjolnirEvent; } +// Add both stable and unstable m.call events +for (const evType of ElementCall.CALL_EVENT_TYPE.names) { + stateHandlers[evType] = textForCallEvent; +} + /** * Determines whether the given event has text to display. * @param ev The event diff --git a/src/components/views/rooms/LiveContentSummary.tsx b/src/components/views/rooms/LiveContentSummary.tsx index 95adf54f13..34ee825268 100644 --- a/src/components/views/rooms/LiveContentSummary.tsx +++ b/src/components/views/rooms/LiveContentSummary.tsx @@ -18,6 +18,8 @@ import React, { FC } from "react"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; +import { Call } from "../../../models/Call"; +import { useParticipants } from "../../../hooks/useCall"; export enum LiveContentType { Video, @@ -55,3 +57,18 @@ export const LiveContentSummary: FC = ({ type, text, active, participantC } ); + +interface LiveContentSummaryWithCallProps { + call: Call; +} + +export function LiveContentSummaryWithCall({ call }: LiveContentSummaryWithCallProps) { + const participants = useParticipants(call); + + return ; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2f8de1d2f0..d95362a2fb 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -470,6 +470,8 @@ "Converts the DM to a room": "Converts the DM to a room", "Displays action": "Displays action", "Someone": "Someone", + "Video call started in %(roomName)s.": "Video call started in %(roomName)s.", + "Video call started in %(roomName)s. (not supported by this browser)": "Video call started in %(roomName)s. (not supported by this browser)", "%(senderName)s placed a voice call.": "%(senderName)s placed a voice call.", "%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s placed a voice call. (not supported by this browser)", "%(senderName)s placed a video call.": "%(senderName)s placed a video call.", @@ -795,6 +797,11 @@ "Don't miss a reply": "Don't miss a reply", "Notifications": "Notifications", "Enable desktop notifications": "Enable desktop notifications", + "Unknown room": "Unknown room", + "Video call started": "Video call started", + "Video": "Video", + "Join": "Join", + "Close": "Close", "Unknown caller": "Unknown caller", "Voice call": "Voice call", "Video call": "Video call", @@ -1051,7 +1058,6 @@ "Video devices": "Video devices", "Turn off camera": "Turn off camera", "Turn on camera": "Turn on camera", - "Join": "Join", "%(count)s people joined|other": "%(count)s people joined", "%(count)s people joined|one": "%(count)s person joined", "Dial": "Dial", @@ -1519,7 +1525,6 @@ "Ban list rules - %(roomName)s": "Ban list rules - %(roomName)s", "Server rules": "Server rules", "User rules": "User rules", - "Close": "Close", "You have not ignored anyone.": "You have not ignored anyone.", "You are currently ignoring:": "You are currently ignoring:", "You are not subscribed to any lists": "You are not subscribed to any lists", @@ -2005,7 +2010,6 @@ "%(count)s unread messages.|other": "%(count)s unread messages.", "%(count)s unread messages.|one": "1 unread message.", "Unread messages.": "Unread messages.", - "Video": "Video", "Joining…": "Joining…", "Joined": "Joined", "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.", diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx new file mode 100644 index 0000000000..faff195226 --- /dev/null +++ b/src/toasts/IncomingCallToast.tsx @@ -0,0 +1,119 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useCallback, useEffect } from 'react'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +import { _t } from '../languageHandler'; +import RoomAvatar from '../components/views/avatars/RoomAvatar'; +import AccessibleButton from '../components/views/elements/AccessibleButton'; +import { MatrixClientPeg } from "../MatrixClientPeg"; +import defaultDispatcher from "../dispatcher/dispatcher"; +import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; +import { Action } from "../dispatcher/actions"; +import ToastStore from "../stores/ToastStore"; +import AccessibleTooltipButton from "../components/views/elements/AccessibleTooltipButton"; +import { + LiveContentSummary, + LiveContentSummaryWithCall, + LiveContentType, +} from "../components/views/rooms/LiveContentSummary"; +import { useCall } from "../hooks/useCall"; +import { useRoomState } from "../hooks/useRoomState"; +import { ButtonEvent } from "../components/views/elements/AccessibleButton"; + +export const getIncomingCallToastKey = (stateKey: string) => `call_${stateKey}`; + +interface Props { + callEvent: MatrixEvent; +} + +export function IncomingCallToast({ callEvent }: Props) { + const roomId = callEvent.getRoomId()!; + const room = MatrixClientPeg.get().getRoom(roomId); + const call = useCall(roomId); + + const dismissToast = useCallback((): void => { + ToastStore.sharedInstance().dismissToast(getIncomingCallToastKey(callEvent.getStateKey()!)); + }, [callEvent]); + + const latestEvent = useRoomState(room, useCallback((state) => { + return state.getStateEvents(callEvent.getType(), callEvent.getStateKey()!); + }, [callEvent])); + + useEffect(() => { + if ("m.terminated" in latestEvent.getContent()) { + dismissToast(); + } + }, [latestEvent, dismissToast]); + + const onJoinClick = useCallback((e: ButtonEvent): void => { + e.stopPropagation(); + + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + metricsTrigger: undefined, + }); + dismissToast(); + }, [room, dismissToast]); + + const onCloseClick = useCallback((e: ButtonEvent): void => { + e.stopPropagation(); + + dismissToast(); + }, [dismissToast]); + + return + +
+
+ + { room ? room.name : _t("Unknown room") } + +
+ { _t("Video call started") } +
+ { call + ? + : + } +
+ + { _t("Join") } + +
+ +
; +} diff --git a/test/Notifier-test.ts b/test/Notifier-test.ts index 1178d35bec..4bac0a5423 100644 --- a/test/Notifier-test.ts +++ b/test/Notifier-test.ts @@ -14,28 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MockedObject } from "jest-mock"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import BasePlatform from "../src/BasePlatform"; +import { ElementCall } from "../src/models/Call"; import Notifier from "../src/Notifier"; +import SettingsStore from "../src/settings/SettingsStore"; +import ToastStore from "../src/stores/ToastStore"; import { getLocalNotificationAccountDataEventType } from "../src/utils/notifications"; import { getMockClientWithEventEmitter, mkEvent, mkRoom, mockPlatformPeg } from "./test-utils"; +import { IncomingCallToast } from "../src/toasts/IncomingCallToast"; describe("Notifier", () => { - let MockPlatform; - let accountDataStore = {}; - - const mockClient = getMockClientWithEventEmitter({ - getUserId: jest.fn().mockReturnValue("@bob:example.org"), - isGuest: jest.fn().mockReturnValue(false), - getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]), - setAccountData: jest.fn().mockImplementation((eventType, content) => { - accountDataStore[eventType] = new MatrixEvent({ - type: eventType, - content, - }); - }), - }); - const accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId); const roomId = "!room1:server"; const testEvent = mkEvent({ event: true, @@ -44,10 +37,33 @@ describe("Notifier", () => { room: roomId, content: {}, }); - const testRoom = mkRoom(mockClient, roomId); + + let MockPlatform: MockedObject; + let mockClient: MockedObject; + let testRoom: MockedObject; + let accountDataEventKey: string; + let accountDataStore = {}; beforeEach(() => { accountDataStore = {}; + mockClient = getMockClientWithEventEmitter({ + getUserId: jest.fn().mockReturnValue("@bob:example.org"), + isGuest: jest.fn().mockReturnValue(false), + getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]), + setAccountData: jest.fn().mockImplementation((eventType, content) => { + accountDataStore[eventType] = new MatrixEvent({ + type: eventType, + content, + }); + }), + decryptEventIfNeeded: jest.fn(), + getRoom: jest.fn(), + getPushActionsForEvent: jest.fn(), + }); + accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId); + + testRoom = mkRoom(mockClient, roomId); + MockPlatform = mockPlatformPeg({ supportsNotifications: jest.fn().mockReturnValue(true), maySendNotifications: jest.fn().mockReturnValue(true), @@ -55,6 +71,8 @@ describe("Notifier", () => { }); Notifier.isBodyEnabled = jest.fn().mockReturnValue(true); + + mockClient.getRoom.mockReturnValue(testRoom); }); describe("_displayPopupNotification", () => { @@ -82,4 +100,73 @@ describe("Notifier", () => { expect(Notifier.getSoundForRoom).toHaveBeenCalledTimes(count); }); }); + + describe("group call notifications", () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + jest.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast"); + + mockClient.getPushActionsForEvent.mockReturnValue({ + notify: true, + tweaks: {}, + }); + + Notifier.onSyncStateChange("SYNCING"); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const callOnEvent = (type?: string) => { + const callEvent = { + getContent: () => { }, + getRoomId: () => roomId, + isBeingDecrypted: () => false, + isDecryptionFailure: () => false, + getSender: () => "@alice:foo", + getType: () => type ?? ElementCall.CALL_EVENT_TYPE.name, + getStateKey: () => "state_key", + } as unknown as MatrixEvent; + + Notifier.onEvent(callEvent); + return callEvent; + }; + + const setGroupCallsEnabled = (val: boolean) => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { + if (name === "feature_group_calls") return val; + }); + }; + + it("should show toast when group calls are supported", () => { + setGroupCallsEnabled(true); + + const callEvent = callOnEvent(); + + expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith(expect.objectContaining({ + key: `call_${callEvent.getStateKey()}`, + priority: 100, + component: IncomingCallToast, + bodyClassName: "mx_IncomingCallToast", + props: { callEvent }, + })); + }); + + it("should not show toast when group calls are not supported", () => { + setGroupCallsEnabled(false); + + callOnEvent(); + + expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled(); + }); + + it("should not show toast when calling with non-group call event", () => { + setGroupCallsEnabled(true); + + callOnEvent("event_type"); + + expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled(); + }); + }); }); diff --git a/test/TextForEvent-test.ts b/test/TextForEvent-test.ts index c99fb56571..27f3090d3e 100644 --- a/test/TextForEvent-test.ts +++ b/test/TextForEvent-test.ts @@ -14,15 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventType, MatrixEvent, RoomMember } from "matrix-js-sdk/src/matrix"; +import { EventType, MatrixClient, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix"; import TestRenderer from 'react-test-renderer'; import { ReactElement } from "react"; +import { mocked } from "jest-mock"; import { getSenderName, textForEvent } from "../src/TextForEvent"; import SettingsStore from "../src/settings/SettingsStore"; -import { createTestClient } from './test-utils'; +import { createTestClient, stubClient } from './test-utils'; import { MatrixClientPeg } from '../src/MatrixClientPeg'; import UserIdentifierCustomisations from '../src/customisations/UserIdentifier'; +import { ElementCall } from "../src/models/Call"; jest.mock("../src/settings/SettingsStore"); jest.mock('../src/customisations/UserIdentifier', () => ({ @@ -444,4 +446,42 @@ describe('TextForEvent', () => { expect(textForEvent(messageEvent)).toEqual('@a: test message'); }); }); + + describe("textForCallEvent()", () => { + let mockClient: MatrixClient; + let callEvent: MatrixEvent; + + beforeEach(() => { + stubClient(); + mockClient = MatrixClientPeg.get(); + + mocked(mockClient.getRoom).mockReturnValue({ + name: "Test room", + } as unknown as Room); + + callEvent = { + getRoomId: jest.fn(), + getType: jest.fn(), + isState: jest.fn().mockReturnValue(true), + } as unknown as MatrixEvent; + }); + + describe.each(ElementCall.CALL_EVENT_TYPE.names)("eventType=%s", (eventType: string) => { + beforeEach(() => { + mocked(callEvent).getType.mockReturnValue(eventType); + }); + + it("returns correct message for call event when supported", () => { + expect(textForEvent(callEvent)).toEqual('Video call started in Test room.'); + }); + + it("returns correct message for call event when supported", () => { + mocked(mockClient).supportsVoip.mockReturnValue(false); + + expect(textForEvent(callEvent)).toEqual( + 'Video call started in Test room. (not supported by this browser)', + ); + }); + }); + }); }); diff --git a/test/toasts/IncomingCallToast-test.tsx b/test/toasts/IncomingCallToast-test.tsx new file mode 100644 index 0000000000..58d15f43e4 --- /dev/null +++ b/test/toasts/IncomingCallToast-test.tsx @@ -0,0 +1,158 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { mocked, Mocked } from "jest-mock"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; +import { ClientWidgetApi, Widget } from "matrix-widget-api"; + +import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { + useMockedCalls, + MockedCall, + stubClient, + mkRoomMember, + setupAsyncStoreWithClient, + resetAsyncStoreWithClient, +} from "../test-utils"; +import defaultDispatcher from "../../src/dispatcher/dispatcher"; +import { Action } from "../../src/dispatcher/actions"; +import { MatrixClientPeg } from "../../src/MatrixClientPeg"; +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"; + +describe("IncomingCallEvent", () => { + useMockedCalls(); + Object.defineProperty(navigator, "mediaDevices", { value: { enumerateDevices: () => [] } }); + jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => { }); + + let client: Mocked; + let room: Room; + let alice: RoomMember; + let bob: RoomMember; + let call: MockedCall; + let widget: Widget; + const dmRoomMap = { + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap; + const toastStore = { + dismissToast: jest.fn(), + } as unknown as ToastStore; + + beforeEach(async () => { + stubClient(); + client = mocked(MatrixClientPeg.get()); + + room = new Room("!1:example.org", client, "@alice:example.org"); + + alice = mkRoomMember(room.roomId, "@alice:example.org"); + bob = mkRoomMember(room.roomId, "@bob:example.org"); + + client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); + client.getRooms.mockReturnValue([room]); + client.reEmitter.reEmit(room, [RoomStateEvent.Events]); + + await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map( + store => setupAsyncStoreWithClient(store, client), + )); + + MockedCall.create(room, "1"); + const maybeCall = CallStore.instance.get(room.roomId); + if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call"); + call = maybeCall; + + widget = new Widget(call.widget); + WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { + stop: () => { }, + } as unknown as ClientWidgetApi); + + jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); + jest.spyOn(ToastStore, "sharedInstance").mockReturnValue(toastStore); + }); + + afterEach(async () => { + cleanup(); // Unmount before we do any cleanup that might update the component + call.destroy(); + WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); + await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map(resetAsyncStoreWithClient)); + jest.restoreAllMocks(); + }); + + const renderToast = () => { render(); }; + + it("correctly shows all the information", () => { + call.participants = new Set([alice, bob]); + renderToast(); + + screen.getByText("Video call started"); + screen.getByText("Video"); + screen.getByLabelText("2 participants"); + + screen.getByRole("button", { name: "Join" }); + screen.getByRole("button", { name: "Close" }); + }); + + it("correctly renders toast without a call", () => { + call.destroy(); + renderToast(); + + screen.getByText("Video call started"); + screen.getByText("Video"); + + screen.getByRole("button", { name: "Join" }); + screen.getByRole("button", { name: "Close" }); + }); + + it("joins the call and closes the toast", async () => { + renderToast(); + + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + + fireEvent.click(screen.getByRole("button", { name: "Join" })); + await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + })); + await waitFor(() => expect(toastStore.dismissToast).toHaveBeenCalledWith( + getIncomingCallToastKey(call.event.getStateKey()!), + )); + + defaultDispatcher.unregister(dispatcherRef); + }); + + it("closes the toast", async () => { + renderToast(); + + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + await waitFor(() => expect(toastStore.dismissToast).toHaveBeenCalledWith( + getIncomingCallToastKey(call.event.getStateKey()!), + )); + + defaultDispatcher.unregister(dispatcherRef); + }); +});