Merge pull request #5532 from matrix-org/dbkr/dtmf

Add in-call dialpad for DTMF sending
pull/21833/head
David Baker 2021-01-13 13:07:27 +00:00 committed by GitHub
commit e2111d9161
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 242 additions and 21 deletions

View File

@ -237,5 +237,6 @@
@import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallView.scss";
@import "./views/voip/_DialPad.scss";
@import "./views/voip/_DialPadContextMenu.scss";
@import "./views/voip/_DialPadModal.scss";
@import "./views/voip/_VideoFeed.scss";

View File

@ -310,8 +310,14 @@ limitations under the License.
}
}
// Makes the alignment correct
.mx_CallView_callControls_nothing {
.mx_CallView_callControls_dialpad {
margin-right: auto;
&::before {
background-image: url('$(res)/img/voip/dialpad.svg');
}
}
.mx_CallView_callControls_button_dialpad_hidden {
margin-right: auto;
cursor: initial;
}

View File

@ -0,0 +1,47 @@
/*
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.
*/
.mx_DialPadContextMenu_header {
margin-top: 12px;
margin-left: 12px;
margin-right: 12px;
}
.mx_DialPadContextMenu_title {
color: $muted-fg-color;
font-size: 12px;
font-weight: 600;
}
.mx_DialPadContextMenu_dialled {
height: 1em;
font-size: 18px;
font-weight: 600;
}
.mx_DialPadContextMenu_dialPad {
margin: 16px;
}
.mx_DialPadContextMenu_horizSep {
position: relative;
&::before {
content: '';
position: absolute;
width: 100%;
border-bottom: 1px solid $input-darker-bg-color;
}
}

17
res/img/voip/dialpad.svg Normal file
View File

@ -0,0 +1,17 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d)">
<circle cx="24" cy="20" r="20" fill="white"/>
</g>
<path d="M24 25.8335C23.0833 25.8335 22.3333 26.5835 22.3333 27.5002C22.3333 28.4168 23.0833 29.1668 24 29.1668C24.9167 29.1668 25.6667 28.4168 25.6667 27.5002C25.6667 26.5835 24.9167 25.8335 24 25.8335ZM19 10.8335C18.0833 10.8335 17.3333 11.5835 17.3333 12.5002C17.3333 13.4168 18.0833 14.1668 19 14.1668C19.9167 14.1668 20.6667 13.4168 20.6667 12.5002C20.6667 11.5835 19.9167 10.8335 19 10.8335ZM19 15.8335C18.0833 15.8335 17.3333 16.5835 17.3333 17.5002C17.3333 18.4168 18.0833 19.1668 19 19.1668C19.9167 19.1668 20.6667 18.4168 20.6667 17.5002C20.6667 16.5835 19.9167 15.8335 19 15.8335ZM19 20.8335C18.0833 20.8335 17.3333 21.5835 17.3333 22.5002C17.3333 23.4168 18.0833 24.1668 19 24.1668C19.9167 24.1668 20.6667 23.4168 20.6667 22.5002C20.6667 21.5835 19.9167 20.8335 19 20.8335ZM29 14.1668C29.9167 14.1668 30.6667 13.4168 30.6667 12.5002C30.6667 11.5835 29.9167 10.8335 29 10.8335C28.0833 10.8335 27.3333 11.5835 27.3333 12.5002C27.3333 13.4168 28.0833 14.1668 29 14.1668ZM24 20.8335C23.0833 20.8335 22.3333 21.5835 22.3333 22.5002C22.3333 23.4168 23.0833 24.1668 24 24.1668C24.9167 24.1668 25.6667 23.4168 25.6667 22.5002C25.6667 21.5835 24.9167 20.8335 24 20.8335ZM29 20.8335C28.0833 20.8335 27.3333 21.5835 27.3333 22.5002C27.3333 23.4168 28.0833 24.1668 29 24.1668C29.9167 24.1668 30.6667 23.4168 30.6667 22.5002C30.6667 21.5835 29.9167 20.8335 29 20.8335ZM29 15.8335C28.0833 15.8335 27.3333 16.5835 27.3333 17.5002C27.3333 18.4168 28.0833 19.1668 29 19.1668C29.9167 19.1668 30.6667 18.4168 30.6667 17.5002C30.6667 16.5835 29.9167 15.8335 29 15.8335ZM24 15.8335C23.0833 15.8335 22.3333 16.5835 22.3333 17.5002C22.3333 18.4168 23.0833 19.1668 24 19.1668C24.9167 19.1668 25.6667 18.4168 25.6667 17.5002C25.6667 16.5835 24.9167 15.8335 24 15.8335ZM24 10.8335C23.0833 10.8335 22.3333 11.5835 22.3333 12.5002C22.3333 13.4168 23.0833 14.1668 24 14.1668C24.9167 14.1668 25.6667 13.4168 25.6667 12.5002C25.6667 11.5835 24.9167 10.8335 24 10.8335Z" fill="#737D8C"/>
<defs>
<filter id="filter0_d" x="0" y="0" width="48" height="48" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -397,7 +397,8 @@ export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => {
return {left, top, chevronOffset};
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
@ -416,6 +417,41 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None
return menuOptions;
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
// and always above elementRect
export const alwaysAboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset;
const buttonBottom = elementRect.bottom + window.pageYOffset;
const buttonTop = elementRect.top + window.pageYOffset;
// Align the right edge of the menu to the right edge of the button
menuOptions.right = window.innerWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more space available.
if (buttonBottom < window.innerHeight / 2) {
menuOptions.top = buttonBottom + vPadding;
} else {
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
}
return menuOptions;
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the right of elementRect
// and always above elementRect
export const alwaysAboveRightOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonLeft = elementRect.left + window.pageXOffset;
const buttonTop = elementRect.top + window.pageYOffset;
// Align the left edge of the menu to the left edge of the button
menuOptions.left = buttonLeft;
// Align the menu vertically above the menu
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
return menuOptions;
};
type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val: boolean) => void];
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
const button = useRef<T>(null);

View File

@ -0,0 +1,59 @@
/*
Copyright 2021 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 React from 'react';
import { _t } from '../../../languageHandler';
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import Dialpad from '../voip/DialPad';
interface IProps extends IContextMenuProps {
call: MatrixCall;
}
interface IState {
value: string;
}
export default class DialpadContextMenu extends React.Component<IProps, IState> {
constructor(props) {
super(props);
this.state = {
value: '',
}
}
onDigitPress = (digit) => {
this.props.call.sendDtmfDigit(digit);
this.setState({value: this.state.value + digit});
}
render() {
return <ContextMenu {...this.props}>
<div className="mx_DialPadContextMenu_header">
<div>
<span className="mx_DialPadContextMenu_title">{_t("Dial pad")}</span>
</div>
<div className="mx_DialPadContextMenu_dialled">{this.state.value}</div>
</div>
<div className="mx_DialPadContextMenu_horizSep" />
<div className="mx_DialPadContextMenu_dialPad">
<Dialpad onDigitPress={this.onDigitPress} hasDialAndDelete={false} />
</div>
</ContextMenu>;
}
}

View File

@ -27,9 +27,10 @@ import { CallEvent } from 'matrix-js-sdk/src/webrtc/call';
import classNames from 'classnames';
import AccessibleButton from '../elements/AccessibleButton';
import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../../Keyboard';
import {aboveLeftOf, ChevronFace, ContextMenuButton} from '../../structures/ContextMenu';
import {alwaysAboveLeftOf, alwaysAboveRightOf, ChevronFace, ContextMenuButton} from '../../structures/ContextMenu';
import CallContextMenu from '../context_menus/CallContextMenu';
import { avatarUrlForMember } from '../../../Avatar';
import DialpadContextMenu from '../context_menus/DialpadContextMenu';
interface IProps {
// The call for us to display
@ -60,6 +61,7 @@ interface IState {
callState: CallState,
controlsVisible: boolean,
showMoreMenu: boolean,
showDialpad: boolean,
}
function getFullScreenElement() {
@ -102,6 +104,7 @@ export default class CallView extends React.Component<IProps, IState> {
private dispatcherRef: string;
private contentRef = createRef<HTMLDivElement>();
private controlsHideTimer: number = null;
private dialpadButton = createRef<HTMLDivElement>();
private contextMenuButton = createRef<HTMLDivElement>();
constructor(props: IProps) {
@ -115,6 +118,7 @@ export default class CallView extends React.Component<IProps, IState> {
callState: this.props.call.state,
controlsVisible: true,
showMoreMenu: false,
showDialpad: false,
}
this.updateCallListeners(null, this.props.call);
@ -226,7 +230,7 @@ export default class CallView extends React.Component<IProps, IState> {
}
private showControls() {
if (this.state.showMoreMenu) return;
if (this.state.showMoreMenu || this.state.showDialpad) return;
if (!this.state.controlsVisible) {
this.setState({
@ -239,6 +243,29 @@ export default class CallView extends React.Component<IProps, IState> {
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
private onDialpadClick = () => {
if (!this.state.showDialpad) {
if (this.controlsHideTimer) {
clearTimeout(this.controlsHideTimer);
this.controlsHideTimer = null;
}
this.setState({
showDialpad: true,
controlsVisible: true,
});
} else {
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
this.setState({
showDialpad: false,
});
}
}
private onMicMuteClick = () => {
const newVal = !this.state.micMuted;
@ -265,6 +292,13 @@ export default class CallView extends React.Component<IProps, IState> {
});
}
private closeDialpad = () => {
this.setState({
showDialpad: false,
});
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
private closeContextMenu = () => {
this.setState({
showMoreMenu: false,
@ -323,20 +357,29 @@ export default class CallView extends React.Component<IProps, IState> {
CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId);
}
private onSecondaryCallResumeClick = () => {
CallHandler.sharedInstance().setActiveCallRoomId(this.props.secondaryCall.roomId);
}
public render() {
const client = MatrixClientPeg.get();
const callRoom = client.getRoom(this.props.call.roomId);
const secCallRoom = this.props.secondaryCall ? client.getRoom(this.props.secondaryCall.roomId) : null;
let dialPad;
let contextMenu;
if (this.state.showDialpad) {
dialPad = <DialpadContextMenu
{...alwaysAboveRightOf(
this.dialpadButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
onFinished={this.closeDialpad}
call={this.props.call}
/>;
}
if (this.state.showMoreMenu) {
contextMenu = <CallContextMenu
{...aboveLeftOf(
{...alwaysAboveLeftOf(
this.contextMenuButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
@ -384,8 +427,15 @@ export default class CallView extends React.Component<IProps, IState> {
onClick={this.onVidMuteClick}
/> : null;
// The 'more' button actions are only relevant in a connected call
// The dial pad & '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 dialpadButton = this.state.callState === CallState.Connected ? <ContextMenuButton
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
inputRef={this.dialpadButton}
onClick={this.onDialpadClick}
isExpanded={this.state.showDialpad}
/> : <div className="mx_CallView_callControls_button mx_CallView_callControls_button_dialpad_hidden" />;
const contextMenuButton = this.state.callState === CallState.Connected ? <ContextMenuButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
onClick={this.onMoreClick}
@ -396,7 +446,7 @@ export default class CallView extends React.Component<IProps, IState> {
// 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" />
{dialpadButton}
<AccessibleButton
className={micClasses}
onClick={this.onMicMuteClick}
@ -558,6 +608,7 @@ export default class CallView extends React.Component<IProps, IState> {
return <div className={"mx_CallView " + myClassName}>
{header}
{contentView}
{dialPad}
{contextMenu}
</div>;
}

View File

@ -54,8 +54,9 @@ class DialPadButton extends React.PureComponent<IButtonProps> {
interface IProps {
onDigitPress: (string) => void;
onDeletePress: (string) => void;
onDialPress: (string) => void;
hasDialAndDelete: boolean;
onDeletePress?: (string) => void;
onDialPress?: (string) => void;
}
export default class Dialpad extends React.PureComponent<IProps> {
@ -68,12 +69,14 @@ export default class Dialpad extends React.PureComponent<IProps> {
/>);
}
buttonNodes.push(<DialPadButton key="del" kind={DialPadButtonKind.Delete}
onButtonPress={this.props.onDeletePress}
/>);
buttonNodes.push(<DialPadButton key="dial" kind={DialPadButtonKind.Dial}
onButtonPress={this.props.onDialPress}
/>);
if (this.props.hasDialAndDelete) {
buttonNodes.push(<DialPadButton key="del" kind={DialPadButtonKind.Delete}
onButtonPress={this.props.onDeletePress}
/>);
buttonNodes.push(<DialPadButton key="dial" kind={DialPadButtonKind.Dial}
onButtonPress={this.props.onDialPress}
/>);
}
return <div className="mx_DialPad">
{buttonNodes}

View File

@ -101,7 +101,8 @@ export default class DialpadModal extends React.PureComponent<IProps, IState> {
</div>
<div className="mx_DialPadModal_horizSep" />
<div className="mx_DialPadModal_dialPad">
<DialPad onDigitPress={this.onDigitPress}
<DialPad hasDialAndDelete={true}
onDigitPress={this.onDigitPress}
onDeletePress={this.onDeletePress}
onDialPress={this.onDialPress}
/>