mirror of https://github.com/vector-im/riot-web
Add notifications and toasts for Element Call calls (#9337)
parent
20f5adc9a9
commit
6356a8c056
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<Props> = ({ type, text, active, participantC
|
|||
</> }
|
||||
</span>
|
||||
);
|
||||
|
||||
interface LiveContentSummaryWithCallProps {
|
||||
call: Call;
|
||||
}
|
||||
|
||||
export function LiveContentSummaryWithCall({ call }: LiveContentSummaryWithCallProps) {
|
||||
const participants = useParticipants(call);
|
||||
|
||||
return <LiveContentSummary
|
||||
type={LiveContentType.Video}
|
||||
text={_t("Video")}
|
||||
active={false}
|
||||
participantCount={participants.size}
|
||||
/>;
|
||||
}
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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<ViewRoomPayload>({
|
||||
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 <React.Fragment>
|
||||
<RoomAvatar
|
||||
room={room ?? undefined}
|
||||
height={24}
|
||||
width={24}
|
||||
/>
|
||||
<div className="mx_IncomingCallToast_content">
|
||||
<div className="mx_IncomingCallToast_info">
|
||||
<span className="mx_IncomingCallToast_room">
|
||||
{ room ? room.name : _t("Unknown room") }
|
||||
</span>
|
||||
<div className="mx_IncomingCallToast_message">
|
||||
{ _t("Video call started") }
|
||||
</div>
|
||||
{ call
|
||||
? <LiveContentSummaryWithCall call={call} />
|
||||
: <LiveContentSummary
|
||||
type={LiveContentType.Video}
|
||||
text={_t("Video")}
|
||||
active={false}
|
||||
participantCount={0}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<AccessibleButton
|
||||
className="mx_IncomingCallToast_joinButton"
|
||||
onClick={onJoinClick}
|
||||
kind="primary"
|
||||
>
|
||||
{ _t("Join") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
<AccessibleTooltipButton
|
||||
className="mx_IncomingCallToast_closeButton"
|
||||
onClick={onCloseClick}
|
||||
title={_t("Close")}
|
||||
/>
|
||||
</React.Fragment>;
|
||||
}
|
|
@ -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<BasePlatform>;
|
||||
let mockClient: MockedObject<MatrixClient>;
|
||||
let testRoom: MockedObject<Room>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<MatrixClient>;
|
||||
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(<IncomingCallToast callEvent={call.event} />); };
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue