diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 9f394d87d7..dbe2c27e41 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -20,14 +20,13 @@ limitations under the License. background-color: $voipcall-plinth-color; padding-left: 8px; padding-right: 8px; - margin: 5px 5px 5px 18px; // XXX: CallContainer sets pointer-events: none - should probably be set back in a better place pointer-events: initial; } .mx_CallView_large { - // XXX: This should be 10 but somehow it's gaining an extra 4px from somewhere... - padding-bottom: 6px; + padding-bottom: 10px; + margin: 5px 5px 5px 18px; .mx_CallView_voice { height: 360px; @@ -40,6 +39,22 @@ 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_holdText { + padding-top: 10px; + padding-bottom: 25px; + } } .mx_CallView_voice { @@ -52,6 +67,17 @@ limitations under the License. border-radius: 8px; } +.mx_CallView_voice_avatarsContainer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + div { + margin-left: 12px; + margin-right: 12px; + } +} + .mx_CallView_voice_hold { // This masks the avatar image so when it's blurred, the edge is still crisp .mx_CallView_voice_avatarContainer { @@ -82,9 +108,33 @@ limitations under the License. } } +.mx_CallView_voice_secondaryAvatarContainer { + border-radius: 2000px; + overflow: hidden; + position: relative; + &::after { + position: absolute; + content: ''; + width: 100%; + height: 100%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: rgba(0, 0, 0, 0.6); + background-image: url('$(res)/img/voip/paused.svg'); + background-position: center; + background-size: 40px; + background-repeat: no-repeat; + } + .mx_CallView_pip &::after { + background-size: 24px; + } +} + .mx_CallView_voice_holdText { height: 20px; padding-top: 20px; + padding-bottom: 15px; color: $accent-fg-color; .mx_AccessibleButton_hasKind { padding: 0px; @@ -167,10 +217,6 @@ limitations under the License. flex-direction: row; align-items: center; justify-content: left; - - .mx_BaseAvatar { - margin-right: 12px; - } } .mx_CallView_header_callType { @@ -179,6 +225,14 @@ limitations under the License. vertical-align: middle; } +.mx_CallView_header_secondaryCallInfo { + &::before { + content: 'ยท'; + margin-left: 6px; + margin-right: 6px; + } +} + .mx_CallView_header_controls { margin-left: auto; } @@ -213,16 +267,31 @@ limitations under the License. } } +.mx_CallView_header_callInfo { + margin-left: 12px; + margin-right: 16px; +} + .mx_CallView_header_roomName { font-weight: bold; font-size: 12px; line-height: initial; + height: 15px; +} + +.mx_CallView_secondaryCall_roomName { + margin-left: 4px; } .mx_CallView_header_callTypeSmall { font-size: 12px; color: $secondary-fg-color; line-height: initial; + height: 15px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 240px; } .mx_CallView_header_phoneIcon { diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 2c30c51041..41dc031b06 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': @@ -543,14 +566,26 @@ export default class CallHandler { } else { this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false); } - this.removeCallForRoom(payload.room_id); + // don't remove the call yet: let the hangup event handler do it (otherwise it will throw + // the hangup event away) break; case 'answer': { 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 +596,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 71c374f34f..65ba693b58 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -15,8 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef, CSSProperties } from 'react'; -import Room from 'matrix-js-sdk/src/models/room'; +import React, { createRef, CSSProperties, ReactNode } from 'react'; 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, @@ -94,10 +94,8 @@ const CONTROLS_HIDE_DELAY = 1000; // Height of the header duplicated from CSS because we need to subtract it from our max // height to get the max height of the video const HEADER_HEIGHT = 44; - -// Also duplicated from the CSS: the bottom padding on the call view -const CALL_PADDING_BOTTOM = 10; - +const BOTTOM_PADDING = 10; +const BOTTOM_MARGIN_TOP_BOTTOM = 10; // top margin plus bottom margin const CONTEXT_MENU_VPADDING = 8; // How far the context menu sits above the button (px) export default class CallView extends React.Component { @@ -109,19 +107,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() { @@ -130,11 +126,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': { @@ -148,85 +162,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(), }); }; @@ -240,7 +210,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, }); }; @@ -270,20 +240,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}); } @@ -342,107 +308,114 @@ 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); + const secCallRoom = this.props.secondaryCall ? client.getRoom(this.props.secondaryCall.roomId) : null; 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; @@ -457,11 +430,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 = {}; @@ -475,7 +448,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 =
; @@ -483,48 +456,71 @@ export default class CallView extends React.Component { // if we're fullscreen, we don't want to set a maxHeight on the video element. const maxVideoHeight = getFullScreenElement() ? null : ( - this.props.maxVideoHeight - HEADER_HEIGHT - CALL_PADDING_BOTTOM + this.props.maxVideoHeight - (HEADER_HEIGHT + BOTTOM_PADDING + BOTTOM_MARGIN_TOP_BOTTOM) ); - contentView =
+ contentView =
{onHoldBackground} - - + {onHoldContent} {callControls}
; } else { - const avatarSize = this.props.room ? 160 : 76; + const avatarSize = this.props.pipMode ? 76 : 160; const classes = classNames({ mx_CallView_voice: true, mx_CallView_voice_hold: isOnHold, }); - contentView =
-
+ let secondaryCallAvatar: ReactNode; + + if (this.props.secondaryCall) { + const secAvatarSize = this.props.pipMode ? 40 : 100; + secondaryCallAvatar =
+
; + } + + contentView =
+
+
+ +
+ {secondaryCallAvatar}
{onHoldText}
{callControls}
; } - 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 =
; @@ -536,7 +532,7 @@ export default class CallView extends React.Component {
; let header: React.ReactNode; - if (this.props.room) { + if (!this.props.pipMode) { header =
{callTypeText} @@ -544,13 +540,28 @@ export default class CallView extends React.Component {
; myClassName = 'mx_CallView_large'; } else { + let secondaryCallInfo; + if (this.props.secondaryCall) { + secondaryCallInfo = + + + + {_t("%(name)s paused", { name: secCallRoom.name })} + + + ; + } + header =
-
+
{callRoom.name}
-
{callTypeText}
+
+ {callTypeText} + {secondaryCallInfo} +
{headerControls}
; 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/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index 5fb71a6d69..5210f28eb1 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -42,10 +42,12 @@ export default class VideoFeed extends React.Component { componentDidMount() { this.vid.current.addEventListener('resize', this.onResize); - if (this.props.type === VideoFeedType.Local) { - this.props.call.setLocalVideoElement(this.vid.current); - } else { - this.props.call.setRemoteVideoElement(this.vid.current); + this.setVideoElement(); + } + + componentDidUpdate(prevProps) { + if (this.props.call !== prevProps.call) { + this.setVideoElement(); } } @@ -53,6 +55,14 @@ export default class VideoFeed extends React.Component { this.vid.current.removeEventListener('resize', this.onResize); } + private setVideoElement() { + if (this.props.type === VideoFeedType.Local) { + this.props.call.setLocalVideoElement(this.vid.current); + } else { + this.props.call.setRemoteVideoElement(this.vid.current); + } + } + onResize = (e) => { if (this.props.onResize) { this.props.onResize(e); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 5340e82ba9..eb1d0a632e 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!", @@ -853,6 +853,7 @@ "Voice Call": "Voice Call", "Fill Screen": "Fill Screen", "Return to call": "Return to call", + "%(name)s paused": "%(name)s paused", "Unknown caller": "Unknown caller", "Incoming voice call": "Incoming voice call", "Incoming video call": "Incoming video call",