Add a dialpad UI for PSTN lookup

Queries the homeserver for PSTN protocol support, and if found,
the add-room button on the DM rooms list section opens a context
menu instead with a 'dial pad' option as well as the current 'start chat'
dialog. Entering a number into this and pressing dial performs
a thirdparty user query for the given string and starts a DM with that
user.
This commit is contained in:
David Baker 2020-12-23 19:02:01 +00:00
parent ee56560e33
commit 452fbb076b
13 changed files with 460 additions and 6 deletions

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 = Object.assign({}, 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 = Object.assign({}, 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>;
}
}