Line 1 / 2 Support

Support one active call plus one call on hold
pull/21833/head
David Baker 2020-12-03 17:45:49 +00:00
parent 31044b68d1
commit 1ce63f0fa7
8 changed files with 468 additions and 234 deletions

View File

@ -38,6 +38,17 @@ limitations under the License.
.mx_CallView_voice {
height: 180px;
}
.mx_CallView_callControls {
bottom: 0px;
}
.mx_CallView_callControls_button {
&::before {
width: 36px;
height: 36px;
}
}
}
.mx_CallView_voice {
@ -81,6 +92,7 @@ limitations under the License.
.mx_CallView_voice_holdText {
height: 20px;
padding-top: 10px;
padding-bottom: 15px;
color: $accent-fg-color;
font-weight: bold;
.mx_AccessibleButton_hasKind {
@ -162,8 +174,34 @@ limitations under the License.
vertical-align: middle;
}
.mx_CallView_header_controls {
.mx_CallView_header_secondaryCallInfo {
margin-left: auto;
display: flex;
flex-direction: row;
align-items: center;
justify-content: left;
.mx_AccessibleButton_hasKind {
padding: 0px;
}
}
.mx_CallView_header_secondaryCallInfo_avatarContainer {
width: 32px;
height: 32px;
margin-right: 12px;
border-radius: 2000px;
overflow: hidden;
position: relative;
.mx_BaseAvatar {
filter: blur(3px);
overflow: hidden;
}
}
.mx_CallView_header_controls {
margin-left: 12px;
}
.mx_CallView_header_button {

View File

@ -81,6 +81,7 @@ import Analytics from './Analytics';
import CountlyAnalytics from "./CountlyAnalytics";
import {UIFeature} from "./settings/UIFeature";
import { CallError } from "matrix-js-sdk/src/webrtc/call";
import { logger } from 'matrix-js-sdk/src/logger';
enum AudioID {
Ring = 'ringAudio',
@ -115,7 +116,7 @@ function getRemoteAudioElement(): HTMLAudioElement {
}
export default class CallHandler {
private calls = new Map<string, MatrixCall>();
private calls = new Map<string, MatrixCall>(); // roomId -> call
private audioPromises = new Map<AudioID, Promise<void>>();
static sharedInstance() {
@ -175,6 +176,28 @@ export default class CallHandler {
return null;
}
getAllActiveCalls() {
const activeCalls = [];
for (const call of this.calls.values()) {
if (call.state !== CallState.Ended && call.state !== CallState.Ringing) {
activeCalls.push(call);
}
}
return activeCalls;
}
getAllActiveCallsNotInRoom(notInThisRoomId) {
const callsNotInThatRoom = [];
for (const [roomId, call] of this.calls.entries()) {
if (roomId !== notInThisRoomId && call.state !== CallState.Ended) {
callsNotInThatRoom.push(call);
}
}
return callsNotInThatRoom;
}
play(audioId: AudioID) {
// TODO: Attach an invisible element for this instead
// which listens?
@ -425,6 +448,8 @@ export default class CallHandler {
this.setCallListeners(call);
this.setCallAudioElement(call);
this.setActiveCallRoomId(roomId);
if (type === PlaceCallType.Voice) {
call.placeVoiceCall();
} else if (type === 'video') {
@ -453,14 +478,6 @@ export default class CallHandler {
switch (payload.action) {
case 'place_call':
{
if (this.getAnyActiveCall()) {
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Existing Call'),
description: _t('You are already in a call.'),
});
return; // don't allow >1 call to be placed.
}
// if the runtime env doesn't do VoIP, whine.
if (!MatrixClientPeg.get().supportsVoip()) {
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
@ -470,6 +487,15 @@ export default class CallHandler {
return;
}
// don't allow > 2 calls to be placed.
if (this.getAllActiveCalls().length > 1) {
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Too Many Calls'),
description: _t("You've reached the maximum number of simultaneous calls."),
});
return;
}
const room = MatrixClientPeg.get().getRoom(payload.room_id);
if (!room) {
console.error("Room %s does not exist.", payload.room_id);
@ -513,24 +539,21 @@ export default class CallHandler {
break;
case 'incoming_call':
{
if (this.getAnyActiveCall()) {
// ignore multiple incoming calls. in future, we may want a line-1/line-2 setup.
// we avoid rejecting with "busy" in case the user wants to answer it on a different device.
// in future we could signal a "local busy" as a warning to the caller.
// see https://github.com/vector-im/vector-web/issues/1964
return;
}
// if the runtime env doesn't do VoIP, stop here.
if (!MatrixClientPeg.get().supportsVoip()) {
return;
}
const call = payload.call as MatrixCall;
if (this.getCallForRoom(call.roomId)) {
// ignore multiple incoming calls to the same room
return;
}
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
this.calls.set(call.roomId, call)
this.setCallListeners(call);
this.setCallAudioElement(call);
}
break;
case 'hangup':
@ -549,8 +572,19 @@ export default class CallHandler {
if (!this.calls.has(payload.room_id)) {
return; // no call to answer
}
if (this.getAllActiveCalls().length > 1) {
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Too Many Calls'),
description: _t("You've reached the maximum number of simultaneous calls."),
});
return;
}
const call = this.calls.get(payload.room_id);
call.answer();
this.setCallAudioElement(call);
this.setActiveCallRoomId(payload.room_id);
CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false);
dis.dispatch({
action: "view_room",
@ -561,6 +595,21 @@ export default class CallHandler {
}
}
setActiveCallRoomId(activeCallRoomId: string) {
logger.info("Setting call in room " + activeCallRoomId + " active");
for (const [roomId, call] of this.calls.entries()) {
if (call.state === CallState.Ended) continue;
if (roomId === activeCallRoomId) {
call.setRemoteOnHold(false);
} else {
logger.info("Holding call in room " + roomId + " because another call is being set active");
call.setRemoteOnHold(true);
}
}
}
private async startCallApp(roomId: string, type: string) {
dis.dispatch({
action: 'appsDrawer',

View File

@ -19,6 +19,7 @@ import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import { ContextMenu, IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import CallHandler from '../../../CallHandler';
interface IProps extends IContextMenuProps {
call: MatrixCall;
@ -34,16 +35,23 @@ export default class CallContextMenu extends React.Component<IProps> {
super(props);
}
onHoldUnholdClick = () => {
this.props.call.setRemoteOnHold(!this.props.call.isRemoteOnHold());
onHoldClick = () => {
this.props.call.setRemoteOnHold(true);
this.props.onFinished();
}
onUnholdClick = () => {
CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId);
this.props.onFinished();
}
render() {
const holdUnholdCaption = this.props.call.isRemoteOnHold() ? _t("Resume") : _t("Hold");
const handler = this.props.call.isRemoteOnHold() ? this.onUnholdClick : this.onHoldClick;
return <ContextMenu {...this.props}>
<MenuItem className="mx_CallContextMenu_item" onClick={this.onHoldUnholdClick}>
<MenuItem className="mx_CallContextMenu_item" onClick={handler}>
{holdUnholdCaption}
</MenuItem>
</ContextMenu>;

View File

@ -26,9 +26,9 @@ import classNames from 'classnames';
import RateLimitedFunc from '../../../ratelimitedfunc';
import SettingsStore from "../../../settings/SettingsStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import CallView from "../voip/CallView";
import {UIFeature} from "../../../settings/UIFeature";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
import CallViewForRoom from '../voip/CallViewForRoom';
interface IProps {
// js-sdk room object
@ -166,8 +166,8 @@ export default class AuxPanel extends React.Component<IProps, IState> {
}
const callView = (
<CallView
room={this.props.room}
<CallViewForRoom
roomId={this.props.room.roomId}
onResize={this.props.onResize}
maxVideoHeight={this.props.maxHeight}
/>

View File

@ -24,7 +24,8 @@ import dis from '../../../dispatcher/dispatcher';
import { ActionPayload } from '../../../dispatcher/payloads';
import PersistentApp from "../elements/PersistentApp";
import SettingsStore from "../../../settings/SettingsStore";
import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
const SHOW_CALL_IN_STATES = [
CallState.Connected,
@ -40,9 +41,50 @@ interface IProps {
interface IState {
roomId: string;
activeCall: MatrixCall;
// The main call that we are displaying (ie. not including the call in the room being viewed, if any)
primaryCall: MatrixCall;
// Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms
// they belong to
secondaryCall: MatrixCall;
}
// Splits a list of calls into one 'primary' one and a list
// (which should be a single element) of other calls.
// The primary will be the one not on hold, or an arbitrary one
// if they're all on hold)
function getPrimarySecondaryCalls(calls: MatrixCall[]): [MatrixCall, MatrixCall[]] {
let primary: MatrixCall = null;
let secondaries: MatrixCall[] = [];
for (const call of calls) {
if (!SHOW_CALL_IN_STATES.includes(call.state)) continue;
if (!call.isRemoteOnHold() && primary === null) {
primary = call;
} else {
secondaries.push(call);
}
}
if (primary === null && secondaries.length > 0) {
primary = secondaries[0];
secondaries = secondaries.slice(1);
}
if (secondaries.length > 1) {
// We should never be in more than two calls so this shouldn't happen
console.log("Found more than 1 secondary call! Other calls will not be shown.");
}
return [primary, secondaries];
}
/**
* CallPreview shows a small version of CallView hovering over the UI in 'picture-in-picture'
* (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing.
*/
export default class CallPreview extends React.Component<IProps, IState> {
private roomStoreToken: any;
private dispatcherRef: string;
@ -51,18 +93,27 @@ export default class CallPreview extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
const roomId = RoomViewStore.getRoomId();
const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls(
CallHandler.sharedInstance().getAllActiveCallsNotInRoom(roomId),
);
this.state = {
roomId: RoomViewStore.getRoomId(),
activeCall: CallHandler.sharedInstance().getAnyActiveCall(),
roomId,
primaryCall: primaryCall,
secondaryCall: secondaryCalls[0],
};
}
public componentDidMount() {
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
}
public componentWillUnmount() {
MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
if (this.roomStoreToken) {
this.roomStoreToken.remove();
}
@ -72,8 +123,16 @@ export default class CallPreview extends React.Component<IProps, IState> {
private onRoomViewStoreUpdate = (payload) => {
if (RoomViewStore.getRoomId() === this.state.roomId) return;
const roomId = RoomViewStore.getRoomId();
const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls(
CallHandler.sharedInstance().getAllActiveCallsNotInRoom(roomId),
);
this.setState({
roomId: RoomViewStore.getRoomId(),
roomId,
primaryCall: primaryCall,
secondaryCall: secondaryCalls[0],
});
};
@ -81,38 +140,35 @@ export default class CallPreview extends React.Component<IProps, IState> {
switch (payload.action) {
// listen for call state changes to prod the render method, which
// may hide the global CallView if the call it is tracking is dead
case 'call_state':
case 'call_state': {
const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls(
CallHandler.sharedInstance().getAllActiveCallsNotInRoom(this.state.roomId),
);
this.setState({
activeCall: CallHandler.sharedInstance().getAnyActiveCall(),
primaryCall: primaryCall,
secondaryCall: secondaryCalls[0],
});
break;
}
}
};
private onCallViewClick = () => {
const call = CallHandler.sharedInstance().getAnyActiveCall();
if (call) {
dis.dispatch({
action: 'view_room',
room_id: call.roomId,
});
}
};
public render() {
const callForRoom = CallHandler.sharedInstance().getCallForRoom(this.state.roomId);
const showCall = (
this.state.activeCall &&
SHOW_CALL_IN_STATES.includes(this.state.activeCall.state) &&
!callForRoom
private onCallRemoteHold = () => {
const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls(
CallHandler.sharedInstance().getAllActiveCallsNotInRoom(this.state.roomId),
);
if (showCall) {
this.setState({
primaryCall: primaryCall,
secondaryCall: secondaryCalls[0],
});
}
public render() {
if (this.state.primaryCall) {
return (
<CallView
onClick={this.onCallViewClick}
showHangup={true}
/>
<CallView call={this.state.primaryCall} secondaryCall={this.state.secondaryCall} pipMode={true} />
);
}

View File

@ -16,7 +16,6 @@ limitations under the License.
*/
import React, { createRef, CSSProperties } from 'react';
import Room from 'matrix-js-sdk/src/models/room';
import dis from '../../../dispatcher/dispatcher';
import CallHandler from '../../../CallHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
@ -33,26 +32,27 @@ import CallContextMenu from '../context_menus/CallContextMenu';
import { avatarUrlForMember } from '../../../Avatar';
interface IProps {
// js-sdk room object. If set, we will only show calls for the given
// room; if not, we will show any active call.
room?: Room;
// The call for us to display
call: MatrixCall,
// Another ongoing call to display information about
secondaryCall?: MatrixCall,
// maxHeight style attribute for the video panel
maxVideoHeight?: number;
// a callback which is called when the user clicks on the video div
onClick?: React.MouseEventHandler;
// a callback which is called when the content in the callview changes
// in a way that is likely to cause a resize.
onResize?: any;
// Whether to show the hang up icon:W
showHangup?: boolean;
// Whether this call view is for picture-in-pictue mode
// otherwise, it's the larger call view when viewing the room the call is in.
// This is sort of a proxy for a number of things but we currently have no
// need to control those things separately, so this is simpler.
pipMode?: boolean;
}
interface IState {
call: MatrixCall;
isLocalOnHold: boolean,
isRemoteOnHold: boolean,
micMuted: boolean,
@ -105,19 +105,17 @@ export default class CallView extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
const call = this.getCall();
this.state = {
call,
isLocalOnHold: call ? call.isLocalOnHold() : null,
isRemoteOnHold: call ? call.isRemoteOnHold() : null,
micMuted: call ? call.isMicrophoneMuted() : null,
vidMuted: call ? call.isLocalVideoMuted() : null,
callState: call ? call.state : null,
isLocalOnHold: this.props.call.isLocalOnHold(),
isRemoteOnHold: this.props.call.isRemoteOnHold(),
micMuted: this.props.call.isMicrophoneMuted(),
vidMuted: this.props.call.isLocalVideoMuted(),
callState: this.props.call.state,
controlsVisible: true,
showMoreMenu: false,
}
this.updateCallListeners(null, call);
this.updateCallListeners(null, this.props.call);
}
public componentDidMount() {
@ -126,11 +124,29 @@ export default class CallView extends React.Component<IProps, IState> {
}
public componentWillUnmount() {
if (getFullScreenElement()) {
exitFullscreen();
}
document.removeEventListener("keydown", this.onNativeKeyDown);
this.updateCallListeners(this.state.call, null);
this.updateCallListeners(this.props.call, null);
dis.unregister(this.dispatcherRef);
}
public componentDidUpdate(prevProps) {
if (this.props.call === prevProps.call) return;
this.setState({
isLocalOnHold: this.props.call.isLocalOnHold(),
isRemoteOnHold: this.props.call.isRemoteOnHold(),
micMuted: this.props.call.isMicrophoneMuted(),
vidMuted: this.props.call.isLocalVideoMuted(),
callState: this.props.call.state,
});
this.updateCallListeners(null, this.props.call);
}
private onAction = (payload) => {
switch (payload.action) {
case 'video_fullscreen': {
@ -144,85 +160,41 @@ export default class CallView extends React.Component<IProps, IState> {
}
break;
}
case 'call_state': {
const newCall = this.getCall();
if (newCall !== this.state.call) {
this.updateCallListeners(this.state.call, newCall);
let newControlsVisible = this.state.controlsVisible;
if (newCall && !this.state.call) {
newControlsVisible = true;
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
this.setState({
call: newCall,
isLocalOnHold: newCall ? newCall.isLocalOnHold() : null,
isRemoteOnHold: newCall ? newCall.isRemoteOnHold() : null,
micMuted: newCall ? newCall.isMicrophoneMuted() : null,
vidMuted: newCall ? newCall.isLocalVideoMuted() : null,
callState: newCall ? newCall.state : null,
controlsVisible: newControlsVisible,
});
} else {
this.setState({
callState: newCall ? newCall.state : null,
});
}
if (!newCall && getFullScreenElement()) {
exitFullscreen();
}
break;
}
}
};
private getCall(): MatrixCall {
let call: MatrixCall;
if (this.props.room) {
const roomId = this.props.room.roomId;
call = CallHandler.sharedInstance().getCallForRoom(roomId);
} else {
call = CallHandler.sharedInstance().getAnyActiveCall();
// Ignore calls if we can't get the room associated with them.
// I think the underlying problem is that the js-sdk sends events
// for calls before it has made the rooms available in the store,
// although this isn't confirmed.
if (MatrixClientPeg.get().getRoom(call.roomId) === null) {
call = null;
}
}
if (call && [CallState.Ended, CallState.Ringing].includes(call.state)) return null;
return call;
}
private updateCallListeners(oldCall: MatrixCall, newCall: MatrixCall) {
if (oldCall === newCall) return;
if (oldCall) {
oldCall.removeListener(CallEvent.State, this.onCallState);
oldCall.removeListener(CallEvent.LocalHoldUnhold, this.onCallLocalHoldUnhold);
oldCall.removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHoldUnhold);
}
if (newCall) {
newCall.on(CallEvent.State, this.onCallState);
newCall.on(CallEvent.LocalHoldUnhold, this.onCallLocalHoldUnhold);
newCall.on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHoldUnhold);
}
}
private onCallState = (state) => {
this.setState({
callState: state,
});
};
private onCallLocalHoldUnhold = () => {
this.setState({
isLocalOnHold: this.state.call ? this.state.call.isLocalOnHold() : null,
isLocalOnHold: this.props.call.isLocalOnHold(),
});
};
private onCallRemoteHoldUnhold = () => {
this.setState({
isRemoteOnHold: this.state.call ? this.state.call.isRemoteOnHold() : null,
isRemoteOnHold: this.props.call.isRemoteOnHold(),
// update both here because isLocalOnHold changes when we hold the call too
isLocalOnHold: this.state.call ? this.state.call.isLocalOnHold() : null,
isLocalOnHold: this.props.call.isLocalOnHold(),
});
};
@ -236,7 +208,7 @@ export default class CallView extends React.Component<IProps, IState> {
private onExpandClick = () => {
dis.dispatch({
action: 'view_room',
room_id: this.state.call.roomId,
room_id: this.props.call.roomId,
});
};
@ -266,20 +238,16 @@ export default class CallView extends React.Component<IProps, IState> {
}
private onMicMuteClick = () => {
if (!this.state.call) return;
const newVal = !this.state.micMuted;
this.state.call.setMicrophoneMuted(newVal);
this.props.call.setMicrophoneMuted(newVal);
this.setState({micMuted: newVal});
}
private onVidMuteClick = () => {
if (!this.state.call) return;
const newVal = !this.state.vidMuted;
this.state.call.setLocalVideoMuted(newVal);
this.props.call.setLocalVideoMuted(newVal);
this.setState({vidMuted: newVal});
}
@ -338,107 +306,113 @@ export default class CallView extends React.Component<IProps, IState> {
private onRoomAvatarClick = () => {
dis.dispatch({
action: 'view_room',
room_id: this.state.call.roomId,
room_id: this.props.call.roomId,
});
}
private onSecondaryRoomAvatarClick = () => {
dis.dispatch({
action: 'view_room',
room_id: this.props.secondaryCall.roomId,
});
}
private onCallResumeClick = () => {
this.state.call.setRemoteOnHold(false);
CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId);
}
private onSecondaryCallResumeClick = () => {
CallHandler.sharedInstance().setActiveCallRoomId(this.props.secondaryCall.roomId);
}
public render() {
if (!this.state.call) return null;
const client = MatrixClientPeg.get();
const callRoom = client.getRoom(this.state.call.roomId);
const callRoom = client.getRoom(this.props.call.roomId);
let contextMenu;
let callControls;
if (this.props.room) {
if (this.state.showMoreMenu) {
contextMenu = <CallContextMenu
{...aboveLeftOf(
this.contextMenuButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
onFinished={this.closeContextMenu}
call={this.state.call}
/>;
}
const micClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: !this.state.micMuted,
mx_CallView_callControls_button_micOff: this.state.micMuted,
});
const vidClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_vidOn: !this.state.vidMuted,
mx_CallView_callControls_button_vidOff: this.state.vidMuted,
});
// Put the other states of the mic/video icons in the document to make sure they're cached
// (otherwise the icon disappears briefly when toggled)
const micCacheClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: this.state.micMuted,
mx_CallView_callControls_button_micOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const vidCacheClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_vidOn: this.state.micMuted,
mx_CallView_callControls_button_vidOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const callControlsClasses = classNames({
mx_CallView_callControls: true,
mx_CallView_callControls_hidden: !this.state.controlsVisible,
});
const vidMuteButton = this.state.call.type === CallType.Video ? <AccessibleButton
className={vidClasses}
onClick={this.onVidMuteClick}
/> : null;
// The 'more' button actions are only relevant in a connected call
// When not connected, we have to put something there to make the flexbox alignment correct
const contextMenuButton = this.state.callState === CallState.Connected ? <ContextMenuButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
onClick={this.onMoreClick}
inputRef={this.contextMenuButton}
isExpanded={this.state.showMoreMenu}
/> : <div className="mx_CallView_callControls_button mx_CallView_callControls_button_more_hidden" />;
// in the near future, the dial pad button will go on the left. For now, it's the nothing button
// because something needs to have margin-right: auto to make the alignment correct.
callControls = <div className={callControlsClasses}>
<div className="mx_CallView_callControls_button mx_CallView_callControls_nothing" />
<AccessibleButton
className={micClasses}
onClick={this.onMicMuteClick}
/>
<AccessibleButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
onClick={() => {
dis.dispatch({
action: 'hangup',
room_id: this.state.call.roomId,
});
}}
/>
{vidMuteButton}
<div className={micCacheClasses} />
<div className={vidCacheClasses} />
{contextMenuButton}
</div>;
if (this.state.showMoreMenu) {
contextMenu = <CallContextMenu
{...aboveLeftOf(
this.contextMenuButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
onFinished={this.closeContextMenu}
call={this.props.call}
/>;
}
const micClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: !this.state.micMuted,
mx_CallView_callControls_button_micOff: this.state.micMuted,
});
const vidClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_vidOn: !this.state.vidMuted,
mx_CallView_callControls_button_vidOff: this.state.vidMuted,
});
// Put the other states of the mic/video icons in the document to make sure they're cached
// (otherwise the icon disappears briefly when toggled)
const micCacheClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: this.state.micMuted,
mx_CallView_callControls_button_micOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const vidCacheClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_vidOn: this.state.micMuted,
mx_CallView_callControls_button_vidOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const callControlsClasses = classNames({
mx_CallView_callControls: true,
mx_CallView_callControls_hidden: !this.state.controlsVisible,
});
const vidMuteButton = this.props.call.type === CallType.Video ? <AccessibleButton
className={vidClasses}
onClick={this.onVidMuteClick}
/> : null;
// The 'more' button actions are only relevant in a connected call
// When not connected, we have to put something there to make the flexbox alignment correct
const contextMenuButton = this.state.callState === CallState.Connected ? <ContextMenuButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
onClick={this.onMoreClick}
inputRef={this.contextMenuButton}
isExpanded={this.state.showMoreMenu}
/> : <div className="mx_CallView_callControls_button mx_CallView_callControls_button_more_hidden" />;
// in the near future, the dial pad button will go on the left. For now, it's the nothing button
// because something needs to have margin-right: auto to make the alignment correct.
const callControls = <div className={callControlsClasses}>
<div className="mx_CallView_callControls_button mx_CallView_callControls_nothing" />
<AccessibleButton
className={micClasses}
onClick={this.onMicMuteClick}
/>
<AccessibleButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
onClick={() => {
dis.dispatch({
action: 'hangup',
room_id: this.props.call.roomId,
});
}}
/>
{vidMuteButton}
<div className={micCacheClasses} />
<div className={vidCacheClasses} />
{contextMenuButton}
</div>;
// The 'content' for the call, ie. the videos for a video call and profile picture
// for voice calls (fills the bg)
let contentView: React.ReactNode;
@ -453,11 +427,11 @@ export default class CallView extends React.Component<IProps, IState> {
});
} else if (this.state.isLocalOnHold) {
onHoldText = _t("%(peerName)s held the call", {
peerName: this.state.call.getOpponentMember().name,
peerName: this.props.call.getOpponentMember().name,
});
}
if (this.state.call.type === CallType.Video) {
if (this.props.call.type === CallType.Video) {
let onHoldContent = null;
let onHoldBackground = null;
const backgroundStyle: CSSProperties = {};
@ -471,7 +445,7 @@ export default class CallView extends React.Component<IProps, IState> {
</div>;
const backgroundAvatarUrl = avatarUrlForMember(
// is it worth getting the size of the div to pass here?
this.state.call.getOpponentMember(), 1024, 1024, 'crop',
this.props.call.getOpponentMember(), 1024, 1024, 'crop',
);
backgroundStyle.backgroundImage = 'url(' + backgroundAvatarUrl + ')';
onHoldBackground = <div className="mx_CallView_video_holdBackground" style={backgroundStyle} />;
@ -481,15 +455,15 @@ export default class CallView extends React.Component<IProps, IState> {
const maxVideoHeight = getFullScreenElement() ? null : this.props.maxVideoHeight - HEADER_HEIGHT;
contentView = <div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
{onHoldBackground}
<VideoFeed type={VideoFeedType.Remote} call={this.state.call} onResize={this.props.onResize}
<VideoFeed type={VideoFeedType.Remote} call={this.props.call} onResize={this.props.onResize}
maxHeight={maxVideoHeight}
/>
<VideoFeed type={VideoFeedType.Local} call={this.state.call} />
<VideoFeed type={VideoFeedType.Local} call={this.props.call} />
{onHoldContent}
{callControls}
</div>;
} else {
const avatarSize = this.props.room ? 200 : 75;
const avatarSize = this.props.pipMode ? 75 : 200;
const classes = classNames({
mx_CallView_voice: true,
mx_CallView_voice_hold: isOnHold,
@ -507,18 +481,18 @@ export default class CallView extends React.Component<IProps, IState> {
</div>;
}
const callTypeText = this.state.call.type === CallType.Video ? _t("Video Call") : _t("Voice Call");
const callTypeText = this.props.call.type === CallType.Video ? _t("Video Call") : _t("Voice Call");
let myClassName;
let fullScreenButton;
if (this.state.call.type === CallType.Video && this.props.room) {
if (this.props.call.type === CallType.Video && !this.props.pipMode) {
fullScreenButton = <div className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
onClick={this.onFullscreenClick} title={_t("Fill Screen")}
/>;
}
let expandButton;
if (!this.props.room) {
if (this.props.pipMode) {
expandButton = <div className="mx_CallView_header_button mx_CallView_header_button_expand"
onClick={this.onExpandClick} title={_t("Return to call")}
/>;
@ -530,7 +504,7 @@ export default class CallView extends React.Component<IProps, IState> {
</div>;
let header: React.ReactNode;
if (this.props.room) {
if (!this.props.pipMode) {
header = <div className="mx_CallView_header">
<div className="mx_CallView_header_phoneIcon"></div>
<span className="mx_CallView_header_callType">{callTypeText}</span>
@ -538,6 +512,27 @@ export default class CallView extends React.Component<IProps, IState> {
</div>;
myClassName = 'mx_CallView_large';
} else {
let secondaryCallInfo;
if (this.props.secondaryCall) {
const secCallRoom = client.getRoom(this.props.secondaryCall.roomId);
secondaryCallInfo = <div className="mx_CallView_header_secondaryCallInfo">
<div className="mx_CallView_header_secondaryCallInfo_avatarContainer">
<AccessibleButton onClick={this.onSecondaryRoomAvatarClick}>
<RoomAvatar room={secCallRoom} height={32} width={32} />
</AccessibleButton>
</div>
<div>
<div className="mx_CallView_header_roomName">{secCallRoom.name}</div>
<AccessibleButton kind="link" onClick={this.onSecondaryCallResumeClick}>
{_t("Resume")}
</AccessibleButton>
</div>
</div>;
} else {
// keeps it present but empty because it has the margin-left: auto to make the alignment correct
secondaryCallInfo = <div className="mx_CallView_header_secondaryCallInfo" />;
}
header = <div className="mx_CallView_header">
<AccessibleButton onClick={this.onRoomAvatarClick}>
<RoomAvatar room={callRoom} height={32} width={32} />
@ -546,6 +541,7 @@ export default class CallView extends React.Component<IProps, IState> {
<div className="mx_CallView_header_roomName">{callRoom.name}</div>
<div className="mx_CallView_header_callTypeSmall">{callTypeText}</div>
</div>
{secondaryCallInfo}
{headerControls}
</div>;
myClassName = 'mx_CallView_pip';

View File

@ -0,0 +1,87 @@
/*
Copyright 2020 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 { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import React from 'react';
import CallHandler from '../../../CallHandler';
import CallView from './CallView';
import dis from '../../../dispatcher/dispatcher';
interface IProps {
// What room we should display the call for
roomId: string,
// maxHeight style attribute for the video panel
maxVideoHeight?: number;
// a callback which is called when the content in the callview changes
// in a way that is likely to cause a resize.
onResize?: any;
}
interface IState {
call: MatrixCall,
}
/*
* Wrapper for CallView that always display the call in a given room,
* or nothing if there is no call in that room.
*/
export default class CallViewForRoom extends React.Component<IProps, IState> {
private dispatcherRef: string;
constructor(props: IProps) {
super(props);
this.state = {
call: this.getCall(),
};
}
public componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
}
public componentWillUnmount() {
dis.unregister(this.dispatcherRef);
}
private onAction = (payload) => {
switch (payload.action) {
case 'call_state': {
const newCall = this.getCall();
if (newCall !== this.state.call) {
this.setState({call: newCall});
}
break;
}
}
};
private getCall(): MatrixCall {
const call = CallHandler.sharedInstance().getCallForRoom(this.props.roomId);
if (call && [CallState.Ended, CallState.Ringing].includes(call.state)) return null;
return call;
}
public render() {
if (!this.state.call) return null;
return <CallView call={this.state.call} pipMode={false}
onResize={this.props.onResize} maxVideoHeight={this.props.maxVideoHeight}
/>;
}
}

View File

@ -54,10 +54,10 @@
"Permission is granted to use the webcam": "Permission is granted to use the webcam",
"No other application is using the webcam": "No other application is using the webcam",
"Unable to capture screen": "Unable to capture screen",
"Existing Call": "Existing Call",
"You are already in a call.": "You are already in a call.",
"VoIP is unsupported": "VoIP is unsupported",
"You cannot place VoIP calls in this browser.": "You cannot place VoIP calls in this browser.",
"Too Many Calls": "Too Many Calls",
"You've reached the maximum number of simultaneous calls.": "You've reached the maximum number of simultaneous calls.",
"You cannot place a call with yourself.": "You cannot place a call with yourself.",
"Call in Progress": "Call in Progress",
"A call is currently being placed!": "A call is currently being placed!",