mirror of
https://github.com/element-hq/element-web
synced 2024-11-29 04:48:50 +03:00
Add notifications and toasts for Element Call calls (#9337)
This commit is contained in:
parent
20f5adc9a9
commit
6356a8c056
10 changed files with 591 additions and 22 deletions
|
@ -337,6 +337,7 @@
|
||||||
@import "./views/spaces/_SpacePublicShare.pcss";
|
@import "./views/spaces/_SpacePublicShare.pcss";
|
||||||
@import "./views/terms/_InlineTermsAgreement.pcss";
|
@import "./views/terms/_InlineTermsAgreement.pcss";
|
||||||
@import "./views/toasts/_AnalyticsToast.pcss";
|
@import "./views/toasts/_AnalyticsToast.pcss";
|
||||||
|
@import "./views/toasts/_IncomingCallToast.pcss";
|
||||||
@import "./views/toasts/_IncomingLegacyCallToast.pcss";
|
@import "./views/toasts/_IncomingLegacyCallToast.pcss";
|
||||||
@import "./views/toasts/_NonUrgentEchoFailureToast.pcss";
|
@import "./views/toasts/_NonUrgentEchoFailureToast.pcss";
|
||||||
@import "./views/typography/_Heading.pcss";
|
@import "./views/typography/_Heading.pcss";
|
||||||
|
|
105
res/css/views/toasts/_IncomingCallToast.pcss
Normal file
105
res/css/views/toasts/_IncomingCallToast.pcss
Normal file
|
@ -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 LegacyCallHandler from "./LegacyCallHandler";
|
||||||
import VoipUserMapper from "./VoipUserMapper";
|
import VoipUserMapper from "./VoipUserMapper";
|
||||||
import { localNotificationsAreSilenced } from "./utils/notifications";
|
import { localNotificationsAreSilenced } from "./utils/notifications";
|
||||||
|
import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast";
|
||||||
|
import ToastStore from "./stores/ToastStore";
|
||||||
|
import { ElementCall } from "./models/Call";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Dispatches:
|
* Dispatches:
|
||||||
|
@ -358,7 +361,7 @@ export const Notifier = {
|
||||||
|
|
||||||
onEvent: function(ev: MatrixEvent) {
|
onEvent: function(ev: MatrixEvent) {
|
||||||
if (!this.isSyncing) return; // don't alert for any messages initially
|
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);
|
MatrixClientPeg.get().decryptEventIfNeeded(ev);
|
||||||
|
|
||||||
|
@ -419,6 +422,8 @@ export const Notifier = {
|
||||||
|
|
||||||
const actions = MatrixClientPeg.get().getPushActionsForEvent(ev);
|
const actions = MatrixClientPeg.get().getPushActionsForEvent(ev);
|
||||||
if (actions?.notify) {
|
if (actions?.notify) {
|
||||||
|
this._performCustomEventHandling(ev);
|
||||||
|
|
||||||
if (RoomViewStore.instance.getRoomId() === room.roomId &&
|
if (RoomViewStore.instance.getRoomId() === room.roomId &&
|
||||||
UserActivity.sharedInstance().userActiveRecently() &&
|
UserActivity.sharedInstance().userActiveRecently() &&
|
||||||
!Modal.hasDialogs()
|
!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) {
|
if (!window.mxNotifier) {
|
||||||
|
|
|
@ -45,6 +45,7 @@ import AccessibleButton from './components/views/elements/AccessibleButton';
|
||||||
import RightPanelStore from './stores/right-panel/RightPanelStore';
|
import RightPanelStore from './stores/right-panel/RightPanelStore';
|
||||||
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
|
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
|
||||||
import { isLocationEvent } from './utils/EventUtils';
|
import { isLocationEvent } from './utils/EventUtils';
|
||||||
|
import { ElementCall } from "./models/Call";
|
||||||
|
|
||||||
export function getSenderName(event: MatrixEvent): string {
|
export function getSenderName(event: MatrixEvent): string {
|
||||||
return event.sender?.name ?? event.getSender() ?? _t("Someone");
|
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");
|
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
|
// 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
|
// 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.
|
// 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;
|
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.
|
* Determines whether the given event has text to display.
|
||||||
* @param ev The event
|
* @param ev The event
|
||||||
|
|
|
@ -18,6 +18,8 @@ import React, { FC } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
|
import { Call } from "../../../models/Call";
|
||||||
|
import { useParticipants } from "../../../hooks/useCall";
|
||||||
|
|
||||||
export enum LiveContentType {
|
export enum LiveContentType {
|
||||||
Video,
|
Video,
|
||||||
|
@ -55,3 +57,18 @@ export const LiveContentSummary: FC<Props> = ({ type, text, active, participantC
|
||||||
</> }
|
</> }
|
||||||
</span>
|
</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",
|
"Converts the DM to a room": "Converts the DM to a room",
|
||||||
"Displays action": "Displays action",
|
"Displays action": "Displays action",
|
||||||
"Someone": "Someone",
|
"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.": "%(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 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.",
|
"%(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",
|
"Don't miss a reply": "Don't miss a reply",
|
||||||
"Notifications": "Notifications",
|
"Notifications": "Notifications",
|
||||||
"Enable desktop notifications": "Enable desktop 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",
|
"Unknown caller": "Unknown caller",
|
||||||
"Voice call": "Voice call",
|
"Voice call": "Voice call",
|
||||||
"Video call": "Video call",
|
"Video call": "Video call",
|
||||||
|
@ -1051,7 +1058,6 @@
|
||||||
"Video devices": "Video devices",
|
"Video devices": "Video devices",
|
||||||
"Turn off camera": "Turn off camera",
|
"Turn off camera": "Turn off camera",
|
||||||
"Turn on camera": "Turn on camera",
|
"Turn on camera": "Turn on camera",
|
||||||
"Join": "Join",
|
|
||||||
"%(count)s people joined|other": "%(count)s people joined",
|
"%(count)s people joined|other": "%(count)s people joined",
|
||||||
"%(count)s people joined|one": "%(count)s person joined",
|
"%(count)s people joined|one": "%(count)s person joined",
|
||||||
"Dial": "Dial",
|
"Dial": "Dial",
|
||||||
|
@ -1519,7 +1525,6 @@
|
||||||
"Ban list rules - %(roomName)s": "Ban list rules - %(roomName)s",
|
"Ban list rules - %(roomName)s": "Ban list rules - %(roomName)s",
|
||||||
"Server rules": "Server rules",
|
"Server rules": "Server rules",
|
||||||
"User rules": "User rules",
|
"User rules": "User rules",
|
||||||
"Close": "Close",
|
|
||||||
"You have not ignored anyone.": "You have not ignored anyone.",
|
"You have not ignored anyone.": "You have not ignored anyone.",
|
||||||
"You are currently ignoring:": "You are currently ignoring:",
|
"You are currently ignoring:": "You are currently ignoring:",
|
||||||
"You are not subscribed to any lists": "You are not subscribed to any lists",
|
"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.|other": "%(count)s unread messages.",
|
||||||
"%(count)s unread messages.|one": "1 unread message.",
|
"%(count)s unread messages.|one": "1 unread message.",
|
||||||
"Unread messages.": "Unread messages.",
|
"Unread messages.": "Unread messages.",
|
||||||
"Video": "Video",
|
|
||||||
"Joining…": "Joining…",
|
"Joining…": "Joining…",
|
||||||
"Joined": "Joined",
|
"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.",
|
"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.",
|
||||||
|
|
119
src/toasts/IncomingCallToast.tsx
Normal file
119
src/toasts/IncomingCallToast.tsx
Normal file
|
@ -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,17 +14,39 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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 { 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 Notifier from "../src/Notifier";
|
||||||
|
import SettingsStore from "../src/settings/SettingsStore";
|
||||||
|
import ToastStore from "../src/stores/ToastStore";
|
||||||
import { getLocalNotificationAccountDataEventType } from "../src/utils/notifications";
|
import { getLocalNotificationAccountDataEventType } from "../src/utils/notifications";
|
||||||
import { getMockClientWithEventEmitter, mkEvent, mkRoom, mockPlatformPeg } from "./test-utils";
|
import { getMockClientWithEventEmitter, mkEvent, mkRoom, mockPlatformPeg } from "./test-utils";
|
||||||
|
import { IncomingCallToast } from "../src/toasts/IncomingCallToast";
|
||||||
|
|
||||||
describe("Notifier", () => {
|
describe("Notifier", () => {
|
||||||
let MockPlatform;
|
const roomId = "!room1:server";
|
||||||
|
const testEvent = mkEvent({
|
||||||
|
event: true,
|
||||||
|
type: "m.room.message",
|
||||||
|
user: "@user1:server",
|
||||||
|
room: roomId,
|
||||||
|
content: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
let MockPlatform: MockedObject<BasePlatform>;
|
||||||
|
let mockClient: MockedObject<MatrixClient>;
|
||||||
|
let testRoom: MockedObject<Room>;
|
||||||
|
let accountDataEventKey: string;
|
||||||
let accountDataStore = {};
|
let accountDataStore = {};
|
||||||
|
|
||||||
const mockClient = getMockClientWithEventEmitter({
|
beforeEach(() => {
|
||||||
|
accountDataStore = {};
|
||||||
|
mockClient = getMockClientWithEventEmitter({
|
||||||
getUserId: jest.fn().mockReturnValue("@bob:example.org"),
|
getUserId: jest.fn().mockReturnValue("@bob:example.org"),
|
||||||
isGuest: jest.fn().mockReturnValue(false),
|
isGuest: jest.fn().mockReturnValue(false),
|
||||||
getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]),
|
getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]),
|
||||||
|
@ -34,20 +56,14 @@ describe("Notifier", () => {
|
||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
decryptEventIfNeeded: jest.fn(),
|
||||||
|
getRoom: jest.fn(),
|
||||||
|
getPushActionsForEvent: jest.fn(),
|
||||||
});
|
});
|
||||||
const accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId);
|
accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId);
|
||||||
const roomId = "!room1:server";
|
|
||||||
const testEvent = mkEvent({
|
testRoom = mkRoom(mockClient, roomId);
|
||||||
event: true,
|
|
||||||
type: "m.room.message",
|
|
||||||
user: "@user1:server",
|
|
||||||
room: roomId,
|
|
||||||
content: {},
|
|
||||||
});
|
|
||||||
const testRoom = mkRoom(mockClient, roomId);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
accountDataStore = {};
|
|
||||||
MockPlatform = mockPlatformPeg({
|
MockPlatform = mockPlatformPeg({
|
||||||
supportsNotifications: jest.fn().mockReturnValue(true),
|
supportsNotifications: jest.fn().mockReturnValue(true),
|
||||||
maySendNotifications: jest.fn().mockReturnValue(true),
|
maySendNotifications: jest.fn().mockReturnValue(true),
|
||||||
|
@ -55,6 +71,8 @@ describe("Notifier", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
Notifier.isBodyEnabled = jest.fn().mockReturnValue(true);
|
Notifier.isBodyEnabled = jest.fn().mockReturnValue(true);
|
||||||
|
|
||||||
|
mockClient.getRoom.mockReturnValue(testRoom);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("_displayPopupNotification", () => {
|
describe("_displayPopupNotification", () => {
|
||||||
|
@ -82,4 +100,73 @@ describe("Notifier", () => {
|
||||||
expect(Notifier.getSoundForRoom).toHaveBeenCalledTimes(count);
|
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.
|
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 TestRenderer from 'react-test-renderer';
|
||||||
import { ReactElement } from "react";
|
import { ReactElement } from "react";
|
||||||
|
import { mocked } from "jest-mock";
|
||||||
|
|
||||||
import { getSenderName, textForEvent } from "../src/TextForEvent";
|
import { getSenderName, textForEvent } from "../src/TextForEvent";
|
||||||
import SettingsStore from "../src/settings/SettingsStore";
|
import SettingsStore from "../src/settings/SettingsStore";
|
||||||
import { createTestClient } from './test-utils';
|
import { createTestClient, stubClient } from './test-utils';
|
||||||
import { MatrixClientPeg } from '../src/MatrixClientPeg';
|
import { MatrixClientPeg } from '../src/MatrixClientPeg';
|
||||||
import UserIdentifierCustomisations from '../src/customisations/UserIdentifier';
|
import UserIdentifierCustomisations from '../src/customisations/UserIdentifier';
|
||||||
|
import { ElementCall } from "../src/models/Call";
|
||||||
|
|
||||||
jest.mock("../src/settings/SettingsStore");
|
jest.mock("../src/settings/SettingsStore");
|
||||||
jest.mock('../src/customisations/UserIdentifier', () => ({
|
jest.mock('../src/customisations/UserIdentifier', () => ({
|
||||||
|
@ -444,4 +446,42 @@ describe('TextForEvent', () => {
|
||||||
expect(textForEvent(messageEvent)).toEqual('@a: test message');
|
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)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
158
test/toasts/IncomingCallToast-test.tsx
Normal file
158
test/toasts/IncomingCallToast-test.tsx
Normal file
|
@ -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 a new issue