Merge pull request #5523 from matrix-org/dbkr/dial_pad

Add a dialpad UI for PSTN lookup
pull/21833/head
David Baker 2021-01-05 17:23:34 +00:00 committed by GitHub
commit 2d37151750
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 461 additions and 6 deletions

View File

@ -236,4 +236,6 @@
@import "./views/verification/_VerificationShowSas.scss";
@import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallView.scss";
@import "./views/voip/_DialPad.scss";
@import "./views/voip/_DialPadModal.scss";
@import "./views/voip/_VideoFeed.scss";

View File

@ -24,6 +24,9 @@ limitations under the License.
.mx_RoomList_iconExplore::before {
mask-image: url('$(res)/img/element-icons/roomlist/explore.svg');
}
.mx_RoomList_iconDialpad::before {
mask-image: url('$(res)/img/element-icons/roomlist/dialpad.svg');
}
.mx_RoomList_explorePrompt {
margin: 4px 12px 4px;

View File

@ -0,0 +1,62 @@
/*
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_DialPad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.mx_DialPad_button {
width: 40px;
height: 40px;
background-color: $theme-button-bg-color;
border-radius: 40px;
font-size: 18px;
font-weight: 600;
text-align: center;
vertical-align: middle;
line-height: 40px;
}
.mx_DialPad_deleteButton, .mx_DialPad_dialButton {
&::before {
content: '';
display: inline-block;
height: 40px;
width: 40px;
vertical-align: middle;
mask-repeat: no-repeat;
mask-size: 20px;
mask-position: center;
background-color: $primary-bg-color;
}
}
.mx_DialPad_deleteButton {
background-color: $notice-primary-color;
&::before {
mask-image: url('$(res)/img/element-icons/call/delete.svg');
mask-position: 9px; // delete icon is right-heavy so have to be slightly to the left to look centered
}
}
.mx_DialPad_dialButton {
background-color: $accent-color;
&::before {
mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
}
}

View File

@ -0,0 +1,74 @@
/*
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_Dialog_dialPadWrapper .mx_Dialog {
padding: 0px;
}
.mx_DialPadModal {
width: 192px;
height: 368px;
}
.mx_DialPadModal_header {
margin-top: 12px;
margin-left: 12px;
margin-right: 12px;
}
.mx_DialPadModal_title {
color: $muted-fg-color;
font-size: 12px;
font-weight: 600;
}
.mx_DialPadModal_cancel {
float: right;
mask: url('$(res)/img/feather-customised/cancel.svg');
mask-repeat: no-repeat;
mask-position: center;
mask-size: cover;
width: 14px;
height: 14px;
background-color: $dialog-close-fg-color;
cursor: pointer;
}
.mx_DialPadModal_field {
border: none;
margin: 0px;
}
.mx_DialPadModal_field input {
font-size: 18px;
font-weight: 600;
}
.mx_DialPadModal_dialPad {
margin-left: 16px;
margin-right: 16px;
margin-top: 16px;
}
.mx_DialPadModal_horizSep {
position: relative;
&::before {
content: '';
position: absolute;
width: 100%;
border-bottom: 1px solid $input-darker-bg-color;
}
}

View File

@ -0,0 +1,10 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path d="M18.3333 2.49951H5.83333C5.25833 2.49951 4.80833 2.79118 4.50833 3.23285L0 9.99951L4.50833 16.7578C4.80833 17.1995 5.25833 17.4995 5.83333 17.4995H18.3333C19.25 17.4995 20 16.7495 20 15.8328V4.16618C20 3.24951 19.25 2.49951 18.3333 2.49951ZM15.8333 12.9912L14.6583 14.1662L11.6667 11.1745L8.675 14.1662L7.5 12.9912L10.4917 9.99951L7.5 7.00784L8.675 5.83284L11.6667 8.82451L14.6583 5.83284L15.8333 7.00784L12.8417 9.99951L15.8333 12.9912Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 692 B

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.99999 15.8335C9.08333 15.8335 8.33333 16.5835 8.33333 17.5002C8.33333 18.4168 9.08333 19.1668 9.99999 19.1668C10.9167 19.1668 11.6667 18.4168 11.6667 17.5002C11.6667 16.5835 10.9167 15.8335 9.99999 15.8335ZM4.99999 0.833496C4.08333 0.833496 3.33333 1.5835 3.33333 2.50016C3.33333 3.41683 4.08333 4.16683 4.99999 4.16683C5.91666 4.16683 6.66666 3.41683 6.66666 2.50016C6.66666 1.5835 5.91666 0.833496 4.99999 0.833496ZM4.99999 5.8335C4.08333 5.8335 3.33333 6.5835 3.33333 7.50016C3.33333 8.41683 4.08333 9.16683 4.99999 9.16683C5.91666 9.16683 6.66666 8.41683 6.66666 7.50016C6.66666 6.5835 5.91666 5.8335 4.99999 5.8335ZM4.99999 10.8335C4.08333 10.8335 3.33333 11.5835 3.33333 12.5002C3.33333 13.4168 4.08333 14.1668 4.99999 14.1668C5.91666 14.1668 6.66666 13.4168 6.66666 12.5002C6.66666 11.5835 5.91666 10.8335 4.99999 10.8335ZM15 4.16683C15.9167 4.16683 16.6667 3.41683 16.6667 2.50016C16.6667 1.5835 15.9167 0.833496 15 0.833496C14.0833 0.833496 13.3333 1.5835 13.3333 2.50016C13.3333 3.41683 14.0833 4.16683 15 4.16683ZM9.99999 10.8335C9.08333 10.8335 8.33333 11.5835 8.33333 12.5002C8.33333 13.4168 9.08333 14.1668 9.99999 14.1668C10.9167 14.1668 11.6667 13.4168 11.6667 12.5002C11.6667 11.5835 10.9167 10.8335 9.99999 10.8335ZM15 10.8335C14.0833 10.8335 13.3333 11.5835 13.3333 12.5002C13.3333 13.4168 14.0833 14.1668 15 14.1668C15.9167 14.1668 16.6667 13.4168 16.6667 12.5002C16.6667 11.5835 15.9167 10.8335 15 10.8335ZM15 5.8335C14.0833 5.8335 13.3333 6.5835 13.3333 7.50016C13.3333 8.41683 14.0833 9.16683 15 9.16683C15.9167 9.16683 16.6667 8.41683 16.6667 7.50016C16.6667 6.5835 15.9167 5.8335 15 5.8335ZM9.99999 5.8335C9.08333 5.8335 8.33333 6.5835 8.33333 7.50016C8.33333 8.41683 9.08333 9.16683 9.99999 9.16683C10.9167 9.16683 11.6667 8.41683 11.6667 7.50016C11.6667 6.5835 10.9167 5.8335 9.99999 5.8335ZM9.99999 0.833496C9.08333 0.833496 8.33333 1.5835 8.33333 2.50016C8.33333 3.41683 9.08333 4.16683 9.99999 4.16683C10.9167 4.16683 11.6667 3.41683 11.6667 2.50016C11.6667 1.5835 10.9167 0.833496 9.99999 0.833496Z" fill="#8D99A5"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -82,6 +82,9 @@ 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';
import { Action } from './dispatcher/actions';
const CHECK_PSTN_SUPPORT_ATTEMPTS = 3;
enum AudioID {
Ring = 'ringAudio',
@ -119,6 +122,8 @@ export default class CallHandler {
private calls = new Map<string, MatrixCall>(); // roomId -> call
private audioPromises = new Map<AudioID, Promise<void>>();
private dispatcherRef: string = null;
private supportsPstnProtocol = null;
private pstnSupportCheckTimer: NodeJS.Timeout; // number actually because we're in the browser
static sharedInstance() {
if (!window.mxCallHandler) {
@ -145,6 +150,8 @@ export default class CallHandler {
if (SettingsStore.getValue(UIFeature.Voip)) {
MatrixClientPeg.get().on('Call.incoming', this.onCallIncoming);
}
this.checkForPstnSupport(CHECK_PSTN_SUPPORT_ATTEMPTS);
}
stop() {
@ -158,6 +165,33 @@ export default class CallHandler {
}
}
private async checkForPstnSupport(maxTries) {
try {
const protocols = await MatrixClientPeg.get().getThirdpartyProtocols();
if (protocols['im.vector.protocol.pstn'] !== undefined) {
this.supportsPstnProtocol = protocols['im.vector.protocol.pstn'];
} else if (protocols['m.protocol.pstn'] !== undefined) {
this.supportsPstnProtocol = protocols['m.protocol.pstn'];
} else {
this.supportsPstnProtocol = null;
}
dis.dispatch({action: Action.PstnSupportUpdated});
} catch (e) {
if (maxTries === 1) {
console.log("Failed to check for pstn protocol support and no retries remain: assuming no support", e);
} else {
console.log("Failed to check for pstn protocol support: will retry", e);
this.pstnSupportCheckTimer = setTimeout(() => {
this.checkForPstnSupport(maxTries - 1);
}, 10000);
}
}
}
getSupportsPstnProtocol() {
return this.supportsPstnProtocol;
}
private onCallIncoming = (call) => {
// we dispatch this synchronously to make sure that the event
// handlers on the call are set up immediately (so that if

View File

@ -80,6 +80,7 @@ import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityProt
import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore";
import {UIFeature} from "../../settings/UIFeature";
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
import DialPadModal from "../views/voip/DialPadModal";
/** constants for MatrixChat.state.view */
export enum Views {
@ -703,6 +704,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.state.resizeNotifier.notifyLeftHandleResized();
});
break;
case Action.OpenDialPad:
Modal.createTrackedDialog('Dial pad', '', DialPadModal, {}, "mx_Dialog_dialPadWrapper");
break;
case 'on_logged_in':
if (
!Lifecycle.isSoftLogout() &&

View File

@ -46,6 +46,7 @@ import { objectShallowClone, objectWithOnly } from "../../../utils/objects";
import { IconizedContextMenuOption, IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
import AccessibleButton from "../elements/AccessibleButton";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import CallHandler from "../../../CallHandler";
interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void;
@ -89,10 +90,44 @@ interface ITagAesthetics {
defaultHidden: boolean;
}
const TAG_AESTHETICS: {
interface ITagAestheticsMap {
// @ts-ignore - TS wants this to be a string but we know better
[tagId: TagID]: ITagAesthetics;
} = {
}
// If we have no dialer support, we just show the create chat dialog
const dmOnAddRoom = (dispatcher?: Dispatcher<ActionPayload>) => {
(dispatcher || defaultDispatcher).dispatch({action: 'view_create_chat'});
};
// If we have dialer support, show a context menu so the user can pick between
// the dialer and the create chat dialog
const dmAddRoomContextMenu = (onFinished: () => void) => {
return <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("Start a Conversation")}
iconClassName="mx_RoomList_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFinished();
defaultDispatcher.dispatch({action: "view_create_chat"});
}}
/>
<IconizedContextMenuOption
label={_t("Open dial pad")}
iconClassName="mx_RoomList_iconDialpad"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFinished();
defaultDispatcher.fire(Action.OpenDialPad);
}}
/>
</IconizedContextMenuOptionList>;
};
const TAG_AESTHETICS: ITagAestheticsMap = {
[DefaultTagID.Invite]: {
sectionLabel: _td("Invites"),
isInvite: true,
@ -108,9 +143,8 @@ const TAG_AESTHETICS: {
isInvite: false,
defaultHidden: false,
addRoomLabel: _td("Start chat"),
onAddRoom: (dispatcher?: Dispatcher<ActionPayload>) => {
(dispatcher || defaultDispatcher).dispatch({action: 'view_create_chat'});
},
// Either onAddRoom or addRoomContextMenu are set depending on whether we
// have dialer support.
},
[DefaultTagID.Untagged]: {
sectionLabel: _td("Rooms"),
@ -178,6 +212,7 @@ function customTagAesthetics(tagId: TagID): ITagAesthetics {
export default class RoomList extends React.PureComponent<IProps, IState> {
private dispatcherRef;
private customTagStoreRef;
private tagAesthetics: ITagAestheticsMap;
constructor(props: IProps) {
super(props);
@ -187,6 +222,10 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
isNameFiltering: !!RoomListStore.instance.getFirstNameFilterCondition(),
};
// shallow-copy from the template as we need to make modifications to it
this.tagAesthetics = objectShallowClone(TAG_AESTHETICS);
this.updateDmAddRoomAction();
this.dispatcherRef = defaultDispatcher.register(this.onAction);
}
@ -202,6 +241,17 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
if (this.customTagStoreRef) this.customTagStoreRef.remove();
}
private updateDmAddRoomAction() {
const dmTagAesthetics = objectShallowClone(TAG_AESTHETICS[DefaultTagID.DM]);
if (CallHandler.sharedInstance().getSupportsPstnProtocol()) {
dmTagAesthetics.addRoomContextMenu = dmAddRoomContextMenu;
} else {
dmTagAesthetics.onAddRoom = dmOnAddRoom;
}
this.tagAesthetics[DefaultTagID.DM] = dmTagAesthetics;
}
private onAction = (payload: ActionPayload) => {
if (payload.action === Action.ViewRoomDelta) {
const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload;
@ -214,6 +264,9 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
show_room_tile: true, // to make sure the room gets scrolled into view
});
}
} else if (payload.action === Action.PstnSupportUpdated) {
this.updateDmAddRoomAction();
this.updateLists();
}
};
@ -355,7 +408,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
const aesthetics: ITagAesthetics = isCustomTag(orderedTagId)
? customTagAesthetics(orderedTagId)
: TAG_AESTHETICS[orderedTagId];
: this.tagAesthetics[orderedTagId];
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
components.push(<RoomSublist

View File

@ -0,0 +1,82 @@
/*
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 * as React from "react";
import AccessibleButton from "../elements/AccessibleButton";
const BUTTONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
enum DialPadButtonKind {
Digit,
Delete,
Dial,
}
interface IButtonProps {
kind: DialPadButtonKind;
digit?: string;
onButtonPress: (string) => void;
}
class DialPadButton extends React.PureComponent<IButtonProps> {
onClick = () => {
this.props.onButtonPress(this.props.digit);
}
render() {
switch (this.props.kind) {
case DialPadButtonKind.Digit:
return <AccessibleButton className="mx_DialPad_button" onClick={this.onClick}>
{this.props.digit}
</AccessibleButton>;
case DialPadButtonKind.Delete:
return <AccessibleButton className="mx_DialPad_button mx_DialPad_deleteButton"
onClick={this.onClick}
/>;
case DialPadButtonKind.Dial:
return <AccessibleButton className="mx_DialPad_button mx_DialPad_dialButton" onClick={this.onClick} />;
}
}
}
interface IProps {
onDigitPress: (string) => void;
onDeletePress: (string) => void;
onDialPress: (string) => void;
}
export default class Dialpad extends React.PureComponent<IProps> {
render() {
const buttonNodes = [];
for (const button of BUTTONS) {
buttonNodes.push(<DialPadButton key={button} kind={DialPadButtonKind.Digit}
digit={button} onButtonPress={this.props.onDigitPress}
/>);
}
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}
</div>;
}
}

View File

@ -0,0 +1,111 @@
/*
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 * as React from "react";
import { ensureDMExists } from "../../../createRoom";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import AccessibleButton from "../elements/AccessibleButton";
import Field from "../elements/Field";
import DialPad from './DialPad';
import dis from '../../../dispatcher/dispatcher';
import Modal from "../../../Modal";
import ErrorDialog from "../../views/dialogs/ErrorDialog";
interface IProps {
onFinished: (boolean) => void;
}
interface IState {
value: string;
}
export default class DialpadModal extends React.PureComponent<IProps, IState> {
constructor(props) {
super(props);
this.state = {
value: '',
}
}
onCancelClick = () => {
this.props.onFinished(false);
}
onChange = (ev) => {
this.setState({value: ev.target.value});
}
onFormSubmit = (ev) => {
ev.preventDefault();
this.onDialPress();
}
onDigitPress = (digit) => {
this.setState({value: this.state.value + digit});
}
onDeletePress = () => {
if (this.state.value.length === 0) return;
this.setState({value: this.state.value.slice(0, -1)});
}
onDialPress = async () => {
const results = await MatrixClientPeg.get().getThirdpartyUser('im.vector.protocol.pstn', {
'm.id.phone': this.state.value,
});
if (!results || results.length === 0 || !results[0].userid) {
Modal.createTrackedDialog('', '', ErrorDialog, {
title: _t("Unable to look up phone number"),
description: _t("There was an error looking up the phone number"),
});
}
const userId = results[0].userid;
const roomId = await ensureDMExists(MatrixClientPeg.get(), userId);
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
this.props.onFinished(true);
}
render() {
return <div className="mx_DialPadModal">
<div className="mx_DialPadModal_header">
<div>
<span className="mx_DialPadModal_title">{_t("Dial pad")}</span>
<AccessibleButton className="mx_DialPadModal_cancel" onClick={this.onCancelClick} />
</div>
<form onSubmit={this.onFormSubmit}>
<Field className="mx_DialPadModal_field" id="dialpad_number"
value={this.state.value} autoFocus={true}
onChange={this.onChange}
/>
</form>
</div>
<div className="mx_DialPadModal_horizSep" />
<div className="mx_DialPadModal_dialPad">
<DialPad onDigitPress={this.onDigitPress}
onDeletePress={this.onDeletePress}
onDialPress={this.onDialPress}
/>
</div>
</div>;
}
}

View File

@ -94,4 +94,16 @@ export enum Action {
* Trigged after the phase of the right panel is set. Should be used with AfterRightPanelPhaseChangePayload.
*/
AfterRightPanelPhaseChange = "after_right_panel_phase_change",
/**
* Opens the modal dial pad
*/
OpenDialPad = "open_dial_pad",
/**
* Fired when CallHandler has checked for PSTN protocol support
* payload: none
* XXX: Is an action the right thing for this?
*/
PstnSupportUpdated = "pstn_support_updated",
}

View File

@ -861,6 +861,9 @@
"Fill Screen": "Fill Screen",
"Return to call": "Return to call",
"%(name)s on hold": "%(name)s on hold",
"Unable to look up phone number": "Unable to look up phone number",
"There was an error looking up the phone number": "There was an error looking up the phone number",
"Dial pad": "Dial pad",
"Unknown caller": "Unknown caller",
"Incoming voice call": "Incoming voice call",
"Incoming video call": "Incoming video call",
@ -1459,6 +1462,8 @@
"Hide Widgets": "Hide Widgets",
"Show Widgets": "Show Widgets",
"Search": "Search",
"Start a Conversation": "Start a Conversation",
"Open dial pad": "Open dial pad",
"Invites": "Invites",
"Favourites": "Favourites",
"People": "People",