Merge pull request #5468 from matrix-org/dbkr/line_1_2

Line 1 / 2 Support
This commit is contained in:
David Baker 2020-12-11 10:27:13 +00:00 committed by GitHub
commit ae2cfb494a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 551 additions and 259 deletions

View file

@ -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 {

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':
@ -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',

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

@ -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<IProps, IState> {
@ -109,19 +107,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() {
@ -130,11 +126,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': {
@ -148,85 +162,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(),
});
};
@ -240,7 +210,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,
});
};
@ -270,20 +240,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});
}
@ -342,107 +308,114 @@ 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);
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 = <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;
@ -457,11 +430,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 = {};
@ -475,7 +448,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} />;
@ -483,48 +456,71 @@ export default class CallView extends React.Component<IProps, IState> {
// 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 = <div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
contentView = <div className={containerClasses}
ref={this.contentRef} onMouseMove={this.onMouseMove}
// Put the max height on here too because this div is ended up 4px larger than the content
// and is causing it to scroll, and I am genuinely baffled as to why.
style={{maxHeight: maxVideoHeight}}
>
{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 ? 160 : 76;
const avatarSize = this.props.pipMode ? 76 : 160;
const classes = classNames({
mx_CallView_voice: true,
mx_CallView_voice_hold: isOnHold,
});
contentView = <div className={classes} onMouseMove={this.onMouseMove}>
<div className="mx_CallView_voice_avatarContainer" style={{width: avatarSize, height: avatarSize}}>
let secondaryCallAvatar: ReactNode;
if (this.props.secondaryCall) {
const secAvatarSize = this.props.pipMode ? 40 : 100;
secondaryCallAvatar = <div className="mx_CallView_voice_secondaryAvatarContainer"
style={{width: secAvatarSize, height: secAvatarSize}}
>
<RoomAvatar
room={callRoom}
height={avatarSize}
width={avatarSize}
room={secCallRoom}
height={secAvatarSize}
width={secAvatarSize}
/>
</div>;
}
contentView = <div className={classes} onMouseMove={this.onMouseMove}>
<div className="mx_CallView_voice_avatarsContainer">
<div className="mx_CallView_voice_avatarContainer" style={{width: avatarSize, height: avatarSize}}>
<RoomAvatar
room={callRoom}
height={avatarSize}
width={avatarSize}
/>
</div>
{secondaryCallAvatar}
</div>
<div className="mx_CallView_voice_holdText">{onHoldText}</div>
{callControls}
</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")}
/>;
@ -536,7 +532,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>
@ -544,13 +540,28 @@ export default class CallView extends React.Component<IProps, IState> {
</div>;
myClassName = 'mx_CallView_large';
} else {
let secondaryCallInfo;
if (this.props.secondaryCall) {
secondaryCallInfo = <span className="mx_CallView_header_secondaryCallInfo">
<AccessibleButton element='span' onClick={this.onSecondaryRoomAvatarClick}>
<RoomAvatar room={secCallRoom} height={16} width={16} />
<span className="mx_CallView_secondaryCall_roomName">
{_t("%(name)s paused", { name: secCallRoom.name })}
</span>
</AccessibleButton>
</span>;
}
header = <div className="mx_CallView_header">
<AccessibleButton onClick={this.onRoomAvatarClick}>
<RoomAvatar room={callRoom} height={32} width={32} />
</AccessibleButton>
<div>
<div className="mx_CallView_header_callInfo">
<div className="mx_CallView_header_roomName">{callRoom.name}</div>
<div className="mx_CallView_header_callTypeSmall">{callTypeText}</div>
<div className="mx_CallView_header_callTypeSmall">
{callTypeText}
{secondaryCallInfo}
</div>
</div>
{headerControls}
</div>;

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

@ -42,10 +42,12 @@ export default class VideoFeed extends React.Component<IProps> {
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<IProps> {
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);

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!",
@ -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",