Line 1 / 2 Support

Support one active call plus one call on hold
This commit is contained in:
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

@ -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';