From 1ce63f0fa7d94badd39b9333c419254dc290e22e Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 3 Dec 2020 17:45:49 +0000 Subject: [PATCH] Line 1 / 2 Support Support one active call plus one call on hold --- res/css/views/voip/_CallView.scss | 40 +- src/CallHandler.tsx | 85 ++++- .../views/context_menus/CallContextMenu.tsx | 14 +- src/components/views/rooms/AuxPanel.tsx | 6 +- src/components/views/voip/CallPreview.tsx | 112 ++++-- src/components/views/voip/CallView.tsx | 354 +++++++++--------- src/components/views/voip/CallViewForRoom.tsx | 87 +++++ src/i18n/strings/en_EN.json | 4 +- 8 files changed, 468 insertions(+), 234 deletions(-) create mode 100644 src/components/views/voip/CallViewForRoom.tsx diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 57806470a1..6ea8192aba 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -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 { diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index b5f696008d..925c638add 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -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(); + private calls = new Map(); // roomId -> call private audioPromises = new Map>(); 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', diff --git a/src/components/views/context_menus/CallContextMenu.tsx b/src/components/views/context_menus/CallContextMenu.tsx index 31e82c19b1..336b72cebf 100644 --- a/src/components/views/context_menus/CallContextMenu.tsx +++ b/src/components/views/context_menus/CallContextMenu.tsx @@ -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 { 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 - + {holdUnholdCaption} ; diff --git a/src/components/views/rooms/AuxPanel.tsx b/src/components/views/rooms/AuxPanel.tsx index 465c9c749a..7966643084 100644 --- a/src/components/views/rooms/AuxPanel.tsx +++ b/src/components/views/rooms/AuxPanel.tsx @@ -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 { } const callView = ( - diff --git a/src/components/views/voip/CallPreview.tsx b/src/components/views/voip/CallPreview.tsx index 8e1b0dd963..c08e52181b 100644 --- a/src/components/views/voip/CallPreview.tsx +++ b/src/components/views/voip/CallPreview.tsx @@ -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 { private roomStoreToken: any; private dispatcherRef: string; @@ -51,18 +93,27 @@ export default class CallPreview extends React.Component { 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 { 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 { 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 ( - + ); } diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index c9f5db77e6..e235b81f3c 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -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 { 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 { } 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 { } 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 { 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 { } 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 { 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 = ; - } - - 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 ? : 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 ? :
; - - // 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 =
-
- - { - dis.dispatch({ - action: 'hangup', - room_id: this.state.call.roomId, - }); - }} - /> - {vidMuteButton} -
-
- {contextMenuButton} -
; + if (this.state.showMoreMenu) { + contextMenu = ; } + 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 ? : 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 ? :
; + + // 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 =
+
+ + { + dis.dispatch({ + action: 'hangup', + room_id: this.props.call.roomId, + }); + }} + /> + {vidMuteButton} +
+
+ {contextMenuButton} +
; + // 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 { }); } 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 {
; 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 =
; @@ -481,15 +455,15 @@ export default class CallView extends React.Component { const maxVideoHeight = getFullScreenElement() ? null : this.props.maxVideoHeight - HEADER_HEIGHT; contentView =
{onHoldBackground} - - + {onHoldContent} {callControls}
; } 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 {
; } - 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 =
; } let expandButton; - if (!this.props.room) { + if (this.props.pipMode) { expandButton =
; @@ -530,7 +504,7 @@ export default class CallView extends React.Component {
; let header: React.ReactNode; - if (this.props.room) { + if (!this.props.pipMode) { header =
{callTypeText} @@ -538,6 +512,27 @@ export default class CallView extends React.Component {
; myClassName = 'mx_CallView_large'; } else { + let secondaryCallInfo; + if (this.props.secondaryCall) { + const secCallRoom = client.getRoom(this.props.secondaryCall.roomId); + secondaryCallInfo =
+
+ + + +
+
+
{secCallRoom.name}
+ + {_t("Resume")} + +
+
; + } else { + // keeps it present but empty because it has the margin-left: auto to make the alignment correct + secondaryCallInfo =
; + } + header =
@@ -546,6 +541,7 @@ export default class CallView extends React.Component {
{callRoom.name}
{callTypeText}
+ {secondaryCallInfo} {headerControls}
; myClassName = 'mx_CallView_pip'; diff --git a/src/components/views/voip/CallViewForRoom.tsx b/src/components/views/voip/CallViewForRoom.tsx new file mode 100644 index 0000000000..4cb4e66fbe --- /dev/null +++ b/src/components/views/voip/CallViewForRoom.tsx @@ -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 { + 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 ; + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 32d6caadde..251242a9c2 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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!",